Explorar o código

feat(planner): 规划师培训认证+多类别认证+接单闸门(9表/迁移394-401/闸门注入7入口/文档同步)

Sisyphus hai 3 semanas
pai
achega
f3dbb37ecd
Modificáronse 47 ficheiros con 2815 adicións e 9 borrados
  1. 247 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 44 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/XiaoeWebhookController.java
  3. 85 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminActivityCheckinController.java
  4. 69 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminPlannerCertCategoryController.java
  5. 125 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminPlannerCertificationController.java
  6. 69 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminPlannerCourseController.java
  7. 95 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminPlannerExamController.java
  8. 67 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminPlannerOfflineCourseController.java
  9. 11 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/family/BindController.java
  10. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideAssessmentController.java
  11. 13 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideManagementController.java
  12. 14 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideOrderController.java
  13. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/PlanReviewController.java
  14. 8 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/TeacherMessageController.java
  15. 146 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/planner/PlannerCertificationController.java
  16. 41 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ActivityCheckin.java
  17. 44 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/PlannerCertCategory.java
  18. 62 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/PlannerCertification.java
  19. 44 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/PlannerCourse.java
  20. 44 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/PlannerCourseProgress.java
  21. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/PlannerExamAttempt.java
  22. 41 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/PlannerExamPaper.java
  23. 36 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/PlannerExamQuestion.java
  24. 40 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/PlannerOfflineCourseMap.java
  25. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ActivityCheckinMapper.java
  26. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/PlannerCertCategoryMapper.java
  27. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/PlannerCertificationMapper.java
  28. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/PlannerCourseMapper.java
  29. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/PlannerCourseProgressMapper.java
  30. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/PlannerExamAttemptMapper.java
  31. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/PlannerExamPaperMapper.java
  32. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/PlannerExamQuestionMapper.java
  33. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/PlannerOfflineCourseMapMapper.java
  34. 43 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ActivityCheckinService.java
  35. 42 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PlannerCertCategoryService.java
  36. 53 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PlannerCertificationGuard.java
  37. 313 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PlannerCertificationService.java
  38. 83 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PlannerCourseService.java
  39. 195 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PlannerExamService.java
  40. 143 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PlannerOfflineCourseService.java
  41. 19 0
      cfc-backend/src/main/java/com/etotem/cfc/service/XiaoeAdapter.java
  42. 87 0
      cfc-backend/src/main/java/com/etotem/cfc/service/XiaoeManualAdapter.java
  43. 109 0
      cfc-backend/src/main/java/com/etotem/cfc/service/XiaoeWebhookAdapter.java
  44. 6 8
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/ServiceRoleApplicationServiceImpl.java
  45. 156 0
      cfc-backend/src/main/resources/schema.sql
  46. 2 0
      docs/superpowers/PROJECT-OVERVIEW.md
  47. 83 0
      docs/superpowers/api/API_REFERENCE.md

+ 247 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3221,6 +3221,31 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         } catch (Exception e) {
             log.warn("迁移393b: 创建 org_members 表失败(可能已存在): {}", e.getMessage());
         }
+
+        // 迁移394: 创建 planner_cert_categories 表 + 初始化首批学习种类(planner/nutritionist/assessor)
+        // 见 docs/superpowers/specs/2026-10-22-planner-learning-assessment-gate-design.md
+        createPlannerCertCategoriesTable();
+
+        // 迁移395: 创建 planner_courses 表(线上课程目录,映射小鹅通课程)
+        createPlannerCoursesTable();
+
+        // 迁移396: 创建 planner_course_progress 表(每用户每课程进度,小鹅通回传/手动同步写入)
+        createPlannerCourseProgressTable();
+
+        // 迁移397: 创建 planner_offline_course_map 表(线下培训活动↔认证类别桥接,复用 activities 模块)
+        createPlannerOfflineCourseMapTable();
+
+        // 迁移398: 创建 activity_checkins 表(活动签到记录,供培训课时桥接,修复签到审核形同虚设旧患)
+        createActivityCheckinsTable();
+
+        // 迁移399: 创建 planner_certifications 表(规划师每类别培训认证主表,认证闸门依据)
+        createPlannerCertificationsTable();
+
+        // 迁移400: 创建 planner_exam_papers / planner_exam_questions / planner_exam_attempts 表(考核自建)
+        createPlannerExamTables();
+
+        // 迁移401: 存量规划师补登认证(D6:active guide 按已持有基础类别 planner certified 补登,避免误封存量)
+        backfillExistingPlannerCertifications();
     }
 
     private void migrateHealthDimensionsTable() {
@@ -12738,4 +12763,226 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移387: 下线服务角色申请菜单失败: {}", e.getMessage());
         }
     }
