소스 검색

test(tdd): 第134轮闭环——装置类16红转绿(分佣/打标/成长档案/任务方案)+S-cfc-131标签契约10例+R134汇报与台账

E2E Test Bot 2 일 전
부모
커밋
f3fc1cb68f

+ 275 - 0
cfc-backend/src/test/java/com/etotem/cfc/integration/ArticleTagFilterContractTest.java

@@ -0,0 +1,275 @@
+package com.etotem.cfc.integration;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.content.ArticleController;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.mapper.ArticleMapper;
+import com.etotem.cfc.mapper.ArticleReadingRecordMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.ArticlePermissionService;
+import com.etotem.cfc.service.ArticleService;
+import com.etotem.cfc.service.ContentDimensionThresholdService;
+import com.etotem.cfc.service.FiveDimensionScoreService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * S-cfc-131(标签订阅缺口键 content.article.listByTag)契约测试。
+ *
+ * <p>规格:{@code docs/superpowers/specs/2026-11-10-article-tag-filter-design.md}(实现 commit 6e9a511e2)。</p>
+ *
+ * <p>锚定条款:</p>
+ * <ul>
+ *   <li>§3.1 {@code getPublicList} 10 参:tag 非空时按 {@code parseCommaOrJsonTags} 解析后内存过滤,
+ *       tags 兼容 JSON 数组({@code ["减脂","睡眠"]})与逗号分隔({@code 减脂,睡眠})两种存储格式;</li>
+ *   <li>§3.1 {@code listTopTags(int limit)}:仅统计已发布且 tags 非空,按频次降序、并列按字典序,
+ *       {@code limit<=0} 默认 20;</li>
+ *   <li>§3.1 {@code POST /api/articles/tags} 返回 {@code Result<List<String>>}(Top 20);</li>
+ *   <li>§3.1 {@code POST /api/articles/list} 读 {@code body.tag} 并透传到 10 参 getPublicList(位置 4)。</li>
+ * </ul>
+ *
+ * <p>口径说明:本类为契约级(Controller↔Service 参数与返回结构),不验 UI 渲染;
+ * 前端 Tab 行为由 cfc-frontend Jest 覆盖。</p>
+ */
+class ArticleTagFilterContractTest {
+
+    private ArticleService articleService;
+    private ArticleController articleController;
+
+    private ArticleMapper articleMapper;
+    private ArticlePermissionService articlePermissionService;
+    private ArticleReadingRecordMapper articleReadingRecordMapper;
+    private FiveDimensionScoreService fiveDimensionScoreService;
+    private ContentDimensionThresholdService contentDimensionThresholdService;
+    private JdbcTemplate jdbcTemplate;
+    private UserMapper userMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        articleService = new ArticleService();
+        articleController = new ArticleController();
+
+        articleMapper = mock(ArticleMapper.class);
+        articlePermissionService = mock(ArticlePermissionService.class);
+        articleReadingRecordMapper = mock(ArticleReadingRecordMapper.class);
+        fiveDimensionScoreService = mock(FiveDimensionScoreService.class);
+        contentDimensionThresholdService = mock(ContentDimensionThresholdService.class);
+        jdbcTemplate = mock(JdbcTemplate.class);
+        userMapper = mock(UserMapper.class);
+
+        injectService("articleMapper", articleMapper);
+        injectService("articlePermissionService", articlePermissionService);
+        injectService("articleReadingRecordMapper", articleReadingRecordMapper);
+        injectService("fiveDimensionScoreService", fiveDimensionScoreService);
+        injectService("contentDimensionThresholdService", contentDimensionThresholdService);
+        injectService("jdbcTemplate", jdbcTemplate);
+        injectService("userMapper", userMapper);
+        injectController("articleService", articleService);
+        injectController("userMapper", userMapper);
+
+        // 权限过滤放行(原样返回)、无家庭得分(阈值过滤 fail-open)、无阅读时长、无已完成记录
+        when(articlePermissionService.filterVisible(any(), any(), any(), any()))
+                .thenAnswer(inv -> inv.getArgument(0));
+        when(fiveDimensionScoreService.getFamilyAverageScores(any())).thenReturn(Collections.emptyMap());
+        when(contentDimensionThresholdService.listByContents(anyString(), any())).thenReturn(Collections.emptyMap());
+        when(jdbcTemplate.queryForList(anyString())).thenReturn(Collections.emptyList());
+        when(articleReadingRecordMapper.selectCompletedArticleIdsByChild(anyLong()))
+                .thenReturn(Collections.emptyList());
+    }
+
+    private void injectService(String field, Object value) throws Exception {
+        Field f = ArticleService.class.getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(articleService, value);
+    }
+
+    private void injectController(String field, Object value) throws Exception {
+        Field f = ArticleController.class.getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(articleController, value);
+    }
+
+    private Article article(Long id, String tags) {
+        Article a = new Article();
+        a.setId(id);
+        a.setStatus("published");
+        a.setTags(tags);
+        a.setTitle("文章" + id);
+        a.setIsFeatured(0);
+        a.setSortOrder(0);
+        a.setPublishedAt(new Date());
+        return a;
+    }
+
+    private void mockAll(List<Article> all) {
+        when(articleMapper.selectList(any())).thenReturn(new ArrayList<>(all));
+    }
+
+    // ---------- TC-AT-01~05:/list 的 tag 过滤语义 ----------
+
+    @Test
+    @DisplayName("TC-AT-01 tag 命中 JSON 数组存法的文章,未命中文章被过滤")
+    void listFiltersByTagJsonArrayStorage() {
+        mockAll(Arrays.asList(
+                article(1L, "[\"减脂\",\"睡眠\"]"),
+                article(2L, "[\"免疫\"]"),
+                article(3L, null)));
+
+        Page<Article> page = articleService.getPublicList(null, null, null, "减脂",
+                1, 10, null, null, null, null);
+
+        assertEquals(1, page.getTotal(), "total 必须是过滤后的真实条数");
+        assertEquals(1, page.getRecords().size());
+        assertEquals(1L, page.getRecords().get(0).getId());
+    }
+
+    @Test
+    @DisplayName("TC-AT-02 tag 命中逗号分隔存法(历史数据兼容)")
+    void listFiltersByTagCommaStorage() {
+        mockAll(Arrays.asList(
+                article(1L, "减脂,睡眠"),
+                article(2L, "免疫")));
+
+        Page<Article> page = articleService.getPublicList(null, null, null, "睡眠",
+                1, 10, null, null, null, null);
+
+        assertEquals(1, page.getTotal());
+        assertEquals(1L, page.getRecords().get(0).getId());
+    }
+
+    @Test
+    @DisplayName("TC-AT-03 tag 为空 → 不做标签过滤,行为与旧版一致")
+    void listWithoutTagKeepsAll() {
+        mockAll(Arrays.asList(article(1L, "[\"减脂\"]"), article(2L, null), article(3L, "免疫")));
+
+        Page<Article> page = articleService.getPublicList(null, null, null, "",
+                1, 10, null, null, null, null);
+
+        assertEquals(3, page.getTotal());
+    }
+
+    @Test
+    @DisplayName("TC-AT-04 tag 无匹配 → 返回空页且 total=0(非 500/异常)")
+    void listWithUnknownTagReturnsEmptyPage() {
+        mockAll(Arrays.asList(article(1L, "[\"减脂\"]")));
+
+        Page<Article> page = articleService.getPublicList(null, null, null, "不存在的标签",
+                1, 10, null, null, null, null);
+
+        assertEquals(0, page.getTotal());
+        assertTrue(page.getRecords().isEmpty());
+    }
+
+    @Test
+    @DisplayName("TC-AT-05 tag 为精确匹配(子串不命中)")
+    void listTagRequiresExactMatch() {
+        mockAll(Arrays.asList(article(1L, "[\"减脂\"]")));
+
+        Page<Article> page = articleService.getPublicList(null, null, null, "减",
+                1, 10, null, null, null, null);
+
+        assertEquals(0, page.getTotal(), "「减」不应命中「减脂」——Tab 语义是全等而非模糊");
+    }
+
+    // ---------- TC-AT-06~09:/tags 聚合语义 ----------
+
+    @Test
+    @DisplayName("TC-AT-06 listTopTags 按出现频次降序")
+    void listTopTagsOrderByFrequencyDesc() {
+        mockAll(Arrays.asList(
+                article(1L, "[\"减脂\",\"睡眠\"]"),
+                article(2L, "[\"减脂\"]"),
+                article(3L, "减脂,免疫")));
+
+        List<String> tags = articleService.listTopTags(20);
+
+        assertEquals(Arrays.asList("减脂", "免疫", "睡眠"), tags);
+    }
+
+    @Test
+    @DisplayName("TC-AT-07 listTopTags 频次并列时按标签字典序")
+    void listTopTagsTieBreakByLexicalOrder() {
+        mockAll(Arrays.asList(
+                article(1L, "[\"睡眠\"]"),
+                article(2L, "[\"免疫\"]"),
+                article(3L, "[\"减脂\"]")));
+
+        List<String> tags = articleService.listTopTags(20);
+
+        // 频次均为 1 → 字典序(按 String.compareTo:免疫 < 减脂 < 睡眠 的码点序)
+        List<String> sorted = new ArrayList<>(Arrays.asList("睡眠", "免疫", "减脂"));
+        Collections.sort(sorted);
+        assertEquals(sorted, tags);
+    }
+
+    @Test
+    @DisplayName("TC-AT-08 listTopTags limit<=0 时默认取 20 条")
+    void listTopTagsDefaultLimit20() {
+        List<Article> all = new ArrayList<>();
+        for (int i = 0; i < 25; i++) {
+            all.add(article((long) i + 1, "[\"tag" + i + "\"]"));
+        }
+        mockAll(all);
+
+        assertEquals(20, articleService.listTopTags(0).size());
+        assertEquals(20, articleService.listTopTags(-5).size());
+        assertEquals(3, articleService.listTopTags(3).size(), "显式 limit 必须生效");
+    }
+
+    @Test
+    @DisplayName("TC-AT-09 listTopTags 忽略空 tags / 空标签项")
+    void listTopTagsSkipsBlankTags() {
+        mockAll(Arrays.asList(
+                article(1L, null),
+                article(2L, "   "),
+                article(3L, "[\"\",\"  \",\"减脂\"]"),
+                article(4L, "减脂,,")));
+
+        assertEquals(Collections.singletonList("减脂"), articleService.listTopTags(20));
+    }
+
+    // ---------- TC-AT-10:Controller 契约 ----------
+
+    @Test
+    @DisplayName("TC-AT-10 POST /api/articles/tags 返回 Top20;/list 透传 body.tag")
+    void controllerExposesTagsEndpointAndPassesTag() {
+        mockAll(Arrays.asList(
+                article(1L, "[\"减脂\"]"),
+                article(2L, "[\"减脂\"]"),
+                article(3L, "[\"免疫\"]")));
+
+        Result<List<String>> tagsResult = articleController.tags();
+        assertEquals(200, tagsResult.getCode());
+        assertNotNull(tagsResult.getData());
+        assertEquals(Arrays.asList("减脂", "免疫"), tagsResult.getData());
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("tag", "免疫");
+        Result<Page<Article>> listResult = articleController.list(body, null, null);
+        assertEquals(200, listResult.getCode());
+        assertEquals(1, listResult.getData().getTotal(),
+                "body.tag 必须透传到 getPublicList 第 4 参(否则标签筛选静默失效)");
+        assertEquals(3L, listResult.getData().getRecords().get(0).getId());
+    }
+}

+ 26 - 6
cfc-backend/src/test/java/com/etotem/cfc/integration/GrowthRecordSyncFlowTest.java

@@ -2,6 +2,7 @@ package com.etotem.cfc.integration;
 
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.controller.growth.GrowthRecordController;
+import com.etotem.cfc.entity.AssessmentOrder;
 import com.etotem.cfc.entity.GrowthRecord;
 import com.etotem.cfc.entity.Family;
 import com.etotem.cfc.dto.ParsedReport;
@@ -13,6 +14,7 @@ import com.etotem.cfc.mapper.FamilyMapper;
 import org.junit.jupiter.api.*;
 import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.mock.web.MockMultipartFile;
 
 import javax.annotation.Resource;
 import java.util.*;
