Selaa lähdekoodia

Phase6: 徽章与成长体系

liaoxg 1 viikko sitten
vanhempi
sitoutus
5cb6b4d

+ 27 - 0
train-backend/src/main/java/com/train/controller/BadgeController.java

@@ -0,0 +1,27 @@
+package com.train.controller;
+
+import com.train.common.Result;
+import com.train.service.BadgeService;
+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.Map;
+
+@Tag(name = "徽章与成长体系", description = "学员端徽章查询")
+@RestController
+@RequestMapping("/api/badge")
+public class BadgeController {
+
+    @Resource
+    private BadgeService badgeService;
+
+    @Operation(summary = "我的徽章")
+    @PostMapping("/mine")
+    public Result<Map<String, Object>> mine(
+            @org.springframework.web.bind.annotation.RequestAttribute("userId") Long userId) {
+        Map<String, Object> data = badgeService.mine(userId);
+        return Result.success(data);
+    }
+}

+ 46 - 0
train-backend/src/main/java/com/train/controller/admin/AdminBadgeController.java

@@ -0,0 +1,46 @@
+package com.train.controller.admin;
+
+import com.train.common.Result;
+import com.train.service.BadgeService;
+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.Map;
+
+@Tag(name = "管理端-徽章", description = "徽章手动颁发")
+@RestController
+@RequestMapping("/api/admin/badge")
+public class AdminBadgeController {
+
+    @Resource
+    private BadgeService badgeService;
+
+    @Operation(summary = "手动颁发徽章")
+    @PostMapping("/grant")
+    public Result<?> grant(@RequestBody Map<String, Object> body,
+                           @org.springframework.web.bind.annotation.RequestAttribute("adminId") Long adminId) {
+        Object uidObj = body.get("uid");
+        if (uidObj == null) {
+            return Result.error("学员ID不能为空");
+        }
+        Long uid;
+        try {
+            uid = Long.valueOf(uidObj.toString());
+        } catch (NumberFormatException e) {
+            return Result.error("学员ID不合法");
+        }
+        String badgeKey = body.get("badgeKey") == null ? null : body.get("badgeKey").toString().trim();
+        if (badgeKey == null || badgeKey.isEmpty()) {
+            return Result.error("徽章键不能为空");
+        }
+        String remark = body.get("remark") == null ? null : body.get("remark").toString();
+
+        boolean ok = badgeService.grantBadge(uid, badgeKey, adminId, remark);
+        if (!ok) {
+            return Result.error("徽章键不存在或用户不存在");
+        }
+        return Result.success();
+    }
+}

+ 40 - 0
train-backend/src/main/java/com/train/entity/TrainBadge.java

@@ -0,0 +1,40 @@
+package com.train.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("train_badge")
+public class TrainBadge implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 徽章键,如 mod_s1/course_start/skill_pitch/ms_light/lec_t1 */
+    private String badgeKey;
+
+    /** module/course/skill/milestone/lecturer */
+    private String category;
+
+    /** 徽章名 */
+    private String name;
+
+    /** 标识色(hex,如 #7C3AED) */
+    private String color;
+
+    /** 载体:电子/实体 */
+    private String carrier;
+
+    /** 获得条件描述(前端展示用) */
+    private String conditionDesc;
+
+    /** 排序 */
+    private Integer sort;
+
+    private Date createdAt;
+}

+ 32 - 0
train-backend/src/main/java/com/train/entity/TrainUserBadge.java

@@ -0,0 +1,32 @@
+package com.train.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("train_user_badge")
+public class TrainUserBadge implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 学员ID(train_user.id) */
+    private Long uid;
+
+    /** 徽章键 */
+    private String badgeKey;
+
+    /** 获得依据(判定结果/手动备注) */
+    private String proof;
+
+    /** 颁发人(train_admin.id,0=系统自动) */
+    private Long grantedBy;
+
+    /** 获得时间 */
+    private Date earnedAt;
+}

+ 9 - 0
train-backend/src/main/java/com/train/mapper/TrainBadgeMapper.java

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

+ 9 - 0
train-backend/src/main/java/com/train/mapper/TrainUserBadgeMapper.java

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

+ 386 - 0
train-backend/src/main/java/com/train/service/BadgeService.java

