|
|
@@ -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;
|
|
|
+ }
|
|
|
+}
|