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

feat(dimension): admin controller, norm/data-source config views, tests

- DimensionAdminController: norm CRUD + data-source CRUD endpoints
- dimension-config.vue: health norm config table with el-dialog edit
- data-source-config.vue: health data source CRUD table
- 3 unit test files (DimensionScoreService, ProductOrderService, DifySyncService)
- router: add /health-norm-config and /health-data-source routes
Sisyphus 4 месяцев назад
Родитель
Сommit
75f248d67d

+ 97 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/DimensionAdminController.java

@@ -0,0 +1,97 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.HealthDataSourceRecord;
+import com.etotem.cfc.entity.HealthNormReference;
+import com.etotem.cfc.mapper.HealthDataSourceRecordMapper;
+import com.etotem.cfc.mapper.HealthNormReferenceMapper;
+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.Date;
+import java.util.List;
+
+@Tag(name = "管理端-健康维度配置")
+@RestController
+@RequestMapping("/api/admin/dimension")
+public class DimensionAdminController {
+
+    @Resource
+    private HealthNormReferenceMapper normMapper;
+
+    @Resource
+    private HealthDataSourceRecordMapper dataSourceRecordMapper;
+
+    @Operation(summary = "常模列表")
+    @PostMapping("/norm/list")
+    public Result<List<HealthNormReference>> listNorms() {
+        List<HealthNormReference> list = normMapper.selectList(
+            new LambdaQueryWrapper<HealthNormReference>()
+                .orderByAsc(HealthNormReference::getDimension)
+                .orderByAsc(HealthNormReference::getGender)
+                .orderByAsc(HealthNormReference::getAgeMin)
+        );
+        return Result.success(list);
+    }
+
+    @Operation(summary = "保存常模(新增/更新)")
+    @PostMapping("/norm/save")
+    public Result<String> saveNorm(@RequestBody HealthNormReference norm) {
+        if (norm.getId() == null) {
+            norm.setCreatedAt(new Date());
+            normMapper.insert(norm);
+        } else {
+            normMapper.updateById(norm);
+        }
+        return Result.success("保存成功");
+    }
+
+    @Operation(summary = "删除常模")
+    @PostMapping("/norm/delete")
+    public Result<String> deleteNorm(@RequestBody Long id) {
+        normMapper.deleteById(id);
+        return Result.success("删除成功");
+    }
+
+    @Operation(summary = "数据源列表")
+    @PostMapping("/data-source/list")
+    public Result<Page<HealthDataSourceRecord>> listDataSources(
+            @RequestParam(required = false, defaultValue = "1") Integer page,
+            @RequestParam(required = false, defaultValue = "20") Integer size) {
+        Page<HealthDataSourceRecord> pageParam = new Page<>(page, size);
+        LambdaQueryWrapper<HealthDataSourceRecord> wrapper = new LambdaQueryWrapper<HealthDataSourceRecord>()
+            .orderByDesc(HealthDataSourceRecord::getCreatedAt);
+        Page<HealthDataSourceRecord> result = dataSourceRecordMapper.selectPage(pageParam, wrapper);
+        return Result.success(result);
+    }
+
+    @Operation(summary = "数据源详情")
+    @PostMapping("/data-source/detail")
+    public Result<HealthDataSourceRecord> getDataSource(@RequestBody Long id) {
+        HealthDataSourceRecord record = dataSourceRecordMapper.selectById(id);
+        return record != null ? Result.success(record) : Result.error("记录不存在");
+    }
+
+    @Operation(summary = "保存数据源(新增/更新)")
+    @PostMapping("/data-source/save")
+    public Result<String> saveDataSource(@RequestBody HealthDataSourceRecord record) {
+        if (record.getId() == null) {
+            record.setCreatedAt(new Date());
+            dataSourceRecordMapper.insert(record);
+        } else {
+            dataSourceRecordMapper.updateById(record);
+        }
+        return Result.success("保存成功");
+    }
+
+    @Operation(summary = "删除数据源")
+    @PostMapping("/data-source/delete")
+    public Result<String> deleteDataSource(@RequestBody Long id) {
+        dataSourceRecordMapper.deleteById(id);
+        return Result.success("删除成功");
+    }
+}

+ 137 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/DifySyncServiceTest.java

