Преглед на файлове

docs(DevDesign): 更新复诊管理与患者提醒功能设计文档

- 调整复诊状态字段描述,明确使用枚举类型处理器映射
- 更新对外响应ID为字符串类型以避免前端精度丢失
- 补充医生ID有效性验证逻辑及权限控制增强
- 引入复诊状态自动重置机制(患者修改已确认记录时)
- 优化患者提醒设置实体字段类型转换(Byte <-> Boolean)
- 统一使用JsonUtils替代FastJSON处理时间点字段序列化
- 明确各接口权限校验策略与绑定关系检查说明
- 增加错误码使用规范与可选细化建议
- 补充数据访问日志记录与审计建议
- 更新API设计示例与开发任务清单说明
mcbaiyun преди 1 месец
родител
ревизия
e15017994b

+ 39 - 15
docs/New/复诊管理功能设计文档.md → docs/OLD/DevDesign/复诊管理功能设计文档.md

@@ -17,7 +17,7 @@
 | doctor_user_id | BIGINT(20) | 医生用户ID,外键(可选) |
 | appointment_time | DATETIME | 预约时间 |
 | actual_time | DATETIME | 医生主动确认已完成的时间记录 |
-| status | VARCHAR(20) | 复诊状态 (PENDING-待确认, CONFIRMED-已确认, CANCELLED-已取消, COMPLETED-已完成) |
+| status | VARCHAR(20) | 复诊状态 (PENDING-待确认, CONFIRMED-已确认, CANCELLED-已取消, COMPLETED-已完成)。数据库中以字符串保存,但在代码层使用 `FollowUpStatus` 枚举与 MyBatis 类型处理器进行映射。 |
 | reason | TEXT | 复诊原因 |
 | notes | TEXT | 备注 |
 | version | INT(11) | 版本号(乐观锁) |
