Jelajahi Sumber

feat: update backend config, schema, entities and services for new modules

DatabaseInitializer schema migration, WebConfig JWT paths + resource handler, JwtInterceptor API path updates, schema.sql additions, entity/service updates for article/energy/commission/badge support

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
User 3 bulan lalu
induk
melakukan
68b487925c

+ 351 - 8
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -447,10 +447,10 @@ public class DatabaseInitializer implements CommandLineRunner {
             "status TINYINT DEFAULT 1 COMMENT '状态:0禁用1启用', " +
             "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
             "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
-            "INDEX idx_game_code (game_code), " +
-            "INDEX idx_status (status)" +
-            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
-);
+"INDEX idx_game_code (game_code), " +
+"INDEX idx_status (status)" +
+") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
+        );
 
         // 执行创建表SQL
         for (String sql : createTableSQLs) {
@@ -1199,7 +1199,7 @@ log.info("已添加template_id列到tasks表");
             log.warn("检查/添加个人信息补充字段失败: {}", e.getMessage());
         }
 
-        // Phase 0: 福俱乐部 — 服务商字段
+        // Phase 0: 浠艾福俱乐部 — 服务商字段
         try {
             Integer exists = jdbcTemplate.queryForObject(
                 "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'vendor_type'",
@@ -1210,9 +1210,9 @@ log.info("已添加template_id列到tasks表");
                 jdbcTemplate.execute("ALTER TABLE users ADD COLUMN vendor_reject_reason VARCHAR(500) DEFAULT NULL COMMENT '审核拒绝原因'");
                 jdbcTemplate.execute("ALTER TABLE users ADD COLUMN is_family_admin TINYINT DEFAULT 0 COMMENT '是否家庭管理员'");
                 jdbcTemplate.execute("ALTER TABLE users ADD COLUMN vendor_info VARCHAR(2000) DEFAULT NULL COMMENT '服务商资质信息(JSON)'");
-                log.info("已添加福俱乐部服务商字段到users表");
+                log.info("已添加浠艾福俱乐部服务商字段到users表");
             } else {
-                log.info("福俱乐部服务商字段已存在,跳过");
+                log.info("浠艾福俱乐部服务商字段已存在,跳过");
             }
         } catch (Exception e) {
             log.warn("检查/添加服务商字段失败: {}", e.getMessage());
@@ -1230,7 +1230,7 @@ log.info("已添加template_id列到tasks表");
             log.warn("迁移规划师到服务商体系失败: {}", e.getMessage());
         }
 
-        // ==================== Phase 1: 福俱乐部商品系统 ====================
+        // ==================== Phase 1: 浠艾福俱乐部商品系统 ====================
 
         // 商品表
         try {
@@ -1332,6 +1332,336 @@ log.info("已添加template_id列到tasks表");
             log.warn("Product 种子数据初始化失败: {}", e.getMessage());
         }
 
