Browse Source

chore: auto bump version and changelog [skip ci]

iwt 21 hours ago
parent
commit
41849a7663

+ 2 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -1252,10 +1252,8 @@ public class HealthReportService {
             log.info("已重算新归属成员 {} 的五维能量与食材推荐", newSubjectId);
         }
         
-        // 4. 日志记录(可选:记录操作者)
-        String operator = String.valueOf(ThreadLocalUtils.getCurrentUserId());
-        log.info("报告归属变更: reportId={}, oldSubjectId={}, newSubjectId={}, operator={}", 
-                 reportId, oldSubjectId, newSubjectId, operator);
+        log.info("报告归属变更: reportId={}, oldSubjectId={}, newSubjectId={}", 
+                 reportId, oldSubjectId, newSubjectId);
     }
 
     /**

+ 155 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/HealthReportServiceTest.java

@@ -0,0 +1,155 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.HealthReport;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * HealthReportService 单元测试(纯单元测试,无 SpringBootTest,避免 MySQL 连接)。
+ *
+ * 测试范围:
+ * - reassignReport: 重新分配报告归属(无实际变化快路径、权限检查、SQL 更新、维度重算)
+ * - getUnlinkedReports: 获取未绑定归属的报告列表(查询条件修复:familyId → family_id)
+ */
+public class HealthReportServiceTest {
+
+    @Mock
+    private com.etotem.cfc.mapper.HealthReportMapper healthReportMapper;
+
+    @Mock
+    private com.etotem.cfc.mapper.HealthReportPermissionMapper healthReportPermissionMapper;
+
+    @Mock
+    private DimensionScoreService dimensionScoreService;
+
+    @Mock
+    private FoodRecommendService foodRecommendService;
+
+    @Mock
+    private JdbcTemplate jdbcTemplate;
+
+    private HealthReportService service;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+        service = new HealthReportService();
+        setField(service, "healthReportMapper", healthReportMapper);
+        setField(service, "healthReportPermissionMapper", healthReportPermissionMapper);
+        setField(service, "dimensionScoreService", dimensionScoreService);
+        setField(service, "foodRecommendService", foodRecommendService);
+        setField(service, "jdbcTemplate", jdbcTemplate);
+    }
+
+    private void setField(Object target, String fieldName, Object value) {
+        try {
+            java.lang.reflect.Field f = target.getClass().getDeclaredField(fieldName);
+            f.setAccessible(true);
+            f.set(target, value);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private HealthReport report(Long id, Long familyId, Long subjectId, String status) {
+        HealthReport r = new HealthReport();
+        r.setId(id);
+        r.setFamilyId(familyId);
+        r.setSubjectId(subjectId);
+        r.setStatus(status);
+        return r;
+    }
+
+    // ── reassignReport ──────────────────────────────────────────────────
+
+    @Test
+    void reassignReport_noActualChange_returnsEarly() {
+        when(healthReportMapper.selectById(1L)).thenReturn(report(1L, 10L, 20L, "active"));
+
+        service.reassignReport(1L, 20L, 10L);
+
+        verify(jdbcTemplate, never()).update(anyString(), any(), anyLong());
+        verify(dimensionScoreService, never()).refreshFromGutReport(anyLong(), anyLong());
+        verify(foodRecommendService, never()).recalculateForUser(anyLong());
+    }
+
+    @Test
+    void reassignReport_unbind_subjectIdNull() {
+        when(healthReportMapper.selectById(1L)).thenReturn(report(1L, 10L, 20L, "active"));
+
+        service.reassignReport(1L, null, 10L);
+
+        verify(jdbcTemplate).update(eq("UPDATE health_reports SET subject_id = ? WHERE id = ?"),
+                eq(null), eq(1L));
+        verify(dimensionScoreService).refreshFromGutReport(eq(20L), eq(1L));
+        verify(foodRecommendService).recalculateForUser(eq(20L));
+    }
+
+    @Test
+    void reassignReport_bind_newMember() {
+        when(healthReportMapper.selectById(1L)).thenReturn(report(1L, 10L, null, "active"));
+
+        service.reassignReport(1L, 30L, 10L);
+
+        verify(jdbcTemplate).update(eq("UPDATE health_reports SET subject_id = ? WHERE id = ?"),
+                eq(30L), eq(1L));
+        verify(dimensionScoreService).refreshFromGutReport(eq(30L), eq(1L));
+        verify(foodRecommendService).recalculateForUser(eq(30L));
+    }
+
+    @Test
+    void reassignReport_switchMember_recalcBoth() {
+        when(healthReportMapper.selectById(1L)).thenReturn(report(1L, 10L, 20L, "active"));
+
+        service.reassignReport(1L, 30L, 10L);
+
+        verify(jdbcTemplate).update(eq("UPDATE health_reports SET subject_id = ? WHERE id = ?"),
+                eq(30L), eq(1L));
+        verify(dimensionScoreService).refreshFromGutReport(eq(20L), eq(1L));
+        verify(dimensionScoreService).refreshFromGutReport(eq(30L), eq(1L));
+        verify(foodRecommendService).recalculateForUser(eq(20L));
+        verify(foodRecommendService).recalculateForUser(eq(30L));
+    }
+
+    @Test
+    void reassignReport_wrongFamilyId_throws() {
+        when(healthReportMapper.selectById(1L)).thenReturn(report(1L, 99L, 20L, "active"));
+
+        assertThrows(RuntimeException.class, () -> service.reassignReport(1L, 30L, 10L));
+
+        verify(jdbcTemplate, never()).update(anyString(), any(), anyLong());
+        verify(dimensionScoreService, never()).refreshFromGutReport(anyLong(), anyLong());
+    }
+
+    @Test
+    void reassignReport_reportNotFound_throws() {
+        when(healthReportMapper.selectById(1L)).thenReturn(null);
+
+        assertThrows(RuntimeException.class, () -> service.reassignReport(1L, 30L, 10L));
+
+        verify(jdbcTemplate, never()).update(anyString(), any(), anyLong());
+    }
+
+    // ── getUnlinkedReports ──────────────────────────────────────────────
+
+    @Test
+    void getUnlinkedReports_returnsReports() {
+        List<HealthReport> reports = Collections.singletonList(report(1L, 10L, null, "active"));
+        when(healthReportMapper.selectList(any())).thenReturn(reports);
+
+        List<HealthReport> result = service.getUnlinkedReports(10L);
+
+        assertEquals(1, result.size());
+        verify(healthReportMapper).selectList(any());
+    }
+}

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-3933eaf2d103d664431da445120973ef68ae7b26
+61d304d3f5979faa75201214e75515319ecff833

+ 16 - 0
cfc-web/src/api/admin.js

@@ -2101,6 +2101,22 @@ export function editHealthReport(data) {
   })
 }
 
