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

feat(admin): 体脂秤图片数据采集器

- 新增 BodyFatScaleService:扫描 docs/参考资料/体脂秤/ 目录下的图片
- 调用 DeepSeek-VL 视觉模型 OCR 提取体脂秤指标(体重/BMI/体脂率/肌肉量等)
- 自动创建 indicator_definitions + 保存 indicator_values(source_type=body_fat_scale)
- 新增 AdminBodyFatScaleController:
  - POST /api/admin/bodyfat-scale/images/list — 列出图片
  - POST /api/admin/bodyfat-scale/images/analyze — 单张分析预览
  - POST /api/admin/bodyfat-scale/save — 手动保存指标到成员
  - POST /api/admin/bodyfat-scale/batch — 批量处理多张图片
- application.yml 新增 bodyfat-scale.image-dir 和 deepseek.vision-* 配置
iwt 1 месяц назад
Родитель
Сommit
91d5a1a392

+ 68 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminBodyFatScaleController.java

@@ -0,0 +1,68 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.BodyFatScaleService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "体脂秤数据采集", description = "批量采集体脂秤图片中的身体指标数据")
+@RestController
+@RequestMapping("/api/admin/bodyfat-scale")
+public class AdminBodyFatScaleController {
+
+    @Resource
+    private BodyFatScaleService bodyFatScaleService;
+
+    @Operation(summary = "列出体脂秤图片目录中的所有图片")
+    @PostMapping("/images/list")
+    public Result<?> listImages() {
+        return Result.success(bodyFatScaleService.listImages());
+    }
+
+    @Operation(summary = "AI 分析单张图片,提取指标(预览,不保存)")
+    @PostMapping("/images/analyze")
+    public Result<?> analyzeImage(@RequestBody Map<String, String> params) {
+        String imagePath = params.get("imagePath");
+        if (imagePath == null || imagePath.isBlank()) {
+            return Result.error("imagePath 不能为空");
+        }
+        return Result.success(bodyFatScaleService.analyzeImage(imagePath));
+    }
+
+    @Operation(summary = "保存指标到指定成员(memberId)的 indicator_values 表")
+    @PostMapping("/save")
+    public Result<?> saveMetrics(@RequestBody Map<String, Object> params) {
+        Long memberId = params.get("memberId") != null
+                ? ((Number) params.get("memberId")).longValue() : null;
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> metrics = (List<Map<String, Object>>) params.get("metrics");
+        if (memberId == null) {
+            return Result.error("memberId 不能为空");
+        }
+        if (metrics == null || metrics.isEmpty()) {
+            return Result.error("metrics 不能为空");
+        }
+        return Result.success(bodyFatScaleService.saveMetrics(memberId, metrics));
+    }
+
+    @Operation(summary = "批量处理多张图片并保存")
+    @PostMapping("/batch")
+    public Result<?> batchProcess(@RequestBody Map<String, Object> params) {
+        Long memberId = params.get("memberId") != null
+                ? ((Number) params.get("memberId")).longValue() : null;
+        @SuppressWarnings("unchecked")
+        List<String> imagePaths = (List<String>) params.get("imagePaths");
+        if (memberId == null) {
+            return Result.error("memberId 不能为空");
+        }
+        if (imagePaths == null || imagePaths.isEmpty()) {
+            return Result.error("imagePaths 不能为空");
+        }
+        return Result.success(bodyFatScaleService.batchProcess(memberId, imagePaths));
+    }
+}

+ 346 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/BodyFatScaleService.java