@@ -0,0 +1,137 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.DanKnowledgeBase;
+import com.etotem.cfc.entity.ProductDimensionConfig;
+import com.etotem.cfc.mapper.ProductDimensionConfigMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@SpringBootTest
+public class DifySyncServiceTest {
+
+    @Resource
+    private DifySyncService difySyncService;
+
+    @MockBean
+    private ProductDimensionConfigMapper productDimensionConfigMapper;
+
+    @Test
+    void syncToDify_skips_whenDimensionCodesEmpty() {
+        DanKnowledgeBase knowledge = new DanKnowledgeBase();
+        knowledge.setTitle("测试");
+        knowledge.setContent("内容");
+
+        difySyncService.syncToDify(knowledge, Collections.emptyList());
+        difySyncService.syncToDify(knowledge, null);
+
+        verify(productDimensionConfigMapper, never()).selectOne(any());
+    }
+
+    @Test
+    void syncToDify_skips_whenNoConfigFound() {
+        ReflectionTestUtils.setField(difySyncService, "difyBaseUrl", "http://localhost");
+        ReflectionTestUtils.setField(difySyncService, "difyApiKey", "test-key");
+
+        DanKnowledgeBase knowledge = new DanKnowledgeBase();
+        knowledge.setTitle("测试知识点");
+        knowledge.setContent("测试内容");
+
+        when(productDimensionConfigMapper.selectOne(any()))
+            .thenReturn(null);
+
+        difySyncService.syncToDify(knowledge, Arrays.asList("growth", "sleep"));
+
+        verify(productDimensionConfigMapper, times(2)).selectOne(any());
+    }
+
+    @Test
+    void syncToDify_skips_whenDifyDatasetIdNull() {
+        ReflectionTestUtils.setField(difySyncService, "difyBaseUrl", "http://localhost");
+        ReflectionTestUtils.setField(difySyncService, "difyApiKey", "test-key");
+
+        DanKnowledgeBase knowledge = new DanKnowledgeBase();
+        knowledge.setTitle("测试");
+        knowledge.setContent("内容");
+
+        ProductDimensionConfig config = new ProductDimensionConfig();
+        config.setId(1L);
+        config.setDimensionCode("growth");
+        config.setDifyDatasetId(null);
+
+        when(productDimensionConfigMapper.selectOne(any())).thenReturn(config);
+
+        difySyncService.syncToDify(knowledge, Arrays.asList("growth"));
+
+        verify(productDimensionConfigMapper, times(1)).selectOne(any());
+    }
+
+    @Test
+    void syncToDify_skips_whenDifyDatasetIdEmpty() {
+        ReflectionTestUtils.setField(difySyncService, "difyBaseUrl", "http://localhost");
+        ReflectionTestUtils.setField(difySyncService, "difyApiKey", "test-key");
+
+        DanKnowledgeBase knowledge = new DanKnowledgeBase();
+        knowledge.setTitle("测试");
+        knowledge.setContent("内容");
+
+        ProductDimensionConfig config = new ProductDimensionConfig();
+        config.setId(1L);
+        config.setDimensionCode("growth");
+        config.setDifyDatasetId("");
+
+        when(productDimensionConfigMapper.selectOne(any())).thenReturn(config);
+
+        difySyncService.syncToDify(knowledge, Arrays.asList("growth"));
+
+        verify(productDimensionConfigMapper, times(1)).selectOne(any());
+    }
+
+    @Test
+    void removeFromDify_skips_whenDatasetIdNull() {
+        ReflectionTestUtils.setField(difySyncService, "difyBaseUrl", "http://localhost");
+        ReflectionTestUtils.setField(difySyncService, "difyApiKey", "test-key");
+
+        difySyncService.removeFromDify(100L, null);
+        difySyncService.removeFromDify(100L, "");
+
+        verify(productDimensionConfigMapper, never()).selectOne(any());
+    }
+
+    @Test
+    void syncToDify_onlyProcessesConfiguredDimensions() {
+        ReflectionTestUtils.setField(difySyncService, "difyBaseUrl", "http://localhost");
+        ReflectionTestUtils.setField(difySyncService, "difyApiKey", "test-key");
+
+        DanKnowledgeBase knowledge = new DanKnowledgeBase();
+        knowledge.setTitle("测试");
+        knowledge.setContent("内容");
+
+        ProductDimensionConfig growthConfig = new ProductDimensionConfig();
+        growthConfig.setDimensionCode("growth");
+        growthConfig.setDifyDatasetId("dataset-growth-123");
+
+        ProductDimensionConfig noConfig = new ProductDimensionConfig();
+        noConfig.setDimensionCode("sleep");
+        noConfig.setDifyDatasetId(null);
+
+        when(productDimensionConfigMapper.selectOne(
+            argThat(q -> "growth".equals(q.getSqlSegment()))))
+            .thenReturn(growthConfig);
+        when(productDimensionConfigMapper.selectOne(
+            argThat(q -> "sleep".equals(q.getSqlSegment()))))
+            .thenReturn(noConfig);
+
+        difySyncService.syncToDify(knowledge, Arrays.asList("growth", "sleep"));
+
+        verify(productDimensionConfigMapper, times(2)).selectOne(any());
+    }
+}