+export function reassignHealthReport(data) {
+  return request({
+    url: '/api/health/report/reassign',
+    method: 'post',
+    data
+  })
+}
+
+export function getUnlinkedReports(data) {
+  return request({
+    url: '/api/health/report/unlinked',
+    method: 'post',
+    data
+  })
+}
+
 // ===== CF值(平台积分)管理 =====
 export function getCfValueBalance(data) {
   return request({

+ 27 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -1066,6 +1066,33 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 
 **专属采集脚本路径(在 `HealthReportDraftService`):** `getDraftById` / `markCollecting` / `updateParseMethod` / `markCollectCompleted` / `markCollectFailed` / `listCollectingByUser`。
 
+### 4.36.1 报告归属管理(`/api/health/report/*`)
+
+报告归属由 `health_reports.subject_id` 字段表示(关联家庭成员 ID,可为 null)。无归属的报告**不参与方案生成**,但可在管理端或小程序手动绑定/重新分配。
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/health/report/reassign` | 重新分配报告归属(含维度重算),请求 `{ reportId, subjectId? }`,`subjectId` 为 null 表示解绑 |
+| `POST /api/health/report/unlinked` | 获取家庭内所有未绑定归属的报告,请求 `{ familyId }`,返回 `List<HealthReport>` |
+| `POST /api/health/report/bind` | 绑定报告到成员(仅绑定,不可解绑),请求 `{ reportId, subjectId }` |
+
+**`/api/health/report/reassign` 请求体:**
+```json
+{
+  "reportId": 123,
+  "subjectId": 456       // 可为 null 表示解除绑定
+}
+```
+
+**响应:** `Result<String>`,`"归属已更新"`
+
+**归属变更触发联动:**
+- 旧归属成员(若有):五维能量重算 + 食材推荐指数刷新
+- 新归属成员(若有):五维能量重算 + 食材推荐指数刷新
+- 方案生成:`subject_id IS NULL` 的报告自动跳过(`triggerAutoPlanAfterConfirm` 已有此逻辑)
+
+**权限检查:** 通过 `familyId`(JWT 注入)验证报告属于当前家庭,跨家庭操作抛出 `RuntimeException("报告不属于该家庭")`。
+
 ---
 
 ### 4.37 报告反馈与 AI 解答(`/api/health/feedback/*`)