Просмотр исходного кода

feat(follow-up): 实现复诊管理功能模块

- 新增复诊记录的增删改查接口及业务逻辑
- 设计并实现复诊状态枚举及其类型处理器
- 添加复诊管理相关错误码定义
- 实现医生与患者权限控制及数据访问限制
- 支持分页查询及按患者查询复诊记录
- 完成VO对象与PO实体类的映射转换
- 集成MyBatis枚举类型处理器支持数据库交互
- 提供Swagger注解完善接口文档说明
mcbaiyun 1 месяц назад
Родитель
Сommit
3c627fd98f

+ 121 - 22
docs/New/复诊管理功能设计文档.md

@@ -60,11 +60,109 @@ CREATE TABLE `t_follow_up` (
 
 
 备注:对外响应的 `id` 建议以字符串形式返回以避免前端 JS 精度丢失(参见 `docs/OLD/DevRule/03-API设计规范.md` 的长整型 ID 传输策略)。
 备注:对外响应的 `id` 建议以字符串形式返回以避免前端 JS 精度丢失(参见 `docs/OLD/DevRule/03-API设计规范.md` 的长整型 ID 传输策略)。
 
 
-## 3. 实体类设计
+## 3. 枚举类型设计
 
 
-### 3.1 PO实体类 (FollowUp.java)
+### 3.1 复诊状态枚举 (FollowUpStatus.java)
 
 
-```java
+``java
+package work.baiyun.chronicdiseaseapp.enums;
+
+import com.baomidou.mybatisplus.annotation.EnumValue;
+
+/**
+ * 复诊状态枚举
+ */
+public enum FollowUpStatus {
+    PENDING("PENDING", "待确认"),
+    CONFIRMED("CONFIRMED", "已确认"),
+    CANCELLED("CANCELLED", "已取消"),
+    COMPLETED("COMPLETED", "已完成");
+
+    @EnumValue
+    private final String code;
+    private final String description;
+
+    FollowUpStatus(String code, String description) {
+        this.code = code;
+        this.description = description;
+    }
+
+    public String getCode() { 
+        return code; 
+    }
+
+    public String getDescription() { 
+        return description; 
+    }
+
+    @Override
+    public String toString() { 
+        return description; 
+    }
+
+    public static FollowUpStatus fromCode(String code) {
+        if (code == null) return null;
+        for (FollowUpStatus status : FollowUpStatus.values()) {
+            if (status.code.equals(code)) return status;
+        }
+        throw new work.baiyun.chronicdiseaseapp.exception.CustomException(
+            work.baiyun.chronicdiseaseapp.enums.ErrorCode.PARAMETER_ERROR.getCode(),
+            "Unknown FollowUpStatus code: " + code);
+    }
+}
+```
+
+### 3.2 枚举类型处理器 (FollowUpStatusTypeHandler.java)
+
+``java
+package work.baiyun.chronicdiseaseapp.handler;
+
+import org.apache.ibatis.type.BaseTypeHandler;
+import org.apache.ibatis.type.JdbcType;
+import org.apache.ibatis.type.MappedJdbcTypes;
+import org.apache.ibatis.type.MappedTypes;
+import work.baiyun.chronicdiseaseapp.enums.FollowUpStatus;
+
+import java.sql.CallableStatement;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+@MappedJdbcTypes(JdbcType.VARCHAR)
+@MappedTypes(FollowUpStatus.class)
+public class FollowUpStatusTypeHandler extends BaseTypeHandler<FollowUpStatus> {
+
+    @Override
+    public void setNonNullParameter(PreparedStatement ps, int i, FollowUpStatus parameter, JdbcType jdbcType)
+            throws SQLException {
+        ps.setString(i, parameter.getCode());
+    }
+
+    @Override
+    public FollowUpStatus getNullableResult(ResultSet rs, String columnName) throws SQLException {
+        String code = rs.getString(columnName);
+        return code == null ? null : FollowUpStatus.fromCode(code);
+    }
+
+    @Override
+    public FollowUpStatus getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
+        String code = rs.getString(columnIndex);
+        return code == null ? null : FollowUpStatus.fromCode(code);
+    }
+
+    @Override
+    public FollowUpStatus getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
+        String code = cs.getString(columnIndex);
+        return code == null ? null : FollowUpStatus.fromCode(code);
+    }
+}
+```
+
+## 4. 实体类设计
+
+### 4.1 PO实体类 (FollowUp.java)
+
+``java
 package work.baiyun.chronicdiseaseapp.model.po;
 package work.baiyun.chronicdiseaseapp.model.po;
 
 
 import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableField;
@@ -72,6 +170,7 @@ import com.baomidou.mybatisplus.annotation.TableName;
 import io.swagger.v3.oas.annotations.media.Schema;
 import io.swagger.v3.oas.annotations.media.Schema;
 import lombok.Data;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 import lombok.EqualsAndHashCode;
+import work.baiyun.chronicdiseaseapp.enums.FollowUpStatus;
 
 
 import java.time.LocalDateTime;
 import java.time.LocalDateTime;
 
 
@@ -97,8 +196,8 @@ public class FollowUp extends BaseEntity {
     private LocalDateTime actualTime;
     private LocalDateTime actualTime;
 
 
     @Schema(description = "复诊状态 (PENDING-待确认, CONFIRMED-已确认, CANCELLED-已取消, COMPLETED-已完成)")
     @Schema(description = "复诊状态 (PENDING-待确认, CONFIRMED-已确认, CANCELLED-已取消, COMPLETED-已完成)")
-    @TableField("status")
-    private String status;
+    @TableField(value = "status", typeHandler = work.baiyun.chronicdiseaseapp.handler.FollowUpStatusTypeHandler.class)
+    private FollowUpStatus status;
 
 
     @Schema(description = "复诊原因")
     @Schema(description = "复诊原因")
     @TableField("reason")
     @TableField("reason")
@@ -110,9 +209,9 @@ public class FollowUp extends BaseEntity {
 }
 }
 ```
 ```
 
 
-### 3.2 VO对象
+### 4.2 VO对象
 
 
-#### 3.2.1 请求对象
+#### 4.2.1 请求对象
 
 
 ``java
 ``java
 // CreateFollowUpRequest.java
 // CreateFollowUpRequest.java
@@ -166,7 +265,7 @@ public class UpdateFollowUpRequest {
 }
 }
 ```
 ```
 
 
-#### 3.2.2 响应对象
+#### 4.2.2 响应对象
 
 
 ``java
 ``java
 // FollowUpResponse.java
 // FollowUpResponse.java
@@ -241,7 +340,7 @@ public class FollowUpPageResponse {
 }
 }
 ```
 ```
 
 
-## 4. Mapper接口设计
+## 5. Mapper接口设计
 
 
 ``java
 ``java
 // FollowUpMapper.java
 // FollowUpMapper.java
@@ -256,9 +355,9 @@ public interface FollowUpMapper extends BaseMapper<FollowUp> {
 }
 }
 ```
 ```
 
 
-## 5. Service层设计
+## 6. Service层设计
 
 
-### 5.1 接口定义
+### 6.1 接口定义
 
 
 ``java
 ``java
 // FollowUpService.java
 // FollowUpService.java
@@ -298,7 +397,7 @@ public interface FollowUpService {
 }
 }
 ```
 ```
 
 
-### 5.2 实现类
+### 6.2 实现类
 
 
 ``java
 ``java
 // FollowUpServiceImpl.java
 // FollowUpServiceImpl.java
@@ -518,7 +617,7 @@ public class FollowUpServiceImpl implements FollowUpService {
 }
 }
 ```
 ```
 
 
-## 6. Controller层设计
+## 7. Controller层设计
 
 
 ``java
 ``java
 // FollowUpController.java
 // FollowUpController.java
@@ -670,7 +769,7 @@ public class FollowUpController {
 }
 }
 ```
 ```
 
 
-## 7. 请求/响应对象补充
+## 8. 请求/响应对象补充
 
 
 ``java
 ``java
 // DeleteFollowUpRequest.java
 // DeleteFollowUpRequest.java
@@ -687,7 +786,7 @@ public class DeleteFollowUpRequest {
 }
 }
 ```
 ```
 
 
-## 8. 错误码补充
+## 9. 错误码补充
 
 
 在 ErrorCode 枚举中可能需要添加以下错误码:
 在 ErrorCode 枚举中可能需要添加以下错误码:
 
 
@@ -698,9 +797,9 @@ FOLLOW_UP_ACCESS_DENIED(6001, "无权访问复诊记录"),
 FOLLOW_UP_STATUS_INVALID(6002, "复诊状态无效");
 FOLLOW_UP_STATUS_INVALID(6002, "复诊状态无效");
 ```
 ```
 
 
-## 9. 接口调用示例
+## 10. 接口调用示例
 
 
-### 9.1 患者创建复诊请求
+### 10.1 患者创建复诊请求
 ```
 ```
 POST /follow-up/create
 POST /follow-up/create
 {
 {
@@ -710,7 +809,7 @@ POST /follow-up/create
 }
 }
 ```
 ```
 
 
-### 9.2 医生确认复诊
+### 10.2 医生确认复诊
 ```
 ```
 POST /follow-up/update
 POST /follow-up/update
 {
 {
@@ -720,7 +819,7 @@ POST /follow-up/update
 }
 }
 ```
 ```
 
 
-### 9.3 查询复诊记录
+### 10.3 查询复诊记录
 ```
 ```
 POST /follow-up/list
 POST /follow-up/list
 {
 {
@@ -731,7 +830,7 @@ POST /follow-up/list
 }
 }
 ```
 ```
 
 
-## 10. 权限设计
+## 11. 权限设计
 
 
 1. **患者**:
 1. **患者**:
    - 可以创建复诊请求
    - 可以创建复诊请求
@@ -748,7 +847,7 @@ POST /follow-up/list
    - 具有医生的所有权限
    - 具有医生的所有权限
    - 可以查看所有复诊记录
    - 可以查看所有复诊记录
 
 
-## 11. 状态流转
+## 12. 状态流转
 
 
 ```
 ```
 PENDING(待确认) -> CONFIRMED(已确认) -> COMPLETED(已完成)
 PENDING(待确认) -> CONFIRMED(已确认) -> COMPLETED(已完成)
@@ -757,7 +856,7 @@ PENDING(待确认) -> CONFIRMED(已确认) -> COMPLETED(已完成)
 CANCELLED(已取消)
 CANCELLED(已取消)
 ```
 ```
 
 
-## 12. 安全考虑
+## 13. 安全考虑
 
 
 本项目安全实现应遵循 `docs/OLD/DevRule/08-安全规范.md` 中的已有约定,以下为与复诊管理功能相关的要点(需在实现时落地):
 本项目安全实现应遵循 `docs/OLD/DevRule/08-安全规范.md` 中的已有约定,以下为与复诊管理功能相关的要点(需在实现时落地):
 
 

+ 146 - 0
src/main/java/work/baiyun/chronicdiseaseapp/controller/FollowUpController.java

@@ -0,0 +1,146 @@
+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.tags.Tag;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+import work.baiyun.chronicdiseaseapp.common.R;
+import work.baiyun.chronicdiseaseapp.model.vo.BaseQueryRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.FollowUpResponse;
+import work.baiyun.chronicdiseaseapp.model.vo.CreateFollowUpRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.UpdateFollowUpRequest;
+import work.baiyun.chronicdiseaseapp.service.FollowUpService;
+import work.baiyun.chronicdiseaseapp.enums.ErrorCode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@RestController
+@RequestMapping("/follow-up")
+@Tag(name = "复诊管理", description = "复诊预约与管理相关接口")
+public class FollowUpController {
+
+    private static final Logger logger = LoggerFactory.getLogger(FollowUpController.class);
+
+    @Autowired
+    private FollowUpService followUpService;
+
+    @Operation(summary = "创建复诊请求", description = "患者创建复诊预约请求")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "复诊请求创建成功",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = Void.class))),
+        @ApiResponse(responseCode = "500", description = "服务器内部错误",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = Void.class)))
+    })
+    @PostMapping(path = "/create", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> create(@RequestBody CreateFollowUpRequest req) {
+        try {
+            followUpService.createFollowUp(req);
+            return R.success(200, "复诊请求创建成功");
+        } catch (Exception e) {
+            logger.error("create follow up request failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
+    @Operation(summary = "更新复诊记录", description = "更新复诊记录(医生确认、取消或患者修改)")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "复诊记录更新成功",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = Void.class))),
+        @ApiResponse(responseCode = "500", description = "服务器内部错误",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = Void.class)))
+    })
+    @PutMapping(path = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> update(@PathVariable Long id, @RequestBody UpdateFollowUpRequest req) {
+        try {
+            // 将路径ID赋值到请求对象,保证一致性
+            req.setId(id);
+            followUpService.updateFollowUp(req);
+            return R.success(200, "复诊记录更新成功");
+        } catch (Exception e) {
+            logger.error("update follow up request failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
+    @Operation(summary = "分页查询复诊记录", description = "根据时间范围和分页参数查询复诊记录")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "成功查询复诊记录列表",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = work.baiyun.chronicdiseaseapp.model.vo.FollowUpPageResponse.class))),
+        @ApiResponse(responseCode = "500", description = "服务器内部错误",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = Void.class)))
+    })
+    @PostMapping(path = "/list", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> list(@RequestBody BaseQueryRequest req) {
+        try {
+            Page<FollowUpResponse> page = followUpService.listFollowUps(req);
+            work.baiyun.chronicdiseaseapp.model.vo.FollowUpPageResponse vo = new work.baiyun.chronicdiseaseapp.model.vo.FollowUpPageResponse();
+            vo.setRecords(page.getRecords());
+            vo.setTotal(page.getTotal());
+            vo.setSize(page.getSize());
+            vo.setCurrent(page.getCurrent());
+            vo.setPages(page.getPages());
+            return R.success(200, "ok", vo);
+        } catch (Exception e) {
+            logger.error("list follow up records failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
+    @Operation(summary = "医生分页查询患者复诊记录", description = "医生查询患者复诊记录(需有绑定关系或权限)")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "成功查询复诊记录列表",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = work.baiyun.chronicdiseaseapp.model.vo.FollowUpPageResponse.class))),
+        @ApiResponse(responseCode = "403", description = "无权限访问",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = Void.class)))
+    })
+    @PostMapping(path = "/list-by-patient", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> listByPatient(@RequestParam("patientUserId") Long patientUserId, @RequestBody BaseQueryRequest req) {
+        try {
+            Page<FollowUpResponse> page = followUpService.listFollowUpsByPatient(patientUserId, req);
+            work.baiyun.chronicdiseaseapp.model.vo.FollowUpPageResponse vo = new work.baiyun.chronicdiseaseapp.model.vo.FollowUpPageResponse();
+            vo.setRecords(page.getRecords());
+            vo.setTotal(page.getTotal());
+            vo.setSize(page.getSize());
+            vo.setCurrent(page.getCurrent());
+            vo.setPages(page.getPages());
+            return R.success(200, "ok", vo);
+        } catch (Exception e) {
+            logger.error("list follow up records by patient failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
+    @Operation(summary = "删除复诊记录", description = "根据ID删除复诊记录")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "复诊记录删除成功",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = Void.class))),
+        @ApiResponse(responseCode = "500", description = "服务器内部错误",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = Void.class)))
+    })
+    @DeleteMapping(path = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> delete(@PathVariable Long id) {
+        try {
+            followUpService.deleteFollowUp(id);
+            return R.success(200, "复诊记录删除成功");
+        } catch (Exception e) {
+            logger.error("delete follow up record failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+}

+ 6 - 1
src/main/java/work/baiyun/chronicdiseaseapp/enums/ErrorCode.java

@@ -33,7 +33,12 @@ public enum ErrorCode {
     
     
     // 地理位置相关错误码 5000-5999
     // 地理位置相关错误码 5000-5999
     GEO_REQUEST_FAILED(5000, "地理位置请求失败"),
     GEO_REQUEST_FAILED(5000, "地理位置请求失败"),
-    GEO_SERVICE_UNAVAILABLE(5001, "地理位置服务不可用");
+    GEO_SERVICE_UNAVAILABLE(5001, "地理位置服务不可用"),
+    
+    // 复诊管理相关错误码 6000-6999
+    FOLLOW_UP_NOT_FOUND(6000, "复诊记录不存在"),
+    FOLLOW_UP_ACCESS_DENIED(6001, "无权访问复诊记录"),
+    FOLLOW_UP_STATUS_INVALID(6002, "复诊状态无效");
 
 
     private final int code;
     private final int code;
     private final String message;
     private final String message;

+ 45 - 0
src/main/java/work/baiyun/chronicdiseaseapp/enums/FollowUpStatus.java

@@ -0,0 +1,45 @@
+package work.baiyun.chronicdiseaseapp.enums;
+
+import com.baomidou.mybatisplus.annotation.EnumValue;
+
+/**
+ * 复诊状态枚举
+ */
+public enum FollowUpStatus {
+    PENDING("PENDING", "待确认"),
+    CONFIRMED("CONFIRMED", "已确认"),
+    CANCELLED("CANCELLED", "已取消"),
+    COMPLETED("COMPLETED", "已完成");
+
+    @EnumValue
+    private final String code;
+    private final String description;
+
+    FollowUpStatus(String code, String description) {
+        this.code = code;
+        this.description = description;
+    }
+
+    public String getCode() { 
+        return code; 
+    }
+
+    public String getDescription() { 
+        return description; 
+    }
+
+    @Override
+    public String toString() { 
+        return description; 
+    }
+
+    public static FollowUpStatus fromCode(String code) {
+        if (code == null) return null;
+        for (FollowUpStatus status : FollowUpStatus.values()) {
+            if (status.code.equals(code)) return status;
+        }
+        throw new work.baiyun.chronicdiseaseapp.exception.CustomException(
+            work.baiyun.chronicdiseaseapp.enums.ErrorCode.PARAMETER_ERROR.getCode(),
+            "Unknown FollowUpStatus code: " + code);
+    }
+}

+ 41 - 0
src/main/java/work/baiyun/chronicdiseaseapp/handler/FollowUpStatusTypeHandler.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.MappedJdbcTypes;
+import org.apache.ibatis.type.MappedTypes;
+import work.baiyun.chronicdiseaseapp.enums.FollowUpStatus;
+
+import java.sql.CallableStatement;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+@MappedJdbcTypes(JdbcType.VARCHAR)
+@MappedTypes(FollowUpStatus.class)
+public class FollowUpStatusTypeHandler extends BaseTypeHandler<FollowUpStatus> {
+
+    @Override
+    public void setNonNullParameter(PreparedStatement ps, int i, FollowUpStatus parameter, JdbcType jdbcType)
+            throws SQLException {
+        ps.setString(i, parameter.getCode());
+    }
+
+    @Override
+    public FollowUpStatus getNullableResult(ResultSet rs, String columnName) throws SQLException {
+        String code = rs.getString(columnName);
+        return code == null ? null : FollowUpStatus.fromCode(code);
+    }
+
+    @Override
+    public FollowUpStatus getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
+        String code = rs.getString(columnIndex);
+        return code == null ? null : FollowUpStatus.fromCode(code);
+    }
+
+    @Override
+    public FollowUpStatus getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
+        String code = cs.getString(columnIndex);
+        return code == null ? null : FollowUpStatus.fromCode(code);
+    }
+}

+ 9 - 0
src/main/java/work/baiyun/chronicdiseaseapp/mapper/FollowUpMapper.java

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

+ 44 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/po/FollowUp.java

@@ -0,0 +1,44 @@
+package work.baiyun.chronicdiseaseapp.model.po;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import work.baiyun.chronicdiseaseapp.enums.FollowUpStatus;
+
+import java.time.LocalDateTime;
+
+@Schema(description = "复诊记录表")
+@TableName("t_follow_up")
+@Data
+@EqualsAndHashCode(callSuper = false)
+public class FollowUp extends BaseEntity {
+    @Schema(description = "患者用户ID")
+    @TableField("patient_user_id")
+    private Long patientUserId;
+
+    @Schema(description = "医生用户ID")
+    @TableField("doctor_user_id")
+    private Long doctorUserId;
+
+    @Schema(description = "预约时间")
+    @TableField("appointment_time")
+    private LocalDateTime appointmentTime;
+
+    @Schema(description = "医生主动确认已完成的时间记录")
+    @TableField("actual_time")
+    private LocalDateTime actualTime;
+
+    @Schema(description = "复诊状态 (PENDING-待确认, CONFIRMED-已确认, CANCELLED-已取消, COMPLETED-已完成)")
+    @TableField(value = "status", typeHandler = work.baiyun.chronicdiseaseapp.handler.FollowUpStatusTypeHandler.class)
+    private FollowUpStatus status;
+
+    @Schema(description = "复诊原因")
+    @TableField("reason")
+    private String reason;
+
+    @Schema(description = "备注")
+    @TableField("notes")
+    private String notes;
+}

+ 22 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/vo/CreateFollowUpRequest.java

@@ -0,0 +1,22 @@
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import jakarta.validation.constraints.*;
+import java.time.LocalDateTime;
+
+@Schema(description = "创建复诊请求")
+@Data
+public class CreateFollowUpRequest {
+    @Schema(description = "医生用户ID", required = true)
+    @NotNull(message = "医生ID不能为空")
+    private Long doctorUserId;
+
+    @Schema(description = "预约时间", required = true)
+    @NotNull(message = "预约时间不能为空")
+    private LocalDateTime appointmentTime;
+
+    @Schema(description = "复诊原因")
+    private String reason;
+}

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

@@ -0,0 +1,24 @@
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import java.util.List;
+
+@Schema(description = "复诊记录分页响应")
+@Data
+public class FollowUpPageResponse {
+    @Schema(description = "数据列表")
+    private List<FollowUpResponse> records;
+
+    @Schema(description = "总数")
+    private long total;
+
+    @Schema(description = "每页大小")
+    private long size;
+
+    @Schema(description = "当前页码")
+    private long current;
+
+    @Schema(description = "总页数")
+    private long pages;
+}

+ 43 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/vo/FollowUpResponse.java

@@ -0,0 +1,43 @@
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Schema(description = "复诊记录响应")
+@Data
+public class FollowUpResponse {
+    @Schema(description = "记录ID")
+    private String id;
+
+    @Schema(description = "患者用户ID")
+    private Long patientUserId;
+
+    @Schema(description = "医生用户ID")
+    private Long doctorUserId;
+
+    @Schema(description = "预约时间")
+    private LocalDateTime appointmentTime;
+
+    @Schema(description = "实际就诊时间")
+    private LocalDateTime actualTime;
+
+    @Schema(description = "复诊状态")
+    private String status;
+
+    @Schema(description = "复诊原因")
+    private String reason;
+
+    @Schema(description = "备注")
+    private String notes;
+
+    @Schema(description = "创建时间")
+    private LocalDateTime createTime;
+
+    @Schema(description = "患者昵称")
+    private String patientNickname;
+
+    @Schema(description = "医生昵称")
+    private String doctorNickname;
+}

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

@@ -0,0 +1,24 @@
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import jakarta.validation.constraints.*;
+import java.time.LocalDateTime;
+
+@Schema(description = "更新复诊请求")
+@Data
+public class UpdateFollowUpRequest {
+    @Schema(description = "复诊记录ID", required = true)
+    @NotNull(message = "复诊记录ID不能为空")
+    private Long id;
+
+    @Schema(description = "预约时间")
+    private LocalDateTime appointmentTime;
+
+    @Schema(description = "复诊状态")
+    private String status;
+
+    @Schema(description = "备注")
+    private String notes;
+}

+ 34 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/FollowUpService.java

@@ -0,0 +1,34 @@
+package work.baiyun.chronicdiseaseapp.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import work.baiyun.chronicdiseaseapp.model.vo.BaseQueryRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.FollowUpResponse;
+import work.baiyun.chronicdiseaseapp.model.vo.CreateFollowUpRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.UpdateFollowUpRequest;
+
+public interface FollowUpService {
+    /**
+     * 患者创建复诊请求
+     */
+    void createFollowUp(CreateFollowUpRequest request);
+
+    /**
+     * 更新复诊记录(医生确认、取消或患者修改)
+     */
+    void updateFollowUp(UpdateFollowUpRequest request);
+
+    /**
+     * 分页查询当前用户的复诊记录
+     */
+    Page<FollowUpResponse> listFollowUps(BaseQueryRequest request);
+
+    /**
+     * 医生分页查询患者的复诊记录
+     */
+    Page<FollowUpResponse> listFollowUpsByPatient(Long patientUserId, BaseQueryRequest request);
+
+    /**
+     * 删除复诊记录
+     */
+    void deleteFollowUp(Long id);
+}

+ 227 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/impl/FollowUpServiceImpl.java

@@ -0,0 +1,227 @@
+package work.baiyun.chronicdiseaseapp.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import work.baiyun.chronicdiseaseapp.mapper.FollowUpMapper;
+import work.baiyun.chronicdiseaseapp.mapper.UserInfoMapper;
+import work.baiyun.chronicdiseaseapp.model.po.FollowUp;
+import work.baiyun.chronicdiseaseapp.model.po.UserInfo;
+import work.baiyun.chronicdiseaseapp.model.vo.BaseQueryRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.FollowUpResponse;
+import work.baiyun.chronicdiseaseapp.model.vo.CreateFollowUpRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.UpdateFollowUpRequest;
+import work.baiyun.chronicdiseaseapp.service.FollowUpService;
+import work.baiyun.chronicdiseaseapp.util.SecurityUtils;
+import work.baiyun.chronicdiseaseapp.enums.PermissionGroup;
+import work.baiyun.chronicdiseaseapp.exception.CustomException;
+import work.baiyun.chronicdiseaseapp.enums.ErrorCode;
+import work.baiyun.chronicdiseaseapp.enums.FollowUpStatus;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Service
+public class FollowUpServiceImpl implements FollowUpService {
+
+    @Autowired
+    private FollowUpMapper followUpMapper;
+
+    @Autowired
+    private UserInfoMapper userInfoMapper;
+
+    @Override
+    public void createFollowUp(CreateFollowUpRequest request) {
+        Long userId = SecurityUtils.getCurrentUserId();
+        FollowUp followUp = new FollowUp();
+        BeanUtils.copyProperties(request, followUp);
+        followUp.setPatientUserId(userId);
+        followUp.setStatus(FollowUpStatus.PENDING); // 默认状态为待确认
+        followUpMapper.insert(followUp);
+    }
+
+    @Override
+    public void updateFollowUp(UpdateFollowUpRequest request) {
+        Long userId = SecurityUtils.getCurrentUserId();
+        FollowUp followUp = followUpMapper.selectById(request.getId());
+        
+        if (followUp == null) {
+            throw new CustomException(
+                ErrorCode.FOLLOW_UP_NOT_FOUND.getCode(),
+                "复诊记录不存在");
+        }
+        
+        // 权限检查:患者只能修改自己的记录,医生只能修改分配给自己的记录
+        PermissionGroup role = SecurityUtils.getCurrentUserRole();
+        if (role == PermissionGroup.PATIENT && 
+            !followUp.getPatientUserId().equals(userId)) {
+            throw new CustomException(
+                ErrorCode.FOLLOW_UP_ACCESS_DENIED.getCode(),
+                "无权操作该复诊记录");
+        }
+        
+        if ((role == PermissionGroup.DOCTOR || 
+             role == PermissionGroup.SYS_ADMIN) && 
+            !followUp.getDoctorUserId().equals(userId)) {
+            throw new CustomException(
+                ErrorCode.FOLLOW_UP_ACCESS_DENIED.getCode(),
+                "无权操作该复诊记录");
+        }
+        
+        // 更新字段
+        if (request.getAppointmentTime() != null) {
+            followUp.setAppointmentTime(request.getAppointmentTime());
+        }
+        if (request.getStatus() != null) {
+            followUp.setStatus(FollowUpStatus.fromCode(request.getStatus()));
+        }
+        if (request.getNotes() != null) {
+            followUp.setNotes(request.getNotes());
+        }
+        
+        // 如果状态更新为已完成,则设置实际就诊时间
+        if (FollowUpStatus.COMPLETED.equals(followUp.getStatus())) {
+            followUp.setActualTime(LocalDateTime.now());
+        }
+        
+        followUpMapper.updateById(followUp);
+    }
+
+    @Override
+    public Page<FollowUpResponse> listFollowUps(BaseQueryRequest request) {
+        Long userId = SecurityUtils.getCurrentUserId();
+        PermissionGroup role = SecurityUtils.getCurrentUserRole();
+        
+        Page<FollowUp> page = new Page<>(request.getPageNum(), request.getPageSize());
+        LambdaQueryWrapper<FollowUp> wrapper = new LambdaQueryWrapper<>();
+        
+        // 根据用户角色查询不同的数据
+        if (role == PermissionGroup.PATIENT) {
+            wrapper.eq(FollowUp::getPatientUserId, userId);
+        } else if (role == PermissionGroup.DOCTOR || 
+                   role == PermissionGroup.SYS_ADMIN) {
+            wrapper.eq(FollowUp::getDoctorUserId, userId);
+        }
+        
+        wrapper.ge(request.getStartTime() != null, FollowUp::getCreateTime, request.getStartTime())
+               .le(request.getEndTime() != null, FollowUp::getCreateTime, request.getEndTime())
+               .orderByDesc(FollowUp::getCreateTime);
+
+        Page<FollowUp> result = followUpMapper.selectPage(page, wrapper);
+
+        // 批量查询用户信息
+        Set<Long> userIds = result.getRecords().stream()
+            .flatMap(r -> java.util.stream.Stream.of(r.getPatientUserId(), r.getDoctorUserId()))
+            .filter(id -> id != null)
+            .collect(Collectors.toSet());
+
+        Map<Long, UserInfo> userInfoMap;
+        if (userIds.isEmpty()) {
+            userInfoMap = java.util.Collections.emptyMap();
+        } else {
+            List<UserInfo> userInfos = userInfoMapper.selectBatchIds(userIds);
+            userInfoMap = userInfos.stream().collect(Collectors.toMap(UserInfo::getId, u -> u));
+        }
+
+        List<FollowUpResponse> responses = result.getRecords().stream().map(r -> {
+            FollowUpResponse resp = new FollowUpResponse();
+            BeanUtils.copyProperties(r, resp);
+            resp.setId(r.getId().toString());
+            
+            // 设置用户昵称
+            UserInfo patient = userInfoMap.get(r.getPatientUserId());
+            if (patient != null) {
+                resp.setPatientNickname(patient.getNickname());
+            }
+            
+            UserInfo doctor = userInfoMap.get(r.getDoctorUserId());
+            if (doctor != null) {
+                resp.setDoctorNickname(doctor.getNickname());
+            }
+            
+            // 设置状态码
+            if (r.getStatus() != null) {
+                resp.setStatus(r.getStatus().getCode());
+            }
+            
+            return resp;
+        }).collect(Collectors.toList());
+
+        Page<FollowUpResponse> responsePage = new Page<>();
+        responsePage.setRecords(responses);
+        responsePage.setCurrent(result.getCurrent());
+        responsePage.setSize(result.getSize());
+        responsePage.setTotal(result.getTotal());
+        responsePage.setPages(result.getPages());
+        return responsePage;
+    }
+
+    @Override
+    public Page<FollowUpResponse> listFollowUpsByPatient(Long patientUserId, BaseQueryRequest request) {
+        // 检查绑定关系
+        Long userId = SecurityUtils.getCurrentUserId();
+        PermissionGroup role = SecurityUtils.getCurrentUserRole();
+        
+        // 只有医生和管理员可以查询其他患者的复诊记录
+        if (role != PermissionGroup.DOCTOR && 
+            role != PermissionGroup.SYS_ADMIN) {
+            throw new CustomException(
+                ErrorCode.FOLLOW_UP_ACCESS_DENIED.getCode(),
+                "无权查询其他患者的复诊记录");
+        }
+        
+        Page<FollowUp> page = new Page<>(request.getPageNum(), request.getPageSize());
+        LambdaQueryWrapper<FollowUp> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(FollowUp::getPatientUserId, patientUserId)
+               .ge(request.getStartTime() != null, FollowUp::getCreateTime, request.getStartTime())
+               .le(request.getEndTime() != null, FollowUp::getCreateTime, request.getEndTime())
+               .orderByDesc(FollowUp::getCreateTime);
+
+        Page<FollowUp> result = followUpMapper.selectPage(page, wrapper);
+
+        List<FollowUpResponse> responses = result.getRecords().stream().map(r -> {
+            FollowUpResponse resp = new FollowUpResponse();
+            BeanUtils.copyProperties(r, resp);
+            resp.setId(r.getId().toString());
+            // 设置状态码
+            if (r.getStatus() != null) {
+                resp.setStatus(r.getStatus().getCode());
+            }
+            return resp;
+        }).collect(Collectors.toList());
+
+        Page<FollowUpResponse> responsePage = new Page<>();
+        responsePage.setRecords(responses);
+        responsePage.setCurrent(result.getCurrent());
+        responsePage.setSize(result.getSize());
+        responsePage.setTotal(result.getTotal());
+        responsePage.setPages(result.getPages());
+        return responsePage;
+    }
+
+    @Override
+    public void deleteFollowUp(Long id) {
+        Long userId = SecurityUtils.getCurrentUserId();
+        FollowUp followUp = followUpMapper.selectById(id);
+        
+        if (followUp == null) {
+            throw new CustomException(
+                ErrorCode.FOLLOW_UP_NOT_FOUND.getCode(),
+                "复诊记录不存在");
+        }
+        
+        // 只有患者本人才能删除自己的复诊记录
+        if (!followUp.getPatientUserId().equals(userId)) {
+            throw new CustomException(
+                ErrorCode.FOLLOW_UP_ACCESS_DENIED.getCode(),
+                "无权删除该复诊记录");
+        }
+        
+        followUpMapper.deleteById(id);
+    }
+}