Browse Source

test(interface): 接口冒烟闭环 2026-10-31 01:26 — 通过率 44% (fix=0 reg=2)

E2E Test Bot 1 week ago
parent
commit
62f37b6099

+ 9 - 5
INTERFACE_TEST_REPORT.md

@@ -1,14 +1,18 @@
-# CFC 接口冒烟测试报告 (20261031_000444)
+# CFC 接口冒烟测试报告 (20261031_012445)
 
-- 目标: cfc.iwintrue.com  用例总数: **542**
-- 业务成功率(BIZ_OK): **246/542 = 45%**
+- 目标: cfc.iwintrue.com  用例总数: **549**
+- 业务成功率(BIZ_OK): **244/549 = 44%**
 
 ## 分类汇总
-- BIZ_OK: 246
-- BIZ_FAIL: 292
+- BIZ_OK: 244
+- BIZ_FAIL: 301
 - NOT_FOUND: 2
 - CONN_ERR: 2
 
+## ⚠️ 回归劣化(2 个,之前通过现失败)
+- `POST /api/user/portrait/generate`  (现 CONN_ERR)
+- `POST /api/user/portrait/regenerate`  (现 CONN_ERR)
+
 ## 需开发团队关注的契约缺口 (NOT_FOUND=2)
 - `POST /api/badges/child/list`  (biz=None, NOT_FOUND)
 - `POST /api/badges/statistics/child`  (biz=None, NOT_FOUND)

+ 60 - 16
cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java

@@ -47,14 +47,48 @@ public class FoodRecommendService {
     @Resource
     private FamilyFoodOverviewService familyFoodOverviewService;
 
+    /**
+     * 把外部传入的 subjectId 归一为 food_recommend_idx 的统一写入/读取键(T28 方向1:写侧统一为 users.id)。
+     * <p>
+     * 报告确认链路把被检测人归一为 {@code family_members.id}(memberId,见
+     * {@link HealthReportService#resolveSubjectToMemberId}),而家庭总览读侧按
+     * {@code family_members.user_id}(即 users.id)关联 food_recommend_idx —— 若把 memberId 原样写入
+     * {@code food_recommend_idx.user_id},两条键不唯一对齐,家庭总览重算链路失效(AC-09 P0)。
+     * <p>
+     * 规则:
+     * <ul>
+     *   <li>命中 {@code family_members.id} 且该成员有登录账号(user_id 非空)→ 返回其 {@code users.id}(登录成员统一键);</li>
+     *   <li>命中 {@code family_members.id} 但 user_id 为空(纯档案成员,无登录账号)→ 原样返回 memberId
+     *       (历史口径:无 users.id 可挂,且家庭总览/按家庭查询天然排除此类成员,不会参与 min 计算);</li>
+     *   <li>未命中 {@code family_members.id} → 视为已是 {@code users.id},原样返回(幂等,兼容已按 users.id 写入的历史数据)。</li>
+     * </ul>
+     */
+    public Long resolveFoodIndexUserId(Long subjectId) {
+        if (subjectId == null) return null;
+        try {
+            FamilyMember m = familyMemberMapper.selectById(subjectId);
+            if (m != null) {
+                return m.getUserId() != null ? m.getUserId() : subjectId;
+            }
+        } catch (Exception e) {
+            log.warn("subjectId 归一为 food_recommend_idx 键失败 subjectId={}: {}", subjectId, e.getMessage());
+        }
+        return subjectId;
+    }
+
     @Transactional
     public void recalculateForUser(Long userId) {
+        // T28 方向1:入参可能是 family_members.id(报告确认链路 subjectId,经 resolveSubjectToMemberId 归一)
+        // 或 users.id;food_recommend_idx 的写入键统一归一为成员真实登录用户 users.id,
+        // 与家庭总览读侧(family_members.user_id 关联)对齐。读侧 getLatestReport / snapshotFoodIndices 的
+        // subject 语义仍用原始入参(health_reports.subject_id 本就是 memberId)。
+        Long writeKey = resolveFoodIndexUserId(userId);
         HealthReport report = healthReportService.getLatestReport(userId);
         if (report == null) {
             foodRecommendIndexMapper.delete(new LambdaQueryWrapper<FoodRecommendIndex>()
-                    .eq(FoodRecommendIndex::getUserId, userId));
+                    .eq(FoodRecommendIndex::getUserId, writeKey));
             log.info("用户{}无最新健康报告,已清除推荐指数", userId);
-            refreshFamilyOverviews(userId);
+            refreshFamilyOverviews(writeKey);
             return;
         }
 
@@ -67,9 +101,9 @@ public class FoodRecommendService {
                             .eq(ReportFoodSuitability::getReportId, report.getId()));
             if (fallback.isEmpty()) {
                 foodRecommendIndexMapper.delete(new LambdaQueryWrapper<FoodRecommendIndex>()
-                        .eq(FoodRecommendIndex::getUserId, userId));
+                        .eq(FoodRecommendIndex::getUserId, writeKey));
                 log.info("报告{}无食材推荐数据,已清除用户{}的推荐指数", report.getId(), userId);
-                refreshFamilyOverviews(userId);
+                refreshFamilyOverviews(writeKey);
                 return;
             }
             // 回退:将 report_food_suitability 映射为 Food 列表后继续处理
