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

feat(commission): T32 团队关联订单/关联金额按商城商品-SKU 维度统计接口

- CommissionService 新增 getTeamProductPerformance(userId, maxLevel):
  口径复用 referral_tree 下级成员 + product_orders(fulfillStatus=completed),
  按 (productId, skuId) 分组,组内 orderNo 去重,金额分→元(BigDecimal 2 位 HALF_UP),
  商品名取 products.name,取不到回退订单行快照;skuId null→0;输出按金额降序稳定排序
- CommissionController 新增 POST /api/commission/team/product-performance
  (maxLevel 可选,默认 3,上限 10),Result<Map> 包装
- 新增 CommissionServiceTeamProductPerformanceTest(5 case:分组/去重/分→元换算/空团队/字段归一化)
- 同步 docs/superpowers/api/API_REFERENCE.md 4.21 与 PROJECT-OVERVIEW.md
- 无新增数据库迁移,未改动任何前端文件
Sisyphus Agent 1 неделя назад
Родитель
Сommit
ece8679abe

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/CommissionController.java

@@ -96,6 +96,18 @@ public class CommissionController {
         return Result.success(commissionService.getTeamConsumption(userId, maxLevel));
         return Result.success(commissionService.getTeamConsumption(userId, maxLevel));
     }
     }
 
 
+    /**
+     * 团队「关联订单 / 关联金额(元)」按商城商品-SKU 维度统计
+     */
+    @PostMapping("/team/product-performance")
+    public Result<Map<String, Object>> teamProductPerformance(@RequestAttribute("userId") Long userId,
+                                                              @RequestBody(required = false) Map<String, Object> params) {
+        Integer maxLevel = params != null && params.get("maxLevel") != null
+                ? ((Number) params.get("maxLevel")).intValue()
+                : 3;
+        return Result.success(commissionService.getTeamProductPerformance(userId, maxLevel));
+    }
+
     /**
     /**
      * 获取我的推荐人
      * 获取我的推荐人
      */
      */

+ 159 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java

@@ -26,15 +26,20 @@ import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.util.ArrayList;
 import java.util.ArrayList;
 import java.util.Calendar;
 import java.util.Calendar;
+import java.util.Comparator;
 import java.util.Date;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Objects;
 import java.util.Random;
 import java.util.Random;
+import java.util.Set;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
 import com.etotem.cfc.util.SortUtil;
 import com.etotem.cfc.util.SortUtil;
 
 
@@ -744,6 +749,160 @@ public class CommissionService {
         return result;
         return result;
     }
     }
 
 