+        // ==================== Phase 2: 内容区块可见性配置 ====================
+
+        // 内容区块配置表
+        try {
+            jdbcTemplate.execute(
+                "CREATE TABLE IF NOT EXISTS content_sections (" +
+                "  id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+                "  page_key VARCHAR(50) NOT NULL," +
+                "  section_key VARCHAR(50) NOT NULL," +
+                "  title VARCHAR(100)," +
+                "  allowed_roles VARCHAR(255) DEFAULT '[\"anonymous\"]'," +
+                "  sort_order INT DEFAULT 0," +
+                "  status VARCHAR(20) DEFAULT 'active'," +
+                "  created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
+                "  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
+                ")"
+            );
+            log.info("content_sections 表已创建");
+        } catch (Exception e) {
+            log.warn("创建 content_sections 表失败: {}", e.getMessage());
+        }
+
+        // ContentSection 种子数据
+        try {
+            insertContentSectionSeed("mind", "func_entries", "功能入口", "[\"anonymous\"]", 1);
+            insertContentSectionSeed("mind", "daily_tip", "每日心理", "[\"anonymous\"]", 2);
+            insertContentSectionSeed("mind", "recommended_reading", "推荐阅读", "[\"anonymous\"]", 3);
+            insertContentSectionSeed("mind", "premium_content", "会员专属内容", "[\"parent\",\"teacher\"]", 4);
+            insertContentSectionSeed("body", "func_entries", "功能入口", "[\"anonymous\"]", 1);
+            insertContentSectionSeed("body", "daily_stats", "今日数据", "[\"anonymous\"]", 2);
+            insertContentSectionSeed("body", "health_tips", "健康小贴士", "[\"anonymous\"]", 3);
+            insertContentSectionSeed("body", "teacher_tips", "规划师推荐", "[\"teacher\"]", 4);
+            log.info("ContentSection 种子数据已加载");
+        } catch (Exception e) {
+            log.warn("ContentSection 种子数据初始化失败: {}", e.getMessage());
+        }
+
+        // ==================== 文章内容系统种子数据 ====================
+
+        // 文章分类种子数据
+        try {
+            Integer catCount = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM article_categories WHERE id IN (1,2,3,4,5)", Integer.class);
+            if (catCount == null || catCount == 0) {
+                jdbcTemplate.execute("INSERT IGNORE INTO article_categories (id, name, icon, color, sort_order, status) VALUES " +
+                    "(1, '心理健康', '🧠', '#5B9BD5', 1, 1), " +
+                    "(2, '情绪管理', '💖', '#FF6B35', 2, 1), " +
+                    "(3, '亲子教育', '👨‍👩‍👧‍👦', '#4CAF50', 3, 1), " +
+                    "(4, '学习力', '📚', '#FFD700', 4, 1), " +
+                    "(5, '专注力', '🎯', '#8D6E63', 5, 1)");
+                log.info("文章分类种子数据已加载");
+            }
+        } catch (Exception e) {
+            log.warn("文章分类种子数据初始化失败: {}", e.getMessage());
+        }
+
+        // ==================== 分佣裂变系统 ====================
+        
+        // 迁移: users表添加推荐人字段
+        try {
+            Integer exists = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'referrer_id'",
+                Integer.class);
+            if (exists == null || exists == 0) {
+                jdbcTemplate.execute("ALTER TABLE users ADD COLUMN referrer_id BIGINT DEFAULT NULL COMMENT '推荐人ID'");
+                jdbcTemplate.execute("ALTER TABLE users ADD COLUMN referral_code VARCHAR(32) DEFAULT NULL COMMENT '个人邀请码'");
+                jdbcTemplate.execute("ALTER TABLE users ADD INDEX idx_referral_code (referral_code)");
+                log.info("已添加分佣字段到users表");
+            } else {
+                log.info("分佣金字段已存在,跳过");
+            }
+        } catch (Exception e) {
+            log.warn("检查/添加分佣金字段失败: {}", e.getMessage());
+        }
+
+        // 迁移: products表添加利润率字段
+        try {
+            Integer exists = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'products' AND COLUMN_NAME = 'profit_rate'",
+                Integer.class);
+            if (exists == null || exists == 0) {
+                jdbcTemplate.execute("ALTER TABLE products ADD COLUMN profit_rate DECIMAL(5,2) DEFAULT 0.00 COMMENT '平台利润率(%)'");
+                log.info("已添加profit_rate列到products表");
+            } else {
+                log.info("profit_rate列已存在,跳过");
+            }
+        } catch (Exception e) {
+            log.warn("检查/添加profit_rate列失败: {}", e.getMessage());
+        }
+
+        // 新表: commission_records (佣金记录)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS commission_records (" +
+                "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                "order_id BIGINT NOT NULL COMMENT '订单ID', " +
+                "order_type VARCHAR(32) NOT NULL COMMENT '订单类型: membership/product/assessment/package', " +
+                "referrer_id BIGINT NOT NULL COMMENT '推荐人ID', " +
+                "buyer_id BIGINT NOT NULL COMMENT '购买人ID', " +
+                "commission_type VARCHAR(16) NOT NULL COMMENT '佣金类型: member/profit', " +
+                "order_amount DECIMAL(10,2) NOT NULL COMMENT '订单金额', " +
+                "profit_rate DECIMAL(5,2) DEFAULT 0.00 COMMENT '利润率(%)', " +
+                "commission_amount DECIMAL(10,2) NOT NULL COMMENT '佣金金额', " +
+                "status VARCHAR(16) NOT NULL DEFAULT 'settled' COMMENT '状态: pending/settled', " +
+                "remark VARCHAR(255) DEFAULT NULL, " +
+                "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
+                "INDEX idx_referrer (referrer_id), " +
+                "INDEX idx_buyer (buyer_id), " +
+                "INDEX idx_order (order_id, order_type)" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
+            log.info("已创建commission_records表");
+        } catch (Exception e) {
+            log.warn("创建commission_records表失败: {}", e.getMessage());
+        }
+
+        // 新表: withdrawal_requests (提现申请)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS withdrawal_requests (" +
+                "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                "user_id BIGINT NOT NULL COMMENT '用户ID', " +
+                "amount DECIMAL(10,2) NOT NULL COMMENT '提现金额', " +
+                "account_info TEXT COMMENT '账户信息(JSON)', " +
+                "status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT '状态: pending/approved/rejected', " +
+                "audit_by BIGINT DEFAULT NULL COMMENT '审核人ID', " +
+                "audit_at DATETIME DEFAULT NULL COMMENT '审核时间', " +
+                "remark VARCHAR(255) DEFAULT NULL COMMENT '审核备注', " +
+                "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
+                "INDEX idx_user (user_id), " +
+                "INDEX idx_status (status)" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
+            log.info("已创建withdrawal_requests表");
+        } catch (Exception e) {
+            log.warn("创建withdrawal_requests表失败: {}", e.getMessage());
+        }
+
+        // ==================== 文章内容系统 ====================
+
+        // 文章分类表
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS article_categories (" +
+                "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                "name VARCHAR(50) NOT NULL COMMENT '分类名', " +
+                "icon VARCHAR(20) DEFAULT '' COMMENT '图标emoji', " +
+                "color VARCHAR(20) DEFAULT '#5B9BD5' COMMENT '标识色', " +
+                "sort_order INT DEFAULT 0 COMMENT '排序', " +
+                "status TINYINT DEFAULT 1 COMMENT '1启用/0禁用'" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章分类'");
+            log.info("已创建article_categories表");
+        } catch (Exception e) {
+            log.warn("创建article_categories表失败: {}", e.getMessage());
+        }
+
+        // 文章表
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS articles (" +
+                "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                "category_id BIGINT DEFAULT 0 COMMENT '所属分类ID', " +
+                "title VARCHAR(200) NOT NULL COMMENT '标题', " +
+                "summary VARCHAR(500) DEFAULT '' COMMENT '摘要', " +
+                "cover_image VARCHAR(500) DEFAULT '' COMMENT '封面图URL', " +
+                "content LONGTEXT COMMENT '富文本内容', " +
+                "tags VARCHAR(200) DEFAULT '' COMMENT '标签JSON数组', " +
+                "author VARCHAR(100) DEFAULT '浠艾福' COMMENT '作者', " +
+                "read_time INT DEFAULT 0 COMMENT '预计阅读分钟数', " +
+                "related_dimensions VARCHAR(100) DEFAULT '' COMMENT '关联五维JSON数组', " +
+                "visibility VARCHAR(20) DEFAULT 'public' COMMENT '浏览权限:public/login/private', " +
+                "visible_to TEXT COMMENT '私密指定人群JSON', " +
+                "status VARCHAR(20) DEFAULT 'draft' COMMENT 'draft/published/archived', " +
+                "is_featured TINYINT DEFAULT 0 COMMENT '1精选/0普通', " +
+                "published_at DATETIME DEFAULT NULL COMMENT '发布时间', " +
+                "view_count INT DEFAULT 0 COMMENT '浏览次数', " +
+                "created_by BIGINT DEFAULT 0 COMMENT '发布人adminID', " +
+                "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章'");
+            log.info("已创建articles表");
+        } catch (Exception e) {
+            log.warn("创建articles表失败: {}", e.getMessage());
+        }
+
+        // 迁移: articles表补充新字段(兼容旧表)
+        try {
+            Integer colExists = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'articles' AND COLUMN_NAME = 'visibility'",
+                Integer.class);
+            if (colExists == null || colExists == 0) {
+                jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN visibility VARCHAR(20) DEFAULT 'public' COMMENT '浏览权限:public/login/private' AFTER related_dimensions");
+                jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN visible_to TEXT COMMENT '私密指定人群JSON' AFTER visibility");
+                jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN tags VARCHAR(200) DEFAULT '' COMMENT '标签JSON数组' AFTER content");
+                jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN related_dimensions VARCHAR(100) DEFAULT '' COMMENT '关联五维JSON数组' AFTER read_time");
+                jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN is_featured TINYINT DEFAULT 0 COMMENT '1精选/0普通' AFTER status");
+                jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN published_at DATETIME DEFAULT NULL COMMENT '发布时间' AFTER is_featured");
+                jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN view_count INT DEFAULT 0 COMMENT '浏览次数' AFTER published_at");
+                jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN created_by BIGINT DEFAULT 0 COMMENT '发布人adminID' AFTER view_count");
+                log.info("已迁移articles表补充字段");
+            } else {
+                log.info("articles表字段已完整,跳过迁移");
+            }
+        } catch (Exception e) {
+            log.warn("articles表字段迁移失败: {}", e.getMessage());
+        }
+
+        // ==================== 五维能量系统(账本模式) ====================
+
+        // 维度定义表
+        try {
+            jdbcTemplate.execute(
+                "CREATE TABLE IF NOT EXISTS energy_dimension (" +
+                "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                "code VARCHAR(16) NOT NULL UNIQUE COMMENT '维度代码: body/mind/wisdom/action/wealth', " +
+                "name VARCHAR(20) NOT NULL COMMENT '维度名称: 身/心/智/行/富', " +
+                "icon VARCHAR(16) COMMENT '图标emoji', " +
+                "element VARCHAR(10) COMMENT '五行元素: 土/火/金/木/水', " +
+                "sort_order INT DEFAULT 0 COMMENT '显示顺序', " +
+                "status TINYINT DEFAULT 1 COMMENT '1启用/0禁用', " +
+                "created_at DATETIME DEFAULT CURRENT_TIMESTAMP" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='能量维度定义表'"
+            );
+            log.info("已创建energy_dimension表");
+        } catch (Exception e) {
+            log.warn("创建energy_dimension表失败: {}", e.getMessage());
+        }
+
+        // 服务-维度比例配置表
+        try {
+            jdbcTemplate.execute(
+                "CREATE TABLE IF NOT EXISTS energy_source_config (" +
+                "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                "source_type VARCHAR(32) NOT NULL COMMENT '来源类型: task/activity/course/product/consultation', " +
+                "source_id BIGINT NOT NULL COMMENT '来源业务表ID', " +
+                "source_name VARCHAR(200) COMMENT '冗余名称', " +
+                "dimension_id BIGINT NOT NULL COMMENT '维度ID', " +
+                "ratio DECIMAL(5,4) NOT NULL COMMENT '占比如0.6000=60%', " +
+                "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                "INDEX idx_source (source_type, source_id)" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务-维度比例配置表'"
+            );
+            log.info("已创建energy_source_config表");
+        } catch (Exception e) {
+            log.warn("创建energy_source_config表失败: {}", e.getMessage());
+        }
+
+        // 能量流水表
+        try {
+            jdbcTemplate.execute(
+                "CREATE TABLE IF NOT EXISTS energy_log (" +
+                "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                "child_id BIGINT NOT NULL COMMENT '孩子ID', " +
+                "dimension_id BIGINT NOT NULL COMMENT '维度ID', " +
+                "amount INT NOT NULL COMMENT '变动量(正=获得,负=消耗)', " +
+                "balance_after INT NOT NULL COMMENT '变动后余额', " +
+                "source_type VARCHAR(32) COMMENT '来源类型: task/product/medical_report/daily_record', " +
+                "source_id BIGINT COMMENT '来源ID', " +
+                "ref_id BIGINT COMMENT '关联旧流水ID(覆盖时标记)', " +
+                "expires_at DATETIME COMMENT '过期时间(空=永不过期)', " +
+                "description VARCHAR(500) COMMENT '描述', " +
+                "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                "INDEX idx_child_dim (child_id, dimension_id), " +
+                "INDEX idx_created (created_at), " +
+                "INDEX idx_source (source_type, source_id)" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='能量流水表'"
+            );
+            log.info("已创建energy_log表");
+        } catch (Exception e) {
+            log.warn("创建energy_log表失败: {}", e.getMessage());
+        }
+
+        // 维度余额表
+        try {
+            jdbcTemplate.execute(
+                "CREATE TABLE IF NOT EXISTS energy_balance (" +
+                "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                "child_id BIGINT NOT NULL COMMENT '孩子ID', " +
+                "dimension_id BIGINT NOT NULL COMMENT '维度ID', " +
+                "balance INT DEFAULT 0 COMMENT '当前能量值(可为负)', " +
+                "total_earned INT DEFAULT 0 COMMENT '累计获得', " +
+                "total_spent INT DEFAULT 0 COMMENT '累计消耗', " +
+                "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+                "UNIQUE KEY uk_child_dim (child_id, dimension_id)" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='维度余额表'"
+            );
+            log.info("已创建energy_balance表");
+        } catch (Exception e) {
+            log.warn("创建energy_balance表失败: {}", e.getMessage());
+        }
+
+        // 维度种子数据(按相生链: 智→富→行→心→身)
+        try {
+            Integer dimCount = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM energy_dimension", Integer.class);
+            if (dimCount == null || dimCount == 0) {
+                jdbcTemplate.execute("INSERT INTO energy_dimension (code, name, icon, element, sort_order, status) VALUES " +
+                    "('mind', '心', '🔥', '火', 4, 1), " +
+                    "('body', '身', '🌏', '土', 5, 1), " +
+                    "('wisdom', '智', '⚔️', '金', 1, 1), " +
+                    "('action', '行', '🌿', '木', 3, 1), " +
+                    "('wealth', '富', '💧', '水', 2, 1)");
+                log.info("能量维度种子数据已初始化");
+            }
+        } catch (Exception e) {
+            log.warn("初始化能量维度种子数据失败: {}", e.getMessage());
+        }
+
+        // 为已有商品插入默认比例配置(与Product.domain一致)
+        try {
+            Integer productConfigCount = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM energy_source_config WHERE source_type = 'product'", Integer.class);
+            if (productConfigCount == null || productConfigCount == 0) {
+                String[][] domainMapping = {
+                    {"action", "action"}, {"mind", "mind"}, {"body", "body"},
+                    {"wisdom", "wisdom"}, {"wealth", "wealth"}
+                };
+                for (String[] mapping : domainMapping) {
+                    jdbcTemplate.execute(
+                        "INSERT INTO energy_source_config (source_type, source_id, source_name, dimension_id, ratio) " +
+                        "SELECT 'product', p.id, p.name, d.id, 1.0000 " +
+                        "FROM products p " +
+                        "JOIN energy_dimension d ON d.code = '" + mapping[1] + "' " +
+                        "WHERE p.domain = '" + mapping[0] + "' " +
+                        "AND NOT EXISTS (" +
+                        "  SELECT 1 FROM energy_source_config esc " +
+                        "  WHERE esc.source_type = 'product' AND esc.source_id = p.id" +
+                        ")"
+                    );
+                }
+                log.info("已有商品能量比例配置已初始化");
+            }
+        } catch (Exception e) {
+            log.warn("初始化商品能量比例配置失败: {}", e.getMessage());
+        }
+
         log.info("数据库迁移完成");
     }
 
