Ver código fonte

feat(critical-value): 实现危急值配置管理模块

- 新增危急值配置表 `t_critical_value_module` 及其建表 SQL 文档
- 设计并实现完整的后端接口:列表、详情、新增/更新、删除、类型枚举等
- 引入 `ValueType` 枚举及 MyBatis TypeHandler 支持数据库映射
- 添加 Controller、Service、Mapper、Entity、VO 全套结构
- 实现健康数据上传时的危急值监控切面(AOP)
- 提供整表结构接口支持前端表单快速加载与保存
- 支持按字段名自动识别数值并比对危急值配置
- 增加 Swagger 注解提升接口文档可读性
- 统一返回格式与异常处理机制
- 支持 ID 精度转换避免前端 JavaScript 精度丢失
mcbaiyun 2 semanas atrás
pai
commit
a20cc0b8b0

+ 26 - 0
docs/DB/t_critical_value_module.txt

@@ -0,0 +1,26 @@
+/*
+ * 危急值配置表
+ * 遵循项目数据库表设计规范:
+ * 1. 使用 't_' 前缀命名
+ * 2. 主键为 BIGINT 类型
+ * 3. 包含 create_user、create_time、update_user、update_time、version、remark 等标准审计字段
+ * 4. 字符集为 utf8mb4,排序规则为 utf8mb4_unicode_ci,引擎为 InnoDB
+ * 5. create_time 字段设置 DEFAULT CURRENT_TIMESTAMP
+ * 6. update_time 字段设置 DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+ */
+
+CREATE TABLE `t_critical_value_module` (
+  `id` bigint(20) NOT NULL COMMENT '主键ID',
+  `value_type` varchar(50) NOT NULL COMMENT '危急值类型,如 height, weight, systolic, fasting 等',
+  `critical_above_value` decimal(10,2) DEFAULT NULL COMMENT '危急值上限',
+  `critical_below_value` decimal(10,2) DEFAULT NULL COMMENT '危急值下限',
+  `record_time` datetime DEFAULT NULL 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`),
+  INDEX `idx_value_type` (`value_type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='危急值配置表';

+ 319 - 0
docs/Dev/modules/Processing/critical-values-backend-design.md

@@ -0,0 +1,319 @@
+# 危急值模块 — 后端设计与实现(Processing)
+
+目的
+- 为前端 `uniapp-ts` 的危急值管理页面提供后端实现设计与参考代码结构。
+- 保持与前端约定一致:只使用 `GET`(只读)与 `POST`(写)两种请求方法。
+
+范围
+- 包含数据库表设计(建表 SQL)、REST 接口定义(路径与方法)、后端分层职责(Controller/Service/Mapper/Entity/DTO)、常见校验与错误处理、以及部署/测试建议。
+
+说明
+- 本设计基于 `docs/新增危急值模块.md` 的字段与接口约定:表 `t_critical_value_module`、字段 `value_type`、`critical_above_value`、`critical_below_value` 等;接口仅使用 `GET` 与 `POST`。
+
+一、数据库设计
+
+表名:`t_critical_value_module`
+
+示例建表 SQL:
+
+```sql
+CREATE TABLE `t_critical_value_module` (
+  `id` BIGINT NOT NULL,
+  `value_type` VARCHAR(50) NOT NULL COMMENT '危急值类型,如 height, weight, systolic, fasting 等',
+  `critical_above_value` DECIMAL(10,2) DEFAULT NULL COMMENT '危急值上限',
+  `critical_below_value` DECIMAL(10,2) DEFAULT NULL COMMENT '危急值下限',
+  `record_time` DATETIME DEFAULT NULL COMMENT '记录时间',
+  `create_user` BIGINT DEFAULT NULL,
+  `create_time` DATETIME DEFAULT NULL,
+  `update_user` BIGINT DEFAULT NULL,
+  `update_time` DATETIME DEFAULT NULL,
+  `remark` VARCHAR(255) DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  INDEX `idx_value_type` (`value_type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='危急值配置表';
+```
+
+备注:
+- `value_type` 使用枚举值(参见前端 `critical-values.vue` 提取的枚举),后端可以在 Service 层做校验。
+- 上下限字段允许为空(NULL),表示只配置其中一端。
+ - 建议与项目现有 `BaseEntity`/`CustomMetaObjectHandler` 约定保持一致:
+   - 应用侧使用 MyBatis-Plus 的 `IdType.ASSIGN_ID`(雪花算法)或项目通用 ID 策略来生成 `id`,因此建表时可不使用 `AUTO_INCREMENT`,以与现有表保持一致。
+   - `create_time` / `update_time` 通常由项目的 `CustomMetaObjectHandler` 自动填充(见 `model/po/BaseEntity.java`),建议数据库列不依赖 `DEFAULT CURRENT_TIMESTAMP`,而由应用层填充以保持一致性和时区控制。
+   - `create_user` / `update_user` 在项目中为 `BIGINT`(用户 id),文档上方示例已做相应调整。
+
+二、后端包/类结构(建议,基于 Spring Boot + MyBatis-Plus)
+
+建议使用项目现有包路径风格:`work.baiyun.chronicdiseaseapp.processing.critical`(模块根包),目录结构示例:
+  - `controller`
+    - `CriticalValueController.java` — REST 接口入口
+  - `service`
+    - `CriticalValueService.java` (接口)
+    - `CriticalValueServiceImpl.java` (实现)
+  - `mapper`
+    - `CriticalValueMapper.java` (MyBatis-Plus 接口,继承 `BaseMapper<CriticalValue>`,使用注解或 Mapper 扫描,无 XML)
+  - `model.po`
+    - `CriticalValue.java`(实体,对应数据库表 `t_critical_value_module`)
+  - `model.vo`
+    - `CreateOrUpdateCriticalValueRequest.java`(请求)
+    - `CriticalValueResponse.java`(响应)
+  - `enum`
+    - `ValueType.java`(后端枚举,值与前端一致)
+  - `exception`
+    - `ProcessingException.java`(模块内统一异常)
+
+说明:
+- 本项目使用 MyBatis-Plus(见现有 `mapper` 文件如 `MedicineMapper`),推荐 `CriticalValueMapper` 直接继承 `com.baomidou.mybatisplus.core.mapper.BaseMapper<CriticalValue>` 并添加 `@Mapper` 注解或通过包扫描注册,不需要 `*.xml` 映射文件。
+- 在 Service 中优先使用 MyBatis-Plus 的 `QueryWrapper` / `LambdaQueryWrapper` / `UpdateWrapper` 进行查询与更新;仅在特殊场景(复杂 SQL 或性能优化)下使用 `@Select` 注解编写原生 SQL。
+
+三、主要接口(与文档一致)
+
+1) 获取危急值列表
+- 方法:GET
+- 路径:`/api/critical-values`
+- 参数:`value_type`(可选)
+- 行为:根据 `value_type` 过滤并返回对应记录集合;若不传 `value_type` 返回所有。
+- 返回示例(HTTP 200):
+
+```json
+[{
+  "id": 1,
+  "value_type": "fasting",
+  "critical_above_value": 7.0,
+  "critical_below_value": 3.9,
+  "remark": "空腹血糖危急值"
+}]
+```
+
+2) 获取危急值详情
+- 方法:GET
+- 路径:`/api/critical-values/{id}`
+- 行为:返回单条记录
+
+3) 新增或更新危急值
+- 方法:POST
+- 路径:`/api/critical-values`
+- 行为:统一写接口;当请求体包含 `id` 字段时视为更新(先校验存在),否则为新增。
+- 建议校验:
+  - `value_type` 非空并为合法枚举
+  - `critical_above_value` 与 `critical_below_value` 至少有一个非空
+  - 数值范围合理(如不为负数,或根据具体指标做额外校验)
+
+4) 删除危急值
+- 方法:POST
+- 路径:`/api/critical-values/delete`
+- 行为:接收 `id`(或 `ids` 列表)作为写操作进行删除。
+
+5) 获取危急值类型枚举
+- 方法:GET
+- 路径:`/api/critical-values/types`
+- 行为:返回后端枚举列表(value+label),便于前端构建下拉:
+
+返回示例:
+```json
+[
+  {"value":"height","label":"身高"},
+  {"value":"weight","label":"体重"},
+  {"value":"fasting","label":"血糖-空腹"}
+]
+```
+
+四、示例 DTO / Entity(简化)
+
+实体(model.po)示例:`CriticalValue.java`
+```java
+package work.baiyun.chronicdiseaseapp.model.po;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import work.baiyun.chronicdiseaseapp.model.po.BaseEntity;
+
+@TableName("t_critical_value_module")
+public class CriticalValue extends BaseEntity {
+  private String valueType;
+
+  @TableField("critical_above_value")
+  private BigDecimal criticalAboveValue;
+
+  @TableField("critical_below_value")
+  private BigDecimal criticalBelowValue;
+
+  @TableField("record_time")
+  private LocalDateTime recordTime;
+
+  // remark / create/update fields inherited from BaseEntity
+  // getter/setter
+}
+```
+
+请求/响应(model.vo)示例命名:
+
+1) 创建/更新请求:`CreateOrUpdateCriticalValueRequest.java`
+```java
+public class CreateOrUpdateCriticalValueRequest {
+  private Long id; // 可选,存在则为更新
+  private String valueType;
+  private BigDecimal criticalAboveValue;
+  private BigDecimal criticalBelowValue;
+  private String remark;
+  // getter/setter
+}
+```
+
+2) 对外响应:`CriticalValueResponse.java`
+```java
+public class CriticalValueResponse {
+  private String id; // 返回给前端为 String,避免 JS 精度问题
+  private String valueType;
+  private BigDecimal criticalAboveValue;
+  private BigDecimal criticalBelowValue;
+  private String remark;
+  private LocalDateTime recordTime;
+  // getter/setter
+}
+```
+
+五、Controller 示例方法签名(伪码)
+
+```java
+@RestController
+@RequestMapping("/api/critical-values")
+public class CriticalValueController {
+
+  @GetMapping("")
+  public R<List<CriticalValueResponse>> list(@RequestParam(required=false) String valueType) { ... }
+
+  @GetMapping("/{id}")
+  public R<CriticalValueResponse> get(@PathVariable String id) { ... }
+
+  @PostMapping("")
+  public R<?> saveOrUpdate(@RequestBody CreateOrUpdateCriticalValueRequest req) { ... }
+
+  @PostMapping("/delete")
+  public R<?> delete(@RequestBody DeleteRequest req) { ... }
+
+  @GetMapping("/types")
+  public R<List<EnumVO>> types() { ... }
+}
+```
+
+六、Mapper (MyBatis-Plus) 示例(QueryWrapper / 注解 SQL)
+
+说明:本项目优先使用 MyBatis-Plus 的 `QueryWrapper` / `LambdaQueryWrapper` 进行查询与更新;仅在复杂或性能敏感场景使用 `@Select` 注解编写原生 SQL。下面给出 QueryWrapper 的伪代码与经修正的示例 SQL(供参考),SQL 中参数请使用 MyBatis 占位符 `#{...}`。
+
+QueryWrapper 伪代码示例:
+
+```java
+LambdaQueryWrapper<CriticalValue> wrapper = new LambdaQueryWrapper<>();
+if (valueType != null) {
+  wrapper.eq(CriticalValue::getValueType, valueType);
+}
+wrapper.orderByAsc(CriticalValue::getId);
+List<CriticalValue> list = criticalValueMapper.selectList(wrapper);
+```
+
+原生 SQL(示意,若使用 `@Select` 请使用类似写法并注意字段映射):
+
+- 查询(按 type)
+```sql
+SELECT id, value_type, critical_above_value, critical_below_value, remark
+FROM t_critical_value_module
+WHERE (#{valueType} IS NULL OR value_type = #{valueType})
+ORDER BY id;
+```
+
+- 插入
+```sql
+INSERT INTO t_critical_value_module (value_type, critical_above_value, critical_below_value, remark, create_user)
+VALUES (#{valueType}, #{criticalAboveValue}, #{criticalBelowValue}, #{remark}, #{createUser});
+```
+
+- 更新
+```sql
+UPDATE t_critical_value_module
+SET value_type = #{valueType}, critical_above_value = #{criticalAboveValue},
+    critical_below_value = #{criticalBelowValue}, remark = #{remark}, update_user = #{updateUser}
+WHERE id = #{id};
+```
+
+- 删除
+```sql
+DELETE FROM t_critical_value_module WHERE id = #{id};
+```
+
+备注:推荐在文档中把以上 SQL 视为参考;实际实现优先用 `QueryWrapper`。如果使用 `@Select` 注解,请确保 SQL 与 VO 字段别名一致并使用 `#{}` 占位。
+
+七、校验与异常处理
+- 使用统一的参数校验(JSR-303 注解)在 DTO 层校验 `@NotBlank`、`@DecimalMin` 等。
+- Service 层检查业务一致性(例如:更新时若 `id` 不存在返回 404/自定义错误)。
+- 返回格式建议统一:{
+  code: int, message: string, data: object
+}
+
+文档 / Swagger 注意:
+- 若项目使用泛型包装类(如 `R<T>`)返回值,Swagger 可能无法正确展示泛型内部结构。建议在 Controller 方法上使用 `@ApiResponse` / `@Content` / `@Schema(implementation = ...)` 明确指定实际返回类型;对于分页等复杂泛型,创建专用的文档 VO 并在注解中引用。示例:
+
+```java
+@Operation(summary = "获取危急值列表")
+@ApiResponse(responseCode = "200", description = "OK",
+    content = @Content(mediaType = "application/json",
+        schema = @Schema(implementation = CriticalValueListResponse.class)))
+public R<?> list(...) { ... }
+```
+
+ - 为常见的错误状态码也添加 `@ApiResponse` 注解(400/401/403/404/500),提高文档准确性和前端对接效率。
+
+八、安全与权限
+- 仅医生或管理员有写入权限;GET 可根据权限决定是否所有用户可见。
+- 建议在 Controller 或 Service 层使用已有的认证/鉴权组件(如 Spring Security 或项目现有拦截器)。
+
+九、测试建议
+- 单元测试:Service 层模拟 Mapper,测试新增/更新/删除/查询逻辑。
+- 集成测试:使用内存数据库(H2)或测试库执行 Mapper SQL、Controller 接口联调。
+
+十、迁移与上线说明
+- 提交建表 SQL 到数据库迁移脚本(如 Flyway/Liquibase)或交付 DB 管理员执行。
+- 文档路径:`api-springboot/docs/Dev/modules/Processing/critical-values-backend-design.md`
+
+十一、注意事项与前端对接要点
+- 前端使用的枚举值必须与后端 `ValueType` 一致;推荐在后端 `/types` 接口返回 label 与 value,前端直接消费。
+- 删除使用 `POST /api/critical-values/delete`,前端发送 `{id: 123}`;避免跨域或预检问题需与前端同事确认请求头与 Content-Type(建议 `application/json`)。
+- 前端页面未提供导入/导出功能,后端无需实现该功能。
+
+补充要点(从历史问题与经验教训中提取,强烈建议遵循):
+
+- ID 精度(前端显示/处理):
+  - 问题背景:使用 Snowflake 等 64 位整型 ID 时,前端 JavaScript 的 Number 会出现精度丢失。
+  - 建议:在 API 响应的 VO 中将 `id` 定义为 `String` 并在 Service 层转换:
+
+```java
+// VO / Response
+public class CriticalValueResponse {
+  private String id; // 注意:String 类型以避免前端精度丢失
+  // ...其他字段
+}
+
+// Service 层转换示例
+CriticalValueResponse vo = new CriticalValueResponse();
+BeanUtils.copyProperties(entity, vo);
+vo.setId(entity.getId() == null ? null : entity.getId().toString());
+```
+
+  - 说明:请求入参仍可按现有习惯使用 Long(或前端传 String 再后端解析),但响应务必返回 String,避免前端误差。
+
+- 类型映射兼容性(TINYINT(1) 与 Boolean):
+  - 问题背景:历史问题中发现 MySQL 的 `TINYINT(1)` 在某些 JDBC 驱动下不能稳定映射为 Java `Boolean`,导致 VO 中布尔字段为 null。
+  - 建议:如果模块涉及 `TINYINT(1)` 布尔字段,PO 使用 `Byte` 或 `Integer` 更稳妥;由 Service 在返回 VO 时明确转换为 `Boolean`。
+
+示例:
+
+```java
+// PO
+private Byte notificationEnabled; // 对应 TINYINT(1)
+
+// Service -> VO 转换
+vo.setNotificationEnabled(entity.getNotificationEnabled() != null && entity.getNotificationEnabled() == 1);
+```
+
+  - 说明:本“危急值”模块当前字段为 DECIMAL 类型,不直接受此问题影响,但建议在文档中保留此经验以供团队复用。
+
+—— 结束 ——

+ 190 - 0
src/main/java/work/baiyun/chronicdiseaseapp/aspect/HealthDataCriticalAlertAspect.java

@@ -0,0 +1,190 @@
+package work.baiyun.chronicdiseaseapp.aspect;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import work.baiyun.chronicdiseaseapp.mapper.CriticalValueMapper;
+import work.baiyun.chronicdiseaseapp.model.po.CriticalValue;
+import work.baiyun.chronicdiseaseapp.util.SecurityUtils;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.math.BigDecimal;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 健康数据危急值监控切面
+ * 拦截上传类的 controller 方法(如 add/upload/create),在上传时检查是否超过配置的危急值,若超过则记录告警日志
+ */
+@Aspect
+@Component
+public class HealthDataCriticalAlertAspect {
+
+    private static final Logger logger = LoggerFactory.getLogger(HealthDataCriticalAlertAspect.class);
+
+    @Autowired
+    private CriticalValueMapper criticalValueMapper;
+
+    @Pointcut("execution(* work.baiyun.chronicdiseaseapp.controller..*(..)) && (execution(* *..add*(..)) || execution(* *..upload*(..)) || execution(* *..create*(..)))")
+    public void uploadMethods() {}
+
+    @Around("uploadMethods()")
+    public Object aroundUpload(ProceedingJoinPoint pjp) throws Throwable {
+        // diagnostics entry (no request usage)
+
+        // log entry for diagnostics
+        logger.debug("HealthDataCriticalAlertAspect invoked for {}", pjp.getSignature().toShortString());
+
+        // Execute original method (do not interfere with business logic)
+        Object result = pjp.proceed();
+        try {
+            // Only check if user logged in
+            Long userId = null;
+            try { userId = SecurityUtils.getCurrentUserId(); } catch (Exception ignored) {}
+            if (userId == null) return result;
+
+            // Extract candidate numeric measurements from method arguments
+            Object[] args = pjp.getArgs();
+            Map<String, BigDecimal> measurements = new HashMap<>();
+
+            for (Object arg : args) {
+                if (arg == null) continue;
+                // inspect common field names
+                collectIfPresent(arg, new String[]{"height", "weight", "bmi", "systolic", "systolicPressure", "diastolic", "diastolicPressure", "value", "rate", "heartRate", "hr"}, measurements);
+                // also try nested glucose type fields like fasting/random
+                collectIfPresent(arg, new String[]{"glucose", "glucoseValue", "bloodGlucose"}, measurements);
+                // detect explicit type for glucose (fasting/random)
+                String type = extractStringField(arg, new String[]{"type", "measurementType", "glucoseType", "valueType"});
+                if (type != null) {
+                    // normalize common names
+                    if (type.equalsIgnoreCase("fasting") || type.equalsIgnoreCase("空腹")) {
+                        BigDecimal v = getNumericField(arg, new String[]{"value", "glucose", "glucoseValue"});
+                        if (v != null) measurements.put("fasting", v);
+                    } else if (type.equalsIgnoreCase("random") || type.equalsIgnoreCase("随机")) {
+                        BigDecimal v = getNumericField(arg, new String[]{"value", "glucose", "glucoseValue"});
+                        if (v != null) measurements.put("random", v);
+                    }
+                }
+            }
+
+            // Debug: 输出提取到的测量值以便排查
+            logger.debug("HealthDataCriticalAlertAspect extracted measurements: {}", measurements);
+
+            // If no explicit glucose type found but a generic glucose/value present,
+            // default to treating it as 'random' (非空腹) to avoid incorrectly triggering 空腹阈值.
+            if (measurements.containsKey("value") || measurements.containsKey("glucose") || measurements.containsKey("glucoseValue")) {
+                BigDecimal g = measurements.getOrDefault("value", measurements.getOrDefault("glucose", measurements.get("glucoseValue")));
+                if (g != null) {
+                    // only default to random if neither fasting nor random already present
+                    if (!measurements.containsKey("fasting") && !measurements.containsKey("random")) {
+                        measurements.put("random", g);
+                    }
+                }
+            }
+
+            // Map measurement keys to value_type codes used in CriticalValue
+            Map<String, String> mapKeyToCode = new HashMap<>();
+            mapKeyToCode.put("height", "height");
+            mapKeyToCode.put("weight", "weight");
+            mapKeyToCode.put("bmi", "bmi");
+            mapKeyToCode.put("systolic", "systolic");
+            mapKeyToCode.put("systolicPressure", "systolic");
+            mapKeyToCode.put("diastolic", "diastolic");
+            mapKeyToCode.put("diastolicPressure", "diastolic");
+            mapKeyToCode.put("fasting", "fasting");
+            mapKeyToCode.put("random", "random");
+            mapKeyToCode.put("heartRate", "heartRate");
+            mapKeyToCode.put("rate", "heartRate");
+            mapKeyToCode.put("hr", "heartRate");
+
+            // For each measurement, load threshold and compare
+            for (Map.Entry<String, BigDecimal> e : measurements.entrySet()) {
+                String key = e.getKey();
+                BigDecimal measured = e.getValue();
+                String code = mapKeyToCode.get(key);
+                if (code == null) continue;
+
+                QueryWrapper<CriticalValue> qw = new QueryWrapper<>();
+                qw.eq("value_type", code);
+                CriticalValue cv = criticalValueMapper.selectOne(qw);
+                if (cv == null) continue;
+
+                BigDecimal low = cv.getCriticalBelowValue();
+                BigDecimal high = cv.getCriticalAboveValue();
+
+                boolean warn = false;
+                if (low != null && measured.compareTo(low) < 0) warn = true;
+                if (high != null && measured.compareTo(high) > 0) warn = true;
+
+                if (warn) {
+                    logger.warn("危急值警告: userId={} type={} measured={} below={} above={} controllerMethod={}",
+                            userId, code, measured, low, high, pjp.getSignature().toShortString());
+                }
+            }
+
+        } catch (Exception ex) {
+            logger.error("HealthDataCriticalAlertAspect failed to check critical values", ex);
+        }
+
+        return result;
+    }
+
+    private void collectIfPresent(Object arg, String[] names, Map<String, BigDecimal> dest) {
+        for (String n : names) {
+            BigDecimal v = getNumericField(arg, new String[]{n});
+            if (v != null) dest.put(n, v);
+        }
+    }
+
+    private BigDecimal getNumericField(Object obj, String[] names) {
+        for (String n : names) {
+            Object val = extractFieldValue(obj, n);
+            if (val == null) continue;
+            try {
+                if (val instanceof Number) return new BigDecimal(((Number) val).toString());
+                String s = String.valueOf(val).trim();
+                if (s.isEmpty()) continue;
+                return new BigDecimal(s);
+            } catch (Exception ignored) {}
+        }
+        return null;
+    }
+
+    private String extractStringField(Object obj, String[] names) {
+        for (String n : names) {
+            Object val = extractFieldValue(obj, n);
+            if (val == null) continue;
+            try { return String.valueOf(val); } catch (Exception ignored) {}
+        }
+        return null;
+    }
+
+    private Object extractFieldValue(Object obj, String name) {
+        try {
+            // try getter
+            String cap = name.substring(0,1).toUpperCase() + name.substring(1);
+            Method m = null;
+            try { m = obj.getClass().getMethod("get" + cap); } catch (NoSuchMethodException ignored) {}
+            if (m == null) {
+                try { m = obj.getClass().getMethod("is" + cap); } catch (NoSuchMethodException ignored) {}
+            }
+            if (m != null) return m.invoke(obj);
+
+            // try field
+            try {
+                Field f = obj.getClass().getDeclaredField(name);
+                f.setAccessible(true);
+                return f.get(obj);
+            } catch (NoSuchFieldException ignored) {}
+        } catch (Exception ignored) {}
+        return null;
+    }
+}

+ 181 - 0
src/main/java/work/baiyun/chronicdiseaseapp/controller/CriticalValueController.java

@@ -0,0 +1,181 @@
+package work.baiyun.chronicdiseaseapp.controller;
+
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+import work.baiyun.chronicdiseaseapp.model.po.CriticalValue;
+import work.baiyun.chronicdiseaseapp.model.vo.CreateOrUpdateCriticalValueRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.CriticalValueResponse;
+import work.baiyun.chronicdiseaseapp.model.vo.DeleteRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.EnumVO;
+import work.baiyun.chronicdiseaseapp.model.vo.CriticalValuesFormRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.CriticalValuesFormResponse;
+import work.baiyun.chronicdiseaseapp.service.CriticalValueService;
+import work.baiyun.chronicdiseaseapp.enums.ValueType;
+import work.baiyun.chronicdiseaseapp.common.R;
+import work.baiyun.chronicdiseaseapp.enums.ErrorCode;
+import work.baiyun.chronicdiseaseapp.exception.CustomException;
+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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@RestController
+@RequestMapping("/critical-values")
+@Tag(name = "危急值管理", description = "危急值配置管理接口")
+public class CriticalValueController {
+
+    private static final Logger logger = LoggerFactory.getLogger(CriticalValueController.class);
+
+    @Autowired
+    private CriticalValueService criticalValueService;
+
+    @Operation(summary = "获取危急值列表", description = "按类型查询危急值配置,valueType 可选")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "成功",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = work.baiyun.chronicdiseaseapp.model.vo.CriticalValueResponse.class))),
+        @ApiResponse(responseCode = "500", description = "服务器内部错误",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = Void.class)))
+    })
+    @GetMapping(path = "", produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> list(@RequestParam(required = false) String valueType) {
+        try {
+            List<CriticalValue> list = criticalValueService.list(valueType);
+            List<CriticalValueResponse> resp = list.stream().map(e -> {
+                CriticalValueResponse r = new CriticalValueResponse();
+                BeanUtils.copyProperties(e, r);
+                r.setId(e.getId() == null ? null : e.getId().toString());
+                r.setValueType(e.getValueType() == null ? null : e.getValueType().getCode());
+                return r;
+            }).collect(Collectors.toList());
+            return R.success(200, "ok", resp);
+        } catch (CustomException e) {
+            logger.error("list critical values failed", e);
+            return R.fail(e.getCode(), e.getMessage());
+        } catch (Exception e) {
+            logger.error("list critical values failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
+    @Operation(summary = "获取危急值详情", description = "根据ID获取单条危急值配置")
+    @GetMapping(path = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> get(@PathVariable Long id) {
+        try {
+            CriticalValue entity = criticalValueService.getById(id);
+            if (entity == null) return R.success(200, "ok", null);
+            CriticalValueResponse r = new CriticalValueResponse();
+            BeanUtils.copyProperties(entity, r);
+            r.setId(entity.getId() == null ? null : entity.getId().toString());
+            r.setValueType(entity.getValueType() == null ? null : entity.getValueType().getCode());
+            return R.success(200, "ok", r);
+        } catch (CustomException e) {
+            logger.error("get critical value failed", e);
+            return R.fail(e.getCode(), e.getMessage());
+        } catch (Exception e) {
+            logger.error("get critical value failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
+    @Operation(summary = "新增或更新危急值", description = "统一写接口:无 id 为新增,含 id 为更新")
+    @PostMapping(path = "", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> saveOrUpdate(@RequestBody CreateOrUpdateCriticalValueRequest req) {
+        try {
+            criticalValueService.saveOrUpdate(req);
+            return R.success(200, "ok");
+        } catch (CustomException e) {
+            logger.error("saveOrUpdate critical value failed", e);
+            return R.fail(e.getCode(), e.getMessage());
+        } catch (Exception e) {
+            logger.error("saveOrUpdate critical value failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
+    @Operation(summary = "删除危急值", description = "支持批量删除")
+    @PostMapping(path = "/delete", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> delete(@RequestBody DeleteRequest req) {
+        try {
+            criticalValueService.deleteByIds(req.getIds());
+            return R.success(200, "ok");
+        } catch (CustomException e) {
+            logger.error("delete critical value failed", e);
+            return R.fail(e.getCode(), e.getMessage());
+        } catch (Exception e) {
+            logger.error("delete critical value failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
+    @Operation(summary = "获取危急值类型枚举", description = "返回 value+label 列表供前端下拉使用")
+    @GetMapping(path = "/types", produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> types() {
+        try {
+            List<EnumVO> list = Arrays.stream(ValueType.values())
+                    .map(v -> new EnumVO(v.getCode(), v.getDescription()))
+                    .collect(Collectors.toList());
+            return R.success(200, "ok", list);
+        } catch (Exception e) {
+            logger.error("types critical values failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
+    @Operation(summary = "获取整表危急值配置", description = "返回前端所需的整表结构(physical/bmi/bloodPressure/bloodGlucose/heartRate)")
+    @GetMapping(path = "/form", produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> getForm() {
+        try {
+            CriticalValuesFormResponse resp = criticalValueService.loadForm();
+            return R.success(200, "ok", resp);
+        } catch (CustomException e) {
+            logger.error("get form failed", e);
+            return R.fail(e.getCode(), e.getMessage());
+        } catch (Exception e) {
+            logger.error("get form failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
+    @Operation(summary = "保存整表危急值配置", description = "接收前端整表对象并拆分写入多条配置")
+    @PostMapping(path = "/form", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> saveForm(@RequestBody CriticalValuesFormRequest req) {
+        try {
+            criticalValueService.saveForm(req);
+            return R.success(200, "ok");
+        } catch (CustomException e) {
+            logger.error("save form failed", e);
+            return R.fail(e.getCode(), e.getMessage());
+        } catch (Exception e) {
+            logger.error("save form failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
+    @Operation(summary = "重置整表危急值配置", description = "删除或清空已配置的危急值条目")
+    @DeleteMapping(path = "/form", produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> resetForm() {
+        try {
+            criticalValueService.resetForm();
+            return R.success(200, "ok");
+        } catch (CustomException e) {
+            logger.error("reset form failed", e);
+            return R.fail(e.getCode(), e.getMessage());
+        } catch (Exception e) {
+            logger.error("reset form failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+}

+ 49 - 0
src/main/java/work/baiyun/chronicdiseaseapp/enums/ValueType.java

@@ -0,0 +1,49 @@
+package work.baiyun.chronicdiseaseapp.enums;
+
+import com.baomidou.mybatisplus.annotation.EnumValue;
+
+/**
+ * 危急值类型枚举(与前端 value_type 保持一致)
+ */
+public enum ValueType {
+    HEIGHT("height", "身高"),
+    WEIGHT("weight", "体重"),
+    BMI("bmi", "BMI"),
+    SYSTOLIC("systolic", "收缩压"),
+    DIASTOLIC("diastolic", "舒张压"),
+    FASTING("fasting", "血糖-空腹"),
+    RANDOM("random", "血糖-随机"),
+    HEART_RATE("heartRate", "心率");
+
+    @EnumValue
+    private final String code;
+    private final String description;
+
+    ValueType(String code, String description) {
+        this.code = code;
+        this.description = description;
+    }
+
+    public String getCode() {
+        return code;
+    }
+
+    public String getDescription() {
+        return description;
+    }
+
+    @Override
+    public String toString() {
+        return description;
+    }
+
+    public static ValueType fromCode(String code) {
+        if (code == null) return null;
+        for (ValueType t : ValueType.values()) {
+            if (t.code.equals(code)) return t;
+        }
+        throw new work.baiyun.chronicdiseaseapp.exception.CustomException(
+                work.baiyun.chronicdiseaseapp.enums.ErrorCode.PARAMETER_ERROR.getCode(),
+                "Unknown ValueType code: " + code);
+    }
+}

+ 40 - 0
src/main/java/work/baiyun/chronicdiseaseapp/handler/ValueTypeTypeHandler.java

@@ -0,0 +1,40 @@
+package work.baiyun.chronicdiseaseapp.handler;
+
+import org.apache.ibatis.type.BaseTypeHandler;
+import org.apache.ibatis.type.JdbcType;
+import org.apache.ibatis.type.MappedJdbcTypes;
+import org.apache.ibatis.type.MappedTypes;
+import work.baiyun.chronicdiseaseapp.enums.ValueType;
+
+import java.sql.CallableStatement;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+@MappedJdbcTypes(JdbcType.VARCHAR)
+@MappedTypes(ValueType.class)
+public class ValueTypeTypeHandler extends BaseTypeHandler<ValueType> {
+
+    @Override
+    public void setNonNullParameter(PreparedStatement ps, int i, ValueType parameter, JdbcType jdbcType) throws SQLException {
+        ps.setString(i, parameter.getCode());
+    }
+
+    @Override
+    public ValueType getNullableResult(ResultSet rs, String columnName) throws SQLException {
+        String code = rs.getString(columnName);
+        return code == null ? null : ValueType.fromCode(code);
+    }
+
+    @Override
+    public ValueType getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
+        String code = rs.getString(columnIndex);
+        return code == null ? null : ValueType.fromCode(code);
+    }
+
+    @Override
+    public ValueType getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
+        String code = cs.getString(columnIndex);
+        return code == null ? null : ValueType.fromCode(code);
+    }
+}

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

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

+ 38 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/po/CriticalValue.java

@@ -0,0 +1,38 @@
+package work.baiyun.chronicdiseaseapp.model.po;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import work.baiyun.chronicdiseaseapp.enums.ValueType;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+@Schema(description = "危急值配置表")
+@TableName("t_critical_value_module")
+@Data
+@EqualsAndHashCode(callSuper = false)
+public class CriticalValue extends BaseEntity {
+
+    @Schema(description = "危急值类型,参见 ValueType 枚举")
+    @TableField(value = "value_type", typeHandler = work.baiyun.chronicdiseaseapp.handler.ValueTypeTypeHandler.class)
+    private ValueType valueType;
+
+    @Schema(description = "危急值上限")
+    @TableField("critical_above_value")
+    private BigDecimal criticalAboveValue;
+
+    @Schema(description = "危急值下限")
+    @TableField("critical_below_value")
+    private BigDecimal criticalBelowValue;
+
+    @Schema(description = "记录时间")
+    @TableField("record_time")
+    private LocalDateTime recordTime;
+
+    @Schema(description = "备注")
+    @TableField("remark")
+    private String remark;
+}

+ 16 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/vo/CreateOrUpdateCriticalValueRequest.java

@@ -0,0 +1,16 @@
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+@Schema(description = "创建或更新危急值请求")
+public class CreateOrUpdateCriticalValueRequest {
+    private Long id; // 可选,存在则为更新
+    private String valueType;
+    private BigDecimal criticalAboveValue;
+    private BigDecimal criticalBelowValue;
+    private String remark;
+}

+ 20 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/vo/CriticalValueResponse.java

@@ -0,0 +1,20 @@
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+@Data
+@Schema(description = "危急值响应VO")
+public class CriticalValueResponse {
+    private String id; // 返回给前端为 String,避免 JS 精度问题
+    private String valueType;
+    private BigDecimal criticalAboveValue;
+    private BigDecimal criticalBelowValue;
+    private String remark;
+    private LocalDateTime recordTime;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+}

+ 47 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/vo/CriticalValuesFormRequest.java

@@ -0,0 +1,47 @@
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+@Schema(description = "前端整表提交 - 请求体")
+public class CriticalValuesFormRequest {
+
+    private Physical physical;
+    private MinMaxVO bmi;
+    private BloodPressure bloodPressure;
+    private BloodGlucose bloodGlucose;
+    private MinMaxVO heartRate;
+
+    @Data
+    public static class Physical {
+        private BigDecimal heightMin;
+        private BigDecimal heightMax;
+        private BigDecimal weightMin;
+        private BigDecimal weightMax;
+    }
+
+    @Data
+    public static class MinMaxVO {
+        private BigDecimal min;
+        private BigDecimal max;
+    }
+
+    @Data
+    public static class BloodPressure {
+        private BigDecimal systolicMin;
+        private BigDecimal systolicMax;
+        private BigDecimal diastolicMin;
+        private BigDecimal diastolicMax;
+    }
+
+    @Data
+    public static class BloodGlucose {
+        private BigDecimal fastingMin;
+        private BigDecimal fastingMax;
+        private BigDecimal randomMin;
+        private BigDecimal randomMax;
+    }
+}

+ 14 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/vo/CriticalValuesFormResponse.java

@@ -0,0 +1,14 @@
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(description = "前端整表获取 - 响应体")
+public class CriticalValuesFormResponse {
+    private CriticalValuesFormRequest.Physical physical;
+    private CriticalValuesFormRequest.MinMaxVO bmi;
+    private CriticalValuesFormRequest.BloodPressure bloodPressure;
+    private CriticalValuesFormRequest.BloodGlucose bloodGlucose;
+    private CriticalValuesFormRequest.MinMaxVO heartRate;
+}

+ 12 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/vo/DeleteRequest.java

@@ -0,0 +1,12 @@
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@Schema(description = "删除请求,支持批量")
+public class DeleteRequest {
+    private List<Long> ids;
+}

+ 11 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/vo/EnumVO.java

@@ -0,0 +1,11 @@
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+@Data
+@AllArgsConstructor
+public class EnumVO {
+    private String value;
+    private String label;
+}

+ 19 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/CriticalValueService.java

@@ -0,0 +1,19 @@
+package work.baiyun.chronicdiseaseapp.service;
+
+import work.baiyun.chronicdiseaseapp.model.po.CriticalValue;
+import work.baiyun.chronicdiseaseapp.model.vo.CreateOrUpdateCriticalValueRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.CriticalValuesFormRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.CriticalValuesFormResponse;
+
+import java.util.List;
+
+public interface CriticalValueService {
+    List<CriticalValue> list(String valueType);
+    CriticalValue getById(Long id);
+    void saveOrUpdate(CreateOrUpdateCriticalValueRequest req);
+    void deleteByIds(List<Long> ids);
+
+    CriticalValuesFormResponse loadForm();
+    void saveForm(CriticalValuesFormRequest req);
+    void resetForm();
+}

+ 209 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/impl/CriticalValueServiceImpl.java

@@ -0,0 +1,209 @@
+package work.baiyun.chronicdiseaseapp.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+import work.baiyun.chronicdiseaseapp.mapper.CriticalValueMapper;
+import work.baiyun.chronicdiseaseapp.model.po.CriticalValue;
+import work.baiyun.chronicdiseaseapp.model.vo.CreateOrUpdateCriticalValueRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.CriticalValuesFormRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.CriticalValuesFormResponse;
+import work.baiyun.chronicdiseaseapp.service.CriticalValueService;
+
+import java.util.List;
+
+@Service
+public class CriticalValueServiceImpl implements CriticalValueService {
+
+    @Autowired
+    private CriticalValueMapper criticalValueMapper;
+
+    @Override
+    public List<CriticalValue> list(String valueType) {
+        QueryWrapper<CriticalValue> wrapper = new QueryWrapper<>();
+        if (valueType != null) wrapper.eq("value_type", valueType);
+        wrapper.orderByAsc("id");
+        return criticalValueMapper.selectList(wrapper);
+    }
+
+    @Override
+    public CriticalValue getById(Long id) {
+        return criticalValueMapper.selectById(id);
+    }
+
+    @Override
+    public void saveOrUpdate(CreateOrUpdateCriticalValueRequest req) {
+        CriticalValue entity = new CriticalValue();
+        // 手动映射,避免 BeanUtils 对枚举字段映射错误
+        if (req.getId() != null) entity.setId(req.getId());
+        if (req.getValueType() != null) {
+            entity.setValueType(work.baiyun.chronicdiseaseapp.enums.ValueType.fromCode(req.getValueType()));
+        }
+        entity.setCriticalAboveValue(req.getCriticalAboveValue());
+        entity.setCriticalBelowValue(req.getCriticalBelowValue());
+        entity.setRemark(req.getRemark());
+
+        if (req.getId() == null) {
+            criticalValueMapper.insert(entity);
+        } else {
+            criticalValueMapper.updateById(entity);
+        }
+    }
+
+    @Override
+    public void deleteByIds(List<Long> ids) {
+        if (CollectionUtils.isEmpty(ids)) return;
+        criticalValueMapper.deleteBatchIds(ids);
+    }
+
+    @Override
+    public CriticalValuesFormResponse loadForm() {
+        CriticalValuesFormResponse resp = new CriticalValuesFormResponse();
+        // initialize empty nested objects to avoid NPE in controller/frontend
+        CriticalValuesFormRequest.Physical physical = new CriticalValuesFormRequest.Physical();
+        CriticalValuesFormRequest.MinMaxVO bmi = new CriticalValuesFormRequest.MinMaxVO();
+        CriticalValuesFormRequest.BloodPressure bp = new CriticalValuesFormRequest.BloodPressure();
+        CriticalValuesFormRequest.BloodGlucose bg = new CriticalValuesFormRequest.BloodGlucose();
+        CriticalValuesFormRequest.MinMaxVO hr = new CriticalValuesFormRequest.MinMaxVO();
+
+        List<CriticalValue> rows = criticalValueMapper.selectList(new QueryWrapper<>());
+        for (CriticalValue r : rows) {
+            if (r.getValueType() == null) continue;
+            String code = r.getValueType().getCode();
+            java.math.BigDecimal low = r.getCriticalBelowValue();
+            java.math.BigDecimal high = r.getCriticalAboveValue();
+            switch (code) {
+                case "height":
+                    physical.setHeightMin(low);
+                    physical.setHeightMax(high);
+                    break;
+                case "weight":
+                    physical.setWeightMin(low);
+                    physical.setWeightMax(high);
+                    break;
+                case "bmi":
+                    bmi.setMin(low);
+                    bmi.setMax(high);
+                    break;
+                case "systolic":
+                    bp.setSystolicMin(low);
+                    bp.setSystolicMax(high);
+                    break;
+                case "diastolic":
+                    bp.setDiastolicMin(low);
+                    bp.setDiastolicMax(high);
+                    break;
+                case "fasting":
+                    bg.setFastingMin(low);
+                    bg.setFastingMax(high);
+                    break;
+                case "random":
+                    bg.setRandomMin(low);
+                    bg.setRandomMax(high);
+                    break;
+                case "heartRate":
+                    hr.setMin(low);
+                    hr.setMax(high);
+                    break;
+                default:
+                    // ignore unknown types
+            }
+        }
+
+        resp.setPhysical(physical);
+        resp.setBmi(bmi);
+        resp.setBloodPressure(bp);
+        resp.setBloodGlucose(bg);
+        resp.setHeartRate(hr);
+        return resp;
+    }
+
+    @Override
+    public void saveForm(CriticalValuesFormRequest req) {
+        if (req == null) return;
+        // helper to upsert a single type
+        java.util.function.BiConsumer<String, java.util.function.Supplier<java.math.BigDecimal[]>> upsert = (code, supplier) -> {
+            java.math.BigDecimal[] arr = supplier.get();
+            java.math.BigDecimal low = arr[0];
+            java.math.BigDecimal high = arr[1];
+
+            QueryWrapper<CriticalValue> wrapper = new QueryWrapper<>();
+            wrapper.eq("value_type", code);
+            CriticalValue exist = criticalValueMapper.selectOne(wrapper);
+            if (exist == null) {
+                // 如果两边都为空,则不插入空记录
+                if (low == null && high == null) return;
+                CriticalValue v = new CriticalValue();
+                v.setValueType(work.baiyun.chronicdiseaseapp.enums.ValueType.fromCode(code));
+                v.setCriticalBelowValue(low);
+                v.setCriticalAboveValue(high);
+                criticalValueMapper.insert(v);
+            } else {
+                // 使用 UpdateWrapper 显式设置列,确保能将列更新为 NULL(绕过全局忽略 null 的更新策略)
+                com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<CriticalValue> uw = new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<>();
+                uw.eq("id", exist.getId());
+                if (low != null) {
+                    uw.set("critical_below_value", low);
+                } else {
+                    uw.setSql("critical_below_value = NULL");
+                }
+                if (high != null) {
+                    uw.set("critical_above_value", high);
+                } else {
+                    uw.setSql("critical_above_value = NULL");
+                }
+                criticalValueMapper.update(null, uw);
+            }
+        };
+
+        // physical.height
+        upsert.accept("height", () -> new java.math.BigDecimal[]{
+                req.getPhysical() == null ? null : req.getPhysical().getHeightMin(),
+                req.getPhysical() == null ? null : req.getPhysical().getHeightMax()
+        });
+        // physical.weight
+        upsert.accept("weight", () -> new java.math.BigDecimal[]{
+                req.getPhysical() == null ? null : req.getPhysical().getWeightMin(),
+                req.getPhysical() == null ? null : req.getPhysical().getWeightMax()
+        });
+        // bmi
+        upsert.accept("bmi", () -> new java.math.BigDecimal[]{
+                req.getBmi() == null ? null : req.getBmi().getMin(),
+                req.getBmi() == null ? null : req.getBmi().getMax()
+        });
+        // systolic
+        upsert.accept("systolic", () -> new java.math.BigDecimal[]{
+                req.getBloodPressure() == null ? null : req.getBloodPressure().getSystolicMin(),
+                req.getBloodPressure() == null ? null : req.getBloodPressure().getSystolicMax()
+        });
+        // diastolic
+        upsert.accept("diastolic", () -> new java.math.BigDecimal[]{
+                req.getBloodPressure() == null ? null : req.getBloodPressure().getDiastolicMin(),
+                req.getBloodPressure() == null ? null : req.getBloodPressure().getDiastolicMax()
+        });
+        // fasting
+        upsert.accept("fasting", () -> new java.math.BigDecimal[]{
+                req.getBloodGlucose() == null ? null : req.getBloodGlucose().getFastingMin(),
+                req.getBloodGlucose() == null ? null : req.getBloodGlucose().getFastingMax()
+        });
+        // random
+        upsert.accept("random", () -> new java.math.BigDecimal[]{
+                req.getBloodGlucose() == null ? null : req.getBloodGlucose().getRandomMin(),
+                req.getBloodGlucose() == null ? null : req.getBloodGlucose().getRandomMax()
+        });
+        // heartRate
+        upsert.accept("heartRate", () -> new java.math.BigDecimal[]{
+                req.getHeartRate() == null ? null : req.getHeartRate().getMin(),
+                req.getHeartRate() == null ? null : req.getHeartRate().getMax()
+        });
+    }
+
+    @Override
+    public void resetForm() {
+        // delete all the types we manage
+        QueryWrapper<CriticalValue> wrapper = new QueryWrapper<>();
+        wrapper.in("value_type", "height", "weight", "bmi", "systolic", "diastolic", "fasting", "random", "heartRate");
+        criticalValueMapper.delete(wrapper);
+    }
+}