+    /**
+     * 团队「关联订单 / 关联金额(元)」统计,按商城商品-SKU 维度聚合。
+     *
+     * <p>口径与 {@link #getTeamConsumption(Long, Integer)} 保持一致:团队关系取
+     * referral_tree(parentId=userId, level≤maxLevel)的下级成员,订单取
+     * product_orders(buyerId IN memberIds AND fulfillStatus='completed')。</p>
+     *
+     * <p>返回:totalOrderCount(团队级 orderNo 去重数)、totalAmountYuan(元,2 位小数)、
+     * maxLevel、items(按 (productId, skuId) 分组,orderCount 为该组 orderNo 去重数)。</p>
+     */
+    public Map<String, Object> getTeamProductPerformance(Long userId, Integer maxLevel) {
+        if (maxLevel == null || maxLevel <= 0) maxLevel = 3;
+        if (maxLevel > 10) maxLevel = 10; // 安全上限
+
+        // 1. 查 referral_tree: parentId = userId 且 level <= maxLevel
+        List<ReferralTree> treeNodes = referralTreeMapper.selectList(
+                new LambdaQueryWrapper<ReferralTree>()
+                        .eq(ReferralTree::getParentId, userId)
+                        .le(ReferralTree::getLevel, maxLevel)
+        );
+
+        List<Long> memberIds = treeNodes.stream()
+                .map(ReferralTree::getMemberId)
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+        if (memberIds.isEmpty()) {
+            return emptyTeamProductPerformance(maxLevel);
+        }
+
+        // 2. 查下级已完成订单
+        List<ProductOrder> orders = productOrderMapper.selectList(
+                new LambdaQueryWrapper<ProductOrder>()
+                        .in(ProductOrder::getBuyerId, memberIds)
+                        .eq(ProductOrder::getFulfillStatus, "completed")
+        );
+        if (orders == null || orders.isEmpty()) {
+            return emptyTeamProductPerformance(maxLevel);
+        }
+
+        // 3. 团队级汇总:订单号去重 + 金额累计(分)
+        Set<String> teamOrderNos = new LinkedHashSet<>();
+        long teamAmountCents = 0L;
+        for (ProductOrder o : orders) {
+            if (o == null) continue;
+            if (o.getOrderNo() != null) teamOrderNos.add(o.getOrderNo());
+            teamAmountCents += o.getTotalAmount() != null ? o.getTotalAmount() : 0;
+        }
+
+        // 4. 按 (productId, skuId) 分组
+        Map<String, SkuPerformanceAgg> aggs = new LinkedHashMap<>();
+        for (ProductOrder o : orders) {
+            if (o == null) continue;
+            Long productId = o.getProductId();
+            Long skuId = o.getSkuId() != null ? o.getSkuId() : 0L;
+            String key = String.valueOf(productId) + "#" + skuId;
+            SkuPerformanceAgg agg = aggs.get(key);
+            if (agg == null) {
+                agg = new SkuPerformanceAgg(productId, skuId);
+                aggs.put(key, agg);
+            }
+            agg.add(o);
+        }
+
+        // 5. 商品名(取不到则回退订单行快照名)
+        Map<Long, String> productNameMap = new HashMap<>();
+        List<Long> productIds = orders.stream()
+                .filter(Objects::nonNull)
+                .map(ProductOrder::getProductId)
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+        if (!productIds.isEmpty()) {
+            for (Product p : productMapper.selectBatchIds(productIds)) {
+                if (p != null && p.getId() != null && p.getName() != null) {
+                    productNameMap.put(p.getId(), p.getName());
+                }
+            }
+        }
+
+        // 6. 组装 items(金额降序,其次 productId / skuId 升序,保证输出稳定)
+        List<SkuPerformanceAgg> sortedAggs = new ArrayList<>(aggs.values());
+        sortedAggs.sort(Comparator
+                .comparingLong((SkuPerformanceAgg a) -> a.amountCents).reversed()
+                .thenComparingLong(a -> a.productId != null ? a.productId : 0L)
+                .thenComparingLong(a -> a.skuId));
+
+        List<Map<String, Object>> items = new ArrayList<>();
+        for (SkuPerformanceAgg agg : sortedAggs) {
+            String name = null;
+            if (agg.productId != null) {
+                name = productNameMap.get(agg.productId);
+            }
+            if (name == null || name.isEmpty()) {
+                name = agg.fallbackProductName != null ? agg.fallbackProductName : "";
+            }
+
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("productId", agg.productId);
+            item.put("productName", name);
+            item.put("skuId", agg.skuId);
+            item.put("orderCount", agg.orderNos.size());
+            item.put("amountYuan", centsToYuan(agg.amountCents));
+            items.add(item);
+        }
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("totalOrderCount", teamOrderNos.size());
+        result.put("totalAmountYuan", centsToYuan(teamAmountCents));
+        result.put("maxLevel", maxLevel);
+        result.put("items", items);
+        return result;
+    }
+
+    private Map<String, Object> emptyTeamProductPerformance(Integer maxLevel) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("totalOrderCount", 0);
+        result.put("totalAmountYuan", BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
+        result.put("maxLevel", maxLevel);
+        result.put("items", new ArrayList<Map<String, Object>>());
+        return result;
+    }
+
+    /** 分 → 元(2 位小数,四舍五入) */
+    private static BigDecimal centsToYuan(long cents) {
+        return BigDecimal.valueOf(cents).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
+    }
+
+    /** 单个 (productId, skuId) 分组的聚合状态 */
+    private static class SkuPerformanceAgg {
+        private final Long productId;
+        private final Long skuId;
+        private final Set<String> orderNos = new LinkedHashSet<>();
+        private long amountCents;
+        private String fallbackProductName;
+
+        private SkuPerformanceAgg(Long productId, Long skuId) {
+            this.productId = productId;
+            this.skuId = skuId;
+        }
+
+        private void add(ProductOrder order) {
+            if (order.getOrderNo() != null) {
+                orderNos.add(order.getOrderNo());
+            }
+            if (order.getTotalAmount() != null) {
+                amountCents += order.getTotalAmount();
+            }
+            if (fallbackProductName == null && order.getProductName() != null) {
+                fallbackProductName = order.getProductName();
+            }
+        }
+    }
+
     /**
     /**
      * 获取我的推荐人信息(直推人),支持按家庭成员维度查询
      * 获取我的推荐人信息(直推人),支持按家庭成员维度查询
      * memberId 不为空时按成员查其关联用户的推荐人;为空时直接用 userId
      * memberId 不为空时按成员查其关联用户的推荐人;为空时直接用 userId

+ 193 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/CommissionServiceTeamProductPerformanceTest.java

@@ -0,0 +1,193 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.entity.ReferralTree;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ProductOrderMapper;
+import com.etotem.cfc.mapper.ReferralTreeMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+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.Mockito.when;
+
+/**
+ * 团队页「关联订单 / 关联金额(元)」按商城商品-SKU 维度统计单元测试。
+ *
+ * <p>口径与 {@code getTeamConsumption} 一致:referral_tree 下级成员 + product_orders
+ * fulfillStatus=completed;按 (productId, skuId) 分组,订单号去重,金额分→元。</p>
+ */
+class CommissionServiceTeamProductPerformanceTest {
+
+    @Mock
+    private ReferralTreeMapper referralTreeMapper;
+    @Mock
+    private ProductOrderMapper productOrderMapper;
+    @Mock
+    private ProductMapper productMapper;
+
+    @InjectMocks
+    private CommissionService commissionService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), ReferralTree.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), ProductOrder.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), Product.class);
+    }
+
+    private ReferralTree treeNode(Long memberId, int level) {
+        ReferralTree node = new ReferralTree();
+        node.setParentId(1L);
+        node.setMemberId(memberId);
+        node.setLevel(level);
+        return node;
+    }
+
+    private ProductOrder order(String orderNo, Long productId, Long skuId, Integer totalAmount, Long buyerId) {
+        ProductOrder o = new ProductOrder();
+        o.setOrderNo(orderNo);
+        o.setProductId(productId);
+        o.setSkuId(skuId);
+        o.setTotalAmount(totalAmount);
+        o.setBuyerId(buyerId);
+        o.setFulfillStatus("completed");
+        return o;
+    }
+
+    private Product product(Long id, String name) {
+        Product p = new Product();
+        p.setId(id);
+        p.setName(name);
+        return p;
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> invokeWith(Long userId, Integer maxLevel, List<ReferralTree> tree,
+                                          List<ProductOrder> orders, List<Product> products) {
+        when(referralTreeMapper.selectList(any())).thenReturn(tree);
+        if (!tree.isEmpty()) {
+            when(productOrderMapper.selectList(any())).thenReturn(orders);
+        }
+        when(productMapper.selectBatchIds(any())).thenReturn(products);
+        return commissionService.getTeamProductPerformance(userId, maxLevel);
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Map<String, Object>> itemsOf(Map<String, Object> result) {
+        Object items = result.get("items");
+        assertNotNull(items, "items 必须存在");
+        return (List<Map<String, Object>>) items;
+    }
+
+    private static void assertYuan(String expected, Object actual) {
+        assertTrue(actual instanceof BigDecimal, "金额应为 BigDecimal,实际:" + actual);
+        assertEquals(0, new BigDecimal(expected).compareTo((BigDecimal) actual),
+                "金额应为 " + expected + " 元,实际:" + actual);
+    }
+
+    @Test
+    void 按下级已完成订单按商品与SKU分组且金额换算为元() {
+        List<ReferralTree> tree = Arrays.asList(treeNode(101L, 1), treeNode(102L, 2));
+        List<ProductOrder> orders = Arrays.asList(
+                // 同一订单 A 含两个 SKU:各自计 1 单,团队级只算 1 单
+                order("A", 12L, 5L, 149500, 101L),
+                order("A", 12L, 6L, 50000, 101L),
+                order("B", 12L, 5L, 10000, 102L)
+        );
+
+        Map<String, Object> result = invokeWith(1L, 3, tree, orders,
+                Collections.singletonList(product(12L, "肠道菌群测评")));
+
+        assertEquals(2, result.get("totalOrderCount"), "团队级订单数应按 orderNo 去重(A/B 两单)");
+        assertYuan("2095.00", result.get("totalAmountYuan"));
+        assertEquals(3, result.get("maxLevel"));
+
+        List<Map<String, Object>> items = itemsOf(result);
+        assertEquals(2, items.size(), "应按 (productId, skuId) 分成两组");
+
+        Map<String, Object> sku5 = items.get(0);
+        assertEquals(12L, sku5.get("productId"));
+        assertEquals("肠道菌群测评", sku5.get("productName"));
+        assertEquals(5L, ((Number) sku5.get("skuId")).longValue());
+        assertEquals(2, sku5.get("orderCount"), "SKU 5 关联订单 A/B 两单");
+        assertYuan("1595.00", sku5.get("amountYuan"));
+
+        Map<String, Object> sku6 = items.get(1);
+        assertEquals(6L, ((Number) sku6.get("skuId")).longValue());
+        assertEquals(1, sku6.get("orderCount"));
+        assertYuan("500.00", sku6.get("amountYuan"));
+    }
+
+    @Test
+    void skuId为空时归入0且商品名回退订单快照() {
+        List<ReferralTree> tree = Collections.singletonList(treeNode(101L, 1));
+        ProductOrder o = order("C", 88L, null, 9900, 101L);
+        o.setProductName("快照商品名");
+
+        Map<String, Object> result = invokeWith(1L, 3, tree, Collections.singletonList(o),
+                new ArrayList<Product>());
+
+        List<Map<String, Object>> items = itemsOf(result);
+        assertEquals(1, items.size());
+        assertEquals(0L, ((Number) items.get(0).get("skuId")).longValue(), "skuId 为 null 时应返回 0");
+        assertEquals("快照商品名", items.get(0).get("productName"), "商品表取不到时应回退订单行快照名");
+        assertYuan("99.00", items.get(0).get("amountYuan"));
+        assertEquals(1, result.get("totalOrderCount"));
+    }
+
+    @Test
+    void 空团队返回空列表且总额为0() {
+        Map<String, Object> result = invokeWith(1L, 3, new ArrayList<ReferralTree>(),
+                new ArrayList<ProductOrder>(), new ArrayList<Product>());
+
+        assertTrue(itemsOf(result).isEmpty());
+        assertEquals(0, result.get("totalOrderCount"));
+        assertYuan("0.00", result.get("totalAmountYuan"));
+        assertEquals(3, result.get("maxLevel"));
+    }
+
+    @Test
+    void 有下级但无已完成订单返回空列表() {
+        List<ReferralTree> tree = Collections.singletonList(treeNode(101L, 1));
+
+        Map<String, Object> result = invokeWith(1L, 2, tree,
+                new ArrayList<ProductOrder>(), new ArrayList<Product>());
+
+        assertTrue(itemsOf(result).isEmpty());
+        assertEquals(0, result.get("totalOrderCount"));
+        assertYuan("0.00", result.get("totalAmountYuan"));
+        assertEquals(2, result.get("maxLevel"));
+    }
+
+    @Test
+    void maxLevel为空或超限时归一化() {
+        List<ReferralTree> tree = Collections.singletonList(treeNode(101L, 1));
+
+        Map<String, Object> defaulted = invokeWith(1L, null, tree,
+                new ArrayList<ProductOrder>(), new ArrayList<Product>());
+        assertEquals(3, defaulted.get("maxLevel"), "maxLevel 缺省应为 3");
+
+        Map<String, Object> capped = invokeWith(1L, 99, tree,
+                new ArrayList<ProductOrder>(), new ArrayList<Product>());
+        assertEquals(10, capped.get("maxLevel"), "maxLevel 超过 10 应截断为 10");
+    }
+}

+ 3 - 2
docs/superpowers/PROJECT-OVERVIEW.md

@@ -1,7 +1,7 @@
 # 浠艾福 项目全景 — 阶段性需求与设计汇总
 # 浠艾福 项目全景 — 阶段性需求与设计汇总
 
 
-**文档版本:** v2.23
-**日期:** 2026-10-31
+**文档版本:** v2.24
+**日期:** 2026-11-01
 **状态:** 已确认(v2.1 Phase 2-4 全栈完成)+ 虚拟支付改造(Tasks 1-12 已完成,退款闭环实施中)+ 新用户注册引导(12 Tasks 全栈完成)+ 家庭成员关系条增强(✅ 已实施)+ TabBar 重构(✅ 4Tab + 中间⭐扇形菜单,设计已对齐实现)+ **LIFETIME 终身会员(✅ 11 Tasks 全栈完成)** + **健康数据中心(✅ 全栈完成)** + **SKU 价格单位统一分 + 规格选择响应式修复(✅ 7 Tasks 全栈完成)** + **AI健康教练人格分化与管家自助选择(✅ 11 Tasks 全栈完成)** + **优惠券全链路改为家庭维度(待实施)** + **关系三图重构(关键人拓展章鱼图/珍珠图/能力图,设计规格已完成)** + **注册零填写+信息按需补充(✅ 全栈完成:登录直接进首页 + 家庭邀请独立页真实姓名/家庭身份必填 + 功能→信息字段映射表 + 添加成员手机号冲突邀请加入)** + **健康报告归属重分配(✅ 后端接口 + 管理端页面 + 小程序入口已实现,见 `specs/2026-10-05-health-report-reassign-design.md`)**
 **状态:** 已确认(v2.1 Phase 2-4 全栈完成)+ 虚拟支付改造(Tasks 1-12 已完成,退款闭环实施中)+ 新用户注册引导(12 Tasks 全栈完成)+ 家庭成员关系条增强(✅ 已实施)+ TabBar 重构(✅ 4Tab + 中间⭐扇形菜单,设计已对齐实现)+ **LIFETIME 终身会员(✅ 11 Tasks 全栈完成)** + **健康数据中心(✅ 全栈完成)** + **SKU 价格单位统一分 + 规格选择响应式修复(✅ 7 Tasks 全栈完成)** + **AI健康教练人格分化与管家自助选择(✅ 11 Tasks 全栈完成)** + **优惠券全链路改为家庭维度(待实施)** + **关系三图重构(关键人拓展章鱼图/珍珠图/能力图,设计规格已完成)** + **注册零填写+信息按需补充(✅ 全栈完成:登录直接进首页 + 家庭邀请独立页真实姓名/家庭身份必填 + 功能→信息字段映射表 + 添加成员手机号冲突邀请加入)** + **健康报告归属重分配(✅ 后端接口 + 管理端页面 + 小程序入口已实现,见 `specs/2026-10-05-health-report-reassign-design.md`)**
 **指标变化曲线·指标速查·指标关联(✅ 全栈完成:后端11任务+小程序M1-M8+管理端W1-W6;HBuilderX人工验收待用户执行,见 `specs/2026-10-09-indicator-change-curve-design.md`)**
 **指标变化曲线·指标速查·指标关联(✅ 全栈完成:后端11任务+小程序M1-M8+管理端W1-W6;HBuilderX人工验收待用户执行,见 `specs/2026-10-09-indicator-change-curve-design.md`)**
 **饮食/睡眠/运动打卡接入任务完成链路(✅ 全栈完成:实体taskId字段 + 三个Controller isDailyProgress分流回写 + RepeatTaskGenerator每日生成打卡任务 + tasks.vue跳转 + 三打卡页taskId透传 + API文档;见 `specs/2026-10-13-health-checkin-task-linkage-design.md` 与 `plans/2026-10-13-health-checkin-task-linkage.md`)**
 **饮食/睡眠/运动打卡接入任务完成链路(✅ 全栈完成:实体taskId字段 + 三个Controller isDailyProgress分流回写 + RepeatTaskGenerator每日生成打卡任务 + tasks.vue跳转 + 三打卡页taskId透传 + API文档;见 `specs/2026-10-13-health-checkin-task-linkage-design.md` 与 `plans/2026-10-13-health-checkin-task-linkage.md`)**
@@ -11,6 +11,7 @@
 **后台多渠道发货(🟡 设计稿待审查:管理端发货对齐微信小程序后台四种方式——快递 logistics_type=1 / 同城配送 2(deliveryMethod 新增编码 6)/ 用户自提 4(不再跳过微信同步)/ 虚拟商品 3(支付成功自动发货 autoShipVirtualOrder,幂等 fail-open);另修正 upload_shipping_info URL 缺 /wxa 前缀;cfc-web 发货对话框按类型切换;见 `specs/2026-10-22-fulfillment-multi-mode-design.md`)**
 **后台多渠道发货(🟡 设计稿待审查:管理端发货对齐微信小程序后台四种方式——快递 logistics_type=1 / 同城配送 2(deliveryMethod 新增编码 6)/ 用户自提 4(不再跳过微信同步)/ 虚拟商品 3(支付成功自动发货 autoShipVirtualOrder,幂等 fail-open);另修正 upload_shipping_info URL 缺 /wxa 前缀;cfc-web 发货对话框按类型切换;见 `specs/2026-10-22-fulfillment-multi-mode-design.md`)**
 **CF 值体系统一收尾(F-8 清理批次,✅ 2026-10-25):三套旧 CF 账本(`family_platform_balance` / `family_members.system_points` / `user_platform_balance`)写接口全部 410 Gone(`PointsService` 5 个 + `PlatformPointsService` 6 个 + `FamilyPlatformPointsService` 7 个),只读历史保留;`system_points` 误导性「CF值」注释与 F-3b1 遗留「待 Q1 确认」注释清理完毕(F10 已定:每个完成成员各发一次);新增《CF 值获取/消耗单一事实源》规范文档;核销 W0.2「product_config 下线」条目(基线/测试库均无该表,`/api/admin/product-config/*` 已 410 Gone,无需删表);见 `specs/2026-10-25-cf-value-single-source-of-truth.md`**
 **CF 值体系统一收尾(F-8 清理批次,✅ 2026-10-25):三套旧 CF 账本(`family_platform_balance` / `family_members.system_points` / `user_platform_balance`)写接口全部 410 Gone(`PointsService` 5 个 + `PlatformPointsService` 6 个 + `FamilyPlatformPointsService` 7 个),只读历史保留;`system_points` 误导性「CF值」注释与 F-3b1 遗留「待 Q1 确认」注释清理完毕(F10 已定:每个完成成员各发一次);新增《CF 值获取/消耗单一事实源》规范文档;核销 W0.2「product_config 下线」条目(基线/测试库均无该表,`/api/admin/product-config/*` 已 410 Gone,无需删表);见 `specs/2026-10-25-cf-value-single-source-of-truth.md`**
 **商品套餐独立模型重写(📋 计划就绪:套餐=独立商品(product_type='bundle')非SKU + 内容清单新表 product_combo_items(迁移437, 挂套餐SKU级, 子商品SKU×数量) + 移除旧combo/bundle两机制(迁移438/439删表) + 购物车勾选子集服务端offer检测(节省额>0才提示) + 一键替换为套餐接口 + 下单仅扣套餐SKU库存;cfc-web 套餐内容编辑/移除SKU组合模式 + cfc-frontend 购物车一键替换提示/详情内含清单;9任务实施计划;见 `specs/2026-10-31-bundle-redesign-design.md` 与 `plans/2026-10-31-bundle-redesign.md`)**
 **商品套餐独立模型重写(📋 计划就绪:套餐=独立商品(product_type='bundle')非SKU + 内容清单新表 product_combo_items(迁移437, 挂套餐SKU级, 子商品SKU×数量) + 移除旧combo/bundle两机制(迁移438/439删表) + 购物车勾选子集服务端offer检测(节省额>0才提示) + 一键替换为套餐接口 + 下单仅扣套餐SKU库存;cfc-web 套餐内容编辑/移除SKU组合模式 + cfc-frontend 购物车一键替换提示/详情内含清单;9任务实施计划;见 `specs/2026-10-31-bundle-redesign-design.md` 与 `plans/2026-10-31-bundle-redesign.md`)**
+**团队页「关联订单 / 关联金额(元)」按商品-SKU 统计(✅ 后端接口已完成 2026-11-01):新增 `POST /api/commission/team/product-performance`,复用 `referral_tree` 下级 + `product_orders` 已完成履约订单口径,按 `(productId, skuId)` 分组、组内 orderNo 去重、金额分→元(2 位小数),空团队返回空列表与 0;前端 `promotion/team.vue` 本期不改;见 `specs/2026-11-01-team-product-performance-design.md` 与 `api/API_REFERENCE.md` 4.21**
 **维护:** 所有需求变更需更新本文档
 **维护:** 所有需求变更需更新本文档
 
 
 ---
 ---

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

@@ -902,6 +902,7 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/commission/team/consumption` | 团队总消费额分级汇总,请求 `{maxLevel}`(默认 3),返回 `{totalConsumption: 分, levelBreakdown[{level, memberCount, consumption}], totalMembers, maxLevel}` |
 | `POST /api/commission/team/consumption` | 团队总消费额分级汇总,请求 `{maxLevel}`(默认 3),返回 `{totalConsumption: 分, levelBreakdown[{level, memberCount, consumption}], totalMembers, maxLevel}` |
 | `POST /api/commission/my-referrer` | 获取我的直推人信息,返回 `{hasReferrer, referrerId, nickname, avatar, referralCode, bindTime}`,无推荐人时 hasReferrer=false |
 | `POST /api/commission/my-referrer` | 获取我的直推人信息,返回 `{hasReferrer, referrerId, nickname, avatar, referralCode, bindTime}`,无推荐人时 hasReferrer=false |
 | `POST /api/commission/team/tree` | 团队树形扁平数据(不含根节点),请求 `{maxLevel}`(默认 3),返回 `{nodes[{userId, nickname, avatar, level, parentId, referrerId, memberConsumption, myCfEarnedFromMember}], totalMembers, maxLevel}` |
 | `POST /api/commission/team/tree` | 团队树形扁平数据(不含根节点),请求 `{maxLevel}`(默认 3),返回 `{nodes[{userId, nickname, avatar, level, parentId, referrerId, memberConsumption, myCfEarnedFromMember}], totalMembers, maxLevel}` |
+| `POST /api/commission/team/product-performance` | 团队「关联订单 / 关联金额(元)」按商城商品-SKU 维度统计,请求 `{maxLevel}`(可选,默认 3,上限 10),返回 `{totalOrderCount: 团队级 orderNo 去重数, totalAmountYuan: 元(2 位小数), maxLevel, items[{productId, productName, skuId, orderCount, amountYuan}]}`;口径与 `/team/consumption` 同源(`referral_tree` 下级 + `product_orders.fulfillStatus='completed'`),按 `(productId, skuId)` 分组且组内 orderNo 去重(skuId 为 null 返回 0),空团队/无订单返回 `items=[]`、`totalOrderCount=0`、`totalAmountYuan=0.00` |
 
 
 ### 4.22 管理端-能量配置(`/api/admin/energy-config/*`)
 ### 4.22 管理端-能量配置(`/api/admin/energy-config/*`)