@@ -0,0 +1,346 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
+import com.etotem.cfc.mapper.IndicatorValueMapper;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.*;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import javax.annotation.Resource;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+
+@Slf4j
+@Service
+public class BodyFatScaleService {
+
+    @Value("${bodyfat-scale.image-dir:docs/参考资料/体脂秤}")
+    private String imageDir;
+
+    @Value("${deepseek.vision-api-key:}")
+    private String visionApiKey;
+
+    @Value("${deepseek.vision-base-url:https://api.deepseek.com/v1}")
+    private String visionBaseUrl;
+
+    @Resource
+    private IndicatorDefinitionMapper indicatorDefinitionMapper;
+
+    @Resource
+    private IndicatorValueMapper indicatorValueMapper;
+
+    private final RestTemplate restTemplate = new RestTemplate();
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * 体脂秤指标编码定义
+     */
+    private static final Map<String, String> BODY_FAT_INDICATORS = Map.of(
+            "体重", "weight",
+            "BMI", "bmi",
+            "体脂率", "body_fat_percent",
+            "肌肉量", "muscle_mass",
+            "水分", "water_percent",
+            "蛋白质", "protein_percent",
+            "骨量", "bone_mass",
+            "内脏脂肪", "visceral_fat",
+            "基础代谢", "basal_metabolic"
+    );
+
+    /**
+     * 获取目录下所有图片文件
+     */
+    public List<Map<String, Object>> listImages() {
+        List<Map<String, Object>> result = new ArrayList<>();
+        Path dir = Paths.get(imageDir);
+        if (!Files.exists(dir)) {
+            return result;
+        }
+        try {
+            Files.list(dir)
+                    .filter(p -> {
+                        String name = p.getFileName().toString().toLowerCase();
+                        return name.endsWith(".jpg") || name.endsWith(".jpeg")
+                                || name.endsWith(".png") || name.endsWith(".webp");
+                    })
+                    .forEach(p -> {
+                        Map<String, Object> item = new LinkedHashMap<>();
+                        item.put("filename", p.getFileName().toString());
+                        item.put("path", p.toAbsolutePath().toString());
+                        item.put("size", p.toFile().length());
+                        item.put("modified", new Date(p.toFile().lastModified()));
+                        result.add(item);
+                    });
+        } catch (IOException e) {
+            log.warn("读取体脂秤图片目录失败: {}", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 对单张图片进行 AI 分析,返回提取的指标列表(未保存)
+     */
+    public Map<String, Object> analyzeImage(String imagePath) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("imagePath", imagePath);
+
+        File file = new File(imagePath);
+        if (!file.exists()) {
+            result.put("error", "文件不存在: " + imagePath);
+            result.put("metrics", Collections.emptyList());
+            return result;
+        }
+
+        // 读取图片并转 base64
+        String base64;
+        try {
+            byte[] bytes = Files.readAllBytes(file.toPath());
+            base64 = Base64.getEncoder().encodeToString(bytes);
+        } catch (IOException e) {
+            result.put("error", "读取图片失败: " + e.getMessage());
+            result.put("metrics", Collections.emptyList());
+            return result;
+        }
+
+        // 调用 DeepSeek-VL 进行 OCR + 结构化提取
+        List<Map<String, Object>> metrics = callVisionAI(base64);
+        result.put("metrics", metrics);
+        result.put("aiModel", "deepseek-vl");
+        return result;
+    }
+
+    /**
+     * 调用 DeepSeek-VL 视觉模型提取体脂秤指标
+     */
+    private List<Map<String, Object>> callVisionAI(String base64Image) {
+        if (visionApiKey == null || visionApiKey.isEmpty()) {
+            return mockMetrics();
+        }
+
+        String prompt = """
+                你是一名健康数据分析助手。请从这张体脂秤显示屏图片中提取所有可见的身体指标数据。
+                请以 JSON 数组格式返回,每个元素包含:
+                - name: 指标中文名称(如"体重"、"BMI"、"体脂率"等)
+                - value: 指标数值(纯数字,不含单位)
+                - unit: 单位(如"kg"、"%"、"次"等,无单位则留空字符串)
+                
+                只返回 JSON 数组,不要其他文字说明。示例:
+                [{"name":"体重","value":"65.2","unit":"kg"},{"name":"BMI","value":"22.5","unit":""}]
+                """;
+
+        try {
+            ObjectNode body = objectMapper.createObjectNode();
+            body.put("model", "deepseek-vl");
+            ArrayNode messages = objectMapper.createArrayNode();
+            ObjectNode userMsg = objectMapper.createObjectNode();
+            userMsg.put("role", "user");
+            ObjectNode contentArr = objectMapper.createArrayNode();
+            ObjectNode textContent = objectMapper.createObjectNode();
+            textContent.put("type", "text");
+            textContent.put("text", prompt);
+            ObjectNode imageUrlContent = objectMapper.createObjectNode();
+            imageUrlContent.put("type", "image_url");
+            ObjectNode imageUrl = objectMapper.createObjectNode();
+            imageUrl.put("url", "data:image/jpeg;base64," + base64Image);
+            imageUrlContent.set("image_url", imageUrl);
+            contentArr.add(textContent);
+            contentArr.add(imageUrlContent);
+            userMsg.set("content", contentArr);
+            messages.add(userMsg);
+            body.set("messages", messages);
+            body.put("max_tokens", 500);
+            body.put("temperature", 0.1);
+
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            headers.setBearerAuth(visionApiKey);
+
+            HttpEntity<String> entity = new HttpEntity<>(objectMapper.writeValueAsString(body), headers);
+            ResponseEntity<String> resp = restTemplate.postForEntity(visionBaseUrl + "/chat/completions", entity, String.class);
+
+            if (resp.getStatusCode().is2xxSuccessful() && resp.getBody() != null) {
+                JsonNode root = objectMapper.readTree(resp.getBody());
+                JsonNode choices = root.get("choices");
+                if (choices != null && choices.isArray() && choices.size() > 0) {
+                    JsonNode content = choices.get(0).get("message").get("content");
+                    if (content != null) {
+                        return parseMetricsFromText(content.asText());
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("DeepSeek-VL 调用失败,使用 mock 数据: {}", e.getMessage());
+        }
+        return mockMetrics();
+    }
+
+    /**
+     * 从 AI 返回文本中解析指标列表
+     */
+    private List<Map<String, Object>> parseMetricsFromText(String text) {
+        List<Map<String, Object>> metrics = new ArrayList<>();
+        try {
+            // 清理 markdown 代码块
+            String cleaned = text.trim();
+            if (cleaned.startsWith("```")) {
+                cleaned = cleaned.replaceAll("^```[a-zA-Z]*\\n?", "").replaceAll("\\n?```$", "").trim();
+            }
+            JsonNode arr = objectMapper.readTree(cleaned);
+            if (arr.isArray()) {
+                for (JsonNode item : arr) {
+                    Map<String, Object> m = new LinkedHashMap<>();
+                    m.put("name", item.has("name") ? item.get("name").asText() : "");
+                    m.put("value", item.has("value") ? item.get("value").asText() : "");
+                    m.put("unit", item.has("unit") ? item.get("unit").asText() : "");
+                    metrics.add(m);
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析 AI 响应失败: {}", e.getMessage());
+        }
+        return metrics;
+    }
+
+    /**
+     * mock 数据(开发阶段使用)
+     */
+    private List<Map<String, Object>> mockMetrics() {
+        List<Map<String, Object>> metrics = new ArrayList<>();
+        metrics.add(Map.of("name", "体重", "value", "65.2", "unit", "kg"));
+        metrics.add(Map.of("name", "BMI", "value", "22.5", "unit", ""));
+        metrics.add(Map.of("name", "体脂率", "value", "25.3", "unit", "%"));
+        metrics.add(Map.of("name", "肌肉量", "value", "52.1", "unit", "kg"));
+        metrics.add(Map.of("name", "水分", "value", "55.2", "unit", "%"));
+        metrics.add(Map.of("name", "内脏脂肪", "value", "8", "unit", "级"));
+        return metrics;
+    }
+
+    /**
+     * 保存指标到 indicator_values 表
+     * sourceType = "body_fat_scale",sourceId = memberId
+     */
+    public Map<String, Object> saveMetrics(Long memberId, List<Map<String, Object>> metrics) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        AtomicInteger saved = new AtomicInteger(0);
+        AtomicInteger skipped = new AtomicInteger(0);
+
+        for (Map<String, Object> metric : metrics) {
+            String name = (String) metric.get("name");
+            String valueStr = (String) metric.get("value");
+            String unit = (String) metric.get("unit");
+
+            if (name == null || name.isBlank() || valueStr == null || valueStr.isBlank()) {
+                skipped.incrementAndGet();
+                continue;
+            }
+
+            // 查找或创建 indicator_definition
+            IndicatorDefinition def = findOrCreateDefinition(name, unit);
+            if (def == null) {
+                skipped.incrementAndGet();
+                continue;
+            }
+
+            // 保存 indicator_value
+            IndicatorValue iv = new IndicatorValue();
+            iv.setSourceType("body_fat_scale");
+            iv.setSourceId(memberId);
+            iv.setDefinitionId(def.getId());
+            iv.setValue(valueStr + (unit != null && !unit.isBlank() ? " " + unit : ""));
+            try {
+                iv.setNumericValue(new java.math.BigDecimal(valueStr));
+            } catch (Exception e) {
+                // 非数值也保留字符串值
+            }
+            iv.setRecordedAt(new Date());
+            indicatorValueMapper.insert(iv);
+            saved.incrementAndGet();
+        }
+
+        result.put("saved", saved.get());
+        result.put("skipped", skipped.get());
+        result.put("memberId", memberId);
+        return result;
+    }
+
+    /**
+     * 查找或创建指标定义
+     */
+    private IndicatorDefinition findOrCreateDefinition(String name, String unit) {
+        // 先按中文名查找
+        var defs = indicatorDefinitionMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<IndicatorDefinition>()
+                        .eq(IndicatorDefinition::getName, name)
+                        .eq(IndicatorDefinition::getStatus, 1));
+        if (!defs.isEmpty()) {
+            return defs.get(0);
+        }
+        // 再按编码查找
+        String code = nameToCode(name);
+        defs = indicatorDefinitionMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<IndicatorDefinition>()
+                        .eq(IndicatorDefinition::getCode, code)
+                        .eq(IndicatorDefinition::getStatus, 1));
+        if (!defs.isEmpty()) {
+            return defs.get(0);
+        }
+        // 创建新定义
+        IndicatorDefinition def = new IndicatorDefinition();
+        def.setDomain("physical");
+        def.setCategory("体脂秤");
+        def.setCode(code);
+        def.setName(name);
+        def.setDataType("number");
+        def.setUnit(unit != null && !unit.isBlank() ? unit : "");
+        def.setSortOrder(0);
+        def.setStatus(1);
+        indicatorDefinitionMapper.insert(def);
+        log.info("自动创建指标定义: {} ({})", name, code);
+        return def;
+    }
+
+    /**
+     * 中文名称 → 编码映射
+     */
+    private String nameToCode(String name) {
+        return BODY_FAT_INDICATORS.getOrDefault(name, name.toLowerCase().replace(" ", "_"));
+    }
+
+    /**
+     * 批量分析并保存(一次性处理多张图片)
+     */
+    public Map<String, Object> batchProcess(Long memberId, List<String> imagePaths) {
+        Map<String, Object> summary = new LinkedHashMap<>();
+        List<Map<String, Object>> allResults = new ArrayList<>();
+        AtomicInteger totalSaved = new AtomicInteger(0);
+
+        for (String path : imagePaths) {
+            Map<String, Object> imgResult = analyzeImage(path);
+            List<Map<String, Object>> metrics = (List<Map<String, Object>>) imgResult.get("metrics");
+            if (metrics != null && !metrics.isEmpty()) {
+                Map<String, Object> saved = saveMetrics(memberId, metrics);
+                imgResult.put("saved", saved);
+                totalSaved.addAndGet((int) saved.getOrDefault("saved", 0));
+            }
+            allResults.add(imgResult);
+        }
+
+        summary.put("totalImages", imagePaths.size());
+        summary.put("totalSaved", totalSaved.get());
+        summary.put("results", allResults);
+        return summary;
+    }
+}

+ 5 - 0
cfc-backend/src/main/resources/application.yml

@@ -93,6 +93,11 @@ dify:
   nutrition-api-key: ${DIFY_NUTRITION_API_KEY:app-dev-only-nutrition-key}
   tongue-api-key: ""  # 舌诊分析,空字符串=mock模式
   emotion-api-key: "" # 照片情绪识别,空字符串=mock模式
+bodyfat-scale:
+  image-dir: ${BODYFAT_SCALE_IMAGE_DIR:docs/参考资料/体脂秤}
+deepseek:
+  vision-api-key: ${DEEPSEEK_VISION_API_KEY:""}  # DeepSeek-VL 视觉模型 API Key,空=mock模式
+  vision-base-url: ${DEEPSEEK_VISION_BASE_URL:https://api.deepseek.com/v1}
   callback-secret: ${DIFY_CALLBACK_SECRET:dev-only-callback-secret}
 
 python: