Procházet zdrojové kódy

feat(badges): 契约缺口修复 - 新增 /api/badges/child/list 与 /api/badges/statistics/child

小程序端 dist 调用 getChildBadges/getChildBadgeStats 触发 NOT_FOUND=2(两个端点缺失)。
新增 POST /api/badges/child/list(返回 [{badge, childBadge}],childId 即 family_members.id)与
POST /api/badges/statistics/child(返回 total/active/expired/revoked + categoryStats/levelStats,与 member 同口径),
childId 缺省回落 currentMemberId;补充 MockMvc 单测 3 例;API_REFERENCE 同步记录。

close #契约缺口 NOT_FOUND=2
iwt před 2 týdny
rodič
revize
6375a69447

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/BadgeController.java

@@ -80,6 +80,24 @@ public class BadgeController {
         return Result.success("授予成功");
     }
 
+    // ===== 孩子勋章(child 视角,契约缺口修复)=====
+
+    @Operation(summary = "获取孩子的勋章列表")
+    @PostMapping("/child/list")
+    public Result<List<Map<String, Object>>> getChildBadges(@RequestBody Map<String, Object> body,
+                                                            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long childId = ParamUtils.getLong(body.get("childId"), currentMemberId);
+        return Result.success(badgeService.getChildBadgeList(childId));
+    }
+
+    @Operation(summary = "孩子勋章统计")
+    @PostMapping("/statistics/child")
+    public Result<Map<String, Object>> getChildBadgeStats(@RequestBody Map<String, Object> body,
+                                                          @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long childId = ParamUtils.getLong(body.get("childId"), currentMemberId);
+        return Result.success(badgeService.getChildBadgeStats(childId));
+    }
+
     // ===== 家庭成员勋章 =====
 
     @Operation(summary = "获取成员的勋章列表")

+ 27 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/BadgeService.java

@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
@@ -142,6 +143,32 @@ public class BadgeService {
         }
     }
 
+    // ===== 孩子勋章(child 视角,契约 /api/badges/child/list、/api/badges/statistics/child)=====
+
+    /**
+     * 孩子的勋章列表(含勋章定义 + 成员勋章),返回结构 {@code [{badge, childBadge}]}。
+     * childId 参数即 family_members.id(memberId),与 {@code child_badge.child_id} 对齐。
+     */
+    public List<Map<String, Object>> getChildBadgeList(Long childId) {
+        List<MemberBadge> memberBadges = getActiveMemberBadges(childId);
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (MemberBadge cb : memberBadges) {
+            Badge badge = getBadgeById(cb.getBadgeId());
+            if (badge != null) {
+                Map<String, Object> item = new HashMap<>();
+                item.put("badge", badge);
+                item.put("childBadge", cb);
+                result.add(item);
+            }
+        }
+        return result;
+    }
+
+    /** 孩子的勋章统计(与 member 统计同口径:total/active/expired/revoked + categoryStats/levelStats) */
+    public Map<String, Object> getChildBadgeStats(Long childId) {
+        return getMemberBadgeStats(childId);
+    }
+
     // ===== 统计与排行 =====
 
     public Map<String, Object> getMemberBadgeStats(Long memberId) {

+ 114 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/BadgeControllerTest.java

@@ -0,0 +1,114 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.entity.Badge;
+import com.etotem.cfc.entity.MemberBadge;
+import com.etotem.cfc.service.BadgeService;
+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 org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 契约缺口修复单测:POST /api/badges/child/list、POST /api/badges/statistics/child。
+ * 验证孩子勋章列表返回 [{badge, childBadge}]、统计返回 total/active 等字段,
+ * childId 从 body 读取(缺省回落 currentMemberId)。
+ */
+class BadgeControllerTest {
+
+    @Mock
+    private BadgeService badgeService;
+
+    @InjectMocks
+    private BadgeController controller;
+
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    private Badge badge(Long id, String name) {
+        Badge b = new Badge();
+        b.setId(id);
+        b.setName(name);
+        b.setCategory("学习");
+        b.setIsActive(1);
+        return b;
+    }
+
+    private MemberBadge childBadge(Long badgeId) {
+        MemberBadge cb = new MemberBadge();
+        cb.setBadgeId(badgeId);
+        cb.setStatus(1);
+        cb.setIsFavorite(false);
+        return cb;
+    }
+
+    @Test
+    void childList_返回badge加childBadge结构() throws Exception {
+        List<Map<String, Object>> data = new ArrayList<>();
+        Map<String, Object> item = new HashMap<>();
+        item.put("badge", badge(1L, "阅读之星"));
+        item.put("childBadge", childBadge(1L));
+        data.add(item);
+        when(badgeService.getChildBadgeList(anyLong())).thenReturn(data);
+
+        mockMvc.perform(post("/api/badges/child/list")
+                        .requestAttr("currentMemberId", 9L)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"childId\":9}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data[0].badge.name").value("阅读之星"))
+                .andExpect(jsonPath("$.data[0].childBadge.badgeId").value(1));
+    }
+
+    @Test
+    void childList_缺省回落currentMemberId() throws Exception {
+        when(badgeService.getChildBadgeList(9L)).thenReturn(new ArrayList<>());
+
+        mockMvc.perform(post("/api/badges/child/list")
+                        .requestAttr("currentMemberId", 9L)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data").isArray());
+    }
+
+    @Test
+    void statisticsChild_返回统计字段() throws Exception {
+        Map<String, Object> stats = new HashMap<>();
+        stats.put("total", 3);
+        stats.put("active", 2);
+        stats.put("expired", 1);
+        stats.put("revoked", 0);
+        when(badgeService.getChildBadgeStats(9L)).thenReturn(stats);
+
+        mockMvc.perform(post("/api/badges/statistics/child")
+                        .requestAttr("currentMemberId", 9L)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"childId\":9}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.total").value(3))
+                .andExpect(jsonPath("$.data.active").value(2));
+    }
+}

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

