Przeglądaj źródła

算法3.0更新,待测试

Made-with: Cursor
jiapu 5 miesięcy temu
rodzic
commit
e779ad6690

+ 9 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/UserProfileDTO.java

@@ -44,6 +44,15 @@ public class UserProfileDTO {
     @NotNull(message = "身长不能为空")
     private BigDecimal bodyHeight;
 
+    /** 脊柱长度C7-S4(cm,选填,30-60) */
+    private BigDecimal spineLength;
+
+    /** 身高(cm,选填,用于BMI推算指寸) */
+    private BigDecimal height;
+
+    /** 体重(kg,选填,用于BMI推算指寸) */
+    private BigDecimal weight;
+
     /** 1寸指宽(cm,选填) */
     private BigDecimal fingerWidth1;
 

+ 3 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/Acupoint.java

@@ -44,6 +44,9 @@ public class Acupoint {
     /** 相对大椎穴横向偏移(寸,双侧穴位填写) */
     private BigDecimal lateralOffset;
 
+    /** Panjabi归一化Y位置系数(0~1,基于C7-S4脊柱长度) */
+    private BigDecimal normalizedY;
+
     /**
      * 侧别:1=中心,2=左侧,3=右侧,4=双侧
      */

+ 6 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/UserAcupoint.java

@@ -46,6 +46,12 @@ public class UserAcupoint {
     /** 计算后的Y坐标(mm,相对大椎穴中心,向下为正) */
     private BigDecimal coordinateY;
 
+    /** 计算后的Z坐标(mm,体表=0) */
+    private BigDecimal coordinateZ;
+
+    /** 置信度:high=高/medium=中/low=低 */
+    private String confidence;
+
     /** 首次生成时间(自动填充) */
     @TableField(fill = FieldFill.INSERT)
     private LocalDateTime createTime;

+ 9 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/UserProfile.java

@@ -39,6 +39,15 @@ public class UserProfile {
     /** 身长(cm)(坐高) */
     private BigDecimal bodyHeight;
 
+    /** 脊柱长度C7-S4(cm,30-60) */
+    private BigDecimal spineLength;
+
+    /** 身高(cm) */
+    private BigDecimal height;
+
+    /** 体重(kg) */
+    private BigDecimal weight;
+
     /** 1寸指宽(cm,选填,提高穴位精度) */
     @TableField("finger_width_1")
     private BigDecimal fingerWidth1;

+ 271 - 173
code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserAcupointServiceImpl.java

@@ -18,97 +18,112 @@ import org.springframework.transaction.annotation.Transactional;
 
 import java.math.BigDecimal;
 import java.math.RoundingMode;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
+import java.util.*;
 
 /**
- * 用户穴位坐标 Service 实现类
- * 核心功能:根据子用户体型数据(指宽/肩宽/身长)与穴位定位参数,计算个性化穴位坐标
+ * 用户穴位坐标 Service 实现类(算法3.0)
  *
- * <p>坐标计算规则(相对定位):</p>
- * <ul>
- *   <li>1寸(mm) = 对应指宽(cm)× 10</li>
- *   <li>coordinateY(mm)= 纵向偏移(寸)× 1寸换算值</li>
- *   <li>coordinateX(mm)= 横向偏移(寸)× 1寸换算值,中心穴位固定为 0</li>
- *   <li>双侧穴位(side=4)生成 left/right 两条记录,X 取反对称</li>
- * </ul>
+ * <p>核心算法流程:</p>
+ * <ol>
+ *   <li>三路指寸融合:A路(实测加权) + B路(BMI换算) → 最终指寸(mm)</li>
+ *   <li>背部一寸宽度:肩宽推算 + 指寸验证 双源融合</li>
+ *   <li>Y轴:Panjabi椎骨归一化位置 × 脊柱长度,回退为 纵向偏移(寸) × 最终指寸</li>
+ *   <li>X轴:横向偏移 × 背部一寸宽度 × 区域系数;八髎穴采用骶后孔解剖基准偏移</li>
+ *   <li>Z轴:体表 = 0</li>
+ *   <li>置信度:双路→高,单路→中,无→低</li>
+ * </ol>
  */
 @Service
 public class UserAcupointServiceImpl extends ServiceImpl<UserAcupointMapper, UserAcupoint> implements UserAcupointService {
 
-    /** 指宽未设置时使用的默认 1寸换算值(mm),取中医标准均值 18 mm */
-    private static final BigDecimal DEFAULT_ONE_CUN_MM = new BigDecimal("18.0");
+    // ==================== 常量 ====================
+
+    /** 标准人BMI: 65kg / 1.70m^2 ≈ 22.49 */
+    private static final BigDecimal STANDARD_BMI = new BigDecimal("22.49");
+    /** 标准人指寸 24.0mm */
+    private static final BigDecimal STANDARD_CUN_MM = new BigDecimal("24.0");
+    /** 标准参考人身高 160cm(八髎穴基准偏移参照) */
+    private static final BigDecimal STANDARD_HEIGHT_CM = new BigDecimal("160.0");
+    /** 肩宽 → 肩胛骨内缘间距比例系数 */
+    private static final BigDecimal SCAPULA_RATIO = new BigDecimal("0.420");
+    /** 肩胛骨内缘间距对应寸数 = 6寸 */
+    private static final BigDecimal SCAPULA_CUN_COUNT = new BigDecimal("6");
+    /** 骶段区域收窄系数 */
+    private static final BigDecimal SACRAL_REGION_COEFFICIENT = new BigDecimal("0.85");
+    /** 判定骶段的 normalizedY 阈值(≥0.750 视为骶段) */
+    private static final BigDecimal SACRAL_Y_THRESHOLD = new BigDecimal("0.750");
+
+    private static final BigDecimal TWO = new BigDecimal("2");
+    private static final BigDecimal BD_1_5 = new BigDecimal("1.5");
+    private static final BigDecimal BD_3 = new BigDecimal("3");
+    private static final BigDecimal BD_100 = new BigDecimal("100");
+    private static final BigDecimal BD_50 = new BigDecimal("50");
+    private static final BigDecimal BD_25 = new BigDecimal("25");
+    private static final BigDecimal DIFF_THRESHOLD = new BigDecimal("0.15");
+
+    /** 八髎穴骶后孔基准偏移(mm),基于160cm标准参考人CT测量 */
+    private static final Map<String, BigDecimal> BALIAO_OFFSETS;
+    static {
+        Map<String, BigDecimal> m = new HashMap<>();
+        m.put("shangliao", new BigDecimal("15"));
+        m.put("ciliao",    new BigDecimal("17"));
+        m.put("zhongliao", new BigDecimal("16"));
+        m.put("xialiao",   new BigDecimal("11"));
+        BALIAO_OFFSETS = Collections.unmodifiableMap(m);
+    }
 
     @Autowired
     private UserProfileMapper userProfileMapper;
-
     @Autowired
     private AcupointMapper acupointMapper;
 
-    /**
-     * 为指定子用户生成(或重新生成)全量穴位坐标
-     *
-     * @param profileId 子用户ID(user_profile.id)
-     */
+    // ==================== 公开接口 ====================
+
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void generateForUser(Long profileId) {
-        // 1. 校验子用户存在
         UserProfile profile = userProfileMapper.selectById(profileId);
         if (profile == null) {
             throw new BusinessException(ResultCode.PROFILE_NOT_FOUND);
         }
 
-        // 2. 查询全量活跃穴位
         LambdaQueryWrapper<Acupoint> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(Acupoint::getStatus, 1);
         List<Acupoint> acupoints = acupointMapper.selectList(wrapper);
 
-        // 3. 清除该用户旧的穴位坐标记录
         baseMapper.deleteByUserId(profileId);
 
-        // 4. 计算并批量插入
         List<UserAcupoint> records = buildAcupointRecords(profile, acupoints);
         if (!records.isEmpty()) {
             saveBatch(records);
         }
 
-        // 5. 更新 user_profile.acupoint_table_id(使用 profileId 标记已生成)
         UserProfile update = new UserProfile();
         update.setId(profileId);
         update.setAcupointTableId(profileId);
         userProfileMapper.updateById(update);
 
         LogUtil.info(UserAcupointServiceImpl.class,
-                "为子用户[{}]生成穴位坐标,共生成[{}]条记录", profileId, records.size());
+                "为子用户[{}]生成穴位坐标(算法3.0),共[{}]条记录,置信度[{}]",
+                profileId, records.size(), determineConfidence(profile));
     }
 
-    /**
-     * 当穴位管理新增一条穴位时,为所有已生成过穴位表的子用户追加该穴位坐标
-     *
-     * @param acupointId 新增的穴位ID
-     */
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void generateForAllUsersByAcupoint(Long acupointId) {
-        // 1. 查询新增穴位信息
         Acupoint acupoint = acupointMapper.selectById(acupointId);
         if (acupoint == null) {
             LogUtil.info(UserAcupointServiceImpl.class, "穴位[{}]不存在,跳过批量生成", acupointId);
             return;
         }
 
-        // 2. 查询所有已生成过穴位表的子用户(acupoint_table_id 不为空)
         LambdaQueryWrapper<UserProfile> profileWrapper = new LambdaQueryWrapper<>();
         profileWrapper.isNotNull(UserProfile::getAcupointTableId);
         List<UserProfile> profiles = userProfileMapper.selectList(profileWrapper);
 
         int total = 0;
         for (UserProfile profile : profiles) {
-            // 3. 删除该用户下此穴位的旧记录(幂等处理)
             baseMapper.deleteByUserIdAndAcupointId(profile.getId(), acupointId);
-            // 4. 为该用户计算并插入新穴位坐标
             List<UserAcupoint> records = buildAcupointRecords(profile, Collections.singletonList(acupoint));
             if (!records.isEmpty()) {
                 saveBatch(records);
@@ -120,51 +135,225 @@ public class UserAcupointServiceImpl extends ServiceImpl<UserAcupointMapper, Use
                 acupointId, profiles.size(), total);
     }
 
-    /**
-     * 查询指定子用户的穴位坐标列表
-     *
-     * @param profileId 子用户ID
-     * @return 穴位坐标列表
-     */
     @Override
     public List<UserAcupoint> listByUser(Long profileId) {
         return baseMapper.listByUserId(profileId);
     }
 
-    // ======================== 核心算法 ========================
+    // ==================== 核心算法(3.0) ====================
 
     /**
-     * 为单个子用户批量构建穴位坐标记录列表
-     *
-     * @param profile   子用户体型数据
-     * @param acupoints 需要计算的穴位列表
-     * @return 待插入的 UserAcupoint 记录列表
+     * 为单个子用户批量构建穴位坐标记录
+     * 先预算全局参数(最终指寸、背部一寸宽度、置信度),再逐穴位计算坐标
      */
     private List<UserAcupoint> buildAcupointRecords(UserProfile profile, List<Acupoint> acupoints) {
+        boolean hasRouteA = hasRouteA(profile);
+        BigDecimal finalCunMm = calculateFinalCunMm(profile);
+        BigDecimal backOneCunWidth = calculateBackOneCunWidth(profile, finalCunMm, hasRouteA);
+        String confidence = determineConfidence(profile);
+
         List<UserAcupoint> records = new ArrayList<>();
         for (Acupoint acupoint : acupoints) {
             if (acupoint.getLocationType() == null) {
                 continue;
             }
             if (acupoint.getLocationType() == 2) {
-                // 绝对定位:直接使用 abs_x/abs_y
-                records.add(buildAbsoluteRecord(profile.getId(), acupoint));
+                records.add(buildAbsoluteRecord(profile.getId(), acupoint, confidence));
             } else {
-                // 相对定位:按指寸换算坐标
-                records.addAll(buildRelativeRecords(profile, acupoint));
+                records.addAll(buildRelativeRecords(profile, acupoint, finalCunMm, backOneCunWidth, confidence));
             }
         }
         return records;
     }
 
+    // -------------------- 第一步:三路指寸融合 --------------------
+
+    private boolean hasRouteA(UserProfile profile) {
+        return profile.getFingerWidth1() != null
+                || profile.getFingerWidth15() != null
+                || profile.getFingerWidth3() != null;
+    }
+
+    /**
+     * A路:实测融合值
+     * 1寸值权重50%,1.5寸值(折算为1寸)权重25%,3寸值(折算为1寸)权重25%
+     * 返回 null 表示无实测数据
+     */
+    private BigDecimal calculateRouteA(UserProfile profile) {
+        BigDecimal totalWeight = BigDecimal.ZERO;
+        BigDecimal weightedSum = BigDecimal.ZERO;
+
+        if (profile.getFingerWidth1() != null) {
+            BigDecimal valueMm = profile.getFingerWidth1().multiply(BigDecimal.TEN);
+            weightedSum = weightedSum.add(valueMm.multiply(BD_50));
+            totalWeight = totalWeight.add(BD_50);
+        }
+        if (profile.getFingerWidth15() != null) {
+            BigDecimal valueMm = profile.getFingerWidth15()
+                    .divide(BD_1_5, 4, RoundingMode.HALF_UP)
+                    .multiply(BigDecimal.TEN);
+            weightedSum = weightedSum.add(valueMm.multiply(BD_25));
+            totalWeight = totalWeight.add(BD_25);
+        }
+        if (profile.getFingerWidth3() != null) {
+            BigDecimal valueMm = profile.getFingerWidth3()
+                    .divide(BD_3, 4, RoundingMode.HALF_UP)
+                    .multiply(BigDecimal.TEN);
+            weightedSum = weightedSum.add(valueMm.multiply(BD_25));
+            totalWeight = totalWeight.add(BD_25);
+        }
+
+        if (totalWeight.compareTo(BigDecimal.ZERO) == 0) {
+            return null;
+        }
+        return weightedSum.divide(totalWeight, 2, RoundingMode.HALF_UP);
+    }
+
+    /**
+     * B路:BMI换算法
+     * B路指寸(mm) = 24.0 × (用户BMI / 标准人BMI)
+     * 返回 null 表示身高体重数据不可用
+     */
+    private BigDecimal calculateRouteB(UserProfile profile) {
+        if (profile.getHeight() == null || profile.getWeight() == null) {
+            return null;
+        }
+        if (profile.getHeight().compareTo(BigDecimal.ZERO) <= 0
+                || profile.getWeight().compareTo(BigDecimal.ZERO) <= 0) {
+            return null;
+        }
+        BigDecimal heightM = profile.getHeight().divide(BD_100, 4, RoundingMode.HALF_UP);
+        BigDecimal userBMI = profile.getWeight().divide(
+                heightM.multiply(heightM), 4, RoundingMode.HALF_UP);
+        BigDecimal bmiRatio = userBMI.divide(STANDARD_BMI, 4, RoundingMode.HALF_UP);
+        return STANDARD_CUN_MM.multiply(bmiRatio).setScale(2, RoundingMode.HALF_UP);
+    }
+
+    /**
+     * 最终指寸(mm) = 双路平均 > 单路 > 默认24mm
+     */
+    private BigDecimal calculateFinalCunMm(UserProfile profile) {
+        BigDecimal routeA = calculateRouteA(profile);
+        BigDecimal routeB = calculateRouteB(profile);
+
+        if (routeA != null && routeB != null) {
+            return routeA.add(routeB).divide(TWO, 2, RoundingMode.HALF_UP);
+        } else if (routeA != null) {
+            return routeA;
+        } else if (routeB != null) {
+            return routeB;
+        }
+        return STANDARD_CUN_MM;
+    }
+
+    // -------------------- 第二步:背部一寸宽度(双源融合) --------------------
+
+    /**
+     * 来源一:肩宽推算 = 肩宽(mm) × 0.420 / 6
+     * 来源二:指寸验证 = 最终指寸(mm)(体型修正系数微调简化为0)
+     * 融合策略:差异率≤15%取平均;否则有实测取指寸,无实测取肩宽
+     */
+    private BigDecimal calculateBackOneCunWidth(UserProfile profile, BigDecimal finalCunMm, boolean hasRouteA) {
+        BigDecimal backCunShoulder = null;
+
+        if (profile.getShoulderWidth() != null
+                && profile.getShoulderWidth().compareTo(BigDecimal.ZERO) > 0) {
+            BigDecimal shoulderMm = profile.getShoulderWidth().multiply(BigDecimal.TEN);
+            BigDecimal scapulaDistance = shoulderMm.multiply(SCAPULA_RATIO);
+            backCunShoulder = scapulaDistance.divide(SCAPULA_CUN_COUNT, 2, RoundingMode.HALF_UP);
+        }
+
+        if (backCunShoulder != null) {
+            BigDecimal diff = backCunShoulder.subtract(finalCunMm).abs();
+            BigDecimal diffRate = diff.divide(backCunShoulder, 4, RoundingMode.HALF_UP);
+
+            if (diffRate.compareTo(DIFF_THRESHOLD) <= 0) {
+                return backCunShoulder.add(finalCunMm).divide(TWO, 2, RoundingMode.HALF_UP);
+            } else if (hasRouteA) {
+                return finalCunMm;
+            } else {
+                return backCunShoulder;
+            }
+        }
+        return finalCunMm;
+    }
+
+    // -------------------- 第三步:置信度 --------------------
+
+    private String determineConfidence(UserProfile profile) {
+        boolean a = hasRouteA(profile);
+        boolean b = profile.getHeight() != null && profile.getWeight() != null;
+        if (a && b) return "high";
+        if (a || b) return "medium";
+        return "low";
+    }
+
+    // -------------------- 第四步:Y轴坐标 --------------------
+
+    /**
+     * 优先使用 Panjabi归一化: Y = 脊柱长度(mm) × normalizedY
+     * 回退使用 寸制: Y = longitudeOffset × finalCunMm
+     */
+    private BigDecimal calculateY(UserProfile profile, Acupoint acupoint, BigDecimal finalCunMm) {
+        if (acupoint.getNormalizedY() != null && profile.getSpineLength() != null
+                && profile.getSpineLength().compareTo(BigDecimal.ZERO) > 0) {
+            BigDecimal spineLengthMm = profile.getSpineLength().multiply(BigDecimal.TEN);
+            return spineLengthMm.multiply(acupoint.getNormalizedY()).setScale(2, RoundingMode.HALF_UP);
+        }
+        if (acupoint.getLongitudeOffset() != null) {
+            return acupoint.getLongitudeOffset().multiply(finalCunMm).setScale(2, RoundingMode.HALF_UP);
+        }
+        return BigDecimal.ZERO;
+    }
+
+    // -------------------- 第五步:X轴坐标 --------------------
+
+    /**
+     * 横向坐标计算(分策略):
+     * - 督脉/中心穴位: X=0
+     * - 膀胱经第一/二侧线: lateralOffset × backOneCunWidth × 区域系数
+     * - 八髎穴: 骶后孔基准偏移 × 身高缩放比例
+     */
+    private BigDecimal calculateLateralMm(Acupoint acupoint, BigDecimal backOneCunWidth, UserProfile profile) {
+        String code = acupoint.getAcupointCode();
+        if (code != null && BALIAO_OFFSETS.containsKey(code)) {
+            return calculateBaliaoX(code, profile);
+        }
+
+        BigDecimal lateralOffset = acupoint.getLateralOffset();
+        if (lateralOffset == null || lateralOffset.compareTo(BigDecimal.ZERO) == 0) {
+            return BigDecimal.ZERO;
+        }
+
+        BigDecimal regionCoefficient = BigDecimal.ONE;
+        if (acupoint.getNormalizedY() != null
+                && acupoint.getNormalizedY().compareTo(SACRAL_Y_THRESHOLD) >= 0) {
+            regionCoefficient = SACRAL_REGION_COEFFICIENT;
+        }
+
+        return lateralOffset.multiply(backOneCunWidth)
+                .multiply(regionCoefficient)
+                .setScale(2, RoundingMode.HALF_UP);
+    }
+
     /**
-     * 绝对定位穴位:直接使用 abs_x/abs_y 构建一条坐标记录
-     *
-     * @param profileId 子用户ID
-     * @param acupoint  穴位信息
-     * @return 坐标记录
+     * 八髎穴X轴: 基准偏移(mm) × (用户身高 / 160cm标准身高)
      */
-    private UserAcupoint buildAbsoluteRecord(Long profileId, Acupoint acupoint) {
+    private BigDecimal calculateBaliaoX(String acupointCode, UserProfile profile) {
+        BigDecimal baseOffset = BALIAO_OFFSETS.get(acupointCode);
+        if (baseOffset == null) {
+            return BigDecimal.ZERO;
+        }
+        BigDecimal heightScale = BigDecimal.ONE;
+        if (profile.getHeight() != null && profile.getHeight().compareTo(BigDecimal.ZERO) > 0) {
+            heightScale = profile.getHeight().divide(STANDARD_HEIGHT_CM, 4, RoundingMode.HALF_UP);
+        }
+        return baseOffset.multiply(heightScale).setScale(2, RoundingMode.HALF_UP);
+    }
+
+    // -------------------- 记录构建 --------------------
+
+    private UserAcupoint buildAbsoluteRecord(Long profileId, Acupoint acupoint, String confidence) {
         UserAcupoint record = new UserAcupoint();
         record.setUserId(profileId);
         record.setAcupointId(acupoint.getId());
@@ -174,147 +363,56 @@ public class UserAcupointServiceImpl extends ServiceImpl<UserAcupointMapper, Use
         record.setCunType(null);
         record.setCoordinateX(acupoint.getAbsX() != null ? acupoint.getAbsX() : BigDecimal.ZERO);
         record.setCoordinateY(acupoint.getAbsY() != null ? acupoint.getAbsY() : BigDecimal.ZERO);
+        record.setCoordinateZ(BigDecimal.ZERO);
+        record.setConfidence(confidence);
         return record;
     }
 
-    /**
-     * 相对定位穴位:根据子用户体型数据计算坐标,按侧别生成 1~2 条记录
-     *
-     * @param profile  子用户体型数据
-     * @param acupoint 穴位信息
-     * @return 坐标记录列表(中心1条;单侧1条;双侧2条)
-     */
-    private List<UserAcupoint> buildRelativeRecords(UserProfile profile, Acupoint acupoint) {
+    private List<UserAcupoint> buildRelativeRecords(UserProfile profile, Acupoint acupoint,
+                                                     BigDecimal finalCunMm, BigDecimal backOneCunWidth,
+                                                     String confidence) {
         List<UserAcupoint> list = new ArrayList<>();
 
-        // 获取 1寸 对应的 mm 换算值
-        BigDecimal oneCunMm = getOneCunMm(profile, acupoint.getFingerWidthType());
-        // 纵向坐标(Y):纵向偏移 × 1寸换算值
-        BigDecimal coordY = acupoint.getLongitudeOffset() != null
-                ? acupoint.getLongitudeOffset().multiply(oneCunMm).setScale(2, RoundingMode.HALF_UP)
-                : BigDecimal.ZERO;
-        // 横向偏移值(用于左/右侧计算)
-        BigDecimal lateralMm = acupoint.getLateralOffset() != null
-                ? acupoint.getLateralOffset().multiply(oneCunMm).setScale(2, RoundingMode.HALF_UP)
-                : BigDecimal.ZERO;
-
-        // 指寸类型文本
-        String cunTypeText = resolveCunType(acupoint.getFingerWidthType());
+        BigDecimal coordY = calculateY(profile, acupoint, finalCunMm);
+        BigDecimal lateralMm = calculateLateralMm(acupoint, backOneCunWidth, profile);
 
         Integer side = acupoint.getSide();
         if (side == null || side == 1) {
-            // 中心穴位,X=0
-            list.add(buildRecord(profile.getId(), acupoint, "center",
-                    acupoint.getLongitudeOffset(), cunTypeText, BigDecimal.ZERO, coordY));
+            list.add(buildRecord(profileId(profile), acupoint, "center",
+                    BigDecimal.ZERO, coordY, confidence));
         } else if (side == 2) {
-            // 仅左侧,X 为负数(向左)
-            list.add(buildRecord(profile.getId(), acupoint, "left",
-                    acupoint.getLongitudeOffset(), cunTypeText, lateralMm.negate(), coordY));
+            list.add(buildRecord(profileId(profile), acupoint, "left",
+                    lateralMm.negate(), coordY, confidence));
         } else if (side == 3) {
-            // 仅右侧,X 为正数(向右)
-            list.add(buildRecord(profile.getId(), acupoint, "right",
-                    acupoint.getLongitudeOffset(), cunTypeText, lateralMm, coordY));
+            list.add(buildRecord(profileId(profile), acupoint, "right",
+                    lateralMm, coordY, confidence));
         } else if (side == 4) {
-            // 双侧:生成左右两条记录
-            list.add(buildRecord(profile.getId(), acupoint, "left",
-                    acupoint.getLongitudeOffset(), cunTypeText, lateralMm.negate(), coordY));
-            list.add(buildRecord(profile.getId(), acupoint, "right",
-                    acupoint.getLongitudeOffset(), cunTypeText, lateralMm, coordY));
+            list.add(buildRecord(profileId(profile), acupoint, "left",
+                    lateralMm.negate(), coordY, confidence));
+            list.add(buildRecord(profileId(profile), acupoint, "right",
+                    lateralMm, coordY, confidence));
         }
         return list;
     }
 
-    /**
-     * 构建单条 UserAcupoint 记录
-     *
-     * @param profileId   子用户ID
-     * @param acupoint    穴位信息
-     * @param side        侧别(center/left/right)
-     * @param cunValue    纵向偏移(寸)
-     * @param cunType     指寸类型文本
-     * @param coordinateX X 坐标(mm)
-     * @param coordinateY Y 坐标(mm)
-     * @return 坐标记录
-     */
     private UserAcupoint buildRecord(Long profileId, Acupoint acupoint, String side,
-                                      BigDecimal cunValue, String cunType,
-                                      BigDecimal coordinateX, BigDecimal coordinateY) {
+                                      BigDecimal coordinateX, BigDecimal coordinateY,
+                                      String confidence) {
         UserAcupoint record = new UserAcupoint();
         record.setUserId(profileId);
         record.setAcupointId(acupoint.getId());
         record.setAcupointName(acupoint.getName());
         record.setAcupointSide(side);
-        record.setCunValue(cunValue);
-        record.setCunType(cunType);
+        record.setCunValue(acupoint.getLongitudeOffset());
+        record.setCunType(null);
         record.setCoordinateX(coordinateX);
         record.setCoordinateY(coordinateY);
+        record.setCoordinateZ(BigDecimal.ZERO);
+        record.setConfidence(confidence);
         return record;
     }
 
-    /**
-     * 根据穴位的指寸类型和子用户的实测指宽,计算 1寸 对应的 mm 换算值
-     * <ul>
-     *   <li>指寸类型=1(1寸):1寸 = fingerWidth1 × 10 mm</li>
-     *   <li>指寸类型=2(1.5寸):1寸 = (fingerWidth15 / 1.5) × 10 mm</li>
-     *   <li>指寸类型=3(3寸):1寸 = (fingerWidth3 / 3) × 10 mm</li>
-     *   <li>如未设置指宽则使用默认值 18 mm/寸</li>
-     * </ul>
-     *
-     * @param profile          子用户体型数据
-     * @param fingerWidthType  穴位指寸类型(1/2/3)
-     * @return 1寸对应的 mm 值
-     */
-    private BigDecimal getOneCunMm(UserProfile profile, Integer fingerWidthType) {
-        if (fingerWidthType == null) {
-            return DEFAULT_ONE_CUN_MM;
-        }
-        switch (fingerWidthType) {
-            case 1:
-                // 1寸指宽(cm)× 10 = mm
-                if (profile.getFingerWidth1() != null) {
-                    return profile.getFingerWidth1().multiply(BigDecimal.TEN).setScale(2, RoundingMode.HALF_UP);
-                }
-                break;
-            case 2:
-                // 1.5寸指宽 / 1.5 × 10 = 1寸 mm
-                if (profile.getFingerWidth15() != null) {
-                    return profile.getFingerWidth15()
-                            .divide(new BigDecimal("1.5"), 4, RoundingMode.HALF_UP)
-                            .multiply(BigDecimal.TEN)
-                            .setScale(2, RoundingMode.HALF_UP);
-                }
-                break;
-            case 3:
-                // 3寸指宽 / 3 × 10 = 1寸 mm
-                if (profile.getFingerWidth3() != null) {
-                    return profile.getFingerWidth3()
-                            .divide(new BigDecimal("3"), 4, RoundingMode.HALF_UP)
-                            .multiply(BigDecimal.TEN)
-                            .setScale(2, RoundingMode.HALF_UP);
-                }
-                break;
-            default:
-                break;
-        }
-        // 指宽未设置,使用默认值
-        return DEFAULT_ONE_CUN_MM;
-    }
-
-    /**
-     * 将穴位的指寸类型编号转为中文文本
-     *
-     * @param fingerWidthType 指寸类型编号(1/2/3)
-     * @return 中文类型文本(1寸/1.5寸/3寸)
-     */
-    private String resolveCunType(Integer fingerWidthType) {
-        if (fingerWidthType == null) {
-            return null;
-        }
-        switch (fingerWidthType) {
-            case 1: return "1寸";
-            case 2: return "1.5寸";
-            case 3: return "3寸";
-            default: return null;
-        }
+    private Long profileId(UserProfile profile) {
+        return profile.getId();
     }
 }

+ 3 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserProfileServiceImpl.java

@@ -233,6 +233,9 @@ public class UserProfileServiceImpl extends ServiceImpl<UserProfileMapper, UserP
         profile.setPhone(dto.getPhone());
         profile.setShoulderWidth(dto.getShoulderWidth());
         profile.setBodyHeight(dto.getBodyHeight());
+        profile.setSpineLength(dto.getSpineLength());
+        profile.setHeight(dto.getHeight());
+        profile.setWeight(dto.getWeight());
         profile.setFingerWidth1(dto.getFingerWidth1());
         profile.setFingerWidth15(dto.getFingerWidth15());
         profile.setFingerWidth3(dto.getFingerWidth3());

+ 20 - 14
code/backend/src/main/resources/sql/init.sql

@@ -68,6 +68,9 @@ CREATE TABLE IF NOT EXISTS `user_profile` (
     `phone`             VARCHAR(20)           COMMENT '手机号(唯一)',
     `shoulder_width`    DECIMAL(5,1)          COMMENT '肩宽(cm)',
     `body_height`       DECIMAL(5,1)          COMMENT '身长/坐高(cm)',
+    `spine_length`      DECIMAL(5,1)          COMMENT '脊柱长度C7-S4(cm,范围30-60)',
+    `height`            DECIMAL(5,1)          COMMENT '身高(cm)',
+    `weight`            DECIMAL(5,1)          COMMENT '体重(kg)',
     `finger_width_1`    DECIMAL(4,2)          COMMENT '1寸指宽(cm,选填,提高穴位精度)',
     `finger_width_15`   DECIMAL(4,2)          COMMENT '1.5寸指宽(cm,选填)',
     `finger_width_3`    DECIMAL(4,2)          COMMENT '3寸指宽(cm,选填)',
@@ -140,6 +143,7 @@ CREATE TABLE IF NOT EXISTS `acupoint` (
     `location_type`     TINYINT                COMMENT '定位方式:1=相对定位,2=绝对定位',
     `longitude_offset`  DECIMAL(6,2)           COMMENT '相对大椎穴纵向偏移(寸)',
     `lateral_offset`    DECIMAL(6,2)           COMMENT '相对大椎穴横向偏移(寸,双侧穴位填写)',
+    `normalized_y`      DECIMAL(6,4)           COMMENT 'Panjabi归一化Y位置系数(0~1,基于C7-S4脊柱长度)',
     `side`              TINYINT                COMMENT '侧别:1=中心,2=左侧,3=右侧,4=双侧',
     `finger_width_type` TINYINT                COMMENT '指寸类型:1=一寸,2=1.5寸,3=三寸',
     `abs_x`             DECIMAL(8,2)           COMMENT '绝对坐标X(mm)',
@@ -156,20 +160,20 @@ CREATE TABLE IF NOT EXISTS `acupoint` (
   DEFAULT CHARSET = utf8mb4
   COMMENT = '穴位信息表';
 
--- 初始化12个常用穴位
-INSERT INTO `acupoint` (`acupoint_code`, `name`, `meridian`, `effects`, `location_type`, `longitude_offset`, `lateral_offset`, `side`, `finger_width_type`, `gender`, `status`, `deleted`, `create_time`, `update_time`) VALUES
-('dazhui',   '大椎穴', '督脉',         '驱寒,祛湿',   1, 0.00,  NULL,  1, 1, 0, 1, 0, NOW(), NOW()),
-('fengmen',  '风门穴', '足太阳膀胱经', '祛风,解表',   1, 2.00,  1.50,  4, 1, 0, 1, 0, NOW(), NOW()),
-('feishu',   '肺俞穴', '足太阳膀胱经', '宣肺,止咳',   1, 3.00,  1.50,  4, 1, 0, 1, 0, NOW(), NOW()),
-('xinshu',   '心俞穴', '足太阳膀胱经', '养心,安神',   1, 5.00,  1.50,  4, 1, 0, 1, 0, NOW(), NOW()),
-('ganshu',   '肝俞穴', '足太阳膀胱经', '疏肝,理气',   1, 9.00,  1.50,  4, 1, 0, 1, 0, NOW(), NOW()),
-('pishu',    '脾俞穴', '足太阳膀胱经', '健脾,祛湿',   1, 11.00, 1.50,  4, 1, 0, 1, 0, NOW(), NOW()),
-('weishu',   '胃俞穴', '足太阳膀胱经', '和胃,消食',   1, 12.00, 1.50,  4, 1, 0, 1, 0, NOW(), NOW()),
-('shenshu',  '肾俞穴', '足太阳膀胱经', '补肾,扶阳',   1, 14.00, 1.50,  4, 1, 0, 1, 0, NOW(), NOW()),
-('mingmen',  '命门穴', '督脉',         '扶阳,固本',   1, 14.00, NULL,  1, 1, 0, 1, 0, NOW(), NOW()),
-('zhongwan', '中脘穴', '任脉',         '健脾,和胃',   2, NULL,  NULL,  1, NULL, 0, 1, 0, NOW(), NOW()),
-('qihai',    '气海穴', '任脉',         '扶阳,益气',   2, NULL,  NULL,  1, NULL, 0, 1, 0, NOW(), NOW()),
-('guanyuan', '关元穴', '任脉',         '温肾,扶阳',   2, NULL,  NULL,  1, NULL, 0, 1, 0, NOW(), NOW());
+-- 初始化12个常用穴位(含Panjabi归一化Y位置系数)
+INSERT INTO `acupoint` (`acupoint_code`, `name`, `meridian`, `effects`, `location_type`, `longitude_offset`, `lateral_offset`, `normalized_y`, `side`, `finger_width_type`, `gender`, `status`, `deleted`, `create_time`, `update_time`) VALUES
+('dazhui',   '大椎穴', '督脉',         '驱寒,祛湿',   1, 0.00,  NULL, 0.0000, 1, 1, 0, 1, 0, NOW(), NOW()),
+('fengmen',  '风门穴', '足太阳膀胱经', '祛风,解表',   1, 2.00,  1.50, 0.0780, 4, 1, 0, 1, 0, NOW(), NOW()),
+('feishu',   '肺俞穴', '足太阳膀胱经', '宣肺,止咳',   1, 3.00,  1.50, 0.1200, 4, 1, 0, 1, 0, NOW(), NOW()),
+('xinshu',   '心俞穴', '足太阳膀胱经', '养心,安神',   1, 5.00,  1.50, 0.2030, 4, 1, 0, 1, 0, NOW(), NOW()),
+('ganshu',   '肝俞穴', '足太阳膀胱经', '疏肝,理气',   1, 9.00,  1.50, 0.3610, 4, 1, 0, 1, 0, NOW(), NOW()),
+('pishu',    '脾俞穴', '足太阳膀胱经', '健脾,祛湿',   1, 11.00, 1.50, 0.4380, 4, 1, 0, 1, 0, NOW(), NOW()),
+('weishu',   '胃俞穴', '足太阳膀胱经', '和胃,消食',   1, 12.00, 1.50, 0.4760, 4, 1, 0, 1, 0, NOW(), NOW()),
+('shenshu',  '肾俞穴', '足太阳膀胱经', '补肾,扶阳',   1, 14.00, 1.50, 0.5520, 4, 1, 0, 1, 0, NOW(), NOW()),
+('mingmen',  '命门穴', '督脉',         '扶阳,固本',   1, 14.00, NULL, 0.5520, 1, 1, 0, 1, 0, NOW(), NOW()),
+('zhongwan', '中脘穴', '任脉',         '健脾,和胃',   2, NULL,  NULL, NULL,   1, NULL, 0, 1, 0, NOW(), NOW()),
+('qihai',    '气海穴', '任脉',         '扶阳,益气',   2, NULL,  NULL, NULL,   1, NULL, 0, 1, 0, NOW(), NOW()),
+('guanyuan', '关元穴', '任脉',         '温肾,扶阳',   2, NULL,  NULL, NULL,   1, NULL, 0, 1, 0, NOW(), NOW());
 
 -- ----------------------------
 -- 用户穴位坐标表(根据用户体型数据和穴位信息计算生成)
@@ -184,6 +188,8 @@ CREATE TABLE IF NOT EXISTS `user_acupoint` (
     `cun_type`       VARCHAR(10)            COMMENT '指寸类型:1寸/1.5寸/3寸',
     `coordinate_x`   DECIMAL(8,2)           COMMENT '计算后的X坐标(mm,相对大椎穴,向右为正)',
     `coordinate_y`   DECIMAL(8,2)           COMMENT '计算后的Y坐标(mm,相对大椎穴,向下为正)',
+    `coordinate_z`   DECIMAL(8,2)           COMMENT 'Z坐标(mm,体表=0)',
+    `confidence`     VARCHAR(10)            COMMENT '置信度:high=高/medium=中/low=低',
     `create_time`    DATETIME               COMMENT '首次生成时间',
     `update_time`    DATETIME               COMMENT '最后更新时间',
     PRIMARY KEY (`id`),

+ 31 - 0
code/backend/src/main/resources/sql/migration_algorithm_v3.sql

@@ -0,0 +1,31 @@
+-- ========== 算法3.0 数据库迁移脚本 ==========
+-- 请在已有数据库上执行此脚本,新建库请直接使用更新后的 init.sql
+
+SET NAMES utf8mb4;
+
+-- 1. user_profile 表新增字段
+ALTER TABLE `user_profile`
+    ADD COLUMN `spine_length` DECIMAL(5,1) DEFAULT NULL COMMENT '脊柱长度C7-S4(cm,范围30-60)' AFTER `body_height`,
+    ADD COLUMN `height`       DECIMAL(5,1) DEFAULT NULL COMMENT '身高(cm)' AFTER `spine_length`,
+    ADD COLUMN `weight`       DECIMAL(5,1) DEFAULT NULL COMMENT '体重(kg)' AFTER `height`;
+
+-- 2. acupoint 表新增字段
+ALTER TABLE `acupoint`
+    ADD COLUMN `normalized_y` DECIMAL(6,4) DEFAULT NULL COMMENT 'Panjabi归一化Y位置系数(0~1,基于C7-S4脊柱长度)' AFTER `lateral_offset`;
+
+-- 3. user_acupoint 表新增字段
+ALTER TABLE `user_acupoint`
+    ADD COLUMN `coordinate_z` DECIMAL(8,2) DEFAULT NULL COMMENT 'Z坐标(mm,体表=0)' AFTER `coordinate_y`,
+    ADD COLUMN `confidence`   VARCHAR(10)  DEFAULT NULL COMMENT '置信度:high=高/medium=中/low=低' AFTER `coordinate_z`;
+
+-- 4. 更新现有穴位的 Panjabi 归一化 Y 位置系数
+UPDATE `acupoint` SET `normalized_y` = 0.0000 WHERE `acupoint_code` = 'dazhui';
+UPDATE `acupoint` SET `normalized_y` = 0.0780 WHERE `acupoint_code` = 'fengmen';
+UPDATE `acupoint` SET `normalized_y` = 0.1200 WHERE `acupoint_code` = 'feishu';
+UPDATE `acupoint` SET `normalized_y` = 0.2030 WHERE `acupoint_code` = 'xinshu';
+UPDATE `acupoint` SET `normalized_y` = 0.3610 WHERE `acupoint_code` = 'ganshu';
+UPDATE `acupoint` SET `normalized_y` = 0.4380 WHERE `acupoint_code` = 'pishu';
+UPDATE `acupoint` SET `normalized_y` = 0.4760 WHERE `acupoint_code` = 'weishu';
+UPDATE `acupoint` SET `normalized_y` = 0.5520 WHERE `acupoint_code` = 'shenshu';
+UPDATE `acupoint` SET `normalized_y` = 0.5520 WHERE `acupoint_code` = 'mingmen';
+-- zhongwan / qihai / guanyuan 为绝对定位,normalized_y 保持 NULL