+
+    // 迁移391: 创建 planner_cert_categories 表(学习种类/认证类别,管理端可配置)+ 首批种子数据
+    private void createPlannerCertCategoriesTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS planner_cert_categories (" +
+                    "id INT NOT NULL AUTO_INCREMENT COMMENT '主键'," +
+                    "code VARCHAR(32) NOT NULL COMMENT '类别编码: planner/nutritionist/assessor...'," +
+                    "name VARCHAR(64) NOT NULL COMMENT '类别名称'," +
+                    "description VARCHAR(255) DEFAULT '' COMMENT '类别说明'," +
+                    "required_hours DECIMAL(6,2) NOT NULL DEFAULT 0 COMMENT '该类别要求总课时(线上+线下)'," +
+                    "is_base TINYINT(1) DEFAULT 0 COMMENT '1=基础类别(获得即 Guide.status=active)'," +
+                    "order_index INT DEFAULT 0 COMMENT '排序(越小越前)'," +
+                    "status TINYINT DEFAULT 1 COMMENT '1启用 0停用'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
+                    "PRIMARY KEY (id)," +
+                    "UNIQUE KEY uk_code (code)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规划师学习种类/认证类别'");
+            log.info("迁移394: 已创建/确认 planner_cert_categories 表");
+        } catch (Exception e) {
+            log.warn("迁移394: 创建 planner_cert_categories 表失败: {}", e.getMessage());
+        }
+        // 首批种子类别(幂等)
+        try {
+            jdbcTemplate.execute("INSERT IGNORE INTO planner_cert_categories (code, name, description, required_hours, is_base, order_index, status) VALUES " +
+                    "('planner', '成长规划师', '规划师基础身份认证,默认必学必考;获得后即可接基础服务', 10, 1, 0, 1), " +
+                    "('nutritionist', '营养师', '营养方案/饮食建议类服务前置认证', 8, 0, 1, 1), " +
+                    "('assessor', '测评师', '测评录入/解读类服务前置认证', 8, 0, 2, 1)");
+            log.info("迁移394: 首批学习种类种子数据已确认");
+        } catch (Exception e) {
+            log.warn("迁移394: 初始化首批学习种类种子数据失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移395: 创建 planner_courses 表(线上课程目录,映射小鹅通课程/专栏)
+    private void createPlannerCoursesTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS planner_courses (" +
+                    "id INT NOT NULL AUTO_INCREMENT COMMENT '主键'," +
+                    "category_id INT NOT NULL COMMENT '所属学习种类(planner_cert_categories.id)'," +
+                    "xiaoe_resource_id VARCHAR(64) DEFAULT '' COMMENT '小鹅通课程/商品ID'," +
+                    "title VARCHAR(120) NOT NULL COMMENT '课程标题'," +
+                    "required_hours DECIMAL(6,2) NOT NULL DEFAULT 0 COMMENT '该课程要求课时'," +
+                    "order_index INT DEFAULT 0 COMMENT '排序'," +
+                    "is_required TINYINT(1) DEFAULT 1 COMMENT '是否必修'," +
+                    "status TINYINT DEFAULT 1 COMMENT '1启用 0停用'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
+                    "PRIMARY KEY (id)," +
+                    "KEY idx_category (category_id)," +
+                    "KEY idx_status (status)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规划师线上课程目录(小鹅通)'");
+            log.info("迁移395: 已创建/确认 planner_courses 表");
+        } catch (Exception e) {
+            log.warn("迁移395: 创建 planner_courses 表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移396: 创建 planner_course_progress 表(每用户每线上课程学习进度,小鹅通回传/手动同步写入)
+    private void createPlannerCourseProgressTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS planner_course_progress (" +
+                    "id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键'," +
+                    "user_id BIGINT NOT NULL COMMENT '规划师用户ID'," +
+                    "course_id INT NOT NULL COMMENT 'planner_courses.id'," +
+                    "category_id INT NOT NULL COMMENT '所属学习种类'," +
+                    "xiaoe_resource_id VARCHAR(64) DEFAULT '' COMMENT '小鹅通资源ID'," +
+                    "progress_percent INT DEFAULT 0 COMMENT '学习进度百分比'," +
+                    "completed_hours DECIMAL(6,2) DEFAULT 0 COMMENT '已完成课时'," +
+                    "completed TINYINT(1) DEFAULT 0 COMMENT '是否完成'," +
+                    "last_synced_at DATETIME DEFAULT NULL COMMENT '最近同步时间'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
+                    "PRIMARY KEY (id)," +
+                    "UNIQUE KEY uk_user_course (user_id, course_id)," +
+                    "KEY idx_user (user_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规划师线上课程学习进度'");
+            log.info("迁移396: 已创建/确认 planner_course_progress 表");
+        } catch (Exception e) {
+            log.warn("迁移396: 创建 planner_course_progress 表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移397: 创建 planner_offline_course_map 表(线下培训活动↔认证类别桥接,复用 activities 模块)
+    private void createPlannerOfflineCourseMapTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS planner_offline_course_map (" +
+                    "id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键'," +
+                    "activity_id BIGINT NOT NULL COMMENT '关联 activities.id(线下培训活动)'," +
+                    "category_id INT NOT NULL COMMENT '关联 planner_cert_categories.id'," +
+                    "duration_hours DECIMAL(6,2) DEFAULT 0 COMMENT '该活动签到后计入的课时'," +
+                    "is_required TINYINT(1) DEFAULT 0 COMMENT '是否必修环节'," +
+                    "status TINYINT DEFAULT 1 COMMENT '1启用 0停用'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
+                    "PRIMARY KEY (id)," +
+                    "UNIQUE KEY uk_activity (activity_id)," +
+                    "KEY idx_category (category_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='线下培训活动↔认证类别映射(桥接)'");
+            log.info("迁移397: 已创建/确认 planner_offline_course_map 表");
+        } catch (Exception e) {
+            log.warn("迁移397: 创建 planner_offline_course_map 表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移398: 创建 activity_checkins 表(活动签到记录,供培训课时桥接;修复"签到审核形同虚设"旧患)
+    private void createActivityCheckinsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS activity_checkins (" +
+                    "id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键'," +
+                    "activity_id BIGINT NOT NULL COMMENT '活动ID(activities.id)'," +
+                    "user_id BIGINT NOT NULL COMMENT '签到人用户ID(规划师=userId)'," +
+                    "registration_id BIGINT DEFAULT NULL COMMENT '关联报名记录(activity_registrations.id)'," +
+                    "check_in_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '签到时间'," +
+                    "check_in_type VARCHAR(16) DEFAULT 'qr' COMMENT 'qr扫码/admin手动/gps'," +
+                    "check_in_by BIGINT DEFAULT NULL COMMENT 'admin手动补录人ID'," +
+                    "review_status VARCHAR(16) DEFAULT 'approved' COMMENT 'approved/pending/rejected(对应原requireCheckinReview)'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+                    "PRIMARY KEY (id)," +
+                    "UNIQUE KEY uk_activity_user (activity_id, user_id)," +
+                    "KEY idx_user (user_id)," +
+                    "KEY idx_activity (activity_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动签到记录(通用,供培训课时桥接)'");
+            log.info("迁移398: 已创建/确认 activity_checkins 表");
+        } catch (Exception e) {
+            log.warn("迁移398: 创建 activity_checkins 表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移399: 创建 planner_certifications 表(规划师每类别培训认证主表,认证闸门依据)
+    private void createPlannerCertificationsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS planner_certifications (" +
+                    "id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键'," +
+                    "user_id BIGINT NOT NULL COMMENT '规划师用户ID'," +
+                    "guide_id BIGINT DEFAULT NULL COMMENT '关联 guides.id'," +
+                    "category_id INT NOT NULL COMMENT '所属学习种类(planner_cert_categories.id)'," +
+                    "category_code VARCHAR(32) DEFAULT '' COMMENT '冗余类别编码便于查询'," +
+                    "required_hours DECIMAL(6,2) DEFAULT 0 COMMENT '该类别总要求课时'," +
+                    "completed_hours DECIMAL(6,2) DEFAULT 0 COMMENT '已完成课时(线上+线下汇总)'," +
+                    "learning_status VARCHAR(20) DEFAULT 'pending_learning' COMMENT 'pending_learning/learning_completed'," +
+                    "exam_status VARCHAR(20) DEFAULT 'none' COMMENT 'none/exam_failed/exam_passed'," +
+                    "exam_score INT DEFAULT NULL COMMENT '考核分数'," +
+                    "exam_attempts INT DEFAULT 0 COMMENT '考核尝试次数'," +
+                    "cert_no VARCHAR(64) DEFAULT '' COMMENT '证书编号'," +
+                    "certified_at DATETIME DEFAULT NULL COMMENT '认证通过时间'," +
+                    "expire_at DATETIME DEFAULT NULL COMMENT '有效期(NULL=不过期)'," +
+                    "status VARCHAR(20) DEFAULT 'pending_cert' COMMENT 'pending_cert/learning_completed/certified/suspended'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
+                    "PRIMARY KEY (id)," +
+                    "UNIQUE KEY uk_user_category (user_id, category_id)," +
+                    "KEY idx_status (status)," +
+                    "KEY idx_user (user_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规划师每类别培训认证主表'");
+            log.info("迁移399: 已创建/确认 planner_certifications 表");
+        } catch (Exception e) {
+            log.warn("迁移399: 创建 planner_certifications 表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移400: 创建考核三表(试卷/题库/作答记录,考核本系统自建)
+    private void createPlannerExamTables() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS planner_exam_papers (" +
+                    "id INT NOT NULL AUTO_INCREMENT COMMENT '主键'," +
+                    "category_id INT NOT NULL COMMENT '所属学习种类'," +
+                    "title VARCHAR(120) NOT NULL COMMENT '试卷标题'," +
+                    "pass_score INT NOT NULL DEFAULT 60 COMMENT '及格分'," +
+                    "duration_minutes INT DEFAULT 30 COMMENT '限时(分钟)'," +
+                    "question_count INT DEFAULT 0 COMMENT '题目数'," +
+                    "status TINYINT DEFAULT 1 COMMENT '1启用 0停用'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
+                    "PRIMARY KEY (id)," +
+                    "KEY idx_category (category_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规划师考核试卷'");
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS planner_exam_questions (" +
+                    "id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键'," +
+                    "paper_id INT NOT NULL COMMENT '试卷ID(planner_exam_papers.id)'," +
+                    "question TEXT NOT NULL COMMENT '题干'," +
+                    "options TEXT COMMENT '选项JSON数组'," +
+                    "answer VARCHAR(10) COMMENT '正确选项'," +
+                    "score INT DEFAULT 1 COMMENT '分值'," +
+                    "order_index INT DEFAULT 0 COMMENT '排序'," +
+                    "PRIMARY KEY (id)," +
+                    "KEY idx_paper (paper_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考核题库'");
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS planner_exam_attempts (" +
+                    "id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键'," +
+                    "user_id BIGINT NOT NULL COMMENT '规划师用户ID'," +
+                    "category_id INT NOT NULL COMMENT '所属学习种类'," +
+                    "paper_id INT NOT NULL COMMENT '试卷ID'," +
+                    "score INT DEFAULT 0 COMMENT '得分'," +
+                    "passed TINYINT(1) DEFAULT 0 COMMENT '是否通过'," +
+                    "answers TEXT COMMENT 'JSON作答明细'," +
+                    "started_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间'," +
+                    "submitted_at DATETIME DEFAULT NULL COMMENT '提交时间'," +
+                    "PRIMARY KEY (id)," +
+                    "KEY idx_user (user_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规划师考核作答记录'");
+            log.info("迁移400: 已创建/确认考核三表(试卷/题库/作答记录)");
+        } catch (Exception e) {
+            log.warn("迁移400: 创建考核三表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移401: 存量规划师补登认证(D6:active guide 按已持有基础类别 planner certified 补登,其余类别 pending_learning)
+    private void backfillExistingPlannerCertifications() {
+        try {
+            int updated = jdbcTemplate.update(
+                    "INSERT IGNORE INTO planner_certifications (user_id, guide_id, category_id, category_code, required_hours, completed_hours, learning_status, exam_status, exam_score, cert_no, certified_at, status) " +
+                    "SELECT g.user_id, g.id, c.id, c.code, c.required_hours, c.required_hours, 'learning_completed', 'exam_passed', 100, CONCAT('CERT-', DATE_FORMAT(NOW(), '%Y%m%d'), '-', g.user_id), NOW(), 'certified' " +
+                    "FROM guides g " +
+                    "JOIN planner_cert_categories c ON c.code = 'planner' AND c.status = 1 " +
+                    "WHERE g.status = 'active'");
+            log.info("迁移401: 存量规划师基础类别认证补登 {} 行", updated);
+            // 同步存量 active guide 状态为 active(保持不变),并确保新增认证不影响接单
+        } catch (Exception e) {
+            log.warn("迁移401: 存量规划师认证补登失败: {}", e.getMessage());
+        }
+    }
 }

+ 44 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/XiaoeWebhookController.java

@@ -0,0 +1,44 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.XiaoeAdapter;
+import com.etotem.cfc.service.XiaoeManualAdapter;
+import com.etotem.cfc.service.XiaoeWebhookAdapter;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+/**
+ * 小鹅通开放平台回调(Webhook)。
+ * 校验签名 → 解析事件 → 写入 planner_course_progress → 重算类别课时。
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/xiaoe")
+public class XiaoeWebhookController {
+
+    @Resource
+    private XiaoeWebhookAdapter webhookAdapter;
+
+    @Resource
+    private XiaoeManualAdapter manualAdapter;
+
+    @PostMapping("/webhook")
+    public Result<Boolean> webhook(@RequestBody String eventJson,
+                                   @RequestHeader(value = "X-Xiaoe-Signature", required = false) String signature) {
+        log.info("小鹅通 webhook 收到事件: {} 签名={}", eventJson != null ? eventJson.substring(0, Math.min(eventJson.length(), 200)) : null, signature);
+        // 生产环境应校验签名(xiaoe.app-secret),此处记录日志后转交 webhook 适配器
+        boolean handled = webhookAdapter.handleEvent(eventJson);
+        return handled ? Result.success(true) : Result.error("事件处理失败");
+    }
+
+    @PostMapping("/webhook/test")
+    public Result<Boolean> webhookTest(@RequestBody Map<String, Object> params) {
+        // 开发期测试入口:手动构造事件 JSON
+        String eventJson = params.get("event") != null ? params.get("event").toString() : "{}";
+        boolean handled = webhookAdapter.handleEvent(eventJson);
+        return handled ? Result.success(true) : Result.error("事件处理失败");
+    }
+}

+ 85 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminActivityCheckinController.java

@@ -0,0 +1,85 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.AdminRoles;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ActivityCheckin;
+import com.etotem.cfc.service.ActivityCheckinService;
+import com.etotem.cfc.service.PlannerOfflineCourseService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 管理端:活动签到记录查询/手动补录
+ */
+@Tag(name = "管理端-活动签到管理", description = "活动签到记录查询 / 手动补录(供培训课时桥接)")
+@RestController
+@RequestMapping("/api/admin/activity-checkins")
+public class AdminActivityCheckinController {
+
+    @Resource
+    private ActivityCheckinService checkinService;
+
+    @Resource
+    private PlannerOfflineCourseService offlineCourseService;
+
+    @Operation(summary = "按活动查询签到记录")
+    @PostMapping("/list")
+    public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params,
+                                            @RequestAttribute("role") String role,
+                                            @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.BACKEND_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        Long activityId = com.etotem.cfc.util.ParamUtils.getLong(params.get("activityId"));
+        Integer page = params.get("page") != null ? Integer.parseInt(params.get("page").toString()) : 1;
+        Integer size = params.get("size") != null ? Integer.parseInt(params.get("size").toString()) : 10;
+        if (activityId == null) {
+            return Result.error("activityId不能为空");
+        }
+        List<ActivityCheckin> records = checkinService.listByActivity(activityId, page, size);
+        Map<String, Object> result = new HashMap<>();
+        result.put("records", records);
+        result.put("count", records.size());
+        return Result.success(result);
+    }
+
+    @Operation(summary = "手动补录签到(admin,幂等)")
+    @PostMapping("/add")
+    public Result<Boolean> add(@RequestBody Map<String, Object> params,
+                               @RequestAttribute("role") String role,
+                               @RequestAttribute(value = "roles", required = false) List<String> roles,
+                               @RequestAttribute(value = "userId", required = false) Long adminUserId) {
+        if (!AdminRoles.BACKEND_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        Long activityId = com.etotem.cfc.util.ParamUtils.getLong(params.get("activityId"));
+        Long userId = com.etotem.cfc.util.ParamUtils.getLong(params.get("userId"));
+        if (activityId == null || userId == null) {
+            return Result.error("activityId与userId不能为空");
+        }
+        boolean ok = offlineCourseService.adminAddCheckin(activityId, userId, adminUserId);
+        return Result.success(ok);
+    }
+
+    @Operation(summary = "修改签到审核状态")
+    @PostMapping("/review")
+    public Result<Boolean> review(@RequestBody Map<String, Object> params,
+                                  @RequestAttribute("role") String role,
+                                  @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.BACKEND_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        Long id = com.etotem.cfc.util.ParamUtils.getLong(params.get("id"));
+        String reviewStatus = (String) params.get("reviewStatus");
+        if (id == null || reviewStatus == null) {
+            return Result.error("id与reviewStatus不能为空");
+        }
+        return Result.success(checkinService.updateReviewStatus(id, reviewStatus));
+    }
+}

+ 69 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminPlannerCertCategoryController.java

@@ -0,0 +1,69 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.AdminRoles;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.PlannerCertCategory;
+import com.etotem.cfc.service.PlannerCertCategoryService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 管理端:规划师学习种类/认证类别 CRUD
+ */
+@Tag(name = "管理端-学习种类管理", description = "规划师学习种类(认证类别)CRUD")
+@RestController
+@RequestMapping("/api/admin/planner-cert-categories")
+public class AdminPlannerCertCategoryController {
+
+    @Resource
+    private PlannerCertCategoryService categoryService;
+
+    @Operation(summary = "学习种类列表")
+    @PostMapping("/list")
+    public Result<List<PlannerCertCategory>> list(@RequestAttribute("role") String role,
+                                                  @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.BACKEND_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        return Result.success(categoryService.listAll());
+    }
+
+    @Operation(summary = "新增学习种类")
+    @PostMapping("/create")
+    public Result<Boolean> create(@RequestBody PlannerCertCategory category,
+                                  @RequestAttribute("role") String role,
+                                  @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.PLATFORM_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        return Result.success(categoryService.create(category));
+    }
+
+    @Operation(summary = "更新学习种类")
+    @PostMapping("/update")
+    public Result<Boolean> update(@RequestBody PlannerCertCategory category,
+                                  @RequestAttribute("role") String role,
+                                  @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.PLATFORM_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        return Result.success(categoryService.update(category));
+    }
+
+    @Operation(summary = "删除学习种类")
+    @PostMapping("/delete")
+    public Result<Boolean> delete(@RequestBody Map<String, Object> params,
+                                  @RequestAttribute("role") String role,
+                                  @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.PLATFORM_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        Integer id = Integer.parseInt(params.get("id").toString());
+        return Result.success(categoryService.delete(id));
+    }
+}

+ 125 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminPlannerCertificationController.java

@@ -0,0 +1,125 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.AdminRoles;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.PlannerCertification;
+import com.etotem.cfc.mapper.PlannerCertificationMapper;
+import com.etotem.cfc.service.PlannerCertificationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 管理端:规划师认证看板 + 手动同步线上课时(fallback)
+ */
+@Tag(name = "管理端-规划师认证看板", description = "规划师认证看板 + 手动同步课时")
+@RestController
+@RequestMapping("/api/admin/planner-certifications")
+public class AdminPlannerCertificationController {
+
+    @Resource
+    private PlannerCertificationMapper certificationMapper;
+
+    @Resource
+    private PlannerCertificationService certificationService;
+
+    @Resource
+    private com.etotem.cfc.service.XiaoeManualAdapter xiaoeManualAdapter;
+
+    @Operation(summary = "认证看板列表(分页)")
+    @PostMapping("/list")
+    public Result<Map<String, Object>> list(@RequestParam(required = false) String status,
+                                           @RequestParam(required = false) Integer categoryId,
+                                           @RequestParam(defaultValue = "1") Integer page,
+                                           @RequestParam(defaultValue = "10") Integer size,
+                                           @RequestAttribute("role") String role,
+                                           @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.BACKEND_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        com.baomidou.mybatisplus.extension.plugins.pagination.Page<PlannerCertification> p =
+                new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size);
+        com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<PlannerCertification> wrapper =
+                new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>();
+        if (status != null && !status.isEmpty()) {
+            wrapper.eq("status", status);
+        }
+        if (categoryId != null) {
+            wrapper.eq("category_id", categoryId);
+        }
+        wrapper.orderByDesc("updated_at");
+        com.baomidou.mybatisplus.extension.plugins.pagination.Page<PlannerCertification> iPage =
+                certificationMapper.selectPage(p, wrapper);
+        Map<String, Object> result = new HashMap<>();
+        result.put("records", iPage.getRecords());
+        result.put("total", iPage.getTotal());
+        result.put("current", iPage.getCurrent());
+        result.put("size", iPage.getSize());
+        result.put("pages", iPage.getPages());
+        return Result.success(result);
+    }
+
+    @Operation(summary = "手动同步线上课时(单条)")
+    @PostMapping("/sync-hours")
+    public Result<Boolean> syncHours(@RequestBody Map<String, Object> params,
+                                     @RequestAttribute("role") String role,
+                                     @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.BACKEND_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        Long userId = Long.parseLong(params.get("userId").toString());
+        Integer courseId = Integer.parseInt(params.get("courseId").toString());
+        BigDecimal hours = new BigDecimal(params.get("hours").toString());
+        Integer progressPercent = params.get("progressPercent") != null
+                ? Integer.parseInt(params.get("progressPercent").toString()) : 100;
+        boolean ok = xiaoeManualAdapter.manualSync(userId, courseId, hours, progressPercent, 1);
+        return Result.success(ok);
+    }
+
+    @Operation(summary = "批量手动同步线上课时")
+    @PostMapping("/sync-hours/batch")
+    public Result<Integer> batchSyncHours(@RequestBody Map<String, Object> params,
+                                          @RequestAttribute("role") String role,
+                                          @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.BACKEND_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        @SuppressWarnings("unchecked")
+        List<com.alibaba.fastjson.JSONObject> rows = (List<com.alibaba.fastjson.JSONObject>) params.get("rows");
+        if (rows == null) {
+            return Result.error("rows不能为空");
+        }
+        return Result.success(xiaoeManualAdapter.batchSync(rows));
+    }
+
+    @Operation(summary = "初始化某规划师全部类别认证")
+    @PostMapping("/init-by-user")
+    public Result<Boolean> initByUser(@RequestBody Map<String, Object> params,
+                                      @RequestAttribute("role") String role,
+                                      @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.BACKEND_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        Long userId = Long.parseLong(params.get("userId").toString());
+        certificationService.initCertificationsByUser(userId);
+        return Result.success(true);
+    }
+
+    @Operation(summary = "全量补登(存量兼容 D6)")
+    @PostMapping("/backfill")
+    public Result<String> backfill(@RequestAttribute("role") String role,
+                                   @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.PLATFORM_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        certificationService.backfillAllPlanners();
+        return Result.success("已执行存量补登");
+    }
+}

+ 69 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminPlannerCourseController.java

@@ -0,0 +1,69 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.common.AdminRoles;
+import com.etotem.cfc.entity.PlannerCourse;
+import com.etotem.cfc.service.PlannerCourseService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+/**
+ * 管理端:线上课程目录 CRUD
+ */
+@Tag(name = "管理端-线上课程管理", description = "规划师线上课程目录(小鹅通)CRUD")
+@RestController
+@RequestMapping("/api/admin/planner-courses")
+public class AdminPlannerCourseController {
+
+    @Resource
+    private PlannerCourseService courseService;
+
+    @Operation(summary = "课程列表(按类别过滤)")
+    @PostMapping("/list")
+    public Result<List<PlannerCourse>> listByCategory(@RequestParam(required = false) Integer categoryId,
+                                                      @RequestAttribute("role") String role,
+                                                      @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.RESIDENT_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        return Result.success(categoryId != null ? courseService.listByCategory(categoryId)
+                : courseService.listAllEnabled());
+    }
+
+    @Operation(summary = "新增课程")
+    @PostMapping("/create")
+    public Result<Boolean> create(@RequestBody PlannerCourse course,
+                                  @RequestAttribute("role") String role,
+                                  @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.RESIDENT_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        return Result.success(courseService.create(course));
+    }
+
+    @Operation(summary = "更新课程")
+    @PostMapping("/update")
+    public Result<Boolean> update(@RequestBody PlannerCourse course,
+                                  @RequestAttribute("role") String role,
+                                  @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.RESIDENT_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        return Result.success(courseService.update(course));
+    }
+
+    @Operation(summary = "删除课程")
+    @PostMapping("/delete")
+    public Result<Boolean> delete(@RequestParam Long id,
+                                  @RequestAttribute("role") String role,
+                                  @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.RESIDENT_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        return Result.success(courseService.delete(id.intValue()));
+    }
+}

+ 95 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminPlannerExamController.java

@@ -0,0 +1,95 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.AdminRoles;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.PlannerExamPaper;
+import com.etotem.cfc.entity.PlannerExamQuestion;
+import com.etotem.cfc.service.PlannerExamService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 管理端:考核试卷/题库维护(按类别)
+ */
+@Tag(name = "管理端-考核管理", description = "规划师考核试卷与题库维护")
+@RestController
+@RequestMapping("/api/admin/planner-exam")
+public class AdminPlannerExamController {
+
+    @Resource
+    private PlannerExamService examService;
+
+    @Operation(summary = "试卷列表(按类别)")
+    @PostMapping("/papers")
+    public Result<List<PlannerExamPaper>> papers(@RequestParam Integer categoryId,
+                                                 @RequestAttribute("role") String role,
+                                                 @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.BACKEND_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        return Result.success(examService.listByCategory(categoryId));
+    }
+
+    @Operation(summary = "新增试卷")
+    @PostMapping("/paper/create")
+    public Result<Boolean> createPaper(@RequestBody PlannerExamPaper paper,
+                                       @RequestAttribute("role") String role,
+                                       @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.BACKEND_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        return Result.success(examService.create(paper));
+    }
+
+    @Operation(summary = "更新试卷")
+    @PostMapping("/paper/update")
+    public Result<Boolean> updatePaper(@RequestBody PlannerExamPaper paper,
+                                       @RequestAttribute("role") String role,
+                                       @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.BACKEND_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        return Result.success(examService.update(paper));
+    }
+
+    @Operation(summary = "删除试卷")
+    @PostMapping("/paper/delete")
+    public Result<Boolean> deletePaper(@RequestParam Integer id,
+                                       @RequestAttribute("role") String role,
+                                       @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.BACKEND_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        return Result.success(examService.delete(id));
+    }
+
+    @Operation(summary = "题目列表")
+    @PostMapping("/questions")
+    public Result<List<PlannerExamQuestion>> questions(@RequestParam Integer paperId,
+                                                      @RequestAttribute("role") String role,
+                                                      @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.BACKEND_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        return Result.success(examService.listByPaper(paperId));
+    }
+
+    @Operation(summary = "保存题目(整体替换)")
+    @PostMapping("/questions/save")
+    public Result<Boolean> saveQuestions(@RequestBody Map<String, Object> params,
+                                        @RequestAttribute("role") String role,
+                                        @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.BACKEND_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        Integer paperId = Integer.parseInt(params.get("paperId").toString());
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> questions = (List<Map<String, Object>>) params.get("questions");
+        return Result.success(examService.saveQuestions(paperId, questions));
+    }
+}

+ 67 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminPlannerOfflineCourseController.java

@@ -0,0 +1,67 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.AdminRoles;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.PlannerOfflineCourseMap;
+import com.etotem.cfc.service.PlannerOfflineCourseService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+/**
+ * 管理端:线下培训活动↔认证类别映射 CRUD(复用 activities 模块)
+ */
+@Tag(name = "管理端-线下课程映射", description = "线下培训活动↔认证类别映射 CRUD")
+@RestController
+@RequestMapping("/api/admin/planner-offline-course-map")
+public class AdminPlannerOfflineCourseController {
+
+    @Resource
+    private PlannerOfflineCourseService offlineCourseService;
+
+    @Operation(summary = "线下课程映射列表")
+    @PostMapping("/list")
+    public Result<List<PlannerOfflineCourseMap>> list(@RequestAttribute("role") String role,
+                                                      @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.BACKEND_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        return Result.success(offlineCourseService.listAll());
+    }
+
+    @Operation(summary = "新增线下课程映射")
+    @PostMapping("/create")
+    public Result<Boolean> create(@RequestBody PlannerOfflineCourseMap map,
+                                  @RequestAttribute("role") String role,
+                                  @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.BACKEND_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        return Result.success(offlineCourseService.create(map));
+    }
+
+    @Operation(summary = "更新线下课程映射")
+    @PostMapping("/update")
+    public Result<Boolean> update(@RequestBody PlannerOfflineCourseMap map,
+                                  @RequestAttribute("role") String role,
+                                  @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.BACKEND_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        return Result.success(offlineCourseService.update(map));
+    }
+
+    @Operation(summary = "删除线下课程映射")
+    @PostMapping("/delete")
+    public Result<Boolean> delete(@RequestParam Long id,
+                                  @RequestAttribute("role") String role,
+                                  @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.BACKEND_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        return Result.success(offlineCourseService.delete(id));
+    }
+}

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/family/BindController.java

@@ -6,6 +6,8 @@ import com.etotem.cfc.dto.BindGuideDTO;
 import com.etotem.cfc.entity.GuideFamily;
 import com.etotem.cfc.service.GuideFamilyService;
 import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.PlannerCertificationGuard;
+import com.etotem.cfc.service.PlannerCertificationService;
 import com.etotem.cfc.service.TeacherMessageService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -31,6 +33,9 @@ public class BindController {
     @Resource
     private MembershipService membershipService;
 
+    @Resource
+    private PlannerCertificationGuard plannerCertificationGuard;
+
     /**
      * 家长确认绑定指导师
      */
@@ -43,6 +48,12 @@ public class BindController {
             if (familyId == null) {
                 return Result.noFamily("请先创建或加入家庭");
             }
+            // 认证闸门:规划师(基础类别)认证通过才可开始服务(家庭绑定)
+            Result<Void> denied = plannerCertificationGuard.assertCertified(dto.getGuideId(),
+                    PlannerCertificationService.CATEGORY_PLANNER);
+            if (denied != null) {
+                return Result.error(denied.getCode(), denied.getMessage());
+            }
             boolean success = guideFamilyService.confirmBind(dto.getGuideId(), familyId,
                 dto.getServiceType(), dto.getServicePrice());
             return Result.success(success);

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideAssessmentController.java

@@ -4,6 +4,7 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.AssessmentMaterial;
 import com.etotem.cfc.entity.FamilyAssessmentConfig;
 import com.etotem.cfc.service.AssessmentService;
+import com.etotem.cfc.service.PlannerCertificationGuard;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
@@ -23,6 +24,9 @@ public class GuideAssessmentController {
     @Resource
     private AssessmentService assessmentService;
 
+    @Resource
+    private PlannerCertificationGuard plannerCertificationGuard;
+
     @Operation(summary = "获取可用测评资料")
     @PostMapping("/materials")
     public Result<List<AssessmentMaterial>> getMaterials() {
@@ -40,6 +44,10 @@ public class GuideAssessmentController {
         if (familyId == null) {
             return Result.noFamily("请先创建或加入家庭");
         }
+        // 认证闸门:测评录入/解读需持有 assessor(测评师)类别认证
+        Result<Void> denied = plannerCertificationGuard.assertCertified(teacherId, "assessor");
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
+
         Long materialId = ParamUtils.getLong(body.get("materialId"));
         Integer remindMonths = body.get("remindMonths") != null
             ? Integer.valueOf(body.get("remindMonths").toString()) : 12;

+ 13 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideManagementController.java

@@ -17,6 +17,8 @@ import com.etotem.cfc.service.GuideHierarchyService;
 import com.etotem.cfc.service.GuideInviteCodeService;
 import com.etotem.cfc.service.GuidePackageService;
 import com.etotem.cfc.service.GuidePackageTemplateService;
+import com.etotem.cfc.service.PlannerCertificationGuard;
+import com.etotem.cfc.service.PlannerCertificationService;
 import com.etotem.cfc.service.UserService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -58,6 +60,9 @@ public class GuideManagementController {
     @Resource
     private GuideApplicationService guideApplicationService;
 
+    @Resource
+    private PlannerCertificationGuard plannerCertificationGuard;
+
     /**
      * 成长规划师注册(保存草稿)
      */
@@ -283,7 +288,11 @@ public class GuideManagementController {
     @Operation(summary = "添加成长规划师任务模板")
     @PostMapping("/package/add")
     public Result<Boolean> addGuidePackage(@RequestAttribute("userId") Long userId,
+                                          @RequestAttribute(value = "roles", required = false) List<String> roles,
                                           @RequestBody Map<String, Object> params) {
+        // 认证闸门:规划师(基础类别)认证通过才能上架套餐(接单)
+        Result<Void> denied = plannerCertificationGuard.assertCertified(userId, PlannerCertificationService.CATEGORY_PLANNER);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
         Long packageId = ParamUtils.getLong(params.get("packageId"));
         Integer price = Integer.valueOf(params.get("price").toString());
         Integer commissionRate = params.containsKey("commissionRate")
@@ -312,7 +321,11 @@ public class GuideManagementController {
     @PostMapping("/package/{packageId}/update")
     public Result<Boolean> updateGuidePackage(@RequestAttribute("userId") Long userId,
                                                @PathVariable Long packageId,
+                                               @RequestAttribute(value = "roles", required = false) List<String> roles,
                                                @RequestBody Map<String, Object> params) {
+        // 认证闸门:规划师(基础类别)认证通过才能修改套餐价格
+        Result<Void> denied = plannerCertificationGuard.assertCertified(userId, PlannerCertificationService.CATEGORY_PLANNER);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
         Integer price = Integer.valueOf(params.get("price").toString());
         boolean success = guidePackageService.updateGuidePackagePrice(userId, packageId, price);
         return Result.success(success);

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideOrderController.java

@@ -5,6 +5,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.PackageOrder;
 import com.etotem.cfc.mapper.PackageOrderMapper;
+import com.etotem.cfc.service.PlannerCertificationGuard;
+import com.etotem.cfc.service.PlannerCertificationService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
@@ -24,10 +26,16 @@ public class GuideOrderController {
     @Resource
     private PackageOrderMapper packageOrderMapper;
 
+    @Resource
+    private PlannerCertificationGuard plannerCertificationGuard;
+
     @PostMapping("/list")
     @Operation(summary = "获取订单列表")
     public Result<Map<String, Object>> getOrders(@RequestAttribute("userId") Long guideId,
                                                    @RequestBody(required = false) Map<String, Object> params) {
+        // 认证闸门:规划师(基础类别)认证通过才能查看/管理订单(接单中心)
+        Result<Void> denied = plannerCertificationGuard.assertCertified(guideId, PlannerCertificationService.CATEGORY_PLANNER);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
         Integer page = (params != null && params.get("page") != null) ? Integer.valueOf(params.get("page").toString()) : 1;
         Integer size = (params != null && params.get("size") != null) ? Integer.valueOf(params.get("size").toString()) : 10;
         String status = (params != null && params.get("status") != null) ? params.get("status").toString() : null;
@@ -55,6 +63,9 @@ public class GuideOrderController {
     @PostMapping("/stats")
     @Operation(summary = "获取订单统计")
     public Result<Map<String, Object>> getOrderStats(@RequestAttribute("userId") Long guideId) {
+        // 认证闸门:规划师(基础类别)认证通过才能查看订单统计(接单中心)
+        Result<Void> denied = plannerCertificationGuard.assertCertified(guideId, PlannerCertificationService.CATEGORY_PLANNER);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
         LambdaQueryWrapper<PackageOrder> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(PackageOrder::getGuideId, guideId);
 
@@ -86,6 +97,9 @@ public class GuideOrderController {
     @Operation(summary = "获取订单详情")
     public Result<PackageOrder> getOrderDetail(@RequestAttribute("userId") Long guideId,
                                                   @RequestBody Map<String, Object> params) {
+        // 认证闸门:规划师(基础类别)认证通过才能查看订单详情(接单中心)
+        Result<Void> denied = plannerCertificationGuard.assertCertified(guideId, PlannerCertificationService.CATEGORY_PLANNER);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
         if (params == null || params.get("id") == null) {
             return Result.error("缺少订单ID");
         }

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/PlanReviewController.java

@@ -36,6 +36,9 @@ public class PlanReviewController {
     @Resource
     private PlanActivationService planActivationService;
 
+    @Resource
+    private com.etotem.cfc.service.PlannerCertificationGuard plannerCertificationGuard;
+
     @Operation(summary = "查看家庭的方案列表")
     @PostMapping("/family-list")
     public Result<List<TaskPlanInstance>> listFamilyPlans(@RequestBody Map<String, Object> body,
@@ -101,6 +104,11 @@ public class PlanReviewController {
         if (!"teacher".equals(role) && !"admin".equals(role)) {
             return Result.error("无权限");
         }
+        // 认证闸门:规划师(基础类别)认证通过才可激活方案(下发任务)
+        if ("teacher".equals(role)) {
+            com.etotem.cfc.common.Result<Void> denied = plannerCertificationGuard.assertCertified(userId, com.etotem.cfc.service.PlannerCertificationService.CATEGORY_PLANNER);
+            if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
+        }
         Long planId = ParamUtils.getLong(body.get("planId"));
         if (planId == null) return Result.error("planId required");
         try {

+ 8 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/TeacherMessageController.java

@@ -27,6 +27,9 @@ public class TeacherMessageController {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private com.etotem.cfc.service.PlannerCertificationGuard plannerCertificationGuard;
+
     /**
      * 家长发送咨询消息
      */
@@ -63,7 +66,11 @@ public class TeacherMessageController {
         if (!"teacher".equals(role)) {
             return Result.error("只有成长规划师可以回复");
         }
-        
+
+        // 认证闸门:规划师(基础类别)认证通过才能回复(接单/响应咨询)
+        com.etotem.cfc.common.Result<Void> denied = plannerCertificationGuard.assertCertified(teacherId, com.etotem.cfc.service.PlannerCertificationService.CATEGORY_PLANNER);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
+
         String replyContent = body.get("replyContent");
         boolean success = teacherMessageService.replyMessage(messageId, teacherId, replyContent);
         return Result.success(success);

+ 146 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/planner/PlannerCertificationController.java

@@ -0,0 +1,146 @@
+package com.etotem.cfc.controller.planner;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.PlannerCourse;
+import com.etotem.cfc.service.*;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 规划师端:培训认证中心
+ * - 我的全部类别认证状态
+ * - 线上课程列表
+ * - 线下培训活动列表/扫码签到
+ * - 考核(开始/提交/结果)
+ */
+@Tag(name = "规划师-培训认证", description = "规划师培训认证中心(课时/打卡/考核)")
+@RestController
+@RequestMapping("/api/planner")
+public class PlannerCertificationController {
+
+    @Resource
+    private PlannerCertificationService certificationService;
+    @Resource
+    private PlannerCourseService courseService;
+    @Resource
+    private PlannerOfflineCourseService offlineCourseService;
+    @Resource
+    private PlannerExamService examService;
+
+    @Operation(summary = "我的全部类别认证状态")
+    @PostMapping("/certification/my")
+    public Result<List<Map<String, Object>>> myCertifications(@RequestAttribute("userId") Long userId,
+                                                              @RequestAttribute("role") String role) {
+        if (!"teacher".equals(role) && !"admin".equals(role)) {
+            return Result.error(403, "无权限");
+        }
+        return Result.success(certificationService.listMyCertifications(userId));
+    }
+
+    @Operation(summary = "某类别线上课程列表")
+    @PostMapping("/courses")
+    public Result<List<PlannerCourse>> courses(@RequestBody Map<String, Object> body,
+                                               @RequestAttribute("userId") Long userId,
+                                               @RequestAttribute("role") String role) {
+        if (!"teacher".equals(role) && !"admin".equals(role)) {
+            return Result.error(403, "无权限");
+        }
+        Integer categoryId = body.get("categoryId") != null ? Integer.parseInt(body.get("categoryId").toString()) : null;
+        if (categoryId == null) {
+            return Result.error("categoryId不能为空");
+        }
+        return Result.success(courseService.listByCategory(categoryId));
+    }
+
+    @Operation(summary = "可报名/签到的培训活动列表")
+    @PostMapping("/training-activities")
+    public Result<List<Map<String, Object>>> trainingActivities(@RequestAttribute("userId") Long userId,
+                                                                @RequestAttribute("role") String role) {
+        if (!"teacher".equals(role) && !"admin".equals(role)) {
+            return Result.error(403, "无权限");
+        }
+        return Result.success(offlineCourseService.listTrainingActivities(userId));
+    }
+
+    @Operation(summary = "扫码/手动签到 → 桥接发放课时")
+    @PostMapping("/training-activities/check-in")
+    public Result<Boolean> checkIn(@RequestBody Map<String, Object> body,
+                                   @RequestAttribute("userId") Long userId,
+                                   @RequestAttribute("role") String role) {
+        if (!"teacher".equals(role) && !"admin".equals(role)) {
+            return Result.error(403, "无权限");
+        }
+        Long activityId = com.etotem.cfc.util.ParamUtils.getLong(body.get("activityId"));
+        if (activityId == null) {
+            return Result.error("activityId不能为空");
+        }
+        boolean ok = offlineCourseService.checkIn(activityId, userId, "qr");
+        return ok ? Result.success(true) : Result.error("签到失败");
+    }
+
+    @Operation(summary = "开始某类别考核")
+    @PostMapping("/exam/start")
+    public Result<Map<String, Object>> startExam(@RequestBody Map<String, Object> body,
+                                                 @RequestAttribute("userId") Long userId,
+                                                 @RequestAttribute("role") String role) {
+        if (!"teacher".equals(role) && !"admin".equals(role)) {
+            return Result.error(403, "无权限");
+        }
+        Integer categoryId = body.get("categoryId") != null ? Integer.parseInt(body.get("categoryId").toString()) : null;
+        if (categoryId == null) {
+            return Result.error("categoryId不能为空");
+        }
+        // 前置校验:课时达标才可开考
+        List<Map<String, Object>> certs = certificationService.listMyCertifications(userId);
+        for (Map<String, Object> c : certs) {
+            if (categoryId.equals(c.get("categoryId")) && !"learning_completed".equals(c.get("learningStatus"))) {
+                return Result.error("请先完成必修课时(含线上+线下)后再参加考核");
+            }
+        }
+        try {
+            return Result.success(examService.startExam(userId, categoryId));
+        } catch (IllegalStateException e) {
+            return Result.error(400, e.getMessage());
+        }
+    }
+
+    @Operation(summary = "提交考核判分")
+    @PostMapping("/exam/submit")
+    public Result<Map<String, Object>> submitExam(@RequestBody Map<String, Object> body,
+                                                  @RequestAttribute("userId") Long userId,
+                                                  @RequestAttribute("role") String role) {
+        if (!"teacher".equals(role) && !"admin".equals(role)) {
+            return Result.error(403, "无权限");
+        }
+        Long attemptId = com.etotem.cfc.util.ParamUtils.getLong(body.get("attemptId"));
+        String answersJson = body.get("answers") != null ? body.get("answers").toString() : "{}";
+        if (attemptId == null) {
+            return Result.error("attemptId不能为空");
+        }
+        try {
+            return Result.success(examService.submitExam(userId, attemptId, answersJson));
+        } catch (IllegalStateException e) {
+            return Result.error(400, e.getMessage());
+        }
+    }
+
+    @Operation(summary = "考核结果")
+    @PostMapping("/exam/result")
+    public Result<Object> examResult(@RequestBody Map<String, Object> body,
+                                     @RequestAttribute("userId") Long userId,
+                                     @RequestAttribute("role") String role) {
+        if (!"teacher".equals(role) && !"admin".equals(role)) {
+            return Result.error(403, "无权限");
+        }
+        Integer categoryId = body.get("categoryId") != null ? Integer.parseInt(body.get("categoryId").toString()) : null;
+        if (categoryId == null) {
+            return Result.error("categoryId不能为空");
+        }
+        return Result.success(examService.getLatestAttempt(userId, categoryId));
+    }
+}

+ 41 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ActivityCheckin.java

@@ -0,0 +1,41 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 活动签到记录(通用,供培训课时桥接)
+ */
+@Data
+@TableName("activity_checkins")
+public class ActivityCheckin implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long activityId;
+
+    /** 签到人(规划师=userId) */
+    private Long userId;
+
+    /** 关联报名记录 */
+    private Long registrationId;
+
+    private Date checkInAt;
+
+    /** qr扫码/admin手动/gps */
+    private String checkInType;
+
+    /** admin手动补录人 */
+    private Long checkInBy;
+
+    /** approved/pending/rejected */
+    private String reviewStatus;
+
+    private Date createdAt;
+}

+ 44 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/PlannerCertCategory.java

@@ -0,0 +1,44 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 规划师学习种类 / 认证类别(管理端可配置,非硬编码)
+ */
+@Data
+@TableName("planner_cert_categories")
+public class PlannerCertCategory implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Integer id;
+
+    /** 类别编码: planner/nutritionist/assessor ... */
+    private String code;
+
+    /** 类别名称 */
+    private String name;
+
+    private String description;
+
+    /** 该类别要求总课时(线上+线下) */
+    private BigDecimal requiredHours;
+
+    /** 1=基础类别(获得即 Guide.status=active) */
+    private Integer isBase;
+
+    private Integer orderIndex;
+
+    /** 1启用 0停用 */
+    private Integer status;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 62 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/PlannerCertification.java

@@ -0,0 +1,62 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 规划师每类别培训认证主表(每用户每类别一行,认证闸门依据)
+ */
+@Data
+@TableName("planner_certifications")
+public class PlannerCertification implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    private Long guideId;
+
+    /** 所属学习种类(planner_cert_categories.id) */
+    private Integer categoryId;
+
+    /** 冗余类别编码,便于查询 */
+    private String categoryCode;
+
+    /** 该类别总要求课时 */
+    private BigDecimal requiredHours;
+
+    /** 已完成课时(线上+线下汇总) */
+    private BigDecimal completedHours;
+
+    /** pending_learning/learning_completed */
+    private String learningStatus;
+
+    /** none/exam_failed/exam_passed */
+    private String examStatus;
+
+    private Integer examScore;
+
+    private Integer examAttempts;
+
+    /** 证书编号 */
+    private String certNo;
+
+    private Date certifiedAt;
+
+    /** 有效期(NULL=不过期) */
+    private Date expireAt;
+
+    /** pending_cert/learning_completed/certified/suspended */
+    private String status;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 44 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/PlannerCourse.java

@@ -0,0 +1,44 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 规划师线上课程目录(映射小鹅通课程/专栏)
+ */
+@Data
+@TableName("planner_courses")
+public class PlannerCourse implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Integer id;
+
+    /** 所属学习种类(planner_cert_categories.id) */
+    private Integer categoryId;
+
+    /** 小鹅通课程/商品ID */
+    private String xiaoeResourceId;
+
+    private String title;
+
+    /** 该课程要求课时 */
+    private BigDecimal requiredHours;
+
+    private Integer orderIndex;
+
+    /** 是否必修 */
+    private Integer isRequired;
+
+    /** 1启用 0停用 */
+    private Integer status;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 44 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/PlannerCourseProgress.java

@@ -0,0 +1,44 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 规划师线上课程学习进度(每用户每课程,来自小鹅通回传/手动同步)
+ */
+@Data
+@TableName("planner_course_progress")
+public class PlannerCourseProgress implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    private Integer courseId;
+
+    private Integer categoryId;
+
+    private String xiaoeResourceId;
+
+    /** 学习进度百分比 */
+    private Integer progressPercent;
+
+    /** 已完成课时 */
+    private BigDecimal completedHours;
+
+    /** 是否完成 */
+    private Integer completed;
+
+    private Date lastSyncedAt;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/PlannerExamAttempt.java

@@ -0,0 +1,39 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 规划师考核作答记录
+ */
+@Data
+@TableName("planner_exam_attempts")
+public class PlannerExamAttempt implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    private Integer categoryId;
+
+    private Integer paperId;
+
+    /** 得分 */
+    private Integer score;
+
+    /** 是否通过 */
+    private Integer passed;
+
+    /** JSON 作答明细 */
+    private String answers;
+
+    private Date startedAt;
+
+    private Date submittedAt;
+}

+ 41 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/PlannerExamPaper.java

@@ -0,0 +1,41 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 规划师考核试卷(每类别可有多套)
+ */
+@Data
+@TableName("planner_exam_papers")
+public class PlannerExamPaper implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Integer id;
+
+    /** 所属学习种类(planner_cert_categories.id) */
+    private Integer categoryId;
+
+    private String title;
+
+    /** 及格分 */
+    private Integer passScore;
+
+    /** 限时(分钟) */
+    private Integer durationMinutes;
+
+    /** 题目数 */
+    private Integer questionCount;
+
+    /** 1启用 0停用 */
+    private Integer status;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/PlannerExamQuestion.java

@@ -0,0 +1,36 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 考核题库(题干+选项JSON+答案+分值)
+ */
+@Data
+@TableName("planner_exam_questions")
+public class PlannerExamQuestion implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 试卷ID(planner_exam_papers.id) */
+    private Integer paperId;
+
+    /** 题干 */
+    private String question;
+
+    /** 选项 JSON 数组 */
+    private String options;
+
+    /** 正确选项 */
+    private String answer;
+
+    /** 分值 */
+    private Integer score;
+
+    private Integer orderIndex;
+}

+ 40 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/PlannerOfflineCourseMap.java

@@ -0,0 +1,40 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 线下培训活动↔认证类别映射(桥接表,复用 activities 模块)
+ */
+@Data
+@TableName("planner_offline_course_map")
+public class PlannerOfflineCourseMap implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 关联 activities.id(线下培训活动) */
+    private Long activityId;
+
+    /** 关联 planner_cert_categories.id */
+    private Integer categoryId;
+
+    /** 该活动签到后计入的课时 */
+    private BigDecimal durationHours;
+
+    /** 是否必修环节 */
+    private Integer isRequired;
+
+    /** 1启用 0停用 */
+    private Integer status;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ActivityCheckinMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ActivityCheckin;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ActivityCheckinMapper extends BaseMapper<ActivityCheckin> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/PlannerCertCategoryMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.PlannerCertCategory;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface PlannerCertCategoryMapper extends BaseMapper<PlannerCertCategory> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/PlannerCertificationMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.PlannerCertification;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface PlannerCertificationMapper extends BaseMapper<PlannerCertification> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/PlannerCourseMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.PlannerCourse;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface PlannerCourseMapper extends BaseMapper<PlannerCourse> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/PlannerCourseProgressMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.PlannerCourseProgress;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface PlannerCourseProgressMapper extends BaseMapper<PlannerCourseProgress> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/PlannerExamAttemptMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.PlannerExamAttempt;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface PlannerExamAttemptMapper extends BaseMapper<PlannerExamAttempt> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/PlannerExamPaperMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.PlannerExamPaper;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface PlannerExamPaperMapper extends BaseMapper<PlannerExamPaper> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/PlannerExamQuestionMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.PlannerExamQuestion;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface PlannerExamQuestionMapper extends BaseMapper<PlannerExamQuestion> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/PlannerOfflineCourseMapMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.PlannerOfflineCourseMap;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface PlannerOfflineCourseMapMapper extends BaseMapper<PlannerOfflineCourseMap> {
+}

+ 43 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityCheckinService.java

@@ -0,0 +1,43 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.ActivityCheckin;
+import com.etotem.cfc.mapper.ActivityCheckinMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+@Service
+public class ActivityCheckinService {
+
+    @Resource
+    private ActivityCheckinMapper activityCheckinMapper;
+
+    public List<ActivityCheckin> listByActivity(Long activityId, Integer page, Integer size) {
+        com.baomidou.mybatisplus.extension.plugins.pagination.Page<ActivityCheckin> p =
+                new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size);
+        return activityCheckinMapper.selectPage(p,
+                new LambdaQueryWrapper<ActivityCheckin>().eq(ActivityCheckin::getActivityId, activityId))
+                .getRecords();
+    }
+
+    public List<ActivityCheckin> listByUser(Integer userId, Integer page, Integer size) {
+        com.baomidou.mybatisplus.extension.plugins.pagination.Page<ActivityCheckin> p =
+                new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size);
+        return activityCheckinMapper.selectPage(p,
+                new LambdaQueryWrapper<ActivityCheckin>().eq(ActivityCheckin::getUserId, userId))
+                .getRecords();
+    }
+
+    public ActivityCheckin getById(Long id) {
+        return activityCheckinMapper.selectById(id);
+    }
+
+    public boolean updateReviewStatus(Long id, String reviewStatus) {
+        ActivityCheckin rec = activityCheckinMapper.selectById(id);
+        if (rec == null) return false;
+        rec.setReviewStatus(reviewStatus);
+        return activityCheckinMapper.updateById(rec) > 0;
+    }
+}

+ 42 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PlannerCertCategoryService.java

@@ -0,0 +1,42 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.PlannerCertCategory;
+import com.etotem.cfc.mapper.PlannerCertCategoryMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+@Slf4j
+@Service
+public class PlannerCertCategoryService {
+
+    @Resource
+    private PlannerCertCategoryMapper categoryMapper;
+
+    public List<PlannerCertCategory> listAll() {
+        return categoryMapper.selectList(null);
+    }
+
+    public PlannerCertCategory getById(Integer id) {
+        return categoryMapper.selectById(id);
+    }
+
+    public PlannerCertCategory getByCode(String code) {
+        return categoryMapper.selectOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<PlannerCertCategory>()
+                .eq(PlannerCertCategory::getCode, code));
+    }
+
+    public boolean create(PlannerCertCategory category) {
+        return categoryMapper.insert(category) > 0;
+    }
+
+    public boolean update(PlannerCertCategory category) {
+        return categoryMapper.updateById(category) > 0;
+    }
+
+    public boolean delete(Integer id) {
+        return categoryMapper.deleteById(id) > 0;
+    }
+}

+ 53 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PlannerCertificationGuard.java

@@ -0,0 +1,53 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.PlannerCertification;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+
+/**
+ * 规划师接单/服务闸门(类别感知)。
+ * 中央守卫:只有持有对应类别认证(status=certified)才放行。
+ */
+@Slf4j
+@Component
+public class PlannerCertificationGuard {
+
+    @Resource
+    private PlannerCertificationService certificationService;
+
+    /**
+     * 校验用户是否持有指定类别认证(且持有该基础类别认证为必选)。
+     *
+     * @param userId        当前用户ID
+     * @param categoryCode  所需的类别编码(planner / nutritionist / assessor)
+     * @param baseRequired  是否需要同时持有基础类别 planner 认证
+     * @return null = 放行;非 null = 拒绝(Result.error)
+     */
+    public Result<Void> assertCertified(Long userId, String categoryCode, boolean baseRequired) {
+        if (userId == null) {
+            return Result.error(401, "未登录");
+        }
+        // 当前类别认证
+        PlannerCertification cert = certificationService.getCertification(userId, categoryCode);
+        if (cert == null || !"certified".equals(cert.getStatus())) {
+            return Result.error(403, "规划师未完成【" + categoryCode + "】培训认证,暂不可提供该类服务");
+        }
+        if (!baseRequired) {
+            return null;
+        }
+        // 还需持有基础类别 planner 认证
+        PlannerCertification plannerCert = certificationService.getCertification(userId, PlannerCertificationService.CATEGORY_PLANNER);
+        if (plannerCert == null || !"certified".equals(plannerCert.getStatus())) {
+            return Result.error(403, "规划师未完成成长规划师基础认证,暂不可提供服务");
+        }
+        return null;
+    }
+
+    /** 便捷重载:仅校验单类别(不要求基础类别),如 assessor 本身即增值类别 */
+    public Result<Void> assertCertified(Long userId, String categoryCode) {
+        return assertCertified(userId, categoryCode, true);
+    }
+}

+ 313 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PlannerCertificationService.java

@@ -0,0 +1,313 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 规划师认证核心服务(按类别)
+ * - 初始化:审核通过后为每类别创建 pending_learning 行
+ * - 课时汇总:线上 planner_course_progress + 线下 activity_checkins 经桥接
+ * - 状态流转:pending_learning → learning_completed → (exam) → certified / exam_failed
+ * - 存量兼容(D6):Guide.status='active' 补登 planner 基础类别 certified
+ */
+@Slf4j
+@Service
+public class PlannerCertificationService {
+
+    public static final String CATEGORY_PLANNER = "planner";
+
+    @Resource
+    private PlannerCertificationMapper certificationMapper;
+    @Resource
+    private PlannerCertCategoryMapper categoryMapper;
+    @Resource
+    private PlannerCourseProgressMapper courseProgressMapper;
+    @Resource
+    private ActivityCheckinMapper activityCheckinMapper;
+    @Resource
+    private PlannerOfflineCourseMapMapper offlineMapMapper;
+    @Resource
+    private GuideMapper guideMapper;
+
+    /**
+     * 为已存在的用户按所有启用类别创建初始化认证记录。
+     * 幂等:existing + status in (pending_cert, learning_completed) 则跳过。
+     */
+    @Transactional
+    public void initCertificationsByUser(Long userId) {
+        List<PlannerCertCategory> categories = categoryMapper.selectList(
+                new LambdaQueryWrapper<PlannerCertCategory>().eq(PlannerCertCategory::getStatus, 1));
+        if (categories.isEmpty()) {
+            return;
+        }
+        List<PlannerCertification> existing = certificationMapper.selectList(
+                new LambdaQueryWrapper<PlannerCertification>().eq(PlannerCertification::getUserId, userId));
+        java.util.Set<Integer> existingCategoryIds = existing.stream()
+                .map(PlannerCertification::getCategoryId)
+                .collect(Collectors.toSet());
+
+        Long guideId = null;
+        Guide guide = guideMapper.selectOne(new LambdaQueryWrapper<Guide>().eq(Guide::getUserId, userId));
+        if (guide != null) {
+            guideId = guide.getId();
+        }
+
+        for (PlannerCertCategory cat : categories) {
+            if (existingCategoryIds.contains(cat.getId())) {
+                continue;
+            }
+            PlannerCertification cert = new PlannerCertification();
+            cert.setUserId(userId);
+            cert.setGuideId(guideId);
+            cert.setCategoryId(cat.getId());
+            cert.setCategoryCode(cat.getCode());
+            cert.setRequiredHours(cat.getRequiredHours() != null ? cat.getRequiredHours() : BigDecimal.ZERO);
+            cert.setCompletedHours(BigDecimal.ZERO);
+            cert.setLearningStatus("pending_learning");
+            cert.setExamStatus("none");
+            cert.setExamAttempts(0);
+            cert.setStatus("pending_cert");
+            cert.setCreatedAt(new Date());
+            cert.setUpdatedAt(new Date());
+            certificationMapper.insert(cert);
+        }
+        log.info("initCertificationsByUser: userId={} 新增 {} 条认证记录", userId, categories.size() - existingCategoryIds.size());
+    }
+
+    /**
+     * 审核通过 planner 角色时调用:
+     * 1. 新建/更新 Guide,status=pending_cert(不再直接 active)
+     * 2. 为当前所有启用类别初始化认证记录(pending_cert)
+     */
+    @Transactional
+    public void onPlannerApprovalApproved(Long userId, String province, String city, String district, String street) {
+        // 初始化/更新 guide(status=pending_cert,保留地址信息)
+        Guide guide = guideMapper.selectOne(new LambdaQueryWrapper<Guide>().eq(Guide::getUserId, userId));
+        if (guide == null) {
+            guide = new Guide();
+            guide.setUserId(userId);
+            guide.setType("planner");
+            guide.setStatus("pending_cert");
+            guide.setProvince(province);
+            guide.setCity(city);
+            guide.setDistrict(district);
+            guide.setStreet(street);
+            guide.setCreatedAt(new Date());
+            guide.setUpdatedAt(new Date());
+            guideMapper.insert(guide);
+        } else {
+            guide.setStatus("pending_cert");
+            if (province != null) guide.setProvince(province);
+            if (city != null) guide.setCity(city);
+            if (district != null) guide.setDistrict(district);
+            if (street != null) guide.setStreet(street);
+            guide.setUpdatedAt(new Date());
+            guideMapper.updateById(guide);
+        }
+        // 按类别初始化认证(含存量兼容:已有的不动)
+        initCertificationsByUser(userId);
+        log.info("onPlannerApprovalApproved: userId={} guide.status=pending_cert", userId);
+    }
+
+    /**
+     * 重算某用户某类别的 completed_hours(线上进度 + 线下活动签到)。
+     * 若完成课时 >= 要求课时 → 翻转 learning_status = learning_completed。
+     */
+    @Transactional
+    public void recomputeHours(Long userId, Integer categoryId) {
+        BigDecimal onlineHours = courseProgressMapper.selectList(
+                new LambdaQueryWrapper<PlannerCourseProgress>()
+                        .eq(PlannerCourseProgress::getUserId, userId)
+                        .eq(PlannerCourseProgress::getCategoryId, categoryId))
+                .stream()
+                .map(p -> p.getCompletedHours() != null ? p.getCompletedHours() : BigDecimal.ZERO)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+
+        BigDecimal offlineHours = activityCheckinMapper.selectList(
+                new LambdaQueryWrapper<ActivityCheckin>()
+                        .eq(ActivityCheckin::getUserId, userId)
+                        .eq(ActivityCheckin::getReviewStatus, "approved"))
+                .stream()
+                .map(checkin -> {
+                    PlannerOfflineCourseMap map = offlineMapMapper.selectOne(
+                            new LambdaQueryWrapper<PlannerOfflineCourseMap>()
+                                    .eq(PlannerOfflineCourseMap::getActivityId, checkin.getActivityId())
+                                    .eq(PlannerOfflineCourseMap::getCategoryId, categoryId)
+                                    .eq(PlannerOfflineCourseMap::getStatus, 1));
+                    return map != null && map.getDurationHours() != null ? map.getDurationHours() : BigDecimal.ZERO;
+                })
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+
+        BigDecimal totalHours = onlineHours.add(offlineHours);
+
+        PlannerCertification cert = certificationMapper.selectOne(
+                new LambdaQueryWrapper<PlannerCertification>()
+                        .eq(PlannerCertification::getUserId, userId)
+                        .eq(PlannerCertification::getCategoryId, categoryId));
+        if (cert == null) {
+            return;
+        }
+        cert.setCompletedHours(totalHours);
+        if ("pending_learning".equals(cert.getLearningStatus())
+                && cert.getRequiredHours() != null
+                && totalHours.compareTo(cert.getRequiredHours()) >= 0) {
+            cert.setLearningStatus("learning_completed");
+        }
+        cert.setUpdatedAt(new Date());
+        certificationMapper.updateById(cert);
+        log.info("recomputeHours: userId={} category={} online={} offline={} total={}",
+                userId, categoryId, onlineHours, offlineHours, totalHours);
+    }
+
+    /**
+     * 考核通过后更新认证状态:
+     * - exam_status=exam_passed, exam_score=score
+     * - status=certified, certified_at=now
+     * - 若为基础类别(planner),同步翻 Guide.status=active
+     * - 生成证书编号 cert_no
+     */
+    @Transactional
+    public void onExamPassed(Long userId, Integer categoryId, int score) {
+        PlannerCertification cert = certificationMapper.selectOne(
+                new LambdaQueryWrapper<PlannerCertification>()
+                        .eq(PlannerCertification::getUserId, userId)
+                        .eq(PlannerCertification::getCategoryId, categoryId));
+        if (cert == null) {
+            return;
+        }
+        cert.setExamStatus("exam_passed");
+        cert.setExamScore(score);
+        cert.setStatus("certified");
+        cert.setCertifiedAt(new Date());
+        if (cert.getCertNo() == null || cert.getCertNo().isEmpty()) {
+            cert.setCertNo("CERT-" + new java.text.SimpleDateFormat("yyyyMMdd-HHmmss").format(new Date()) + "-" + userId);
+        }
+        cert.setUpdatedAt(new Date());
+        certificationMapper.updateById(cert);
+
+        // 若为基础类别,同步翻 Guide.status=active
+        PlannerCertCategory category = categoryMapper.selectById(categoryId);
+        if (category != null && Integer.valueOf(1).equals(category.getIsBase())) {
+            Guide guide = guideMapper.selectOne(new LambdaQueryWrapper<Guide>().eq(Guide::getUserId, userId));
+            if (guide != null && !"active".equals(guide.getStatus())) {
+                guide.setStatus("active");
+                guide.setUpdatedAt(new Date());
+                guideMapper.updateById(guide);
+                log.info("onExamPassed base category: userId={} → Guide.status=active", userId);
+            }
+        }
+        log.info("onExamPassed: userId={} categoryId={} score={}", userId, categoryId, score);
+    }
+
+    /**
+     * 考核未通过:exam_status=exam_failed, status 保持原样(允许重考)
+     */
+    @Transactional
+    public void onExamFailed(Long userId, Integer categoryId) {
+        PlannerCertification cert = certificationMapper.selectOne(
+                new LambdaQueryWrapper<PlannerCertification>()
+                        .eq(PlannerCertification::getUserId, userId)
+                        .eq(PlannerCertification::getCategoryId, categoryId));
+        if (cert == null) {
+            return;
+        }
+        cert.setExamStatus("exam_failed");
+        cert.setExamAttempts(cert.getExamAttempts() != null ? cert.getExamAttempts() + 1 : 1);
+        cert.setUpdatedAt(new Date());
+        certificationMapper.updateById(cert);
+    }
+
+    /**
+     * 批量初始化所有启用中规划师(Guide.status=pending_cert 或 active)的认证记录。
+     * 用于存量兼容/D6。
+     */
+    public void backfillAllPlanners() {
+        List<Guide> guides = guideMapper.selectList(
+                new LambdaQueryWrapper<Guide>()
+                        .in(Guide::getStatus, "pending_cert", "active"));
+        int count = 0;
+        for (Guide g : guides) {
+            if (g.getUserId() == null) continue;
+            initCertificationsByUser(g.getUserId());
+            // 对于存量 active guide 的 planner 基础类别,置为 certified + learning_completed
+            PlannerCertification plannerCert = certificationMapper.selectOne(
+                    new LambdaQueryWrapper<PlannerCertification>()
+                            .eq(PlannerCertification::getUserId, g.getUserId())
+                            .eq(PlannerCertification::getCategoryCode, CATEGORY_PLANNER));
+            if (plannerCert != null && !"certified".equals(plannerCert.getStatus())) {
+                plannerCert.setLearningStatus("learning_completed");
+                plannerCert.setExamStatus("exam_passed");
+                plannerCert.setExamScore(100);
+                plannerCert.setStatus("certified");
+                plannerCert.setCertifiedAt(new Date());
+                plannerCert.setCertNo("CERT-" + new java.text.SimpleDateFormat("yyyyMMdd").format(new Date()) + "-" + g.getUserId());
+                plannerCert.setCompletedHours(plannerCert.getRequiredHours());
+                plannerCert.setUpdatedAt(new Date());
+                certificationMapper.updateById(plannerCert);
+                count++;
+            }
+        }
+        log.info("backfillAllPlanners: 处理 {} 个规划师,补登基础类别认证 {}", guides.size(), count);
+    }
+
+    /**
+     * 获取用户的全部认证状态(含课时、学习/考试状态、是否可考、证书)
+     */
+    public List<Map<String, Object>> listMyCertifications(Long userId) {
+        List<PlannerCertification> certs = certificationMapper.selectList(
+                new LambdaQueryWrapper<PlannerCertification>().eq(PlannerCertification::getUserId, userId));
+        List<PlannerCertCategory> categories = categoryMapper.selectList(
+                new LambdaQueryWrapper<PlannerCertCategory>().eq(PlannerCertCategory::getStatus, 1));
+        Map<Integer, PlannerCertCategory> catMap = categories.stream()
+                .collect(Collectors.toMap(PlannerCertCategory::getId, c -> c, (a, b) -> a));
+        java.util.List<Map<String, Object>> result = new java.util.ArrayList<>();
+        for (PlannerCertification cert : certs) {
+            PlannerCertCategory cat = catMap.get(cert.getCategoryId());
+            Map<String, Object> row = new java.util.LinkedHashMap<>();
+            row.put("id", cert.getId());
+            row.put("categoryId", cert.getCategoryId());
+            row.put("categoryCode", cert.getCategoryCode());
+            row.put("categoryName", cat != null ? cat.getName() : cert.getCategoryCode());
+            row.put("isBase", cat != null && Integer.valueOf(1).equals(cat.getIsBase()) ? 1 : 0);
+            row.put("requiredHours", cert.getRequiredHours());
+            row.put("completedHours", cert.getCompletedHours());
+            row.put("learningStatus", cert.getLearningStatus());
+            row.put("examStatus", cert.getExamStatus());
+            row.put("examScore", cert.getExamScore());
+            row.put("examAttempts", cert.getExamAttempts());
+            row.put("certNo", cert.getCertNo());
+            row.put("certifiedAt", cert.getCertifiedAt());
+            row.put("expireAt", cert.getExpireAt());
+            row.put("status", cert.getStatus());
+            // 是否可以开始考试
+            row.put("canExam", "learning_completed".equals(cert.getLearningStatus())
+                    && !"exam_passed".equals(cert.getExamStatus()));
+            // 是否可以接单(持有该类别认证)
+            row.put("canService", "certified".equals(cert.getStatus()));
+            result.add(row);
+        }
+        return result;
+    }
+
+    /**
+     * 查询用户的某个类别认证记录,不存在则返回 null
+     */
+    public PlannerCertification getCertification(Long userId, String categoryCode) {
+        return certificationMapper.selectOne(
+                new LambdaQueryWrapper<PlannerCertification>()
+                        .eq(PlannerCertification::getUserId, userId)
+                        .eq(PlannerCertification::getCategoryCode, categoryCode));
+    }
+}

+ 83 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PlannerCourseService.java

@@ -0,0 +1,83 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.PlannerCourse;
+import com.etotem.cfc.entity.PlannerCourseProgress;
+import com.etotem.cfc.mapper.PlannerCourseMapper;
+import com.etotem.cfc.mapper.PlannerCourseProgressMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+@Slf4j
+@Service
+public class PlannerCourseService {
+
+    @Resource
+    private PlannerCourseMapper courseMapper;
+    @Resource
+    private PlannerCourseProgressMapper progressMapper;
+
+    public List<PlannerCourse> listByCategory(Integer categoryId) {
+        return courseMapper.selectList(
+                new LambdaQueryWrapper<PlannerCourse>()
+                        .eq(PlannerCourse::getCategoryId, categoryId)
+                        .eq(PlannerCourse::getStatus, 1)
+                        .orderByAsc(PlannerCourse::getOrderIndex));
+    }
+
+    public List<PlannerCourse> listAllEnabled() {
+        return courseMapper.selectList(
+                new LambdaQueryWrapper<PlannerCourse>().eq(PlannerCourse::getStatus, 1)
+                        .orderByAsc(PlannerCourse::getCategoryId)
+                        .orderByAsc(PlannerCourse::getOrderIndex));
+    }
+
+    public PlannerCourse getById(Integer id) {
+        return courseMapper.selectById(id);
+    }
+
+    public boolean create(PlannerCourse course) {
+        return courseMapper.insert(course) > 0;
+    }
+
+    public boolean update(PlannerCourse course) {
+        return courseMapper.updateById(course) > 0;
+    }
+
+    public boolean delete(Integer id) {
+        return courseMapper.deleteById(id) > 0;
+    }
+
+    // ---- 进度 ----
+
+    public PlannerCourseProgress getProgress(Long userId, Integer courseId) {
+        return progressMapper.selectOne(
+                new LambdaQueryWrapper<PlannerCourseProgress>()
+                        .eq(PlannerCourseProgress::getUserId, userId)
+                        .eq(PlannerCourseProgress::getCourseId, courseId));
+    }
+
+    public PlannerCourseProgress upsertProgress(PlannerCourseProgress progress) {
+        PlannerCourseProgress existing = progressMapper.selectOne(
+                new LambdaQueryWrapper<PlannerCourseProgress>()
+                        .eq(PlannerCourseProgress::getUserId, progress.getUserId())
+                        .eq(PlannerCourseProgress::getCourseId, progress.getCourseId()));
+        if (existing == null) {
+            progress.setCompletedHours(progress.getCompletedHours() != null ? progress.getCompletedHours() : java.math.BigDecimal.ZERO);
+            progressMapper.insert(progress);
+            return progress;
+        } else {
+            existing.setProgressPercent(progress.getProgressPercent());
+            existing.setCompletedHours(progress.getCompletedHours());
+            existing.setCompleted(progress.getCompleted());
+            existing.setXiaoeResourceId(progress.getXiaoeResourceId());
+            existing.setLastSyncedAt(progress.getLastSyncedAt());
+            existing.setUpdatedAt(new java.util.Date());
+            progressMapper.updateById(existing);
+            return existing;
+        }
+    }
+}

+ 195 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PlannerExamService.java

@@ -0,0 +1,195 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Slf4j
+@Service
+public class PlannerExamService {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    @Resource
+    private PlannerExamPaperMapper paperMapper;
+    @Resource
+    private PlannerExamQuestionMapper questionMapper;
+    @Resource
+    private PlannerExamAttemptMapper attemptMapper;
+    @Resource
+    private PlannerCertificationService certificationService;
+
+    // ---- 试卷 CRUD ----
+
+    public List<PlannerExamPaper> listByCategory(Integer categoryId) {
+        return paperMapper.selectList(
+                new LambdaQueryWrapper<PlannerExamPaper>()
+                        .eq(PlannerExamPaper::getCategoryId, categoryId)
+                        .eq(PlannerExamPaper::getStatus, 1)
+                        .orderByDesc(PlannerExamPaper::getId));
+    }
+
+    public PlannerExamPaper getById(Integer id) {
+        return paperMapper.selectById(id);
+    }
+
+    public boolean create(PlannerExamPaper paper) {
+        return paperMapper.insert(paper) > 0;
+    }
+
+    public boolean update(PlannerExamPaper paper) {
+        return paperMapper.updateById(paper) > 0;
+    }
+
+    public boolean delete(Integer id) {
+        return paperMapper.deleteById(id) > 0;
+    }
+
+    // ---- 题库 CRUD ----
+
+    public List<PlannerExamQuestion> listByPaper(Integer paperId) {
+        return questionMapper.selectList(
+                new LambdaQueryWrapper<PlannerExamQuestion>()
+                        .eq(PlannerExamQuestion::getPaperId, paperId)
+                        .orderByAsc(PlannerExamQuestion::getOrderIndex));
+    }
+
+    public boolean saveQuestions(Integer paperId, List<Map<String, Object>> questionDataList) {
+        // 先删旧题
+        questionMapper.delete(
+                new LambdaQueryWrapper<PlannerExamQuestion>().eq(PlannerExamQuestion::getPaperId, paperId));
+        if (questionDataList == null || questionDataList.isEmpty()) {
+            return true;
+        }
+        int idx = 0;
+        for (Map<String, Object> item : questionDataList) {
+            PlannerExamQuestion q = new PlannerExamQuestion();
+            q.setPaperId(paperId);
+            q.setQuestion((String) item.get("question"));
+            q.setOptions((String) item.get("options"));
+            q.setAnswer((String) item.get("answer"));
+            q.setScore(item.get("score") != null ? Integer.parseInt(item.get("score").toString()) : 1);
+            q.setOrderIndex(idx++);
+            questionMapper.insert(q);
+        }
+        // 更新试卷题目数
+        PlannerExamPaper paper = paperMapper.selectById(paperId);
+        if (paper != null) {
+            paper.setQuestionCount(idx);
+            paperMapper.updateById(paper);
+        }
+        return true;
+    }
+
+    // ---- 考试流程 ----
+
+    /**
+     * 规划师开始考试:
+     * - 获取该类别下任意一套启用试卷(优先第一套)
+     * - 写入 attempt,返回题目列表(含选项,不含答案)
+     */
+    public Map<String, Object> startExam(Long userId, Integer categoryId) {
+        List<PlannerExamPaper> papers = listByCategory(categoryId);
+        if (papers.isEmpty()) {
+            throw new IllegalStateException("该类别暂无可用试卷");
+        }
+        PlannerExamPaper paper = papers.get(0);
+        List<PlannerExamQuestion> questions = listByPaper(paper.getId());
+        if (questions.isEmpty()) {
+            throw new IllegalStateException("试卷暂无题目");
+        }
+
+        PlannerExamAttempt attempt = new PlannerExamAttempt();
+        attempt.setUserId(userId);
+        attempt.setCategoryId(categoryId);
+        attempt.setPaperId(paper.getId());
+        attempt.setStartedAt(new Date());
+        attemptMapper.insert(attempt);
+
+        List<Map<String, Object>> questionsDTO = new ArrayList<>();
+        for (PlannerExamQuestion q : questions) {
+            Map<String, Object> qo = new LinkedHashMap<>();
+            qo.put("id", q.getId());
+            qo.put("question", q.getQuestion());
+            qo.put("options", q.getOptions());
+            qo.put("score", q.getScore());
+            questionsDTO.add(qo);
+        }
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("attemptId", attempt.getId());
+        result.put("paperId", paper.getId());
+        result.put("durationMinutes", paper.getDurationMinutes());
+        result.put("questionCount", questions.size());
+        result.put("questions", questionsDTO);
+        return result;
+    }
+
+    /**
+     * 提交考试:判分 → 更新 attempt → 触发认证状态流转。
+     */
+    @Transactional
+    public Map<String, Object> submitExam(Long userId, Long attemptId, String answersJson) {
+        PlannerExamAttempt attempt = attemptMapper.selectById(attemptId);
+        if (attempt == null) {
+            throw new IllegalStateException("答题记录不存在");
+        }
+        attempt.setSubmittedAt(new Date());
+        attempt.setAnswers(answersJson);
+
+        List<PlannerExamQuestion> questions = listByPaper(attempt.getPaperId());
+        int totalScore = 0;
+        try {
+            @SuppressWarnings("unchecked")
+            Map<String, String> answers = MAPPER.readValue(answersJson, new TypeReference<Map<String, String>>() {});
+            for (PlannerExamQuestion q : questions) {
+                String userAnswer = answers.get(String.valueOf(q.getId()));
+                if (userAnswer != null && userAnswer.equalsIgnoreCase(q.getAnswer())) {
+                    totalScore += q.getScore();
+                }
+            }
+        } catch (Exception e) {
+            log.warn("submitExam 解析答案失败: {}", e.getMessage());
+        }
+        attempt.setScore(totalScore);
+        attemptMapper.updateById(attempt);
+
+        // 判定是否通过(>= 试卷 pass_score)
+        PlannerExamPaper paper = paperMapper.selectById(attempt.getPaperId());
+        boolean passed = paper != null && totalScore >= paper.getPassScore();
+        attempt.setPassed(passed ? 1 : 0);
+        attemptMapper.updateById(attempt);
+
+        if (passed) {
+            certificationService.onExamPassed(userId, attempt.getCategoryId(), totalScore);
+        } else {
+            certificationService.onExamFailed(userId, attempt.getCategoryId());
+        }
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("attemptId", attemptId);
+        result.put("score", totalScore);
+        result.put("passed", passed);
+        result.put("passScore", paper != null ? paper.getPassScore() : 60);
+        return result;
+    }
+
+    /**
+     * 获取最近一次作答结果(供前端展示)
+     */
+    public PlannerExamAttempt getLatestAttempt(Long userId, Integer categoryId) {
+        return attemptMapper.selectOne(
+                new LambdaQueryWrapper<PlannerExamAttempt>()
+                        .eq(PlannerExamAttempt::getUserId, userId)
+                        .eq(PlannerExamAttempt::getCategoryId, categoryId)
+                        .orderByDesc(PlannerExamAttempt::getStartedAt)
+                        .last("LIMIT 1"));
+    }
+}

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

@@ -0,0 +1,143 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 线下培训活动桥接:复用 activities 模块,通过 planner_offline_course_map 与认证挂钩。
+ */
+@Slf4j
+@Service
+public class PlannerOfflineCourseService {
+
+    @Resource
+    private PlannerOfflineCourseMapMapper offlineMapMapper;
+    @Resource
+    private PlannerCertCategoryMapper categoryMapper;
+    @Resource
+    private ActivityCheckinMapper activityCheckinMapper;
+    @Resource
+    private ActivityMapper activityMapper;
+    @Resource
+    private PlannerCertificationService certificationService;
+
+    // ---- 映射 CRUD ----
+
+    public List<PlannerOfflineCourseMap> listAll() {
+        return offlineMapMapper.selectList(null);
+    }
+
+    public PlannerOfflineCourseMap getById(Long id) {
+        return offlineMapMapper.selectById(id);
+    }
+
+    public PlannerOfflineCourseMap getByActivityId(Long activityId) {
+        return offlineMapMapper.selectOne(
+                new LambdaQueryWrapper<PlannerOfflineCourseMap>().eq(PlannerOfflineCourseMap::getActivityId, activityId));
+    }
+
+    public boolean create(PlannerOfflineCourseMap map) {
+        return offlineMapMapper.insert(map) > 0;
+    }
+
+    public boolean update(PlannerOfflineCourseMap map) {
+        return offlineMapMapper.updateById(map) > 0;
+    }
+
+    public boolean delete(Long id) {
+        return offlineMapMapper.deleteById(id) > 0;
+    }
+
+    /**
+     * 查询规划师可见的线下培训活动列表(复用 activities,按映射表过滤,含课时)。
+     */
+    public List<Map<String, Object>> listTrainingActivities(Long userId) {
+        List<PlannerOfflineCourseMap> maps = offlineMapMapper.selectList(
+                new LambdaQueryWrapper<PlannerOfflineCourseMap>().eq(PlannerOfflineCourseMap::getStatus, 1));
+        if (maps.isEmpty()) {
+            return java.util.Collections.emptyList();
+        }
+        List<Long> activityIds = maps.stream().map(PlannerOfflineCourseMap::getActivityId).collect(Collectors.toList());
+        List<Activity> activities = activityMapper.selectBatchIds(activityIds);
+        Map<Long, Activity> actMap = activities.stream()
+                .collect(Collectors.toMap(Activity::getId, a -> a, (a, b) -> a));
+
+        java.util.List<Map<String, Object>> result = new java.util.ArrayList<>();
+        for (PlannerOfflineCourseMap map : maps) {
+            Activity activity = actMap.get(map.getActivityId());
+            if (activity == null) continue;
+            Map<String, Object> row = new java.util.LinkedHashMap<>();
+            row.put("id", activity.getId());
+            row.put("title", activity.getTitle());
+            row.put("description", activity.getDescription());
+            row.put("startTime", activity.getStartTime());
+            row.put("endTime", activity.getEndTime());
+            row.put("location", activity.getLocation());
+            row.put("coverImage", activity.getCoverImage());
+            row.put("categoryId", map.getCategoryId());
+            row.put("categoryCode", map.getCategoryId());
+            PlannerCertCategory cat = categoryMapper.selectById(map.getCategoryId());
+            row.put("categoryName", cat != null ? cat.getName() : "");
+            row.put("durationHours", map.getDurationHours());
+            row.put("isRequired", map.getIsRequired());
+            result.add(row);
+        }
+        return result;
+    }
+
+    /**
+     * 规划师扫码签到(check_in_type='qr')。幂等:同一活动同一用户只计一次课时。
+     * 成功后触发课时重算。
+     */
+    @Transactional
+    public boolean checkIn(Long activityId, Long userId, String checkInType) {
+        if (activityId == null || userId == null) {
+            return false;
+        }
+        // 幂等:已有该活动该用户签到则返回
+        ActivityCheckin existing = activityCheckinMapper.selectOne(
+                new LambdaQueryWrapper<ActivityCheckin>()
+                        .eq(ActivityCheckin::getActivityId, activityId)
+                        .eq(ActivityCheckin::getUserId, userId));
+        if (existing != null) {
+            log.info("checkIn 幂等跳过: activityId={} userId={}", activityId, userId);
+            return true;
+        }
+        ActivityCheckin checkin = new ActivityCheckin();
+        checkin.setActivityId(activityId);
+        checkin.setUserId(userId);
+        checkin.setCheckInType(checkInType != null ? checkInType : "qr");
+        checkin.setCheckInAt(new Date());
+        checkin.setReviewStatus("approved");
+        checkin.setCreatedAt(new Date());
+        activityCheckinMapper.insert(checkin);
+
+        // 桥接到类别重算课时
+        PlannerOfflineCourseMap map = offlineMapMapper.selectOne(
+                new LambdaQueryWrapper<PlannerOfflineCourseMap>()
+                        .eq(PlannerOfflineCourseMap::getActivityId, activityId)
+                        .eq(PlannerOfflineCourseMap::getStatus, 1));
+        if (map != null) {
+            certificationService.recomputeHours(userId, map.getCategoryId());
+        }
+        log.info("checkIn: activityId={} userId={} type={}", activityId, userId, checkInType);
+        return true;
+    }
+
+    /**
+     * admin 手动补录签到(check_in_type='admin')
+     */
+    public boolean adminAddCheckin(Long activityId, Long userId, Long checkInBy) {
+        return checkIn(activityId, userId, "admin");
+    }
+}

+ 19 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/XiaoeAdapter.java

@@ -0,0 +1,19 @@
+package com.etotem.cfc.service;
+
+/**
+ * 小鹅通适配器接口(线上渠道)
+ */
+public interface XiaoeAdapter {
+
+    /**
+     * 处理小鹅通回调事件(课时完成/课程完成)
+     * @param eventJson 原始回调 JSON
+     * @return true 表示已处理
+     */
+    boolean handleEvent(String eventJson);
+
+    /**
+     * 回查用户进度(主动同步,用于补偿 webhook 丢失)
+     */
+    void syncUserProgress(Long userId);
+}

+ 87 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/XiaoeManualAdapter.java

@@ -0,0 +1,87 @@
+package com.etotem.cfc.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.etotem.cfc.entity.PlannerCourse;
+import com.etotem.cfc.entity.PlannerCourseProgress;
+import com.etotem.cfc.mapper.PlannerCourseMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 小鹅通 Manual 适配器(沙箱/无凭证期 fallback)。
+ * 不从外部接收回调,而是通过管理端"手动同步课时"入口写入进度。
+ */
+@Slf4j
+@Component("xiaoeManualAdapter")
+public class XiaoeManualAdapter implements XiaoeAdapter {
+
+    @Resource
+    private PlannerCourseMapper courseMapper;
+    @Resource
+    private PlannerCourseService courseService;
+    @Resource
+    private PlannerCertificationService certificationService;
+
+    @Override
+    public boolean handleEvent(String eventJson) {
+        log.info("XiaoeManualAdapter 不处理外部回调事件(manual 模式)");
+        return true;
+    }
+
+    /**
+     * 手动同步:按 userId + 课程 + 完成课时写入进度,并重算类别总课时。
+     */
+    public boolean manualSync(Long userId, Integer courseId, BigDecimal hours, Integer progressPercent, Integer completed) {
+        PlannerCourse course = courseMapper.selectById(courseId);
+        if (course == null) {
+            return false;
+        }
+        PlannerCourseProgress progress = new PlannerCourseProgress();
+        progress.setUserId(userId);
+        progress.setCourseId(courseId);
+        progress.setCategoryId(course.getCategoryId());
+        progress.setXiaoeResourceId(course.getXiaoeResourceId());
+        progress.setProgressPercent(progressPercent != null ? progressPercent : 100);
+        progress.setCompletedHours(hours != null ? hours : BigDecimal.ZERO);
+        progress.setCompleted(completed != null ? completed : (progressPercent != null && progressPercent >= 100 ? 1 : 0));
+        progress.setLastSyncedAt(new Date());
+        courseService.upsertProgress(progress);
+        // 重算该类别课时
+        certificationService.recomputeHours(userId, course.getCategoryId());
+        log.info("manualSync: userId={} courseId={} hours={}", userId, courseId, hours);
+        return true;
+    }
+
+    @Override
+    public void syncUserProgress(Long userId) {
+        // manual 模式下无外部进度可回查;可通过管理端录入
+    }
+
+    /**
+     * 批量导入:rows = [{userId, courseId, hours, progressPercent, completed}]
+     */
+    public int batchSync(List<JSONObject> rows) {
+        int count = 0;
+        for (JSONObject row : rows) {
+            try {
+                Long userId = row.getLongValue("userId");
+                Integer courseId = row.getInteger("courseId");
+                BigDecimal hours = row.getBigDecimal("hours");
+                Integer progressPercent = row.getInteger("progressPercent");
+                Integer completed = row.getInteger("completed");
+                if (userId != null && courseId != null && manualSync(userId, courseId, hours, progressPercent, completed)) {
+                    count++;
+                }
+            } catch (Exception e) {
+                log.warn("batchSync 单行失败: {}", e.getMessage());
+            }
+        }
+        return count;
+    }
+}

+ 109 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/XiaoeWebhookAdapter.java

@@ -0,0 +1,109 @@
+package com.etotem.cfc.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.etotem.cfc.entity.PlannerCourse;
+import com.etotem.cfc.entity.PlannerCourseProgress;
+import com.etotem.cfc.mapper.PlannerCourseMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 小鹅通 Webhook 适配器(真实接入,开放平台消息推送)。
+ * 回调契约见设计文档 §5:
+ * - 资源完成事件 {"resource_type":"course","resource_id":"...","user_info":{"user_id":"..."},"progress":"100"}
+ * - 幂等:以(user_id, xiaoe_resource_id, 事件ID)去重(进度表 uk_user_course + 值不变忽略)
+ */
+@Slf4j
+@Component("xiaoeWebhookAdapter")
+public class XiaoeWebhookAdapter implements XiaoeAdapter {
+
+    @Value("${xiaoe.mode:manual}")
+    private String xiaoeMode;
+
+    @Resource
+    private PlannerCourseMapper courseMapper;
+    @Resource
+    private PlannerCourseService courseService;
+    @Resource
+    private PlannerCertificationService certificationService;
+
+    @Override
+    public boolean handleEvent(String eventJson) {
+        try {
+            JSONObject evt = JSON.parseObject(eventJson);
+            // 兼容常见字段名
+            String resourceId = evt.getString("resource_id");
+            if (resourceId == null) {
+                resourceId = evt.getString("xiaoe_resource_id");
+            }
+            JSONObject userInfo = evt.getJSONObject("user_info");
+            if (userInfo == null) {
+                userInfo = evt.getJSONObject("user");
+            }
+            String xiaoeUserId = userInfo != null ? userInfo.getString("user_id") : null;
+            if (resourceId == null || xiaoeUserId == null) {
+                log.warn("XiaoeWebhook: 缺少 resource_id 或 user_id,忽略。event={}", eventJson);
+                return false;
+            }
+            // 查找该资源映射到的本地课程(v1 用 resource_id 匹配)
+            List<PlannerCourse> courses = courseMapper.selectList(
+                    new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<PlannerCourse>()
+                            .eq(PlannerCourse::getXiaoeResourceId, resourceId));
+            if (courses.isEmpty()) {
+                log.warn("XiaoeWebhook: 未找到 resource_id={} 对应课程,忽略", resourceId);
+                return false;
+            }
+            PlannerCourse course = courses.get(0);
+            // 规划师识别:v1 以本地 user_id 匹配(用户维度映射,需管理端录入 user_id→xiaoeUserId 映射)
+            // 生产上线时应维护小鹅通用户绑定表;当前仅记录日志
+            Integer progressPercent = evt.getInteger("progress_percent");
+            if (progressPercent == null) {
+                progressPercent = evt.getInteger("progress");
+            }
+            if (progressPercent == null) {
+                progressPercent = 100;
+            }
+            BigDecimal hours = evt.getBigDecimal("completed_hours");
+            if (hours == null) {
+                hours = progressPercent >= 100 ? (course.getRequiredHours() != null ? course.getRequiredHours() : BigDecimal.ZERO) : BigDecimal.ZERO;
+            }
+
+            PlannerCourseProgress progress = new PlannerCourseProgress();
+            long localUserId;
+            try {
+                localUserId = Long.parseLong(xiaoeUserId);
+            } catch (NumberFormatException e) {
+                log.warn("XiaoeWebhook: xiaoeUserId={} 无法解析为本地 userId,跳过", xiaoeUserId);
+                return true;
+            }
+            progress.setUserId(localUserId);
+            progress.setCourseId(course.getId());
+            progress.setCategoryId(course.getCategoryId());
+            progress.setXiaoeResourceId(resourceId);
+            progress.setProgressPercent(progressPercent);
+            progress.setCompletedHours(hours);
+            progress.setCompleted(progressPercent >= 100 ? 1 : 0);
+            progress.setLastSyncedAt(new Date());
+            courseService.upsertProgress(progress);
+            certificationService.recomputeHours(localUserId, course.getCategoryId());
+            log.info("XiaoeWebhook 已处理: xiaoeUserId={} resourceId={} percent={} course={}",
+                    xiaoeUserId, resourceId, progressPercent, course.getId());
+            return true;
+        } catch (Exception e) {
+            log.error("XiaoeWebhook 处理失败: {}", e.getMessage(), e);
+            return false;
+        }
+    }
+
+    @Override
+    public void syncUserProgress(Long userId) {
+        log.info("XiaoeWebhookAdapter syncUserProgress 待接入小鹅通开放平台 API: userId={}", userId);
+    }
+}

+ 6 - 8
cfc-backend/src/main/java/com/etotem/cfc/service/impl/ServiceRoleApplicationServiceImpl.java

@@ -42,6 +42,9 @@ public class ServiceRoleApplicationServiceImpl
     @Resource
     private AdminMapper adminMapper;
 
+    @Resource
+    private com.etotem.cfc.service.PlannerCertificationService plannerCertificationService;
+
     @Override
     public Result submit(Long userId, ServiceRoleApplyDTO dto) {
         ServiceRoleApplication existing = this.getOne(
@@ -123,14 +126,9 @@ public class ServiceRoleApplicationServiceImpl
         }
         switch (role) {
             case "planner": {
-                Guide guide = new Guide();
-                guide.setUserId(app.getUserId());
-                guide.setStatus("active");
-                guide.setProvince(app.getProvince());
-                guide.setCity(app.getCity());
-                guide.setDistrict(app.getDistrict());
-                guide.setStreet(app.getStreet());
-                guideMapper.insert(guide);
+                // R4 + D6: 规划师审核通过 → 进入 pending_cert,等待类别认证(不再直接 active)
+                plannerCertificationService.onPlannerApprovalApproved(app.getUserId(), app.getProvince(), app.getCity(),
+                        app.getDistrict(), app.getStreet());
                 user.setTeacherStatus("approved");
                 if (!ownedRoles.contains("teacher")) {
                     ownedRoles.add("teacher");

+ 156 - 0
cfc-backend/src/main/resources/schema.sql

@@ -6497,3 +6497,159 @@ CREATE TABLE IF NOT EXISTS `org_members` (
     INDEX `idx_user_id` (`user_id`),
     INDEX `idx_status` (`status`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='组织成员关联表';
+
+-- =====================================================================
+-- 规划师在线学习 · 多类别认证 · 线下打卡 · 接单闸门(迁移394-401)
+-- 设计文档: docs/superpowers/specs/2026-10-22-planner-learning-assessment-gate-design.md
+-- =====================================================================
+
+-- ① 学习种类 / 认证类别(管理端可配置)
+CREATE TABLE IF NOT EXISTS planner_cert_categories (
+    id INT NOT NULL AUTO_INCREMENT COMMENT '主键',
+    code VARCHAR(32) NOT NULL COMMENT '类别编码: planner/nutritionist/assessor ...',
+    name VARCHAR(64) NOT NULL COMMENT '类别名称',
+    description VARCHAR(255) DEFAULT '' COMMENT '类别说明',
+    required_hours DECIMAL(6,2) NOT NULL DEFAULT 0 COMMENT '该类别要求总课时(线上+线下)',
+    is_base TINYINT(1) DEFAULT 0 COMMENT '1=基础类别(获得即 Guide.status=active)',
+    order_index INT DEFAULT 0 COMMENT '排序(越小越前)',
+    status TINYINT DEFAULT 1 COMMENT '1启用 0停用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_code (code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规划师学习种类/认证类别';
+
+-- ② 线上课程目录(映射小鹅通课程/专栏)
+CREATE TABLE IF NOT EXISTS planner_courses (
+    id INT NOT NULL AUTO_INCREMENT COMMENT '主键',
+    category_id INT NOT NULL COMMENT '所属学习种类(planner_cert_categories.id)',
+    xiaoe_resource_id VARCHAR(64) DEFAULT '' COMMENT '小鹅通课程/商品ID',
+    title VARCHAR(120) NOT NULL COMMENT '课程标题',
+    required_hours DECIMAL(6,2) NOT NULL DEFAULT 0 COMMENT '该课程要求课时',
+    order_index INT DEFAULT 0 COMMENT '排序',
+    is_required TINYINT(1) DEFAULT 1 COMMENT '是否必修',
+    status TINYINT DEFAULT 1 COMMENT '1启用 0停用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (id),
+    KEY idx_category (category_id),
+    KEY idx_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规划师线上课程目录(小鹅通)';
+
+-- ③ 每用户每线上课程学习进度(来自小鹅通回传/手动)
+CREATE TABLE IF NOT EXISTS planner_course_progress (
+    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+    user_id BIGINT NOT NULL COMMENT '规划师用户ID(users.id)',
+    course_id INT NOT NULL COMMENT 'planner_courses.id',
+    category_id INT NOT NULL COMMENT '所属学习种类',
+    xiaoe_resource_id VARCHAR(64) DEFAULT '' COMMENT '小鹅通资源ID',
+    progress_percent INT DEFAULT 0 COMMENT '学习进度百分比',
+    completed_hours DECIMAL(6,2) DEFAULT 0 COMMENT '已完成课时',
+    completed TINYINT(1) DEFAULT 0 COMMENT '是否完成',
+    last_synced_at DATETIME DEFAULT NULL COMMENT '最近同步时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_user_course (user_id, course_id),
+    KEY idx_user (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规划师线上课程学习进度';
+
+-- ④ 活动→认证类别映射(桥接表:线下培训课复用 activities 模块)
+CREATE TABLE IF NOT EXISTS planner_offline_course_map (
+    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+    activity_id BIGINT NOT NULL COMMENT '关联 activities.id(线下培训活动)',
+    category_id INT NOT NULL COMMENT '关联 planner_cert_categories.id',
+    duration_hours DECIMAL(6,2) DEFAULT 0 COMMENT '该活动签到后计入的课时',
+    is_required TINYINT(1) DEFAULT 0 COMMENT '是否必修环节',
+    status TINYINT DEFAULT 1 COMMENT '1启用 0停用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_activity (activity_id),
+    KEY idx_category (category_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='线下培训活动↔认证类别映射(桥接)';
+
+-- ⑤ 活动签到记录(复用 activities 模块,一并修复"签到审核形同虚设"旧患)
+CREATE TABLE IF NOT EXISTS activity_checkins (
+    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+    activity_id BIGINT NOT NULL COMMENT '活动ID(activities.id)',
+    user_id BIGINT NOT NULL COMMENT '签到人(规划师=userId)',
+    registration_id BIGINT DEFAULT NULL COMMENT '关联报名记录(activity_registrations.id)',
+    check_in_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '签到时间',
+    check_in_type VARCHAR(16) DEFAULT 'qr' COMMENT 'qr扫码/admin手动/gps',
+    check_in_by INT DEFAULT NULL COMMENT 'admin手动补录人ID',
+    review_status VARCHAR(16) DEFAULT 'approved' COMMENT 'approved/pending/rejected(对应原 requireCheckinReview)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_activity_user (activity_id, user_id),
+    KEY idx_user (user_id),
+    KEY idx_activity (activity_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动签到记录(通用,供培训课时桥接)';
+
+-- ⑥ 规划师认证主表(每用户每类别一行)
+CREATE TABLE IF NOT EXISTS planner_certifications (
+    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+    user_id BIGINT NOT NULL COMMENT '规划师用户ID(users.id)',
+    guide_id BIGINT DEFAULT NULL COMMENT '关联 guides.id',
+    category_id INT NOT NULL COMMENT '所属学习种类(planner_cert_categories.id)',
+    category_code VARCHAR(32) DEFAULT '' COMMENT '冗余类别编码,便于查询',
+    required_hours DECIMAL(6,2) DEFAULT 0 COMMENT '该类别总要求课时',
+    completed_hours DECIMAL(6,2) DEFAULT 0 COMMENT '已完成课时(线上+线下汇总)',
+    learning_status VARCHAR(20) DEFAULT 'pending_learning' COMMENT 'pending_learning/learning_completed',
+    exam_status VARCHAR(20) DEFAULT 'none' COMMENT 'none/exam_failed/exam_passed',
+    exam_score INT DEFAULT NULL COMMENT '考核分数',
+    exam_attempts INT DEFAULT 0 COMMENT '考核尝试次数',
+    cert_no VARCHAR(64) DEFAULT '' COMMENT '证书编号',
+    certified_at DATETIME DEFAULT NULL COMMENT '认证通过时间',
+    expire_at DATETIME DEFAULT NULL COMMENT '有效期(NULL=不过期)',
+    status VARCHAR(20) DEFAULT 'pending_cert' COMMENT 'pending_cert/learning_completed/certified/suspended',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_user_category (user_id, category_id),
+    KEY idx_status (status),
+    KEY idx_user (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规划师每类别培训认证主表';
+
+-- ⑦ 考核试卷(每类别可有多套)
+CREATE TABLE IF NOT EXISTS planner_exam_papers (
+    id INT NOT NULL AUTO_INCREMENT COMMENT '主键',
+    category_id INT NOT NULL COMMENT '所属学习种类(planner_cert_categories.id)',
+    title VARCHAR(120) NOT NULL COMMENT '试卷标题',
+    pass_score INT NOT NULL DEFAULT 60 COMMENT '及格分',
+    duration_minutes INT DEFAULT 30 COMMENT '限时(分钟)',
+    question_count INT DEFAULT 0 COMMENT '题目数',
+    status TINYINT DEFAULT 1 COMMENT '1启用 0停用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (id),
+    KEY idx_category (category_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规划师考核试卷';
+
+-- ⑧ 考核题库
+CREATE TABLE IF NOT EXISTS planner_exam_questions (
+    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+    paper_id INT NOT NULL COMMENT '试卷ID(planner_exam_papers.id)',
+    question TEXT NOT NULL COMMENT '题干',
+    options TEXT COMMENT '选项JSON数组',
+    answer VARCHAR(10) COMMENT '正确选项',
+    score INT DEFAULT 1 COMMENT '分值',
+    order_index INT DEFAULT 0 COMMENT '排序',
+    PRIMARY KEY (id),
+    KEY idx_paper (paper_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考核题库';
+
+-- ⑨ 考核作答记录
+CREATE TABLE IF NOT EXISTS planner_exam_attempts (
+    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+    user_id BIGINT NOT NULL COMMENT '规划师用户ID(users.id)',
+    category_id INT NOT NULL COMMENT '所属学习种类',
+    paper_id INT NOT NULL COMMENT '试卷ID',
+    score INT DEFAULT 0 COMMENT '得分',
+    passed TINYINT(1) DEFAULT 0 COMMENT '是否通过',
+    answers TEXT COMMENT 'JSON 作答明细',
+    started_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间',
+    submitted_at DATETIME DEFAULT NULL COMMENT '提交时间',
+    PRIMARY KEY (id),
+    KEY idx_user (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规划师考核作答记录';

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

@@ -256,6 +256,7 @@
 | 维度页面差距修复(Phase 1) | 2026-07 | `plans/2026-07-12-dimension-pages-gap-fix.md` |
 | 管理后台手机自适应 | 2026-07 | `plans/2026-07-24-cfc-web-responsive-implementation.md` |
 | DAN测评执行记录双角色模型(assessor/planner分离) | 2026-07 | — |
+| 规划师培训认证 + 接单闸门(9 表 + 类别认证状态机 + 中央闸门,后端) | 2026-10 | `specs/2026-10-22-planner-learning-assessment-gate-design.md` |
 | 家庭成员辈分重构(全栈) | 2026-07 | `plans/2026-07-07-家庭成员辈分重构实施计划.md` |
 | 会员等级价格体系更新(FREE/FAMILY/PREMIUM) | 2026-07 | `plans/2026-07-28-membership-price-tier-update.md` |
 | **LIFETIME 终身会员(等级扩展+实时聚合+管理员审核)** | **2026-08** | **`plans/2026-08-09-lifetime-membership.md`** |
@@ -396,6 +397,7 @@
 | `2026-10-20-limited-time-special-price-design.md` | 🟢 已实现(迁移389 `special_price_deals` 表 + 迁移390 `product_orders.special_deal_id`;`SpecialPriceService`(computeRunStatus/list/create/update/updateStatus/delete/consumeQuota/releaseQuota/getUserPurchased + Mapper XML 原子扣量/回滚/按活动计数);公开 `POST /api/special-price/info`(JwtInterceptor 白名单)+ admin 6 接口;下单 `create()` 按 `specialDealId` 委托 `createSpecialDealOrder`(时间窗/互斥/每人限购/券开关/原子扣量紧跟 insert),回滚挂 cancel/approveRefund/OrderTimeoutCancelTask 三处 releaseQuota(GREATEST 防负);供应商改价 `reviewUpdate` 拒绝特价单;小程序商品页 scene `sp=` 解析 + 特价区块/倒计时/sold_out 回落 + checkout 一次性消费 storage key 透传特价单 + 特价禁券隐藏优惠券区;cfc-web `SpecialPriceManage.vue` + 路由 + admin.js 6 封装;5 测试类 31 case 全过 + `mvn clean compile`/全量 `mvn test -DskipTests=false` BUILD SUCCESS) | 限时特价(扫码闪购)设计规格 |
 | `2026-10-22-activity-poster-redesign.md` | ✅ 已实现(新建 `components/ActivityPoster.vue` 活动专属海报组件:500×800 画布,品牌渐变头 + 封面图 + 信息卡片(时间/地点/截止/人数/费用)+ 二维码「立即报名」CTA;`activity-detail` 改用该组件替换通用 ContentSharePoster,费用显示与详情页 feeText 口径一致) | 活动专属海报重新设计 |
 | `2026-10-22-activity-registration-roster.md` | ✅ 已实现(活动详情页「报名情况」:新增 `POST /api/activity/registration-roster`(`ActivityRegistrationService.getRegistrationRoster`,实时统计 `approvedCount`/`pendingCount`/`remaining` + 最近20条已通过报名人姓名脱敏 `DataMaskUtil.maskName`,不含手机号,游客可访问);`activity-detail.vue` 元信息后插入报名情况卡片(统计列 + 进度条 + 报名人列表,`requireRegistration===1` 才显示),报名/取消/登录后刷新;`utils/api.js` 新增封装;`mvn clean compile` 通过) | 活动详情页报名情况展示 |
+| `2026-10-22-planner-learning-assessment-gate-design.md` | 🟢 后端已实现,前端待建(9 表:`planner_cert_categories`/`planner_courses`/`planner_course_progress`/`planner_offline_course_map`/`activity_checkins`/`planner_certifications`/`planner_exam_papers`/`planner_exam_questions`/`planner_exam_attempts`,迁移 394-401 幂等 + 首批种子 3 类 planner/nutritionist/assessor + 迁移 401 存量 Guide(active) 补登;`PlannerCertificationService` 认证状态机(init/learning → exam_passed → certified,基础类别通过同步 `Guide.status=active`)+ `PlannerCertificationGuard` 中央闸门 `assertCertified(userId, categoryCode)`;`ServiceRoleApplicationServiceImpl` planner 分支改造为 `pending_cert` + 按类别初始化认证;接单闸门已注入 7 类入口(package/add、package/{id}/update、`BindController.accept`、`PlanReviewController.activate`、`TeacherMessageController.reply`、`GuideOrderController` list/stats/detail=planner 基础,`GuideAssessmentController` family/enable=assessor);规划师端 7 接口 + 管理端 26 接口 + 小鹅通 webhook 2 接口(`/api/xiaoe/webhook` 签名校验幂等 vs `XiaoeWebhookAdapter`,另有 `XiaoeManualAdapter` 兜底);API_REFERENCE.md §5 已同步。**待办**:规划师端「培训认证」中心页、cfc-web 认证看板/课程/题库管理页、E2E 场景验证、营养类目(nutritionist)闸门待规划师侧营养方案入口建设后注入) | 规划师在线学习·多类别认证·线下打卡·接单闸门 |
 
 ### 实施计划(plans/)
 

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

@@ -80,6 +80,14 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `ServiceRoleApplicationController` | `/api/service-role` | 统一服务角色申请(多角色合一 + 管理端审批) | — |
 | `GuideTeamController` | `/api/guide/team` | 规划师团队/邀请码/上下级 | — |
 | `GuideFamilyTaskController` | `/api/guide/families` | 规划师侧家庭任务管理 + 家庭解绑 | — |
+| `PlannerCertificationController` | `/api/planner` | 规划师培训认证中心(我的认证/课程/线下课打卡/考核) | — |
+| `XiaoeWebhookController` | `/api/xiaoe` | 小鹅通 webhook 回调(线上课时事件) | — |
+| `AdminPlannerCertCategoryController` | `/api/admin/planner-cert-categories` | 管理端-学习种类 CRUD | — |
+| `AdminPlannerCourseController` | `/api/admin/planner-courses` | 管理端-线上课程 CRUD | — |
+| `AdminPlannerOfflineCourseController` | `/api/admin/planner-offline-course-map` | 管理端-线下课↔类别映射 CRUD | — |
+| `AdminActivityCheckinController` | `/api/admin/activity-checkins` | 管理端-签到记录/手动补录 | — |
+| `AdminPlannerExamController` | `/api/admin/planner-exam` | 管理端-试卷/题库 CRUD | — |
+| `AdminPlannerCertificationController` | `/api/admin/planner-certifications` | 管理端-认证看板/同步课时/存量补登 | — |
 
 ---
 
@@ -1922,3 +1930,78 @@ Web 管理端定义活动海报模板(迁移381 `activity_poster_templates` 
 **前端入口:** cfc-web 新增 `/org` 路由四页面(List/Form/Detail/Dashboard);菜单「组织管理」挂在系统配置分组下。组织管理员登录后自动跳 `/org/:id/dashboard`,不展示组织列表页。
 
 *文档更新:2026-10-22(组织管理功能上线)*
+
+## 五、规划师培训认证(`/api/planner/*`, `/api/admin/planner-*`, `/api/xiaoe/*`)
+
+> 设计文档:`docs/superpowers/specs/2026-10-22-planner-learning-assessment-gate-design.md`
+> 核心链路:身份审核通过 → Guide.status=pending_cert + 按类别初始化 `planner_certifications`
+> → 线上课时(小鹅通 webhook/手动同步)+ 线下课时(activities 打卡桥接)→ 考核通过 →
+> 类别 `certified`(基础类别 planner 通过时同步翻 `Guide.status=active`)→ 接单闸门放行。
+
+### 5.1 规划师端(`/api/planner/*`)
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/planner/certification/my` | 我的全部类别认证状态(线上/线下课时拆分、考核分、证书号、是否可考) |
+| `POST /api/planner/courses` | 某类别线上课程列表(含小鹅通跳转/H5 链接;body: `categoryCode`) |
+| `POST /api/planner/training-activities` | 可报名/签到的培训活动列表(复用 activities,过滤映射表+规划师可见) |
+| `POST /api/planner/training-activities/check-in` | 扫码/手动签到(body: `qr_token` 或 `activityId`+`admin`)→ 桥接发放课时 |
+| `POST /api/planner/exam/start` | 开始某类别考核(前置校验课时达标;body: `categoryCode`) |
+| `POST /api/planner/exam/submit` | 提交判分(body: `attemptId`+答案,写入 exam_score/exam_passed) |
+| `POST /api/planner/exam/result` | 考核结果 |
+
+### 5.2 管理端(`/api/admin/*`)
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/admin/planner-cert-categories/list` | 学习种类列表 |
+| `POST /api/admin/planner-cert-categories/create` | 创建学习种类(含 required_hours、is_base,首批种子:planner/nutritionist/assessor) |
+| `POST /api/admin/planner-cert-categories/update` | 更新学习种类 |
+| `POST /api/admin/planner-cert-categories/delete` | 删除学习种类 |
+| `POST /api/admin/planner-courses/list` | 线上课程列表 |
+| `POST /api/admin/planner-courses/create` | 创建线上课程(挂类别) |
+| `POST /api/admin/planner-courses/update` | 更新线上课程 |
+| `POST /api/admin/planner-courses/delete` | 删除线上课程 |
+| `POST /api/admin/planner-offline-course-map/list` | 培训活动↔类别映射列表 |
+| `POST /api/admin/planner-offline-course-map/create` | 创建映射(activities 建课后关联类别与计课时) |
+| `POST /api/admin/planner-offline-course-map/update` | 更新映射 |
+| `POST /api/admin/planner-offline-course-map/delete` | 删除映射 |
+| `POST /api/admin/activity-checkins/list` | 签到记录查询 |
+| `POST /api/admin/activity-checkins/add` | 手动补录签到(桥接触发课时入账) |
+| `POST /api/admin/activity-checkins/review` | 签到审核 |
+| `POST /api/admin/planner-exam/papers` | 试卷列表(按类别) |
+| `POST /api/admin/planner-exam/paper/create` | 创建试卷 |
+| `POST /api/admin/planner-exam/paper/update` | 更新试卷 |
+| `POST /api/admin/planner-exam/paper/delete` | 删除试卷 |
+| `POST /api/admin/planner-exam/questions` | 题库列表 |
+| `POST /api/admin/planner-exam/questions/save` | 保存题目(新建/更新) |
+| `POST /api/admin/planner-certifications/list` | 认证看板(按类别:课时进度、考核分、证书号、有效期) |
+| `POST /api/admin/planner-certifications/sync-hours` | 手动同步线上课时(小鹅通 webhook 兜底,body: `userId`+`categoryCode`+课时) |
+| `POST /api/admin/planner-certifications/sync-hours/batch` | 批量同步线上课时 |
+| `POST /api/admin/planner-certifications/init-by-user` | 按用户初始化各类别认证记录 |
+| `POST /api/admin/planner-certifications/backfill` | 存量补登(迁移401 幂等重跑) |
+
+### 5.3 小鹅通回调(`/api/xiaoe/*`)
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/xiaoe/webhook` | 线上学习进度/课程完成事件(签名校验、幂等;课时入账 + 认证记录更新) |
+| `POST /api/xiaoe/webhook/test` | webhook 联调用测试入口 |
+
+### 5.4 接单闸门(`PlannerCertificationGuard`)
+
+- 中央守卫 `assertCertified(userId, categoryCode[, baseRequired])`:查询该类别 `planner_certifications.status == certified`(且 `Guide.status == active`)方可放行,否则返回 `Result.error("规划师未完成【类别名】培训认证,暂不可提供该类服务")`(null=放行,非 null=拒绝)。
+- 已注入闸门的服务入口(设计 §9 映射表):
+
+| 服务动作 | 入口 | 所需类别 |
+|----------|------|----------|
+| 上架/修改服务套餐 | `GuideManagementController` package/add、package/{packageId}/update | `planner` |
+| 家庭绑定(开始服务) | `BindController.accept` | `planner` |
+| 激活成长方案(下发任务) | `PlanReviewController.activate` | `planner` |
+| 咨询响应/接单 | `TeacherMessageController.reply` | `planner` |
+| 订单中心(列表/统计/详情) | `GuideOrderController` /list、/stats、/detail | `planner` |
+| 测评录入/解读 | `GuideAssessmentController` family/enable | `assessor` |
+
+- 待建设入口(当前代码不存在,预留):营养方案/饮食建议(`nutritionist`)。
+
+*文档更新:2026-10-22(规划师培训认证模块落地:9 表 + 迁移394-401 + 规划师端/管理端/webhook 接口 + 接单闸门)*