@@ -2485,3 +2485,20 @@ W0.2 双配置中心合并:`product_config` 已并入 `service_config`(唯
 - 读不到配置 → 回落 AVG(保证兼容)
 
 *文档更新:2026-10-31(家庭数据汇总方式配置(后端):family_metric_agg_config 表 + 配置 CRUD 4 接口 + 珍珠图/章鱼图家庭汇集 2 接口 + 五维聚合读配置)*
+
+### 4.57 勋章系统(`/api/badges/*`,`BadgeController`)
+
+**孩子勋章列表**(契约缺口修复:小程序端 `utils/api.js` 的 `getChildBadges(childId)`/`getUserBadges(childId)` 调用):
+
+`POST /api/badges/child/list` — Body `{ childId }`(缺省回落 JWT `currentMemberId`)。返回 `data: [ { badge: Badge, childBadge: MemberBadge } ]`:
+- `childId` 语义为 `family_members.id`(memberId,与 `child_badge.child_id` 对齐),非遗留 `children.id`
+- `badge`:勋章定义(id/badgeId/name/icon/category/level/rarity/isActive…)
+- `childBadge`:成员勋章记录(badgeId/earnedAt/expireAt/status/isFavorite…)
+
+**孩子勋章统计**(契约缺口修复:小程序端 `getChildBadgeStats(childId)` 调用):
+
+`POST /api/badges/statistics/child` — Body `{ childId }`(缺省回落 JWT `currentMemberId`)。返回 `data`:`{ total, active, expired, revoked, categoryStats: [ {category, count} ], levelStats: [ {level, count} ] }`(与 `/api/badges/statistics/member` 同口径)。
+
+**同族既有接口**(小程序端):`POST /api/badges/list`(启用勋章列表)、`POST /api/badges/member/list`(成员勋章列表 `{memberId}`)、`POST /api/badges/favorite`(收藏 `{memberId, badgeId}`)、`POST /api/badges/statistics/member`(成员统计 `{memberId}`)、`POST /api/badges/ranking/family`(家庭排行)。管理端(cfc-web):`/api/badges/all`、`/detail`、`/create`、`/update`、`/delete`、`/grant`。
+
+*文档更新:2026-11-01(契约缺口修复:新增 POST /api/badges/child/list、POST /api/badges/statistics/child,对齐小程序端 dist 调用的 NOT_FOUND=2)*