+ 166 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/ProductOrderServiceTest.java

@@ -0,0 +1,166 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.ChildInfoDTO;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ProductOrderMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.CommissionService;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@SpringBootTest
+public class ProductOrderServiceTest {
+
+    @Resource
+    private ProductOrderService productOrderService;
+
+    @MockBean
+    private ProductOrderMapper orderMapper;
+
+    @MockBean
+    private ProductMapper productMapper;
+
+    @MockBean
+    private UserMapper userMapper;
+
+    @MockBean
+    private CommissionService commissionService;
+
+    @MockBean
+    private EnergyService energyService;
+
+    @MockBean
+    private UserService userService;
+
+    private ProductOrder pendingOrder(String orderNo, Long buyerId) {
+        ProductOrder order = new ProductOrder();
+        order.setId(1L);
+        order.setOrderNo(orderNo);
+        order.setBuyerId(buyerId);
+        order.setStatus("pending");
+        order.setTotalAmount(19900L);
+        order.setProductId(10L);
+        order.setCreatedAt(new Date());
+        order.setUpdatedAt(new Date());
+        return order;
+    }
+
+    @Test
+    void handlePaymentSuccess_updatesOrderStatus() {
+        String orderNo = "ORDER_TEST_001";
+        String transactionId = "TXN_12345";
+        ProductOrder order = pendingOrder(orderNo, 100L);
+
+        when(orderMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(order);
+
+        Result<String> result = productOrderService.handlePaymentSuccess(orderNo, transactionId);
+
+        assertEquals(200, result.getCode());
+        assertEquals("支付成功", result.getData());
+        verify(orderMapper).updateById(order);
+        assertEquals("paid", order.getStatus());
+        assertEquals(transactionId, order.getTransactionId());
+        assertNotNull(order.getPaidAt());
+    }
+
+    @Test
+    void handlePaymentSuccess_returnsError_whenOrderNotFound() {
+        when(orderMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
+
+        Result<String> result = productOrderService.handlePaymentSuccess("NON_EXISTENT", "TXN_1");
+
+        assertEquals(500, result.getCode());
+        assertEquals("订单不存在", result.getMessage());
+    }
+
+    @Test
+    void handlePaymentSuccess_skips_whenOrderNotPending() {
+        ProductOrder order = pendingOrder("ORDER_TEST_002", 100L);
+        order.setStatus("paid");
+
+        when(orderMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(order);
+
+        Result<String> result = productOrderService.handlePaymentSuccess("ORDER_TEST_002", "TXN_2");
+
+        assertEquals(200, result.getCode());
+        assertEquals("订单状态非待支付,跳过", result.getData());
+        verify(orderMapper, never()).updateById(any());
+    }
+
+    @Test
+    void handlePaymentSuccess_settlesCommission() {
+        ProductOrder order = pendingOrder("ORDER_COMMISSION_TEST", 200L);
+        when(orderMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(order);
+
+        productOrderService.handlePaymentSuccess("ORDER_COMMISSION_TEST", "TXN_3");
+
+        verify(commissionService).settle(eq(order.getId()), eq("product"),
+            eq(order.getBuyerId()), eq(order.getTotalAmount()), eq(order.getProductId()));
+    }
+
+    @Test
+    void handlePaymentSuccess_awardsEnergy_toChildUser() {
+        ProductOrder order = pendingOrder("ORDER_CHILD_TEST", 300L);
+        when(orderMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(order);
+
+        User childUser = new User();
+        childUser.setId(300L);
+        childUser.setRole("child");
+        when(userMapper.selectById(300L)).thenReturn(childUser);
+
+        productOrderService.handlePaymentSuccess("ORDER_CHILD_TEST", "TXN_4");
+
+        verify(energyService).awardEnergy(eq(300L), eq("product"),
+            eq(order.getId()), eq(order.getTotalAmount()), eq("商品购买能量奖励"), eq(30));
+    }
+
+    @Test
+    void handlePaymentSuccess_awardsEnergy_toFirstChild_ofParentUser() {
+        ProductOrder order = pendingOrder("ORDER_PARENT_TEST", 400L);
+        when(orderMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(order);
+
+        User parentUser = new User();
+        parentUser.setId(400L);
+        parentUser.setRole("parent");
+        when(userMapper.selectById(400L)).thenReturn(parentUser);
+
+        ChildInfoDTO child = new ChildInfoDTO();
+        child.setId(401L);
+        child.setName("孩子");
+        when(userService.getChildren(400L)).thenReturn(Collections.singletonList(child));
+
+        productOrderService.handlePaymentSuccess("ORDER_PARENT_TEST", "TXN_5");
+
+        verify(energyService).awardEnergy(eq(401L), eq("product"),
+            eq(order.getId()), eq(order.getTotalAmount()), eq("商品购买能量奖励"), eq(30));
+    }
+
+    @Test
+    void handlePaymentSuccess_noEnergyAward_whenNoChild() {
+        ProductOrder order = pendingOrder("ORDER_NO_CHILD", 500L);
+        when(orderMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(order);
+
+        User parentUser = new User();
+        parentUser.setId(500L);
+        parentUser.setRole("parent");
+        when(userMapper.selectById(500L)).thenReturn(parentUser);
+        when(userService.getChildren(500L)).thenReturn(Collections.emptyList());
+
+        productOrderService.handlePaymentSuccess("ORDER_NO_CHILD", "TXN_6");
+
+        verify(energyService, never()).awardEnergy(anyLong(), anyString(), anyLong(), anyLong(), anyString(), anyInt());
+    }
+}

+ 188 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/impl/DimensionScoreServiceImplTest.java

@@ -0,0 +1,188 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.HealthDataSourceRecord;
+import com.etotem.cfc.entity.HealthDimensionScore;
+import com.etotem.cfc.entity.HealthNormReference;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.mapper.HealthDataSourceRecordMapper;
+import com.etotem.cfc.mapper.HealthNormReferenceMapper;
+import com.etotem.cfc.service.impl.DimensionScoreServiceImpl;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@SpringBootTest
+public class DimensionScoreServiceImplTest {
+
+    @Resource
+    private DimensionScoreServiceImpl dimensionScoreService;
+
+    @MockBean
+    private HealthDimensionScoreService scoreService;
+
+    @MockBean
+    private HealthNormReferenceMapper normMapper;
+
+    @MockBean
+    private HealthReportService healthReportService;
+
+    @MockBean
+    private HealthDataSourceRecordService dataSourceRecordService;
+
+    @MockBean
+    private DimensionEnergySyncService dimensionEnergySyncService;
+
+    private HealthDimensionScore score(Long memberId, String dimension, int scoreVal) {
+        HealthDimensionScore s = new HealthDimensionScore();
+        s.setMemberId(memberId);
+        s.setDimension(dimension);
+        s.setScore(scoreVal);
+        s.setPercentile(50);
+        s.setDataSource("MANUAL");
+        s.setTier(3);
+        s.setAssessDate(new java.sql.Date(System.currentTimeMillis()));
+        return s;
+    }
+
+    private HealthDimensionScore scoreWithDate(Long memberId, String dimension, int scoreVal, int daysAgo) {
+        HealthDimensionScore s = score(memberId, dimension, scoreVal);
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.DAY_OF_YEAR, -daysAgo);
+        s.setAssessDate(new java.sql.Date(cal.getTimeInMillis()));
+        return s;
+    }
+
+    @Test
+    void getHealthDimension_aggregatesAll7Dimensions() {
+        Long memberId = 1L;
+        List<HealthDimensionScore> scores = Arrays.asList(
+            score(memberId, "growth", 80),
+            score(memberId, "sleep", 75),
+            score(memberId, "vision", 70),
+            score(memberId, "immunity", 85),
+            score(memberId, "nutrition", 78),
+            score(memberId, "gut", 82),
+            score(memberId, "exercise", 88)
+        );
+        when(scoreService.getLatestScores(memberId)).thenReturn(scores);
+        when(scoreService.getHistoryScores(eq(memberId), anyString(), eq(2)))
+            .thenReturn(Collections.emptyList());
+        when(normMapper.selectNorm(anyString(), anyString(), anyInt())).thenReturn(null);
+
+        var result = dimensionScoreService.getHealthDimension(memberId);
+
+        assertNotNull(result);
+        assertEquals(memberId, result.getMemberId());
+        assertEquals(7, result.getDimensions().size());
+        assertNotNull(result.getHealthIndex());
+        BigDecimal expectedIndex = BigDecimal.valueOf(80 + 75 + 70 + 85 + 78 + 82 + 88)
+            .divide(BigDecimal.valueOf(7), 2, RoundingMode.HALF_UP);
+        assertEquals(expectedIndex, result.getHealthIndex());
+    }
+
+    @Test
+    void getHealthDimension_trendUp_whenScoreIncreasedBy5OrMore() {
+        Long memberId = 1L;
+        List<HealthDimensionScore> latest = Collections.singletonList(score(memberId, "growth", 85));
+        List<HealthDimensionScore> history = Arrays.asList(
+            scoreWithDate(memberId, "growth", 85),
+            scoreWithDate(memberId, "growth", 78)
+        );
+        when(scoreService.getLatestScores(memberId)).thenReturn(latest);
+        when(scoreService.getHistoryScores(eq(memberId), eq("growth"), eq(2))).thenReturn(history);
+        when(normMapper.selectNorm(anyString(), anyString(), anyInt())).thenReturn(null);
+
+        var result = dimensionScoreService.getHealthDimension(memberId);
+        var growthDim = result.getDimensions().stream()
+            .filter(d -> "growth".equals(d.getDimension())).findFirst().orElseThrow();
+
+        assertEquals("up", growthDim.getTrend());
+    }
+
+    @Test
+    void getHealthDimension_trendDown_whenScoreDecreasedBy5OrMore() {
+        Long memberId = 1L;
+        List<HealthDimensionScore> latest = Collections.singletonList(score(memberId, "sleep", 65));
+        List<HealthDimensionScore> history = Arrays.asList(
+            scoreWithDate(memberId, "sleep", 65),
+            scoreWithDate(memberId, "sleep", 73)
+        );
+        when(scoreService.getLatestScores(memberId)).thenReturn(latest);
+        when(scoreService.getHistoryScores(eq(memberId), eq("sleep"), eq(2))).thenReturn(history);
+        when(normMapper.selectNorm(anyString(), anyString(), anyInt())).thenReturn(null);
+
+        var result = dimensionScoreService.getHealthDimension(memberId);
+        var sleepDim = result.getDimensions().stream()
+            .filter(d -> "sleep".equals(d.getDimension())).findFirst().orElseThrow();
+
+        assertEquals("down", sleepDim.getTrend());
+    }
+
+    @Test
+    void getHealthDimension_trendStable_whenScoreChangeLessThan5() {
+        Long memberId = 1L;
+        List<HealthDimensionScore> latest = Collections.singletonList(score(memberId, "vision", 72));
+        List<HealthDimensionScore> history = Arrays.asList(
+            scoreWithDate(memberId, "vision", 72),
+            scoreWithDate(memberId, "vision", 74)
+        );
+        when(scoreService.getLatestScores(memberId)).thenReturn(latest);
+        when(scoreService.getHistoryScores(eq(memberId), eq("vision"), eq(2))).thenReturn(history);
+        when(normMapper.selectNorm(anyString(), anyString(), anyInt())).thenReturn(null);
+
+        var result = dimensionScoreService.getHealthDimension(memberId);
+        var visionDim = result.getDimensions().stream()
+            .filter(d -> "vision".equals(d.getDimension())).findFirst().orElseThrow();
+
+        assertEquals("stable", visionDim.getTrend());
+    }
+
+    @Test
+    void getHealthDimension_trendStable_whenNoHistory() {
+        Long memberId = 1L;
+        List<HealthDimensionScore> latest = Collections.singletonList(score(memberId, "immunity", 90));
+        when(scoreService.getLatestScores(memberId)).thenReturn(latest);
+        when(scoreService.getHistoryScores(eq(memberId), eq("immunity"), eq(2))).thenReturn(Collections.emptyList());
+        when(normMapper.selectNorm(anyString(), anyString(), anyInt())).thenReturn(null);
+
+        var result = dimensionScoreService.getHealthDimension(memberId);
+        var immunityDim = result.getDimensions().stream()
+            .filter(d -> "immunity".equals(d.getDimension())).findFirst().orElseThrow();
+
+        assertEquals("stable", immunityDim.getTrend());
+    }
+
+    @Test
+    void getHealthDimension_levelMapsCorrectly() {
+        Long memberId = 1L;
+        when(scoreService.getLatestScores(memberId)).thenReturn(Collections.emptyList());
+        when(scoreService.getHistoryScores(anyLong(), anyString(), anyInt())).thenReturn(Collections.emptyList());
+        when(normMapper.selectNorm(anyString(), anyString(), anyInt())).thenReturn(null);
+
+        var result = dimensionScoreService.getHealthDimension(memberId);
+
+        for (var dim : result.getDimensions()) {
+            if (dim.getScore() != null) {
+                if (dim.getScore().intValue() >= 85) {
+                    assertEquals("excellent", dim.getLevel(), "score " + dim.getScore() + " should be excellent");
+                } else if (dim.getScore().intValue() >= 60) {
+                    assertEquals("good", dim.getLevel(), "score " + dim.getScore() + " should be good");
+                } else if (dim.getScore().intValue() >= 40) {
+                    assertEquals("fair", dim.getLevel(), "score " + dim.getScore() + " should be fair");
+                } else {
+                    assertEquals("poor", dim.getLevel(), "score " + dim.getScore() + " should be poor");
+                }
+            }
+        }
+    }
+}

+ 15 - 2
cfc-web/src/router/index.js

@@ -406,10 +406,23 @@ const routes = [
         name: 'KnowledgeBase',
         component: () => import('@/views/admin/knowledge'),
         meta: { title: '知识库管理', perm: 'system:config' }
-      }
+      },
+      // ========== 健康维度配置 ==========
+      {
+        path: 'health-norm-config',
+        name: 'HealthNormConfig',
+        component: () => import('@/views/admin/dimension-config'),
+        meta: { title: '健康常模配置', perm: 'system:config' }
+      },
+      {
+        path: 'health-data-source',
+        name: 'HealthDataSource',
+        component: () => import('@/views/admin/data-source-config'),
+        meta: { title: '健康数据源配置', perm: 'system:config' }
+      }
     ]
   }
-]
+]
 
 const router = new VueRouter({
   mode: 'history',

+ 162 - 0
cfc-web/src/views/admin/data-source-config.vue

@@ -0,0 +1,162 @@
+<template>
+  <div class="data-source-config">
+    <el-card>
+      <div slot="header">
+        <span>健康数据源配置</span>
+        <el-button style="float: right" type="primary" size="small" @click="handleCreate">+ 添加数据源</el-button>
+      </div>
+      <el-table :data="records" stripe v-loading="loading">
+        <el-table-column prop="id" label="ID" width="60"></el-table-column>
+        <el-table-column prop="memberId" label="成员ID" width="80"></el-table-column>
+        <el-table-column prop="sourceType" label="类型" width="100">
+          <template slot-scope="scope">
+            <el-tag size="mini">{{ scope.row.sourceType }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="dimensionCodes" label="维度" width="150" show-overflow-tooltip></el-table-column>
+        <el-table-column prop="confidence" label="置信度" width="80">
+          <template slot-scope="scope">{{ scope.row.confidence }}</template>
+        </el-table-column>
+        <el-table-column prop="fileUrl" label="文件URL" min-width="200" show-overflow-tooltip>
+          <template slot-scope="scope">
+            <a v-if="scope.row.fileUrl" :href="scope.row.fileUrl" target="_blank" style="color:#409EFF;font-size:12px;">查看文件</a>
+            <span v-else style="color:#999">-</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="createdAt" label="创建时间" width="160">
+          <template slot-scope="scope">{{ formatDate(scope.row.createdAt) }}</template>
+        </el-table-column>
+        <el-table-column label="操作" width="150" fixed="right">
+          <template slot-scope="scope">
+            <el-button size="mini" @click="handleEdit(scope.row)">编辑</el-button>
+            <el-button size="mini" type="danger" @click="handleDelete(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-pagination
+        v-if="total > 0"
+        style="margin-top:16px;text-align:right"
+        :total="total"
+        :page.sync="page"
+        :limit.sync="size"
+        @current-change="fetchRecords"
+        layout="total, prev, pager, next"
+      />
+    </el-card>
+
+    <el-dialog :visible.sync="dialogVisible" :title="isEdit ? '编辑数据源' : '添加数据源'" width="550px">
+      <el-form :model="form" label-width="100px">
+        <el-form-item label="成员ID" required>
+          <el-input-number v-model="form.memberId" :min="1" style="width:100%"/>
+        </el-form-item>
+        <el-form-item label="来源类型" required>
+          <el-select v-model="form.sourceType" placeholder="选择类型" style="width:100%">
+            <el-option label="REPORT" value="REPORT"></el-option>
+            <el-option label="PHOTO" value="PHOTO"></el-option>
+            <el-option label="VOICE" value="VOICE"></el-option>
+            <el-option label="MANUAL" value="MANUAL"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="维度编码">
+          <el-input v-model="form.dimensionCodes" placeholder="如: growth,sleep,vision"/>
+        </el-form-item>
+        <el-form-item label="置信度">
+          <el-input-number v-model="form.confidence" :min="0" :max="1" :precision="2" style="width:100%"/>
+        </el-form-item>
+        <el-form-item label="解析结果">
+          <el-input type="textarea" v-model="form.parsedResult" :rows="3" placeholder="JSON格式"/>
+        </el-form-item>
+        <el-form-item label="文件URL">
+          <el-input v-model="form.fileUrl" placeholder="https://..."/>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSubmit" :loading="submitting">保存</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'DataSourceConfig',
+  data() {
+    return {
+      loading: false,
+      submitting: false,
+      records: [],
+      total: 0,
+      page: 1,
+      size: 20,
+      dialogVisible: false,
+      isEdit: false,
+      form: this.emptyForm()
+    }
+  },
+  mounted() {
+    this.fetchRecords()
+  },
+  methods: {
+    emptyForm() {
+      return {
+        id: null,
+        memberId: null,
+        sourceType: 'MANUAL',
+        dimensionCodes: '',
+        parsedResult: '',
+        fileUrl: '',
+        confidence: null
+      }
+    },
+    formatDate(d) {
+      if (!d) return '-'
+      return new Date(d).toLocaleString()
+    },
+    fetchRecords() {
+      this.loading = true
+      this.$axios.post('/api/admin/dimension/data-source/list', null, { params: { page: this.page, size: this.size } })
+        .then(res => {
+          if (res.data.code === 200) {
+            this.records = res.data.data.records || []
+            this.total = res.data.data.total || 0
+          }
+        }).catch(() => {}).finally(() => { this.loading = false })
+    },
+    handleCreate() {
+      this.form = this.emptyForm()
+      this.isEdit = false
+      this.dialogVisible = true
+    },
+    handleEdit(row) {
+      this.form = { ...row }
+      this.isEdit = true
+      this.dialogVisible = true
+    },
+    handleSubmit() {
+      this.submitting = true
+      this.$axios.post('/api/admin/dimension/data-source/save', this.form).then(res => {
+        if (res.data.code === 200) {
+          this.dialogVisible = false
+          this.fetchRecords()
+          this.$message.success('保存成功')
+        } else {
+          this.$message.error(res.data.message || '保存失败')
+        }
+      }).catch(() => { this.$message.error('保存失败') }).finally(() => { this.submitting = false })
+    },
+    handleDelete(row) {
+      this.$confirm('确认删除此数据源?', '提示', { type: 'warning' }).then(() => {
+        this.$axios.post('/api/admin/dimension/data-source/delete', row.id).then(res => {
+          if (res.data.code === 200) {
+            this.fetchRecords()
+            this.$message.success('删除成功')
+          } else {
+            this.$message.error(res.data.message || '删除失败')
+          }
+        })
+      }).catch(() => {})
+    }
+  }
+}
+</script>

+ 192 - 0
cfc-web/src/views/admin/dimension-config.vue

@@ -0,0 +1,192 @@
+<template>
+  <div class="norm-config">
+    <el-card>
+      <div slot="header">
+        <span>健康维度常模配置</span>
+        <el-button style="float: right" type="primary" size="small" @click="handleCreate">+ 添加常模</el-button>
+      </div>
+      <el-table :data="norms" stripe v-loading="loading">
+        <el-table-column prop="id" label="ID" width="60"></el-table-column>
+        <el-table-column prop="dimension" label="维度" width="100">
+          <template slot-scope="scope">
+            <el-tag size="mini">{{ scope.row.dimension }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="gender" label="性别" width="80">
+          <template slot-scope="scope">
+            {{ genderLabel(scope.row.gender) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="年龄段" width="120">
+          <template slot-scope="scope">
+            {{ scope.row.ageMin }}~{{ scope.row.ageMax }}月
+          </template>
+        </el-table-column>
+        <el-table-column label="P5/P50/P95" width="150">
+          <template slot-scope="scope">
+            {{ scope.row.percentile5 }} / {{ scope.row.percentile50 }} / {{ scope.row.percentile95 }}
+          </template>
+        </el-table-column>
+        <el-table-column label="百分位详情" min-width="280">
+          <template slot-scope="scope">
+            <span style="font-size:12px; color:#888;">
+              P5={{ scope.row.percentile5 }} P15={{ scope.row.percentile15 }} P25={{ scope.row.percentile25 }} |
+              P50={{ scope.row.percentile50 }} |
+              P75={{ scope.row.percentile75 }} P85={{ scope.row.percentile85 }} P95={{ scope.row.percentile95 }}
+            </span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="source" label="数据来源" width="120" show-overflow-tooltip></el-table-column>
+        <el-table-column label="操作" width="150" fixed="right">
+          <template slot-scope="scope">
+            <el-button size="mini" @click="handleEdit(scope.row)">编辑</el-button>
+            <el-button size="mini" type="danger" @click="handleDelete(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <el-dialog :visible.sync="dialogVisible" :title="isEdit ? '编辑常模' : '添加常模'" width="600px">
+      <el-form :model="form" label-width="100px">
+        <el-form-item label="维度" required>
+          <el-select v-model="form.dimension" placeholder="选择维度" style="width:100%">
+            <el-option label="growth 生长发育" value="growth"></el-option>
+            <el-option label="sleep 睡眠质量" value="sleep"></el-option>
+            <el-option label="vision 视力健康" value="vision"></el-option>
+            <el-option label="immunity 免疫力" value="immunity"></el-option>
+            <el-option label="nutrition 营养均衡" value="nutrition"></el-option>
+            <el-option label="gut 肠胃健康" value="gut"></el-option>
+            <el-option label="exercise 运动活力" value="exercise"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="性别">
+          <el-select v-model="form.gender" placeholder="选择性别" style="width:100%">
+            <el-option label="全部" value="all"></el-option>
+            <el-option label="男" value="male"></el-option>
+            <el-option label="女" value="female"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="年龄范围">
+          <el-input-number v-model="form.ageMin" :min="0" :max="240" style="width:120px" /> 月 ~
+          <el-input-number v-model="form.ageMax" :min="0" :max="240" style="width:120px" /> 月
+        </el-form-item>
+        <el-row :gutter="10">
+          <el-col :span="8">
+            <el-form-item label="P5"><el-input-number v-model="form.percentile5" :min="0" :max="100" style="width:100%"/></el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="P15"><el-input-number v-model="form.percentile15" :min="0" :max="100" style="width:100%"/></el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="P25"><el-input-number v-model="form.percentile25" :min="0" :max="100" style="width:100%"/></el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="10">
+          <el-col :span="8">
+            <el-form-item label="P50"><el-input-number v-model="form.percentile50" :min="0" :max="100" style="width:100%"/></el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="P75"><el-input-number v-model="form.percentile75" :min="0" :max="100" style="width:100%"/></el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="P85"><el-input-number v-model="form.percentile85" :min="0" :max="100" style="width:100%"/></el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="10">
+          <el-col :span="8">
+            <el-form-item label="P95"><el-input-number v-model="form.percentile95" :min="0" :max="100" style="width:100%"/></el-form-item>
+          </el-col>
+          <el-col :span="16">
+            <el-form-item label="数据来源"><el-input v-model="form.source" placeholder="如: 中国九市儿童生长发育标准"/></el-form-item>
+          </el-col>
+        </el-row>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSubmit" :loading="submitting">保存</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'DimensionNormConfig',
+  data() {
+    return {
+      loading: false,
+      submitting: false,
+      norms: [],
+      dialogVisible: false,
+      isEdit: false,
+      form: this.emptyForm()
+    }
+  },
+  mounted() {
+    this.fetchNorms()
+  },
+  methods: {
+    emptyForm() {
+      return {
+        id: null,
+        dimension: 'growth',
+        gender: 'all',
+        ageMin: 0,
+        ageMax: 216,
+        percentile5: 0,
+        percentile15: 0,
+        percentile25: 0,
+        percentile50: 50,
+        percentile75: 0,
+        percentile85: 0,
+        percentile95: 0,
+        source: ''
+      }
+    },
+    genderLabel(g) {
+      const map = { all: '全部', male: '男', female: '女' }
+      return map[g] || g
+    },
+    fetchNorms() {
+      this.loading = true
+      this.$axios.post('/api/admin/dimension/norm/list').then(res => {
+        if (res.data.code === 200) this.norms = res.data.data || []
+      }).catch(() => {}).finally(() => { this.loading = false })
+    },
+    handleCreate() {
+      this.form = this.emptyForm()
+      this.isEdit = false
+      this.dialogVisible = true
+    },
+    handleEdit(row) {
+      this.form = { ...row }
+      this.isEdit = true
+      this.dialogVisible = true
+    },
+    handleSubmit() {
+      this.submitting = true
+      this.$axios.post('/api/admin/dimension/norm/save', this.form).then(res => {
+        if (res.data.code === 200) {
+          this.dialogVisible = false
+          this.fetchNorms()
+          this.$message.success('保存成功')
+        } else {
+          this.$message.error(res.data.message || '保存失败')
+        }
+      }).catch(() => { this.$message.error('保存失败') }).finally(() => { this.submitting = false })
+    },
+    handleDelete(row) {
+      this.$confirm('确认删除此常模?', '提示', { type: 'warning' }).then(() => {
+        this.$axios.post('/api/admin/dimension/norm/delete', row.id).then(res => {
+          if (res.data.code === 200) {
+            this.fetchNorms()
+            this.$message.success('删除成功')
+          } else {
+            this.$message.error(res.data.message || '删除失败')
+          }
+        })
+      }).catch(() => {})
+    }
+  }
+}
+</script>