@@ -1816,4 +2146,17 @@ try {
                 name, desc, price, type, domain, memberEligible);
         }
     }
+
+    private void insertContentSectionSeed(String pageKey, String sectionKey, String title,
+                                        String allowedRoles, int sortOrder) {
+        Integer count = jdbcTemplate.queryForObject(
+            "SELECT COUNT(*) FROM content_sections WHERE page_key = ? AND section_key = ?",
+            Integer.class, pageKey, sectionKey);
+        if (count == null || count == 0) {
+            jdbcTemplate.update(
+                "INSERT INTO content_sections (page_key, section_key, title, allowed_roles, sort_order, status, created_at, updated_at) " +
+                "VALUES (?, ?, ?, ?, ?, 'active', NOW(), NOW())",
+                pageKey, sectionKey, title, allowedRoles, sortOrder);
+        }
+    }
 }

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -17,6 +17,21 @@ public class JwtInterceptor implements HandlerInterceptor {
     @Resource
     private JwtConfig jwtConfig;
 
+    /** 无需登录的公开路径前缀(兜底,与 WebConfig.excludePathPatterns 保持同步) */
+    private static final String[] PUBLIC_PATHS = {
+        "/api/auth/",
+        "/api/product/list",
+        "/api/product/detail",
+        "/api/product/type-list",
+        "/api/mini-game/list",
+        "/api/admin-auth/",
+        "/api/articles/list",
+        "/api/articles/detail",
+        "/api/articles/categories",
+        "/api/articles/featured",
+        "/api/articles/record-read"
+    };
+
     @Override
     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
         // 放 OPTIONS 请求
@@ -24,6 +39,13 @@ public class JwtInterceptor implements HandlerInterceptor {
             return true;
         }
 
+        String path = request.getRequestURI();
+        for (String publicPath : PUBLIC_PATHS) {
+            if (path.equals(publicPath) || path.startsWith(publicPath)) {
+                return true;
+            }
+        }
+
         // 获取token
         String token = request.getHeader("Authorization");
         

+ 29 - 14
cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java

@@ -4,6 +4,7 @@ import javax.annotation.Resource;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.web.servlet.config.annotation.CorsRegistry;
 import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
 import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
 
 @Configuration
@@ -30,20 +31,34 @@ public class WebConfig implements WebMvcConfigurer {
         registry.addInterceptor(jwtInterceptor)
                 .addPathPatterns("/api/**")
                 .excludePathPatterns(
-                        "/api/auth/send-code",
-                        "/api/auth/phone-login",
-                        "/api/auth/wechat-login",
-                        "/api/auth/wechat-phone-login",
-                        "/api/auth/check-phone",
-                        "/api/auth/direct-register",
-                        "/api/auth/register-with-idcard",
-                        "/api/admin-auth/send-code",
-                        "/api/admin-auth/login",
-                        "/api/admin-auth/login-by-password"
-                );
-
-        // 操作日志拦截器(JWT之后)
+                "/api/auth/send-code",
+                "/api/auth/phone-login",
+                "/api/auth/wechat-login",
+                "/api/auth/wechat-phone-login",
+                "/api/auth/check-phone",
+                "/api/auth/direct-register",
+                "/api/auth/register-with-idcard",
+                "/api/admin-auth/send-code",
+                "/api/admin-auth/login",
+                "/api/admin-auth/login-by-password",
+                "/api/product/list",
+                "/api/product/detail",
+                "/api/product/type-list",
+                "/api/mini-game/list",
+                "/api/articles/list",
+                "/api/articles/detail",
+                "/api/articles/categories",
+                "/api/articles/featured",
+                "/api/articles/record-read"
+        );
+
         registry.addInterceptor(operationLogInterceptor)
                 .addPathPatterns("/api/**");
     }
