复诊管理功能设计文档.md 30 KB

复诊管理功能设计文档

1. 功能概述

复诊管理功能旨在为患者和医生提供一个便捷的复诊预约和管理平台。该功能允许患者发起复诊请求,医生审核并安排复诊时间,双方可以查看和管理复诊记录。

2. 数据库设计

2.1 复诊记录表 (t_follow_up)

根据项目数据库表设计规范(参见 docs/OLD/DevRule/07-数据库规范.md),创建复诊记录表:

字段名 类型 描述
id BIGINT(20) 主键ID,使用雪花算法(MyBatis-Plus ASSIGN_ID
patient_user_id BIGINT(20) 患者用户ID,外键(可选)
doctor_user_id BIGINT(20) 医生用户ID,外键(可选)
appointment_time DATETIME 预约时间
actual_time DATETIME 医生主动确认已完成的时间记录
status VARCHAR(20) 复诊状态 (PENDING-待确认, CONFIRMED-已确认, CANCELLED-已取消, COMPLETED-已完成)
reason TEXT 复诊原因
notes TEXT 备注
version INT(11) 版本号(乐观锁)
create_user BIGINT(20) 创建者ID
create_time DATETIME 创建时间,默认值CURRENT_TIMESTAMP
update_user BIGINT(20) 更新者ID
update_time DATETIME 更新时间,默认值CURRENT_TIMESTAMP,更新时自动设置为当前时间
remark VARCHAR(255) 备注

遵循的数据库规范要点:

  • 使用 BIGINT 主键并通过雪花算法生成(MyBatis-Plus ASSIGN_ID)。
  • 公共字段(versioncreate_usercreate_timeupdate_userupdate_time)由 BaseEntityCustomMetaObjectHandler 自动填充。
  • 根据需要添加外键约束(注意性能影响)或在应用层校验关联数据完整性。
  • 数据迁移建议使用 Flyway/Liquibase(脚本命名遵循 V{version}__{description}.sql)。

示例建表(参考):

CREATE TABLE `t_follow_up` (
  `id` bigint(20) NOT NULL COMMENT '主键ID',
  `patient_user_id` bigint(20) NOT NULL COMMENT '患者用户ID',
  `doctor_user_id` bigint(20) NOT NULL COMMENT '医生用户ID',
  `appointment_time` datetime NOT NULL COMMENT '预约时间',
  `actual_time` datetime DEFAULT NULL COMMENT '医生主动确认已完成的时间记录',
  `status` varchar(20) NOT NULL COMMENT '复诊状态 (PENDING-待确认, CONFIRMED-已确认, CANCELLED-已取消, COMPLETED-已完成)',
  `reason` text COMMENT '复诊原因',
  `notes` text COMMENT '备注',
  `version` int(11) DEFAULT '0' COMMENT '版本号(乐观锁)',
  `create_user` bigint(20) DEFAULT NULL COMMENT '创建者ID',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_user` bigint(20) DEFAULT NULL COMMENT '更新者ID',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `remark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`),
  KEY `idx_patient_user_id` (`patient_user_id`),
  KEY `idx_doctor_user_id` (`doctor_user_id`),
  KEY `idx_appointment_time` (`appointment_time`),
  KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='复诊记录表';

备注:对外响应的 id 建议以字符串形式返回以避免前端 JS 精度丢失(参见 docs/OLD/DevRule/03-API设计规范.md 的长整型 ID 传输策略)。

3. 实体类设计

3.1 PO实体类 (FollowUp.java)

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 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("status")
    private String status;

    @Schema(description = "复诊原因")
    @TableField("reason")
    private String reason;

    @Schema(description = "备注")
    @TableField("notes")
    private String notes;
}

3.2 VO对象

3.2.1 请求对象

``java // CreateFollowUpRequest.java 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;

}

// UpdateFollowUpRequest.java 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;

}


#### 3.2.2 响应对象

``java
// FollowUpResponse.java
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;
}

// FollowUpPageResponse.java
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;
}

4. Mapper接口设计

``java // FollowUpMapper.java 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 { }


## 5. Service层设计

### 5.1 接口定义

``java
// FollowUpService.java
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);
}

5.2 实现类

``java // FollowUpServiceImpl.java package work.baiyun.chronicdiseaseapp.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; 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 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("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 work.baiyun.chronicdiseaseapp.exception.CustomException(
            work.baiyun.chronicdiseaseapp.enums.ErrorCode.DATA_NOT_FOUND.getCode(),
            "复诊记录不存在");
    }

    // 权限检查:患者只能修改自己的记录,医生只能修改分配给自己的记录
    work.baiyun.chronicdiseaseapp.enums.PermissionGroup role = SecurityUtils.getCurrentUserRole();
    if (role == work.baiyun.chronicdiseaseapp.enums.PermissionGroup.PATIENT && 
        !followUp.getPatientUserId().equals(userId)) {
        throw new work.baiyun.chronicdiseaseapp.exception.CustomException(
            work.baiyun.chronicdiseaseapp.enums.ErrorCode.DATA_ACCESS_DENIED.getCode(),
            "无权操作该复诊记录");
    }

    if ((role == work.baiyun.chronicdiseaseapp.enums.PermissionGroup.DOCTOR || 
         role == work.baiyun.chronicdiseaseapp.enums.PermissionGroup.SYS_ADMIN) && 
        !followUp.getDoctorUserId().equals(userId)) {
        throw new work.baiyun.chronicdiseaseapp.exception.CustomException(
            work.baiyun.chronicdiseaseapp.enums.ErrorCode.DATA_ACCESS_DENIED.getCode(),
            "无权操作该复诊记录");
    }

    // 更新字段
    if (request.getAppointmentTime() != null) {
        followUp.setAppointmentTime(request.getAppointmentTime());
    }
    if (request.getStatus() != null) {
        followUp.setStatus(request.getStatus());
    }
    if (request.getNotes() != null) {
        followUp.setNotes(request.getNotes());
    }

    // 如果状态更新为已完成,则设置实际就诊时间
    if ("COMPLETED".equals(request.getStatus())) {
        followUp.setActualTime(java.time.LocalDateTime.now());
    }

    followUpMapper.updateById(followUp);
}

@Override
public Page<FollowUpResponse> listFollowUps(BaseQueryRequest request) {
    Long userId = SecurityUtils.getCurrentUserId();
    work.baiyun.chronicdiseaseapp.enums.PermissionGroup role = SecurityUtils.getCurrentUserRole();

    Page<FollowUp> page = new Page<>(request.getPageNum(), request.getPageSize());
    LambdaQueryWrapper<FollowUp> wrapper = new LambdaQueryWrapper<>();

    // 根据用户角色查询不同的数据
    if (role == work.baiyun.chronicdiseaseapp.enums.PermissionGroup.PATIENT) {
        wrapper.eq(FollowUp::getPatientUserId, userId);
    } else if (role == work.baiyun.chronicdiseaseapp.enums.PermissionGroup.DOCTOR || 
               role == work.baiyun.chronicdiseaseapp.enums.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());
        }

        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();
    work.baiyun.chronicdiseaseapp.enums.PermissionGroup role = SecurityUtils.getCurrentUserRole();

    // 只有医生和管理员可以查询其他患者的复诊记录
    if (role != work.baiyun.chronicdiseaseapp.enums.PermissionGroup.DOCTOR && 
        role != work.baiyun.chronicdiseaseapp.enums.PermissionGroup.SYS_ADMIN) {
        throw new work.baiyun.chronicdiseaseapp.exception.CustomException(
            work.baiyun.chronicdiseaseapp.enums.ErrorCode.DATA_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());
        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 work.baiyun.chronicdiseaseapp.exception.CustomException(
            work.baiyun.chronicdiseaseapp.enums.ErrorCode.DATA_NOT_FOUND.getCode(),
            "复诊记录不存在");
    }

    // 只有患者本人才能删除自己的复诊记录
    if (!followUp.getPatientUserId().equals(userId)) {
        throw new work.baiyun.chronicdiseaseapp.exception.CustomException(
            work.baiyun.chronicdiseaseapp.enums.ErrorCode.DATA_ACCESS_DENIED.getCode(),
            "无权删除该复诊记录");
    }

    followUpMapper.deleteById(id);
}

}


## 6. Controller层设计

``java
// FollowUpController.java
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());
        }
    }
}

7. 请求/响应对象补充

``java // DeleteFollowUpRequest.java package work.baiyun.chronicdiseaseapp.model.vo;

import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data;

@Schema(description = "删除复诊记录请求") @Data public class DeleteFollowUpRequest {

@Schema(description = "复诊记录ID")
private Long id;

}


## 8. 错误码补充

在 ErrorCode 枚举中可能需要添加以下错误码:

``java
// 在 ErrorCode.java 中添加
FOLLOW_UP_NOT_FOUND(6000, "复诊记录不存在"),
FOLLOW_UP_ACCESS_DENIED(6001, "无权访问复诊记录"),
FOLLOW_UP_STATUS_INVALID(6002, "复诊状态无效");

9. 接口调用示例

9.1 患者创建复诊请求

POST /follow-up/create
{
  "doctorUserId": 1988172854356631553,
  "appointmentTime": "2025-12-01T10:00:00",
  "reason": "血糖控制不佳,需要调整治疗方案"
}

9.2 医生确认复诊

POST /follow-up/update
{
  "id": 1988502746686595073,
  "status": "CONFIRMED",
  "notes": "已确认预约"
}

9.3 查询复诊记录

POST /follow-up/list
{
  "pageNum": 1,
  "pageSize": 10,
  "startTime": "2025-11-01T00:00:00",
  "endTime": "2025-12-01T23:59:59"
}

10. 权限设计

  1. 患者

    • 可以创建复诊请求
    • 可以查看自己的复诊记录
    • 可以删除自己的待确认复诊请求
    • 可以修改自己的待确认复诊请求
  2. 医生

    • 可以查看分配给自己的复诊请求
    • 可以确认、取消或完成复诊
    • 可以查看患者的复诊历史
  3. 系统管理员

    • 具有医生的所有权限
    • 可以查看所有复诊记录

11. 状态流转

PENDING(待确认) -> CONFIRMED(已确认) -> COMPLETED(已完成)
     |
     v
CANCELLED(已取消)

12. 安全考虑

本项目安全实现应遵循 docs/OLD/DevRule/08-安全规范.md 中的已有约定,以下为与复诊管理功能相关的要点(需在实现时落地):

  1. 认证与授权

    • 使用 Token(优先 Authorization: Bearer <token>,兼容 X-Token / token)并通过 AuthInterceptor 校验,将 currentUserId/currentUserRole 注入请求上下文。
    • 仅允许符合角色与绑定关系的用户访问资源(患者仅访问本人,医生访问分配给自己的或其患者,系统管理员可查看所有记录)。
  2. 输入校验

    • 使用 Bean Validation 注解(@NotNull@Future 等)对请求参数进行校验。
    • 对时间类型(appointmentTime)增加业务校验:不可为过去时间、合法时间窗口、与已有预约冲突的检测。
  3. 异常与统一错误响应

    • 使用全局异常处理器(@RestControllerAdvice/CustomExceptionHandler)统一映射 CustomException、校验异常、未知异常到 R.fail(code, message),并返回合适的 HTTP 状态码(如 400/403/404/500)。
    • 业务异常(如 FOLLOW_UP_NOT_FOUND / FOLLOW_UP_ACCESS_DENIED)应使用明确错误码并在 ErrorCode 中定义。
  4. 日志与敏感信息

    • 在日志中避免记录完整 Token 或敏感字段;若需记录,脱敏或仅记录前 8 位。
    • 记录关键审计事件:创建、更新、取消、完成、删除等操作的用户 ID 与时间戳。
  5. SQL 注入与 ORM 使用

    • 使用 MyBatis-Plus 提供的预编译与条件构造器,禁止使用字符串拼接构建 SQL。
  6. 会话安全与 Token 管理

    • Token 存储与过期策略遵循全局规范(存 t_user_token,有效期 72 小时,低于阈值自动延长)。
  7. 部署/传输

    • 建议生产环境使用 HTTPS,证书放置在 classpath:cert/ 并在启动时加载。
  8. 最小权限原则

    • 服务端对每个接口、数据读取与修改操作都进行基于角色的授权校验,不信任前端传入的角色或用户 ID。
  9. 审计与监控

    • 对认证失败、权限拒绝、异常错误等关键安全事件进行集中日志与告警(便于安全分析)。