@@ -127,7 +129,7 @@ public class GrowthRecordSyncFlowTest {
 
         // === Then:返回错误提示 ===
         assertEquals(500, result.getCode());
-        assertTrue(result.getMessage().contains("请选择孩子"));
+        assertTrue(result.getMessage().contains("请选择家庭成员"));
     }
 
     // ========== 场景2:创建档案并购买测评 ==========
@@ -142,7 +144,7 @@ public class GrowthRecordSyncFlowTest {
     void parentCreatesRecordWithOrder_Success() {
         // === Given:家长填写档案信息并选择同时购买测评 ===
         Map<String, Object> request = new HashMap<>();
-        request.put("childId", CHILD_ID);
+        request.put("memberId", CHILD_ID);
         request.put("familyId", FAMILY_ID);
         request.put("packageId", 5L);
         request.put("school", "北京市第一小学");
@@ -160,6 +162,15 @@ public class GrowthRecordSyncFlowTest {
         when(familyMapper.selectById(FAMILY_ID)).thenReturn(family);
         when(growthRecordService.createRecord(any(GrowthRecord.class))).thenReturn(savedRecord);
 
+        // 下单服务返回真实订单对象:控制器会取 orderNo/actualPrice/status,
+        // 返回 null 会在控制器内 NPE(R134 修正)
+        AssessmentOrder order = new AssessmentOrder();
+        order.setOrderNo("AS20261111000001");
+        order.setActualPrice(19900L);
+        order.setStatus("pending");
+        when(assessmentOrderService.createOrder(anyLong(), anyLong(), anyLong(), any(),
+                anyLong(), any(), any(), anyLong(), anyLong())).thenReturn(order);
+
         // === When:提交创建档案和购买测评 ===
         Result<Map<String, Object>> result = growthRecordController.createRecordWithOrder(request, PARENT_USER_ID, FAMILY_ID, CHILD_ID);
 
@@ -198,16 +209,25 @@ public class GrowthRecordSyncFlowTest {
                 .resilienceScore(84)
                 .build();
 
-        when(reportParseService.parseReport(eq(fileUrl))).thenReturn(parsedReport);
+        MockMultipartFile file = new MockMultipartFile(
+                "file", "health_2026_06.pdf", "application/pdf", "%PDF-1.4 test".getBytes());
+        when(storageService.storeFile(any())).thenReturn(fileUrl);
+        when(storageService.getFilePath(eq(fileUrl))).thenReturn("D:/tmp/health_2026_06.pdf");
+        when(reportParseService.parseReport(eq("D:/tmp/health_2026_06.pdf"))).thenReturn(parsedReport);
 
         // === When:系统解析报告 ===
-        Result<Map<String, Object>> result = growthRecordController.uploadAndParse(null);
+        Result<Map<String, Object>> result = growthRecordController.uploadAndParse(file);
 
         // === Then:Milestone-2 - 报告解析成功,返回提取的数据 ===
+        // 当前契约(upload-and-parse Stage 1):{sourceFileUrl, parsed, reportDateStr, hasDate}
         assertEquals(200, result.getCode());
         assertNotNull(result.getData());
-        assertEquals(85, result.getData().get("overallScore"));
-        verify(reportParseService).parseReport(eq(fileUrl));
+        assertEquals(fileUrl, result.getData().get("sourceFileUrl"));
+        Object parsedObj = result.getData().get("parsed");
+        assertNotNull(parsedObj);
+        assertEquals(85, ((ParsedReport) parsedObj).getOverallScore());
+        assertEquals(Boolean.FALSE, result.getData().get("hasDate"));
+        verify(reportParseService).parseReport(eq("D:/tmp/health_2026_06.pdf"));
     }
 
     // ========== 场景4:关联成长记录与测评结果 ==========

+ 36 - 8
cfc-backend/src/test/java/com/etotem/cfc/service/CfCommissionServiceSettleTest.java

@@ -12,16 +12,20 @@ import com.etotem.cfc.entity.CfIndirectSalesTier;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.OctopusEffectRecord;
 import com.etotem.cfc.entity.OctopusKeyPerson;
+import com.etotem.cfc.entity.OrderItem;
 import com.etotem.cfc.entity.PpointConsumptionLog;
 import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductOrder;
 import com.etotem.cfc.entity.ReferralTree;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.OctopusEffectKeyPersonMapper;
 import com.etotem.cfc.mapper.OctopusEffectRecordMapper;
 import com.etotem.cfc.mapper.OctopusKeyPersonMapper;
+import com.etotem.cfc.mapper.OrderItemMapper;
 import com.etotem.cfc.mapper.PpointConsumptionLogMapper;
 import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ProductOrderMapper;
 import com.etotem.cfc.mapper.ReferralTreeMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -81,6 +85,10 @@ class CfCommissionServiceSettleTest {
     @Mock
     private ProductMapper productMapper;
     @Mock
+    private ProductOrderMapper productOrderMapper;
+    @Mock
+    private OrderItemMapper orderItemMapper;
+    @Mock
     private OctopusKeyPersonMapper octopusKeyPersonMapper;
     @Mock
     private OctopusEffectRecordMapper octopusEffectRecordMapper;
@@ -119,12 +127,21 @@ class CfCommissionServiceSettleTest {
         TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FamilyMember.class);
         TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), OctopusKeyPerson.class);
 
-        // 商品:有效 P 点=100(商品订单 100 元 → 基数 100 P 点)
+        // 商品:成本价未配(0 分)→ 单商品 P 点基数 = 实付 − 0 = 实付
         Product p = new Product();
         p.setId(PRODUCT_ID);
         p.setCategoryId(1L);
         when(productMapper.selectById(PRODUCT_ID)).thenReturn(p);
         when(ppointConfigService.getEffectivePpoint(PRODUCT_ID, 1L)).thenReturn(100);
+
+        // 商品订单:数量 1、无 order_items 明细 → 走「单商品 实付 − 成本×数量」分支
+        // (c22aca496 起 P 点口径改为成本价核算,基数单位=分;缺这两个 mock 会让
+        //  calcOrderPpoints 内 NPE 被 catch 吞掉 → 基数为 0 → 整链路静默跳过,R134 修正)
+        ProductOrder order = new ProductOrder();
+        order.setId(ORDER_ID);
+        order.setQuantity(1);
+        when(productOrderMapper.selectById(ORDER_ID)).thenReturn(order);
+        when(orderItemMapper.findByOrderId(ORDER_ID)).thenReturn(Collections.emptyList());
         // 非商品路径的服务费率(L2 配置中心)
         when(configService.getIntConfig(CfConfigKeys.COMMISSION_SERVICE_RATE, 1000)).thenReturn(1000);
 
@@ -191,6 +208,15 @@ class CfCommissionServiceSettleTest {
         return m;
     }
 
+    /** 带成本价的商品(成本价单位:分) */
+    private Product product(Integer purchasePrice) {
+        Product p = new Product();
+        p.setId(PRODUCT_ID);
+        p.setCategoryId(1L);
+        p.setPurchasePrice(purchasePrice);
+        return p;
+    }
+
     private CfDirectSalesTier directTier(String name, int min, int rate) {
         CfDirectSalesTier t = new CfDirectSalesTier();
         t.setTierName(name);
@@ -253,16 +279,16 @@ class CfCommissionServiceSettleTest {
 
         cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 10000, PRODUCT_ID);
 
-        // F03a:基数 100 P 点 × 40%
+        // F03a:基数 = 实付 10000 分 − 成本 0 = 10000 P 点(成本价核算口径),× 40%
         ArgumentCaptor<Map<String, Object>> ctxCaptor = ArgumentCaptor.forClass(Map.class);
         verify(cfBalanceService).earnByRule(eq(memberIdOf(10L)), eq("F03a"), eq(ORDER_ID), ctxCaptor.capture());
-        assertEquals(100, ctxCaptor.getValue().get("base_ppoints"));
+        assertEquals(10000, ctxCaptor.getValue().get("base_ppoints"));
         assertEquals(40, ctxCaptor.getValue().get("rate_percent"));
 
-        // F03b:基数 100 P 点 × 5%
+        // F03b:同一基数 10000 P 点 × 5%
         ArgumentCaptor<Map<String, Object>> ctxCaptor2 = ArgumentCaptor.forClass(Map.class);
         verify(cfBalanceService).earnByRule(eq(memberIdOf(20L)), eq("F03b"), eq(ORDER_ID), ctxCaptor2.capture());
-        assertEquals(100, ctxCaptor2.getValue().get("base_ppoints"));
+        assertEquals(10000, ctxCaptor2.getValue().get("base_ppoints"));
         assertEquals(5, ctxCaptor2.getValue().get("rate_percent"));
 
         // 当前消费人返 CF 走 F02 统一账本:实付 10000 分
@@ -297,13 +323,15 @@ class CfCommissionServiceSettleTest {
         when(cfBalanceService.earnByRule(eq(memberIdOf(10L)), eq("F03a"), eq(ORDER_ID), any()))
                 .thenReturn(earnResult(48, false));
 
-        // 分润基数 12000 分(120 元 → 120 P 点),实付 10000 分
+        // 成本价 2000 分:分润基数 = 实付 10000 − 成本 2000 = 8000 P 点,
+        // 而当前人 F02 仍按实付 10000 分返 → 体现「实付与分润基数分离」(c22aca496 成本价口径)
+        when(productMapper.selectById(PRODUCT_ID)).thenReturn(product(2000));
         cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 12000, PRODUCT_ID, 10000);
 
         verify(cfEarnDispatchService).earnConsumeCf(eq(BUYER_ID), eq("product"), eq(ORDER_ID), eq(10000));
         ArgumentCaptor<Map<String, Object>> captor = ArgumentCaptor.forClass(Map.class);
         verify(cfBalanceService).earnByRule(eq(memberIdOf(10L)), eq("F03a"), eq(ORDER_ID), captor.capture());
-        assertEquals(120, captor.getValue().get("base_ppoints"));
+        assertEquals(8000, captor.getValue().get("base_ppoints"));
         assertEquals(40, captor.getValue().get("rate_percent"));
     }
 
@@ -400,7 +428,7 @@ class CfCommissionServiceSettleTest {
         assertEquals("product", log.getOrderType());
         assertEquals(PRODUCT_ID, log.getProductId());
         assertEquals(10000, log.getAmountCent());
-        assertEquals(100, log.getPpoints());
+        assertEquals(10000, log.getPpoints());
         assertNotNull(log.getCreatedAt());
     }
 

+ 9 - 4
cfc-backend/src/test/java/com/etotem/cfc/unit/ArticleAutoTagServiceTest.java

@@ -62,7 +62,9 @@ class ArticleAutoTagServiceTest {
 
         service.toggleStatus(1L, "published");
 
-        verify(taskExecutor, times(1)).execute(any(Runnable.class));
+        // 发布路径现有两个异步任务:自动打标 + 朗读音频预生成(ensureArticleAudioAfterPublish)
+        // → 共 2 次 execute;若打标调度被移除会掉到 1(R134 对齐音频特性)
+        verify(taskExecutor, times(2)).execute(any(Runnable.class));
     }
 
     @Test
@@ -71,7 +73,8 @@ class ArticleAutoTagServiceTest {
 
         service.toggleStatus(1L, "published");
 
-        verify(taskExecutor, never()).execute(any(Runnable.class));
+        // 标签非空 → 不调度打标任务,只剩朗读音频 1 次(空标签场景为 2 次,差量即打标)
+        verify(taskExecutor, times(1)).execute(any(Runnable.class));
     }
 
     @Test
@@ -100,7 +103,8 @@ class ArticleAutoTagServiceTest {
 
         service.auditArticle(2L, "approved", null, 99L);
 
-        verify(taskExecutor, times(1)).execute(any(Runnable.class));
+        // 审核通过发布:自动打标 + 朗读音频 = 2 次异步任务
+        verify(taskExecutor, times(2)).execute(any(Runnable.class));
     }
 
     @Test
@@ -135,7 +139,8 @@ class ArticleAutoTagServiceTest {
         Article created = service.publishByUser("标题", "<p>内容</p>", null, "private", "mind", 10L, "user");
 
         assertEquals("published", created.getStatus());
-        verify(taskExecutor, times(1)).execute(any(Runnable.class));
+        // 发布成功:自动打标 + 朗读音频预生成 = 2 次异步任务
+        verify(taskExecutor, times(2)).execute(any(Runnable.class));
     }
 
     @Test

+ 4 - 3
cfc-backend/src/test/java/com/etotem/cfc/unit/TaskPlanServiceTest.java

@@ -133,10 +133,11 @@ class TaskPlanServiceTest {
         when(packageService.getPackageById(30L)).thenReturn(pkg);
         // templateItemMapper.selectList → 空列表(避免 NPE)
         when(templateItemMapper.selectList(any())).thenReturn(Collections.emptyList());
-        // instanceMapper.insert → 返回1,用 Answer 捕获实例并设 ID
-        TaskPlanInstance capturedInstance = new TaskPlanInstance();
+        // instanceMapper.insert → 返回1;ID 必须回写到「传入的同一实例」,
+        // 否则 service 内 result.put("planId", plan.getId()) 取到的是未赋值的对象(R134 修正)
         when(instanceMapper.insert(any(TaskPlanInstance.class))).thenAnswer(invocation -> {
-            capturedInstance.setId(100L);
+            TaskPlanInstance arg = invocation.getArgument(0);
+            arg.setId(100L);
             return 1;
         });
 

+ 226 - 0
docs/tdd/orchestration/report-test-2026-11-11-R134.md

@@ -0,0 +1,226 @@
+# 测试负责人工作汇报 · 第 134 轮(2026-11-11 02:10 GMT+8,本自动化触发)
+
+> 执行档:**review(内网 cfc.iwintrue.com/admin + /frontend 浏览器自动化已授权;langgraph@251 分派;未联网装依赖、未启停服务、未改生产代码;测试侧自处置 4 个测试类改装置 + 新增 1 个契约测试类)**
+> 基线 HEAD:`b7d74eedc`(R133,01:35 推送)→ **origin/cfclub == local HEAD(窗口内零源码提交、零 SRS 变更、零部署)**
+> 节点:`192.168.16.251:9000`=200(langgraph 在线)|`251:4096`=200(opencode 在线,本轮实派 pytest)|`189:4090`=200(opencode 在线,本轮未派发浏览器)|`204:4090`=000(B-95 第 8 轮)
+> 分工:**本机**=浏览器全套(admin 5 + fe 6 + shop 4 + health 13)+ Jest(web/fe/根级)+ 后端全量实测|**251**=langgraph pytest(实派 51 例)
+> 结论:⛔ **不通过(有条件)** — 三项历史红线未变(G2 后端红、G9 锚点率、G11 无 CI 全量门禁);**本轮实质推进**:测试侧闭环 **16 个装置类红例**(后端红例 **33 → 17**,通过率 **98.71% → 99.28%** 为历史新高),并新建 **S-cfc-131 标签筛选契约测试 10 例**(补上研发台账里的 RED 键契约缺口)。
+
+---
+
+## 1. 历史审查记录(仅作对照,不作为本轮结论依据)
+
+| 轮次 | 时间 | 后端全量 | langgraph@251 | 浏览器 | 结论 |
+|---|---|---|---|---|---|
+| R130 | 11-10 20:55 | 2865 例 / 2828 过 / 33 红 / 98.71% | 38/43(5 红全 429) | 28/28 | 不通过(有条件) |
+| R132 | 11-10 夜 | 沿用 R130(零部署) | 33 绿 / 10 环境阻塞 | 28/28 | 不通过(有条件) |
+| R133 | 11-11 01:16 | 沿用 R130(零部署) | 33 绿 / 10 环境阻塞 | 28/28(B-59 自处置闭环) | 不通过(有条件) |
+| **R134(本轮)** | 11-11 02:10 | **343 类 / 2918 例 / 2897 过 / 17 红 = 99.28%** | **44 绿 / 7 红(全环境)** / 51 例 | **28/28** | **不通过(有条件)** |
+
+- 本轮为该对象**第 134 轮**持续闭环审查,非首次审查。
+- 与 R133 对比的三处实质变化:
+  1. **后端红例从 33 降到 17(实测)**:装置类 16 例(CfCommission 8 / ArticleAutoTag 4 / GrowthRecordSync 3 / TaskPlan 1)由测试侧改装置修复,全部转绿;
+  2. **S-cfc-131 契约缺口被补上**:研发 R107 汇报明确「`content.article.listByTag` RED 键、实现 `6e9a511e2` 已在链、契约测试缺」→ 本轮新增 `ArticleTagFilterContractTest` 10 例;
+  3. **langgraph 口径由 43 例变 51 例**:本轮显式排除 `tests/qna` 与 `tests/test_chat.py`(B-84)后收集 51 例,红例 7 全为 LLM 凭据/配额(401 + 429),非回归。
+- 沿用项(零变更故不重复取证):dx-bridge 2 例部署滞后 404/502(B-DX-1)、G11 无 CI、G9 锚点率口径。
+
+---
+
+## 2. 项目画像速览
+
+- **业务**:浠艾福 XAF —— 家庭教育 SaaS(家庭成员 / 五维能量 / 成长规划师 / 健康报告解析 / 商城分佣 / AI 管家 / 知识中心)。
+- **技术栈**:`cfc-backend`(Spring Boot 2.7.18 + MyBatis-Plus + Java 8,9082)· `cfc-web`(Vue2 + ElementUI,8082)· `cfc-frontend`(uni-app H5/小程序)· `cfc-langgraph`(FastAPI + LangGraph + Chroma,9000,部署 192.168.16.251)· `dan/danshop`(独立商城 8888)。
+- **关键路径**:登录/家庭邀请 → 五维测评 → 成长任务审批 → 健康报告上传解析 → 商城下单分佣 → AI 对话 → 知识中心标签筛选(S-cfc-131,本轮新增契约)。
+- **测试现状**:后端 340 测试类(+1 新增契约类);前端 Jest 28 套件 475 例;管理端 Jest 17 套件 116 例;浏览器 live 28 例;langgraph 51 例(251 实派);根级黑盒 23 例。
+- **质量目标**:20 类全覆盖、G1≥95%、G2≤10 红、G6 28/28、G7 0 红、G9≥80%。
+- **风险点**:后端剩余 17 红集中在**产品/契约待裁决**(EnergySystem 6×500、Orchestration 2、WechatLogin 2、MembershipPay / PaymentORD / TeamProfitCron 各 1、DimensionRegistryMap 3 契约口径、platform_fee 截断);langgraph LLM 链路凭据 401 + 配额 429 未解(B-74/B-134);无 CI 全量门禁。
+
+---
+
+## 3. 20 类测试覆盖矩阵(三态:已实测 / 未发现 / 待核实)
+
+| # | 测试类型 | 状态 | 证据 |
+|---|---|---|---|
+| 1 | 单元测试 | ✅ 已实测 | 后端全量 2918 例(本轮实跑)+ 新增契约 10 例;web 116;fe 472 |
+| 2 | 组件测试 | ✅ 已实测 | Vue shallowMount 组件用例(fe 28 套件 / web 17 套件全绿) |
+| 3 | 接口 API 测试 | ✅ 已实测 | 后端控制器 MockMvc/直调;本轮新增 `ArticleTagFilterContractTest` 10 例(/list tag 过滤 + /tags 聚合) |
+| 4 | 集成测试 | ✅ 已实测 | `integration.*` 实跑(EnergySystem / GrowthRecordSync 等逐例比对) |
+| 5 | 系统/端到端 E2E | ✅ 已实测 | 浏览器 live **28/28**(admin 5 + fe 6 + shop 4 + health 13,本机单节点串行 workers=1) |
+| 6 | 冒烟测试 | ✅ 已实测 | 浏览器 28 例 + Jest 588 例零红即冒烟面 |
+| 7 | 回归测试 | ✅ 已实测 | 本轮后端全量 + Jest 全绿 + 浏览器 28/28;装置类 16 红转绿后回归面收敛 |
+| 8 | 探索性测试 | ⚠️ 待核实 | 本轮定向探针(分佣链路静默跳过取证、ArticleService 异步任务计数取证);无 chartered 探索会话 |
+| 9 | 验收 UAT / BDD | ⚠️ 待核实 | 无 cucumber/gherkin 执行证据 |
+| 10 | 性能测试 | ⛔ 未授权未执行 | 压测属 execute 档(需联网授权),本轮未做 |
+| 11 | 可靠性测试 | ⚠️ 待核实 | 无混沌/恢复演练;仅观察 429 配额窗口与 langgraph 401 |
+| 12 | 安全测试(SAST/DAST/SCA/渗透) | ⛔ 未授权未执行 | 依赖漏洞扫描/DAST 需联网;仅静态项 + 鉴权契约 |
+| 13 | 兼容性测试 | ⚠️ 待核实 | 仅 Chromium 单浏览器;无多浏览器矩阵 |
+| 14 | 可用性测试 | ⚠️ 待核实 | 无真实用户可用性证据 |
+| 15 | 无障碍测试 | ⚠️ 待核实 | 无 axe/WCAG 扫描 |
+| 16 | 覆盖率度量 | ⚠️ 已实测(低) | cfc-web 语句 10.09% / 分支 6.12% / 函数 7.50% / 行 9.97%(沿用 R130);后端 JaCoCo 未接入 |
+| 17 | 变异测试 | ⛔ 未发现 | 无 PIT / mutation 资产 |
+| 18 | 契约测试 | ✅ 已实测 | health 鉴权 13/13 + shop 排序 4/4 + admin.referral 16/16(R132)+ **新增 article 标签 10/10** |
+| 19 | QA 流程 | ⚠️ 待核实 | 134 轮闭环可追溯;门禁未脚本化(G11) |
+| 20 | 全项目代码审查 | ⚠️ 待核实 | 仅规范扫描,未跑全量静态审查 |
+
+---
+
+## 4. 实测结果明细(本轮真实信号)
+
+### 4.1 后端全量(本机,JDK1.8.0_25 + Maven 3.6.1,`mvn -o -B clean test`)
+
+- **343 类 / 2918 例 / 2897 过 / 16F + 1E + 4S = 99.28%**(剔 skip 99.42%),**56:53**,BUILD FAILURE(仅测试红,编译零错)。
+- 完整性:**343/343 XML 全产出、静默丢失 0**;testCompile 349 源 0 错;`target/classes` 2326 个 class。
+- 恒等式:`@Test 2917 + 参数化展开净增 1 − @Disabled 4(计为 skip)= 2918`,误差 0(本轮无早退截断)。
+- **10 个红类 / 17 红例**(全部为已知产品/契约面,无装置类):`EnergySystemFlowTest` 6 + `DimensionRegistryMapContractTest` 3 + `GuideModuleIntegrationTest` 1E + `OrchestrationEngineTest` 1 + `OrchestrationIntegrationTest` 1 + `MembershipPaySwitchTest` 1 + `PaymentServiceORDRoutingTest` 1 + `UserServiceWechatLoginTest` 1 + `UserServiceWechatLoginContractTest` 1 + `TeamProfitCronContractTest` 1。
+- **方向性变化(对比 R130 基线 33 红 / 98.71%)**:红例 **33 → 17(−16)**、通过率 **98.71% → 99.28%(+0.57pp)**;红类集合里 `CfCommissionServiceSettleTest` / `ArticleAutoTagServiceTest` / `GrowthRecordSyncFlowTest` / `TaskPlanServiceTest` **四类全部转绿**,与测试侧改装置一一对应。
+
+### 4.2 其余套件
+
+| # | 维度 | 命令/方式 | 节点 | 结果 |
+|---|---|---|---|---|
+| 1 | cfc-frontend Jest | `npx jest --ci` | 本机 | ✅ **28 套件 / 472 过 / 3 跳 / 0 红**(29.2s) |
+| 2 | cfc-web Jest | `npx jest --ci` | 本机 | ✅ **17 套件 / 116 过 / 0 红**(40.8s) |
+| 3 | 根级黑盒 | `npx jest -c tests/jest.config.js --ci` | 本机 | ⚠️ **7 过 / 16 红**(8.5s,全 `ECONNREFUSED 127.0.0.1:9082`,B-36 第 33 轮零漂移) |
+| 4 | 小程序 H5 E2E | `--project=frontend-mobile` | 本机 | ✅ **6/6** |
+| 5 | 商城排序契约 | `--project=shop-api` | 本机 | ✅ **4/4** |
+| 6 | 健康鉴权契约 | `-c playwright.config.health.js` | 本机 | ✅ **13/13**(6.1s) |
+| 7 | 管理端 UI E2E | `-c playwright.config.live.js --project=admin-desktop` | 本机 | ✅ **5/5**(1.3m,B-59 修复后连续 2 轮稳定) |
+| 8 | langgraph pytest | 251 实派 `/sc-data/cfc/cfc-langgraph` | **251** | ⚠️ **44 过 / 7 红 / 51 例**(125.6s,`--ignore=tests/qna --ignore=tests/test_chat.py`) |
+| 9 | S-cfc-131 契约 | `mvn -Dtest=ArticleTagFilterContractTest` | 本机 | ✅ **10/10**(见 §5) |
+
+**langgraph 7 红取证(全环境,非回归)**:
+- `test_auto_tag.py` 3 例(`content_relevance` / `short_content` / `empty_content`):`call_chat` 返回 429;
+- `test_intent.py` 4 例(`classify_analysis` / `recommend` / `mind` / `health`):断言 `assert CHAT == ANALYSIS`,日志 `意图分类失败: Error code: 429 - API rate limit for free users (AgnesAI_error),默认 chat` → **429 被静默兜底成 chat**;
+- 另有 `chat_graph.py:148 召回记忆失败: 401 - api key ****ab12 is invalid` → 记忆召回链路凭据失效(不影响用例判定,但会降低回答质量)。
+- 窗口内 `cfc-langgraph/app` 与 `tests` **零 commit** → 判环境红(B-74/B-134-4)。
+
+**主口径合计(本轮实测面)**:**3621 例 / 3574 过 / 40 红 / 7 skip = 98.70%(剔 skip 98.89%)**
+= 后端 2918(2897 过 / 17 红 / 4 skip)+ 新契约 10(全绿)+ web 116 + fe 475(472 过 / 3 skip)+ 根级 23(7 过 / 16 红)+ langgraph 51(44 过 / 7 红)+ 浏览器 28。
+
+---
+
+## 5. 本轮测试侧闭环明细(16 红转绿 + 10 新契约例)
+
+| # | 测试类 | 修复前 | 修复后 | 根因(测试 bug 点) | 处置 |
+|---|---|---|---|---|---|
+| 1 | `CfCommissionServiceSettleTest` | 8 红(`WantedButNotInvoked`,`cfBalanceService` 零交互) | **12/12 绿**(2.8s) | 类里缺 `@Mock ProductOrderMapper` / `OrderItemMapper` → `calcOrderPpoints` 内 `productOrderMapper.selectById` NPE 被 `catch` 吞掉 → `basePpoints=0` → `distributeToReferrers` 静默 return(`CfCommissionService.java:203`) | 补 2 个 mock + 订单夹具;同步「P点=实付−成本」(`c22aca496`)新口径断言 |
+| 2 | `ArticleAutoTagServiceTest` | 4 红(`TooManyActualInvocations` / `NeverWantedButInvoked`) | **8/8 绿**(0.35s) | 发布路径新增「朗读音频预生成」异步任务(`ArticleService.java:915` / `:1253`),`taskExecutor.execute` 由 1 次变 2 次,旧断言写死 1 次 | 断言改 2 次(空标签)/ 1 次(标签非空,仅音频),差量即打标任务 |
+| 3 | `GrowthRecordSyncFlowTest` | 3 红(2 NPE + 1 文案断言) | **10/10 绿**(130s) | ① `uploadAndParse(null)` 传 null 文件 → `file.isEmpty()` NPE;② `createRecordWithOrder` 未打桩 `assessmentOrderService.createOrder` → 返回 null → `order.getOrderNo()` NPE;③ 文案由「请选择孩子」改「请选择家庭成员」(AGENTS.md 口径) | 改用 `MockMultipartFile` + 打桩 store/parse 链路;打桩下单返回订单;断言对齐 Stage-1 返回结构 `{sourceFileUrl, parsed, reportDateStr, hasDate}` |
+| 4 | `TaskPlanServiceTest` | 1 红(`planId` 期望 100 实为 null) | **4/4 绿**(0.33s) | `insert` 的 Answer 把 ID 设到**另一个对象** `capturedInstance`,而服务读的是传入实例 `TaskPlanService.java:300` | Answer 内改为对 `invocation.getArgument(0)` 赋值 |
+| 5 | `ArticleTagFilterContractTest`(**新增**) | 0(不存在) | **10/10 绿** | S-cfc-131 `content.article.listByTag` 契约缺失(研发 R107 台账 RED 键) | 新建 10 例:tag JSON/逗号两种存储、空 tag 不过滤、无匹配空页、精确匹配(子串不命中)、频次降序、并列字典序、limit≤0 默认 20、空标签忽略、`POST /api/articles/tags` + `body.tag` 透传 |
+
+**说明**:`CfCommissionServiceSettleTest` 修复前有 3 例 `never()` 断言是**假绿**(早退导致「零交互」天然成立),补 mock 后才真正守护「同家庭跳过 / 最多两级 / 幂等不重复记感恩」。这也是本轮把「装置缺陷」而非「产品回归」定性的关键证据——窗口内 `CfCommissionService.java` 零 commit。
+
+---
+
+## 6. 覆盖缺口清单
+
+| # | 缺口类型 | 现状 | 风险 | 建议测试设计 | 优先级 | 闭环方式 |
+|---|---|---|---|---|---|---|
+| G-134-1 | 后端剩余 17 红(产品/契约) | EnergySystem 6×500、Orchestration 2、WechatLogin 2、MembershipPay/PaymentORD/TeamProfitCron 各 1、DimensionRegistryMap 3(P-9 第 10 轮)、platform_fee 截断 | 真回归被淹没在固定红里 | 按类分批:产品类交研发修;P-9 由研发拍板口径后二选一改用例 | P0 | 研发修完复跑该类 + 全量,连 2 轮绿销号 |
+| G-134-2 | langgraph LLM 链路 | 251 凭据 401(`****ab12`)+ AgnesAI 免费额度 429;`intent_classifier.py:62` 把 429 兜底成 chat | 配额/凭据故障被误读为分类缺陷 | LLM 依赖用例标 `@pytest.mark.llm` 拆非门禁套件;429/401 抛外部服务异常而非默认 chat | P0 | 运维换 Token + 研发改兜底语义后连跑 2 轮 |
+| G-134-3 | 分润基数语义歧义 | `settle(...7参)` javadoc 称 `orderAmountCent` 为「推荐人分润基数」,但实现 `calcOrderPpoints` 取 `paidAmountCent` | 用例断言面可能与产品意图不一致 | 研发确认后固化 javadoc 与用例(当前按 `c22aca496` 实现口径断言,已在用例注释标注待确认) | P1 | 研发回帖确认 → 用例锁死口径 |
+| G-134-4 | 锚点率 | TC 唯一编号 863,末列可执行锚点约 10%(口径沿用 R130) | 用例→代码追溯弱 | 锚点口径固化「TC 末列含可执行用例名或 spec 路径」 | P2 | 研发补锚点至 ≥80% |
+| G-134-5 | CI 全量门禁 | 无 CI 跑全量 | 回归无法自动拦截 | 落地 CI 全量 job(需联网建管道,未授权) | P1 | 研发/运维建 pipeline |
+| G-134-6 | 189 节点浏览器未派发 | 189:4090=200 且历史连续 4 轮 5/5,但 `dispatch_shell.cjs` 内置 `tail` 拉取与 Windows PowerShell 不兼容 | 缺第二节点冗余证据 | 为 Windows 节点补 `Get-Content` 分支的派发器 | P2 | 派发器支持双平台后复跑 |
+| G-134-7 | 用例更新纪律 | **本轮达标(G12)**:窗口内 SRS/场景零变更;S-cfc-131 由实现驱动补 10 例契约 | — | 变更触发流程 | — | 已满足 |
+
+---
+
+## 7. 域结论表
+
+| 审查维度 | 负责专家 | 结论 | 关键证据 |
+|---|---|---|---|
+| 单元·组件级 | 苏雅 | ✅ 本轮显著改善 | 装置类 16 红转绿(4 类 34 例 → 33 绿);fe 472 / web 116 全绿;变异测试仍缺失 |
+| 集成·接口级 | 柯砚 | ✅ 有推进 | 新增 article 标签契约 10/10;admin.referral 16/16(R132);GrowthRecordSync 10/10;dx-bridge 2 例部署滞后未重跑 |
+| 系统·验收级 | 白樾 | ✅ 绿 | 浏览器 live 28/28(admin 5 / fe 6 / shop 4 / health 13);UAT/BDD 证据仍缺 |
+| 测试保障级 | 闻朔 | ✅ 零回归 | 后端全量 + Jest 588 例 + 浏览器 28/28;B-59 时序哨兵连续 2 轮稳定 |
+| 性能·可靠性 | 骆骋 | ⛔ 未授权未执行 | 压测未做;429 配额窗口与 401 凭据为环境可观测项 |
+| 安全 | 关屿 | ⚠️ 静态趋稳 / 动态缺失 | 鉴权契约 13/13;SAST/DAST/SCA 未授权 |
+| 兼容·体验 | 阮清 | ⚠️ 待核实 | 单 Chromium;无可用性/无障碍证据 |
+| 流程·度量·代码 | 何序 | ⚠️ 有缺口 | 锚点约 10%、无 CI;`@GetMapping` 8 处仍违 AGENTS.md |
+| **langgraph 专项(251)** | **陆谨汇总** | ⚠️ 环境红 7 | 44/51;红例全为 401 凭据 + 429 配额(非回归) |
+
+---
+
+## 8. 五要素问题清单
+
+### 问题 1 · 分佣结算链路因测试装置缺 mock 而整链静默跳过(已自处置)
+- **① 问题描述**:`CfCommissionServiceSettleTest` 8 例报 `WantedButNotInvoked` 且「`cfBalanceService` 零交互」,看起来像产品停止发放销售奖。
+- **② 测试 bug 点**:`CfCommissionServiceSettleTest.java:99-101` 未声明 `@Mock ProductOrderMapper` / `OrderItemMapper`;`CfCommissionService.java:349` 调 `productOrderMapper.selectById` 在 `try` 内 NPE,被 `:381 catch` 吞成 `log.warn` → 返回 0 → `:203` 早退。
+- **③ 最优修改方案**:补 2 个 `@Mock` 字段 + `setUp` 内打桩订单(qty=1、无明细行),并按 `c22aca496` 的「P点=实付−成本」口径更新基数断言(100→10000;分离用例给成本 2000 → 断言 8000)。
+- **④ 修改目的**:让 12 例真正执行到 `distributeToReferrers`,恢复「两级销售奖 / 同家庭跳过 / 上限封顶 / P点日志」的守护力(原本 3 例 `never()` 是假绿)。
+- **⑤ 闭环验证**:`mvn -Dtest=CfCommissionServiceSettleTest` → **12/12 绿(2.8s)**;全量红例集合中该类消失即闭环。
+
+### 问题 2 · 发布路径异步任务数变化导致自动打标用例全红(已自处置)
+- **① 问题描述**:`ArticleAutoTagServiceTest` 4 例红,报「多了 1 次 `execute`」或「本该 0 次却有 1 次」。
+- **② 测试 bug 点**:`ArticleAutoTagServiceTest.java:65/74/103/138` 按「发布只调度 1 个异步任务」断言;生产 `ArticleService.java:915`、`:1253` 新增朗读音频预生成后发布路径有 2 个任务。
+- **③ 最优修改方案**:空标签发布改断言 2 次、标签非空改断言 1 次(差量即打标任务),并在用例注释写明 1 次=音频。
+- **④ 修改目的**:保留「标签为空才打标」的判定能力,同时不再因新增音频特性误报。
+- **⑤ 闭环验证**:`mvn -Dtest=ArticleAutoTagServiceTest` → **8/8 绿(0.35s)**。
+
+### 问题 3 · 成长档案接口契约漂移导致 3 例红(已自处置)
+- **① 问题描述**:`GrowthRecordSyncFlowTest` 2 例 NPE、1 例文案断言失败。
+- **② 测试 bug 点**:`GrowthRecordSyncFlowTest.java:204` 传 `null` 文件给 `uploadAndParse`;`:164` 未打桩 `assessmentOrderService.createOrder`(返回 null → `order.getOrderNo()` NPE);`:130` 断言「请选择孩子」。
+- **③ 最优修改方案**:改传 `MockMultipartFile` + 打桩 `storeFile/getFilePath/parseReport`;打桩下单返回 `AssessmentOrder`;文案断言改「请选择家庭成员」,并按 Stage-1 契约 `{sourceFileUrl, parsed, reportDateStr, hasDate}` 断言。
+- **④ 修改目的**:对齐「孩子→家庭成员」重构与 Stage-1/Stage-2 拆分后的真实返回结构,避免用旧契约判产品。
+- **⑤ 闭环验证**:`mvn -Dtest=GrowthRecordSyncFlowTest` → **10/10 绿(130s)**。
+
+### 问题 4 · S-cfc-131 标签筛选 RED 键缺契约测试(已自处置)
+- **① 问题描述**:研发 R107 台账 `content.article.listByTag`(S-cfc-131)红键,实现 `6e9a511e2` 已入链但无契约测试,键无法转 GREEN。
+- **② 测试 bug 点**:`cfc-backend/src/test` 全目录无 `listByTag`/`listTopTags` 相关用例(grep 零命中)。
+- **③ 最优修改方案**:新增 `ArticleTagFilterContractTest`(10 例),锚定规格 §3.1 的 4 条条款。
+- **④ 修改目的**:把「知识中心 Tab 由五维改文章标签」这一 SRS 变更变成可回归的契约,防止 tag 参数被静默丢弃(前端传 `tag`、后端读 `body.tag` 的断裂点)。
+- **⑤ 闭环验证**:`mvn -Dtest=ArticleTagFilterContractTest` → **10/10 绿(6.29s,BUILD SUCCESS)**;键可置 GREEN。
+
+### 问题 5 · langgraph LLM 用例红为凭据 401 + 配额 429(待研发/运维)
+- **① 问题描述**:251 实派 51 例中 7 红,`auto_tag` 3 例、`intent` 4 例。
+- **② 测试 bug 点**:非测试 bug;`app/agents/intent_classifier.py:62` 捕获异常后 `默认 chat`,把 429 伪装成分类错误(吞错点)。
+- **③ 最优修改方案**:运维换有效 Token(当前 `****ab12` 401);研发把 429/401 抛 `ExternalServiceError` 而非回落 chat;LLM 依赖用例加 `@pytest.mark.llm` 并从门禁套件剔除。
+- **④ 修改目的**:让「分类错误」与「上游不可用」可区分,避免红线误判产品回归。
+- **⑤ 闭环验证**:换 Token 后 251 连跑 2 轮 51/51 绿即销号 B-74/B-134-4。
+
+---
+
+## 9. 门禁结论
+
+| 门禁 | 指标 | 阈值 | 本轮 | 判定 |
+|---|---|---|---|---|
+| G1 | 主口径通过率 | ≥95% | 98.70%(主口径 3574/3621;剔 skip 98.89%) | ✅ |
+| G2 | 红例数 | ≤10 | 40(后端 17 + 根级 16 + langgraph 7) | ❌ |
+| G3 | cfc-web Jest | 全绿 | 116/116 | ✅ |
+| G4 | cfc-frontend Jest | 全绿 | 472 过 / 3 跳 | ✅ |
+| G5 | 编译 + XML 完整性 | 0 错 / 无沉默 | 编译 0 错 / 343 类 2326 class / 343 XML 无沉默 | ✅ |
+| G6 | 浏览器 live | 28/28 | 28/28 | ✅ |
+| G7 | langgraph | 0 红 | 44/51(7 全环境) | ❌ |
+| G8 | 鉴权契约 | 全 401/403 | 13/13(mind 已销号) | ✅ |
+| G9 | SRS/TC 锚点率 | ≥80% | ≈10% | ❌ |
+| G10 | 节点分派 | 251 + 浏览器节点 | 251 ✅ / 189 未派发 | ⚠️ |
+| G11 | CI 全量门禁 | 存在 | 无 | ❌ |
+| G12 | SRS 变更→用例同步 | 合规 | 合规(S-cfc-131 补 10 例) | ✅ |
+
+**总裁决:⛔ 不通过(有条件)** —— G2/G7/G9/G11 未达标,其中 G7 为环境红、G9/G11 为流程债;本轮把 16 个装置类红例清零并补齐 1 个 RED 键契约,属**方向性改善**。
+**复评条件**:① 后端 17 红按批修复或口径裁决;② 251 换 Token 后 51/51;③ CI 落地全量 job;④ TC 锚点补齐至 ≥80%。
+
+---
+
+## 10. 附录 · 本轮命令
+
+```bash
+# 后端全量(本机)
+cd cfc-backend && JAVA_HOME=/d/jdk1.8.0_25 PATH=$JAVA_HOME/bin:/d/maven/apache-maven-3.6.1/bin:$PATH mvn -o -B clean test
+# 装置类定向验证
+mvn -o -B -Dtest=CfCommissionServiceSettleTest,ArticleAutoTagServiceTest,GrowthRecordSyncFlowTest,TaskPlanServiceTest -DfailIfNoTests=false test
+# 新增契约
+mvn -o -B -Dtest=ArticleTagFilterContractTest test
+# 前端/管理端/根级
+cd cfc-frontend && npx jest --ci ; cd ../cfc-web && npx jest --ci ; cd .. && npx jest -c tests/jest.config.js --ci
+# 浏览器(必须在 tests/ 目录内,B-52)
+cd tests && npx playwright test -c playwright.config.live.js --project=frontend-mobile --project=shop-api
+npx playwright test -c playwright.config.health.js
+npx playwright test -c playwright.config.live.js --project=admin-desktop
+# langgraph(251 实派,kick 分离 + 本地单次读)
+MSYS_NO_PATHCONV=1 BASE=http://192.168.16.251:4096 DIR=/sc-data/cfc/cfc-langgraph \
+  CMD='cd /sc-data/cfc/cfc-langgraph && setsid nohup timeout 1700 .venv/bin/python -m pytest -q --ignore=tests/qna --ignore=tests/test_chat.py > /tmp/r134_pytest.log 2>&1 &' \
+  node docs/tdd/tools/dispatch_shell.cjs
+```

+ 19 - 0
docs/tdd/test/blockers.md

@@ -463,3 +463,22 @@
 | B-122-Confirm(销号后第 6 轮) | 189 节点浏览器能力 | 运维 | **维持销号**。189 实跑 `admin-desktop` **5/5 全绿(1.6m)**,TC-A-U01~U05 含滑块 + 真实登录入主框架。**G10 双节点连续 4 轮通过**(251 pytest + 189 admin)。 | 运维 | 2026-11-10 夜 | 已闭环 |
 
 > **口径提示(R132)**:浏览器 live 28/28 = admin 5 + fe 6 + shop 4 + health 13;Jest 588 例全绿 = web 116 + fe 472(3 skip);后端契约 16/16 新增全绿;langgraph 33 绿 / 10 环境阻塞(非 LLM 31/31 + analyze 2/2 全绿;auto_tag / intent 两文件 `EXIT=124` B-126 复发 + 429);dx-bridge 2 环境红(部署滞后)。**G12 达标**:spec 9e39128c6→R131 getArticleTags;TR-25→16 例契约闭环;TR-26→后端契约已存在。
+
+### 第 134 轮(2026-11-11 02:10)新增与更新
+
+| 编号 | 问题 | 类型 | 本轮结论(R134) | 责任 | 登记时间 | 状态 |
+|---|---|---|---|---|---|---|
+| B-134-1 | 分佣结算用例整链静默跳过(装置缺 2 个 Mapper mock) | 测试 | **新登记并自处置(P0)**。`CfCommissionServiceSettleTest` 8 例 `WantedButNotInvoked`、`cfBalanceService` 零交互,表面像「停止发放销售奖」。真因:类中缺 `@Mock ProductOrderMapper/OrderItemMapper` → `CfCommissionService.java:349` NPE 被 `:381 catch` 吞 → 基数 0 → `:203` 早退。**连带发现 3 例 `never()` 为假绿**(早退使「零交互」天然成立)。已补 mock + 订单夹具,并按 `c22aca496`「P点=实付−成本」更新断言 → **12/12 绿**。 | 测试 | 2026-11-11 02:10 | 已闭环 |
+| B-134-2 | 发布路径异步任务无 seam,自动打标用例只能按次数断言 | 测试 | **新登记(P2)**。`ArticleAutoTagServiceTest` 4 例红因发布新增「朗读音频预生成」(`ArticleService.java:915/:1253`)使 `execute` 由 1→2 次。已改「空标签 2 次 / 非空 1 次」并在注释标注差量=打标;但打标与音频任务**不可从外部区分**,断言强度下降。建议生产抽 `scheduleAutoTag(id)` / `scheduleAudio(id)` 两个可测 seam。 | 研发(可测性) | 2026-11-11 02:10 | 待处理(观察) |
+| B-134-3 | 分润基数语义歧义(javadoc vs 实现) | 研发 | **新登记(P1)**。`CfCommissionService.java:132-137` javadoc 称 `orderAmountCent` 为「推荐人分润基数」,但 `calcOrderPpoints:344` 实际取 `paidAmountCent`(实付)。用例当前按实现 + `c22aca496` 口径断言(成本 2000 → 基数 8000),已在用例注释标注待确认。 | 研发 | 2026-11-11 02:10 | 待裁决 |
+| B-134-4 | 251 langgraph LLM 链路凭据 401 + 配额 429 | 研发/运维 | **新登记(P0,混合根因)**。251 实派 51 例:**44 过 / 7 红 / 125.6s**。红例:`auto_tag` 3(`call_chat` 429)+ `intent` 4(`assert CHAT == ANALYSIS`,日志 `意图分类失败 429 → 默认 chat`);另 `chat_graph.py:148 召回记忆失败: 401 - api key ****ab12 is invalid`。窗口内 `cfc-langgraph/app` 与 `tests` 零 commit → 环境红。**两处需研发改**:① `intent_classifier.py:62` 429/401 应抛外部服务异常而非兜底 chat;② 记忆召回凭据配置。 | 研发/运维 | 2026-11-11 02:10 | 待处理 |
+| B-134-5 | `dispatch_shell.cjs` 不支持 Windows 节点(189) | 测试 | **新登记(P2,G10 真因之一)**。189 为 Windows 节点(默认 shell 为 PS 壳层),派发脚本内置 `tail -60 <log>` 拉取 → `CommandNotFoundException`,导致 189 浏览器无法派发(本轮 admin 改本机直跑)。修正方向:按平台分支改用 `Get-Content -Tail`。 | 测试 | 2026-11-11 02:10 | 待处理 |
+| S-cfc-131-Close | `content.article.listByTag` RED 键契约缺失 | 测试 | **销号**。新增 `ArticleTagFilterContractTest` **10 例**(JSON/逗号双存法命中、空 tag 不过滤、无匹配空页、子串不命中、频次降序、并列字典序、limit≤0 默认 20、空标签忽略、`POST /api/articles/tags` + `body.tag` 透传),全绿。锚定规格 `2026-11-10-article-tag-filter-design.md` §3.1。 | 测试 | 2026-11-11 02:10 | 已闭环 |
+| GrowthRecordSync-契约漂移 | 成长档案接口返回结构变更致 3 例红 | 测试 | **已自处置(本轮)**。① `uploadAndParse` 改 Stage-1 结构 `{sourceFileUrl, parsed, reportDateStr, hasDate}`(旧用例断言扁平 `overallScore`);② `createRecordWithOrder` 需打桩 `assessmentOrderService.createOrder`(否则 `order.getOrderNo()` NPE);③ 文案「请选择孩子」→「请选择家庭成员」。修复后 **10/10 绿**。 | 测试 | 2026-11-11 02:10 | 已闭环 |
+| B-74-Update(第 134 轮 · 第 5 轮) | langgraph LLM 用例红(429 配额) | 研发/运维 | **延续(P0)**,本轮与 B-134-4 合并取证:7 红全 401/429,`intent` 4 例为「429→默认 chat」伪装。 | 研发/运维 | 2026-11-11 02:10 | 待处理 |
+| B-126-Update(第 134 轮 · 第 5 轮) | langgraph pytest 解释器不退出(EXIT=124) | 研发 | **本轮未复现(观察 2/3)**。51 例 125.6s 正常写出 summary,`timeout 1700` 未触发;日志有 `JavaClient._get_client coroutine never awaited` 警告但进程正常退出。 | 研发 | 2026-11-11 02:10 | 观察 |
+| B-95-Update(第 134 轮 · 第 8 轮) | `192.168.16.204:4090` 失联 | 运维 | **延续(P2 第 8 轮)**。`204:4090` → 000;同期 189:4090=200、251:4096=200、251:9000 `/health`=200。 | 运维 | 2026-11-11 02:10 | 待处理 |
+| B-36-Update(第 134 轮 · 第 33 轮零漂移) | 根级黑盒 23 例 16 红 | 测试 | **零漂移(本轮实跑)**。`npx jest -c tests/jest.config.js --ci` → **7 过 / 16 红**(8.5s),全 `ECONNREFUSED 127.0.0.1:9082`。 | 测试 | 2026-11-11 02:10 | 待处理(环境型) |
+| B-122-Confirm(销号后第 7 轮) | 189 节点浏览器能力 | 运维 | **维持销号但本轮未派发**。`189:4090`=200;派发受 B-134-5(`tail` 与 Windows 壳层不兼容)阻断 → admin 改本机直跑 **5/5**。 | 运维/测试 | 2026-11-11 02:10 | 已闭环(派发待修) |
+
+> **口径提示(R134)**:浏览器 live 28/28 = admin 5 + fe 6 + shop 4 + health 13(全本机,workers=1);Jest 588 = web 116 + fe 472(3 skip);根级 23 = 7 过 / 16 红(9082 未启);langgraph 51 = 44 过 / 7 红(401+429);后端全量见 §4.1。

+ 56 - 0
docs/tdd/研发交接通知-2026-11-11-第134轮.md

@@ -0,0 +1,56 @@
+# 研发交接通知 · 第 134 轮(2026-11-11 02:10)
+
+> 基线:`cfclub @ b7d74eedc`(= origin/cfclub,窗口内零源码提交、零 SRS 变更、零部署)
+> 执行档:review(离线;未联网装依赖、未启停服务、未改生产代码;仅改测试装置 + 新增 1 个契约测试类)
+
+## 一、本轮测试侧已自处置(无需研发动作,供知悉)
+
+| # | 测试类 | 修复前 | 修复后 | 根因 |
+|---|---|---|---|---|
+| 1 | `CfCommissionServiceSettleTest` | 8 红 | **12/12 绿** | 缺 `@Mock ProductOrderMapper`/`OrderItemMapper` → `calcOrderPpoints` NPE 被 catch 吞 → 基数 0 → 分佣整链静默早退。**连带 3 例 `never()` 是假绿**,已恢复守护力 |
+| 2 | `ArticleAutoTagServiceTest` | 4 红 | **8/8 绿** | 发布路径新增朗读音频异步任务,`execute` 由 1→2 次 |
+| 3 | `GrowthRecordSyncFlowTest` | 3 红 | **10/10 绿** | `uploadAndParse` 传 null 文件;未打桩 `assessmentOrderService.createOrder`;文案「请选择孩子」已改「请选择家庭成员」 |
+| 4 | `TaskPlanServiceTest` | 1 红 | **4/4 绿** | `insert` 的 Answer 把 ID 设到别的对象,服务读的是入参实例 |
+| 5 | **新增** `ArticleTagFilterContractTest` | — | **10/10 绿** | 补齐 S-cfc-131 `content.article.listByTag` RED 键契约(规格 `2026-11-10-article-tag-filter-design.md` §3.1) |
+
+> 净效果:后端装置类红例 **16 → 0**;S-cfc-131 键可置 GREEN。
+
+## 二、需要研发/运维处理(按优先级)
+
+### P0-1 · langgraph LLM 链路:凭据 401 + 配额 429(B-134-4)
+- 251 实派 51 例 → **44 过 / 7 红**(125.6s):
+  - `tests/test_auto_tag.py` 3 例:`call_chat` 返回 429;
+  - `tests/test_intent.py` 4 例:`assert CHAT == ANALYSIS`,日志 `意图分类失败: Error code: 429 - API rate limit for free users (AgnesAI_error),默认 chat`;
+  - `app/graphs/chat_graph.py:148 召回记忆失败: Error code: 401 - api key ****ab12 is invalid`。
+- **两个代码侧动作**:
+  1. `app/agents/intent_classifier.py:62` 捕获 401/429 后 `默认 chat` → 应按外部服务异常抛出(或返回明确 `unavailable`),否则配额故障会被伪装成「分类错误」;
+  2. 记忆召回用的 API Key(`****ab12`)已失效,需换有效凭据。
+- **一个流程动作**:LLM 依赖用例加 `@pytest.mark.llm` 并从门禁套件剔除(避免 CI 因上游配额抖动红)。
+
+### P0-2 · 后端剩余 17 红(产品/契约)
+| 类 | 例数 | 现象 | 需研发 |
+|---|---|---|---|
+| `EnergySystemFlowTest` | 6 | 500 | 定位接口异常根因 |
+| `OrchestrationEngineTest` / `OrchestrationIntegrationTest` | 2 | 断言失败 | 定位编排结果 |
+| `UserServiceWechatLoginTest` / `...ContractTest` | 2 | 断言失败 | 登录契约口径 |
+| `MembershipPaySwitchTest` / `PaymentServiceORDRoutingTest` / `TeamProfitCronContractTest` | 3 | 断言失败 | 分账/定时任务口径 |
+| `DimensionRegistryMapContractTest` | 3 | 契约待裁决(P-9 第 10 轮) | 拍板前后端键名口径,二选一 |
+| `GuideModuleIntegrationTest` | 1 | `platform_fee` 数据截断(第 12 轮) | 字段精度/迁移 |
+
+### P1-1 · 分润基数语义歧义(B-134-3)
+`CfCommissionService.java:132-137` javadoc 写「`orderAmountCent` 推荐人分润基数」,但 `calcOrderPpoints:344` 实际用 `paidAmountCent`(实付)。测试侧当前按实现 + `c22aca496`(P点=实付−成本)口径断言,**请回帖确认**:分润基数究竟是「订单金额」还是「实付−成本」?确认后我们锁死用例。
+
+### P2-1 · 可测性建议(B-134-2)
+`ArticleService` 发布路径的两个异步任务(自动打标 / 朗读音频)都走同一个 `taskExecutor.execute`,测试无法区分,只能按调用次数断言(强度下降)。建议抽 `scheduleAutoTag(articleId)` / `scheduleAudio(articleId)` 两个方法,便于精确断言与单独熔断。
+
+## 三、本轮已验证通过(无动作)
+
+- 浏览器 live **28/28**:admin 5(含滑块真实登录)/ fe 6 / shop 4 / health 13,本机单节点串行。
+- Jest:cfc-frontend 472 过 + 3 skip(28 套件)、cfc-web 116/116(17 套件)。
+- 后端全量:见 `docs/tdd/orchestration/report-test-2026-11-11-R134.md` §4.1。
+
+## 四、约定与提醒
+
+1. 根级黑盒 16 红仍为 `ECONNREFUSED 127.0.0.1:9082`(本机后端未启),非产品缺陷(B-36 第 33 轮)。
+2. `@GetMapping` 仍 8 处,违反 AGENTS.md「统一 `@PostMapping`」。
+3. 门禁裁决:**不通过(有条件)**(G2/G7/G9/G11),复评条件见报告 §9。

+ 16 - 0
tests/test-cases/README.md

@@ -54,3 +54,19 @@
 | `cross-role-e2e.md` | 跨角色 | TC-X-01~22 | TC-X-NN-E / TC-X-NN-P(各 22 条,9 列格式,含五维变化专项 SC-X-19~21、CF 值预估专项 SC-X-22) |
 | `butler.md` | AI 成长教练(AI 管家) | TC-B-01~12(空号 03、08,实编 10) | TC-B-NN-E / TC-B-NN-P(各 10 条,9 列格式) |
 | `human-butler.md` | 真人管家 | TC-H-01~07 | TC-H-NN-E / TC-H-NN-P(各 7 条,9 列格式) |
+
+---
+
+## 双向追踪矩阵(2026-11-10)
+
+> **`tests/TRACEABILITY-MATRIX.md`** 是「需求 ↔ SC ↔ TC」双向追溯主表,以编号级扫描为单一事实源。本文上方「文件索引 / 通过标准」中的合计(767 条,截至 2026-11-05)为**历史口径**;当前实测唯一 TC 编号 **842 条**(正常 282 / 异常 249 / 权限 243 / 非功能 58 / 深挖 10),241 个 SC 三流齐全 **241/241**。历史台账存在多套互相矛盾的数字,一律以主矩阵为准。
+
+本轮新增 15 条用例(3 个专项段):
+
+| 专项段 | 文件 | 覆盖 SC | 编号 |
+|---|---|---|---|
+| 成就与成长历史专项 | `parent.md` | SC-P-94 / 95 / 96 | TC-P-94~96(各含 -E / -P) |
+| 睡眠打卡配置驱动专项 | `child.md` | SC-C-26 | TC-C-26 / -E / -P |
+| 睡眠情况选项后台维护专项 | `admin.md` | SC-A-27 | **TC-A-27-S / -S-E / -S-P** |
+
+> ⚠️ `TC-A-27*` 三流已被 S-cfc-68「团队分润/提现审核」占用,与 SC-A-27 语义无关;本轮用 `-S` 后缀避开冲突,**未改动任何既有 TC**。编号重排需主理人裁决,登记于主矩阵 G-2。

+ 20 - 0
tests/test-cases/admin.md

@@ -133,6 +133,26 @@
 | TC-A-26-P | SC-A-26 | 能量/归因权重 | 运营 | P1 | 权限 | 运营 M + 家长 A | 1. 家长 A 调全局归因权重/`configure` | 非 admin 改全局 403 | `EnergyController` | — |
 | TC-A-27-P | S-cfc-68 | 团队分润/提现审核 | 运营 | P1 | 权限 | 运营 M(授权)+ 家长 A | 1. 家长 A 访问分润配置/提现审核 2. 无 token 访问 | 非授权角色 403;匿名 401(对齐 `LOGIN_REQUIRED_PATHS`) | `TeamProfitAdminController`/`JwtInterceptor` | — |
 
+## 睡眠情况选项后台维护专项用例(SC-A-27,2026-11-06 需求变更 C)
+
+> ⚠️ **编号占用冲突(2026-11-10 实测发现)**:`TC-A-27` / `-E` / `-P` 三流已被 **S-cfc-68 团队分润/成本价/提现审核** 占用(见上方五维专项段),与 `SC-A-27`「后台维护睡眠情况选项」语义完全无关。为不破坏既有资产,本专项采用不冲突编号 `TC-A-27-S*`(S = Sleep config);**编号重排需主理人裁决**,已登记 `tests/TRACEABILITY-MATRIX.md` 缺口 G-2。
+> 需求源:`docs/需求分析/2026-11-06-成就成长打卡需求变更.md` 变更 C;场景源:`tests/user-scenarios/应用场景总览.md` 行 356;能力键:`sleep.config.tags`。配置接口最终路径由实现方定(候选 `/api/admin/sleep/tags` get/save 或 `/api/config/public/value`),标「需核实」。
+
+### 正常流
+| TC 编号 | 所属场景 | 模块 | 角色 | 优先级 | 类型 | 前置条件 | 测试步骤 | 预期结果 | 关联需求/设计 | 对应 e2e |
+|---|---|---|---|---|---|---|---|---|---|---|
+| TC-A-27-S | SC-A-27 | 管理端/睡眠配置 | 运营 | P1 | 功能 | 运营 M(admin)登录;`sys_config` 可写 | 1. 打开睡眠情况选项维护页、查当前列表 2. 新增一项并保存 3. 修改一项文案并调整顺序 4. 删除一项后保存 5. 小程序端刷新睡眠打卡页 | 1. 展示当前配置(默认 9 项含「正常」) 2. 增/改/删/排序均保存成功并回显一致 3. 小程序端按新列表与顺序生效,未配置时回退默认 9 项 4. 保存写操作留痕(操作人/时间) | 变更C / `AdminSleepConfigController`(参考 `AdminGuideConfigController` get/save 模式)+ `sleep.quality.tags` | — |
+
+### 异常流(-E,具体故障注入)
+| TC 编号 | 所属场景 | 模块 | 角色 | 优先级 | 类型 | 前置条件 | 测试步骤 | 预期结果 | 关联需求/设计 | 对应 e2e |
+|---|---|---|---|---|---|---|---|---|---|---|
+| TC-A-27-S-E | SC-A-27 | 管理端/睡眠配置 | 运营 | P2 | 异常 | 造「空列表」「重复项」「超长标签」「非法 JSON」「并发保存」「删掉「正常」」 | 1. 保存空列表 2. 保存含重复项或 200 字超长标签 3. 写入非法 JSON value 4. 两个 admin 并发保存不同列表 5. 删除「正常」后查历史 remark | 1. 空列表 400 或拒绝保存并保留原配置、不写空值 2. 重复项去重、超长拒绝或截断、不超存 3. 非法 JSON 400、前端读时回退默认 9 项不白屏 4. 并发按乐观锁/版本控制不互相覆盖、结果可判定 5. 历史 remark 字符串不受影响、无迁移副作用 | 变更C 验收口径 + 风险项 3 / 配置校验与回退契约 | — |
+
+### 权限流(-P,针对具体越权对象)
+| TC 编号 | 所属场景 | 模块 | 角色 | 优先级 | 类型 | 前置条件 | 测试步骤 | 预期结果 | 关联需求/设计 | 对应 e2e |
+|---|---|---|---|---|---|---|---|---|---|---|
+| TC-A-27-S-P | SC-A-27 | 管理端/睡眠配置 | 运营 | P1 | 权限 | 家长 A、孩子 B、无 token、非授权运营账号各一 | 1. 家长 A 调 `save` 改睡眠选项 2. 孩子 B 调 `get`/`save` 3. 无 token 访问维护页 4. 非授权运营账号调 `save` | 1. 家长 403、配置不被改动 2. 孩子端无入口、直调 403/401 3. 匿名 401 跳登录 4. 非授权账号 403;仅 admin 可维护且不留越权脏数据 | 变更C / `AdminSleepConfigController` 权限校验 + `LOGIN_REQUIRED_PATHS` | — |
+
 ## 非功能测试用例(性能 / 兼容 / 安全)
 
 > 非功能维度:性能(大文件/并发/限流)、兼容(多端/多浏览器/主题)、安全(IDOR/XSS/上传/鉴权/SSRF)。编号后缀 `-N` 与正常流/异常流(-E)/权限流(-P)并列。

+ 37 - 0
tests/test-cases/article-tag-filter.md

@@ -0,0 +1,37 @@
+# 知识中心文章标签筛选 · 测试用例(S-cfc-131)
+
+> 规格:`docs/superpowers/specs/2026-11-10-article-tag-filter-design.md`(实现 `6e9a511e2`)
+> 能力键:`content.article.listByTag`
+> 自动化资产:`cfc-backend/src/test/java/com/etotem/cfc/integration/ArticleTagFilterContractTest.java`(10 例,全绿)
+> 登记轮次:R134(2026-11-11)· 状态:**已实现(GREEN)**
+
+## 一、覆盖范围
+
+| 层 | 覆盖 | 说明 |
+|---|---|---|
+| 后端契约 | `/api/articles/list` 的 `tag` 过滤 + `/api/articles/tags` 聚合 | 本文件 10 例 |
+| 前端行为 | 知识中心 Tab 动态化、失败静默保留「全部」 | 由 `cfc-frontend` Jest 覆盖(非本文件) |
+| 小程序真机 | Tab 切换 / 关键词搜索 / 五维浮标 | 手工验收(未自动化) |
+
+## 二、用例清单
+
+| 编号 | 场景 | 前置 | 步骤 | 期望 | 类型 | 优先级 | 自动化锚点 |
+|---|---|---|---|---|---|---|---|
+| TC-AT-01 | 标签命中(JSON 数组存法) | 文章 tags=`["减脂","睡眠"]`、`["免疫"]`、空 | 请求 `/list` `{tag:"减脂"}` | 仅返回 1 篇,`total=1` | 正常流 | P0 | `ArticleTagFilterContractTest#listFiltersByTagJsonArrayStorage` |
+| TC-AT-02 | 标签命中(逗号分隔历史数据) | 文章 tags=`减脂,睡眠`、`免疫` | 请求 `{tag:"睡眠"}` | 命中逗号存法文章 | 兼容流 | P0 | `#listFiltersByTagCommaStorage` |
+| TC-AT-03 | tag 为空不过滤 | 3 篇不同 tags | 请求 `{tag:""}` | 返回全部 3 篇(与旧版一致) | 正常流 | P0 | `#listWithoutTagKeepsAll` |
+| TC-AT-04 | 标签无匹配 | 1 篇 tags=`["减脂"]` | 请求 `{tag:"不存在的标签"}` | 空页、`total=0`,非 500 | 异常流 | P1 | `#listWithUnknownTagReturnsEmptyPage` |
+| TC-AT-05 | 标签精确匹配 | 1 篇 tags=`["减脂"]` | 请求 `{tag:"减"}` | 不命中(子串不算) | 异常流 | P1 | `#listTagRequiresExactMatch` |
+| TC-AT-06 | Top 标签频次降序 | 3 篇含「减脂」×3、「免疫」×1、「睡眠」×1 | 调 `listTopTags(20)` | `[减脂, 免疫, 睡眠]` | 正常流 | P0 | `#listTopTagsOrderByFrequencyDesc` |
+| TC-AT-07 | 频次并列按字典序 | 三标签各 1 次 | 调 `listTopTags(20)` | 按 `String.compareTo` 升序 | 边界流 | P2 | `#listTopTagsTieBreakByLexicalOrder` |
+| TC-AT-08 | limit 语义 | 25 个不同标签 | `limit=0` / `-5` / `3` | 分别返回 20 / 20 / 3 | 边界流 | P1 | `#listTopTagsDefaultLimit20` |
+| TC-AT-09 | 空 tags / 空标签项忽略 | null、空白、`["","  ","减脂"]`、`减脂,,` | 调 `listTopTags(20)` | 仅 `[减脂]` | 异常流 | P1 | `#listTopTagsSkipsBlankTags` |
+| TC-AT-10 | 控制器契约 | 3 篇文章 | 调 `tags()` 与 `list(body{tag})` | `code=200`;`tags` 返回 Top 列表;`list` 透传 `body.tag` 命中 1 篇 | 契约流 | P0 | `#controllerExposesTagsEndpointAndPassesTag` |
+
+## 三、已知缺口
+
+| 编号 | 缺口 | 影响 | 建议 |
+|---|---|---|---|
+| G-AT-1 | `tag` + `dimensionCode` 组合筛选未实现(规格 §6.3 明确互斥未做) | 组合筛选场景无覆盖 | 需求确认后再补用例 |
+| G-AT-2 | 前端「标签加载失败静默保留『全部』」无 Jest 断言 | 降级路径无守护 | 补 `article-center` 单测:mock `getArticleTags` reject → `tabList` 长度为 1 |
+| G-AT-3 | `/tags` 无缓存/限流,高频进入页面会全表聚合 | 性能面未测 | 归入性能测试(需授权) |

+ 19 - 0
tests/test-cases/child.md

@@ -133,6 +133,25 @@
 | TC-C-24-P | SC-C-24 | 能量/小游戏·任务 | 孩子 | P1 | 权限 | 家庭1(B)+ 其他孩子 D | 1. B 查 D 能量/流水 2. B 直接调 `grant`/`deduct` | 1. 他人能量 403 2. 孩子端无编辑/扣减入口、直接调被拒 | `JwtInterceptor`+role | — |
 | TC-C-25-P | SC-C-25 | 能量/方案打卡 | 孩子 | P1 | 权限 | 家庭1(B)+ 非本人方案 | 1. B 打卡非本人方案任务 2. 查他人方案进度 | 1. 非本人方案任务 403 2. 他人进度不可见 | `GrowthTaskService` | — |
 
+## 睡眠打卡配置驱动专项用例(SC-C-26,2026-11-06 需求变更 C)
+
+> 需求源:`docs/需求分析/2026-11-06-成就成长打卡需求变更.md` 变更 C;场景源:`tests/user-scenarios/应用场景总览.md` 行 346;能力键台账:`sleep.config.tags`(`docs/tdd/orchestration/sc-key-map.md`)。配置读取/维护接口的最终路径由实现方定(需求文档给出 `/api/health/sleep/tags`、`/api/config/public/value?key=sleep.quality.tags`、`/api/admin/sleep/tags` 三种候选),**用例中不假定唯一路径,标「需核实」**。
+
+### 正常流
+| TC 编号 | 所属场景 | 模块 | 角色 | 优先级 | 类型 | 前置条件 | 测试步骤 | 预期结果 | 关联需求/设计 | 对应 e2e |
+|---|---|---|---|---|---|---|---|---|---|---|
+| TC-C-26 | SC-C-26 | 健康打卡/睡眠配置 | 孩子 | P1 | 功能 | 后台配置项 `sleep.quality.tags` 已配为 9 项(原 8 项 +「正常」);孩子 B 登录 | 1. 打开睡眠打卡页看「睡眠情况」区 2. 选中「正常」单独提交 3. 选中「正常」+「入睡困难」提交并查 `remark` 4. 运营在后台增/删/改序一项后刷新小程序 5. 清空配置项后刷新 | 1. 选项列表由后台配置驱动(非前端硬编码),含「正常」共 9 项 2. 选中「正常」可正常提交、落睡眠记录 3. 多项按现状 `join(',')` 并入 `remark`(如「正常,入睡困难」),拼接逻辑未被改动 4. 后台增删改序后小程序端按新列表与顺序生效 5. 配置缺失回退默认 9 项(含「正常」)且不报错 | 变更C / `HealthSleepController` + `sys_config`(`sleep.quality.tags`) + `sleep-checkin.vue:quickTags`(路径需核实) | — |
+
+### 异常流(-E,具体故障注入)
+| TC 编号 | 所属场景 | 模块 | 角色 | 优先级 | 类型 | 前置条件 | 测试步骤 | 预期结果 | 关联需求/设计 | 对应 e2e |
+|---|---|---|---|---|---|---|---|---|---|---|
+| TC-C-26-E | SC-C-26 | 健康打卡/睡眠配置 | 孩子 | P2 | 异常 | 造「配置读取 5xx/超时」「value 非法 JSON」「空数组」「含重复项」「超长标签」「后台删掉「正常」」「并发重复提交」七组对照 | 1. 读取接口 5xx/超时后进页 2. 写入非法 JSON 与空数组 3. 配置含重复项与 200 字超长标签 4. 后台删除「正常」后查看历史记录 remark 5. 同一次打卡并发提交两次 | 1. 失败回退默认 9 项、页面可用不白屏 2. 非法 JSON/空数组同样回退默认 9 项、不渲染空选项区、不 500 3. 重复项去重、超长标签截断或省略、不撑破布局 4. 历史 remark 中已落库的「正常」字符串不受影响、无迁移副作用 5. 并发提交幂等、仅生成 1 条睡眠记录 | 变更C 验收口径 + 风险项 3(默认值/历史数据)/ `quickTags` 回退契约 | — |
+
+### 权限流(-P,针对具体越权对象)
+| TC 编号 | 所属场景 | 模块 | 角色 | 优先级 | 类型 | 前置条件 | 测试步骤 | 预期结果 | 关联需求/设计 | 对应 e2e |
+|---|---|---|---|---|---|---|---|---|---|---|
+| TC-C-26-P | SC-C-26 | 健康打卡/睡眠配置 | 孩子 | P1 | 权限 | 孩子 B、家长 A、运营 M 账号可用;家庭 F9 另有孩子 D 的睡眠记录 | 1. 孩子 B 直接调后台配置保存接口(`save`)改选项列表 2. 家长 A 查 F9 孩子 D 的睡眠记录 `remark` 3. 非 admin 的普通账号调后台 get/save | 1. 孩子端无维护入口、直调 `save` 被 role 校验拒(403/401)、配置不被改动 2. 非本家庭睡眠记录 403、不泄露 `remark` 内容 3. 非 admin 账号 get/save 均被拒;仅运营 M(admin)可维护,且保存有操作留痕 | 变更C / `AdminSleepConfigController` 权限校验(路径需核实)+ `JwtInterceptor` 家庭归属 | — |
+
 ## 非功能测试用例(性能 / 兼容 / 安全)
 
 > 非功能维度:性能(大文件/并发/限流)、兼容(多端/多浏览器/主题)、安全(IDOR/XSS/上传/鉴权/SSRF)。编号后缀 `-N` 与正常流/异常流(-E)/权限流(-P)并列。

+ 25 - 0
tests/test-cases/parent.md

@@ -345,6 +345,31 @@
 | TC-P-92-P | SC-P-92 | 能量/配置 | 家长 | P1 | 权限 | 家庭1 + 运营 M | 1. 家长 A 调全局权重/`configure` 改配置 | 非 admin 改全局 403 | `EnergyController` | — |
 | TC-P-93-P | SC-P-93 | 能量/文章·活动 | 家长 | P2 | 权限 | 家庭1 + F9 | 1. 家长 A 查 F9 成员文章/活动能量流水 | 非本家庭流水 403 | `ArticleService`/`ActivityService` | — |
 
+## 成就与成长历史专项用例(SC-P-94~96,2026-11-06 需求变更 A/B/D)
+
+> 需求源:`docs/需求分析/2026-11-06-成就成长打卡需求变更.md` 变更 A/B/D;场景源:`tests/user-scenarios/应用场景总览.md` 行 339-341;能力键台账:`docs/tdd/orchestration/sc-key-map.md`(`task.history.view` / `growth.history.view` / `achievement.grid.fiveCol`)。每条含正常流 + 具体异常流(-E) + 权限流(-P)。接口路径以实现为准,未在需求/接口参考中明确的标「需核实」。
+
+### 正常流
+| TC 编号 | 所属场景 | 模块 | 角色 | 优先级 | 类型 | 前置条件 | 测试步骤 | 预期结果 | 关联需求/设计 | 对应 e2e |
+|---|---|---|---|---|---|---|---|---|---|---|
+| TC-P-94 | SC-P-94 | 成就/历史完成任务 | 家长 | P1 | 功能 | 家长 A 登录;该成员存在 ≥1 条 `status=completed` 任务;`POST /api/tasks/history` 可用 | 1. 进入我的成就页 2. 点「完成任务」卡 3. 查看历史完成任务页首屏 4. 翻至第 2 页后返回成就页 | 1. 进入「历史完成任务」页(非 `/pages/tasks/tasks`),标题正确 2. 仅展示该成员已完成任务、按完成时间倒序 3. `page`/`size` 生效,总数与后端 `Page<Task>` 一致 4. 返回正常;原任务管理入口 `/pages/tasks/tasks` 保持可用、未被改造 | 变更A / `TaskController.getTaskHistory`(`/api/tasks/history`) + `utils/api.js:getTaskHistory` | — |
+| TC-P-95 | SC-P-95 | 成就/成长历史记录 | 家长 | P1 | 功能 | 家长 A 登录;已存在饮食/运动/睡眠/心情等多类型「今日成长」打卡记录及成长档案记录 | 1. 点「成长记录」卡 2. 查看聚合列表 3. 按类型筛选「睡眠」 4. 点单条记录看明细/回链 5. 分页 | 1. 进入「成长历史记录」页(非成长首页) 2. 聚合展示全部打卡记录、按日期倒序、类型标识清晰 3. 筛选后仅剩该类型记录 4. 明细/回链可用且数据与列表一致 5. 打卡记录与档案记录口径明确(合并或分栏)且不重复计数;空态「暂无成长记录」 | 变更B / `HealthCheckinController /api/health/checkin/list` + `GrowthController /api/growth/record/list`(合并口径需核实) | — |
+| TC-P-96 | SC-P-96 | 成就/布局 | 家长 | P1 | 功能 | 家长 A 登录;可切换 320/375/414px 三种视口 | 1. 打开「我的成就」区块 2. 量测 5 张卡(连续打卡/完成任务/收集勋章/成长记录/参加挑战)列宽与所在行 3. 检查「我的团队」「卡券中心」 4. 长昵称/超长数值/窄屏复测 | 1. 5 卡单行 5 列等比(各约 20%),不换行、不错位、不溢出 2. 三种视口均保持单行 3. 「我的团队」「卡券中心」列数与间距与改动前一致(独立类隔离,未受 `.achievement-grid` 全局改动影响) 4. 超长文案省略不撑破网格 | 变更D / `profile.vue`「我的成就」区块独立网格类(`.ach-item{width:25%}` → 20%) | — |
+
+### 异常流(-E,具体故障注入)
+| TC 编号 | 所属场景 | 模块 | 角色 | 优先级 | 类型 | 前置条件 | 测试步骤 | 预期结果 | 关联需求/设计 | 对应 e2e |
+|---|---|---|---|---|---|---|---|---|---|---|
+| TC-P-94-E | SC-P-94 | 成就/历史完成任务 | 家长 | P2 | 异常 | 造「零完成记录」「page=-1/超大」「`category` 非法」「history 接口 5xx 或超时」四组对照 | 1. 零完成记录进页 2. 传 `page=-1` 与 `page=999999` 3. 传非法 `category` 4. 令接口返回 5xx/超时后重试 | 1. 展示空态「暂无已完成任务」、不白屏、不显示他类任务 2. 非法分页 400 或归一到首页、不返回错乱数据 3. 非法 `category` 400 或忽略、不 500 4. 失败有友好降级与重试入口、不残留半页脏数据;原任务列表入口仍可用 | 变更A 验收口径 / `TaskController.getTaskHistory` 分页参数校验 | — |
+| TC-P-95-E | SC-P-95 | 成就/成长历史记录 | 家长 | P2 | 异常 | 造「checkin 接口超时」「growth/record 返回空」「两源同一记录 ID」「筛选类型非法/日期倒挂」「明细 ID 不存在」 | 1. 单源超时下进页 2. 另一源返回空 3. 注入两源重复 ID 4. 传非法类型与 `start>end` 5. 点已删除记录的明细 | 1. 部分源失败时展示可用源并提示、不 500 不白屏 2. 空源给出空态、不误判为无记录 3. 同源/跨源重复 ID 去重、不重复计数 4. 非法筛选 400 或回退全量、不返回错序数据 5. 明细缺失给明确提示并可返回列表 | 变更B 风险项(聚合口径需实现方回链)/ 两接口降级契约 | — |
+| TC-P-96-E | SC-P-96 | 成就/布局 | 家长 | P2 | 异常 | 造「连续打卡 99999 天」「卡名超长」「图标加载失败」「320px 窄屏」「系统字体放大 150%」 | 1. 注入超长数值与文案 2. 断网或改坏图标 URL 3. 320px 与放大字体下复测 4. 快速横竖屏切换 | 1. 数值/文案省略或换行在卡内、不撑破 20% 列宽、5 卡仍单行 2. 图标失败有占位、不错位 3. 窄屏/大字体下不换行错位、不出现横向滚动 4. 切换后列宽重算正确、「我的团队」「卡券中心」不受影响 | 变更D 隔离要求(不得改 `.achievement-grid` 全局) | — |
+
+### 权限流(-P,针对具体越权对象)
+| TC 编号 | 所属场景 | 模块 | 角色 | 优先级 | 类型 | 前置条件 | 测试步骤 | 预期结果 | 关联需求/设计 | 对应 e2e |
+|---|---|---|---|---|---|---|---|---|---|---|
+| TC-P-94-P | SC-P-94 | 成就/历史完成任务 | 家长 | P1 | 权限 | 家庭1(A+B)与无关家庭 F9(成员 D)均有已完成任务;孩子 B 可登录 | 1. 家长 A 调 `/api/tasks/history` 传 F9 成员 `memberId` 2. 孩子 B 直接调同一接口查家长 A 的任务历史 3. 未登录访问历史页 | 1. 非本家庭 `memberId` 403,响应不泄露 F9 任务标题/完成时间 2. 孩子端无「历史完成任务」入口,直调被 role 校验拒(403/401) 3. 未登录 401 且跳登录、不返回任务数据 | `JwtInterceptor` + 家庭归属校验(`TaskController.getTaskHistory`) | — |
+| TC-P-95-P | SC-P-95 | 成就/成长历史记录 | 家长 | P1 | 权限 | 家庭1 与 F9 各有打卡/档案记录 | 1. 家长 A 传 F9 成员 ID 查 `/api/health/checkin/list` 与 `/api/growth/record/list` 2. 孩子 B 查非本人成长档案记录 3. 家长 A 尝试改他人打卡记录 | 1. 两类接口对非本家庭成员一律 403、不返回记录内容 2. 孩子端仅见本人打卡、直调他人 403 3. 家长无编辑权限、写操作被拒(403)、数据不被改动 | `HealthCheckinController` / `GrowthController` + 家庭归属校验 | — |
+| TC-P-96-P | SC-P-96 | 成就/布局 | 家长 | P2 | 权限 | 家长 A 与 F9 家长 D 各有不同成就统计值;孩子 B 可登录 | 1. A、D 分别登录看 5 张卡数值 2. A 带 F9 成员 `memberId` 调成就统计接口 3. 孩子 B 直接拼家长端成就页路由 | 1. 每张卡数值仅为当前登录成员自己的,A 与 D 数值互不可见 2. 传他人 `memberId` 403、不返回他人统计值(样式改造不得顺带暴露数据) 3. 孩子端被 role 校验拦截、无「我的成就」5 列区块及其数据 | 变更D 隔离要求 + `profile.vue` 成就统计取值归属(接口名需核实) | — |
+
 ## 非功能测试用例(性能 / 兼容 / 安全)
 
 > 非功能维度:性能(大文件/并发/限流)、兼容(多端/多浏览器/主题)、安全(IDOR/XSS/上传/鉴权/SSRF)。编号后缀 `-N` 与正常流/异常流(-E)/权限流(-P)并列。

+ 2 - 0
tests/user-scenarios/应用场景总览.md

@@ -447,4 +447,6 @@
 
 > **本轮修订(2026-11-05 二次 · AI管家聚焦 + 真人管家)**:AI 管家定位由「健康管家」聚焦为「AI 成长教练」,核心职责收敛为「问题解答 + 任务跟踪」,移出弱相关场景 SC-B-03(异常指标预警)、SC-B-08(健康简报),butler 实编降至 10(空号 03、08)。新增独立角色「真人管家 / human-butler」,新建 `09-human-butler.md`,场景 SC-H-01~07(M4/S2/C1)。场景总数 230 → 235。用例 TC-B-/TC-H- 待下轮补。
 
+> **【2026-11-10 测试侧补写完成】** 上条待补的 5 个 SC 已全部派生三流用例:`TC-P-94~96`、`TC-C-26` 各含 -E/-P;`SC-A-27` 因 `TC-A-27*` 编号被 S-cfc-68 团队分润占用,改用 `TC-A-27-S/-S-E/-S-P`(未改动既有 TC)。共新增 **15 条**,落在 `tests/test-cases/parent.md`、`child.md`、`admin.md`。双向追溯主表见 `tests/TRACEABILITY-MATRIX.md`;编号级实测 241/241 三流齐全、唯一 TC 842 条。注意「编号齐全 ≠ 语义/执行齐全」:主矩阵 §4 记录 51 个编号错位、226 个 SC 无三类流执行证据。
+
 > **本轮扩展(+5 成就成长睡眠打卡 · 2026-11-06)**:用户 2026-11-06 需求变更(4 条:完成任务→历史页 / 成长记录→成长历史页 / 睡眠情况含「正常」+后台可维护 / 我的成就固定五列),新增 SC-P-94~96(家长·成就与成长历史)、SC-C-26(孩子·睡眠打卡配置驱动)、SC-A-27(运营·睡眠情况选项维护),场景总数 235 → 241。完整需求变更见 `docs/需求分析/2026-11-06-成就成长打卡需求变更.md`;用例 TC-P-94~96 / TC-C-26 / TC-A-27 待下轮在 `test-cases/*.md` 补(含正常/异常/权限三类)。