@@ -58,7 +58,7 @@ CREATE TABLE `t_follow_up` (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='复诊记录表';
 ```
 
-备注:对外响应的 `id` 建议以字符串形式返回以避免前端 JS 精度丢失(参见 `docs/OLD/DevRule/03-API设计规范.md` 的长整型 ID 传输策略)。
+备注:对外响应的 `id` 以字符串形式返回以避免前端 JS 精度丢失(参见 `docs/OLD/DevRule/03-API设计规范.md` 的长整型 ID 传输策略)。此外,响应中的 `patientUserId` 和 `doctorUserId` 同样以字符串返回(参见 Service 层处理逻辑)。
 
 ## 3. 枚举类型设计
 
@@ -110,6 +110,8 @@ public enum FollowUpStatus {
             "Unknown FollowUpStatus code: " + code);
     }
 }
+
+> 注:`CreateFollowUpRequest` 当前仅使用 `@NotNull` 对 `appointmentTime` 做基本校验,服务端并没有在该类上使用 `@Future` 注解来强制预约时间必须为未来时间。若业务需要,请在 `FollowUpServiceImpl.createFollowUp` 中添加额外时间校验(例如禁止选择过去时间、检测预约冲突等)。
 ```
 
 ### 3.2 枚举类型处理器 (FollowUpStatusTypeHandler.java)
@@ -440,10 +442,15 @@ public class FollowUpServiceImpl implements FollowUpService {
     @Override
     public void createFollowUp(CreateFollowUpRequest request) {
         Long userId = SecurityUtils.getCurrentUserId();
+        // 验证医生ID是否有效
+        UserInfo doctor = userInfoMapper.selectById(request.getDoctorUserId());
+        if (doctor == null || doctor.getRole() != PermissionGroup.DOCTOR) {
+            throw new CustomException(ErrorCode.INVALID_DOCTOR.getCode(), ErrorCode.INVALID_DOCTOR.getMessage());
+        }
         FollowUp followUp = new FollowUp();
         BeanUtils.copyProperties(request, followUp);
         followUp.setPatientUserId(userId);
-        followUp.setStatus("PENDING"); // 默认状态为待确认
+        followUp.setStatus(FollowUpStatus.PENDING); // 默认状态为待确认
         followUpMapper.insert(followUp);
     }
 
@@ -454,7 +461,7 @@ public class FollowUpServiceImpl implements FollowUpService {
         
         if (followUp == null) {
             throw new work.baiyun.chronicdiseaseapp.exception.CustomException(
-                work.baiyun.chronicdiseaseapp.enums.ErrorCode.DATA_NOT_FOUND.getCode(),
+                work.baiyun.chronicdiseaseapp.enums.ErrorCode.FOLLOW_UP_NOT_FOUND.getCode(),
                 "复诊记录不存在");
         }
         
@@ -467,27 +474,37 @@ public class FollowUpServiceImpl implements FollowUpService {
                 "无权操作该复诊记录");
         }
         
-        if ((role == work.baiyun.chronicdiseaseapp.enums.PermissionGroup.DOCTOR || 
-             role == work.baiyun.chronicdiseaseapp.enums.PermissionGroup.SYS_ADMIN) && 
+           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(),
                 "无权操作该复诊记录");
         }
         
+        // 如果患者修改了已确认的复诊申请的关键字段(appointmentTime 或 reason),重置状态为待确认
+        boolean isModifiedByPatient = role == work.baiyun.chronicdiseaseapp.enums.PermissionGroup.PATIENT;
+        boolean isConfirmed = work.baiyun.chronicdiseaseapp.enums.FollowUpStatus.CONFIRMED.equals(followUp.getStatus());
+        boolean hasModifiedAppointmentTime = request.getAppointmentTime() != null && !request.getAppointmentTime().equals(followUp.getAppointmentTime());
+        boolean hasModifiedReason = request.getReason() != null && !request.getReason().equals(followUp.getReason());
+        if (isModifiedByPatient && isConfirmed && (hasModifiedAppointmentTime || hasModifiedReason)) {
+            followUp.setStatus(work.baiyun.chronicdiseaseapp.enums.FollowUpStatus.PENDING);
+        }
+
         // 更新字段
         if (request.getAppointmentTime() != null) {
             followUp.setAppointmentTime(request.getAppointmentTime());
         }
         if (request.getStatus() != null) {
-            followUp.setStatus(request.getStatus());
+            // 传入状态字符串会通过 FollowUpStatus.fromCode 解析,若 code 无效,将抛出参数异常(ErrorCode.PARAMETER_ERROR)
+            followUp.setStatus(work.baiyun.chronicdiseaseapp.enums.FollowUpStatus.fromCode(request.getStatus()));
         }
         if (request.getNotes() != null) {
             followUp.setNotes(request.getNotes());
         }
         
         // 如果状态更新为已完成,则设置实际就诊时间
-        if ("COMPLETED".equals(request.getStatus())) {
+        if (work.baiyun.chronicdiseaseapp.enums.FollowUpStatus.COMPLETED.equals(followUp.getStatus())) {
             followUp.setActualTime(java.time.LocalDateTime.now());
         }
         
@@ -545,6 +562,11 @@ public class FollowUpServiceImpl implements FollowUpService {
             if (doctor != null) {
                 resp.setDoctorNickname(doctor.getNickname());
             }
+            // 避免前端精度丢失:将 Long 类型 ID 转为字符串
+            if (r.getPatientUserId() != null) resp.setPatientUserId(r.getPatientUserId().toString());
+            if (r.getDoctorUserId() != null) resp.setDoctorUserId(r.getDoctorUserId().toString());
+            // 状态使用枚举 code(字符串)形式返回
+            if (r.getStatus() != null) resp.setStatus(r.getStatus().getCode());
             
             return resp;
         }).collect(Collectors.toList());
@@ -560,7 +582,7 @@ public class FollowUpServiceImpl implements FollowUpService {
 
     @Override
     public Page<FollowUpResponse> listFollowUpsByPatient(Long patientUserId, BaseQueryRequest request) {
-        // 检查绑定关系
+        // 注意:当前实现只校验调用者的角色(DOCTOR 或 SYS_ADMIN),并不主动校验 "绑定关系"。如需按绑定关系限制,需要额外调用 UserBindingService 校验。
         Long userId = SecurityUtils.getCurrentUserId();
         work.baiyun.chronicdiseaseapp.enums.PermissionGroup role = SecurityUtils.getCurrentUserRole();
         
@@ -724,7 +746,7 @@ public class FollowUpController {
         }
     }
 
-    @Operation(summary = "医生分页查询患者复诊记录", description = "医生查询患者复诊记录(需有绑定关系或权限)")
+    @Operation(summary = "医生分页查询患者复诊记录", description = "医生查询患者复诊记录(当前实现仅校验角色 DOCTOR 或 SYS_ADMIN,未强制绑定关系校验)")
     @ApiResponses(value = {
         @ApiResponse(responseCode = "200", description = "成功查询复诊记录列表",
             content = @Content(mediaType = "application/json",
@@ -851,8 +873,8 @@ POST /follow-up/list
 1. **患者**:
    - 可以创建复诊请求
    - 可以查看自己的复诊记录
-   - 可以删除自己的待确认复诊请求
-   - 可以修改自己的待确认复诊请求
+    - 可以删除自己的复诊记录(仅要求为本人,不再在服务端严格限制状态为待确认)
+    - 可以修改自己的复诊记录(如果修改已确认申请的关键字段,如预约时间或原因,服务器会将状态重置为 PENDING,需医生重新确认)
    - 可以更新复诊申请原因
 
 2. **医生**:
@@ -861,8 +883,8 @@ POST /follow-up/list
    - 可以查看患者的复诊历史
 
 3. **系统管理员**:
-   - 具有医生的所有权限
-   - 可以查看所有复诊记录
+    - 在部分接口中被当作医生处理(例如 `listFollowUps` 会把 SYS_ADMIN 当作 DOCTOR 并过滤 `doctor_user_id = currentUserId`),但在 `listFollowUpsByPatient` 中允许按 `patientUserId` 查询患者记录。
+    - 注意:该行为在不同接口存在不一致性,若预期 `SYS_ADMIN` 应拥有全局查看权限,建议在实现中统一授权策略。
 
 ## 12. 状态流转
 
@@ -871,6 +893,8 @@ PENDING(待确认) -> CONFIRMED(已确认) -> COMPLETED(已完成)
      |
      v
 CANCELLED(已取消)
+
+注:当患者在已确认(CONFIRMED)状态下修改预约时间或复诊原因时,服务端会自动将记录状态回退为 PENDING 以等待医生重新确认(见 `FollowUpServiceImpl.updateFollowUp`)。
 ```
 
 ## 13. 安全考虑
@@ -882,7 +906,7 @@ CANCELLED(已取消)
     - 仅允许符合角色与绑定关系的用户访问资源(患者仅访问本人,医生访问分配给自己的或其患者,系统管理员可查看所有记录)。
 
 2. **输入校验**:
-    - 使用 Bean Validation 注解(`@NotNull`、`@Future` 等)对请求参数进行校验
+    - 使用 Bean Validation 注解(`@NotNull` 等)对请求参数进行基本校验;注意:当前实现未强制使用 `@Future` 校验 `appointmentTime`(代码只使用 `@NotNull`),如果需要禁止提交过去时间,应在后端服务中补充时间校验逻辑
     - 对时间类型(`appointmentTime`)增加业务校验:不可为过去时间、合法时间窗口、与已有预约冲突的检测。
 
 3. **异常与统一错误响应**:

+ 21 - 2
docs/New/患者健康档案功能设计文档.md → docs/OLD/DevDesign/患者健康档案功能设计文档.md

@@ -62,7 +62,7 @@ CREATE TABLE `t_patient_health_record` (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='患者健康档案表';
 ```
 
-备注:对外响应的 `id` 建议以字符串形式返回以避免前端 JS 精度丢失(参见 `docs/OLD/DevRule/03-API设计规范.md` 的长整型 ID 传输策略)。
+备注:对外响应的 `id` 以字符串形式返回以避免前端 JS 精度丢失(参见 `docs/OLD/DevRule/03-API设计规范.md` 的长整型 ID 传输策略)。此外,`patientUserId` 在响应中也使用字符串类型返回,避免前端精度丢失。
 
 ## 3. 实体类设计
 
@@ -267,6 +267,8 @@ public interface PatientHealthRecordService {
      */
     PatientHealthRecordResponse getPatientHealthRecord(Long patientUserId);
 }
+
+注:`createOrUpdateHealthRecord` 使用 `patient_user_id` 做唯一查重(`uk_patient_user_id`),如果存在则更新,否则插入新记录。该方法不会显式返回新插入 ID;调用方可通过 `getCurrentUserHealthRecord` 获取最新记录。服务端没有对传入字段(如吸烟、饮酒史)做额外的语义校验,若需强校验应在 Service 层补充。
 ```
 
 ### 5.2 实现类
@@ -345,7 +347,8 @@ public class PatientHealthRecordServiceImpl implements PatientHealthRecordServic
         PatientHealthRecord record = patientHealthRecordMapper.selectOne(wrapper);
         
         if (record == null) {
-            return null; // 或返回空对象
+            // 当前实现直接返回 null,调用方(controller)会原样返回成功响应中的 data 为 null。
+            return null;
         }
         
         PatientHealthRecordResponse response = new PatientHealthRecordResponse();
@@ -416,6 +419,8 @@ public class PatientHealthRecordController {
         }
     }
 
+注意:`save` 接口没有对当前用户角色做额外限制(会使用当前 loginUserId 作为 patientUserId),因此仅认证通过的用户能创建/更新其健康档案。
+
     @Operation(summary = "获取当前用户健康档案", description = "患者获取自己的健康档案")
     @ApiResponses(value = {
         @ApiResponse(responseCode = "200", description = "成功获取健康档案",
@@ -456,6 +461,20 @@ public class PatientHealthRecordController {
         }
     }
 }
+
+## 7. 权限设计
+
+1. **患者**:
+    - 可以创建或更新自己的健康档案(通过 `/patient-health-record/save`)。
+    - 可以查看自己的健康档案(通过 `/patient-health-record/my-record`)。
+
+2. **医生**:
+    - 可以通过 `/patient-health-record/patient/{patientUserId}` 查看指定患者的健康档案。当前实现仅基于角色校验(DOCTOR 或 SYS_ADMIN),没有在此处进行绑定关系检查。
+
+3. **系统管理员**:
+    - 在当前实现中,`SYS_ADMIN` 与 `DOCTOR` 在 `getPatientHealthRecord` 中具有同等查询权限。若要使管理员拥有更广泛的权限(例如强制查看所有记录),应统一权限策略并在 Service 层明确限制。
+
+注:当前实现的权限校验仅基于角色(`PermissionGroup.DOCTOR` 或 `PermissionGroup.SYS_ADMIN`),并不在此处主动检查医生是否与患者存在绑定关系(关系校验需由 `UserBindingService` 提供并在 Service 层显式调用)。
 ```
 
 ## 7. 错误码补充

+ 64 - 39
docs/New/患者提醒数据管理功能设计文档.md → docs/OLD/DevDesign/患者提醒数据管理功能设计文档.md

@@ -14,15 +14,15 @@
 |--------|------|------|
 | id | BIGINT(20) | 主键ID,使用雪花算法(MyBatis-Plus `ASSIGN_ID`) |
 | patient_user_id | BIGINT(20) | 患者用户ID,每个患者只有一条记录 |
-| is_notification_enabled | TINYINT(1) | 是否启用消息通知总开关 (0-禁用, 1-启用) |
-| is_subscription_available | TINYINT(1) | 一次性订阅开关,服务器每发送消息后需重新申请用户授权 (0-需要重新授权, 1-已授权可发送) |
-| is_blood_pressure_enabled | TINYINT(1) | 测量血压提醒开关 (0-禁用, 1-启用) |
+| is_notification_enabled | TINYINT(1) | 是否启用消息通知总开关 (0-禁用, 1-启用)。在 Java 实体中以 `Byte` 类型保存,服务层对其值作 Boolean 转换(1=true)。 |
+| is_subscription_available | TINYINT(1) | 一次性订阅开关,服务器每发送消息后需重新申请用户授权 (0-需要重新授权, 1-已授权可发送)。在实体中为 `Byte`,对外 VO 封装为 `Boolean`。 |
+| is_blood_pressure_enabled | TINYINT(1) | 测量血压提醒开关 (0-禁用, 1-启用)。实体字段为 `Byte`,响应层转为 `Boolean`。 |
 | blood_pressure_times | TEXT | 测量血压的时间点(JSON格式存储,参考用药管理功能设计文档) |
-| is_blood_sugar_enabled | TINYINT(1) | 测量血糖提醒开关 (0-禁用, 1-启用) |
+| is_blood_sugar_enabled | TINYINT(1) | 测量血糖提醒开关 (0-禁用, 1-启用)。实体字段为 `Byte`,响应层转为 `Boolean`。 |
 | blood_sugar_times | TEXT | 测量血糖的时间点(JSON格式存储) |
-| is_heart_rate_enabled | TINYINT(1) | 测量心率提醒开关 (0-禁用, 1-启用) |
+| is_heart_rate_enabled | TINYINT(1) | 测量心率提醒开关 (0-禁用, 1-启用)。实体字段为 `Byte`,响应层转为 `Boolean`。 |
 | heart_rate_times | TEXT | 测量心率的时间点(JSON格式存储) |
-| is_medication_enabled | TINYINT(1) | 用药提醒开关 (0-禁用, 1-启用) |
+| is_medication_enabled | TINYINT(1) | 用药提醒开关 (0-禁用, 1-启用)。实体字段为 `Byte`,响应层转为 `Boolean`。 |
 | version | INT(11) | 版本号(乐观锁) |
 | create_user | BIGINT(20) | 创建者ID |
 | create_time | DATETIME | 创建时间,默认值CURRENT_TIMESTAMP |
@@ -63,7 +63,7 @@ CREATE TABLE `t_patient_reminder` (
 
 ## 3. 枚举类型设计
 
-本功能设计中不使用枚举类型,所有提醒类型直接作为字段存储在患者提醒设置表中。
+本功能设计中不使用枚举类型,所有提醒类型直接作为字段存储在患者提醒设置表中。注意:实体层字段在数据库中以 `TINYINT(1)` 保存(Java 实体使用 `Byte`),但对外 VO 使用 `Boolean`,并在 Service 层做相应的转换。
         return code == null ? null : ReminderType.fromCode(code);
     }
 
@@ -107,14 +107,17 @@ public class PatientReminder extends BaseEntity {
 
     @Schema(description = "是否启用消息通知总开关 (0-禁用, 1-启用)")
     @TableField("is_notification_enabled")
+    // 注意:数据库中为 TINYINT(1),实体类 `PatientReminder` 使用 `Byte`,响应 VO 仍是 Boolean
     private Boolean notificationEnabled;
 
     @Schema(description = "一次性订阅开关 (0-需要重新授权, 1-已授权可发送)")
     @TableField("is_subscription_available")
+    // 注意:数据库中为 TINYINT(1),实体类 `PatientReminder` 使用 `Byte`,响应 VO 仍是 Boolean
     private Boolean subscriptionAvailable;
 
     @Schema(description = "测量血压提醒开关 (0-禁用, 1-启用)")
     @TableField("is_blood_pressure_enabled")
+    // 注意:数据库中为 TINYINT(1),实体类 `PatientReminder` 使用 `Byte`,响应 VO 仍是 Boolean
     private Boolean bloodPressureEnabled;
 
     @Schema(description = "测量血压的时间点(JSON格式存储)")
@@ -123,6 +126,7 @@ public class PatientReminder extends BaseEntity {
 
     @Schema(description = "测量血糖提醒开关 (0-禁用, 1-启用)")
     @TableField("is_blood_sugar_enabled")
+    // 注意:数据库中为 TINYINT(1),实体类 `PatientReminder` 使用 `Byte`,响应 VO 仍是 Boolean
     private Boolean bloodSugarEnabled;
 
     @Schema(description = "测量血糖的时间点(JSON格式存储)")
@@ -131,6 +135,7 @@ public class PatientReminder extends BaseEntity {
 
     @Schema(description = "测量心率提醒开关 (0-禁用, 1-启用)")
     @TableField("is_heart_rate_enabled")
+    // 注意:数据库中为 TINYINT(1),实体类 `PatientReminder` 使用 `Byte`,响应 VO 仍是 Boolean
     private Boolean heartRateEnabled;
 
     @Schema(description = "测量心率的时间点(JSON格式存储)")
@@ -139,6 +144,7 @@ public class PatientReminder extends BaseEntity {
 
     @Schema(description = "用药提醒开关 (0-禁用, 1-启用)")
     @TableField("is_medication_enabled")
+    // 注意:数据库中为 TINYINT(1),实体类 `PatientReminder` 使用 `Byte`,响应 VO 仍是 Boolean
     private Boolean medicationEnabled;
 }
 ```
@@ -154,6 +160,7 @@ package work.baiyun.chronicdiseaseapp.model.vo;
 import io.swagger.v3.oas.annotations.media.Schema;
 import lombok.Data;
 import java.util.List;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 
 @Schema(description = "患者提醒设置请求")
 @Data
@@ -307,8 +314,8 @@ public interface PatientReminderMapper extends BaseMapper<PatientReminder> {
 package work.baiyun.chronicdiseaseapp.service;
 
 import work.baiyun.chronicdiseaseapp.model.vo.PatientReminderOverviewResponse;
-import work.baiyun.chronicdiseaseapp.model.vo.CreatePatientReminderRequest;
-import work.baiyun.chronicdiseaseapp.model.vo.UpdatePatientReminderRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.PatientReminderRequest;
+// Update operations are handled by savePatientReminder, no separate UpdatePatientReminderRequest implementation is required.
 
 public interface PatientReminderService {
     /**
@@ -319,7 +326,7 @@ public interface PatientReminderService {
     /**
      * 保存患者提醒设置(创建或更新)
      */
-    void savePatientReminder(CreatePatientReminderRequest request);
+    void savePatientReminder(PatientReminderRequest request);
 
     /**
      * 删除患者提醒设置
@@ -334,7 +341,8 @@ public interface PatientReminderService {
 // PatientReminderServiceImpl.java
 package work.baiyun.chronicdiseaseapp.service.impl;
 
-import com.alibaba.fastjson.JSON;
+import com.fasterxml.jackson.core.type.TypeReference;
+import work.baiyun.chronicdiseaseapp.util.JsonUtils;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
@@ -343,7 +351,7 @@ import work.baiyun.chronicdiseaseapp.mapper.PatientReminderMapper;
 import work.baiyun.chronicdiseaseapp.model.po.PatientReminder;
 import work.baiyun.chronicdiseaseapp.model.vo.PatientReminderOverviewResponse;
 import work.baiyun.chronicdiseaseapp.model.vo.PatientReminderResponse;
-import work.baiyun.chronicdiseaseapp.model.vo.CreatePatientReminderRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.PatientReminderRequest;
 import work.baiyun.chronicdiseaseapp.service.PatientReminderService;
 import work.baiyun.chronicdiseaseapp.util.SecurityUtils;
 import java.util.List;
@@ -357,8 +365,11 @@ public class PatientReminderServiceImpl implements PatientReminderService {
     @Override
     public PatientReminderOverviewResponse getReminderOverview() {
         Long userId = SecurityUtils.getCurrentUserId();
+        // 注意:getReminderOverview 是只读方法,不会修改提醒设置;保存逻辑和订阅清理在 savePatientReminder 中完成。
         
-        PatientReminder patientReminder = patientReminderMapper.selectByPatientUserId(userId);
+        PatientReminder patientReminder = patientReminderMapper.selectOne(
+            new QueryWrapper<PatientReminder>().eq("patient_user_id", userId)
+        );
         
         PatientReminderOverviewResponse response = new PatientReminderOverviewResponse();
         
@@ -366,18 +377,29 @@ public class PatientReminderServiceImpl implements PatientReminderService {
             PatientReminderResponse reminderResponse = new PatientReminderResponse();
             BeanUtils.copyProperties(patientReminder, reminderResponse);
             reminderResponse.setId(patientReminder.getId().toString());
-            reminderResponse.setBloodPressureTimes(JSON.parseArray(patientReminder.getBloodPressureTimes(), String.class));
-            reminderResponse.setBloodSugarTimes(JSON.parseArray(patientReminder.getBloodSugarTimes(), String.class));
-            reminderResponse.setHeartRateTimes(JSON.parseArray(patientReminder.getHeartRateTimes(), String.class));
+            reminderResponse.setPatientUserId(patientReminder.getPatientUserId().toString());
+            // 通过 byte->Boolean 映射:1 -> true, others -> false
+            reminderResponse.setNotificationEnabled(patientReminder.getNotificationEnabled() != null && patientReminder.getNotificationEnabled() == 1);
+            reminderResponse.setSubscriptionAvailable(patientReminder.getSubscriptionAvailable() != null && patientReminder.getSubscriptionAvailable() == 1);
+            reminderResponse.setBloodPressureEnabled(patientReminder.getBloodPressureEnabled() != null && patientReminder.getBloodPressureEnabled() == 1);
+            reminderResponse.setBloodSugarEnabled(patientReminder.getBloodSugarEnabled() != null && patientReminder.getBloodSugarEnabled() == 1);
+            reminderResponse.setHeartRateEnabled(patientReminder.getHeartRateEnabled() != null && patientReminder.getHeartRateEnabled() == 1);
+            reminderResponse.setMedicationEnabled(patientReminder.getMedicationEnabled() != null && patientReminder.getMedicationEnabled() == 1);
+            // 时间点字段使用 JsonUtils 解析为 List<String>
+            reminderResponse.setBloodPressureTimes(JsonUtils.fromJson(patientReminder.getBloodPressureTimes(), new TypeReference<List<String>>() {}));
+            reminderResponse.setBloodSugarTimes(JsonUtils.fromJson(patientReminder.getBloodSugarTimes(), new TypeReference<List<String>>() {}));
+            reminderResponse.setHeartRateTimes(JsonUtils.fromJson(patientReminder.getHeartRateTimes(), new TypeReference<List<String>>() {}));
             response.setReminder(reminderResponse);
         }
         
         return response;
     }
 
+注:若患者尚未设置提醒,`getReminderOverview` 将返回成功响应,但 `data.reminder` 为 `null`。调用方应对这种情况做兼容处理(例如在前端显示 "尚未设置提醒" 的提示)。
+
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public void savePatientReminder(CreatePatientReminderRequest request) {
+    public void savePatientReminder(PatientReminderRequest request) {
         Long userId = SecurityUtils.getCurrentUserId();
         
         // 检查是否已存在记录
@@ -387,52 +409,53 @@ public class PatientReminderServiceImpl implements PatientReminderService {
             // 创建患者提醒设置
             PatientReminder patientReminder = new PatientReminder();
             patientReminder.setPatientUserId(userId);
-            patientReminder.setNotificationEnabled(request.getNotificationEnabled() != null ? request.getNotificationEnabled() : true);
-            patientReminder.setSubscriptionAvailable(request.getSubscriptionAvailable() != null ? request.getSubscriptionAvailable() : true);
-            patientReminder.setBloodPressureEnabled(request.getBloodPressureEnabled() != null ? request.getBloodPressureEnabled() : true);
-            patientReminder.setBloodPressureTimes(JSON.toJSONString(request.getBloodPressureTimes()));
-            patientReminder.setBloodSugarEnabled(request.getBloodSugarEnabled() != null ? request.getBloodSugarEnabled() : false);
-            patientReminder.setBloodSugarTimes(JSON.toJSONString(request.getBloodSugarTimes()));
-            patientReminder.setHeartRateEnabled(request.getHeartRateEnabled() != null ? request.getHeartRateEnabled() : true);
-            patientReminder.setHeartRateTimes(JSON.toJSONString(request.getHeartRateTimes()));
-            patientReminder.setMedicationEnabled(request.getMedicationEnabled() != null ? request.getMedicationEnabled() : true);
+            // 设置默认值与类型转换(Boolean -> Byte),默认值与代码中一致
+            patientReminder.setNotificationEnabled(request.getNotificationEnabled() != null ? (request.getNotificationEnabled() ? (byte)1 : (byte)0) : (byte)1);
+            patientReminder.setSubscriptionAvailable(request.getSubscriptionAvailable() != null ? (request.getSubscriptionAvailable() ? (byte)1 : (byte)0) : (byte)1);
+            patientReminder.setBloodPressureEnabled(request.getBloodPressureEnabled() != null ? (request.getBloodPressureEnabled() ? (byte)1 : (byte)0) : (byte)1);
+            patientReminder.setBloodPressureTimes(JsonUtils.toJson(request.getBloodPressureTimes()));
+            patientReminder.setBloodSugarEnabled(request.getBloodSugarEnabled() != null ? (request.getBloodSugarEnabled() ? (byte)1 : (byte)0) : (byte)0);
+            patientReminder.setBloodSugarTimes(JsonUtils.toJson(request.getBloodSugarTimes()));
+            patientReminder.setHeartRateEnabled(request.getHeartRateEnabled() != null ? (request.getHeartRateEnabled() ? (byte)1 : (byte)0) : (byte)1);
+            patientReminder.setHeartRateTimes(JsonUtils.toJson(request.getHeartRateTimes()));
+            patientReminder.setMedicationEnabled(request.getMedicationEnabled() != null ? (request.getMedicationEnabled() ? (byte)1 : (byte)0) : (byte)1);
             patientReminderMapper.insert(patientReminder);
         } else {
             // 更新提醒设置
             if (request.getNotificationEnabled() != null) {
-                existing.setNotificationEnabled(request.getNotificationEnabled());
+                existing.setNotificationEnabled(request.getNotificationEnabled() ? (byte)1 : (byte)0);
             }
             
             if (request.getSubscriptionAvailable() != null) {
-                existing.setSubscriptionAvailable(request.getSubscriptionAvailable());
+                existing.setSubscriptionAvailable(request.getSubscriptionAvailable() ? (byte)1 : (byte)0);
             }
             
             if (request.getBloodPressureEnabled() != null) {
-                existing.setBloodPressureEnabled(request.getBloodPressureEnabled());
+                existing.setBloodPressureEnabled(request.getBloodPressureEnabled() ? (byte)1 : (byte)0);
             }
             
             if (request.getBloodPressureTimes() != null) {
-                existing.setBloodPressureTimes(JSON.toJSONString(request.getBloodPressureTimes()));
+                existing.setBloodPressureTimes(JsonUtils.toJson(request.getBloodPressureTimes()));
             }
             
             if (request.getBloodSugarEnabled() != null) {
-                existing.setBloodSugarEnabled(request.getBloodSugarEnabled());
+                existing.setBloodSugarEnabled(request.getBloodSugarEnabled() ? (byte)1 : (byte)0);
             }
             
             if (request.getBloodSugarTimes() != null) {
-                existing.setBloodSugarTimes(JSON.toJSONString(request.getBloodSugarTimes()));
+                existing.setBloodSugarTimes(JsonUtils.toJson(request.getBloodSugarTimes()));
             }
             
             if (request.getHeartRateEnabled() != null) {
-                existing.setHeartRateEnabled(request.getHeartRateEnabled());
+                existing.setHeartRateEnabled(request.getHeartRateEnabled() ? (byte)1 : (byte)0);
             }
             
             if (request.getHeartRateTimes() != null) {
-                existing.setHeartRateTimes(JSON.toJSONString(request.getHeartRateTimes()));
+                existing.setHeartRateTimes(JsonUtils.toJson(request.getHeartRateTimes()));
             }
             
             if (request.getMedicationEnabled() != null) {
-                existing.setMedicationEnabled(request.getMedicationEnabled());
+                existing.setMedicationEnabled(request.getMedicationEnabled() ? (byte)1 : (byte)0);
             }
             
             patientReminderMapper.updateById(existing);
@@ -481,7 +504,7 @@ import org.springframework.http.MediaType;
 import org.springframework.web.bind.annotation.*;
 import work.baiyun.chronicdiseaseapp.common.R;
 import work.baiyun.chronicdiseaseapp.model.vo.PatientReminderOverviewResponse;
-import work.baiyun.chronicdiseaseapp.model.vo.CreatePatientReminderRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.PatientReminderRequest;
 import work.baiyun.chronicdiseaseapp.service.PatientReminderService;
 import work.baiyun.chronicdiseaseapp.enums.ErrorCode;
 import org.slf4j.Logger;
@@ -527,7 +550,7 @@ public class PatientReminderController {
                 schema = @Schema(implementation = Void.class)))
     })
     @PostMapping(path = "/save", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
-    public R<?> savePatientReminder(@RequestBody CreatePatientReminderRequest req) {
+    public R<?> savePatientReminder(@RequestBody PatientReminderRequest req) {
         try {
             patientReminderService.savePatientReminder(req);
             return R.success(200, "患者提醒设置保存成功");
@@ -561,10 +584,10 @@ public class PatientReminderController {
 
 ## 8. 错误码补充
 
-在 ErrorCode 枚举中可能需要添加以下错误码:
+当前实现使用 `ErrorCode.DATA_NOT_FOUND`(找不到记录)和 `ErrorCode.DATA_ACCESS_DENIED`(无权访问)来返回对应错误;如果你希望更精细的错误编码(便于前端识别或自动化监控),可以添加如下错误码:
 
 ```java
-// 在 ErrorCode.java 中添加
+// 在 ErrorCode.java 中添加(可选)
 PATIENT_REMINDER_NOT_FOUND(7000, "患者提醒记录不存在"),
 PATIENT_REMINDER_ACCESS_DENIED(7001, "无权访问患者提醒记录");
 ```
@@ -617,6 +640,8 @@ POST /patient-reminder/save
   "heartRateTimes": ["18:00"],
   "medicationEnabled": true
 }
+
+注意:当 `notificationEnabled` 为 false 时,服务端会把 `subscriptionAvailable` 自动设置为 false;如果调用方同时传入 `subscriptionAvailable=true`,保存后仍会被重置为 false(以保证一致性)。
 ```
 
 ### 9.3 删除患者提醒设置

+ 7 - 3
docs/OLD/DevDesign/查询绑定患者健康数据设计.md

@@ -24,12 +24,16 @@
 
 2. Service 层提供 `listDataByPatient(patientUserId, BaseQueryRequest)` 等方法,返回 `Page<TResponse>` 供 Controller 组装对应 PageResponse VO。
 
+说明:仓库中部分 Service/Controller 已实现该机制(如 `BloodGlucoseDataServiceImpl.listBloodGlucoseDataByPatient`),实现如下:
+- Controller:取 `boundUserId = SecurityUtils.getCurrentUserId()`,通过 `userBindingService.checkUserBinding` 校验;若绑定存在则回填 `bindingType` 并调用 Service;
+- Service:通过 `patientUserId` 替换原 `userId` 进行查询,若 `bindingType=="FAMILY"` 则限制 `startTime` 最小值为最近 365 天(此处逻辑已在 `BloodGlucoseDataServiceImpl` / `HeartRateDataServiceImpl` 中实现)。
+
 3. 权限策略(建议):
    - DOCTOR:默认具有查询所有字段的权限;可以查看所有历史数据(受分页限制)。
    - FAMILY:只允许查询近 N 天(例如 365 天内)或限制敏感字段(如“医生诊断备注”等),并在返回字段中做适当脱敏/限制。绑定关系可在 `CreateUserBindingRequest` 中声明。
 
 4. 审计日志:
-   - 在 Controller 的 `list-by-bound-user` 接口中记录 `boundUserId`, `patientUserId`, `queryType`, `startTime`, `endTime`,写入到 regular logs 或独立审计表(参考 `docs/DevRule/06-日志和错误处理规范.md`)。
+	- 在 Controller 的 `list-by-bound-user` 接口中记录 `boundUserId`, `patientUserId`, `queryType`, `startTime`, `endTime`,写入到 regular logs(仓库实现使用 `logger.info(...)`)或独立审计表(参考 `docs/DevRule/06-日志和错误处理规范.md`)。如需合规审计,建议实现 `patient_data_access_log` 表。
 
 ## API 设计示例
 
@@ -121,10 +125,10 @@ Page<BloodGlucoseDataResponse> listBloodGlucoseDataByPatient(Long patientUserId,
 
 ## 开发任务清单
 1. Controller:在 `BloodGlucoseDataController`, `BloodPressureDataController`, `HeartRateDataController`, `PhysicalDataController` 等实现 `list-by-bound-user`。
-2. Service:添加 `listByPatient` 变体并对 bindingType 做权限过滤。
+2. Service:添加 `listByPatient` 变体并对 bindingType 做权限过滤(仓库已实现,参见 `BloodGlucoseDataServiceImpl.listBloodGlucoseDataByPatient`,`HeartRateDataServiceImpl.listHeartRateDataByPatient`)
 3. Mapper:如必要,添加 `AND user_id = #{patientUserId}` 的查询条件。
 4. Unit / Integration Tests:覆盖权限校验、数据返回与分页。
-5. 日志 & 审计:实现访问日志或可选审计表
+5. 日志 & 审计:Controller 使用 `logger.info` 写访问日志;如需合规审计,另外实现 `patient_data_access_log` 表与写入逻辑
 
 ## 参考
 - docs/前端ID精度丢失问题解决方案.md

+ 5 - 5
docs/OLD/DevDesign/用户头像本地上传与获取接口设计-项目改动概要.md

@@ -12,13 +12,13 @@ avatar:
 
 ## 2. Controller 层
 - 新增 `UserAvatarController`,实现:
-  - `POST /user/avatar/upload`:接收 MultipartFile,校验类型/大小,仅允许当前用户上传,调用 Service 处理,返回 `R<String>`。
-  - `GET /user/avatar/{userId}`:根据 userId 查询头像相对路径,返回图片流,找不到返回默认图片
+  - `POST /user/avatar/upload`:接收 MultipartFile,校验类型/大小,仅允许当前用户上传,调用 Service 处理,返回 `R<String>`(data 字段为相对路径,例如 `userId/timestamp.ext`)。未经认证时返回 `R.fail(401, "未认证", null)`
+  - `GET /user/avatar/{userId}`:根据 userId 查询头像相对路径,返回图片流,找不到或文件不存在返回404。Controller 会尝试用 `Files.probeContentType` 标定 Content-Type
 - 接口需加 Swagger 注解,响应格式与项目一致。
 
 ## 3. Service 层
-- 增 `UserAvatarService` 及实现类,负责:
-  - 头像存储路径生成、文件校验、保存本地、数据库 avatar 字段更新。
+- 增 `UserAvatarService` 及实现类:`UserAvatarServiceImpl`,负责:
+  - 头像存储路径生成(默认 `{root}/{userId}/{timestamp}.{ext}`)、文件校验(由 `FileUtils` 验证类型与大小)、保存本地、数据库 `avatar` 字段更新。若发生参数异常(如文件类型或大小),服务抛出 `CustomException` 并使用 `ErrorCode.PARAMETER_ERROR` 返回前端。
   - 获取头像文件流,处理异常和默认头像。
   - 日志记录(info/error,含 userId、文件名、IP)。
 
@@ -27,7 +27,7 @@ avatar:
 - 如有头像相关 VO,userId 字段类型需为 String。
 
 ## 5. 工具类/配置类
-- 如有需要,增加头像相关配置类、文件工具类(如路径拼接、类型校验等)
+- 已有 `AvatarProperties` 配置类,前缀 `avatar`:`root-path`、`max-size`、`allowed-types`,`maxSize` 默认 2MB,`allowedTypes` 默认为 `jpg,png,jpeg,webp`
 
 ## 6. 日志与安全
 - 上传/更新/异常操作按规范记录日志。

+ 27 - 6
docs/OLD/DevDesign/用户头像本地上传与获取接口设计.md

@@ -1,3 +1,12 @@
+  // Controller 在返回头像资源时会尝试使用 `Files.probeContentType` 识别 mime type 并写到响应头(若识别失败将使用默认且不影响返回)。
+  @Operation(summary = "获取用户头像", description = "根据用户ID获取头像图片")
+  @ApiResponses(value = {
+  @ApiResponse(responseCode = "200", description = "获取成功,返回图片流"),
+  @ApiResponse(responseCode = "404", description = "未找到头像或文件不存在,返回404")
+  })
+  @GetMapping(value = "/user/avatar/{userId}", produces = MediaType.IMAGE_JPEG_VALUE)
+  public ResponseEntity<Resource> getAvatar(@PathVariable String userId) {
+  // ...查找头像文件并返回流...
 # 用户头像本地上传与获取接口设计
 
 > 本文档遵循《03-API设计规范》《06-日志和错误处理规范》《Swagger泛型返回类型字段信息不显示问题解决方案》《前端ID精度丢失问题解决方案》等项目规范。
@@ -25,8 +34,18 @@
   - 上传/更新头像:`POST /user/avatar/upload`,参数为`MultipartFile`,仅允许当前用户操作。
   - 获取头像:`GET /user/avatar/{userId}`,userId为字符串,返回图片流。
 2. Service层实现头像存储、路径生成、文件校验、数据库更新、异常处理、日志记录等逻辑。
+
+说明(基于当前实现):
+- 服务实现类:`UserAvatarServiceImpl`(`work.baiyun.chronicdiseaseapp.service.impl.UserAvatarServiceImpl`)负责具体逻辑;控制器为 `UserAvatarController`。
+- 配置:使用 `AvatarProperties`,@ConfigurationProperties 前缀为 `avatar`,默认 `maxSize` 为 2MB,`allowedTypes` 默认为 "jpg,png,jpeg,webp";若未在配置文件中设置 `root-path`,则使用默认相对目录 `avatar-storage`。
+- 文件命名:当前实现使用 `timestamp.ext`(如 `1597891234567.jpg`)并将文件放到 `{root}/{userId}/{timestamp}.{ext}` 下(`relative = currentUserId + "/" + savedFileName`)。
+- 路径与安全:保存前会使用 `normalize()` 并检查路径前缀以防止路径穿越;若检测失败,抛出系统异常并记录日志。
+- 错误码:对非法文件类型与超限的文件,服务会抛出 `CustomException` 并使用 `ErrorCode.PARAMETER_ERROR`;若用户不存在抛 `ErrorCode.USER_NOT_EXIST`;IO 错误返回 `ErrorCode.SYSTEM_ERROR`。
+- 获取接口:`/user/avatar/{userId}` 返回 `Resource` 流;若找不到资源或字段为空则返回 404;若 userId 非法 (非数字),会记录错误并返回 404。
 3. 配置文件增加头像存储根路径、最大文件大小、允许类型等配置项。
-4. 文件存储结构建议:`{avatarRootPath}/{userId}/{userId}_{timestamp}.{ext}`,防止文件名冲突。
+4. 文件存储结构说明(项目实现):`{avatarRootPath}/{userId}/{timestamp}.{ext}`。
+  - 说明:实现没有将 `userId` 作为文件名前缀,仅把 userId 作为目录名(`relative = userId + "/" + timestamp + "." + ext`)。
+  - 建议(可选):如果希望文件名包含 `userId`(便于查找或排障),改为 `{userId}_{timestamp}.{ext}`;当前实现已由目录结构与时间戳保证唯一性。
   - 文件命名策略:建议使用 `{userId}_{timestamp}.{ext}`(如 `123_1597891234567.jpg`)或基于日期格式的时间戳(如 `123_20200820T153012.jpg`)生成上传文件名。
     - 优点:保证唯一性、防止并发覆盖;便于前端缓存失效控制(每次上传返回不同 URL);也可以直接用于文件历史管理与清理。
     - 存储示例:
@@ -34,7 +53,7 @@
       - avatar 字段(相对路径)存储:`user/123/123_1597891234567.jpg`
   - 推荐策略:保存新头像后异步删除旧头像(注意合规和备份需求),避免磁盘堆积;若需要保留历史版本,则保留旧文件并在数据库中标记版本。
 5. 上传接口需校验文件类型与大小,超限或非法类型返回400,未认证401,IO异常500。
-6. 获取接口根据数据库记录返回本地文件流,若无头像返回默认图片,路径防穿越。
+6. 获取接口根据数据库记录返回本地文件流,若无头像或文件不存在返回 404(当前实现为返回 `ResponseEntity.status(HttpStatus.NOT_FOUND)`),路径防穿越。
 7. 日志记录上传、更新、异常操作,包括userId、操作时间、文件名、IP等,按日志规范分级。
 8. 所有接口响应均为`R<T>`格式,错误用`R.fail`,成功用`R.success`。
 9. Swagger注解需覆盖所有状态码,泛型返回类型需用`@Schema(implementation=...)`。
@@ -68,7 +87,7 @@ public R<String> uploadAvatar(@RequestPart("file") MultipartFile file, HttpServl
   // 生成保存文件名(示例为 userId + 时间戳):
   // String originalFilename = file.getOriginalFilename();
   // String ext = StringUtils.getFilenameExtension(originalFilename); // 或使用 FilenameUtils
-  // String savedFileName = userId + "_" + System.currentTimeMillis() + "." + ext;
+  // String savedFileName = System.currentTimeMillis() + "." + ext; // 项目实现使用 timestamp.ext
   // String relativePath = "user/" + userId + "/" + savedFileName;
   // 保存文件到 avatarRootPath + File.separator + relativePath;
   // 更新数据库:UserInfo.avatar = relativePath;
@@ -84,7 +103,7 @@ public R<String> uploadAvatar(@RequestPart("file") MultipartFile file, HttpServl
 @Operation(summary = "获取用户头像", description = "根据用户ID获取头像图片")
 @ApiResponses(value = {
   @ApiResponse(responseCode = "200", description = "获取成功,返回图片流"),
-  @ApiResponse(responseCode = "404", description = "未找到头像,返回默认图片")
+  @ApiResponse(responseCode = "404", description = "未找到头像或文件不存在,返回404")
 })
 @GetMapping(value = "/user/avatar/{userId}", produces = MediaType.IMAGE_JPEG_VALUE)
 public ResponseEntity<Resource> getAvatar(@PathVariable String userId) {
@@ -92,12 +111,14 @@ public ResponseEntity<Resource> getAvatar(@PathVariable String userId) {
 }
 ```
 
+注意:Controller 在返回头像资源时会尝试使用 `Files.probeContentType` 自动识别 mime type 并设置到返回的 header(若无法识别则回退到默认处理)。
+
 #### 响应示例(上传成功)
 ```json
 {
   "code": 200,
   "message": "上传成功",
-  "data": "/user/avatar/user/123/123_1597891234567.jpg"
+  "data": "123/1597891234567.jpg"
 }
 ```
 
@@ -128,7 +149,7 @@ avatar:
 
 
 ### 统一访问与健壮性
-- 获取头像接口优先查找本地文件(根据avatar字段拼接绝对路径),找不到时可回退到默认头像或兼容历史URL
+- 获取头像接口优先查找本地文件(根据avatar字段拼接绝对路径),找不到时返回404;如需兼容历史URL或默认图片,可在该接口实现中增加回退逻辑
 - avatar字段建议varchar(255)或更长,确保能存储完整相对路径。
 - 开发和测试时可手动插入或模拟avatar字段的不同内容(如空、相对路径、异常值),验证接口健壮性。
 

+ 105 - 17
docs/New/用户行为日志与动态查询功能设计文档.md → docs/OLD/DevDesign/用户行为日志与动态查询功能设计文档.md

@@ -3,8 +3,8 @@
 ## 1. 功能概述
 
 用户行为日志与动态查询功能旨在记录系统中所有用户的关键行为操作,包括所有后端接口的调用(如患者上传数据、医生确认复诊、管理员管理操作等),并基于日志数据为医生提供患者动态查询服务。该功能允许记录用户活动,并根据用户角色提供差异化的查询权限:
-- 医生可查询绑定患者的特定动态(患者健康数据更新、健康档案更新、复诊申请更新)。
-- 管理员可查询所有用户的动态(包括患者、医生、家属、管理员的活动)。
+- 医生可查询绑定患者的特定动态(患者健康数据上传/更新、健康档案更新、复诊申请及回复等)。查询时会校验医生与患者是否存在绑定关系(通过 `UserBindingService.checkUserBinding`)。
+- 管理员可查询所有用户的动态(包括患者、医生、家属、管理员的活动)。管理员查询受 `PermissionGroup.SYS_ADMIN` 控制。
 
 该功能遵循项目数据库规范(参见 `docs/OLD/DevRule/07-数据库规范.md`)和API设计规范(参见 `docs/OLD/DevRule/03-API设计规范.md`),确保日志记录的完整性和查询的安全性。
 
@@ -22,7 +22,7 @@
 | activity_description | TEXT | 活动描述(简要说明操作内容) |
 | related_entity_type | VARCHAR(50) | 相关实体类型(可选,如 BLOOD_GLUCOSE, HEALTH_RECORD, FOLLOW_UP) |
 | related_entity_id | BIGINT(20) | 相关实体ID(可选,用于关联具体记录) |
-| metadata | JSON | 元数据(存储额外信息,如数值、状态变更等,JSON格式) |
+| metadata | JSON | 元数据(存储额外信息,如数值、状态变更等,JSON格式)。服务端在写入时使用 `JsonUtils.toJson(metadata)`,查询时使用 `JsonUtils.fromJson` 反序列化回对象并在 response 中返回。 |
 | ip_address | VARCHAR(45) | 操作IP地址 |
 | user_agent | TEXT | 用户代理字符串 |
 | version | INT(11) | 版本号(乐观锁) |
@@ -80,7 +80,7 @@ import com.baomidou.mybatisplus.annotation.EnumValue;
  */
 public enum ActivityType {
     // 患者健康数据相关
-    BLOOD_GLUCOSE_UPLOAD("BLOOD_GLUCOSE_UPLOAD", "血糖数据上传"),
+为了方便记录用户对系统的所有关键操作,项目使用切面 `UserActivityLogAspect`(调用 `UserBindingService.getBoundPatientIds`):
     BLOOD_PRESSURE_UPLOAD("BLOOD_PRESSURE_UPLOAD", "血压数据上传"),
     HEART_RATE_UPLOAD("HEART_RATE_UPLOAD", "心率数据上传"),
     PHYSICAL_DATA_UPLOAD("PHYSICAL_DATA_UPLOAD", "体格数据上传"),
@@ -158,6 +158,9 @@ public enum ActivityType {
         throw new work.baiyun.chronicdiseaseapp.exception.CustomException(
             work.baiyun.chronicdiseaseapp.enums.ErrorCode.PARAMETER_ERROR.getCode(),
             "Unknown ActivityType code: " + code);
+    
+        Page<UserActivityResponse> queryBoundPatientsActivitiesForDoctor(PatientActivityQueryRequest request);
+        Page<UserActivityResponse> queryBoundFamiliesActivitiesForFamily(PatientActivityQueryRequest request);
     }
 }
 ```
@@ -174,7 +177,7 @@ import com.baomidou.mybatisplus.annotation.EnumValue;
  */
 public enum RelatedEntityType {
     BLOOD_GLUCOSE("BLOOD_GLUCOSE", "血糖数据"),
-    BLOOD_PRESSURE("BLOOD_PRESSURE", "血压数据"),
+### 6.1 UserActivityLogService 接口(调用 `UserBindingService.getBoundPatientIds`)
     HEART_RATE("HEART_RATE", "心率数据"),
     PHYSICAL_DATA("PHYSICAL_DATA", "体格数据"),
     HEALTH_RECORD("HEALTH_RECORD", "健康档案"),
@@ -410,6 +413,18 @@ public enum RelatedEntityType {
             "Unknown RelatedEntityType code: " + code);
     }
 }
+
+## 4. 自动记录(AOP 切面)
+
+为了方便记录用户对系统的所有关键操作,项目使用切面 `UserActivityLogAspect`:
+- 切点:拦截 `work.baiyun.chronicdiseaseapp.controller` 包下的所有控制器方法(排除 `WeChatController.getOpenid`),在方法前后记录日志;
+- 用户判断:若未登录或无法获取 `currentUserId`,不记录日志;避免记录匿名行为;
+- 活动类型推断:通过 `inferActivityType` 根据类名与方法名进行推断(例如 `BloodGlucoseController.addXXX` → `BLOOD_GLUCOSE_UPLOAD`);可根据需要在切面中扩展规则;
+- 相关实体推断:通过 `inferRelatedEntityType` 根据类名映射(如 `FollowUp` 映射 `RelatedEntityType.FOLLOW_UP`);
+- 实体 ID 提取:`extractEntityId` 简化实现 — 从方法参数中找第一个 Long 值并当作关联 ID;若需更精确解析可扩展解析规则;
+- 日志记录:将 `metadata` 使用 `JsonUtils.toJson` 序列化并写入 `t_user_activity_log.metadata` 字段;发生异常时只打印错误,不影响业务逻辑。
+
+注:切面在生产环境可配合异步队列(如 Kafka、RabbitMQ)或微服务日志管道,减少写入主库的开销并提高查询性能。
 ```
 
 ### 3.3 枚举类型处理器
@@ -586,6 +601,13 @@ public interface UserActivityLogService {
 
 实现日志记录和查询逻辑,包括权限校验(医生只能查绑定患者,管理员查所有)。使用 `JsonUtils` 工具类处理 `metadata` 字段的JSON序列化/反序列化。
 
+查询流程(医生查询患者动态)简述:
+1. 校验用户角色为 `PermissionGroup.DOCTOR`。
+2. 校验医生与患者是否绑定(`UserBindingService.checkUserBinding`),如未绑定返回拒绝访问。
+3. 根据服务端允许的活动类型列表(`allowedTypes`)过滤 `activity_type`;若前端传入活动类型集合,则取交集(`queryTypes`),无交集则返回空页。
+4. 使用 MyBatis-Plus `QueryWrapper` 构造 `user_id` 和 `activity_type IN (...)` 条件,并按 `create_time` 倒序返回分页结果。
+5. 使用 `JsonUtils.fromJson` 把 `metadata` 反序列化为对象,填充 `UserActivityResponse` 返回。
+
 ```java
 package work.baiyun.chronicdiseaseapp.service.impl;
 
@@ -644,21 +666,50 @@ public class UserActivityLogServiceImpl implements UserActivityLogService {
             throw new RuntimeException("无权限");
         }
         
-        // 校验绑定关系
-        boolean isBound = userBindingService.checkBinding(currentUserId, request.getPatientUserId());
-        if (!isBound) {
+        // 校验绑定关系(调用 `UserBindingService.checkUserBinding`)
+        CheckUserBindingRequest checkReq = new CheckUserBindingRequest();
+        checkReq.setPatientUserId(request.getPatientUserId());
+        checkReq.setBoundUserId(currentUserId);
+        CheckUserBindingResponse checkResp = userBindingService.checkUserBinding(checkReq);
+        if (!checkResp.getExists()) {
             throw new RuntimeException("未绑定该患者");
         }
         
         // 过滤特定活动类型(患者健康数据、健康档案、复诊)
+        // 允许医生查询的动态类型(仅限医疗相关业务数据)
         List<ActivityType> allowedTypes = List.of(
-            ActivityType.BLOOD_GLUCOSE_UPLOAD, ActivityType.HEALTH_RECORD_UPDATE, 
-            ActivityType.FOLLOW_UP_CREATE, ActivityType.FOLLOW_UP_UPDATE
+            ActivityType.BLOOD_GLUCOSE_UPLOAD, ActivityType.BLOOD_GLUCOSE_UPDATE, ActivityType.BLOOD_GLUCOSE_DELETE,
+            ActivityType.BLOOD_PRESSURE_UPLOAD,
+            ActivityType.HEART_RATE_UPLOAD,
+            ActivityType.PHYSICAL_DATA_UPLOAD,
+            ActivityType.HEALTH_RECORD_CREATE, ActivityType.HEALTH_RECORD_UPDATE,
+            ActivityType.FOLLOW_UP_CREATE, ActivityType.FOLLOW_UP_UPDATE, ActivityType.FOLLOW_UP_CONFIRM
         );
+
+        // 允许医生查询的类型(列举)
+        // 注意:ActivityType.OTHER_OPERATION 不包含在 allowedTypes 中,表示无法推断或通用的日志项不会出现在医生视图中;若需查看全部活动类型(含 OTHER_OPERATION),使用管理员接口 `queryAllActivities`。
+        // - BLOOD_GLUCOSE_UPLOAD / BLOOD_GLUCOSE_UPDATE / BLOOD_GLUCOSE_DELETE
+        // - BLOOD_PRESSURE_UPLOAD
+        // - HEART_RATE_UPLOAD
+        // - PHYSICAL_DATA_UPLOAD
+        // - HEALTH_RECORD_CREATE / HEALTH_RECORD_UPDATE
+        // - FOLLOW_UP_CREATE / FOLLOW_UP_UPDATE / FOLLOW_UP_CONFIRM
         
+        // 如果前端指定了活动类型,则在 allowedTypes 中取交集(queryTypes),否则使用 allowedTypes
+        List<ActivityType> queryTypes = allowedTypes;
+        if (request.getActivityTypes() != null && !request.getActivityTypes().isEmpty()) {
+            queryTypes = request.getActivityTypes().stream()
+                .filter(allowedTypes::contains)
+                .collect(Collectors.toList());
+            // 过滤后没有匹配的类型,返回空结果
+            if (queryTypes.isEmpty()) {
+                return new Page<>(request.getCurrent(), request.getSize());
+            }
+        }
+
         QueryWrapper<UserActivityLog> wrapper = new QueryWrapper<>();
         wrapper.eq("user_id", request.getPatientUserId())
-               .in("activity_type", allowedTypes)
+               .in("activity_type", queryTypes)
                .orderByDesc("create_time");
         
         Page<UserActivityLog> page = userActivityLogMapper.selectPage(
@@ -685,12 +736,16 @@ public class UserActivityLogServiceImpl implements UserActivityLogService {
         return resultPage;
     }
 
+    // 说明:
+    // - 分页查询基于 MyBatis-Plus 的 `selectPage(Page, QueryWrapper)` 实现,Page 对象负责分页参数与总数。
+    // - 查询总是按 `create_time` 倒序返回,前端需按此规则展示最近的活动在前。
+
     @Override
     public Page<UserActivityResponse> queryAllActivities(PatientActivityQueryRequest request) {
         PermissionGroup role = SecurityUtils.getCurrentUserRole();
         
-        // 权限校验:仅管理员
-        if (!PermissionGroup.ADMIN.equals(role)) {
+        // 权限校验:仅系统管理员(SYS_ADMIN)
+        if (!PermissionGroup.SYS_ADMIN.equals(role)) {
             throw new RuntimeException("无权限");
         }
         
@@ -806,12 +861,45 @@ public class UserActivityLogController {
         }
     }
 }
+
+## 8. 接口调用示例
+
+### 8.1 医生查询绑定患者动态
+```
+POST /user-activity/query-patient-activities
+Content-Type: application/json
+
+{
+    "patientUserId": 1988172854356631553,
+    "pageNum": 1,
+    "pageSize": 10,
+    "activityTypes": ["BLOOD_GLUCOSE_UPLOAD", "FOLLOW_UP_CREATE"],
+    "startTime": "2025-11-01T00:00:00",
+    "endTime": "2025-12-01T23:59:59"
+}
+```
+
+说明:若 `activityTypes` 中的类型不是服务端允许的类型(医疗数据/健康档案/复诊),该类型会被忽略;若交集为空,返回空结果。
+
+### 8.2 管理员查询所有动态
+```
+POST /user-activity/query-all-activities
+Content-Type: application/json
+
+{
+    "userId": 1988196438584090626,
+    "pageNum": 1,
+    "pageSize": 20
+}
+```
+
+说明:该接口仅对 `PermissionGroup.SYS_ADMIN` 开放。
 ```
 
 ## 8. 权限控制
 
 - **医生权限**:通过 UserBindingService 校验医生与患者的绑定关系,仅允许查询绑定患者的特定动态(健康数据、健康档案、复诊相关)。
-- **管理员权限**:通过角色校验(PermissionGroup.ADMIN),允许查询所有用户的动态。
+- **管理员权限**:通过角色校验(`PermissionGroup.SYS_ADMIN`),允许查询所有用户的动态。
 - 在 Service 层实现权限逻辑,避免在 Controller 层硬编码。
 
 ## 9. 日志记录集成
@@ -843,7 +931,7 @@ public class UserActivityLogAspect {
     @Autowired
     private UserActivityLogService userActivityLogService;
 
-    @Pointcut("execution(* work.baiyun.chronicdiseaseapp.controller.*.*(..)) && !execution(* work.baiyun.chronicdiseaseapp.controller.UserActivityLogController.*(..))")
+    @Pointcut("execution(* work.baiyun.chronicdiseaseapp.controller..*(..)) && !execution(* work.baiyun.chronicdiseaseapp.controller.WeChatController.getOpenid(..))")
     public void controllerMethods() {}
 
     @Around("controllerMethods()")
@@ -853,11 +941,11 @@ public class UserActivityLogAspect {
             return joinPoint.proceed();
         }
 
-        // 获取方法信息
+        // 获取方法信息(实际实现还会通过 RequestContextHolder 获取 IP/User-Agent)
         String methodName = joinPoint.getSignature().getName();
         String className = joinPoint.getTarget().getClass().getSimpleName();
         
-        // 映射活动类型(可根据方法名或注解动态确定
+        // 映射活动类型(实际实现用 inferActivityType 更细粒度地基于类名与方法名匹配活动类型
         ActivityType activityType = mapMethodToActivityType(className, methodName);
         if (activityType == null) {
             return joinPoint.proceed();

+ 14 - 13
docs/New/用药管理功能设计文档.md → docs/OLD/DevDesign/用药管理功能设计文档.md

@@ -486,7 +486,7 @@ import work.baiyun.chronicdiseaseapp.model.vo.MedicationQueryRequest;
 import work.baiyun.chronicdiseaseapp.model.vo.MedicationResponse;
 import work.baiyun.chronicdiseaseapp.model.vo.CreateMedicationRequest;
 import work.baiyun.chronicdiseaseapp.model.vo.UpdateMedicationRequest;
-import work.baiyun.chronicdiseaseapp.service.MedicationService;
+import work.baiyun.chronicdiseaseapp.service.PatientMedicationService;
 import work.baiyun.chronicdiseaseapp.enums.ErrorCode;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -498,10 +498,10 @@ import java.util.List;
 @Tag(name = "用药管理", description = "用药管理相关接口")
 public class PatientMedicationController {
 
-    private static final Logger logger = LoggerFactory.getLogger(MedicationController.class);
+    private static final Logger logger = LoggerFactory.getLogger(PatientMedicationController.class);
 
     @Autowired
-    private MedicationService medicationService;
+    private PatientMedicationService medicationService;
 
     @Operation(summary = "创建用药记录", description = "创建新的用药记录")
     @ApiResponses(value = {
@@ -686,7 +686,8 @@ public class JsonUtils {
 
 ```java
 // 在 ErrorCode.java 中添加
-MEDICATION_NOT_FOUND(8000, "用药记录不存在");
+// NOTE: 项目代码中已存在错误码 `MEDICATION_NOT_FOUND(7000, "用药记录不存在")`。
+// 当前实现:`PatientMedicationServiceImpl` 在找不到记录时抛出 `ErrorCode.DATA_NOT_FOUND`(4000)。建议:把 `DATA_NOT_FOUND` 替换为更明确的 `MEDICATION_NOT_FOUND`,便于客户端定位错误类型与处理。
 ```
 
 ## 9. 接口调用示例
@@ -751,18 +752,18 @@ GET /patient-medication/patient/123456
 ## 10. 权限设计
 
 1. **患者**:
-   - 可以查看自己的用药记录
-   - 可以添加新的用药记录
-   - 可以编辑自己的用药记录
-   - 可以删除自己的用药记录
+    - 目标:仅允许患者查看/管理本人的用药记录。
+    - 当前实现:接口需要登录(由 `AuthInterceptor` 校验),但 `PatientMedicationService` 并未强制检查 `currentUserId` 是否与 `patientUserId` 一致——因此前端/调用方需在调用时传入正确的患者ID。
+    - 建议:服务层或控制层显式校验 `SecurityUtils.getCurrentUserId()` 是否等于 `patientUserId`,以防止越权写入或修改。
 
 2. **医生**:
-   - 可以查看患者的用药记录
-   - 可以添加、编辑或删除患者的用药记录
+    - 目标:医生可查看其绑定患者的用药记录(只读或写权限视业务而定)。
+    - 当前实现:控制器与服务中未检查医生角色或绑定关系,理论上任何登录用户传入患者 ID 都可进行增删改查。
+    - 建议:在医生操作的接口中(或在服务层)增加绑定校验 `UserBindingService.checkUserBinding`,并在必要时校验 `PermissionGroup.DOCTOR`。
 
 3. **系统管理员**:
-   - 具有患者和医生的所有权限
-   - 可以管理所有用药记录
+    - 具有全局权限,可以查询和管理所有用药记录(基于 `PermissionGroup.SYS_ADMIN` 校验)。
+    - 建议:管理员接口或逻辑分支在查询大规模数据时需引入审计与分页限制,避免误操作带来数据风险。
 
 ## 11. 安全考虑
 
@@ -779,7 +780,7 @@ GET /patient-medication/patient/123456
 
 3. **异常与统一错误响应**:
     - 使用全局异常处理器(`@RestControllerAdvice`/`CustomExceptionHandler`)统一映射 `CustomException`、校验异常、未知异常到 `R.fail(code, message)`,并返回合适的 HTTP 状态码(如 400/403/404/500)。
-    - 业务异常(如 `MEDICATION_NOT_FOUND`)应使用明确错误码并在 `ErrorCode` 中定义。
+    - 业务异常(如 `MEDICATION_NOT_FOUND`)应使用明确错误码并在 `ErrorCode` 中定义(项目中已存在 `MEDICATION_NOT_FOUND(7000, "用药记录不存在")`)
 
 4. **日志与敏感信息**:
     - 在日志中避免记录完整 Token 或敏感字段;若需记录,脱敏或仅记录前 8 位。

+ 11 - 2
docs/New/药品信息管理功能设计文档.md → docs/OLD/DevDesign/药品信息管理功能设计文档.md

@@ -27,6 +27,8 @@
 - 使用 `BIGINT` 主键并通过雪花算法生成(MyBatis-Plus `ASSIGN_ID`)。
 - 公共字段(`version`、`create_user`、`create_time`、`update_user`、`update_time`)由 `BaseEntity` 与 `CustomMetaObjectHandler` 自动填充。
 - 根据需要添加索引以提高查询效率。
+ - `name` 建议添加唯一索引以保证药品名称唯一性(示例中已经有 `UNIQUE KEY uk_name`);创建/更新时再做服务端二次校验以保证一致性。
+ - `pinyin_first_letters` 会在服务层统一做小写处理(`String::toLowerCase`),以便在关键字搜索中简化匹配规则。
 - 数据迁移建议使用 Flyway/Liquibase(脚本命名遵循 `V{version}__{description}.sql`)。
 
 示例建表(参考):
@@ -308,6 +310,7 @@ public class MedicineServiceImpl implements MedicineService {
     public void createMedicine(CreateMedicineRequest request) {
         // 检查药品名称是否已存在
         LambdaQueryWrapper<Medicine> queryWrapper = new LambdaQueryWrapper<>();
+        // 注意:名称检验使用精确匹配(与数据库字符集相关),同时数据库也建议使用唯一约束防止重复
         queryWrapper.eq(Medicine::getName, request.getName());
         if (medicineMapper.selectCount(queryWrapper) > 0) {
             throw new work.baiyun.chronicdiseaseapp.exception.CustomException(
@@ -317,6 +320,7 @@ public class MedicineServiceImpl implements MedicineService {
 
         Medicine medicine = new Medicine();
         BeanUtils.copyProperties(request, medicine);
+        // 服务中统一把拼音首字母转成小写,便于搜索和匹配
         medicine.setPinyinFirstLetters(request.getPinyinFirstLetters().toLowerCase());
         medicineMapper.insert(medicine);
     }
@@ -352,6 +356,7 @@ public class MedicineServiceImpl implements MedicineService {
 
         // 根据关键字搜索(药品名称或拼音首字母)
         if (request.getKeyword() != null && !request.getKeyword().trim().isEmpty()) {
+            // 将关键字统一小写后再比对 `name` 与 `pinyin_first_letters` 字段(`pinyin_first_letters` 在插入/更新时已被小写化)
             String keyword = request.getKeyword().trim().toLowerCase();
             wrapper.and(w -> w.like(Medicine::getName, keyword)
                               .or()
@@ -558,10 +563,14 @@ public class MedicineController {
 
 ## 7. 错误码补充
 
-在 ErrorCode 枚举中可能需要添加以下错误码:
+在目前实现中:
+- 创建或更新时,重复名称校验会抛出 `ErrorCode.PARAMETER_ERROR`(参数错误),消息为 "药品名称已存在"。
+- 查询或获取单个药品时,找不到记录会抛出 `ErrorCode.DATA_NOT_FOUND`(数据不存在)。
+
+如果希望为这类错误提供更清晰的前端区分或监控告警,可以按需在 `ErrorCode.java` 中额外添加如下错误码(可选):
 
 ```java
-// 在 ErrorCode.java 中添加
+// 在 ErrorCode.java 中添加(可选)
 MEDICINE_NOT_FOUND(7000, "药品信息不存在"),
 MEDICINE_NAME_EXISTS(7001, "药品名称已存在");
 ```