@@ -86,7 +120,7 @@ public class FoodRecommendService {
 
         List<FoodRecommendIndex> existingIndices = foodRecommendIndexMapper.selectList(
                 new LambdaQueryWrapper<FoodRecommendIndex>()
-                        .eq(FoodRecommendIndex::getUserId, userId)
+                        .eq(FoodRecommendIndex::getUserId, writeKey)
         );
 
         Map<Long, FoodRecommendIndex> existingMap = existingIndices.stream()
@@ -111,7 +145,7 @@ public class FoodRecommendService {
                     foodRecommendIndexMapper.updateById(existing);
 
                     FoodRecommendIdxLog logEntry = new FoodRecommendIdxLog();
-                    logEntry.setUserId(userId);
+                    logEntry.setUserId(writeKey);
                     logEntry.setFoodId(foodId);
                     logEntry.setFoodName(foodName);
                     logEntry.setPreviousIndex(oldIndex);
@@ -125,7 +159,7 @@ public class FoodRecommendService {
                 }
             } else {
                 FoodRecommendIndex index = new FoodRecommendIndex();
-                index.setUserId(userId);
+                index.setUserId(writeKey);
                 index.setFoodId(foodId);
                 index.setFoodName(foodName);
                 index.setIndexScore(newIndex);
@@ -135,7 +169,7 @@ public class FoodRecommendService {
                 foodRecommendIndexMapper.insert(index);
 
                 FoodRecommendIdxLog logEntry = new FoodRecommendIdxLog();
-                logEntry.setUserId(userId);
+                logEntry.setUserId(writeKey);
                 logEntry.setFoodId(foodId);
                 logEntry.setFoodName(foodName);
                 logEntry.setPreviousIndex(null);
@@ -153,7 +187,7 @@ public class FoodRecommendService {
             foodRecommendIndexMapper.deleteById(removed.getId());
 
             FoodRecommendIdxLog logEntry = new FoodRecommendIdxLog();
-            logEntry.setUserId(userId);
+            logEntry.setUserId(writeKey);
             logEntry.setFoodId(removed.getFoodId());
             logEntry.setFoodName(removed.getFoodName());
             logEntry.setPreviousIndex(removed.getIndexScore());
@@ -170,7 +204,9 @@ public class FoodRecommendService {
         try {
             List<FoodRecommendIndex> finalIndices = foodRecommendIndexMapper.selectList(
                     new LambdaQueryWrapper<FoodRecommendIndex>()
-                            .eq(FoodRecommendIndex::getUserId, userId));
+                            .eq(FoodRecommendIndex::getUserId, writeKey));
+            // snapshotFoodIndices 的 subject 语义 = 被检测成员(memberId),保持原始入参;
+            // finalIndices 已按 writeKey 归一化键查询,内容正确,此处不做二次归一化。
             normalizeIndicatorPipeline.snapshotFoodIndices(userId, finalIndices);
         } catch (Exception e) {
             log.warn("食材指数归一化快照失败 userId={}: {}", userId, e.getMessage());
@@ -178,7 +214,7 @@ public class FoodRecommendService {
 
         // 家庭食材总览重算(T28):最新指数已全量覆盖写入,刷新所属家庭的持久化最小值总览。
         // try/catch + 只 warn,绝不因总览重算失败让报告确认入库主流程回滚(与归一化管道处理方式一致)
-        refreshFamilyOverviews(userId);
+        refreshFamilyOverviews(writeKey);
     }
 
     /**
@@ -212,9 +248,11 @@ public class FoodRecommendService {
     }
 
     public List<FoodRecommendIndex> getIndexByUser(Long userId) {
+        // T28 方向1:读侧归一化,把可能传入的 memberId 解析为 food_recommend_idx 统一键(users.id)
+        Long resolved = resolveFoodIndexUserId(userId);
         return foodRecommendIndexMapper.selectList(
                 new LambdaQueryWrapper<FoodRecommendIndex>()
-                        .eq(FoodRecommendIndex::getUserId, userId)
+                        .eq(FoodRecommendIndex::getUserId, resolved)
         );
     }
 
@@ -285,9 +323,11 @@ public class FoodRecommendService {
     }
 
     public List<FoodRecommendIdxLog> getIndexHistory(Long userId, Long foodId) {
+        // T28 方向1:读侧归一化,把可能传入的 memberId 解析为统一键(users.id),日志键与 food_recommend_idx 一致
+        Long resolved = resolveFoodIndexUserId(userId);
         return foodRecommendIdxLogMapper.selectList(
                 new LambdaQueryWrapper<FoodRecommendIdxLog>()
-                        .eq(FoodRecommendIdxLog::getUserId, userId)
+                        .eq(FoodRecommendIdxLog::getUserId, resolved)
                         .eq(FoodRecommendIdxLog::getFoodId, foodId)
                         .orderByDesc(FoodRecommendIdxLog::getChangedAt)
         );
@@ -319,9 +359,11 @@ public class FoodRecommendService {
      * @param queryUserId 查询目标,传memberId则按成员查,否则退回userId
      */
     public List<FoodRecommendIndex> getCautionFoods(Long queryUserId) {
+        // T28 方向1:读侧归一化,把可能传入的 memberId 解析为统一键(users.id)
+        Long resolved = resolveFoodIndexUserId(queryUserId);
         return foodRecommendIndexMapper.selectList(
                 new LambdaQueryWrapper<FoodRecommendIndex>()
-                        .eq(FoodRecommendIndex::getUserId, queryUserId)
+                        .eq(FoodRecommendIndex::getUserId, resolved)
                         .lt(FoodRecommendIndex::getIndexScore, 0)
                         .orderByAsc(FoodRecommendIndex::getIndexScore)
         );
@@ -376,11 +418,13 @@ public class FoodRecommendService {
      * 每个元素: { foodId, foodName, currentScore, trend: [reportDate, score], changeDir: "up"|"down"|"flat" }
      */
     public List<Map<String, Object>> getTrend(Long userId, List<Long> foodIds) {
+        // T28 方向1:读侧归一化,把可能传入的 memberId 解析为统一键(users.id),日志键与 food_recommend_idx 一致
+        Long resolved = resolveFoodIndexUserId(userId);
         List<FoodRecommendIdxLog> logs;
         if (foodIds != null && !foodIds.isEmpty()) {
             logs = foodRecommendIdxLogMapper.selectList(
                     new LambdaQueryWrapper<FoodRecommendIdxLog>()
-                            .eq(FoodRecommendIdxLog::getUserId, userId)
+                            .eq(FoodRecommendIdxLog::getUserId, resolved)
                             .in(FoodRecommendIdxLog::getFoodId, foodIds)
                             .orderByDesc(FoodRecommendIdxLog::getChangedAt)
                             .last("LIMIT 60")
@@ -388,7 +432,7 @@ public class FoodRecommendService {
         } else {
             logs = foodRecommendIdxLogMapper.selectList(
                     new LambdaQueryWrapper<FoodRecommendIdxLog>()
-                            .eq(FoodRecommendIdxLog::getUserId, userId)
+                            .eq(FoodRecommendIdxLog::getUserId, resolved)
                             .orderByDesc(FoodRecommendIdxLog::getChangedAt)
                             .last("LIMIT 120")
             );

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PlannerOfflineCourseService.java

@@ -72,6 +72,13 @@ public class PlannerOfflineCourseService {
         Map<Long, Activity> actMap = activities.stream()
                 .collect(Collectors.toMap(Activity::getId, a -> a, (a, b) -> a));
 
+        // 当前用户已签到的活动ID集合(幂等展示已签到状态)
+        java.util.Set<Long> checkedInIds = activityCheckinMapper.selectList(
+                        new LambdaQueryWrapper<ActivityCheckin>()
+                                .eq(ActivityCheckin::getUserId, userId)
+                                .in(ActivityCheckin::getActivityId, activityIds))
+                .stream().map(ActivityCheckin::getActivityId).collect(Collectors.toSet());
+
         java.util.List<Map<String, Object>> result = new java.util.ArrayList<>();
         for (PlannerOfflineCourseMap map : maps) {
             Activity activity = actMap.get(map.getActivityId());
@@ -90,6 +97,7 @@ public class PlannerOfflineCourseService {
             row.put("categoryName", cat != null ? cat.getName() : "");
             row.put("durationHours", map.getDurationHours());
             row.put("isRequired", map.getIsRequired());
+            row.put("checkedIn", checkedInIds.contains(activity.getId()));
             result.add(row);
         }
         return result;

+ 114 - 2
cfc-backend/src/test/java/com/etotem/cfc/integration/service/FoodRecommendServiceTest.java

@@ -50,6 +50,9 @@ public class FoodRecommendServiceTest {
     @Mock
     private FoodService foodService;
 
+    @Mock
+    private FamilyFoodOverviewService familyFoodOverviewService;
+
     private FoodRecommendService service;
 
     @BeforeEach
@@ -62,6 +65,7 @@ public class FoodRecommendServiceTest {
         setField(service, "familyMemberMapper", familyMemberMapper);
         setField(service, "healthReportService", healthReportService);
         setField(service, "foodService", foodService);
+        setField(service, "familyFoodOverviewService", familyFoodOverviewService);
     }
 
     private static void setField(Object target, String fieldName, Object value) throws Exception {
@@ -164,9 +168,117 @@ public class FoodRecommendServiceTest {
 
         service.recalculateForUser(1L);
 
-        // Both should be deleted and logged
+        // 食材A/B 从最新报告移除 → 2 次 deleteById + 2 条"已移除"日志;
+        // 食材C 是新报告新增 → 另 1 条"首次建立基线"日志。日志合计 3 条。
         verify(foodRecommendIndexMapper, times(2)).deleteById(any(Serializable.class));
-        verify(foodRecommendIdxLogMapper, times(2)).insert(any());
+        verify(foodRecommendIdxLogMapper, times(3)).insert(any());
+    }
+
+    // === T28 方向1:键对齐(AC-09 P0 修复)===
+    // 报告确认链路入参是 family_members.id(memberId),而家庭总览读侧按 family_members.user_id(users.id)
+    // 关联 food_recommend_idx —— 写侧必须把 memberId 归一为 users.id,且 refreshFamilyOverviews 用归一化键。
+
+    @Test
+    public void resolveFoodIndexUserId_memberId_mapsToLoginUser() {
+        when(familyMemberMapper.selectById(1043L)).thenReturn(mockMember(1043L, 81330L));
+
+        Long resolved = service.resolveFoodIndexUserId(1043L);
+
+        assertEquals(81330L, resolved);
+    }
+
+    @Test
+    public void resolveFoodIndexUserId_nonLoginMember_keepsMemberId() {
+        // 纯档案成员(无登录账号):无 users.id 可挂,保留 memberId 键(家庭总览天然排除此类成员)
+        when(familyMemberMapper.selectById(1043L)).thenReturn(mockMember(1043L, null));
+
+        Long resolved = service.resolveFoodIndexUserId(1043L);
+
+        assertEquals(1043L, resolved);
+    }
+
+    @Test
+    public void resolveFoodIndexUserId_alreadyUserId_unchanged() {
+        when(familyMemberMapper.selectById(81330L)).thenReturn(null);
+
+        Long resolved = service.resolveFoodIndexUserId(81330L);
+
+        assertEquals(81330L, resolved);
+    }
+
+    @Test
+    public void recalculateForUser_memberIdInput_writesNormalizedUserIdKey() {
+        // 报告确认链路传 memberId=1043(family_members.id),food_recommend_idx.user_id 必须写 users.id=81330
+        HealthReport report = mockReport(1L, 1L);
+        when(familyMemberMapper.selectById(1043L)).thenReturn(mockMember(1043L, 81330L));
+        when(healthReportService.getLatestReport(1043L)).thenReturn(report);
+        Food food = new Food();
+        food.setId(100L);
+        food.setName("食材A");
+        food.setScore(-25);
+        when(foodService.listByReportId(1L)).thenReturn(Collections.singletonList(food));
+        when(foodRecommendIndexMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        service.recalculateForUser(1043L);
+
+        ArgumentCaptor<FoodRecommendIndex> idxCaptor = ArgumentCaptor.forClass(FoodRecommendIndex.class);
+        verify(foodRecommendIndexMapper).insert(idxCaptor.capture());
+        // AC-09 关键断言:写入键 = users.id(81330),绝不是 memberId(1043)
+        assertEquals(81330L, idxCaptor.getValue().getUserId());
+        assertEquals(-25, idxCaptor.getValue().getIndexScore());
+
+        // 变更日志键同样归一
+        ArgumentCaptor<FoodRecommendIdxLog> logCaptor = ArgumentCaptor.forClass(FoodRecommendIdxLog.class);
+        verify(foodRecommendIdxLogMapper).insert(logCaptor.capture());
+        assertEquals(81330L, logCaptor.getValue().getUserId());
+    }
+
+    @Test
+    public void recalculateForUser_memberIdInput_refreshFamilyOverviewsUsesNormalizedUserId() {
+        // refreshFamilyOverviews 必须用归一化键(users.id)反查家庭,触发家庭总览重算(AC-09 触发链)
+        HealthReport report = mockReport(1L, 1L);
+        FamilyMember loginMember = mockMember(1043L, 81330L); // familyId = 1
+        when(familyMemberMapper.selectById(1043L)).thenReturn(loginMember);
+        when(healthReportService.getLatestReport(1043L)).thenReturn(report);
+        Food food = new Food();
+        food.setId(100L);
+        food.setName("食材A");
+        food.setScore(-25);
+        when(foodService.listByReportId(1L)).thenReturn(Collections.singletonList(food));
+        when(foodRecommendIndexMapper.selectList(any())).thenReturn(Collections.emptyList());
+        // refreshFamilyOverviews(userId=81330) 按 family_members.user_id 反查 → 命中家庭 1
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.singletonList(loginMember));
+
+        service.recalculateForUser(1043L);
+
+        // 触发家庭总览重算 → 证明归一化键反查成功命中家庭
+        verify(familyFoodOverviewService).recalculate(1L);
+
+        // 反查的 where 条件必须按 user_id = users.id(81330),而非 memberId(1043)
+        ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper<FamilyMember>> wrapperCaptor =
+                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
+        verify(familyMemberMapper).selectList(wrapperCaptor.capture());
+        com.baomidou.mybatisplus.core.conditions.AbstractWrapper<FamilyMember, ?, ?> aw =
+                (com.baomidou.mybatisplus.core.conditions.AbstractWrapper<FamilyMember, ?, ?>) wrapperCaptor.getValue();
+        // MP 3.5.x 的 paramNameValuePairs 在 getSqlSegment()/getCustomSqlSegment() 时才惰性填充(eq() 时不写),
+        // 必须先强制生成 SQL 段,再读取参数值,否则拿到的 map 为空。
+        aw.getCustomSqlSegment();
+        java.util.Map<String, Object> params = aw.getParamNameValuePairs();
+        assertTrue(params.values().contains(81330L), "反查应使用归一化 users.id 键");
+        assertFalse(params.values().contains(1043L), "反查不应使用 memberId 键");
+    }
+
+    @Test
+    public void getIndexByUser_memberIdInput_normalizesToUserIdKey() {
+        // 读侧边界归一化:前端传 memberId(family_members.id)时,按 users.id 查询新写入的键对齐数据
+        when(familyMemberMapper.selectById(1043L)).thenReturn(mockMember(1043L, 81330L));
+        when(foodRecommendIndexMapper.selectList(any())).thenReturn(
+                Collections.singletonList(mockIndex(81330L, 100L, "食材A", -25)));
+
+        List<FoodRecommendIndex> result = service.getIndexByUser(1043L);
+
+        assertEquals(1, result.size());
+        assertEquals(81330L, result.get(0).getUserId());
     }
 
     // === getIndexByUser ===

+ 6 - 0
cfc-frontend/pages.json

@@ -166,6 +166,12 @@
           "style": {
             "navigationBarTitleText": "待办服务任务"
           }
+        },
+        {
+          "path": "certification-center",
+          "style": {
+            "navigationBarTitleText": "培训认证中心"
+          }
         }
       ]
     },

+ 7 - 0
cfc-frontend/pages/teacher/teacher-profile.vue

@@ -29,6 +29,10 @@
         <text>📊 我的成长记录</text>
         <text class="arrow">›</text>
       </view>
+      <view class="menu-item" @click="goToCertification">
+        <text>🎓 培训认证中心</text>
+        <text class="arrow">›</text>
+      </view>
 <!-- 聊天功能已隐藏(临时): <view class="menu-item" @click="goToMessages">
 <text>💬 消息中心</text>
 <text class="arrow">›</text>
@@ -123,6 +127,9 @@ export default {
     goToGrowth() {
       uni.reLaunch({ url: '/pages/growth-main/index' })
     },
+    goToCertification() {
+      uni.navigateTo({ url: '/pages/teacher/certification-center' })
+    },
     goToMessages() {
       uni.navigateTo({ url: '/pages/teacher/messages' })
     },

+ 16 - 0
cfc-frontend/utils/api.js

@@ -2993,6 +2993,22 @@ export const getReportSummaryList = (params) => request('/api/report-summary/lis
 export const getReportSummaryDetail = (id) => request('/api/report-summary/detail/' + id, 'POST', {})
 export const downloadReportSummary = (id) => request('/api/report-summary/download/' + id, 'POST', {})
 
+// === 规划师培训认证(W3 规划师认证闸门)===
+// 我的全部类别认证状态
+export const getPlannerCertifications = () => request('/api/planner/certification/my', 'POST', {})
+// 某类别线上课程列表
+export const getPlannerCourses = (categoryId) => request('/api/planner/courses', 'POST', { categoryId })
+// 可报名/签到的培训活动列表
+export const getPlannerTrainingActivities = () => request('/api/planner/training-activities', 'POST', {})
+// 扫码/手动签到 → 桥接发放课时
+export const plannerTrainingCheckIn = (activityId) => request('/api/planner/training-activities/check-in', 'POST', { activityId })
+// 开始某类别考核
+export const plannerExamStart = (categoryId) => request('/api/planner/exam/start', 'POST', { categoryId })
+// 提交考核判分
+export const plannerExamSubmit = (attemptId, answers) => request('/api/planner/exam/submit', 'POST', { attemptId, answers })
+// 考核结果
+export const plannerExamResult = (categoryId) => request('/api/planner/exam/result', 'POST', { categoryId })
+
 // 服务角色统一申请
 export const submitServiceRoleApply = (data) => {
     return request('/api/service-role/apply', 'POST', data)

+ 31 - 0
cfc-web/src/router/index.js

@@ -975,6 +975,37 @@ const routes = [
         name: 'OrgDashboard',
         component: () => import('@/views/admin/org/Dashboard.vue'),
         meta: { title: '组织大屏', perm: 'org:*' }
+      },
+      // ========== 规划师培训认证管理(W3 认证闸门) ==========
+      {
+        path: 'planner-cert-categories',
+        name: 'PlannerCertCategories',
+        component: () => import('@/views/admin/planner-cert/Categories.vue'),
+        meta: { title: '学习种类管理', perm: 'planner:cert' }
+      },
+      {
+        path: 'planner-cert-courses',
+        name: 'PlannerCertCourses',
+        component: () => import('@/views/admin/planner-cert/Courses.vue'),
+        meta: { title: '线上课程管理', perm: 'planner:cert' }
+      },
+      {
+        path: 'planner-cert-offline',
+        name: 'PlannerCertOffline',
+        component: () => import('@/views/admin/planner-cert/OfflineMap.vue'),
+        meta: { title: '线下课程映射', perm: 'planner:cert' }
+      },
+      {
+        path: 'planner-cert-exams',
+        name: 'PlannerCertExams',
+        component: () => import('@/views/admin/planner-cert/Exams.vue'),
+        meta: { title: '考核题库管理', perm: 'planner:cert' }
+      },
+      {
+        path: 'planner-cert-board',
+        name: 'PlannerCertBoard',
+        component: () => import('@/views/admin/planner-cert/CertBoard.vue'),
+        meta: { title: '规划师认证看板', perm: 'planner:cert' }
       }
     ]
   }

+ 10 - 0
cfc-web/src/views/Layout.vue

@@ -277,6 +277,16 @@ export default {
             { path: '/langgraph-admin', label: 'LangGraph 管理', icon: 'el-icon-cpu', perm: 'config:report-parser' },
           ]},
 
+        // ===== 9. 规划师培训认证 (planner:cert) =====
+        { title: '规划师认证', icon: 'el-icon-certificate', perm: 'planner:cert',
+          children: [
+            { path: '/planner-cert-categories', label: '学习种类管理', icon: 'el-icon-folder', perm: 'planner:cert' },
+            { path: '/planner-cert-courses', label: '线上课程管理', icon: 'el-icon-s-goods', perm: 'planner:cert' },
+            { path: '/planner-cert-offline', label: '线下课程映射', icon: 'el-icon-data-line', perm: 'planner:cert' },
+            { path: '/planner-cert-exams', label: '考核题库管理', icon: 'el-icon-document', perm: 'planner:cert' },
+            { path: '/planner-cert-board', label: '规划师认证看板', icon: 'el-icon-chart-bar', perm: 'planner:cert' },
+          ]},
+
         { title: '家庭服务', icon: 'el-icon-s-custom', perm: 'service:*',
           children: [
             { path: '/my-families', label: '我的家庭', icon: 'el-icon-s-custom', perm: 'service:family' },

+ 8 - 1
interface_test_extra_cases.tsv

@@ -1 +1,8 @@
-auto	auto	POST	/api/report-parser/types/links	false	false
+auto	auto	POST	/api/admin/supply-system/my-teams	false	false
+auto	auto	POST	/api/planner/certification/my	false	false
+auto	auto	POST	/api/planner/courses	false	false
+auto	auto	POST	/api/planner/exam/result	false	false
+auto	auto	POST	/api/planner/exam/start	false	false
+auto	auto	POST	/api/planner/exam/submit	false	false
+auto	auto	POST	/api/planner/training-activities	false	false
+auto	auto	POST	/api/planner/training-activities/check-in	false	false