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

feat(api): 实现绑定用户查询患者健康数据接口

- 在 BloodGlucoseDataController、BloodPressureDataController、HeartRateDataController 和 PhysicalDataController 中添加 `/list-by-bound-user` 接口
- 新增 service 层方法 `listDataByPatient` 支持通过 patientUserId 查询数据
- 增加绑定关系校验逻辑,确保只有已绑定的医生或家属才能访问数据
- 为家属类型绑定增加时间窗口限制(默认365天内)
- 所有接口统一返回 Page 分页结构,并兼容前端 ID 精度要求(字符串类型)
- 添加审计日志记录访问行为,包括 boundUserId、patientUserId、查询时间段等信息
- 补充单元测试覆盖绑定与非绑定情况下的接口行为验证
mcbaiyun 1 месяц назад
Родитель
Сommit
b588cc4be3

+ 141 - 0
docs/DevDesign/查询绑定患者健康数据设计.md

@@ -0,0 +1,141 @@
+# 查询绑定患者健康数据设计
+
+## 概述
+当患者与医生/家属建立绑定关系后,允许被绑定的医生或家属查询该患者的健康数据(血糖、血压、心率、体征等)。本设计描述目标、接口、权限校验、实现要点、测试与文档规范,并参照项目已经存在的经验文档(如 ID 精度、Swagger 泛型文档化)。
+
+## 背景和目标
+- 背景:已有用户绑定(患者 ↔ 医生/家属)功能,且患者在系统内可记录多类健康数据。为了满足医生/家属随访与监护需求,需要允许绑定方访问患者数据。
+- 目标:在保证安全、最小权限原则、并兼顾 API 统一性的前提下实现“绑定方查询患者健康数据”接口。
+
+## 需求与约束
+1. 认证:接口需走已有的Token认证(AuthInterceptor),并依赖 request attribute 中的 `currentUserId`。
+2. 绑定检查:接口必须校验当前请求用户(boundUser)与目标患者(patientUserId)之间是否存在有效绑定关系(`UserBindingService.checkUserBinding`)。
+3. 权限粒度:区分绑定类型(DOCTOR / FAMILY),医生和家属的可见字段或时间范围可不同(设计建议见下)。
+4. 日志审计:所有第三方查询患者数据的调用要记录访问日志,便于审计。
+5. API 兼容:使用与现有分页查询一致的参数和返回格式(`R<T>` + `Page<T>` VO)。
+6. 文档与前端:参照 `docs/Swagger泛型返回类型字段信息不显示问题解决方案.md`,在 Controller 层使用 `@ApiResponse` 与 `@Schema(implementation = ...)` 标注返回类型;参照 `docs/前端ID精度丢失问题解决方案.md` 将 id 字段返回为 string。
+
+## 设计要点(高层)
+1. 在每类健康数据 Controller(`BloodGlucoseDataController`, `BloodPressureDataController`, `HeartRateDataController`, `PhysicalDataController` 等)增加接口 `list-by-bound-user`:
+   - HTTP 方法:POST(与现有 list 同形式),路径示例:`/blood-glucose/list-by-bound-user`。
+   - 参数:`patientUserId`(Long)、`bindingType`(String, 可选)、`BaseQueryRequest`(分页、时间范围)
+   - 身份鉴权:不允许在 body 内传入 boundUserId,使用 `AuthInterceptor` 提供的 `currentUserId` 来判断请求人的身份。
+   - 核心逻辑:先 `userBindingService.checkUserBinding(patientUserId, currentUserId)`,若不存在返回 `R.fail(ErrorCode.DATA_ACCESS_DENIED)`;否则继续查询并返回数据。
+
+2. Service 层提供 `listDataByPatient(patientUserId, BaseQueryRequest)` 等方法,返回 `Page<TResponse>` 供 Controller 组装对应 PageResponse VO。
+
+3. 权限策略(建议):
+   - DOCTOR:默认具有查询所有字段的权限;可以查看所有历史数据(受分页限制)。
+   - FAMILY:只允许查询近 N 天(例如 365 天内)或限制敏感字段(如“医生诊断备注”等),并在返回字段中做适当脱敏/限制。绑定关系可在 `CreateUserBindingRequest` 中声明。
+
+4. 审计日志:
+   - 在 Controller 的 `list-by-bound-user` 接口中记录 `boundUserId`, `patientUserId`, `queryType`, `startTime`, `endTime`,写入到 regular logs 或独立审计表(参考 `docs/DevRule/06-日志和错误处理规范.md`)。
+
+## API 设计示例
+
+示例 - 血糖数据:
+
+Controller 片段(示例,仅说明用法)
+
+````java
+@Operation(summary = "医生/家属分页查询患者血糖数据", description = "绑定方查询患者血糖测量记录(需有绑定关系)")
+@ApiResponses(value = {
+	@ApiResponse(responseCode = "200", description = "成功查询血糖数据列表",
+		content = @Content(mediaType = "application/json",
+			schema = @Schema(implementation = work.baiyun.chronicdiseaseapp.model.vo.BloodGlucoseDataPageResponse.class))),
+	@ApiResponse(responseCode = "403", description = "无权限访问",
+		content = @Content(mediaType = "application/json",
+			schema = @Schema(implementation = Void.class)))
+})
+@PostMapping(path = "/list-by-bound-user", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+public R<?> listByBoundUser(Long patientUserId, String bindingType, @RequestBody BaseQueryRequest req, HttpServletRequest request) {
+	Long boundUserId = (Long) request.getAttribute("currentUserId");
+
+	// 1. 校验参数
+	if (patientUserId == null) {
+		return R.fail(ErrorCode.PARAMETER_ERROR.getCode(), ErrorCode.PARAMETER_ERROR.getMessage());
+	}
+
+	// 2. 校验绑定关系
+	CheckUserBindingRequest checkReq = new CheckUserBindingRequest();
+	checkReq.setPatientUserId(patientUserId);
+	checkReq.setBoundUserId(boundUserId);
+	CheckUserBindingResponse checkResp = userBindingService.checkUserBinding(checkReq);
+	if (!checkResp.getExists()) {
+		return R.fail(ErrorCode.DATA_ACCESS_DENIED.getCode(), "当前用户与目标患者未建立绑定关系");
+	}
+
+	// 3. 依据权限决定查询逻辑(可在 service 层统一处理)
+	Page<BloodGlucoseDataResponse> page = service.listBloodGlucoseDataByPatient(patientUserId, bindingType, req);
+	// 下面同 list 接口组装 PageResponse,返回
+}
+````
+
+Service 层新增方法签名示例:
+
+````java
+Page<BloodGlucoseDataResponse> listBloodGlucoseDataByPatient(Long patientUserId, String bindingType, BaseQueryRequest request);
+````
+
+实现要点:
+- 在 mapper 查询条件中替换 `eq(BloodGlucoseData::getUserId, userId)` 为 patientUserId。
+- 对 FAMILY 限制时间范围(例如:request.setStartTime >= now - 365 天);或在 mapper 层追加条件。
+
+## Swagger 与 API 文档
+- 统一使用 `@ApiResponse` + `@Content` + `@Schema(implementation = ...)` 来避免泛型类型在文档中缺失(参考 docs/Swagger泛型返回类型...)。
+- 所有 VO 的 id 字段在响应层转换成 `String`(参见 docs/前端ID精度...)。
+
+示例:
+
+````java
+@ApiResponse(responseCode = "200", description = "查询成功",
+	content = @Content(mediaType = "application/json",
+		schema = @Schema(implementation = work.baiyun.chronicdiseaseapp.model.vo.BloodGlucoseDataPageResponse.class)))
+````
+
+## 权限/业务细节与差异化策略
+1. 当 `bindingType = DOCTOR`:
+   - 默认开放全部字段及历史记录(分页受 request 限制)。
+2. 当 `bindingType = FAMILY`:
+   - 可限定时间窗口(例如 365 天)和/或禁止返回某些敏感字段(如直接的医疗诊断、医生备注),也可在 VO 中对字段进行脱敏。
+3. 若 `bindingType` 为空:由 `userBindingService.checkUserBinding` 返回的绑定类型决定权限。
+
+实现小技巧:在 `UserBindingService` 中返回绑定类型(CheckUserBindingResponse 已有),Controller/Service 使用该返回值作为权限判断依据。
+
+## 日志 & 审计
+1. 写入标准访问日志,格式包括 `boundUserId`, `patientUserId`, `bindingType`, `operation`, `queryStart`, `queryEnd`, `resultCount`。
+2. 如需合规审计,可在数据库中建立审计表 `patient_data_access_log` 记录详细调用历史;字段示例:id、access_time、accessor_id、patient_id、data_type、start_time、end_time、ip_address。
+
+## 测试建议
+1. 单元测试:模拟 UserBindingService 返回不同绑定关系,验证 `listByBoundUser` 返回或拒绝的逻辑(正常与未绑定)。
+2. 集成测试:启动应用(或测试环境),分别用 doctor / family 的 token 发起查询请求,校验返回结果字段及分页。注意验证 ID 字段为字符串。
+3. 性能测试:对长时间范围查询与频繁查询场景做压测,评估 DB 索引是否充分;若发现慢查询,建议加索引(例如测量时间 idx)或靠缓存(例如热点患者数据)优化。
+
+## 兼容性与前端要求
+1. 前端应使用 token 登录并把 token 放在 `Authorization: Bearer <token>` 中。AuthInterceptor 将解析并注入 `currentUserId`。
+2. 前端需要了解 `id` 字段为字符串,不要当作 Number 处理(参考 docs/前端ID精度...)。
+3. 文档中明确标注字段是否对家属脱敏或受时间窗口限制。
+
+## 迁移/变更管理
+- 该功能应由小步提交:先实现后端接口和测试,再在 Swagger 文档中公开,最后通知前端(以便正确处理 id 字符串与权限错误)。
+
+## 开发任务清单
+1. Controller:在 `BloodGlucoseDataController`, `BloodPressureDataController`, `HeartRateDataController`, `PhysicalDataController` 等实现 `list-by-bound-user`。
+2. Service:添加 `listByPatient` 变体并对 bindingType 做权限过滤。
+3. Mapper:如必要,添加 `AND user_id = #{patientUserId}` 的查询条件。
+4. Unit / Integration Tests:覆盖权限校验、数据返回与分页。
+5. 日志 & 审计:实现访问日志或可选审计表。
+
+## 参考
+- docs/前端ID精度丢失问题解决方案.md
+- docs/Swagger泛型返回类型字段信息不显示问题解决方案.md
+- docs/DevRule/03-API设计规范.md
+- UserBindingController.java / UserBindingService.java
+
+## 责任人
+- 设计:后端开发团队
+- 实现:后端 API 开发者
+- 联调:后端 + 前端
+
+以上为实现“绑定方查询患者健康数据”功能的设计文档草案,若需要我可以继续生成 Controller / Service 的代码模版以及对应的测试用例。
+

+ 53 - 0
src/main/java/work/baiyun/chronicdiseaseapp/controller/BloodGlucoseDataController.java

@@ -32,6 +32,8 @@ public class BloodGlucoseDataController {
 
     @Autowired
     private BloodGlucoseDataService service;
+    @Autowired
+    private work.baiyun.chronicdiseaseapp.service.UserBindingService userBindingService;
 
     @Operation(summary = "添加血糖数据", description = "添加血糖测量记录,包含测量类型和值")
     @ApiResponses(value = {
@@ -80,6 +82,57 @@ public class BloodGlucoseDataController {
         }
     }
 
+    @Operation(summary = "医生/家属分页查询患者血糖数据", description = "绑定方查询患者血糖测量记录(需有绑定关系)")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "成功查询血糖数据列表",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = work.baiyun.chronicdiseaseapp.model.vo.BloodGlucoseDataPageResponse.class))),
+        @ApiResponse(responseCode = "403", description = "无权限访问",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = Void.class)))
+    })
+    @PostMapping(path = "/list-by-bound-user", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> listByBoundUser(Long patientUserId, String bindingType, @RequestBody BaseQueryRequest req) {
+        try {
+            // 获取当前发起查询的用户(绑定方)
+            Long boundUserId = work.baiyun.chronicdiseaseapp.util.SecurityUtils.getCurrentUserId();
+
+            // 1. 参数校验
+            if (patientUserId == null) {
+                return R.fail(ErrorCode.PARAMETER_ERROR.getCode(), ErrorCode.PARAMETER_ERROR.getMessage());
+            }
+
+            // 2. 校验绑定关系
+            work.baiyun.chronicdiseaseapp.model.vo.CheckUserBindingRequest checkReq = new work.baiyun.chronicdiseaseapp.model.vo.CheckUserBindingRequest();
+            checkReq.setPatientUserId(patientUserId);
+            checkReq.setBoundUserId(boundUserId);
+            work.baiyun.chronicdiseaseapp.model.vo.CheckUserBindingResponse checkResp = userBindingService.checkUserBinding(checkReq);
+            if (!Boolean.TRUE.equals(checkResp.getExists())) {
+                return R.fail(ErrorCode.DATA_ACCESS_DENIED.getCode(), "当前用户与目标患者未建立绑定关系");
+            }
+            if ((bindingType == null || bindingType.isEmpty()) && checkResp.getBindingType() != null) {
+                bindingType = checkResp.getBindingType();
+            }
+
+            // 日志审计:记录访问(简化写入普通日志)
+            logger.info("patient data access - type=blood_glucose, boundUserId={}, patientUserId={}, startTime={}, endTime={}", boundUserId, patientUserId, req.getStartTime(), req.getEndTime());
+
+            // 依据绑定类型可在 service 层进行权限限制
+            Page<BloodGlucoseDataResponse> page = service.listBloodGlucoseDataByPatient(patientUserId, bindingType, req);
+            work.baiyun.chronicdiseaseapp.model.vo.BloodGlucoseDataPageResponse vo = new work.baiyun.chronicdiseaseapp.model.vo.BloodGlucoseDataPageResponse();
+            vo.setRecords(page.getRecords());
+            vo.setTotal(page.getTotal());
+            vo.setSize(page.getSize());
+            vo.setCurrent(page.getCurrent());
+            vo.setOptimizeCountSql(page.optimizeCountSql());
+            vo.setPages(page.getPages());
+            return R.success(200, "ok", vo);
+        } catch (Exception e) {
+            logger.error("list blood glucose data by bound user failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
     @Operation(summary = "删除血糖数据", description = "根据ID删除血糖测量记录")
     @ApiResponses(value = {
         @ApiResponse(responseCode = "200", description = "血糖数据删除成功",

+ 47 - 0
src/main/java/work/baiyun/chronicdiseaseapp/controller/BloodPressureDataController.java

@@ -32,6 +32,8 @@ public class BloodPressureDataController {
 
     @Autowired
     private BloodPressureDataService service;
+    @Autowired
+    private work.baiyun.chronicdiseaseapp.service.UserBindingService userBindingService;
 
     @Operation(summary = "添加血压数据", description = "添加收缩压和舒张压的测量记录")
     @ApiResponses(value = {
@@ -80,6 +82,51 @@ public class BloodPressureDataController {
         }
     }
 
+    @Operation(summary = "医生/家属分页查询患者血压数据", description = "绑定方查询患者血压测量记录(需有绑定关系)")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "成功查询血压数据列表",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = work.baiyun.chronicdiseaseapp.model.vo.BloodPressureDataPageResponse.class))),
+        @ApiResponse(responseCode = "403", description = "无权限访问",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = Void.class)))
+    })
+    @PostMapping(path = "/list-by-bound-user", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> listByBoundUser(Long patientUserId, String bindingType, @RequestBody BaseQueryRequest req) {
+        try {
+            Long boundUserId = work.baiyun.chronicdiseaseapp.util.SecurityUtils.getCurrentUserId();
+            if (patientUserId == null) {
+                return R.fail(ErrorCode.PARAMETER_ERROR.getCode(), ErrorCode.PARAMETER_ERROR.getMessage());
+            }
+            work.baiyun.chronicdiseaseapp.model.vo.CheckUserBindingRequest checkReq = new work.baiyun.chronicdiseaseapp.model.vo.CheckUserBindingRequest();
+            checkReq.setPatientUserId(patientUserId);
+            checkReq.setBoundUserId(boundUserId);
+            work.baiyun.chronicdiseaseapp.model.vo.CheckUserBindingResponse checkResp = userBindingService.checkUserBinding(checkReq);
+            if (!Boolean.TRUE.equals(checkResp.getExists())) {
+                return R.fail(ErrorCode.DATA_ACCESS_DENIED.getCode(), "当前用户与目标患者未建立绑定关系");
+            }
+            if ((bindingType == null || bindingType.isEmpty()) && checkResp.getBindingType() != null) {
+                bindingType = checkResp.getBindingType();
+            }
+
+            logger.info("patient data access - type=blood_pressure, boundUserId={}, patientUserId={}, startTime={}, endTime={}", boundUserId, patientUserId, req.getStartTime(), req.getEndTime());
+
+            Page<BloodPressureDataResponse> page = service.listBloodPressureDataByPatient(patientUserId, bindingType, req);
+            work.baiyun.chronicdiseaseapp.model.vo.BloodPressureDataPageResponse vo = new work.baiyun.chronicdiseaseapp.model.vo.BloodPressureDataPageResponse();
+            vo.setRecords(page.getRecords());
+            vo.setTotal(page.getTotal());
+            vo.setSize(page.getSize());
+            vo.setCurrent(page.getCurrent());
+            vo.setOptimizeCountSql(page.optimizeCountSql());
+            vo.setPages(page.getPages());
+            return R.success(200, "ok", vo);
+
+        } catch (Exception e) {
+            logger.error("list blood pressure data by bound user failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
     @Operation(summary = "删除血压数据", description = "根据ID删除血压测量记录")
     @ApiResponses(value = {
         @ApiResponse(responseCode = "200", description = "血压数据删除成功",

+ 46 - 0
src/main/java/work/baiyun/chronicdiseaseapp/controller/HeartRateDataController.java

@@ -32,6 +32,8 @@ public class HeartRateDataController {
 
     @Autowired
     private HeartRateDataService service;
+    @Autowired
+    private work.baiyun.chronicdiseaseapp.service.UserBindingService userBindingService;
 
     @Operation(summary = "添加心率数据", description = "添加心率测量记录")
     @ApiResponses(value = {
@@ -80,6 +82,50 @@ public class HeartRateDataController {
         }
     }
 
+    @Operation(summary = "医生/家属分页查询患者心率数据", description = "绑定方查询患者心率测量记录(需有绑定关系)")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "成功查询心率数据列表",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = work.baiyun.chronicdiseaseapp.model.vo.HeartRateDataPageResponse.class))),
+        @ApiResponse(responseCode = "403", description = "无权限访问",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = Void.class)))
+    })
+    @PostMapping(path = "/list-by-bound-user", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> listByBoundUser(Long patientUserId, String bindingType, @RequestBody BaseQueryRequest req) {
+        try {
+            Long boundUserId = work.baiyun.chronicdiseaseapp.util.SecurityUtils.getCurrentUserId();
+            if (patientUserId == null) {
+                return R.fail(ErrorCode.PARAMETER_ERROR.getCode(), ErrorCode.PARAMETER_ERROR.getMessage());
+            }
+            work.baiyun.chronicdiseaseapp.model.vo.CheckUserBindingRequest checkReq = new work.baiyun.chronicdiseaseapp.model.vo.CheckUserBindingRequest();
+            checkReq.setPatientUserId(patientUserId);
+            checkReq.setBoundUserId(boundUserId);
+            work.baiyun.chronicdiseaseapp.model.vo.CheckUserBindingResponse checkResp = userBindingService.checkUserBinding(checkReq);
+            if (!Boolean.TRUE.equals(checkResp.getExists())) {
+                return R.fail(ErrorCode.DATA_ACCESS_DENIED.getCode(), "当前用户与目标患者未建立绑定关系");
+            }
+            if ((bindingType == null || bindingType.isEmpty()) && checkResp.getBindingType() != null) {
+                bindingType = checkResp.getBindingType();
+            }
+
+            logger.info("patient data access - type=heart_rate, boundUserId={}, patientUserId={}, startTime={}, endTime={}", boundUserId, patientUserId, req.getStartTime(), req.getEndTime());
+
+            Page<HeartRateDataResponse> page = service.listHeartRateDataByPatient(patientUserId, bindingType, req);
+            work.baiyun.chronicdiseaseapp.model.vo.HeartRateDataPageResponse vo = new work.baiyun.chronicdiseaseapp.model.vo.HeartRateDataPageResponse();
+            vo.setRecords(page.getRecords());
+            vo.setTotal(page.getTotal());
+            vo.setSize(page.getSize());
+            vo.setCurrent(page.getCurrent());
+            vo.setOptimizeCountSql(page.optimizeCountSql());
+            vo.setPages(page.getPages());
+            return R.success(200, "ok", vo);
+        } catch (Exception e) {
+            logger.error("list heart rate data by bound user failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
     @Operation(summary = "删除心率数据", description = "根据ID删除心率测量记录")
     @ApiResponses(value = {
         @ApiResponse(responseCode = "200", description = "心率数据删除成功",

+ 60 - 0
src/main/java/work/baiyun/chronicdiseaseapp/controller/PhysicalDataController.java

@@ -32,6 +32,8 @@ public class PhysicalDataController {
 
     @Autowired
     private PhysicalDataService physicalDataService;
+    @Autowired
+    private work.baiyun.chronicdiseaseapp.service.UserBindingService userBindingService;
 
     @Operation(summary = "添加体格数据", description = "添加身高/体重等体格测量记录")
     @ApiResponses(value = {
@@ -96,6 +98,64 @@ public class PhysicalDataController {
         }
     }
 
+    @Operation(summary = "医生/家属分页查询患者体格数据", description = "绑定方查询患者体格测量记录(需有绑定关系)")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "成功查询体格数据列表",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = work.baiyun.chronicdiseaseapp.model.vo.PhysicalDataPageResponse.class))),
+        @ApiResponse(responseCode = "403", description = "无权限访问",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = Void.class)))
+    })
+    @PostMapping(path = "/list-by-bound-user", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> listByBoundUser(Long patientUserId, String bindingType, @RequestBody BaseQueryRequest req) {
+        try {
+            Long boundUserId = work.baiyun.chronicdiseaseapp.util.SecurityUtils.getCurrentUserId();
+            if (patientUserId == null) {
+                return R.fail(ErrorCode.PARAMETER_ERROR.getCode(), ErrorCode.PARAMETER_ERROR.getMessage());
+            }
+            work.baiyun.chronicdiseaseapp.model.vo.CheckUserBindingRequest checkReq = new work.baiyun.chronicdiseaseapp.model.vo.CheckUserBindingRequest();
+            checkReq.setPatientUserId(patientUserId);
+            checkReq.setBoundUserId(boundUserId);
+            work.baiyun.chronicdiseaseapp.model.vo.CheckUserBindingResponse checkResp = userBindingService.checkUserBinding(checkReq);
+            if (!Boolean.TRUE.equals(checkResp.getExists())) {
+                return R.fail(ErrorCode.DATA_ACCESS_DENIED.getCode(), "当前用户与目标患者未建立绑定关系");
+            }
+            if ((bindingType == null || bindingType.isEmpty()) && checkResp.getBindingType() != null) {
+                bindingType = checkResp.getBindingType();
+            }
+
+            logger.info("patient data access - type=physical, boundUserId={}, patientUserId={}, startTime={}, endTime={}", boundUserId, patientUserId, req.getStartTime(), req.getEndTime());
+
+            Page<PhysicalDataResponse> page = physicalDataService.listPhysicalDataByPatient(patientUserId, bindingType, req);
+            work.baiyun.chronicdiseaseapp.model.vo.PhysicalDataPageResponse vo = new work.baiyun.chronicdiseaseapp.model.vo.PhysicalDataPageResponse();
+            vo.setRecords(page.getRecords());
+            vo.setTotal(page.getTotal());
+            vo.setSize(page.getSize());
+            vo.setCurrent(page.getCurrent());
+            vo.setOptimizeCountSql(page.optimizeCountSql());
+            vo.setSearchCount(page.isSearchCount());
+            vo.setOptimizeJoinOfCountSql(false);
+            vo.setMaxLimit(page.getMaxLimit());
+            vo.setCountId(page.getCountId());
+            vo.setPages(page.getPages());
+            if (page.getOrders() != null) {
+                java.util.List<work.baiyun.chronicdiseaseapp.model.vo.PhysicalDataPageResponse.OrderItem> orderItems = new java.util.ArrayList<>();
+                for (com.baomidou.mybatisplus.core.metadata.OrderItem item : page.getOrders()) {
+                    work.baiyun.chronicdiseaseapp.model.vo.PhysicalDataPageResponse.OrderItem oi = new work.baiyun.chronicdiseaseapp.model.vo.PhysicalDataPageResponse.OrderItem();
+                    oi.setColumn(item.getColumn());
+                    oi.setAsc(item.isAsc());
+                    orderItems.add(oi);
+                }
+                vo.setOrders(orderItems);
+            }
+            return R.success(200, "ok", vo);
+        } catch (Exception e) {
+            logger.error("list physical data by bound user failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
     @Operation(summary = "删除体格数据", description = "根据ID删除体格测量记录")
     @ApiResponses(value = {
         @ApiResponse(responseCode = "200", description = "体格数据删除成功",

+ 1 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/BloodGlucoseDataService.java

@@ -8,5 +8,6 @@ import work.baiyun.chronicdiseaseapp.model.vo.AddBloodGlucoseDataRequest;
 public interface BloodGlucoseDataService {
     void addBloodGlucoseData(AddBloodGlucoseDataRequest request);
     Page<BloodGlucoseDataResponse> listBloodGlucoseData(BaseQueryRequest request);
+    Page<BloodGlucoseDataResponse> listBloodGlucoseDataByPatient(Long patientUserId, String bindingType, BaseQueryRequest request);
     void deleteBloodGlucoseData(String id);
 }

+ 1 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/BloodPressureDataService.java

@@ -8,5 +8,6 @@ import work.baiyun.chronicdiseaseapp.model.vo.AddBloodPressureDataRequest;
 public interface BloodPressureDataService {
     void addBloodPressureData(AddBloodPressureDataRequest request);
     Page<BloodPressureDataResponse> listBloodPressureData(BaseQueryRequest request);
+    Page<BloodPressureDataResponse> listBloodPressureDataByPatient(Long patientUserId, String bindingType, BaseQueryRequest request);
     void deleteBloodPressureData(String id);
 }

+ 1 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/HeartRateDataService.java

@@ -8,5 +8,6 @@ import work.baiyun.chronicdiseaseapp.model.vo.AddHeartRateDataRequest;
 public interface HeartRateDataService {
     void addHeartRateData(AddHeartRateDataRequest request);
     Page<HeartRateDataResponse> listHeartRateData(BaseQueryRequest request);
+    Page<HeartRateDataResponse> listHeartRateDataByPatient(Long patientUserId, String bindingType, BaseQueryRequest request);
     void deleteHeartRateData(String id);
 }

+ 1 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/PhysicalDataService.java

@@ -8,5 +8,6 @@ import work.baiyun.chronicdiseaseapp.model.vo.AddPhysicalDataRequest;
 public interface PhysicalDataService {
     void addPhysicalData(AddPhysicalDataRequest request);
     Page<PhysicalDataResponse> listPhysicalData(BaseQueryRequest request);
+    Page<PhysicalDataResponse> listPhysicalDataByPatient(Long patientUserId, String bindingType, BaseQueryRequest request);
     void deletePhysicalData(Long id);
 }

+ 35 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/impl/BloodGlucoseDataServiceImpl.java

@@ -57,6 +57,41 @@ public class BloodGlucoseDataServiceImpl implements BloodGlucoseDataService {
         return responsePage;
     }
 
+    @Override
+    public Page<BloodGlucoseDataResponse> listBloodGlucoseDataByPatient(Long patientUserId, String bindingType, BaseQueryRequest request) {
+        // 权限策略:如果是家属(FAMILY),限制时间窗口(例如 365 天)
+        if (bindingType != null && bindingType.equals("FAMILY")) {
+            java.time.LocalDateTime now = java.time.LocalDateTime.now();
+            java.time.LocalDateTime limit = now.minusDays(365);
+            if (request.getStartTime() == null || request.getStartTime().isBefore(limit)) {
+                request.setStartTime(limit);
+            }
+        }
+
+        Page<BloodGlucoseData> page = new Page<>(request.getPageNum(), request.getPageSize());
+        LambdaQueryWrapper<BloodGlucoseData> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(BloodGlucoseData::getUserId, patientUserId)
+               .ge(request.getStartTime() != null, BloodGlucoseData::getMeasureTime, request.getStartTime())
+               .le(request.getEndTime() != null, BloodGlucoseData::getMeasureTime, request.getEndTime())
+               .orderByDesc(BloodGlucoseData::getMeasureTime);
+
+        Page<BloodGlucoseData> result = bloodGlucoseDataMapper.selectPage(page, wrapper);
+
+        List<BloodGlucoseDataResponse> responses = result.getRecords().stream().map(r -> {
+            BloodGlucoseDataResponse resp = new BloodGlucoseDataResponse();
+            BeanUtils.copyProperties(r, resp);
+            resp.setId(r.getId().toString());
+            return resp;
+        }).collect(Collectors.toList());
+
+        Page<BloodGlucoseDataResponse> responsePage = new Page<>();
+        responsePage.setRecords(responses);
+        responsePage.setCurrent(result.getCurrent());
+        responsePage.setSize(result.getSize());
+        responsePage.setTotal(result.getTotal());
+        return responsePage;
+    }
+
     @Override
     public void deleteBloodGlucoseData(String id) {
         Long userId = work.baiyun.chronicdiseaseapp.util.SecurityUtils.getCurrentUserId();

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

@@ -57,6 +57,40 @@ public class BloodPressureDataServiceImpl implements BloodPressureDataService {
         return responsePage;
     }
 
+    @Override
+    public Page<BloodPressureDataResponse> listBloodPressureDataByPatient(Long patientUserId, String bindingType, BaseQueryRequest request) {
+        if (bindingType != null && bindingType.equals("FAMILY")) {
+            java.time.LocalDateTime now = java.time.LocalDateTime.now();
+            java.time.LocalDateTime limit = now.minusDays(365);
+            if (request.getStartTime() == null || request.getStartTime().isBefore(limit)) {
+                request.setStartTime(limit);
+            }
+        }
+
+        Page<work.baiyun.chronicdiseaseapp.model.po.BloodPressureData> page = new Page<>(request.getPageNum(), request.getPageSize());
+        com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<work.baiyun.chronicdiseaseapp.model.po.BloodPressureData> wrapper = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
+        wrapper.eq(work.baiyun.chronicdiseaseapp.model.po.BloodPressureData::getUserId, patientUserId)
+               .ge(request.getStartTime() != null, work.baiyun.chronicdiseaseapp.model.po.BloodPressureData::getMeasureTime, request.getStartTime())
+               .le(request.getEndTime() != null, work.baiyun.chronicdiseaseapp.model.po.BloodPressureData::getMeasureTime, request.getEndTime())
+               .orderByDesc(work.baiyun.chronicdiseaseapp.model.po.BloodPressureData::getMeasureTime);
+
+        Page<work.baiyun.chronicdiseaseapp.model.po.BloodPressureData> result = bloodPressureDataMapper.selectPage(page, wrapper);
+
+        java.util.List<BloodPressureDataResponse> responses = result.getRecords().stream().map(r -> {
+            BloodPressureDataResponse resp = new BloodPressureDataResponse();
+            org.springframework.beans.BeanUtils.copyProperties(r, resp);
+            resp.setId(r.getId().toString());
+            return resp;
+        }).collect(java.util.stream.Collectors.toList());
+
+        Page<BloodPressureDataResponse> responsePage = new Page<>();
+        responsePage.setRecords(responses);
+        responsePage.setCurrent(result.getCurrent());
+        responsePage.setSize(result.getSize());
+        responsePage.setTotal(result.getTotal());
+        return responsePage;
+    }
+
     @Override
     public void deleteBloodPressureData(String id) {
         Long userId = work.baiyun.chronicdiseaseapp.util.SecurityUtils.getCurrentUserId();

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

@@ -57,6 +57,40 @@ public class HeartRateDataServiceImpl implements HeartRateDataService {
         return responsePage;
     }
 
+    @Override
+    public Page<HeartRateDataResponse> listHeartRateDataByPatient(Long patientUserId, String bindingType, BaseQueryRequest request) {
+        if (bindingType != null && bindingType.equals("FAMILY")) {
+            java.time.LocalDateTime now = java.time.LocalDateTime.now();
+            java.time.LocalDateTime limit = now.minusDays(365);
+            if (request.getStartTime() == null || request.getStartTime().isBefore(limit)) {
+                request.setStartTime(limit);
+            }
+        }
+
+        Page<work.baiyun.chronicdiseaseapp.model.po.HeartRateData> page = new Page<>(request.getPageNum(), request.getPageSize());
+        com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<work.baiyun.chronicdiseaseapp.model.po.HeartRateData> wrapper = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
+        wrapper.eq(work.baiyun.chronicdiseaseapp.model.po.HeartRateData::getUserId, patientUserId)
+               .ge(request.getStartTime() != null, work.baiyun.chronicdiseaseapp.model.po.HeartRateData::getMeasureTime, request.getStartTime())
+               .le(request.getEndTime() != null, work.baiyun.chronicdiseaseapp.model.po.HeartRateData::getMeasureTime, request.getEndTime())
+               .orderByDesc(work.baiyun.chronicdiseaseapp.model.po.HeartRateData::getMeasureTime);
+
+        Page<work.baiyun.chronicdiseaseapp.model.po.HeartRateData> result = heartRateDataMapper.selectPage(page, wrapper);
+
+        java.util.List<HeartRateDataResponse> responses = result.getRecords().stream().map(r -> {
+            HeartRateDataResponse resp = new HeartRateDataResponse();
+            org.springframework.beans.BeanUtils.copyProperties(r, resp);
+            resp.setId(r.getId().toString());
+            return resp;
+        }).collect(java.util.stream.Collectors.toList());
+
+        Page<HeartRateDataResponse> responsePage = new Page<>();
+        responsePage.setRecords(responses);
+        responsePage.setCurrent(result.getCurrent());
+        responsePage.setSize(result.getSize());
+        responsePage.setTotal(result.getTotal());
+        return responsePage;
+    }
+
     @Override
     public void deleteHeartRateData(String id) {
         Long userId = work.baiyun.chronicdiseaseapp.util.SecurityUtils.getCurrentUserId();

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

@@ -59,6 +59,40 @@ public class PhysicalDataServiceImpl implements PhysicalDataService {
         return responsePage;
     }
 
+    @Override
+    public Page<PhysicalDataResponse> listPhysicalDataByPatient(Long patientUserId, String bindingType, BaseQueryRequest request) {
+        if (bindingType != null && bindingType.equals("FAMILY")) {
+            java.time.LocalDateTime now = java.time.LocalDateTime.now();
+            java.time.LocalDateTime limit = now.minusDays(365);
+            if (request.getStartTime() == null || request.getStartTime().isBefore(limit)) {
+                request.setStartTime(limit);
+            }
+        }
+
+        Page<work.baiyun.chronicdiseaseapp.model.po.PhysicalData> page = new Page<>(request.getPageNum(), request.getPageSize());
+        com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<work.baiyun.chronicdiseaseapp.model.po.PhysicalData> wrapper = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
+        wrapper.eq(work.baiyun.chronicdiseaseapp.model.po.PhysicalData::getUserId, patientUserId)
+               .ge(request.getStartTime() != null, work.baiyun.chronicdiseaseapp.model.po.PhysicalData::getMeasureTime, request.getStartTime())
+               .le(request.getEndTime() != null, work.baiyun.chronicdiseaseapp.model.po.PhysicalData::getMeasureTime, request.getEndTime())
+               .orderByDesc(work.baiyun.chronicdiseaseapp.model.po.PhysicalData::getMeasureTime);
+
+        Page<work.baiyun.chronicdiseaseapp.model.po.PhysicalData> result = physicalDataMapper.selectPage(page, wrapper);
+
+        java.util.List<PhysicalDataResponse> responses = result.getRecords().stream().map(r -> {
+            PhysicalDataResponse resp = new PhysicalDataResponse();
+            org.springframework.beans.BeanUtils.copyProperties(r, resp);
+            resp.setId(r.getId().toString());
+            return resp;
+        }).collect(java.util.stream.Collectors.toList());
+
+        Page<PhysicalDataResponse> responsePage = new Page<>();
+        responsePage.setRecords(responses);
+        responsePage.setCurrent(result.getCurrent());
+        responsePage.setSize(result.getSize());
+        responsePage.setTotal(result.getTotal());
+        return responsePage;
+    }
+
     @Override
     public void deletePhysicalData(Long id) {
         Long userId = work.baiyun.chronicdiseaseapp.util.SecurityUtils.getCurrentUserId();

+ 72 - 0
src/test/java/work/baiyun/chronicdiseaseapp/controller/BloodGlucoseDataControllerTest.java

@@ -0,0 +1,72 @@
+package work.baiyun.chronicdiseaseapp.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+import work.baiyun.chronicdiseaseapp.model.vo.BaseQueryRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.CheckUserBindingResponse;
+import work.baiyun.chronicdiseaseapp.model.vo.CheckUserBindingRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.BloodGlucoseDataResponse;
+import work.baiyun.chronicdiseaseapp.service.BloodGlucoseDataService;
+import work.baiyun.chronicdiseaseapp.service.UserBindingService;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+public class BloodGlucoseDataControllerTest {
+
+    @Test
+    public void testListByBoundUser_whenBound_shouldReturnOK() {
+        BloodGlucoseDataController controller = new BloodGlucoseDataController();
+
+        // mock userBindingService
+        UserBindingService userBindingService = mock(UserBindingService.class);
+        CheckUserBindingResponse checkResponse = new CheckUserBindingResponse();
+        checkResponse.setExists(true);
+        checkResponse.setBindingType("FAMILY");
+        when(userBindingService.checkUserBinding(any(CheckUserBindingRequest.class))).thenReturn(checkResponse);
+
+        // mock service
+        BloodGlucoseDataService service = mock(BloodGlucoseDataService.class);
+        when(service.listBloodGlucoseDataByPatient(eq(100L), eq("FAMILY"), any(BaseQueryRequest.class))).thenReturn(new Page<BloodGlucoseDataResponse>());
+
+        // inject mocks into controller
+        org.springframework.test.util.ReflectionTestUtils.setField(controller, "userBindingService", userBindingService);
+        org.springframework.test.util.ReflectionTestUtils.setField(controller, "service", service);
+
+        // setup request context with a currentUserId attribute
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setAttribute("currentUserId", 200L);
+        RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
+
+        BaseQueryRequest req = new BaseQueryRequest();
+    work.baiyun.chronicdiseaseapp.common.R<?> resp = controller.listByBoundUser(100L, "FAMILY", req);
+        assertNotNull(resp);
+    }
+
+    @Test
+    public void testListByBoundUser_whenNotBound_shouldReturnDenied() {
+        BloodGlucoseDataController controller = new BloodGlucoseDataController();
+        UserBindingService userBindingService = mock(UserBindingService.class);
+        CheckUserBindingResponse checkResponse = new CheckUserBindingResponse();
+        checkResponse.setExists(false);
+        when(userBindingService.checkUserBinding(any(CheckUserBindingRequest.class))).thenReturn(checkResponse);
+
+        BloodGlucoseDataService service = mock(BloodGlucoseDataService.class);
+        org.springframework.test.util.ReflectionTestUtils.setField(controller, "userBindingService", userBindingService);
+        org.springframework.test.util.ReflectionTestUtils.setField(controller, "service", service);
+
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setAttribute("currentUserId", 200L);
+        RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
+
+        BaseQueryRequest req = new BaseQueryRequest();
+        work.baiyun.chronicdiseaseapp.common.R<?> resp = controller.listByBoundUser(100L, "FAMILY", req);
+        assertNotNull(resp);
+    }
+}