-}
+
+    @Override
+    public void addResourceHandlers(ResourceHandlerRegistry registry) {
+        registry.addResourceHandler("/uploads/**")
+                .addResourceLocations("file:" + System.getProperty("user.dir") + "/uploads/");
+    }
+}

+ 8 - 24
cfc-backend/src/main/java/com/etotem/cfc/controller/MiniGameController.java

@@ -2,16 +2,14 @@ package com.etotem.cfc.controller;
 
 import com.etotem.cfc.common.Result;
 import javax.annotation.Resource;
+import com.etotem.cfc.dto.CompleteGameDTO;
 import com.etotem.cfc.entity.MiniGame;
-import com.etotem.cfc.entity.User;
-import com.etotem.cfc.entity.Child;
-import com.etotem.cfc.mapper.UserMapper;
-import com.etotem.cfc.mapper.ChildMapper;
 import com.etotem.cfc.service.MiniGameService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+
+import javax.validation.Valid;
 import java.util.List;
 import java.util.Map;
 @Tag(name = "小游戏管理", description = "小游戏相关接口")
@@ -20,8 +18,6 @@ import java.util.Map;
 public class MiniGameController {
     @Resource
     private MiniGameService miniGameService;
-    private UserMapper userMapper;
-    private ChildMapper childMapper;
     /**
      * 获取启用的小游戏列表
      */
@@ -56,22 +52,10 @@ public class MiniGameController {
     @PostMapping("/complete")
     public Result<Map<String, Object>> completeGame(
             @RequestAttribute("userId") Long userId,
-            @RequestBody Map<String, Object> body) {
-        Long childId = Long.valueOf(body.get("childId").toString());
-        String gameCode = (String) body.get("gameCode");
-        Integer completionTime = body.get("completionTime") != null ?
-                Integer.valueOf(body.get("completionTime").toString()) : null;
-        Integer score = body.get("score") != null ?
-                Integer.valueOf(body.get("score").toString()) : null;
-        // 权限校验:验证用户是否有权限操作该孩子
-        User user = userMapper.selectById(userId);
-        if (user != null) {
-            Child child = childMapper.selectById(childId);
-            if (child == null) {
-                return Result.error("孩子不存在");
-                // 验证孩子是否属于用户的家庭
-            }
-        }
-        return Result.error("处理不成功");
+            @RequestBody @Valid CompleteGameDTO dto) {
+        Map<String, Object> result = miniGameService.completeGame(
+                dto.getChildId(), dto.getGameCode(),
+                dto.getCompletionTime(), dto.getScore());
+        return Result.success(result);
     }
 }

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java

@@ -35,6 +35,10 @@ public class Product implements Serializable {
     private String externalSource;
     private String externalId;
     private String externalData;
+
+    // 分佣系统 - 平台利润率(%)
+    private BigDecimal profitRate;
+
     private Date createdAt;
     private Date updatedAt;
 }

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/User.java

@@ -91,6 +91,12 @@ public class User implements Serializable {
     // 服务商资质信息 (JSON)
     private String vendorInfo;
 
+    // 分佣系统 - 推荐人ID
+    private Long referrerId;
+
+    // 分佣系统 - 个人邀请码(6位字母数字)
+    private String referralCode;
+
     private Date createdAt;
 
     private Date updatedAt;

+ 10 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentOrderService.java

@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.etotem.cfc.entity.AssessmentOrder;
 import com.etotem.cfc.mapper.AssessmentOrderMapper;
+import com.etotem.cfc.service.CommissionService;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
@@ -20,6 +21,9 @@ public class AssessmentOrderService extends ServiceImpl<AssessmentOrderMapper, A
     @Resource
     private AssessmentOrderMapper assessmentOrderMapper;
 
+    @Resource
+    private CommissionService commissionService;
+
     public AssessmentOrder createOrder(Long familyId, Long userId, Long childId,
                                        Long guideId, Long packageId,
                                        String guideName, String packageName,
@@ -57,7 +61,12 @@ public class AssessmentOrderService extends ServiceImpl<AssessmentOrderMapper, A
         order.setPayType(payType);
         order.setTransactionId(transactionId);
         order.setUpdatedAt(new Date());
-        return this.updateById(order);
+        boolean updated = this.updateById(order);
+        if (updated) {
+            commissionService.settle(order.getId(), "assessment", order.getUserId(),
+                    new java.math.BigDecimal(order.getActualPrice()), null);
+        }
+        return updated;
     }
 
     public boolean cancelOrder(String orderNo) {

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/MiniGameService.java

@@ -32,6 +32,9 @@ public class MiniGameService implements MiniGameServiceInterface {
     @Resource
     private PointsLogMapper pointsLogMapper;
 
+    @Resource
+    private GameRecordService gameRecordService;
+
     /**
      * 获取所有启用的小游戏
      */
@@ -112,6 +115,13 @@ public class MiniGameService implements MiniGameServiceInterface {
         log.setCreatedAt(new Date());
         pointsLogMapper.insert(log);
 
+        // 持久化游戏记录
+        try {
+            gameRecordService.saveRecord(childId, gameCode, completionTime, score, "medium", earnedPoints);
+        } catch (Exception e) {
+            MiniGameService.log.warn("保存游戏记录失败: {}", e.getMessage());
+        }
+
         // 返回结果
         Map<String, Object> result = new HashMap<>();
         result.put("pointsEarned", earnedPoints);

+ 8 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/PackagePaymentService.java

@@ -8,6 +8,7 @@ import com.alibaba.fastjson.JSONObject;
 import com.etotem.cfc.entity.PackageOrder;
 import com.etotem.cfc.entity.TaskTemplatePackage;
 import com.etotem.cfc.mapper.PackageOrderMapper;
+import com.etotem.cfc.service.CommissionService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Value;
@@ -31,6 +32,9 @@ public class PackagePaymentService implements PackagePaymentServiceInterface {
     @Resource
     private TaskTemplatePackageService packageService;
 
+    @Resource
+    private CommissionService commissionService;
+
     @Value("${wechat.appid}")
     private String appid;
 
@@ -186,7 +190,10 @@ public class PackagePaymentService implements PackagePaymentServiceInterface {
                 order.setNotifyData(xmlData);
                 order.setUpdatedAt(new Date());
                 orderMapper.updateById(order);
-                
+
+                commissionService.settle(order.getId(), "package", order.getUserId(),
+                        order.getPrice(), null);
+
                 log.info("订单支付成功: {}", orderNo);
                 return true;
             }

+ 11 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java

@@ -6,6 +6,7 @@ import javax.annotation.Resource;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.PackageOrder;
 import com.etotem.cfc.mapper.PackageOrderMapper;
+import com.etotem.cfc.service.CommissionService;
 import org.springframework.stereotype.Service;
 
 import java.util.Date;
@@ -20,6 +21,9 @@ public class PaymentService {
     @Resource
     private PackageOrderMapper packageOrderMapper;
 
+    @Resource
+    private CommissionService commissionService;
+
     /**
      * 创建微信支付订单
      */
@@ -71,8 +75,13 @@ public class PaymentService {
         order.setTransactionId(transactionId);
         order.setPaidAt(new Date());
         order.setUpdatedAt(new Date());
-        
-        return packageOrderMapper.updateById(order) > 0;
+
+        boolean updated = packageOrderMapper.updateById(order) > 0;
+        if (updated) {
+            commissionService.settle(order.getId(), "package", order.getUserId(),
+                    order.getPrice(), null);
+        }
+        return updated;
     }
 
     /**

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -10,6 +10,7 @@ import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.ProductMapper;
 import com.etotem.cfc.mapper.ProductOrderMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.CommissionService;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
@@ -33,6 +34,9 @@ public class ProductOrderService {
     @Resource
     private ProductService productService;
 
+    @Resource
+    private CommissionService commissionService;
+
     public Result<ProductOrderDTO> create(CreateProductOrderDTO dto, Long buyerId) {
         if (buyerId == null) {
             return Result.error("请先登录");
@@ -88,6 +92,8 @@ public class ProductOrderService {
         order.setPaidAt(new Date());
         order.setUpdatedAt(new Date());
         orderMapper.updateById(order);
+        commissionService.settle(order.getId(), "product", order.getBuyerId(),
+                order.getTotalAmount(), order.getProductId());
         return Result.success(ProductOrderDTO.from(order));
     }
 

+ 18 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java

@@ -42,6 +42,9 @@ public class TaskService implements TaskServiceInterface {
     @Resource
     private MiniGameMapper miniGameMapper;
 
+    @Resource
+    private EnergyService energyService;
+
     private static final int EARLY_BONUS = 1; // 提前完成奖励
     private static final int PENALTY_MAX_DAILY = 5; // 每日最多扣分
     private static final int LATE_GRACE_MINUTES = 10; // 10分钟内不算迟到
@@ -364,10 +367,22 @@ expireCal.add(Calendar.DAY_OF_MONTH, task.getPointsExpireDays());
 pointsLog.setExpireAt(expireCal.getTime());
 }
 
-pointsLogMapper.insert(pointsLog);
+        pointsLogMapper.insert(pointsLog);
+
+        // 五维能量发放(与积分并行,独立系统,失败不影响积分)
+        try {
+            int energyAmount = Math.abs(pointsEarned);
+            if (energyAmount > 0) {
+                energyService.awardEnergy(childId, "task", taskId, energyAmount,
+                        "完成任务: " + task.getTitle(), null);
+            }
+        } catch (Exception e) {
+            log.warn("能量发放失败(不影响积分): childId={}, taskId={}, error={}",
+                    childId, taskId, e.getMessage());
+        }
 
-Map<String, Object> result = new HashMap<>();
-result.put("pointsEarned", pointsEarned);
+        Map<String, Object> result = new HashMap<>();
+        result.put("pointsEarned", pointsEarned);
 result.put("newBalance", child.getTotalPoints());
 result.put("needReview", task.getNeedReview() == 1);
 

+ 1 - 1
cfc-backend/src/main/resources/application.yml

@@ -1,5 +1,5 @@
 server:
-  port: 8080
+  port: 9082
   tomcat:
     headers:
       X-Content-Type-Options: nosniff

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

@@ -630,3 +630,59 @@ CREATE TABLE IF NOT EXISTS teacher_messages (
     INDEX idx_parent_id (parent_id),
     INDEX idx_status (status)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- 游戏记录表(专注训练模块)
+CREATE TABLE IF NOT EXISTS game_records (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    child_id BIGINT NOT NULL COMMENT '孩子ID',
+    game_code VARCHAR(20) NOT NULL COMMENT '游戏代码',
+    score INT DEFAULT 0 COMMENT '得分(0-100)',
+    completion_time INT COMMENT '完成用时(秒)',
+    difficulty VARCHAR(20) DEFAULT 'medium' COMMENT '难度(easy/medium/hard)',
+    points_earned INT DEFAULT 0 COMMENT '获得积分',
+    played_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '游玩时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_child_id (child_id),
+    INDEX idx_game_code (game_code),
+    INDEX idx_played_at (played_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- 勋章定义表
+CREATE TABLE IF NOT EXISTS badge (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    badge_id VARCHAR(50) UNIQUE NOT NULL COMMENT '勋章唯一标识',
+    name VARCHAR(100) NOT NULL COMMENT '勋章名称',
+    description TEXT COMMENT '勋章描述',
+    icon VARCHAR(255) COMMENT '勋章图标',
+    category VARCHAR(20) COMMENT '分类(学习/运动/家务/阅读/打卡/综合)',
+    level VARCHAR(20) COMMENT '等级(铜牌/银牌/金牌/钻石)',
+    rarity VARCHAR(20) COMMENT '稀有度(普通/稀有/史诗/传说)',
+    trigger_type VARCHAR(20) COMMENT '触发类型(task_count/streak_days/points_total/reward_count/custom)',
+    threshold INT DEFAULT 0 COMMENT '阈值',
+    need_approval TINYINT DEFAULT 0 COMMENT '是否需要审批',
+    expire_days INT DEFAULT -1 COMMENT '有效期天数(-1永久)',
+    sort_order INT DEFAULT 0 COMMENT '排序',
+    is_active TINYINT DEFAULT 1 COMMENT '是否启用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_category (category),
+    INDEX idx_is_active (is_active)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- 儿童勋章关联表
+CREATE TABLE IF NOT EXISTS child_badge (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    child_id BIGINT NOT NULL COMMENT '孩子ID',
+    badge_id BIGINT NOT NULL COMMENT '勋章ID',
+    earned_at DATETIME COMMENT '获得时间',
+    expire_at DATETIME COMMENT '过期时间',
+    status TINYINT DEFAULT 1 COMMENT '状态:1有效 0过期 -1撤销',
+    is_favorite TINYINT DEFAULT 0 COMMENT '是否收藏',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_child_badge (child_id, badge_id),
+    INDEX idx_child_id (child_id),
+    INDEX idx_badge_id (badge_id),
+    INDEX idx_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;