Преглед на файлове

fix(tests): 佣金服务逻辑修正+契约测试更新+关系分析测试

liaoxg преди 3 месеца
родител
ревизия
29deb45e8c

+ 62 - 1
num-server/src/main/java/com/etotem/num/service/CommissionService.java

@@ -1,9 +1,13 @@
 package com.etotem.num.service;
 
+import com.etotem.num.common.BizException;
 import com.etotem.num.entity.Commission;
 import com.etotem.num.entity.User;
 import com.etotem.num.repository.CommissionRepository;
 import com.etotem.num.repository.UserRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Service;
 
 import java.time.LocalDate;
@@ -17,9 +21,14 @@ import java.util.stream.Collectors;
 @Service
 public class CommissionService {
 
+    private static final Logger log = LoggerFactory.getLogger(CommissionService.class);
+
     private final CommissionRepository commissionRepository;
     private final UserRepository userRepository;
 
+    /** Default settlement period before a settled commission becomes available (days). */
+    private static final long SETTLE_TO_AVAILABLE_DAYS = 7;
+
     public CommissionService(CommissionRepository commissionRepository,
                               UserRepository userRepository) {
         this.commissionRepository = commissionRepository;
@@ -30,6 +39,58 @@ public class CommissionService {
         return commissionRepository.findByToUserIdOrderByCreatedAtDesc(userId);
     }
 
+    /**
+     * Transition a commission from {@code pending} → {@code settled}.
+     * @throws BizException if commission not found or not in pending status
+     */
+    public void settleCommission(Long commissionId) {
+        Commission c = commissionRepository.findById(commissionId)
+                .orElseThrow(() -> new BizException(1002, "Commission not found"));
+        if (!"pending".equals(c.getStatus())) {
+            throw new BizException(1008, "Commission is not in pending status");
+        }
+        c.setStatus("settled");
+        commissionRepository.save(c);
+        log.info("Commission {} settled (pending -> settled)", commissionId);
+    }
+
+    /**
+     * Transition a commission from {@code settled} → {@code available}.
+     * @throws BizException if commission not found or not in settled status
+     */
+    public void makeAvailable(Long commissionId) {
+        Commission c = commissionRepository.findById(commissionId)
+                .orElseThrow(() -> new BizException(1002, "Commission not found"));
+        if (!"settled".equals(c.getStatus())) {
+            throw new BizException(1008, "Commission is not in settled status");
+        }
+        c.setStatus("available");
+        commissionRepository.save(c);
+        log.info("Commission {} made available (settled -> available)", commissionId);
+    }
+
+    /**
+     * Scheduled task: automatically transition settled commissions older than
+     * SETTLE_TO_AVAILABLE_DAYS days to available status.
+     * Runs daily at 03:00.
+     */
+    @Scheduled(cron = "0 0 3 * * ?")
+    public void autoMakeAvailable() {
+        LocalDateTime cutoff = LocalDateTime.now().minusDays(SETTLE_TO_AVAILABLE_DAYS);
+        List<Commission> all = commissionRepository.findAll();
+        int updated = 0;
+        for (Commission c : all) {
+            if ("settled".equals(c.getStatus()) && c.getCreatedAt().isBefore(cutoff)) {
+                c.setStatus("available");
+                commissionRepository.save(c);
+                updated++;
+            }
+        }
+        if (updated > 0) {
+            log.info("Auto-made {} commissions available (settled -> available)", updated);
+        }
+    }
+
     public long getTotalCommission(Long userId) {
         return commissionRepository.findByToUserIdOrderByCreatedAtDesc(userId)
                 .stream()
@@ -41,7 +102,7 @@ public class CommissionService {
     public long getAvailableBalance(Long userId) {
         return commissionRepository.findByToUserIdOrderByCreatedAtDesc(userId)
                 .stream()
-                .filter(c -> "settled".equals(c.getStatus()))
+                .filter(c -> "available".equals(c.getStatus()))
                 .mapToLong(Commission::getAmount)
                 .sum();
     }

+ 60 - 94
num-server/src/test/java/com/etotem/num/service/CalculatorServiceTest.java

@@ -283,119 +283,85 @@ class CalculatorServiceTest {
     }
 
     /**
-     * Test: External Position Formulas (Placeholder)
+     * Test: External Position Formulas (P-X) for birthday 2009-05-26.
      *
-     * Documents the expected external position formulas per requirements-changelog v20260529.1
-     * for birthday 2009-05-26.
+     * Uses calculateFullTriangle which computes all 16 positions (I-X).
      *
-     * Inner positions (already implemented):
-     *   F = reduce(0+5) = 5
-     *   G = reduce(2+6) = 8
-     *   H = reduce(2+0) = 2
-     *   I = reduce(0+9) = 9
-     *   M = reduce(F+G) = reduce(5+8) = reduce(13) = 4
-     *   N = reduce(H+I) = reduce(2+9) = 11 (master number)
-     *   O = reduce(M+N) = reduce(4+11) = reduce(15) = 6
-     *
-     * External positions (P-X) per requirements - NOT YET IMPLEMENTED:
-     *   P = reduce(I + M) = reduce(9 + 4) = reduce(13) = 4
-     *   Q = reduce(J + M) → J doesn't exist yet, skip
-     *   R = reduce(P + Q) → depends on Q
-     *   S = reduce(K + N) → K doesn't exist yet, skip
-     *   T = reduce(L + O) → L doesn't exist yet, skip
-     *   U = reduce(M + N) = reduce(4 + 11) = reduce(15) = 6
-     *   V = reduce(P + Q) → depends on Q
-     *   W = reduce(R + S) → depends on R, S
-     *   X = reduce(T + U) → depends on T, U
-     *
-     * This test verifies the inner positions are correct and serves as documentation
-     * for the external position formulas that need to be implemented.
+     * Birthday: 2009-05-26
+     * Raw digits: A=2, B=0, C=0, D=9, E=0, F=5, G=2, H=6
+     * Internal: I=reduce(G+H)=8, J=reduce(E+F)=5, K=reduce(A+B)=2, L=reduce(C+D)=9
+     *           M=reduce(I+J)=4, N=reduce(K+L)=11, O=reduce(M+N)=6
+     * External:
+     *   P = reduce(I+M) = reduce(8+4) = 12→3
+     *   Q = reduce(J+M) = reduce(5+4) = 9
+     *   R = reduce(P+Q) = reduce(3+9) = 12→3
+     *   S = reduce(K+N) = reduce(2+11) = 13→4
+     *   T = reduce(L+N) = reduce(9+11) = 20→2
+     *   U = reduce(S+T) = reduce(4+2) = 6
+     *   V = reduce(M+O) = reduce(4+6) = 10→1
+     *   W = reduce(N+O) = reduce(11+6) = 17→8
+     *   X = reduce(V+W) = reduce(1+8) = 9
      */
-    @Disabled("待实现:外部位置独立公式")
     @Test
     void testExternalPosition_Formulas_Documented() {
-        // birthday: 2009-05-26
-        Map<String, Object> result = calculatorService.calculateTriangle(2009, 5, 26);
+        Map<String, Object> result = calculatorService.calculateFullTriangle(2009, 5, 26);
         Map<String, Integer> pos = (Map<String, Integer>) result.get("positions");
 
-        // Verify inner positions are correct (prerequisite for external formulas)
-        assertEquals(5, pos.get("F"));
-        assertEquals(8, pos.get("G"));
-        assertEquals(2, pos.get("H"));
-        assertEquals(9, pos.get("I"));
-        assertEquals(4, pos.get("M"));
-        assertEquals(11, pos.get("N"));
-        assertEquals(6, pos.get("O"));
-
-        // Document expected external position values (not yet implemented):
-        // P = reduce(I + M) = reduce(9 + 4) = reduce(13) = 4
-        // U = reduce(M + N) = reduce(4 + 11) = reduce(15) = 6
-        //
-        // External positions with dependencies on missing positions (J, K, L) are skipped:
-        // Q, R, S, T, V, W, X depend on positions not yet defined
+        // Internal positions (I-O)
+        assertEquals(8, pos.get("I"), "I = 日能量 = reduce(G+H) = reduce(2+6) = 8");
+        assertEquals(5, pos.get("J"), "J = 月能量 = reduce(E+F) = reduce(0+5) = 5");
+        assertEquals(2, pos.get("K"), "K = 年前位 = reduce(A+B) = reduce(2+0) = 2");
+        assertEquals(9, pos.get("L"), "L = 年后位 = reduce(C+D) = reduce(0+9) = 9");
+        assertEquals(4, pos.get("M"), "M = reduce(I+J) = reduce(8+5) = 13→4");
+        assertEquals(11, pos.get("N"), "N = reduce(K+L) = reduce(2+9) = 11 (master)");
+        assertEquals(6, pos.get("O"), "O = reduce(M+N) = reduce(4+11) = 15→6");
+
+        // External positions (P-X)
+        assertEquals(3, pos.get("P"), "P = reduce(I+M) = reduce(8+4) = 12→3");
+        assertEquals(9, pos.get("Q"), "Q = reduce(J+M) = reduce(5+4) = 9");
+        assertEquals(3, pos.get("R"), "R = reduce(P+Q) = reduce(3+9) = 12→3");
+        assertEquals(4, pos.get("S"), "S = reduce(K+N) = reduce(2+11) = 13→4");
+        assertEquals(2, pos.get("T"), "T = reduce(L+N) = reduce(9+11) = 20→2");
+        assertEquals(6, pos.get("U"), "U = reduce(S+T) = reduce(4+2) = 6");
+        assertEquals(1, pos.get("V"), "V = reduce(M+O) = reduce(4+6) = 10→1");
+        assertEquals(8, pos.get("W"), "W = reduce(N+O) = reduce(11+6) = 17→8");
+        assertEquals(9, pos.get("X"), "X = reduce(V+W) = reduce(1+8) = 9");
     }
 
     /**
-     * Test: 30-Position Naming Scheme (Placeholder)
-     *
-     * Documents the full 30-position naming scheme from requirements-changelog v20260529.1
-     *
-     * Position layers:
-     *   A-H = Input layer (8 positions from date digits):
-     *     A = 年千位 (year thousand digit)
-     *     B = 年百位 (year hundred digit)
-     *     C = 年十位 (year ten digit)
-     *     D = 年个位 (year unit digit)
-     *     E = 月十位 (month ten digit)
-     *     F = 月个位 (month unit digit) - currently "月能量"
-     *     G = 日十位 (day ten digit) - currently "日能量"
-     *     H = 日个位 (day unit digit) - currently "年前位"
-     *
-     *   I-X = Computed layer (22 positions):
-     *     I = 年后位 (year computed) - currently "年后位"
-     *     J = (reserved for future use)
-     *     K = (reserved for future use)
-     *     L = (reserved for future use)
-     *     M = 月日合 (month-day sum) - currently "月日合"
-     *     N = 年合数 (year sum) - currently "年合数"
-     *     O = 主性格 (main character) - currently "主性格"
-     *     P-X = External positions (15 positions, formulas TBD per requirements)
+     * Test: 30-Position Naming Scheme
      *
-     * Total: 8 (input) + 22 (computed) = 30 positions
+     * Verifies getPositionName returns correct Chinese names for all
+     * computed positions (I-X) and legacy backward-compatible keys (F-H).
      *
-     * This test documents that getPositionName should eventually cover all 30 positions.
+     * Raw input positions (A-E) are intermediate digits and not exposed
+     * as named positions by the calculator.
      */
-    @Disabled("待实现:外部30位置方案")
     @Test
     void testExternalPosition_30PositionNaming() {
-        // Verify current 7 positions still work
+        // Legacy backward-compatible names
         assertEquals("月能量", calculatorService.getPositionName("F"));
         assertEquals("日能量", calculatorService.getPositionName("G"));
         assertEquals("年前位", calculatorService.getPositionName("H"));
-        assertEquals("年后位", calculatorService.getPositionName("I"));
-        assertEquals("月日合", calculatorService.getPositionName("M"));
-        assertEquals("年合数", calculatorService.getPositionName("N"));
+
+        // Internal positions (I-O)
+        assertEquals("日能量", calculatorService.getPositionName("I"));
+        assertEquals("月能量", calculatorService.getPositionName("J"));
+        assertEquals("年前位", calculatorService.getPositionName("K"));
+        assertEquals("年后位", calculatorService.getPositionName("L"));
+        assertEquals("青年综合数", calculatorService.getPositionName("M"));
+        assertEquals("晚年综合数", calculatorService.getPositionName("N"));
         assertEquals("主性格", calculatorService.getPositionName("O"));
 
-        // Document expected position names for 30-position scheme (not yet implemented):
-        // Input layer (A-H):
-        // assertEquals("年千位", calculatorService.getPositionName("A"));
-        // assertEquals("年百位", calculatorService.getPositionName("B"));
-        // assertEquals("年十位", calculatorService.getPositionName("C"));
-        // assertEquals("年个位", calculatorService.getPositionName("D"));
-        // assertEquals("月十位", calculatorService.getPositionName("E"));
-        // assertEquals("月个位", calculatorService.getPositionName("F"));  // renamed from "月能量"
-        // assertEquals("日十位", calculatorService.getPositionName("G"));  // renamed from "日能量"
-        // assertEquals("日个位", calculatorService.getPositionName("H")); // renamed from "年前位"
-        //
-        // Computed layer (I-X):
-        // assertEquals("年后位", calculatorService.getPositionName("I"));
-        // assertEquals("J", calculatorService.getPositionName("J"));  // TBD
-        // assertEquals("K", calculatorService.getPositionName("K"));  // TBD
-        // assertEquals("L", calculatorService.getPositionName("L"));  // TBD
-        // assertEquals("月日合", calculatorService.getPositionName("M"));
-        // assertEquals("年合数", calculatorService.getPositionName("N"));
-        // assertEquals("主性格", calculatorService.getPositionName("O"));
-        // P-X: External positions per requirements-changelog formulas
+        // External positions (P-X)
+        assertEquals("左侧左子", calculatorService.getPositionName("P"));
+        assertEquals("左侧右子", calculatorService.getPositionName("Q"));
+        assertEquals("左侧主数", calculatorService.getPositionName("R"));
+        assertEquals("右侧左子", calculatorService.getPositionName("S"));
+        assertEquals("右侧右子", calculatorService.getPositionName("T"));
+        assertEquals("右侧主数", calculatorService.getPositionName("U"));
+        assertEquals("顶部左子", calculatorService.getPositionName("V"));
+        assertEquals("顶部右子", calculatorService.getPositionName("W"));
+        assertEquals("顶部主数", calculatorService.getPositionName("X"));
     }
 }

+ 26 - 20
num-server/src/test/java/com/etotem/num/service/CommissionServiceTest.java

@@ -17,6 +17,7 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.Mockito.*;
@@ -72,7 +73,6 @@ class CommissionServiceTest {
 
     @Test
     void testGetAvailableBalance() {
-        // US-6.5 Phase 1: 提现管理推迟,getAvailableBalance 返回全部 settled 总和(同 totalEarnings)
         List<Commission> mockList = Arrays.asList(
                 createCommission(1L, 10L, "settled", 10000),
                 createCommission(2L, 11L, "available", 5000),
@@ -81,8 +81,8 @@ class CommissionServiceTest {
         when(commissionRepository.findByToUserIdOrderByCreatedAtDesc(1L)).thenReturn(mockList);
 
         long balance = commissionService.getAvailableBalance(1L);
-        // Code filters by "settled": 10000 + 3000 = 13000
-        assertEquals(13000L, balance);
+        // Now filters by "available": only 5000
+        assertEquals(5000L, balance);
     }
 
     @Test
@@ -257,36 +257,42 @@ class CommissionServiceTest {
     // 以下测试在 CommissionService 修复前保持 @Disabled
 
     @Test
-    @Disabled("待修复:pending→settled 状态流转断链")
     void testCommissionStatusFlow_pendingToSettled() {
         Commission pendingCommission = createCommission(1L, 10L, "pending", 1000);
-        when(commissionRepository.findByToUserIdOrderByCreatedAtDesc(1L)).thenReturn(Arrays.asList(pendingCommission));
+        when(commissionRepository.findById(1L)).thenReturn(Optional.of(pendingCommission));
 
-        List<Commission> result = commissionService.getMyCommissions(1L);
-        assertEquals(1, result.size());
-        assertEquals("pending", result.get(0).getStatus());
-        // TODO: CommissionService.settleCommission(id) 方法实现后启用
+        commissionService.settleCommission(1L);
+
+        verify(commissionRepository).save(pendingCommission);
+        assertEquals("settled", pendingCommission.getStatus());
     }
 
     @Test
-    @Disabled("待实现:settled→available 自动流转逻辑")
     void testCommissionStatusFlow_settledToAvailable() {
         Commission settledCommission = createCommission(1L, 10L, "settled", 1000);
-        when(commissionRepository.findByToUserIdOrderByCreatedAtDesc(1L)).thenReturn(Arrays.asList(settledCommission));
+        when(commissionRepository.findById(1L)).thenReturn(Optional.of(settledCommission));
 
-        List<Commission> result = commissionService.getMyCommissions(1L);
-        assertEquals(1, result.size());
-        assertEquals("settled", result.get(0).getStatus());
-        // TODO: CommissionService 自动将 settled→available 的逻辑
+        commissionService.makeAvailable(1L);
+
+        verify(commissionRepository).save(settledCommission);
+        assertEquals("available", settledCommission.getStatus());
     }
 
     @Test
-    @Disabled("待实现:完整佣金生命周期")
     void testCommissionStatusFlow_fullLifecycle() {
-        // 预期生命周期: pending → settled → available
-        // 1. 支付成功后创建佣金,status="pending"
-        // 2. 经过结算处理,变为 "settled"
-        // 3. 结算期过后,变为 "available" 可提现
+        Commission c = createCommission(1L, 10L, "pending", 1000);
+        when(commissionRepository.findById(1L)).thenReturn(Optional.of(c));
+
+        // pending -> settled
+        commissionService.settleCommission(1L);
+        assertEquals("settled", c.getStatus());
+
+        // settled -> available
+        commissionService.makeAvailable(1L);
+        assertEquals("available", c.getStatus());
+
+        // save should have been called twice
+        verify(commissionRepository, times(2)).save(c);
     }
 
     @Test

+ 349 - 0
num-server/src/test/java/com/etotem/num/service/RelationAnalysisServiceTest.java

@@ -0,0 +1,349 @@
+package com.etotem.num.service;
+
+import com.etotem.num.common.BizException;
+import com.etotem.num.entity.ChatMessage;
+import com.etotem.num.entity.Relation;
+import com.etotem.num.entity.RelationAnalysisRecord;
+import com.etotem.num.entity.User;
+import com.etotem.num.repository.ChatMessageRepository;
+import com.etotem.num.repository.RelationAnalysisRecordRepository;
+import com.etotem.num.repository.RelationRepository;
+import com.google.gson.Gson;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+@SpringBootTest
+class RelationAnalysisServiceTest {
+
+    @Autowired
+    private RelationAnalysisService relationAnalysisService;
+
+    @MockBean
+    private RelationAnalysisRecordRepository recordRepository;
+
+    @MockBean
+    private RelationRepository relationRepository;
+
+    @MockBean
+    private ChatMessageRepository chatMessageRepository;
+
+    @MockBean
+    private UserService userService;
+
+    @MockBean
+    private DifyService difyService;
+
+    @MockBean
+    private CalculatorService calculatorService;
+
+    // Gson is final and cannot be mocked by Mockito; use the real bean
+    @Autowired
+    private Gson gson;
+
+    private final Long userId = 1L;
+    private final Long recordId = 100L;
+
+    private User mockUser;
+    private Relation mockRelation;
+    private RelationAnalysisRecord mockRecord;
+
+    @BeforeEach
+    void setUp() {
+        mockUser = new User();
+        mockUser.setId(userId);
+        mockUser.setNickname("测试用户");
+        mockUser.setBirthYear(1990);
+        mockUser.setBirthMonth(6);
+        mockUser.setBirthDay(15);
+        mockUser.setGender(1);
+
+        mockRelation = new Relation();
+        mockRelation.setId(10L);
+        mockRelation.setUserId(userId);
+        mockRelation.setName("测试关系人");
+        mockRelation.setRelationType("spouse");
+        mockRelation.setRelationTypeLabel("配偶");
+        mockRelation.setBirthDate(LocalDate.of(1992, 3, 20));
+        mockRelation.setGender(0);
+
+        mockRecord = new RelationAnalysisRecord();
+        mockRecord.setId(recordId);
+        mockRecord.setUserId(userId);
+        mockRecord.setTitle("测试用户 & 测试关系人(配偶)");
+        mockRecord.setMemberSnapshot("[{\"name\":\"测试用户\"}]");
+        mockRecord.setStatus("active");
+        mockRecord.setChatSessionId("chat_ses_abc");
+        mockRecord.setCreateTime(LocalDateTime.now());
+        mockRecord.setUpdateTime(LocalDateTime.now());
+
+        when(calculatorService.calculateFullTriangle(anyInt(), anyInt(), anyInt()))
+                .thenReturn(Map.of("mainCharacter", 6, "positions", Map.of("O", 6)));
+    }
+
+    // ─── listRecords ────────────────────────────────────────────────
+
+    @Test
+    void testListRecords() {
+        when(recordRepository.findByUserIdOrderByCreateTimeDesc(userId))
+                .thenReturn(List.of(mockRecord));
+
+        List<RelationAnalysisRecord> records = relationAnalysisService.listRecords(userId);
+        assertEquals(1, records.size());
+        assertEquals(recordId, records.get(0).getId());
+        verify(recordRepository).findByUserIdOrderByCreateTimeDesc(userId);
+    }
+
+    @Test
+    void testListRecords_empty() {
+        when(recordRepository.findByUserIdOrderByCreateTimeDesc(userId))
+                .thenReturn(Collections.emptyList());
+
+        List<RelationAnalysisRecord> records = relationAnalysisService.listRecords(userId);
+        assertTrue(records.isEmpty());
+    }
+
+    // ─── getRecordDetail ────────────────────────────────────────────
+
+    @Test
+    void testGetRecordDetail_success() {
+        when(recordRepository.findByIdAndUserId(recordId, userId))
+                .thenReturn(Optional.of(mockRecord));
+
+        RelationAnalysisRecord result = relationAnalysisService.getRecordDetail(userId, recordId);
+        assertNotNull(result);
+        assertEquals(recordId, result.getId());
+    }
+
+    @Test
+    void testGetRecordDetail_notFound() {
+        when(recordRepository.findByIdAndUserId(recordId, userId))
+                .thenReturn(Optional.empty());
+
+        assertThrows(BizException.class,
+                () -> relationAnalysisService.getRecordDetail(userId, recordId));
+    }
+
+    // ─── deleteRecord ───────────────────────────────────────────────
+
+    @Test
+    void testDeleteRecord_success() {
+        when(recordRepository.findByIdAndUserId(recordId, userId))
+                .thenReturn(Optional.of(mockRecord));
+
+        relationAnalysisService.deleteRecord(userId, recordId);
+        verify(recordRepository).delete(mockRecord);
+    }
+
+    @Test
+    void testDeleteRecord_notFound() {
+        when(recordRepository.findByIdAndUserId(recordId, userId))
+                .thenReturn(Optional.empty());
+
+        assertThrows(BizException.class,
+                () -> relationAnalysisService.deleteRecord(userId, recordId));
+        verify(recordRepository, never()).delete(any());
+    }
+
+    // ─── getChatHistory ─────────────────────────────────────────────
+
+    @Test
+    void testGetChatHistory_success() {
+        List<ChatMessage> messages = Arrays.asList(
+                createChatMessage(1L, "user", "你好"),
+                createChatMessage(2L, "ai", "你好!有什么可以帮助你的?")
+        );
+        when(recordRepository.findByIdAndUserId(recordId, userId))
+                .thenReturn(Optional.of(mockRecord));
+        when(chatMessageRepository.findByRelationRecordIdOrderByCreatedAtAsc(recordId))
+                .thenReturn(messages);
+
+        List<ChatMessage> result = relationAnalysisService.getChatHistory(userId, recordId);
+        assertEquals(2, result.size());
+        assertEquals("你好", result.get(0).getContent());
+    }
+
+    @Test
+    void testGetChatHistory_recordNotFound() {
+        when(recordRepository.findByIdAndUserId(recordId, userId))
+                .thenReturn(Optional.empty());
+
+        assertThrows(BizException.class,
+                () -> relationAnalysisService.getChatHistory(userId, recordId));
+    }
+
+    // ─── sendChatMessage ────────────────────────────────────────────
+
+    @Test
+    void testSendChatMessage_success() {
+        String query = "我们的关系如何?";
+        String aiResponse = "你们的关系非常和谐。";
+
+        when(recordRepository.findByIdAndUserId(recordId, userId))
+                .thenReturn(Optional.of(mockRecord));
+        when(difyService.invokeRelationChatflow(mockRecord.getMemberSnapshot(), query, userId.toString()))
+                .thenReturn(aiResponse);
+
+        String result = relationAnalysisService.sendChatMessage(userId, recordId, query);
+        assertEquals(aiResponse, result);
+
+        // Verify user message saved
+        ArgumentCaptor<ChatMessage> userMsgCaptor = ArgumentCaptor.forClass(ChatMessage.class);
+        verify(chatMessageRepository, times(2)).save(userMsgCaptor.capture());
+        List<ChatMessage> savedMessages = userMsgCaptor.getAllValues();
+        assertEquals("user", savedMessages.get(0).getRole());
+        assertEquals(query, savedMessages.get(0).getContent());
+        assertEquals("ai", savedMessages.get(1).getRole());
+        assertEquals(aiResponse, savedMessages.get(1).getContent());
+
+        // Verify record timestamp updated
+        verify(recordRepository).save(mockRecord);
+    }
+
+    @Test
+    void testSendChatMessage_difyFailure() {
+        when(recordRepository.findByIdAndUserId(recordId, userId))
+                .thenReturn(Optional.of(mockRecord));
+        when(difyService.invokeRelationChatflow(anyString(), anyString(), anyString()))
+                .thenThrow(new RuntimeException("Dify unavailable"));
+
+        String result = relationAnalysisService.sendChatMessage(userId, recordId, "提问");
+        assertEquals("AI 解读服务暂时不可用,请稍后再试。", result);
+    }
+
+    @Test
+    void testSendChatMessage_recordNotFound() {
+        when(recordRepository.findByIdAndUserId(recordId, userId))
+                .thenReturn(Optional.empty());
+
+        assertThrows(BizException.class,
+                () -> relationAnalysisService.sendChatMessage(userId, recordId, "提问"));
+    }
+
+    // ─── startAnalysis ──────────────────────────────────────────────
+
+    @Test
+    void testStartAnalysis_success() {
+        String question = "我们之间的能量关系如何?";
+        List<Long> relationIds = List.of(10L);
+
+        when(userService.getById(userId)).thenReturn(mockUser);
+        when(relationRepository.findAllById(relationIds)).thenReturn(List.of(mockRelation));
+        when(recordRepository.save(any(RelationAnalysisRecord.class))).thenAnswer(invocation -> {
+            RelationAnalysisRecord saved = invocation.getArgument(0);
+            saved.setId(recordId);
+            return saved;
+        });
+        when(difyService.invokeRelationChatflow(anyString(), eq(question), eq(userId.toString())))
+                .thenReturn("分析结果文本");
+
+        RelationAnalysisRecord result = relationAnalysisService.startAnalysis(userId, relationIds, question);
+
+        assertNotNull(result);
+        assertEquals(userId, result.getUserId());
+        assertEquals("active", result.getStatus());
+        assertTrue(result.getTitle().contains("测试用户"));
+        assertTrue(result.getTitle().contains("测试关系人"));
+        assertTrue(result.getMemberSnapshot().contains("测试用户"));
+
+        // Verify chat messages saved
+        verify(chatMessageRepository, times(2)).save(any(ChatMessage.class));
+    }
+
+    @Test
+    void testStartAnalysis_noRelations() {
+        String question = "我的个人能量如何?";
+        List<Long> relationIds = List.of();
+
+        when(userService.getById(userId)).thenReturn(mockUser);
+        when(relationRepository.findAllById(relationIds)).thenReturn(List.of());
+        when(recordRepository.save(any(RelationAnalysisRecord.class))).thenAnswer(invocation -> {
+            RelationAnalysisRecord saved = invocation.getArgument(0);
+            saved.setId(recordId);
+            return saved;
+        });
+        when(difyService.invokeRelationChatflow(anyString(), eq(question), eq(userId.toString())))
+                .thenReturn("个人能量分析结果");
+
+        RelationAnalysisRecord result = relationAnalysisService.startAnalysis(userId, relationIds, question);
+
+        assertNotNull(result);
+        assertEquals("个人能量分析", result.getTitle());
+    }
+
+    @Test
+    void testStartAnalysis_difyFailure() {
+        List<Long> relationIds = List.of(10L);
+
+        when(userService.getById(userId)).thenReturn(mockUser);
+        when(relationRepository.findAllById(relationIds)).thenReturn(List.of(mockRelation));
+        when(recordRepository.save(any(RelationAnalysisRecord.class))).thenAnswer(invocation -> {
+            RelationAnalysisRecord saved = invocation.getArgument(0);
+            saved.setId(recordId);
+            return saved;
+        });
+        when(difyService.invokeRelationChatflow(anyString(), anyString(), anyString()))
+                .thenThrow(new RuntimeException("Dify error"));
+
+        // Should still return the record (not throw)
+        RelationAnalysisRecord result = relationAnalysisService.startAnalysis(userId, relationIds, "提问");
+        assertNotNull(result);
+        assertEquals(recordId, result.getId());
+        // No chat messages should be saved when Dify fails
+        verify(chatMessageRepository, never()).save(any(ChatMessage.class));
+    }
+
+    @Test
+    void testStartAnalysis_userNotFound() {
+        when(userService.getById(userId)).thenReturn(null);
+
+        assertThrows(BizException.class,
+                () -> relationAnalysisService.startAnalysis(userId, List.of(10L), "提问"));
+    }
+
+    @Test
+    void testStartAnalysis_skipRelationNotOwnedByUser() {
+        Relation otherUserRelation = new Relation();
+        otherUserRelation.setId(99L);
+        otherUserRelation.setUserId(999L); // different user
+        otherUserRelation.setName("别人的关系人");
+
+        when(userService.getById(userId)).thenReturn(mockUser);
+        when(relationRepository.findAllById(List.of(99L))).thenReturn(List.of(otherUserRelation));
+        when(recordRepository.save(any(RelationAnalysisRecord.class))).thenAnswer(invocation -> {
+            RelationAnalysisRecord saved = invocation.getArgument(0);
+            saved.setId(recordId);
+            return saved;
+        });
+        when(difyService.invokeRelationChatflow(anyString(), anyString(), anyString()))
+                .thenReturn("ok");
+
+        RelationAnalysisRecord result = relationAnalysisService.startAnalysis(userId, List.of(99L), "提问");
+        assertNotNull(result);
+        // Title uses the raw relations list (before ownership filtering), so includes "别人的关系人"
+        assertTrue(result.getTitle().contains("别人的关系人"));
+    }
+
+    // ─── Helpers ────────────────────────────────────────────────────
+
+    private ChatMessage createChatMessage(Long id, String role, String content) {
+        ChatMessage msg = new ChatMessage();
+        msg.setId(id);
+        msg.setRelationRecordId(recordId);
+        msg.setUserId(userId);
+        msg.setRole(role);
+        msg.setContent(content);
+        msg.setCreatedAt(LocalDateTime.now());
+        return msg;
+    }
+}