Przeglądaj źródła

feat(dimension): wave 1 - trend, product-energy, dify, body energy integration

Sisyphus 4 miesięcy temu
rodzic
commit
cde8e3ff6a

+ 42 - 28
cfc-backend/src/main/java/com/etotem/cfc/service/DifySyncService.java

@@ -1,20 +1,25 @@
 package com.etotem.cfc.service;
 
 import com.etotem.cfc.entity.DanKnowledgeBase;
+import com.etotem.cfc.entity.ProductDimensionConfig;
+import com.etotem.cfc.mapper.ProductDimensionConfigMapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
 
+import javax.annotation.Resource;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
-/**
- * Dify 知识库同步服务
- *
- * 在知识点新增/编辑/删除时异步推送到 Dify Dataset。
- * Dify 调用失败不影响主流程,仅记录 error log。
- */
 @Service
 public class DifySyncService {
 
@@ -26,41 +31,50 @@ public class DifySyncService {
     @Value("${dify.api-key}")
     private String difyApiKey;
 
-    /**
-     * 推送知识点到 Dify(异步,不阻塞主流程)
-     *
-     * @param knowledge      知识点
-     * @param dimensionCodes 关联维度代码列表(每个维度对应一个 Dify dataset)
-     */
+    @Resource
+    private ProductDimensionConfigMapper productDimensionConfigMapper;
+
+    private final RestTemplate restTemplate = new RestTemplate();
+
     @Async
     public void syncToDify(DanKnowledgeBase knowledge, List<String> dimensionCodes) {
-        if (dimensionCodes == null || dimensionCodes.isEmpty()) {
-            return;
-        }
+        if (dimensionCodes == null || dimensionCodes.isEmpty()) return;
         for (String code : dimensionCodes) {
             try {
-                // TODO: 根据 dimensionCode 查找 product_dimension_config 中的 dify_dataset_id
-                // 如果 dify_dataset_id 为空则跳过
-                // 调用 Dify API: POST /v1/datasets/{dataset_id}/documents
-                // 文档标题: knowledge.title, 内容: knowledge.content
-                log.info("准备同步知识点[{}]到维度[{}]的 Dify dataset", knowledge.getTitle(), code);
+                ProductDimensionConfig config = productDimensionConfigMapper.selectOne(
+                    new LambdaQueryWrapper<ProductDimensionConfig>().eq(ProductDimensionConfig::getDimensionCode, code));
+                if (config == null || config.getDifyDatasetId() == null || config.getDifyDatasetId().isEmpty()) {
+                    log.warn("维度[{}]未配置 Dify Dataset,跳过同步", code);
+                    continue;
+                }
+                String url = difyBaseUrl + "/v1/datasets/" + config.getDifyDatasetId() + "/documents";
+                HttpHeaders headers = new HttpHeaders();
+                headers.setContentType(MediaType.APPLICATION_JSON);
+                headers.set("Authorization", "Bearer " + difyApiKey);
+                Map<String, Object> body = new HashMap<>();
+                body.put("name", knowledge.getTitle());
+                Map<String, Object> textContent = new HashMap<>();
+                textContent.put("content", knowledge.getContent());
+                body.put("text", textContent);
+                HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
+                restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
+                log.info("同步知识点[{}]到维度[{}]的 Dify dataset 成功", knowledge.getTitle(), code);
             } catch (Exception e) {
                 log.error("同步知识点[{}]到维度[{}]的 Dify dataset 失败", knowledge.getTitle(), code, e);
             }
         }
     }
 
-    /**
-     * 从 Dify 删除知识点文档(异步)
-     *
-     * @param knowledgeId   知识点ID
-     * @param difyDatasetId Dify Dataset ID
-     */
     @Async
     public void removeFromDify(Long knowledgeId, String difyDatasetId) {
         try {
-            // TODO: 调用 Dify API: DELETE /v1/datasets/{dataset_id}/documents/{doc_id}
-            log.info("准备从 Dify dataset[{}]删除知识点[{}]", difyDatasetId, knowledgeId);
+            if (difyDatasetId == null || difyDatasetId.isEmpty()) return;
+            String url = difyBaseUrl + "/v1/datasets/" + difyDatasetId + "/documents/" + knowledgeId;
+            HttpHeaders headers = new HttpHeaders();
+            headers.set("Authorization", "Bearer " + difyApiKey);
+            HttpEntity<String> entity = new HttpEntity<>(headers);
+            restTemplate.exchange(url, HttpMethod.DELETE, entity, String.class);
+            log.info("从 Dify dataset[{}]删除知识点[{}]成功", difyDatasetId, knowledgeId);
         } catch (Exception e) {
             log.error("从 Dify 删除知识点[{}]失败", knowledgeId, e);
         }

+ 38 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DimensionEnergySyncService.java

@@ -0,0 +1,38 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.HealthDimensionScore;
+import org.springframework.stereotype.Service;
+import javax.annotation.Resource;
+import java.util.List;
+
+@Service
+public class DimensionEnergySyncService {
+
+    @Resource
+    private HealthDimensionScoreService scoreService;
+
+    @Resource
+    private EnergyService energyService;
+
+    public void syncBodyEnergy(Long memberId) {
+        List<HealthDimensionScore> latest = scoreService.getLatestScores(memberId);
+        if (latest == null || latest.isEmpty()) return;
+
+        double total = 0;
+        int count = 0;
+        for (HealthDimensionScore s : latest) {
+            total += s.getScore();
+            count++;
+        }
+        int average = count > 0 ? (int) (total / count) : 0;
+        int energyValue = (int) (average * 0.5);
+        if (energyValue <= 0) return;
+
+        try {
+            energyService.awardEnergy(memberId, "dimension", memberId,
+                energyValue, "7维评分同步至身能量", 30);
+        } catch (Exception e) {
+            // Do not block score submission
+        }
+    }
+}

+ 47 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -21,6 +21,9 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
+import com.etotem.cfc.dto.ChildInfoDTO;
+import com.etotem.cfc.service.EnergyService;
+import com.etotem.cfc.service.UserService;
 
 @Service
 public class ProductOrderService {
@@ -43,6 +46,12 @@ public class ProductOrderService {
     @Resource
     private PaymentService paymentService;
 
+    @Resource
+    private EnergyService energyService;
+
+    @Resource
+    private UserService userService;
+
     public Result<ProductOrderDTO> create(CreateProductOrderDTO dto, Long buyerId) {
         if (buyerId == null) {
             return Result.error("请先登录");
@@ -161,6 +170,25 @@ public class ProductOrderService {
         orderMapper.updateById(order);
         commissionService.settle(order.getId(), "product", order.getBuyerId(),
                 order.getTotalAmount(), order.getProductId());
+        // Award energy for product purchase
+        try {
+            User buyer_user = userMapper.selectById(userId);
+            Long childId = null;
+            if ("child".equals(buyer_user.getRole())) {
+                childId = buyer_user.getId();
+            } else {
+                List<ChildInfoDTO> children = userService.getChildren(userId);
+                if (children != null && !children.isEmpty()) {
+                    childId = children.get(0).getId();
+                }
+            }
+            if (childId != null) {
+                energyService.awardEnergy(childId, "product", order.getId(),
+                    order.getTotalAmount(), "商品购买能量奖励", 30);
+            }
+        } catch (Exception e) {
+            // Do not block payment flow
+        }
         Map<String, Object> result = new HashMap<>();
         result.put("order", ProductOrderDTO.from(order));
         result.put("wechatPayParams", wechatPayParams);
@@ -183,6 +211,25 @@ public class ProductOrderService {
         orderMapper.updateById(order);
         commissionService.settle(order.getId(), "product", order.getBuyerId(),
                 order.getTotalAmount(), order.getProductId());
+        // Award energy for product purchase
+        try {
+            User buyer_user = userMapper.selectById(order.getBuyerId());
+            Long childId = null;
+            if ("child".equals(buyer_user.getRole())) {
+                childId = buyer_user.getId();
+            } else {
+                List<ChildInfoDTO> children = userService.getChildren(order.getBuyerId());
+                if (children != null && !children.isEmpty()) {
+                    childId = children.get(0).getId();
+                }
+            }
+            if (childId != null) {
+                energyService.awardEnergy(childId, "product", order.getId(),
+                    order.getTotalAmount(), "商品购买能量奖励", 30);
+            }
+        } catch (Exception e) {
+            // Do not block payment flow
+        }
         return Result.success("支付成功");
     }
 

+ 46 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/RecommendationService.java

@@ -10,6 +10,9 @@ import com.etotem.cfc.mapper.ActivityMapper;
 import com.etotem.cfc.mapper.ArticleMapper;
 import com.etotem.cfc.mapper.ProductMapper;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.*;
+import org.springframework.web.client.RestTemplate;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
@@ -23,6 +26,14 @@ import java.util.*;
 @Service
 public class RecommendationService {
 
+    @Value("${dify.base-url}")
+    private String difyBaseUrl;
+
+    @Value("${dify.api-key}")
+    private String difyApiKey;
+
+    private final RestTemplate restTemplate = new RestTemplate();
+
     @Resource
     private ProductMapper productMapper;
 
@@ -52,9 +63,44 @@ public class RecommendationService {
             results.addAll(searchArticles(tags, limit));
         }
 
+        // Dify AI fallback: supplement with AI recommendations when results are insufficient
+        if (results.size() < limit && tags != null && !tags.isEmpty()) {
+            try {
+                List<RecommendationResult> aiResults = searchViaDify(tags, limit - results.size());
+                Set<Long> existingIds = results.stream().map(RecommendationResult::getId).collect(java.util.stream.Collectors.toSet());
+                for (RecommendationResult r : aiResults) {
+                    if (!existingIds.contains(r.getId())) {
+                        results.add(r);
+                        existingIds.add(r.getId());
+                    }
+                }
+            } catch (Exception e) {
+                log.warn("Dify AI recommendation failed, using existing results only", e);
+            }
+        }
+
         return results;
     }
 
+    private List<RecommendationResult> searchViaDify(List<String> tags, int limit) {
+        List<RecommendationResult> aiResults = new ArrayList<>();
+        try {
+            String query = String.join(" ", tags);
+            String encoded = java.net.URLEncoder.encode(query, "UTF-8");
+            String url = difyBaseUrl + "/v1/datasets?keyword=" + encoded;
+            HttpHeaders headers = new HttpHeaders();
+            headers.set("Authorization", "Bearer " + difyApiKey);
+            HttpEntity<String> entity = new HttpEntity<>(headers);
+            ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                log.info("Dify AI recommendation returned data for query: {}", query);
+            }
+        } catch (Exception e) {
+            log.warn("Dify AI recommendation call failed, fallback to existing results", e);
+        }
+        return aiResults;
+    }
+
     private List<RecommendationResult> searchProducts(List<String> tags, int limit) {
         Set<Long> added = new HashSet<>();
         List<RecommendationResult> results = new ArrayList<>();

+ 35 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/impl/DimensionScoreServiceImpl.java

@@ -15,6 +15,7 @@ import com.etotem.cfc.service.DimensionScoreService;
 import com.etotem.cfc.service.HealthDimensionScoreService;
 import com.etotem.cfc.service.HealthReportService;
 import com.etotem.cfc.service.HealthDataSourceRecordService;
+import com.etotem.cfc.service.DimensionEnergySyncService;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import javax.annotation.Resource;
@@ -49,6 +50,9 @@ public class DimensionScoreServiceImpl implements DimensionScoreService {
     @Resource
     private HealthDataSourceRecordService dataSourceRecordService;
 
+    @Resource
+    private DimensionEnergySyncService dimensionEnergySyncService;
+
     @Override
     public HealthDimensionVO getHealthDimension(Long memberId) {
         List<HealthDimensionScore> latest = scoreService.getLatestScores(memberId);
@@ -74,7 +78,25 @@ public class DimensionScoreServiceImpl implements DimensionScoreService {
                 item.setDataSource(found.getDataSource());
                 item.setTier(found.getTier());
                 item.setLevel(calcLevel(found.getScore()));
-                item.setTrend("stable");
+                List<HealthDimensionVO.DimensionItem> history = getDimensionHistory(memberId, code, 2);
+                if (history.size() >= 2) {
+                    BigDecimal earlierScore = history.get(1).getScore();
+                    BigDecimal latestScore = history.get(0).getScore();
+                    if (latestScore != null && earlierScore != null) {
+                        double diff = latestScore.doubleValue() - earlierScore.doubleValue();
+                        if (diff >= 5) {
+                            item.setTrend("up");
+                        } else if (diff <= -5) {
+                            item.setTrend("down");
+                        } else {
+                            item.setTrend("stable");
+                        }
+                    } else {
+                        item.setTrend("stable");
+                    }
+                } else {
+                    item.setTrend("stable");
+                }
                 if (found.getExpireDate() != null) {
                     item.setExpireDate(new SimpleDateFormat("yyyy-MM-dd").format(found.getExpireDate()));
                 }
@@ -113,6 +135,12 @@ public class DimensionScoreServiceImpl implements DimensionScoreService {
         }
         score.setPercentile(calcPercentile(vo.getDimension(), vo.getScore() != null ? vo.getScore().intValue() : 0));
         scoreService.saveScore(score);
+        // Sync to body energy
+        try {
+            dimensionEnergySyncService.syncBodyEnergy(vo.getMemberId());
+        } catch (Exception e) {
+            // Do not block
+        }
     }
 
     @Override
@@ -138,6 +166,12 @@ public class DimensionScoreServiceImpl implements DimensionScoreService {
         score.setAssessDate(new java.sql.Date(System.currentTimeMillis()));
         score.setPercentile(calcPercentile(vo.getDimension(), avgScore));
         scoreService.saveScore(score);
+        // Sync to body energy
+        try {
+            dimensionEnergySyncService.syncBodyEnergy(vo.getMemberId());
+        } catch (Exception e) {
+            // Do not block
+        }
     }
 
     @Override