@@ -0,0 +1,386 @@
+package com.train.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.train.entity.*;
+import com.train.mapper.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 徽章与成长体系服务:
+ * - ensureCatalog():目录兜底同步(schema 种子 + 代码常量双保险)
+ * - syncBadges(uid):自动判定并颁发符合条件的徽章
+ * - grantBadge(uid, badgeKey, adminId, remark):手动颁发(幂等)
+ * - mine(uid):查询学员全量徽章 + 段位
+ */
+@Slf4j
+@Service
+public class BadgeService {
+
+    @Resource
+    private TrainBadgeMapper trainBadgeMapper;
+    @Resource
+    private TrainUserBadgeMapper trainUserBadgeMapper;
+    @Resource
+    private TrainCertificateMapper trainCertificateMapper;
+    @Resource
+    private TrainClassMapper trainClassMapper;
+    @Resource
+    private TrainCourseMapper trainCourseMapper;
+    @Resource
+    private TrainInviteMapper trainInviteMapper;
+    @Resource
+    private TrainRoadmapMapper trainRoadmapMapper;
+    @Resource
+    private TrainGroupMemberMapper trainGroupMemberMapper;
+    @Resource
+    private TrainVoteMapper trainVoteMapper;
+    @Resource
+    private TrainEnrollmentMapper trainEnrollmentMapper;
+
+    // ========== 32 枚徽章常量(兜底) ==========
+    private static final List<Map<String, String>> CATALOG = Arrays.asList(
+            // module 1-12
+            badge("mod_s1", "module", "启航·上", "#7C3AED", "电子", "完成 S1 家庭目录树检查", 1),
+            badge("mod_s2", "module", "启航·下", "#7C3AED", "电子", "完成 S2 路演两轮投票", 2),
+            badge("mod_a1", "module", "财富诊断", "#F59E0B", "电子", "完成 L1-A1 提交真实财富报告", 3),
+            badge("mod_a2", "module", "财富系统", "#F59E0B", "电子", "完成 L1-A2 自动化生效", 4),
+            badge("mod_h1", "module", "健康趋势", "#10B981", "电子", "完成 L1-H1 提交风险清单", 5),
+            badge("mod_h2", "module", "健康系统", "#10B981", "电子", "完成 L1-H2 解读菌群报告", 6),
+            badge("mod_g1", "module", "成长拆解", "#8B5CF6", "电子", "完成 L1-G1 习惯实验", 7),
+            badge("mod_g2", "module", "成长系统", "#8B5CF6", "电子", "完成 L1-G2 学期计划", 8),
+            badge("mod_b1", "module", "系统架构", "#1D4ED8", "电子", "完成 L2-B1 ≥5条联动规则", 9),
+            badge("mod_b2", "module", "自动化工厂", "#1D4ED8", "电子", "完成 L2-B2 ≥8条自动化", 10),
+            badge("mod_b3", "module", "仪表盘", "#1D4ED8", "电子", "完成 L2-B3 看板上线", 11),
+            badge("mod_b4", "module", "季度闭环", "#1D4ED8", "电子", "完成 L2-B4 系统体检", 12),
+            // course 13-17
+            badge("course_start", "course", "启航徽章", "#7C3AED", "实体+证书", "L0 完课", 13),
+            badge("course_wealth", "course", "财富专精徽章", "#F59E0B", "电子", "L1-A 完课", 14),
+            badge("course_health", "course", "健康专精徽章", "#10B981", "电子", "L1-B 完课", 15),
+            badge("course_growth", "course", "成长规划专精徽章", "#8B5CF6", "电子", "L1-C 完课", 16),
+            badge("course_system", "course", "系统师徽章", "#14B8A6", "电子", "L2 完课+L3 结业", 17),
+            // skill 18-23
+            badge("skill_mask", "skill", "脱敏达人", "#64748B", "电子", "找茬赛满分/脱敏零问题", 18),
+            badge("skill_prompt", "skill", "提示词匠人", "#64748B", "电子", "改编提示词≥10条被采用", 19),
+            badge("skill_automation", "skill", "自动化搭建者", "#64748B", "电子", "自建自动化≥5条稳定1月", 20),
+            badge("skill_pitch", "skill", "路演讲者", "#64748B", "电子", "L0路演获最想抄走票", 21),
+            badge("skill_board", "skill", "看板设计师", "#64748B", "电子", "仪表盘获最佳", 22),
+            badge("skill_retest", "skill", "复测坚持者", "#64748B", "电子", "连续2季度复测", 23),
+            // milestone 24-28
+            badge("ms_21day", "milestone", "21天全勤", "#F59E0B", "实体", "L3打卡≥21天", 24),
+            badge("ms_light", "milestone", "传灯徽章", "#F97316", "实体", "成功转介≥3人报名", 25),
+            badge("ms_repeat", "milestone", "回炉徽章", "#F97316", "电子", "复训≥3模块", 26),
+            badge("ms_share", "milestone", "分享徽章", "#F97316", "实体", "沙龙案例分享≥1次", 27),
+            badge("ms_review", "milestone", "年度复盘", "#F59E0B", "电子", "完成三本账年度复盘", 28),
+            // lecturer 29-32
+            badge("lec_t1", "lecturer", "讲师·铜", "#F97316", "实体+讲师证", "T1认证", 29),
+            badge("lec_t2", "lecturer", "讲师·银", "#D4D4D8", "电子", "T2认证", 30),
+            badge("lec_t3", "lecturer", "讲师·金", "#F59E0B", "电子", "T3认证", 31),
+            badge("lec_t4", "lecturer", "讲师·珊瑚金", "#F97316", "电子", "T4认证", 32)
+    );
+
+    private static Map<String, String> badge(String key, String category, String name,
+                                             String color, String carrier, String conditionDesc, int sort) {
+        Map<String, String> m = new LinkedHashMap<>();
+        m.put("badge_key", key);
+        m.put("category", category);
+        m.put("name", name);
+        m.put("color", color);
+        m.put("carrier", carrier);
+        m.put("condition_desc", conditionDesc);
+        m.put("sort", String.valueOf(sort));
+        return m;
+    }
+
+    // ========== a) 目录兜底同步 ==========
+    public void ensureCatalog() {
+        Long count = trainBadgeMapper.selectCount(new LambdaQueryWrapper<TrainBadge>());
+        if (count != null && count > 0) {
+            return;
+        }
+        log.info("[BadgeService] 徽章目录为空,执行代码常量兜底插入 {} 条", CATALOG.size());
+        for (Map<String, String> row : CATALOG) {
+            try {
+                TrainBadge b = new TrainBadge();
+                b.setBadgeKey(row.get("badge_key"));
+                b.setCategory(row.get("category"));
+                b.setName(row.get("name"));
+                b.setColor(row.get("color"));
+                b.setCarrier(row.get("carrier"));
+                b.setConditionDesc(row.get("condition_desc"));
+                b.setSort(Integer.parseInt(row.get("sort")));
+                b.setCreatedAt(new Date());
+                trainBadgeMapper.insert(b);
+            } catch (Exception e) {
+                // 重复插入(badge_key UNIQUE)被吞,属预期
+                log.debug("插入徽章 {} 失败(可能已存在): {}", row.get("badge_key"), e.getMessage());
+            }
+        }
+    }
+
+    // ========== b) 自动判定并颁发 ==========
+    @Transactional(rollbackFor = Exception.class)
+    public void syncBadges(Long uid) {
+        ensureCatalog();
+
+        // 已拥有的徽章 key 集合
+        List<TrainUserBadge> existingList = trainUserBadgeMapper.selectList(
+                new LambdaQueryWrapper<TrainUserBadge>().eq(TrainUserBadge::getUid, uid));
+        Set<String> ownedKeys = existingList.stream()
+                .map(TrainUserBadge::getBadgeKey)
+                .collect(Collectors.toSet());
+
+        // 证书列表
+        List<TrainCertificate> certs = trainCertificateMapper.selectList(
+                new LambdaQueryWrapper<TrainCertificate>().eq(TrainCertificate::getUid, uid));
+        Set<String> certLevels = certs.stream()
+                .map(TrainCertificate::getLevel)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+
+        // 收集待颁发的徽章 key
+        Set<String> toGrant = new LinkedHashSet<>();
+
+        // --- L0 相关 ---
+        if (certLevels.contains("L0")) {
+            toGrant.add("mod_s1");
+            toGrant.add("mod_s2");
+            toGrant.add("course_start");
+        }
+
+        // --- L2 相关 ---
+        if (certLevels.contains("L2")) {
+            toGrant.add("mod_b1");
+            toGrant.add("mod_b2");
+            toGrant.add("mod_b3");
+            toGrant.add("mod_b4");
+        }
+
+        // --- course_system:L2 + L3 ---
+        if (certLevels.contains("L2") && certLevels.contains("L3")) {
+            toGrant.add("course_system");
+        }
+
+        // --- L1 证书按课程名细分 ---
+        // 对每张 level=L1 的证书,解析课程名(cert -> class -> course -> name)
+        Set<String> l1CourseNames = new HashSet<>();
+        for (TrainCertificate cert : certs) {
+            if (!"L1".equals(cert.getLevel()) || cert.getClassId() == null) {
+                continue;
+            }
+            TrainClass clazz = trainClassMapper.selectById(cert.getClassId());
+            if (clazz == null || clazz.getCourseId() == null) {
+                continue;
+            }
+            TrainCourse course = trainCourseMapper.selectById(clazz.getCourseId());
+            if (course == null || course.getName() == null) {
+                continue;
+            }
+            l1CourseNames.add(course.getName());
+        }
+        for (String courseName : l1CourseNames) {
+            if (courseName.contains("财富")) {
+                toGrant.add("mod_a1");
+                toGrant.add("mod_a2");
+                toGrant.add("course_wealth");
+            }
+            if (courseName.contains("健康")) {
+                toGrant.add("mod_h1");
+                toGrant.add("mod_h2");
+                toGrant.add("course_health");
+            }
+            if (courseName.contains("成长")) {
+                toGrant.add("mod_g1");
+                toGrant.add("mod_g2");
+                toGrant.add("course_growth");
+            }
+        }
+
+        // --- skill_pitch(路演讲者)---
+        // uid 所在的组 → 该组的 roadmap speaker_uid=uid → 该组在 round=2 投票中被投中
+        if (!ownedKeys.contains("skill_pitch")) {
+            List<TrainGroupMember> members = trainGroupMemberMapper.selectList(
+                    new LambdaQueryWrapper<TrainGroupMember>().eq(TrainGroupMember::getUid, uid));
+            for (TrainGroupMember m : members) {
+                if (m.getGroupId() == null) continue;
+                // 查该组是否有 speaker_uid=uid 的路演记录
+                TrainRoadmap roadmap = trainRoadmapMapper.selectOne(
+                        new LambdaQueryWrapper<TrainRoadmap>()
+                                .eq(TrainRoadmap::getGroupId, m.getGroupId())
+                                .eq(TrainRoadmap::getSpeakerUid, uid)
+                                .last("LIMIT 1"));
+                if (roadmap == null) continue;
+                // 该组在 round=2 投票中是否被投中
+                Long voteCount = trainVoteMapper.selectCount(
+                        new LambdaQueryWrapper<TrainVote>()
+                                .eq(TrainVote::getRound, 2)
+                                .eq(TrainVote::getTargetGroupId, m.getGroupId()));
+                if (voteCount != null && voteCount > 0) {
+                    toGrant.add("skill_pitch");
+                    break;
+                }
+            }
+        }
+
+        // --- ms_light(传灯):成功转介 >= 3 人 ---
+        if (!ownedKeys.contains("ms_light")) {
+            Long inviteCount = trainInviteMapper.selectCount(
+                    new LambdaQueryWrapper<TrainInvite>()
+                            .eq(TrainInvite::getInviterId, uid)
+                            .eq(TrainInvite::getSuccessful, 1));
+            if (inviteCount != null && inviteCount >= 3) {
+                toGrant.add("ms_light");
+            }
+        }
+
+        // 批量颁发(幂等:已存在的跳过)
+        for (String key : toGrant) {
+            if (!ownedKeys.contains(key)) {
+                insertUserBadge(uid, key, "系统自动判定", 0L);
+            }
+        }
+    }
+
+    // ========== c) 手动颁发(幂等) ==========
+    @Transactional(rollbackFor = Exception.class)
+    public boolean grantBadge(Long uid, String badgeKey, Long adminId, String remark) {
+        ensureCatalog();
+
+        // 检查徽章键是否在目录中
+        TrainBadge badge = trainBadgeMapper.selectOne(
+                new LambdaQueryWrapper<TrainBadge>().eq(TrainBadge::getBadgeKey, badgeKey).last("LIMIT 1"));
+        if (badge == null) {
+            return false;
+        }
+
+        // 幂等:已存在则直接返回
+        Long exists = trainUserBadgeMapper.selectCount(
+                new LambdaQueryWrapper<TrainUserBadge>()
+                        .eq(TrainUserBadge::getUid, uid)
+                        .eq(TrainUserBadge::getBadgeKey, badgeKey));
+        if (exists != null && exists > 0) {
+            return true;
+        }
+
+        String proof = (remark != null && !remark.trim().isEmpty()) ? remark.trim() : badge.getConditionDesc();
+        insertUserBadge(uid, badgeKey, proof, adminId);
+        return true;
+    }
+
+    // ========== d) 学员端查询 ==========
+    public Map<String, Object> mine(Long uid) {
+        // 先触发自动判定
+        syncBadges(uid);
+
+        // 全量徽章目录(按 sort 升序)
+        List<TrainBadge> allBadges = trainBadgeMapper.selectList(
+                new LambdaQueryWrapper<TrainBadge>().orderByAsc(TrainBadge::getSort));
+
+        // 该用户已获得的徽章
+        List<TrainUserBadge> userBadges = trainUserBadgeMapper.selectList(
+                new LambdaQueryWrapper<TrainUserBadge>().eq(TrainUserBadge::getUid, uid));
+
+        // 构建 badgeKey -> UserBadge 的 map
+        Map<String, TrainUserBadge> earnedMap = userBadges.stream()
+                .collect(Collectors.toMap(TrainUserBadge::getBadgeKey, ub -> ub, (a, b) -> a));
+
+        // 构建 badge key -> badge catalog 的 map
+        Map<String, TrainBadge> catalogMap = allBadges.stream()
+                .collect(Collectors.toMap(TrainBadge::getBadgeKey, b -> b, (a, b) -> a));
+
+        // badges 列表
+        List<Map<String, Object>> badges = new ArrayList<>();
+        int earnedCount = 0;
+        for (TrainBadge b : allBadges) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("badgeKey", b.getBadgeKey());
+            item.put("category", b.getCategory());
+            item.put("name", b.getName());
+            item.put("color", b.getColor());
+            item.put("carrier", b.getCarrier());
+            item.put("condition", b.getConditionDesc());
+            TrainUserBadge ub = earnedMap.get(b.getBadgeKey());
+            boolean earned = (ub != null);
+            item.put("earned", earned);
+            item.put("earnedAt", ub != null ? ub.getEarnedAt() : null);
+            if (earned) {
+                earnedCount++;
+            }
+            badges.add(item);
+        }
+
+        // 段位计算(取最高档)
+        Map<String, String> grade = calcGrade(uid, earnedMap);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("grade", grade);
+        result.put("earnedCount", earnedCount);
+        result.put("badges", badges);
+        return result;
+    }
+
+    // ========== 段位计算 ==========
+    private Map<String, String> calcGrade(Long uid, Map<String, TrainUserBadge> earnedMap) {
+        Map<String, String> grade = new LinkedHashMap<>();
+
+        // 讲师(任一 lec_t1..t4 已获)
+        if (earnedMap.containsKey("lec_t1") || earnedMap.containsKey("lec_t2")
+                || earnedMap.containsKey("lec_t3") || earnedMap.containsKey("lec_t4")) {
+            grade.put("key", "lecturer");
+            grade.put("name", "讲师");
+            return grade;
+        }
+        // 系统师
+        if (earnedMap.containsKey("course_system")) {
+            grade.put("key", "system");
+            grade.put("name", "系统师");
+            return grade;
+        }
+        // 专精
+        if (earnedMap.containsKey("course_wealth") || earnedMap.containsKey("course_health")
+                || earnedMap.containsKey("course_growth")) {
+            grade.put("key", "expert");
+            grade.put("name", "专精");
+            return grade;
+        }
+        // 启航
+        if (earnedMap.containsKey("course_start")) {
+            grade.put("key", "starter");
+            grade.put("name", "启航");
+            return grade;
+        }
+        // 见习(参加 ≥1 期活动:存在报名记录)
+        Long enrollCount = trainEnrollmentMapper.selectCount(
+                new LambdaQueryWrapper<TrainEnrollment>()
+                        .eq(TrainEnrollment::getUid, uid)
+                        .isNotNull(TrainEnrollment::getId));
+        if (enrollCount != null && enrollCount > 0) {
+            grade.put("key", "novice");
+            grade.put("name", "见习");
+            return grade;
+        }
+        // 未入门(无任何学习记录)
+        return null;
+    }
+
+    // ========== 内部辅助 ==========
+    private void insertUserBadge(Long uid, String badgeKey, String proof, Long grantedBy) {
+        try {
+            TrainUserBadge ub = new TrainUserBadge();
+            ub.setUid(uid);
+            ub.setBadgeKey(badgeKey);
+            ub.setProof(proof);
+            ub.setGrantedBy(grantedBy);
+            ub.setEarnedAt(new Date());
+            trainUserBadgeMapper.insert(ub);
+        } catch (Exception e) {
+            // 重复插入(uid+badge_key UNIQUE)被吞,属预期
+            log.debug("用户 {} 颁发徽章 {} 失败(可能已存在): {}", uid, badgeKey, e.getMessage());
+        }
+    }
+}

+ 63 - 0
train-backend/src/main/resources/schema.sql

@@ -483,3 +483,66 @@ CREATE TABLE IF NOT EXISTS train_certificate (
     UNIQUE KEY uk_cert_no (cert_no),
     INDEX idx_uid (uid)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='完课证书';
+
+-- ========== 徽章与成长体系 ==========
+
+-- 徽章目录表(32 枚静态定义,种子数据随表插入)
+CREATE TABLE IF NOT EXISTS train_badge (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    badge_key VARCHAR(50) NOT NULL UNIQUE COMMENT '徽章键,如 mod_s1/course_start/skill_pitch/ms_light/lec_t1',
+    category VARCHAR(20) NOT NULL COMMENT 'module/course/skill/milestone/lecturer',
+    name VARCHAR(50) NOT NULL COMMENT '徽章名',
+    color VARCHAR(20) COMMENT '标识色(hex,如 #7C3AED)',
+    carrier VARCHAR(20) DEFAULT '电子' COMMENT '载体:电子/实体',
+    condition_desc VARCHAR(200) COMMENT '获得条件描述(前端展示用)',
+    sort INT DEFAULT 0 COMMENT '排序',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='徽章目录(32枚)';
+
+-- 用户徽章表
+CREATE TABLE IF NOT EXISTS train_user_badge (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    uid BIGINT NOT NULL COMMENT '学员ID(train_user.id)',
+    badge_key VARCHAR(50) NOT NULL COMMENT '徽章键',
+    proof VARCHAR(200) COMMENT '获得依据(判定结果/手动备注)',
+    granted_by BIGINT COMMENT '颁发人(train_admin.id,0=系统自动)',
+    earned_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '获得时间',
+    UNIQUE KEY uk_uid_badge (uid, badge_key),
+    INDEX idx_uid (uid)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户徽章';
+
+-- 徽章目录种子数据(32 条,ON DUPLICATE KEY UPDATE 幂等)
+INSERT INTO train_badge(badge_key,category,name,color,carrier,condition_desc,sort) VALUES
+('mod_s1','module','启航·上','#7C3AED','电子','完成 S1 家庭目录树检查',1),
+('mod_s2','module','启航·下','#7C3AED','电子','完成 S2 路演两轮投票',2),
+('mod_a1','module','财富诊断','#F59E0B','电子','完成 L1-A1 提交真实财富报告',3),
+('mod_a2','module','财富系统','#F59E0B','电子','完成 L1-A2 自动化生效',4),
+('mod_h1','module','健康趋势','#10B981','电子','完成 L1-H1 提交风险清单',5),
+('mod_h2','module','健康系统','#10B981','电子','完成 L1-H2 解读菌群报告',6),
+('mod_g1','module','成长拆解','#8B5CF6','电子','完成 L1-G1 习惯实验',7),
+('mod_g2','module','成长系统','#8B5CF6','电子','完成 L1-G2 学期计划',8),
+('mod_b1','module','系统架构','#1D4ED8','电子','完成 L2-B1 ≥5条联动规则',9),
+('mod_b2','module','自动化工厂','#1D4ED8','电子','完成 L2-B2 ≥8条自动化',10),
+('mod_b3','module','仪表盘','#1D4ED8','电子','完成 L2-B3 看板上线',11),
+('mod_b4','module','季度闭环','#1D4ED8','电子','完成 L2-B4 系统体检',12),
+('course_start','course','启航徽章','#7C3AED','实体+证书','L0 完课',13),
+('course_wealth','course','财富专精徽章','#F59E0B','电子','L1-A 完课',14),
+('course_health','course','健康专精徽章','#10B981','电子','L1-B 完课',15),
+('course_growth','course','成长规划专精徽章','#8B5CF6','电子','L1-C 完课',16),
+('course_system','course','系统师徽章','#14B8A6','电子','L2 完课+L3 结业',17),
+('skill_mask','skill','脱敏达人','#64748B','电子','找茬赛满分/脱敏零问题',18),
+('skill_prompt','skill','提示词匠人','#64748B','电子','改编提示词≥10条被采用',19),
+('skill_automation','skill','自动化搭建者','#64748B','电子','自建自动化≥5条稳定1月',20),
+('skill_pitch','skill','路演讲者','#64748B','电子','L0路演获最想抄走票',21),
+('skill_board','skill','看板设计师','#64748B','电子','仪表盘获最佳',22),
+('skill_retest','skill','复测坚持者','#64748B','电子','连续2季度复测',23),
+('ms_21day','milestone','21天全勤','#F59E0B','实体','L3打卡≥21天',24),
+('ms_light','milestone','传灯徽章','#F97316','实体','成功转介≥3人报名',25),
+('ms_repeat','milestone','回炉徽章','#F97316','电子','复训≥3模块',26),
+('ms_share','milestone','分享徽章','#F97316','实体','沙龙案例分享≥1次',27),
+('ms_review','milestone','年度复盘','#F59E0B','电子','完成三本账年度复盘',28),
+('lec_t1','lecturer','讲师·铜','#F97316','实体+讲师证','T1认证',29),
+('lec_t2','lecturer','讲师·银','#D4D4D8','电子','T2认证',30),
+('lec_t3','lecturer','讲师·金','#F59E0B','电子','T3认证',31),
+('lec_t4','lecturer','讲师·珊瑚金','#F97316','电子','T4认证',32)
+ON DUPLICATE KEY UPDATE name=VALUES(name);

+ 6 - 0
train-frontend/pages.json

@@ -173,6 +173,12 @@
       "style": {
         "navigationBarTitleText": "案例授权"
       }
+    },
+    {
+      "path": "pages/badge/index",
+      "style": {
+        "navigationBarTitleText": "成长地图"
+      }
     }
   ],
   "globalStyle": {

+ 254 - 0
train-frontend/pages/badge/index.vue

@@ -0,0 +1,254 @@
+<template>
+  <view class="badge-page">
+    <!-- 顶部段位横幅 -->
+    <view class="grade-banner" :style="{ backgroundColor: gradeColor }">
+      <view class="grade-info">
+        <text class="grade-title">{{ gradeText }}</text>
+        <text class="grade-sub">{{ gradeSubText }}</text>
+      </view>
+      <view class="grade-progress">
+        <text class="progress-text">已获得 {{ earnedCount }}/32</text>
+      </view>
+    </view>
+
+    <!-- 徽章分区 -->
+    <view class="badge-section" v-for="(group, gIdx) in badgeGroups" :key="gIdx">
+      <view class="section-header">
+        <text class="section-title">{{ group.title }}</text>
+      </view>
+      <view class="badge-grid">
+        <view 
+          class="badge-card" 
+          v-for="(badge, bIdx) in group.list" 
+          :key="bIdx"
+          @click="handleBadgeClick(badge)"
+          :style="badge.earned ? { backgroundColor: badge.color } : {}"
+        >
+          <view class="badge-content">
+            <text class="badge-icon" :class="badge.earned ? 'icon-earned' : ''">{{ badge.earned ? '✓' : '🔒' }}</text>
+            <text class="badge-name" :class="badge.earned ? 'name-earned' : ''">{{ badge.name }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+    
+    <view class="footer-space"></view>
+  </view>
+</template>
+
+<script>
+import { getMyBadges } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      loading: true,
+      grade: null,
+      earnedCount: 0,
+      badges: [],
+      gradeColorMap: {
+        novice: '#94A3B8',
+        starter: '#7C3AED',
+        expert: '#F59E0B',
+        system: '#14B8A6',
+        lecturer: '#F97316'
+      },
+      categoryMap: {
+        module: '模块徽章',
+        course: '课程徽章',
+        skill: '技能徽章',
+        milestone: '里程碑',
+        lecturer: '讲师荣誉'
+      }
+    }
+  },
+  computed: {
+    gradeColor() {
+      if (!this.grade || !this.grade.key) return '#CBD5E1'
+      return this.gradeColorMap[this.grade.key] || '#CBD5E1'
+    },
+    gradeText() {
+      if (!this.grade || !this.grade.name) return '未入门'
+      return this.grade.name
+    },
+    gradeSubText() {
+      if (!this.grade) return '参加沙龙开启成长之旅'
+      return '持续探索,攀登更高峰'
+    },
+    badgeGroups() {
+      var groups = []
+      var cats = ['module', 'course', 'skill', 'milestone', 'lecturer']
+      for (var i = 0; i < cats.length; i++) {
+        var cat = cats[i]
+        var list = this.badges.filter(function(b) {
+          return b.category === cat
+        })
+        if (list.length > 0) {
+          groups.push({
+            title: this.categoryMap[cat] || cat,
+            list: list
+          })
+        }
+      }
+      return groups
+    }
+  },
+  onLoad() {
+    this.fetchBadges()
+  },
+  methods: {
+    fetchBadges() {
+      var self = this
+      self.loading = true
+      getMyBadges().then(function(resp) {
+        var data = resp.data || {}
+        self.grade = data.grade || null
+        self.earnedCount = data.earnedCount || 0
+        self.badges = data.badges || []
+      }).catch(function(err) {
+        uni.showToast({
+          title: (err && err.message) || '加载失败',
+          icon: 'none'
+        })
+      }).finally(function() {
+        self.loading = false
+      })
+    },
+    handleBadgeClick(badge) {
+      if (badge.earned) {
+        uni.showToast({
+          title: '已获得',
+          icon: 'success'
+        })
+      } else {
+        uni.showModal({
+          title: '获得条件',
+          content: badge.condition || '暂无条件说明',
+          showCancel: false
+        })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.badge-page {
+  min-height: 100vh;
+  background: #F8FAFC;
+  padding: 32rpx;
+}
+
+.grade-banner {
+  border-radius: 24rpx;
+  padding: 48rpx 32rpx;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  color: #FFF;
+  margin-bottom: 40rpx;
+  box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.1);
+}
+
+.grade-info {
+  display: flex;
+  flex-direction: column;
+}
+
+.grade-title {
+  font-size: 44rpx;
+  font-weight: 700;
+  margin-bottom: 8rpx;
+}
+
+.grade-sub {
+  font-size: 26rpx;
+  opacity: 0.9;
+}
+
+.grade-progress {
+  background: rgba(255,255,255,0.2);
+  padding: 12rpx 24rpx;
+  border-radius: 32rpx;
+  backdrop-filter: blur(4px);
+}
+
+.progress-text {
+  font-size: 24rpx;
+  font-weight: 600;
+}
+
+.badge-section {
+  margin-bottom: 40rpx;
+}
+
+.section-header {
+  margin-bottom: 24rpx;
+  padding-left: 16rpx;
+  border-left: 8rpx solid #CBD5E1;
+}
+
+.section-title {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: #1E293B;
+}
+
+.badge-grid {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: flex-start;
+}
+
+.badge-card {
+  width: 180rpx;
+  height: 180rpx;
+  margin: 12rpx;
+  border-radius: 20rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: transform 0.1s;
+  background: #F1F5F9; /* Default locked color */
+  border: 2rpx solid #E2E8F0;
+}
+
+.badge-card:active {
+  transform: scale(0.95);
+}
+
+.badge-content {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  text-align: center;
+}
+
+.badge-icon {
+  font-size: 40rpx;
+  margin-bottom: 8rpx;
+  color: #94A3B8;
+}
+
+.badge-name {
+  font-size: 24rpx;
+  color: #64748B;
+  width: 140rpx;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: pre-wrap;
+  line-height: 1.4;
+}
+
+/* Earned state overrides */
+.icon-earned {
+  color: #FFF;
+}
+.name-earned {
+  color: rgba(255,255,255,0.9);
+}
+
+.footer-space {
+  height: 60rpx;
+}
+</style>

+ 36 - 26
train-frontend/pages/mine/index.vue

@@ -13,36 +13,42 @@
       <text class="section-title">🎓 完课证书</text>
       <view v-if="certLoading" class="empty-text">加载中...</view>
        <view v-else-if="cert.eligible && cert.certNo" class="cert-ok">
-        <text class="cert-no">{{ cert.certNo }}</text>
-        <text class="cert-tip">恭喜完成离场验收全部 6 项!</text>
-        <button class="mini-btn" @click="copyCertNo">复制证书号</button>
+         <text class="cert-no">{{ cert.certNo }}</text>
+         <text class="cert-tip">恭喜完成离场验收全部 6 项!</text>
+         <button class="mini-btn" @click="copyCertNo">复制证书号</button>
+ 
+         <view class="cert-verify-box">
+           <view class="verify-input-group">
+             <input class="verify-input" v-model="verifyInput" placeholder="请输入证书编号查验" />
+             <button class="verify-btn" @click="doVerify" :disabled="verifying">
+               {{ verifying ? '查验中' : '查验' }}
+             </button>
+           </view>
+           <text v-if="verifyResult" class="verify-result" :class="verifyResult.valid ? 'res-valid' : 'res-invalid'">
+             {{ verifyResult.valid ? '有效证书:' + verifyResult.certNo + '(持有人 ' + verifyResult.holderName + ')' : '未查询到该证书编号' }}
+           </text>
+         </view>
+       </view>
+       <view v-else-if="cert.items && cert.items.length">
+         <text class="cert-progress">已完成 {{ certDoneCount }}/6 项</text>
+         <view class="cert-items">
+           <view class="cert-item" v-for="(it, idx) in cert.items" :key="idx">
+             <text class="cert-icon">{{ it.ok ? '✅' : '⬜' }}</text>
+             <text class="cert-label">{{ it.label }}</text>
+           </view>
+         </view>
+       </view>
+       <view v-else class="empty-text">暂无证书信息,完成 6 项离场验收后可领取</view>
+    </view>
 
-        <view class="cert-verify-box">
-          <view class="verify-input-group">
-            <input class="verify-input" v-model="verifyInput" placeholder="请输入证书编号查验" />
-            <button class="verify-btn" @click="doVerify" :disabled="verifying">
-              {{ verifying ? '查验中' : '查验' }}
-            </button>
-          </view>
-          <text v-if="verifyResult" class="verify-result" :class="verifyResult.valid ? 'res-valid' : 'res-invalid'">
-            {{ verifyResult.valid ? '有效证书:' + verifyResult.certNo + '(持有人 ' + verifyResult.holderName + ')' : '未查询到该证书编号' }}
-          </text>
-        </view>
-      </view>
-      <view v-else-if="cert.items && cert.items.length">
-        <text class="cert-progress">已完成 {{ certDoneCount }}/6 项</text>
-        <view class="cert-items">
-          <view class="cert-item" v-for="(it, idx) in cert.items" :key="idx">
-            <text class="cert-icon">{{ it.ok ? '✅' : '⬜' }}</text>
-            <text class="cert-label">{{ it.label }}</text>
-          </view>
-        </view>
-      </view>
-      <view v-else class="empty-text">暂无证书信息,完成 6 项离场验收后可领取</view>
+    <view class="section-card" @click="goBadgeWall">
+      <text class="section-title">🎖 成长地图</text>
+      <text class="section-sub">徽章墙 · 段位成长 · 荣誉可晒</text>
     </view>
 
     <view class="section-card">
       <text class="section-title">🎟️ 卡券核销</text>
+
       <view v-if="coupons.length === 0" class="empty-text">暂无卡券</view>
       <view class="coupon-list">
         <view class="coupon-item" v-for="(c, idx) in coupons" :key="idx">
@@ -93,7 +99,7 @@
 </template>
 
 <script>
-import { getMyCert, getMyCoupons, redeemCoupon, verifyCert } from '@/utils/api.js'
+import { getMyCert, getMyCoupons, redeemCoupon, verifyCert, getMyBadges } from '@/utils/api.js'
 
 export default {
   data() {
@@ -158,6 +164,9 @@ export default {
     goTo(url) {
       uni.navigateTo({ url: url })
     },
+    goBadgeWall() {
+      uni.navigateTo({ url: '/pages/badge/index' })
+    },
     switchToTab(url) {
       uni.switchTab({ url: url })
     },
@@ -259,6 +268,7 @@ export default {
 .verify-fail { background: rgba(239,83,80,0.2); color: #E57373; }
 .section-card { background: #FFF; border-radius: 16rpx; padding: 28rpx 32rpx; margin-bottom: 24rpx; }
 .section-title { display: block; font-size: 30rpx; font-weight: 700; color: #1E293B; margin-bottom: 16rpx; }
+.section-sub { display: block; font-size: 24rpx; color: #94A3B8; margin-top: 8rpx; }
 .empty-text { font-size: 26rpx; color: #94A3B8; text-align: center; padding: 24rpx 0; }
 .cert-ok { display: flex; flex-direction: column; align-items: center; padding: 16rpx 0; }
 .cert-no { font-size: 32rpx; font-weight: 700; color: #F97316; font-family: monospace; margin-bottom: 12rpx; }

+ 3 - 0
train-frontend/utils/api.js

@@ -217,6 +217,9 @@ export const getMyCert = () => {
 export const verifyCert = (certNo) => {
   return request('/api/cert/verify', 'POST', { certNo })
 }
+export const getMyBadges = () => {
+  return request('/api/badge/mine', 'POST')
+}
 export const redeemCoupon = (data) => {
   return request('/api/plan/coupon/redeem', 'POST', data)
 }