package com.etotem.cfc.config; import com.etotem.cfc.mapper.*; import com.etotem.cfc.service.CfLegacyLedgerMigrationService; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import javax.annotation.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import lombok.var; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.CommandLineRunner; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ResourceLoader; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.springframework.util.StreamUtils; import java.nio.charset.StandardCharsets; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.SQLException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.regex.Pattern; @Component public class DatabaseInitializer implements CommandLineRunner { private static final Logger log = LoggerFactory.getLogger(DatabaseInitializer.class); /** 迁移是否已全部完成(供定时任务判断,避免启动期时序竞态) */ private static volatile boolean migrationsComplete = false; public static boolean isMigrationsComplete() { return migrationsComplete; } @Resource private JdbcTemplate jdbcTemplate; @Resource private ResourceLoader resourceLoader; @Resource private CfLegacyLedgerMigrationService cfLegacyLedgerMigrationService; @Value("${app.init-test-data:false}") private boolean initTestData; @Override public void run(String... args) throws Exception { log.info("开始初始化数据库..."); // 1. 检查并创建数据库 initializeDatabase(); // 2. 检查并创建表 initializeTables(); // 3. 执行数据库迁移(添加新列) runMigrations(); // 4. 初始化食品相关表(DDL,始终执行) initFoodTables(); // 5. 初始化测试/种子数据(仅测试/开发环境) if (initTestData) { log.info("init-test-data=true,开始初始化测试/种子数据..."); initializeDefaultData(); log.info("测试/种子数据初始化完成!"); } else { log.info("init-test-data=false,跳过测试/种子数据初始化(生产模式)"); } migrationsComplete = true; log.info("数据库初始化完成!"); } private void initializeDatabase() { try { // 尝试连接数据库,如果不存在则创建 jdbcTemplate.execute("CREATE DATABASE IF NOT EXISTS zxyj DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); log.info("数据库检查完成"); } catch (Exception e) { log.warn("创建数据库失败: {}", e.getMessage()); } } private void initializeTables() { try { org.springframework.core.io.Resource resource = resourceLoader.getResource("classpath:schema.sql"); String content; try (java.io.InputStream is = resource.getInputStream()) { content = new String(StreamUtils.copyToByteArray(is), StandardCharsets.UTF_8); } // Remove SQL single-line comments content = content.replaceAll("(?m)^\\s*--.*$", ""); // 提取所有 CREATE TABLE IF NOT EXISTS 语句。 // 不能用非贪婪 ".*?;" 匹配:COMMENT 字符串字面量中可能含分号(如 // task_reminder_config.offset_minutes 的注释 "截止前N分钟; after_timeout..."), // 会被提前截断导致语句不完整而建表失败。此处按字符扫描,跳过引号内部的分号。 List createTableSQLs = new ArrayList<>(); Pattern createTableHead = Pattern.compile( "^\\s*CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\b", Pattern.CASE_INSENSITIVE); StringBuilder stmt = new StringBuilder(); boolean inSingleQuote = false; boolean inBacktick = false; for (int i = 0; i < content.length(); i++) { char c = content.charAt(i); if (c == '\'' && !inBacktick) { if (inSingleQuote && i + 1 < content.length() && content.charAt(i + 1) == '\'') { stmt.append("''"); i++; continue; } inSingleQuote = !inSingleQuote; stmt.append(c); } else if (c == '`' && !inSingleQuote) { inBacktick = !inBacktick; stmt.append(c); } else if (c == ';' && !inSingleQuote && !inBacktick) { String sql = stmt.toString().trim(); if (!sql.isEmpty() && createTableHead.matcher(sql).find()) { createTableSQLs.add(sql + ";"); } stmt.setLength(0); } else { stmt.append(c); } } log.info("从 schema.sql 加载了 {} 个建表语句", createTableSQLs.size()); for (String sql : createTableSQLs) { try { jdbcTemplate.execute(sql); } catch (Exception e) { log.warn("创建表失败: {}", e.getMessage()); } } log.info("数据表检查完成"); } catch (Exception e) { log.error("读取 schema.sql 失败", e); } } private void runMigrations() { // 迁移1: 任务表添加执行者字段 try { jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN executor_type ENUM('child', 'parent') DEFAULT 'child' COMMENT '执行者类型'"); log.info("已添加executor_type列到tasks表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN executor_id BIGINT COMMENT '执行者ID'"); log.info("已添加executor_id列到tasks表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE tasks ADD INDEX idx_executor (executor_type, executor_id)"); log.info("已添加idx_executor索引到tasks表"); } catch (Exception e) { // 索引已存在,忽略错误 } // 迁移2: 用户表添加角色和积分字段 try { jdbcTemplate.execute("ALTER TABLE users ADD COLUMN roles VARCHAR(100) COMMENT '角色列表'"); log.info("已添加roles列到users表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE users ADD COLUMN total_points INT DEFAULT 0 COMMENT '家长总积分'"); log.info("已添加total_points列到users表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE users ADD INDEX idx_roles (roles(20))"); log.info("已添加idx_roles索引到users表"); } catch (Exception e) { // 索引已存在,忽略错误 } // 迁移3: users表添加新字段 try { jdbcTemplate.execute("ALTER TABLE users ADD COLUMN id_card VARCHAR(18) COMMENT '身份证号'"); log.info("已添加id_card列到users表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE users ADD COLUMN real_name VARCHAR(50) COMMENT '真实姓名'"); log.info("已添加real_name列到users表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE users ADD COLUMN phone VARCHAR(11) COMMENT '手机号'"); log.info("已添加phone列到users表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE users ADD COLUMN birthday DATE COMMENT '生日'"); log.info("已添加birthday列到users表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE users ADD COLUMN gender ENUM('male', 'female') COMMENT '性别'"); log.info("已添加gender列到users表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE users ADD INDEX idx_phone (phone)"); log.info("已添加idx_phone索引到users表"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE users MODIFY COLUMN role ENUM('parent', 'child', 'teacher') NOT NULL"); log.info("已更新users.role字段支持teacher角色"); } catch (Exception e) { // 修改失败,忽略错误 } // 迁移5.1: 修改users.role为支持admin try { jdbcTemplate.execute("ALTER TABLE users MODIFY COLUMN role ENUM('parent', 'child', 'teacher', 'admin') NOT NULL"); log.info("已更新users.role字段支持admin角色"); } catch (Exception e) { // 修改失败,忽略错误 } // 迁移5.2: 确保管理员角色为admin(修复旧数据遗留问题) try { int updated = jdbcTemplate.update("UPDATE admins SET role = 'admin' WHERE username = 'admin'"); if (updated > 0) { log.info("已修正管理员角色为admin"); } } catch (Exception e) { // 更新失败,忽略错误 } // 迁移6: 创建streets表(街道地址维度表) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS streets (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "province VARCHAR(50) COMMENT '省份', " + "province_code VARCHAR(20) COMMENT '省份编码', " + "city VARCHAR(50) COMMENT '城市', " + "city_code VARCHAR(20) COMMENT '城市编码', " + "district VARCHAR(50) COMMENT '区县', " + "district_code VARCHAR(20) COMMENT '区县编码', " + "street VARCHAR(100) COMMENT '街道', " + "street_code VARCHAR(20) COMMENT '街道编码', " + "full_name VARCHAR(200) COMMENT '完整地址', " + "level INT COMMENT '层级:1省2市3区4街道', " + "parent_id BIGINT COMMENT '父级ID', " + "sort_order INT DEFAULT 0 COMMENT '排序', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_level (level), " + "INDEX idx_parent_id (parent_id), " + "INDEX idx_street_code (street_code), " + "INDEX idx_full_name (full_name(50))" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); log.info("已创建streets表"); } catch (Exception e) { // 表已存在,忽略错误 } // 迁移7: families表添加地址字段 try { jdbcTemplate.execute("ALTER TABLE families ADD COLUMN street_id BIGINT COMMENT '街道ID'"); log.info("已添加street_id列到families表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE families ADD COLUMN province VARCHAR(50) COMMENT '省份'"); jdbcTemplate.execute("ALTER TABLE families ADD COLUMN city VARCHAR(50) COMMENT '城市'"); jdbcTemplate.execute("ALTER TABLE families ADD COLUMN district VARCHAR(50) COMMENT '区县'"); jdbcTemplate.execute("ALTER TABLE families ADD COLUMN street VARCHAR(100) COMMENT '街道'"); log.info("已添加地址字段到families表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE families ADD INDEX idx_street_id (street_id)"); log.info("已添加idx_street_id索引到families表"); } catch (Exception e) { // 索引已存在,忽略错误 } // 迁移7b: families表添加teacher_id和creator_id字段 try { jdbcTemplate.execute("ALTER TABLE families ADD COLUMN teacher_id BIGINT COMMENT '绑定的成长规划师ID'"); log.info("已添加teacher_id列到families表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE families ADD COLUMN creator_id BIGINT COMMENT '家庭创建者ID'"); log.info("已添加creator_id列到families表"); } catch (Exception e) { // 列已存在,忽略错误 } // 迁移8: teachers表添加地址字段 try { jdbcTemplate.execute("ALTER TABLE teachers ADD COLUMN street_id BIGINT COMMENT '街道ID'"); log.info("已添加street_id列到teachers表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE teachers ADD COLUMN province VARCHAR(50) COMMENT '省份'"); jdbcTemplate.execute("ALTER TABLE teachers ADD COLUMN city VARCHAR(50) COMMENT '城市'"); jdbcTemplate.execute("ALTER TABLE teachers ADD COLUMN district VARCHAR(50) COMMENT '区县'"); jdbcTemplate.execute("ALTER TABLE teachers ADD COLUMN street VARCHAR(100) COMMENT '街道'"); log.info("已添加地址字段到teachers表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE teachers ADD INDEX idx_street_id (street_id)"); log.info("已添加idx_street_id索引到teachers表"); } catch (Exception e) { // 索引已存在,忽略错误 } // 迁移9: users表添加成长规划师相关字段 try { jdbcTemplate.execute("ALTER TABLE users ADD COLUMN teacher_no VARCHAR(50) COMMENT '成长规划师证书号'"); log.info("已添加teacher_no列到users表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE users ADD COLUMN teacher_photo VARCHAR(500) COMMENT '成长规划师照片'"); log.info("已添加teacher_photo列到users表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE users ADD COLUMN certificate_image VARCHAR(500) COMMENT '成长规划师证书照片'"); log.info("已添加certificate_image列到users表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE users ADD COLUMN teacher_status VARCHAR(20) DEFAULT NULL COMMENT '成长规划师审核状态'"); log.info("已添加teacher_status列到users表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE users ADD INDEX idx_teacher_status (teacher_status)"); log.info("已添加idx_teacher_status索引到users表"); } catch (Exception e) { // 索引已存在,忽略错误 } // 迁移10: users表添加teacherFamilyIds字段(成长规划师关联的家庭ID列表) try { jdbcTemplate.execute("ALTER TABLE users ADD COLUMN teacher_family_ids VARCHAR(500) COMMENT '成长规划师关联的家庭ID列表(逗号分隔)'"); log.info("已添加teacher_family_ids列到users表"); } catch (Exception e) { // 列已存在,忽略错误 } // 迁移11: families表添加teacherId字段(绑定的成长规划师ID) try { jdbcTemplate.execute("ALTER TABLE families ADD COLUMN teacher_id BIGINT COMMENT '绑定的成长规划师ID'"); log.info("已添加teacher_id列到families表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE families ADD INDEX idx_teacher_id (teacher_id)"); log.info("已添加idx_teacher_id索引到families表"); } catch (Exception e) { // 索引已存在,忽略错误 } // 迁移12: 创建 teacher_messages 表(家长咨询成长规划师) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS teacher_messages (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "parent_id BIGINT NOT NULL COMMENT '家长用户ID', " + "teacher_id BIGINT NOT NULL COMMENT '成长规划师用户ID', " + "content TEXT NOT NULL COMMENT '消息内容', " + "reply_content TEXT COMMENT '回复内容', " + "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态:pending(待回复)/replied(已回复)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "replied_at DATETIME COMMENT '回复时间', " + "INDEX idx_family_id (family_id), " + "INDEX idx_teacher_id (teacher_id), " + "INDEX idx_parent_id (parent_id), " + "INDEX idx_status (status)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); log.info("已创建teacher_messages表"); } catch (Exception e) { // 表已存在,忽略错误 } // 迁移13: admin_task_templates表添加teacher_id字段 try { jdbcTemplate.execute("ALTER TABLE admin_task_templates ADD COLUMN teacher_id BIGINT COMMENT '成长规划师ID'"); log.info("已添加teacher_id列到admin_task_templates表"); } catch (Exception e) { // 列已存在,忽略错误 } // 迁移13b: admin_task_templates表添加新字段 try { jdbcTemplate.execute("ALTER TABLE admin_task_templates ADD COLUMN complete_types VARCHAR(100) COMMENT '完成方式JSON'"); log.info("已添加complete_types列到admin_task_templates表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE admin_task_templates ADD COLUMN duration INT COMMENT '预计完成时长(分钟)'"); log.info("已添加duration列到admin_task_templates表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE admin_task_templates ADD COLUMN review_type VARCHAR(20) COMMENT '审核人类型: creator/parent/teacher/ai'"); log.info("已添加review_type列到admin_task_templates表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE admin_task_templates ADD COLUMN task_type VARCHAR(20) COMMENT '任务性质: onetime/recurring'"); log.info("已添加task_type列到admin_task_templates表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE admin_task_templates ADD COLUMN frequency VARCHAR(20) COMMENT '频率: daily/weekly/monthly'"); log.info("已添加frequency列到admin_task_templates表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE admin_task_templates ADD COLUMN max_frequency INT COMMENT '周期内最多完成次数'"); log.info("已添加max_frequency列到admin_task_templates表"); } catch (Exception e) { // 列已存在,忽略错误 } // 迁移14: rewards表child_id字段允许NULL(模板奖励不需要child_id) try { jdbcTemplate.execute("ALTER TABLE rewards MODIFY COLUMN child_id BIGINT NULL COMMENT '孩子ID,模板奖励为NULL'"); log.info("已修改rewards.child_id字段允许NULL"); } catch (Exception e) { // 修改失败,忽略错误 } // 迁移15: admins表添加缺失列 try { jdbcTemplate.execute("ALTER TABLE admins ADD COLUMN nickname VARCHAR(50) DEFAULT NULL AFTER password"); log.info("已添加nickname列到admins表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE admins ADD COLUMN email VARCHAR(100) DEFAULT NULL AFTER phone"); log.info("已添加email列到admins表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE admins MODIFY COLUMN status VARCHAR(20) DEFAULT 'active'"); log.info("已修改admins.status字段类型"); } catch (Exception e) { // 修改失败,忽略错误 } // 迁移16: 创建用户操作日志表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_operation_logs (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT COMMENT \'用户ID\', " + "nickname VARCHAR(50) COMMENT \'用户昵称\', " + "operation_type VARCHAR(20) NOT NULL COMMENT \'操作类型: login/logout/create/update/delete/query等\', " + "description VARCHAR(500) COMMENT \'操作描述\', " + "request_path VARCHAR(200) COMMENT \'请求路径\', " + "request_method VARCHAR(10) COMMENT \'请求方法\', " + "request_params TEXT COMMENT \'请求参数(脱敏)\', " + "response_status INT COMMENT \'响应状态码\', " + "client_ip VARCHAR(50) COMMENT \'客户端IP\', " + "user_agent VARCHAR(500) COMMENT \'User-Agent\', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_user_id (user_id), " + "INDEX idx_operation_type (operation_type), " + "INDEX idx_created_at (created_at)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); log.info("已创建user_operation_logs表"); } catch (Exception e) { // 表已存在,忽略错误 } // 迁移16: tasks表添加bonus_rule字段(POINT-002: 超额积分规则) try { jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN bonus_rule VARCHAR(500) COMMENT \'超额积分规则JSON\'"); log.info("已添加bonus_rule列到tasks表"); } catch (Exception e) { // 列已存在,忽略错误 } // 迁移17: tasks表添加points_expire_days字段(POINT-009: 积分有效期) try { jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN points_expire_days INT DEFAULT 0 COMMENT \'积分有效期天数,0表示永久有效\'"); log.info("已添加points_expire_days列到tasks表"); } catch (Exception e) { // 列已存在,忽略错误 } // 迁移18: points_log表添加expire_at字段(POINT-009: 积分有效期) try { jdbcTemplate.execute("ALTER TABLE points_log ADD COLUMN expire_at DATE COMMENT \'积分过期日期\'"); log.info("已添加expire_at列到points_log表"); } catch (Exception e) { // 列已存在,忽略错误 } // 迁移19: points_log表添加expired字段(POINT-009: 过期状态标记) try { jdbcTemplate.execute("ALTER TABLE points_log ADD COLUMN expired TINYINT DEFAULT 0 COMMENT \'是否已过期: 0否1是\'"); log.info("已添加expired列到points_log表"); } catch (Exception e) { // 列已存在,忽略错误 } // 迁移20: tasks表添加template_id字段(TASK-004: 重复任务模板追踪) try { jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN template_id BIGINT COMMENT \'模板任务ID\'"); log.info("已添加template_id列到tasks表"); } catch (Exception e) { // 列已存在,忽略错误 } // 迁移21: tasks表添加is_template字段(TASK-004: 标记模板任务) try { jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN is_template TINYINT DEFAULT 0 COMMENT '是否为模板任务'"); log.info("已添加is_template列到tasks表"); } catch (Exception e) { // 列已存在,忽略错误 } // 迁移22: 修复tasks表child_id和deadline允许NULL(支持多孩子和家长任务) try { jdbcTemplate.execute("ALTER TABLE tasks MODIFY COLUMN child_id BIGINT NULL COMMENT '执行者ID'"); log.info("已修改tasks.child_id允许NULL"); } catch (Exception e) { // 修改失败,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE tasks MODIFY COLUMN deadline DATETIME NULL COMMENT '截止时间'"); log.info("已修改tasks.deadline允许NULL"); } catch (Exception e) { // 修改失败,忽略错误 } // 迁移23: tasks表添加新字段 try { jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN task_type VARCHAR(20) COMMENT '任务性质: onetime/recurring'"); log.info("已添加task_type列到tasks表"); } catch (Exception e) { // 列已存在,忽略错误 } // 迁移24: tasks表添加parent_task_id字段(购买任务→每日使用子任务链;子任务在父任务完成前不显示) try { jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN parent_task_id BIGINT COMMENT '父任务ID(购买任务→每日使用子任务)'"); log.info("已添加parent_task_id列到tasks表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN frequency VARCHAR(20) COMMENT '频率: daily/weekly/monthly'"); log.info("已添加frequency列到tasks表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN max_frequency INT COMMENT '周期内最多完成次数'"); log.info("已添加max_frequency列到tasks表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN complete_types VARCHAR(100) COMMENT '完成方式JSON'"); log.info("已添加complete_types列到tasks表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN review_type VARCHAR(20) COMMENT '审核人类型: creator/parent/teacher/ai'"); log.info("已添加review_type列到tasks表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN earliest_start DATETIME COMMENT '最早开始时间'"); log.info("已添加earliest_start列到tasks表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN latest_end DATETIME COMMENT '最晚结束时间'"); log.info("已添加latest_end列到tasks表"); } catch (Exception e) { // 列已存在,忽略错误 } // 迁移24: tasks表添加minigame_code字段(小游戏任务) try { jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN minigame_code VARCHAR(50) DEFAULT NULL COMMENT '小游戏代码: schulte/1a2b/sudoku'"); log.info("已添加minigame_code列到tasks表"); } catch (Exception e) { // 列已存在,忽略错误 } // 迁移25: tasks表添加duration字段(TASK-011: 任务预计时长) try { jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN duration INT DEFAULT 0 COMMENT '预计时长(分钟)'"); log.info("已添加duration列到tasks表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE tasks ADD INDEX idx_minigame (minigame_code) WHERE minigame_code IS NOT NULL"); log.info("已添加idx_minigame索引到tasks表"); } catch (Exception e) { // 索引已存在,忽略错误 } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS trial_memberships (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "start_date DATETIME NOT NULL COMMENT '试用开始时间', " + "end_date DATETIME NOT NULL COMMENT '试用结束时间', " + "status VARCHAR(20) DEFAULT 'PENDING' COMMENT '状态:PENDING/ACTIVE/EXPIRED/CONVERTED', " + "trial_days INT DEFAULT 7 COMMENT '试用天数', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_user_id (user_id), " + "INDEX idx_family_id (family_id), " + "INDEX idx_status (status), " + "INDEX idx_end_date (end_date)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); log.info("已创建trial_memberships表"); } catch (Exception e) { log.warn("创建trial_memberships表可能已存在: {}", e.getMessage()); } // assessment_appointments 新字段 try { jdbcTemplate.execute("ALTER TABLE assessment_appointments ADD COLUMN user_id BIGINT COMMENT '用户ID'"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE assessment_appointments ADD COLUMN guide_id BIGINT COMMENT '成长规划师ID'"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE assessment_appointments ADD COLUMN package_id BIGINT COMMENT '套餐ID'"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE assessment_appointments ADD COLUMN remark VARCHAR(500) COMMENT '备注'"); } catch (Exception e) { } // assessment_orders 表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS assessment_orders (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "order_no VARCHAR(64) NOT NULL UNIQUE, " + "family_id BIGINT NOT NULL, " + "user_id BIGINT NOT NULL, " + "child_id BIGINT, " + "guide_id BIGINT NOT NULL, " + "package_id BIGINT NOT NULL, " + "guide_name VARCHAR(50), " + "package_name VARCHAR(100), " + "total_price BIGINT DEFAULT 0, " + "discount_amount BIGINT DEFAULT 0, " + "actual_price BIGINT DEFAULT 0, " + "status VARCHAR(20) DEFAULT 'pending', " + "pay_time VARCHAR(50), " + "pay_type VARCHAR(20), " + "transaction_id VARCHAR(64), " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_family (family_id), " + "INDEX idx_guide (guide_id), " + "INDEX idx_status (status)) " + "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评估订单表'"); } catch (Exception e) { } // 迁移26: guide_applications表添加address和bank_card字段 try { jdbcTemplate.execute("ALTER TABLE guide_applications ADD COLUMN address VARCHAR(500) COMMENT '地址'"); log.info("已添加address列到guide_applications表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE guide_applications ADD COLUMN bank_card VARCHAR(50) COMMENT '银行卡号'"); log.info("已添加bank_card列到guide_applications表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE guide_applications ADD COLUMN bank_name VARCHAR(100) COMMENT '开户行'"); log.info("已添加bank_name列到guide_applications表"); } catch (Exception e) { // 列已存在,忽略错误 } // 迁移27: users表添加address字段(成长规划师地址) try { jdbcTemplate.execute("ALTER TABLE users ADD COLUMN address VARCHAR(500) COMMENT '地址'"); log.info("已添加address列到users表"); } catch (Exception e) { // 列已存在,忽略错误 } // 迁移28: 创建guide_application_records表(申请过程记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_application_records (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "application_id BIGINT NOT NULL COMMENT '关联申请ID', " + "user_id BIGINT NOT NULL COMMENT '操作人用户ID', " + "action VARCHAR(50) NOT NULL COMMENT '操作: submit/approve/reject/withdraw/resubmit', " + "from_status VARCHAR(20) COMMENT '操作前状态', " + "to_status VARCHAR(20) NOT NULL COMMENT '操作后状态', " + "remark VARCHAR(500) COMMENT '备注/原因', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_application_id (application_id), " + "INDEX idx_user_id (user_id), " + "INDEX idx_action (action)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成长规划师申请过程记录表'"); log.info("已创建guide_application_records表"); } catch (Exception e) { } try { Integer exists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'points_log' AND COLUMN_NAME = 'category'", Integer.class); if (exists == null || exists == 0) { jdbcTemplate.execute("ALTER TABLE points_log ADD COLUMN category VARCHAR(20) DEFAULT 'task' COMMENT '积分分类: task任务积分, system系统积分'"); log.info("已添加category列到points_log表"); } else { log.info("category列已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加category列失败: {}", e.getMessage()); } // 迁移22: 创建invite_card表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS invite_card (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "type VARCHAR(20) NOT NULL COMMENT 'child/family/guide', " + "code VARCHAR(64) NOT NULL UNIQUE COMMENT '邀请码', " + "ref_id BIGINT NOT NULL COMMENT '关联ID(childId/familyId)', " + "creator_id BIGINT NOT NULL COMMENT '创建人', " + "expires_at DATETIME NOT NULL, " + "used TINYINT DEFAULT 0, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_code (code), " + "INDEX idx_ref_type (ref_id, type)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='邀请卡片'"); log.info("已创建invite_card表"); } catch (Exception e) { // 表已存在,忽略错误 } // 迁移23: 创建wishes表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS wishes (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT COMMENT '家庭ID', " + "child_id BIGINT COMMENT '目标孩子ID', " + "created_by BIGINT COMMENT '创建者用户ID', " + "creator_type VARCHAR(20) COMMENT '创建者类型: child/parent', " + "title VARCHAR(200) NOT NULL COMMENT '心愿标题', " + "description VARCHAR(500) COMMENT '心愿描述', " + "category VARCHAR(32) COMMENT '心愿分类', " + "points_required INT COMMENT '所需积分', " + "points_set_by BIGINT COMMENT '定价者ID', " + "points_set_at DATETIME COMMENT '定价时间', " + "status VARCHAR(32) DEFAULT 'active' COMMENT '状态: draft/active/pending_exchange/approved/exchanging/fulfilled/cancelled/rejected', " + "exchange_requested_at DATETIME COMMENT '申请兑换时间', " + "exchange_approved_by BIGINT COMMENT '批准者ID', " + "exchange_approved_at DATETIME COMMENT '批准时间', " + "points_deducted_at DATETIME COMMENT '扣积分时间', " + "fulfilled_by BIGINT COMMENT '兑现者ID', " + "fulfilled_at DATETIME COMMENT '兑现时间', " + "child_confirmed_at DATETIME COMMENT '孩子确认收货时间', " + "rejected_by BIGINT COMMENT '拒绝者ID', " + "rejected_at DATETIME COMMENT '拒绝时间', " + "reject_reason VARCHAR(500) COMMENT '拒绝原因', " + "reject_count INT DEFAULT 0 COMMENT '被拒绝次数', " + "cancelled_by BIGINT COMMENT '取消者ID', " + "cancelled_at DATETIME COMMENT '取消时间', " + "cancel_reason VARCHAR(500) COMMENT '取消原因', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_family_id (family_id), " + "INDEX idx_child_id (child_id), " + "INDEX idx_status (status)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='心愿单表'"); log.info("已创建wishes表"); } catch (Exception e) { log.warn("创建wishes表可能已存在: {}", e.getMessage()); } // 迁移24: users表添加个人信息补充字段 try { Integer exists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'ethnicity'", Integer.class); if (exists == null || exists == 0) { jdbcTemplate.execute("ALTER TABLE users " + "ADD COLUMN ethnicity VARCHAR(32) DEFAULT NULL COMMENT '民族', " + "ADD COLUMN blood_type VARCHAR(16) DEFAULT NULL COMMENT '血型', " + "ADD COLUMN highest_education VARCHAR(32) DEFAULT NULL COMMENT '最高学历', " + "ADD COLUMN marital_status VARCHAR(16) DEFAULT NULL COMMENT '婚姻状态', " + "ADD COLUMN family_role VARCHAR(16) DEFAULT NULL COMMENT '家长身份: 爸爸/妈妈/爷爷/奶奶/姥爷/姥姥/其他'"); log.info("已添加个人信息补充字段到users表"); } else { log.info("个人信息补充字段已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加个人信息补充字段失败: {}", e.getMessage()); } // 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'", Integer.class); if (exists == null || exists == 0) { jdbcTemplate.execute("ALTER TABLE users ADD COLUMN vendor_type VARCHAR(32) DEFAULT NULL COMMENT '服务商类型: planner/activity_provider/product_supplier'"); jdbcTemplate.execute("ALTER TABLE users ADD COLUMN vendor_status VARCHAR(16) DEFAULT NULL COMMENT '服务商审核状态: pending/approved/rejected'"); 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表"); } else { log.info("浠艾福俱乐部服务商字段已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加服务商字段失败: {}", e.getMessage()); } // Phase 0: 迁移已有规划师到服务商体系 try { int updated = jdbcTemplate.update( "UPDATE users SET vendor_type = 'planner', vendor_status = 'approved' WHERE teacher_status = 'approved' AND vendor_type IS NULL" ); if (updated > 0) { log.info("已迁移 {} 名规划师到服务商体系", updated); } } catch (Exception e) { log.warn("迁移规划师到服务商体系失败: {}", e.getMessage()); } // ==================== Phase 1: 浠艾福俱乐部商品系统 ====== // 商品表 try { jdbcTemplate.execute( "CREATE TABLE IF NOT EXISTS products (" + " id BIGINT AUTO_INCREMENT PRIMARY KEY," + " name VARCHAR(200) NOT NULL," + " description VARCHAR(1000)," + " intro TEXT," + " cover_image VARCHAR(500)," + " images TEXT," + " price INT NOT NULL DEFAULT 0 COMMENT '价格(分)'," + " stock INT NOT NULL DEFAULT 0," + " sales_count INT NOT NULL DEFAULT 0," + " product_type VARCHAR(32) NOT NULL COMMENT 'activity/course/physical/digital/service'," + " vendor_id BIGINT," + " vendor_name VARCHAR(100)," + " status VARCHAR(32) NOT NULL DEFAULT 'draft' COMMENT 'draft/pending/approved/rejected/on_shelf/off_shelf'," + " reject_reason VARCHAR(500)," + " member_price INT COMMENT '会员价(分)'," + " profit_rate INT DEFAULT 0 COMMENT '平台利润率(千分比)'," + " member_eligible VARCHAR(32) DEFAULT 'none' COMMENT 'all/budget/none'," + " domain VARCHAR(32) COMMENT 'action/mind/body/wisdom/wealth'," + " external_source VARCHAR(64)," + " external_id VARCHAR(128)," + " external_data TEXT," + " created_at DATETIME DEFAULT CURRENT_TIMESTAMP," + " updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + ")" ); } catch (Exception e) { log.warn("创建products表失败: {}", e.getMessage()); } // 商品SKU表 try { jdbcTemplate.execute( "CREATE TABLE IF NOT EXISTS product_skus (" + " id INT NOT NULL AUTO_INCREMENT," + " product_id INT NOT NULL COMMENT '商品ID'," + " sku_code VARCHAR(64) NOT NULL COMMENT 'SKU编码'," + " specs JSON NOT NULL COMMENT '规格JSON'," + " price DECIMAL(10,2) NOT NULL COMMENT 'SKU价格'," + " stock INT NOT NULL DEFAULT 0 COMMENT '库存'," + " original_price DECIMAL(10,2) COMMENT '原价/划线价'," + " image VARCHAR(512) COMMENT 'SKU图片'," + " enabled TINYINT(1) NOT NULL DEFAULT 1," + " create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP," + " update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," + " PRIMARY KEY (id)," + " KEY idx_product_id (product_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品SKU'" ); log.info("已创建product_skus表"); } catch (Exception e) { log.warn("创建product_skus表失败: {}", e.getMessage()); } // 订单表 try { jdbcTemplate.execute( "CREATE TABLE IF NOT EXISTS product_orders (" + " id BIGINT AUTO_INCREMENT PRIMARY KEY," + " order_no VARCHAR(64) NOT NULL UNIQUE," + " product_id BIGINT NOT NULL," + " product_name VARCHAR(200)," + " product_type VARCHAR(32)," + " buyer_id BIGINT NOT NULL," + " family_id BIGINT," + " quantity INT NOT NULL DEFAULT 1," + " unit_price INT NOT NULL COMMENT '单价(分)'," + " total_amount INT NOT NULL COMMENT '总金额(分)'," + " status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT 'pending/paid/completed/cancelled/refunded'," + " payment_method VARCHAR(32) COMMENT 'wechat/points/membership'," + " remark VARCHAR(500)," + " paid_at DATETIME," + " created_at DATETIME DEFAULT CURRENT_TIMESTAMP," + " updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + ")" ); } catch (Exception e) { log.warn("创建product_orders表失败: {}", e.getMessage()); } // 商品浏览/停留时长埋点表(支撑运营看板「商品停留时长 Top」) try { jdbcTemplate.execute( "CREATE TABLE IF NOT EXISTS product_view_records (" + " id BIGINT AUTO_INCREMENT PRIMARY KEY," + " user_id BIGINT," + " product_id BIGINT NOT NULL," + " product_name VARCHAR(200)," + " product_type VARCHAR(32)," + " duration_seconds INT NOT NULL DEFAULT 0 COMMENT '本次浏览停留秒数'," + " source VARCHAR(32) COMMENT 'miniprogram/web'," + " view_at DATETIME," + " created_at DATETIME DEFAULT CURRENT_TIMESTAMP" + ")" ); } catch (Exception e) { log.warn("创建product_view_records表失败: {}", e.getMessage()); } // 系统配置表 try { jdbcTemplate.execute( "CREATE TABLE IF NOT EXISTS sys_config (" + " id BIGINT AUTO_INCREMENT PRIMARY KEY," + " config_key VARCHAR(64) NOT NULL UNIQUE," + " config_value TEXT," + " description VARCHAR(500)," + " updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + ")" ); } catch (Exception e) { log.warn("创建sys_config表失败: {}", e.getMessage()); } // Migration: product_orders add logistics fields try { jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN logistics_no VARCHAR(64) COMMENT '物流单号'"); log.info("已添加logistics_no列到product_orders表"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN logistics_company VARCHAR(100) COMMENT '物流公司'"); log.info("已添加logistics_company列到product_orders表"); } catch (Exception e) { } // SysConfig 种子数据 try { insertSysConfigSeed("points_exchange_rate", "1", "积分兑换汇率(1积分=1分,即0.01元)"); insertSysConfigSeed("points_deduct_cap", "0.5", "积分抵扣上限(50%)"); insertSysConfigSeed("member_fee_standard", "1314", "标准会员年费(元)"); insertSysConfigSeed("member_fee_premium", "1316800", "高级家庭会员年费(分)"); insertSysConfigSeed("member_eligible_budget", "1314", "等值商品选择预算上限(元)"); insertSysConfigSeed("renewal_discount_rate", "0.85", "续费折扣率"); insertSysConfigSeed("member_discount_standard", "0.9", "标准会员折扣率"); insertSysConfigSeed("member_discount_premium", "8000", "高级家庭会员商品折扣(bps)"); insertSysConfigSeed("member_fee_family", "131400", "家庭会员年费(分)"); insertSysConfigSeed("member_fee_family_yearly", "131400", "家庭会员年费(分)"); insertSysConfigSeed("member_fee_family_monthly", "13140", "家庭会员月费(分)"); insertSysConfigSeed("member_fee_family_quarterly", "32850", "家庭会员季费(分)"); insertSysConfigSeed("member_upgrade_threshold", "131400", "自动升级家庭会员累计消费门槛(分)"); insertSysConfigSeed("member_discount_family", "9000", "家庭会员商品折扣(bps)"); insertSysConfigSeed("member_fee_premium_monthly", "131400", "高级家庭会员月费(分)"); insertSysConfigSeed("member_fee_family_original_yearly", "199900", "家庭会员年费原价(分)"); insertSysConfigSeed("member_fee_family_original", "199900", "家庭会员原价兜底(分)"); insertSysConfigSeed("member_fee_premium_original", "1599900", "高级会员年费原价(分)"); insertSysConfigSeed("member_fee_premium_original_monthly", "159900", "高级会员月费原价(分)"); insertSysConfigSeed("commission_member_l1_rate", "2000", "推荐家庭会员L1佣金率(bps)"); insertSysConfigSeed("commission_member_l2_rate", "500", "推荐家庭会员L2佣金率(bps)"); insertSysConfigSeed("commission_product_profit_share", "1000", "下线商品购买利润分成(bps)"); insertSysConfigSeed("commission_service_rate", "1000", "下线服务购买佣金率(bps)"); insertSysConfigSeed("commission_event_fixed", "2000", "推荐新用户报名活动固定佣金(分)"); insertSysConfigSeed("withdraw_min_amount", "10000", "最低提现金额(分)"); insertSysConfigSeed("withdraw_tax_rate", "2000", "佣金税率(bps)"); insertSysConfigSeed("withdraw_fee_free_threshold", "80000", "免税额度(分)"); insertSysConfigSeed("article_energy_rate_normal", "1", "普通文章能量汇率(每100字)"); insertSysConfigSeed("article_energy_rate_premium", "2", "优质文章能量汇率(每100字)"); insertSysConfigSeed("article_energy_rate_original", "3", "原创文章能量汇率(每100字)"); insertSysConfigSeed("article_reading_min_seconds", "60", "文章阅读达标最少秒数"); insertSysConfigSeed("activity_checkin_default_points", "5", "活动签到默认积分"); insertSysConfigSeed("health_checkin_points", "5", "健康打卡积分"); insertSysConfigSeed("finance_checkin_points", "5", "理财打卡积分"); insertSysConfigSeed("trial_enabled", "true", "是否启用会员试用"); insertSysConfigSeed("trial_days", "7", "试用天数"); insertSysConfigSeed("trial_features", "[\"free_activities\",\"free_courses\",\"teacher_consultation\"]", "试用期间可用功能"); insertSysConfigSeed("member_pay_virtual_enabled", "1", "会员支付是否使用虚拟支付通道(1=虚拟,0=普通微信)"); insertSysConfigSeed("gift_membership_threshold", "198000", "购买送会员商品累计达到该金额(分)赠送会员"); insertSysConfigSeed("gift_membership_level", "FAMILY", "达到送会员额度后赠送的会员等级"); insertSysConfigSeed("gift_membership_original_threshold", "1000000", "送会员商品原价达标阈值(分),达到此金额后送会员商品部分实付一口价"); insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达标后一口实付价(分)"); insertSysConfigSeed("health_dimension_weights", "{\"growth\":0.20,\"sleep\":0.15,\"vision\":0.10,\"immunity\":0.15,\"nutrition\":0.15,\"gut\":0.10,\"exercise\":0.15}", "7维健康→身能量权重配置"); insertSysConfigSeed("health_dimension_sync_factor", "0.5", "7维健康同步至身能量系数"); log.info("SysConfig 种子数据已加载"); } catch (Exception e) { log.warn("SysConfig 种子数据初始化失败: {}", e.getMessage()); } // Product 种子数据(5 个示例商品,覆盖全部 5 种 productType) try { insertProductSeed("亲子关系工作坊", "线下半日工作坊,重建家庭对话模式", "activity", 38800, "action", "all"); insertProductSeed("情绪管理21天训练营", "21天视频课程,识别并管理情绪", "course", 19900, "mind", "all"); insertProductSeed("肠道菌群检测套装", "家用检测 + 报告解读", "physical", 59900, "body", "budget"); insertProductSeed("儿童多模态认知测评", "在线测评 + 个性化建议", "digital", 9900, "wisdom", "none"); insertProductSeed("成长规划师1对1咨询", "60分钟专业规划", "service", 50000, "wealth", "budget"); log.info("Product 种子数据已加载"); } catch (Exception e) { log.warn("Product 种子数据初始化失败: {}", e.getMessage()); } // 活动种子数据(3 个示例活动,覆盖身/心/智三个维度) try { insertActivitySeed("亲子户外徒步探险", "周末亲子徒步活动,在自然中锻炼体魄、增进亲子关系,全程约5公里。", "body", "offline", "published", "2026-08-15 09:00:00", "2026-08-15 16:00:00", "北京奥林匹克森林公园", 50, 0, 0, "{\"body\":80,\"mind\":0,\"wisdom\":0,\"action\":20,\"wealth\":0}"); insertActivitySeed("家庭正念静心工作坊", "专业导师带领的正念冥想体验,学习情绪调节和家庭沟通技巧。", "mind", "offline", "published", "2026-08-22 14:00:00", "2026-08-22 17:00:00", "北京市朝阳区心理成长中心", 30, 9900, 7900, "{\"body\":0,\"mind\":80,\"wisdom\":10,\"action\":10,\"wealth\":0}"); insertActivitySeed("少儿科学思维探索营", "通过动手实验激发孩子对科学的兴趣,培养逻辑思维与创造力。", "wisdom", "online", "published", "2026-09-05 10:00:00", "2026-09-05 12:00:00", "线上直播", 200, 19900, 15900, "{\"body\":0,\"mind\":0,\"wisdom\":70,\"action\":30,\"wealth\":0}"); log.info("Activity 种子数据已加载"); } catch (Exception e) { log.warn("Activity 种子数据初始化失败: {}", e.getMessage()); } // 方案规则种子数据(主链路 P0-3:模板包 + 方案生成规则) try { Long pkgFocus = ensureTemplatePackage("专注力提升方案模板", "针对注意力维度低分(0-60)的14天专注力提升训练", 14, "专注力"); Long pkgKeep = ensureTemplatePackage("注意力巩固方案模板", "针对注意力维度正常(61-100)的14天巩固训练", 14, "注意力"); ensureTemplateItems(pkgFocus, new String[][]{ {"舒尔特方格训练", "每日5分钟舒尔特方格,提升视觉专注", "5", "daily"}, {"静坐冥想", "每日10分钟静坐冥想,训练抗干扰能力", "3", "daily"}, {"番茄钟作业法", "使用番茄钟完成当日作业,25分钟一节", "5", "daily"} }); ensureTemplateItems(pkgKeep, new String[][]{ {"专注力保持打卡", "每日记录专注时长,保持良好习惯", "3", "daily"}, {"优质睡眠打卡", "每日21:30前入睡,保障注意力恢复", "3", "daily"}, {"亲子共读", "每周3次亲子共读,培养持续专注", "5", "weekly"} }); ensurePlanRule("attention", 0, 60, pkgFocus, 1); ensurePlanRule("attention", 61, 100, pkgKeep, 2); log.info("方案规则种子数据已加载: 模板包 focus={}, keep={}", pkgFocus, pkgKeep); } catch (Exception e) { log.warn("方案规则种子数据初始化失败: {}", e.getMessage()); } // 报告解析中台:注册菌群检测报告类型(主链路 P0-4) try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM report_type_registry WHERE type_id = ?", Integer.class, "gut_flora"); if (count == null || count == 0) { jdbcTemplate.update( "INSERT INTO report_type_registry (type_id, family, display_name, description, parser_path, " + "min_confidence, is_active, source, review_status, version, created_at, updated_at) " + "VALUES ('gut_flora', 'gut_flora', '菌群检测', '募极生物/北京肠道菌群检测报告', " + "'gut_flora/extract_gut_flora.py', 30, 1, 'prebuilt', 'approved', '1.0', NOW(), NOW())"); log.info("已注册报告类型 gut_flora"); } else { log.info("报告类型 gut_flora 已存在,跳过"); } } catch (Exception e) { log.warn("报告类型注册失败: {}", e.getMessage()); } // 文章种子数据(3 个示例文章,覆盖学习力/情绪管理/亲子教育分类) try { insertArticleSeed("如何培养孩子的自主学习力", "从内在动机出发,帮助孩子建立自主学习习惯的实用方法指南。", "article", 4L, "wisdom", "published", "曹老师", "normal", 5, 1800, "{\"body\":0,\"mind\":0,\"wisdom\":60,\"action\":40,\"wealth\":0}"); insertArticleSeed("儿童情绪管理的五个关键步骤", "当孩子情绪爆发时,家长应该如何应对?本文提供一套可操作的情绪管理框架。", "article", 2L, "mind", "published", "李老师", "premium", 4, 2200, "{\"body\":0,\"mind\":80,\"wisdom\":10,\"action\":10,\"wealth\":0}"); insertArticleSeed("亲子运动的正确打开方式", "不同年龄段孩子的运动建议,让全家人在快乐中收获健康。", "article", 3L, "action", "published", "王老师", "normal", 3, 1500, "{\"body\":40,\"mind\":0,\"wisdom\":0,\"action\":60,\"wealth\":0}"); log.info("Article 种子数据已加载"); } catch (Exception e) { log.warn("Article 种子数据初始化失败: {}", e.getMessage()); } // 补充商品种子数据(2 个,丰富商品库) try { insertProductSeed("少儿专注力训练教具盒", "含舒尔特方格、记忆卡片等8种专注力训练工具,适合5-12岁儿童。", "physical", 16800, "wisdom", "all"); insertProductSeed("家庭年度VIP成长会员", "一年内畅享全部线上课程、活动折扣及规划师咨询服务。", "digital", 298000, "action", "all"); log.info("补充 Product 种子数据已加载"); } catch (Exception e) { 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("mind", "mind_tasks", "心智任务", "[\"parent\",\"child\"]", 5); insertContentSectionSeed("mind", "related_activities", "相关活动", "[\"parent\",\"child\"]", 6); insertContentSectionSeed("mind", "purchased_courses", "已购课程", "[\"parent\",\"child\"]", 7); 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 INT DEFAULT 0 COMMENT '平台利润率(bps,100=1%)'"); 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 INT NOT NULL COMMENT '订单金额(分)', " + "profit_rate INT DEFAULT 0 COMMENT '利润率(bps,100=1%)', " + "commission_amount INT 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 INT 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()); } // ==================== 会员体系 & 佣金扩展 ====== // 迁移: users表添加会员字段 try { Integer exists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'member_level'", Integer.class); if (exists == null || exists == 0) { jdbcTemplate.execute("ALTER TABLE users ADD COLUMN member_level VARCHAR(20) DEFAULT 'FREE' COMMENT '会员等级: FREE/FAMILY/PROVIDER'"); jdbcTemplate.execute("ALTER TABLE users ADD COLUMN member_expire_time DATETIME DEFAULT NULL COMMENT '家庭会员到期时间'"); jdbcTemplate.execute("ALTER TABLE users ADD COLUMN total_consumption BIGINT DEFAULT 0 COMMENT '累计消费金额(分)'"); jdbcTemplate.execute("ALTER TABLE users ADD COLUMN upgrade_from_consumption TINYINT DEFAULT 0 COMMENT '是否通过累计消费自动升级'"); jdbcTemplate.execute("ALTER TABLE users ADD COLUMN direct_count INT DEFAULT 0 COMMENT '直接推荐人数'"); jdbcTemplate.execute("ALTER TABLE users ADD COLUMN indirect_count INT DEFAULT 0 COMMENT '间接推荐人数'"); jdbcTemplate.execute("ALTER TABLE users ADD COLUMN total_commission_earned BIGINT DEFAULT 0 COMMENT '累计获得佣金(分)'"); log.info("已添加会员字段到users表"); } else { log.info("会员字段已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加会员字段失败: {}", e.getMessage()); } // 迁移: commission_records表添加扩展字段 try { Integer exists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'commission_records' AND COLUMN_NAME = 'level'", Integer.class); if (exists == null || exists == 0) { jdbcTemplate.execute("ALTER TABLE commission_records ADD COLUMN level INT DEFAULT 1 COMMENT '佣金层级: 1=直接推荐, 2=间接推荐'"); jdbcTemplate.execute("ALTER TABLE commission_records ADD COLUMN settled_at DATETIME DEFAULT NULL COMMENT '结算时间'"); jdbcTemplate.execute("ALTER TABLE commission_records ADD COLUMN withdrawn_at DATETIME DEFAULT NULL COMMENT '提现时间'"); log.info("已添加扩展字段到commission_records表"); } else { log.info("commission_records扩展字段已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加commission_records扩展字段失败: {}", e.getMessage()); } // 新表: member_upgrade_records (会员升级记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS member_upgrade_records (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "from_level VARCHAR(20) NOT NULL COMMENT '升级前等级', " + "to_level VARCHAR(20) NOT NULL COMMENT '升级后等级', " + "upgrade_type VARCHAR(20) NOT NULL COMMENT '升级类型: pay/consumption/admin', " + "order_id BIGINT DEFAULT NULL COMMENT '关联支付订单ID', " + "consumption_amount BIGINT DEFAULT NULL COMMENT '触发升级的累计消费金额', " + "expire_time DATETIME DEFAULT NULL COMMENT '家庭会员到期时间', " + "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_user (user_id), " + "INDEX idx_order (order_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员升级记录'"); log.info("已创建member_upgrade_records表"); } catch (Exception e) { log.warn("创建member_upgrade_records表失败: {}", 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()); } // 迁移: articles表补充知识中心新字段(content_type/difficulty_level/dimension_ids) try { Integer colExists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'articles' AND COLUMN_NAME = 'content_type'", Integer.class); if (colExists == null || colExists == 0) { jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN content_type VARCHAR(20) DEFAULT 'article' COMMENT '内容类型: article/knowledge/course/tip' AFTER article_type"); jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN difficulty_level TINYINT DEFAULT 0 COMMENT '难度等级 1-5' AFTER content_type"); jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN dimension_ids TEXT COMMENT '关联维度ID列表JSON' AFTER related_dimensions"); log.info("已迁移articles表知识中心新字段"); } else { log.info("articles表知识中心字段已完整,跳过迁移"); } } catch (Exception e) { log.warn("articles表知识中心字段迁移失败: {}", e.getMessage()); } // 文章标签关联表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS article_tags (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "article_id BIGINT NOT NULL COMMENT '文章ID', " + "tag_id BIGINT NOT NULL COMMENT '标签ID(关联dan_knowledge_tag.id)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_article_tag (article_id, tag_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章-标签关联表'"); log.info("已创建article_tags表"); } catch (Exception e) { log.warn("创建article_tags表失败: {}", 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 INT NOT NULL COMMENT '占比(bps,10000=100%)', " + "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 cart_items (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY," + "user_id BIGINT NOT NULL COMMENT '用户ID'," + "product_id BIGINT NOT NULL COMMENT '商品ID'," + "quantity INT DEFAULT 1 COMMENT '数量'," + "selected TINYINT(1) DEFAULT 1 COMMENT '是否选中'," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," + "INDEX idx_user (user_id)," + "INDEX idx_product (product_id)," + "UNIQUE KEY uk_user_product (user_id, product_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='购物车'"); log.info("已创建cart_items表"); } catch (Exception e) { log.warn("创建cart_items表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS shop_categories (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY," + "name VARCHAR(64) NOT NULL COMMENT '分类名称'," + "parent_id BIGINT DEFAULT 0 COMMENT '父分类ID(0=根节点)'," + "level INT DEFAULT 0 COMMENT '层级(0/1/2/3)'," + "sort INT DEFAULT 0 COMMENT '排序'," + "image VARCHAR(512) COMMENT '分类图标'," + "enabled TINYINT(1) DEFAULT 1 COMMENT '启用'," + "dimension_codes VARCHAR(128) COMMENT '维度编码(逗号分隔: body,mind,heart)'," + "product_types VARCHAR(128) COMMENT '商品类型(逗号分隔: PHYSICAL,COURSE,ASSESSMENT)'," + "min_visible_level VARCHAR(50) COMMENT '最低可见会员级别编码(NULL=全部可见)'," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," + "INDEX idx_parent (parent_id)," + "INDEX idx_sort (sort)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品类目'"); log.info("已创建shop_categories表"); } catch (Exception e) { log.warn("创建shop_categories表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS distribution_systems (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY," + "name VARCHAR(64) NOT NULL COMMENT '体系名称'," + "description VARCHAR(255) COMMENT '描述'," + "owner_id BIGINT COMMENT '创建者ID'," + "profit_rate INT DEFAULT 0 COMMENT '平台利润率(千分比)'," + "enabled TINYINT(1) DEFAULT 1 COMMENT '启用'," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售体系'"); log.info("已创建distribution_systems表"); } catch (Exception e) { log.warn("创建distribution_systems表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS distribution_relations (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY," + "system_id BIGINT NOT NULL COMMENT '体系ID'," + "user_id BIGINT NOT NULL COMMENT '用户ID'," + "parent_id BIGINT COMMENT '上级用户ID(null=根)'," + "depth INT DEFAULT 0 COMMENT '层级深度'," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," + "INDEX idx_system (system_id)," + "INDEX idx_user (user_id)," + "INDEX idx_parent (parent_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售关系'"); log.info("已创建distribution_relations表"); } catch (Exception e) { log.warn("创建distribution_relations表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS after_sales_requests (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY," + "order_id BIGINT NOT NULL COMMENT '订单ID'," + "order_no VARCHAR(32) NOT NULL COMMENT '订单号'," + "user_id BIGINT NOT NULL COMMENT '用户ID'," + "type VARCHAR(32) NOT NULL COMMENT '类型: REFUND/RETURN_REFUND'," + "reason VARCHAR(255) COMMENT '原因'," + "description TEXT COMMENT '描述'," + "images TEXT COMMENT '凭证图片(JSON数组)'," + "refund_amount INT DEFAULT 0 COMMENT '退款金额(分)'," + "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/approved/rejected/completed'," + "reject_reason VARCHAR(255) COMMENT '驳回原因'," + "handled_by BIGINT COMMENT '处理人ID'," + "handled_at DATETIME COMMENT '处理时间'," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," + "INDEX idx_order (order_id)," + "INDEX idx_user (user_id)," + "INDEX idx_status (status)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='售后申请'"); log.info("已创建after_sales_requests表"); } catch (Exception e) { log.warn("创建after_sales_requests表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS shop_discount_rules (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY," + "name VARCHAR(64) NOT NULL COMMENT '规则名称'," + "type VARCHAR(32) NOT NULL COMMENT '类型: PERCENT/FIXED/THRESHOLD'," + "value INT NOT NULL COMMENT '折扣值(PERCENT=百分比数字, FIXED/THRESHOLD=金额分)'," + "threshold_amount INT COMMENT '满减门槛(分,仅THRESHOLD)'," + "category_id BIGINT COMMENT '适用类目ID(null=全部)'," + "distribution_system_id BIGINT COMMENT '适用体系ID(null=全部)'," + "product_ids TEXT COMMENT '适用商品ID(逗号分隔,null=全部)'," + "start_time DATETIME COMMENT '开始时间'," + "end_time DATETIME COMMENT '结束时间'," + "max_uses INT DEFAULT 0 COMMENT '最大使用次数(0=不限)'," + "used_count INT DEFAULT 0 COMMENT '已使用次数'," + "enabled TINYINT(1) DEFAULT 1 COMMENT '启用'," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," + "INDEX idx_category (category_id)," + "INDEX idx_enabled (enabled)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='折扣规则'"); log.info("已创建shop_discount_rules表"); } catch (Exception e) { log.warn("创建shop_discount_rules表失败: {}", e.getMessage()); } // 电商扩展字段 - products表 try { jdbcTemplate.execute("ALTER TABLE products ADD COLUMN category_id BIGINT COMMENT '商品类目ID'"); log.info("已添加category_id列到products表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE products ADD COLUMN distribution_system_id BIGINT COMMENT '销售体系ID'"); log.info("已添加distribution_system_id列到products表"); } catch (Exception e) { // 列已存在,忽略错误 } // 电商扩展字段 - product_orders表 try { jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN discount_amount INT DEFAULT 0 COMMENT '优惠金额(分)'"); log.info("已添加discount_amount列到product_orders表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN coupon_id BIGINT COMMENT '优惠券ID'"); log.info("已添加coupon_id列到product_orders表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN cancel_at DATETIME COMMENT '取消时间'"); log.info("已添加cancel_at列到product_orders表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN transaction_id VARCHAR(64) COMMENT '微信支付交易号'"); log.info("已添加transaction_id列到product_orders表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN cover_image VARCHAR(512) COMMENT '商品封面图快照'"); log.info("已添加cover_image列到product_orders表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN address_snapshot TEXT COMMENT '收货地址快照(JSON)'"); log.info("已添加address_snapshot列到product_orders表"); } catch (Exception e) { // 列已存在,忽略错误 } // 能量流水表 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, 10000 " + "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()); } // ==================== 精准营养 P0: health_report_survey ============= try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_report_survey (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "report_id BIGINT NOT NULL COMMENT '关联报告ID', " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "child_id BIGINT NOT NULL COMMENT '孩子ID', " + "survey_data JSON COMMENT '调研问卷结果JSON', " + "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/completed', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_report (report_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告调研问卷'"); log.info("已创建 health_report_survey 表"); } catch (Exception e) { log.warn("创建 health_report_survey 表失败: {}", e.getMessage()); } // ==================== 精准营养 P0: health_gut_flora + health_disease_risk ============= try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_gut_flora (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "report_id BIGINT NOT NULL COMMENT '关联报告ID', " + "bacteria_name VARCHAR(100) NULL COMMENT '菌种名称', " + "bacteria_value VARCHAR(50) COMMENT '菌种丰度值', " + "normal_range VARCHAR(100) COMMENT '正常范围', " + "status VARCHAR(20) COMMENT '状态: 正常/偏低/偏高', " + "category VARCHAR(50) COMMENT '分类: 有益菌/有害菌/中性菌', " + "sort_order INT DEFAULT 0 COMMENT '排序', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_report (report_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菌群检测专项数据'"); log.info("已创建 health_gut_flora 表"); } catch (Exception e) { log.warn("创建 health_gut_flora 表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_disease_risk (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "report_id BIGINT NOT NULL COMMENT '关联报告ID', " + "disease_name VARCHAR(100) NOT NULL COMMENT '疾病名称', " + "risk_value VARCHAR(50) COMMENT '风险值(0-1)', " + "risk_level VARCHAR(20) COMMENT '风险等级: 高风险/需注意/低风险', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_report (report_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='疾病风险评估数据'"); log.info("已创建 health_disease_risk 表"); } catch (Exception e) { log.warn("创建 health_disease_risk 表失败: {}", e.getMessage()); } // ==================== 迁移: health_reports 表添加新字段 ============= try { jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN balance_score INT DEFAULT NULL COMMENT '菌群平衡分'"); log.info("已添加balance_score列到health_reports表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN diversity_score INT DEFAULT NULL COMMENT '菌群多样性分'"); log.info("已添加diversity_score列到health_reports表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN beneficial_score INT DEFAULT NULL COMMENT '有益菌评分'"); log.info("已添加beneficial_score列到health_reports表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN harmful_score INT DEFAULT NULL COMMENT '有害菌评分'"); log.info("已添加harmful_score列到health_reports表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN core_genus_score INT DEFAULT NULL COMMENT '核心菌属评分'"); log.info("已添加core_genus_score列到health_reports表"); } catch (Exception e) { // 列已存在,忽略错误 } // 迁移: health_reports 表添加报告编号和姓名字段 try { jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN report_number VARCHAR(50) DEFAULT NULL COMMENT '报告编号'"); log.info("已添加report_number列到health_reports表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN person_name VARCHAR(50) DEFAULT NULL COMMENT '被检测人姓名'"); log.info("已添加person_name列到health_reports表"); } catch (Exception e) { // 列已存在,忽略错误 } // ==================== 迁移: health_gut_flora 表添加新字段 ============= try { jdbcTemplate.execute("ALTER TABLE health_gut_flora ADD COLUMN population_level VARCHAR(50) DEFAULT NULL COMMENT '处于人群%水平'"); log.info("已添加population_level列到health_gut_flora表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE health_gut_flora ADD COLUMN detection_rate VARCHAR(50) DEFAULT NULL COMMENT '%正常人有检出'"); log.info("已添加detection_rate列到health_gut_flora表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE health_gut_flora ADD COLUMN description TEXT DEFAULT NULL COMMENT '菌种说明'"); log.info("已添加description列到health_gut_flora表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE health_gut_flora ADD COLUMN `level` VARCHAR(20) DEFAULT 'GENUS' COMMENT '级别: GENUS/SPECIES'"); log.info("已添加level列到health_gut_flora表"); } catch (Exception e) { // 列已存在,忽略错误 } // ==================== 迁移: guides 表统一 ====== // 迁移: guides 添加 type 字段(区分服务商类型) try { jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN type VARCHAR(32) DEFAULT 'teacher' COMMENT '服务商类型: teacher/planner/activity_provider/product_supplier'"); log.info("已添加type列到guides表"); } catch (Exception e) { // 列已存在,忽略错误 } // 迁移: guides 添加 teachers 合并字段 try { jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN teacher_no VARCHAR(20) COMMENT '服务商编号'"); log.info("已添加teacher_no列到guides表"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN title VARCHAR(50) COMMENT '职称/称号'"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN specialty VARCHAR(200) COMMENT '专长领域'"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN bio TEXT COMMENT '简介'"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN experience_years TINYINT DEFAULT 0 COMMENT '从业年限'"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN rating INT DEFAULT 500 COMMENT '评分(百分位,500=5.00)'"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN total_appointments INT DEFAULT 0 COMMENT '总服务次数'"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN street_id BIGINT COMMENT '街道ID'"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN province VARCHAR(50) COMMENT '省份'"); jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN city VARCHAR(50) COMMENT '城市'"); jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN district VARCHAR(50) COMMENT '区县'"); jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN street VARCHAR(100) COMMENT '街道'"); log.info("已添加地址字段到guides表"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN verified_at DATETIME COMMENT '审核通过时间'"); } catch (Exception e) { } // 迁移: 将 teachers 表数据合并到 guides(type='teacher') try { int migrated = jdbcTemplate.update( "INSERT IGNORE INTO guides (user_id, type, teacher_no, title, specialty, bio, experience_years, rating, total_appointments, " + "street_id, province, city, district, street, verified_at, status, level, created_at, updated_at) " + "SELECT t.user_id, 'teacher', t.teacher_no, t.title, t.specialty, t.bio, t.experience_years, t.rating, t.total_appointments, " + "t.street_id, t.province, t.city, t.district, t.street, t.verified_at, " + "CASE WHEN t.status = 'active' THEN 'active' ELSE 'inactive' END, " + "CASE WHEN t.verification_status = 'approved' THEN 'senior' WHEN t.verification_status = 'pending' THEN 'junior' ELSE 'junior' END, " + "t.created_at, t.updated_at " + "FROM teachers t " + "LEFT JOIN guides g ON g.user_id = t.user_id " + "WHERE g.id IS NULL" ); if (migrated > 0) { log.info("已从 teachers 表迁移 {} 条数据到 guides(type=teacher)", migrated); } } catch (Exception e) { log.warn("迁移 teachers → guides 数据失败: {}", e.getMessage()); } // 迁移: dan_assessment_results 表添加认知维度字段(6维认知体系扩展) try { Integer colExists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dan_assessment_results' AND COLUMN_NAME = 'perception_score'", Integer.class); if (colExists == null || colExists == 0) { jdbcTemplate.execute("ALTER TABLE dan_assessment_results " + "ADD COLUMN perception_score INT COMMENT '感知得分', " + "ADD COLUMN spatial_score INT COMMENT '空间思维得分', " + "ADD COLUMN processing_speed_score INT COMMENT '加工速度得分'"); log.info("已为 dan_assessment_results 表添加认知维度字段"); } else { log.info("dan_assessment_results 认知维度字段已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加 dan_assessment_results 认知维度字段失败: {}", e.getMessage()); } // 迁移: dan_assessment_results 表添加 EMI 维度字段(v1.2 心智模型重构) try { Integer colExists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dan_assessment_results' AND COLUMN_NAME = 'emotion_management_score'", Integer.class); if (colExists == null || colExists == 0) { jdbcTemplate.execute("ALTER TABLE dan_assessment_results " + "ADD COLUMN emotion_management_score INT COMMENT '情绪管理得分(EMI)', " + "ADD COLUMN empathy_score INT COMMENT '同理心得分(EMI)', " + "ADD COLUMN social_adaptability_score INT COMMENT '社会适应得分(EMI)', " + "ADD COLUMN self_motivation_score INT COMMENT '自我激励得分(EMI)'"); log.info("已为 dan_assessment_results 表添加 EMI 维度字段"); } else { log.info("dan_assessment_results EMI 维度字段已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加 dan_assessment_results EMI 维度字段失败: {}", e.getMessage()); } // 迁移: dan_assessment_results 表添加 EMI 心理子维度字段 try { Integer colExists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dan_assessment_results' AND COLUMN_NAME = 'emotion_score'", Integer.class); if (colExists == null || colExists == 0) { jdbcTemplate.execute("ALTER TABLE dan_assessment_results " + "ADD COLUMN emotion_score INT COMMENT '情绪商数(0-100)', " + "ADD COLUMN resilience_score INT COMMENT '心理韧性(0-100)', " + "ADD COLUMN stress_coping_score INT COMMENT '压力应对(0-100)', " + "ADD COLUMN self_awareness_score INT COMMENT '自我认知(0-100)'"); log.info("已为 dan_assessment_results 表添加 EMI 心理子维度字段"); } else { log.info("dan_assessment_results EMI 心理子维度字段已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加 dan_assessment_results EMI 心理子维度字段失败: {}", e.getMessage()); } // 迁移: dan_assessment_results 表添加大五人格维度字段 try { Integer colExists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dan_assessment_results' AND COLUMN_NAME = 'openness_score'", Integer.class); if (colExists == null || colExists == 0) { jdbcTemplate.execute("ALTER TABLE dan_assessment_results " + "ADD COLUMN openness_score INT COMMENT '开放性(0-100)', " + "ADD COLUMN conscientiousness_score INT COMMENT '尽责性(0-100)', " + "ADD COLUMN extraversion_score INT COMMENT '外向性(0-100)', " + "ADD COLUMN agreeableness_score INT COMMENT '宜人性(0-100)', " + "ADD COLUMN neuroticism_score INT COMMENT '神经质(0-100)', " + "ADD COLUMN big_five_overall_score INT COMMENT '大五人格综合分'"); log.info("已为 dan_assessment_results 表添加大五人格维度字段"); } else { log.info("dan_assessment_results 大五人格维度字段已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加 dan_assessment_results 大五人格维度字段失败: {}", e.getMessage()); } // 迁移: users 表添加出生时辰字段 try { jdbcTemplate.execute("ALTER TABLE users ADD COLUMN birth_hour VARCHAR(10) COMMENT '出生时辰: 子丑寅卯辰巳午未申酉戌亥'"); log.info("已添加birth_hour列到users表"); } catch (Exception e) { // 列已存在,忽略错误 } // 迁移: 重建 family_member_attributes 表结构(v1.2 心智模型) try { Integer colExists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'family_member_attributes' AND COLUMN_NAME = 'member_id'", Integer.class); if (colExists == null || colExists == 0) { // 旧表存在但结构不同,先删除旧表重建 jdbcTemplate.execute("DROP TABLE IF EXISTS family_member_attributes_old"); jdbcTemplate.execute("RENAME TABLE family_member_attributes TO family_member_attributes_old"); jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_member_attributes (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "member_id BIGINT NOT NULL COMMENT '家庭成员ID(children表或users表)', " + "member_type VARCHAR(10) NOT NULL COMMENT '成员类型: child/parent', " + "birth_datetime DATETIME COMMENT '出生日期(精确到分钟,用于八字排盘)', " + "eight_characters JSON COMMENT '八字四柱JSON', " + "wuxing_elements JSON COMMENT '五行星盘元素JSON', " + "zodiac VARCHAR(16) COMMENT '生肖: rat/ox/tiger/rabbit/dragon/snake/horse/sheep/monkey/rooster/dog/pig', " + "blood_type VARCHAR(5) COMMENT '血型: A/B/AB/O', " + "mind_base_score INT DEFAULT 0 COMMENT '心维度基础分(百分位,500=5.00)', " + "wisdom_base_score INT DEFAULT 0 COMMENT '智维度基础分(百分位,500=5.00)', " + "behavior_modifier JSON COMMENT '行为修正因子JSON', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_member (member_id, member_type)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭成员先天属性表'"); log.info("已重建 family_member_attributes 表(v1.2 心智模型)"); } else { log.info("family_member_attributes 表已为 v1.2 结构,跳过"); } } catch (Exception e) { log.warn("重建 family_member_attributes 表失败: {}", e.getMessage()); } try { Integer colExists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'show_to_family'", Integer.class); if (colExists == null || colExists == 0) { jdbcTemplate.execute("ALTER TABLE users ADD COLUMN show_to_family TINYINT DEFAULT 1 COMMENT '是否对家庭成员可见: 1=是, 0=否'"); log.info("已为 users 表添加 show_to_family 列"); } else { log.info("users 表 show_to_family 列已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加 users 表 show_to_family 列失败: {}", e.getMessage()); } // 迁移: tasks 表添加 family_member_id 列(FamilyMember 迁移) try { Integer colExists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'tasks' AND COLUMN_NAME = 'family_member_id'", Integer.class); if (colExists == null || colExists == 0) { jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN family_member_id BIGINT COMMENT '家庭成员ID(关联 family_members.id)'"); log.info("已为 tasks 表添加 family_member_id 列"); } else { log.info("tasks 表 family_member_id 列已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加 tasks 表 family_member_id 列失败: {}", e.getMessage()); } // 迁移: family_members 表添加 role_override 列(FamilyMember 迁移) try { Integer colExists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'family_members' AND COLUMN_NAME = 'role_override'", Integer.class); if (colExists == null || colExists == 0) { jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN role_override VARCHAR(20) DEFAULT 'auto' COMMENT '角色覆盖: auto/parent/child/elderly,空=auto'"); log.info("已为 family_members 表添加 role_override 列"); } else { log.info("family_members 表 role_override 列已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加 family_members 表 role_override 列失败: {}", e.getMessage()); } // 迁移: family_members 表添加 id_card 列(孩子身份证号) try { Integer colExists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'family_members' AND COLUMN_NAME = 'id_card'", Integer.class); if (colExists == null || colExists == 0) { jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN id_card VARCHAR(18) DEFAULT NULL COMMENT '身份证号(孩子)'"); log.info("已为 family_members 表添加 id_card 列"); } else { log.info("family_members 表 id_card 列已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加 family_members 表 id_card 列失败: {}", e.getMessage()); } // 迁移: family_members 表添加孩子专属字段(原children表合并) String[][] childColumns = { {"penalty_enabled", "TINYINT DEFAULT 1 COMMENT '是否扣减积分(0否1是)'"}, {"theme", "VARCHAR(32) DEFAULT 'default' COMMENT '主题'"}, {"system_points", "INT DEFAULT 0 COMMENT '系统积分(干预/平台奖励,仅只读历史;CF值见cf_balance)'"}, {"streak_days", "INT DEFAULT 0 COMMENT '连续打卡天数'"}, {"last_task_date", "DATE COMMENT '上次任务日期'"}, {"focus_max_daily", "TINYINT DEFAULT 3 COMMENT '每日专注上限'"}, {"focus_remaining", "TINYINT DEFAULT 3 COMMENT '今日剩余专注次数'"}, {"focus_reset_date", "DATE COMMENT '专注次数重置日期'"} }; for (String[] col : childColumns) { try { Integer colExists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'family_members' AND COLUMN_NAME = ?", Integer.class, col[0]); if (colExists == null || colExists == 0) { jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN " + col[0] + " " + col[1]); log.info("已为 family_members 表添加 {} 列", col[0]); } } catch (Exception e) { log.warn("检查/添加 family_members 表 {} 列失败: {}", col[0], e.getMessage()); } } // 家庭成员表添加 role_override 列(替换旧 capability_role) ensureColumn("family_members", "role_override", "VARCHAR(20) DEFAULT 'auto' COMMENT '角色覆盖: auto/parent/child/elderly'"); try { jdbcTemplate.execute("ALTER TABLE family_members DROP COLUMN capability_role"); log.info("已删除 family_members.capability_role 旧列,已由 role_override 替代"); } catch (Exception e) { // 列不存在或已删除,忽略错误 } // activities 表添加报名/签到相关字段 ensureColumn("activities", "require_registration", "TINYINT DEFAULT 1 COMMENT '是否需要报名: 1=需要, 0=直接参与'"); ensureColumn("activities", "require_checkin_review", "TINYINT DEFAULT 1 COMMENT '打卡是否需要审核: 1=需要, 0=直接通过'"); ensureColumn("activities", "energy_penalty_unregistered", "INT DEFAULT 5 COMMENT '未报名打卡的行能量扣减值'"); // 2026-10-22 整改:报名审核开关 + 名额占用计数 ensureColumn("activities", "require_review", "TINYINT DEFAULT 0 COMMENT '报名是否需要审核: 1=需要, 0=免审即报名通过'"); ensureColumn("activities", "approved_count", "INT DEFAULT 0 COMMENT '已通过报名人数(名额占用统计)'"); ensureColumn("activities", "signed_count", "INT DEFAULT 0 COMMENT '签到人数(checkin 时 +1,区别于 approved_count 的报名口径)'"); // 修正 require_checkin_review 默认值并归一化存量(该字段此前未被读取,无业务依赖,避免存量活动突然进入待审核) // 同时按已通过报名回填 approved_count,使名额统计与存量数据一致 try { jdbcTemplate.execute("ALTER TABLE activities MODIFY COLUMN require_checkin_review TINYINT DEFAULT 0 COMMENT '打卡是否需要审核: 1=需要, 0=免审直接通过'"); jdbcTemplate.execute("UPDATE activities SET require_checkin_review = 0 WHERE require_checkin_review = 1"); jdbcTemplate.execute("UPDATE activities a SET a.approved_count = (SELECT cnt FROM (SELECT COUNT(*) AS cnt FROM activity_registrations r WHERE r.activity_id = a.id AND r.status = 'approved') t)"); log.info("已修正 require_checkin_review 默认值并回填 approved_count"); } catch (Exception e) { log.warn("修正 activities 报名/签到审核字段失败: {}", e.getMessage()); } // family_members 表添加关系评分字段 ensureColumn("family_members", "trust_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '信任度 0-100'"); ensureColumn("family_members", "intimacy_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '亲密度 0-100'"); ensureColumn("family_members", "communication_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '沟通质量 0-100'"); ensureColumn("family_members", "last_interaction_at", "DATETIME COMMENT '最近互动时间'"); // 创建购物车表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS cart (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "product_id BIGINT NOT NULL COMMENT '商品ID', " + "quantity INT NOT NULL DEFAULT 1 COMMENT '数量', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_user_product (user_id, product_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='购物车'"); log.info("已创建 cart 表"); } catch (Exception e) { log.warn("创建 cart 表失败: {}", e.getMessage()); } // ==================== 迁移: activities表添加visible_scope列 ============= try { Integer colExists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activities' AND COLUMN_NAME = 'visible_scope'", Integer.class); if (colExists == null || colExists == 0) { jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN visible_scope VARCHAR(20) DEFAULT NULL COMMENT '可见范围: family(家庭)/role(角色)/user(用户)' AFTER visibility"); log.info("已添加visible_scope列到activities表"); } else { log.info("activities表visible_scope列已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加activities表visible_scope列失败: {}", e.getMessage()); } // ==================== 迁移: articles表添加article_type列 ============= try { Integer colExists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'articles' AND COLUMN_NAME = 'article_type'", Integer.class); if (colExists == null || colExists == 0) { jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN article_type VARCHAR(20) DEFAULT 'normal' COMMENT '文章类型: normal(普通)/premium(优质)/original(原创)' AFTER read_time"); log.info("已添加article_type列到articles表"); } else { log.info("articles表article_type列已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加articles表article_type列失败: {}", e.getMessage()); } // ==================== 迁移: articles表添加word_count列 ============= try { Integer colExists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'articles' AND COLUMN_NAME = 'word_count'", Integer.class); if (colExists == null || colExists == 0) { jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN word_count INT DEFAULT 0 COMMENT '文章字数' AFTER article_type"); log.info("已添加word_count列到articles表"); } else { log.info("articles表word_count列已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加articles表word_count列失败: {}", e.getMessage()); } // ==================== 迁移: article_reading_records表添加task_id列 ============= try { Integer colExists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'article_reading_records' AND COLUMN_NAME = 'task_id'", Integer.class); if (colExists == null || colExists == 0) { jdbcTemplate.execute("ALTER TABLE article_reading_records ADD COLUMN task_id BIGINT DEFAULT NULL COMMENT '关联的任务ID' AFTER duration_seconds"); log.info("已添加task_id列到article_reading_records表"); } else { log.info("article_reading_records表task_id列已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加article_reading_records表task_id列失败: {}", e.getMessage()); } // ==================== 迁移: tasks表添加source_type列 ============= try { Integer colExists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'tasks' AND COLUMN_NAME = 'source_type'", Integer.class); if (colExists == null || colExists == 0) { jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN source_type VARCHAR(20) DEFAULT NULL COMMENT '来源类型: article/activity/manual' AFTER minigame_code"); log.info("已添加source_type列到tasks表"); } else { log.info("tasks表source_type列已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加tasks表source_type列失败: {}", e.getMessage()); } // ==================== 迁移: tasks表添加source_id列 ============= try { Integer colExists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'tasks' AND COLUMN_NAME = 'source_id'", Integer.class); if (colExists == null || colExists == 0) { jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN source_id BIGINT DEFAULT NULL COMMENT '来源ID' AFTER source_type"); log.info("已添加source_id列到tasks表"); } else { log.info("tasks表source_id列已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加tasks表source_id列失败: {}", e.getMessage()); } // ==================== 迁移: activities表添加checkin_points列 ============= try { Integer colExists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activities' AND COLUMN_NAME = 'checkin_points'", Integer.class); if (colExists == null || colExists == 0) { jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN checkin_points INT DEFAULT 0 COMMENT '签到积分(0=使用系统默认)' AFTER price"); log.info("已添加checkin_points列到activities表"); } else { log.info("activities表checkin_points列已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加activities表checkin_points列失败: {}", e.getMessage()); } // 迁移: 创建family_invitations表(家庭邀请令牌) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_invitations (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "token VARCHAR(64) NOT NULL UNIQUE COMMENT '邀请令牌(UUID)', " + "created_by BIGINT NOT NULL COMMENT '创建人用户ID', " + "status VARCHAR(16) DEFAULT 'active' COMMENT '状态: active/used/expired/revoked', " + "expires_at DATETIME COMMENT '过期时间', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_family_id (family_id), " + "INDEX idx_token (token), " + "INDEX idx_status (status)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭邀请令牌'"); log.info("已创建family_invitations表"); } catch (Exception e) { log.warn("创建family_invitations表失败: {}", e.getMessage()); } // ==================== P0 营养师: nutrition_indicator_mapping ============= try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS nutrition_indicator_mapping (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY," + "indicator_name_pattern VARCHAR(200) NOT NULL COMMENT '指标名匹配模式(SQL LIKE语法,%为通配符)'," + "nutrient VARCHAR(50) NOT NULL COMMENT '关联营养素名'," + "severity_weight DECIMAL(3,2) DEFAULT 1.00 COMMENT '权重(0-1)'," + "deficiency_symptoms TEXT COMMENT '缺乏症状描述'," + "excess_symptoms TEXT COMMENT '过量症状描述'," + "food_suggestions TEXT COMMENT '推荐食物(逗号分隔)'," + "sort_order INT DEFAULT 0 COMMENT '排序(大值优先匹配)'," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," + "INDEX idx_nutrient (nutrient)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标-营养素映射表'"); log.info("已创建 nutrition_indicator_mapping 表"); } catch (Exception e) { log.warn("创建 nutrition_indicator_mapping 表失败: {}", e.getMessage()); } // P0 营养师: nutrition_deficiency_record try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS nutrition_deficiency_record (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY," + "child_id BIGINT NOT NULL COMMENT '孩子ID'," + "report_id BIGINT NOT NULL COMMENT '关联报告ID'," + "nutrient VARCHAR(50) NOT NULL COMMENT '营养素名'," + "deficiency_type VARCHAR(20) NOT NULL COMMENT 'deficiency/excess/borderline/unclassified'," + "severity DECIMAL(5,2) DEFAULT 0 COMMENT '严重程度(0-1)'," + "indicator_name VARCHAR(100) COMMENT '关联指标名'," + "indicator_value VARCHAR(50) COMMENT '指标检测值'," + "ref_range VARCHAR(100) COMMENT '参考范围'," + "symptoms TEXT COMMENT '症状描述'," + "food_suggestions TEXT COMMENT '推荐食物'," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," + "INDEX idx_child (child_id)," + "INDEX idx_report (report_id)," + "INDEX idx_nutrient (nutrient)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='营养缺乏分析记录'"); log.info("已创建 nutrition_deficiency_record 表"); } catch (Exception e) { log.warn("创建 nutrition_deficiency_record 表失败: {}", e.getMessage()); } // P0 营养师: 指标-营养素映射 种子数据 try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM nutrition_indicator_mapping", Integer.class); if (count == null || count == 0) { String[][] seedData = { {"铁蛋白", "铁", "1.00", "乏力、面色苍白、注意力不集中", "铁过量可能损伤肝脏", "动物肝脏,红肉,菠菜,黑木耳", "10"}, {"25-羟基维生素D", "维生素D", "1.00", "影响钙吸收、骨骼发育", "维生素D过量可致高钙血症", "三文鱼,蛋黄,蘑菇,强化牛奶", "20"}, {"维生素D", "维生素D", "1.00", "影响钙吸收、骨骼发育、免疫力下降", "维生素D过量可致高钙血症", "三文鱼,蛋黄,蘑菇,强化牛奶", "15"}, {"锌", "锌", "0.80", "食欲减退、味觉异常、免疫力降低", "锌过量影响铜吸收", "牡蛎,牛肉,南瓜子,扁豆", "10"}, {"钙", "钙", "0.80", "骨骼发育迟缓、牙齿问题", "高钙血症、肾结石", "牛奶,豆腐,虾皮,芝麻酱", "10"}, {"维生素B12", "维生素B12", "1.00", "贫血、神经发育迟缓", "一般不易过量", "牛肉,鸡蛋,牛奶,鱼肉", "10"}, {"维生素C", "维生素C", "0.60", "免疫力下降、牙龈出血", "腹泻、肾结石风险", "猕猴桃,橙子,草莓,青椒", "10"}, {"白蛋白", "蛋白质", "0.90", "发育迟缓、肌肉萎缩", "肾脏负担", "鸡蛋,鱼肉,牛奶,豆制品", "10"}, {"总蛋白", "蛋白质", "0.90", "营养不良、免疫力下降", "血液粘稠", "鸡蛋,鱼肉,牛奶,豆制品", "9"}, {"膳食纤维", "膳食纤维", "0.50", "便秘、肠道菌群失衡", "腹胀、排便过多", "全谷物,蔬菜,豆类,水果", "10"}, {"叶酸", "叶酸", "0.70", "贫血、发育迟缓", "一般不易过量", "菠菜,动物肝脏,豆类,柑橘", "10"}, {"维生素A", "维生素A", "0.70", "视力问题、皮肤干燥、免疫力下降", "头痛、肝脏损伤", "胡萝卜,动物肝脏,南瓜,菠菜", "10"}, {"硒", "硒", "0.50", "免疫力下降、甲状腺功能异常", "脱发、指甲脆裂", "巴西坚果,海鲜,蘑菇", "10"}, {"镁", "镁", "0.40", "肌肉痉挛、睡眠问题", "腹泻、低血压", "坚果,全谷物,深绿蔬菜", "10"}, {"益生菌%", "益生菌", "0.60", "肠道菌群失衡、消化问题", "一般不易过量", "酸奶,泡菜,味噌,康普茶", "10"} }; for (String[] row : seedData) { jdbcTemplate.update("INSERT IGNORE INTO nutrition_indicator_mapping " + "(indicator_name_pattern, nutrient, severity_weight, deficiency_symptoms, excess_symptoms, food_suggestions, sort_order) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", row[0], row[1], new java.math.BigDecimal(row[2]), row[3], row[4], row[5], Integer.parseInt(row[6])); } log.info("nutrition_indicator_mapping 种子数据已加载 (15条)"); } else { log.info("nutrition_indicator_mapping 种子数据已存在 ({}条), 跳过", count); } } catch (Exception e) { log.warn("nutrition_indicator_mapping 种子数据初始化失败: {}", e.getMessage()); } // ==================== 心版块 Phase 1: emotion_checkin 表 ============= try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS emotion_checkin (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "child_id BIGINT NOT NULL COMMENT '孩子ID', " + "user_id BIGINT NOT NULL COMMENT '打卡人用户ID', " + "emotion_type INT NOT NULL COMMENT '情绪类型: 1快乐/2平和平静/3平静/4有点不舒服/5难受/6烦躁/7生气/8焦虑/9恐惧', " + "emotion_name VARCHAR(20) COMMENT '情绪名称: happy/calm/peaceful/uncomfortable/sad/irritable/angry/anxious/fearful', " + "energy_level INT NOT NULL DEFAULT 3 COMMENT '能量状态: 1很低/2低/3中等/4高/5很高', " + "note TEXT COMMENT '打卡备注', " + "photo_url VARCHAR(500) COMMENT '照片URL', " + "favorite_thing VARCHAR(500) COMMENT '今日最喜欢的一件事', " + "points_awarded INT DEFAULT 0 COMMENT '奖励积分', " + "parent_visible TINYINT DEFAULT 1 COMMENT '家长可见: 1可见/0不可见', " + "checkin_date DATE NOT NULL 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_user_id (user_id), " + "INDEX idx_checkin_date (checkin_date), " + "INDEX idx_child_date (child_id, checkin_date)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='情绪打卡表'"); log.info("已创建 emotion_checkin 情绪打卡表"); } catch (Exception e) { log.warn("创建 emotion_checkin 表失败: {}", e.getMessage()); } // ==================== 1a-2: DAN Schema 修复 ============= // v1.2 EMI 心理子维度 String[] danEmiFields = {"emotion_score", "resilience_score", "stress_coping_score", "self_awareness_score"}; for (String field : danEmiFields) { try { Integer exists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dan_assessment_results' AND COLUMN_NAME = ?", Integer.class, field); if (exists == null || exists == 0) { jdbcTemplate.execute("ALTER TABLE dan_assessment_results ADD COLUMN " + field + " INT DEFAULT NULL COMMENT 'EMI子维度 0-100'"); log.info("已添加 {} 列到 dan_assessment_results 表", field); } } catch (Exception e) { log.warn("检查/添加 {} 列失败: {}", field, e.getMessage()); } } // 大五人格维度 String[] danBigFiveFields = {"openness_score", "conscientiousness_score", "extraversion_score", "agreeableness_score", "neuroticism_score", "big_five_overall_score"}; for (String field : danBigFiveFields) { try { Integer exists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dan_assessment_results' AND COLUMN_NAME = ?", Integer.class, field); if (exists == null || exists == 0) { jdbcTemplate.execute("ALTER TABLE dan_assessment_results ADD COLUMN " + field + " INT DEFAULT NULL COMMENT '大五人格 0-100'"); log.info("已添加 {} 列到 dan_assessment_results 表", field); } } catch (Exception e) { log.warn("检查/添加 {} 列失败: {}", field, e.getMessage()); } } // EMI 维度(情绪商数评估字段) String[] danEmiDimFields = {"emotion_management_score", "empathy_score", "social_adaptability_score", "self_motivation_score"}; for (String field : danEmiDimFields) { try { Integer exists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dan_assessment_results' AND COLUMN_NAME = ?", Integer.class, field); if (exists == null || exists == 0) { jdbcTemplate.execute("ALTER TABLE dan_assessment_results ADD COLUMN " + field + " INT DEFAULT NULL COMMENT 'EMI维度 0-100'"); log.info("已添加 {} 列到 dan_assessment_results 表", field); } } catch (Exception e) { log.warn("检查/添加 {} 列失败: {}", field, e.getMessage()); } } // ==================== 1b-1: bazi_reading_template 五行命理解读表 ============= try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS bazi_reading_template (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "element VARCHAR(20) NOT NULL COMMENT '五行: wood/fire/earth/metal/water', " + "`condition` VARCHAR(20) NOT NULL COMMENT '状态: strong/weak/balanced', " + "personal_trait TEXT COMMENT '性格特质描述', " + "mind_growth_advice TEXT COMMENT '心理成长建议', " + "daily_tip_template TEXT COMMENT '每日宜忌模板', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_element_condition (element, `condition`)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五行命理解读模板'"); log.info("已创建 bazi_reading_template 表"); } catch (Exception e) { log.warn("创建 bazi_reading_template 表失败: {}", e.getMessage()); } // ==================== 1b-1: numsoul_config 生命灵数配置表 ============= try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS numsoul_config (" + "number INT PRIMARY KEY COMMENT '灵数1-9', " + "title VARCHAR(50) NOT NULL COMMENT '称号', " + "keywords VARCHAR(200) COMMENT '性格关键词', " + "mind_growth_advice TEXT COMMENT '心理成长建议', " + "recommended_article_tags VARCHAR(200) COMMENT '关联文章标签', " + "color_hex VARCHAR(7) COMMENT '代表色' " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='生命灵数配置'"); log.info("已创建 numsoul_config 表"); } catch (Exception e) { log.warn("创建 numsoul_config 表失败: {}", e.getMessage()); } // ==================== 1b-1: bazi_reading_template 种子数据 ============= try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM bazi_reading_template", Integer.class); if (count == null || count == 0) { jdbcTemplate.batchUpdate( "INSERT INTO bazi_reading_template (element, `condition`, personal_trait, mind_growth_advice, daily_tip_template) VALUES (?, ?, ?, ?, ?)", java.util.Arrays.asList(new Object[][]{ {"wood", "strong", "性格坚韧、有进取心,具有领导力和开拓精神", "学会适当放松,避免过刚易折,培养弹性思维", "宜进行伸展运动,忌高强度冲突"}, {"wood", "weak", "温和有礼、善于合作,但决策时容易犹豫", "增强自信心训练,培养果断决策能力", "宜与自然相处,忌过度迁就他人"}, {"wood", "balanced", "性格中庸有度,既有韧性又有灵活性", "保持现状,适当拓展社交圈", "宜户外活动,忌久坐不动"}, {"fire", "strong", "热情开朗、充满活力,富有感染力和创造力", "注意情绪管理,避免急躁冲动", "宜冷水刺激冷静,忌暴怒争执"}, {"fire", "weak", "内敛含蓄、心思细腻,对艺术有敏锐感知", "适当表达情感,培养社交自信", "宜温暖陪伴,忌独处过久"}, {"fire", "balanced", "性格温润有度,既有热情又懂克制", "保持兴趣爱好,适度拓展表达", "宜参与社交活动,忌情绪压抑"}, {"earth", "strong", "稳重可靠、有责任心,是团队中的中流砥柱", "避免过度承担,学会说'不'", "宜适量运动,忌思虑过重"}, {"earth", "weak", "随和友善、适应性强,容易与他人相处", "增强主见性,培养独立思考能力", "宜接触新事物,忌固守不变"}, {"earth", "balanced", "性情敦厚、处事稳妥,人际关系和谐", "保持学习,适当挑战自我", "宜亲近自然,忌暴饮暴食"}, {"metal", "strong", "意志坚定、追求完美,具有卓越的执行力", "学会灵活变通,避免过于严苛", "宜艺术鉴赏,忌过度批评"}, {"metal", "weak", "温和寡言、善于倾听,是值得信赖的朋友", "增强表达勇气,培养自信心", "宜团体活动,忌过度自我封闭"}, {"metal", "balanced", "为人正直、做事有度,不乏柔韧与包容", "保持开放心态,适度多元发展", "宜阅读学习,忌钻牛角尖"}, {"water", "strong", "智慧深沉、善于谋略,具有出色洞察力", "避免过度缜密,适当放松心情", "宜静坐冥想,忌思虑过深"}, {"water", "weak", "敏感细腻、有同理心,富有创作天赋", "增强抵抗力,培养情绪韧性", "宜温暖环境,忌受寒受凉"}, {"water", "balanced", "性格圆融、随机应变,处事灵活得宜", "保持好奇心,适度扩展影响力", "宜游泳泡澡,忌情绪波动"}, }) ); log.info("bazi_reading_template 种子数据已加载 (15条)"); } } catch (Exception e) { log.warn("bazi_reading_template 种子数据初始化失败: {}", e.getMessage()); } // ==================== 1b-1: numsoul_config 种子数据 ============= try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM numsoul_config", Integer.class); if (count == null || count == 0) { jdbcTemplate.batchUpdate( "INSERT INTO numsoul_config (number, title, keywords, mind_growth_advice, recommended_article_tags, color_hex) VALUES (?, ?, ?, ?, ?, ?)", java.util.Arrays.asList(new Object[][]{ {1, "开创者", "独立、领导、创新、自信", "培养团队协作意识,避免独断专行", "领导力,自信培养", "#FF4444"}, {2, "和平者", "温和、合作、敏感、耐心", "增强自我主张,避免过度迎合他人", "人际交往,情绪表达", "#FF8C00"}, {3, "表达者", "创意、社交、乐观、表达", "保持专注,避免浅尝辄止", "创造力,社交能力", "#FFD700"}, {4, "建设者", "务实、稳定、有序、勤奋", "学会变通,避免过于固执", "专注力,学习习惯", "#4CAF50"}, {5, "自由者", "冒险、变化、好奇、灵活", "培养持久力,避免缺乏恒心", "探索精神,适应能力", "#2196F3"}, {6, "关怀者", "责任、关爱、奉献、平衡", "学会自我关爱,避免过度付出", "家庭关系,责任心", "#9C27B0"}, {7, "探索者", "智慧、分析、内省、完美", "与人分享见解,避免过度封闭", "思维能力,学习力", "#673AB7"}, {8, "成就者", "雄心、管理、决策、权威", "关注内在成长,避免过于追求外在成就", "目标管理,理财思维", "#3F51B5"}, {9, "博爱者", "包容、智慧、善良、豁达", "设定个人边界,避免过度牺牲", "人际关系,情绪管理", "#E91E63"}, }) ); log.info("numsoul_config 种子数据已加载 (9条)"); } } catch (Exception e) { log.warn("numsoul_config 种子数据初始化失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_address (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "receiver_name VARCHAR(50) NOT NULL COMMENT '收货人姓名', " + "phone VARCHAR(20) NOT NULL COMMENT '收货人电话', " + "province VARCHAR(50) COMMENT '省份', " + "city VARCHAR(50) COMMENT '城市', " + "district VARCHAR(50) COMMENT '区县', " + "street VARCHAR(200) COMMENT '详细地址', " + "is_default INT DEFAULT 0 COMMENT '是否默认 1=是 0=否', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_user_id (user_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收货地址表'"); log.info("已创建 user_address 表"); } catch (Exception e) { log.warn("创建 user_address 表失败: {}", e.getMessage()); } // ==================== 会员价格体系 ====== // 迁移: activities表添加member_price列 try { Integer exists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activities' AND COLUMN_NAME = 'member_price'", Integer.class); if (exists == null || exists == 0) { jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN member_price INT COMMENT '会员价(分)' AFTER price"); log.info("已添加member_price列到activities表"); } else { log.info("member_price列已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加activities.member_price失败: {}", e.getMessage()); } // 迁移: 创建 member_discount_configs 表(分类级别折扣配置) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS member_discount_configs (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "level_code VARCHAR(20) NOT NULL COMMENT '会员等级: FAMILY/PROVIDER', " + "target_type VARCHAR(20) NOT NULL COMMENT '目标类型: product/activity', " + "target_id BIGINT DEFAULT NULL COMMENT '目标ID(NULL表示分类级别)', " + "category_id BIGINT DEFAULT NULL COMMENT '类目ID(分类级别折扣)', " + "discount_percent INT NOT NULL COMMENT '折扣百分比(如90表示90%)', " + "enabled TINYINT DEFAULT 1 COMMENT '是否启用', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_level (level_code), " + "INDEX idx_target (target_type, target_id), " + "INDEX idx_category (target_type, category_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员折扣配置'"); log.info("已创建member_discount_configs表"); } catch (Exception e) { log.warn("创建member_discount_configs表失败: {}", e.getMessage()); } // ==================== 食材推荐指数 Phase: food_recommend_idx / food_recommend_idx_log ============= try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `food_recommend_idx` (" + "`id` BIGINT NOT NULL AUTO_INCREMENT, " + "`user_id` BIGINT NOT NULL COMMENT '用户ID', " + "`food_id` BIGINT NOT NULL COMMENT '食材ID', " + "`food_name` VARCHAR(100) COMMENT '食材名称', " + "`index_score` INT NOT NULL DEFAULT 0 COMMENT '推荐指数(0-100, 来自菌群检测报告)', " + "`health_report_id` BIGINT COMMENT '关联健康报告ID', " + "`calculated_at` DATETIME COMMENT '计算时间', " + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, " + "PRIMARY KEY (`id`), " + "INDEX `idx_fri_user` (`user_id`), " + "INDEX `idx_fri_food` (`food_id`), " + "UNIQUE KEY `uk_fri_user_food` (`user_id`, `food_id`) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='食材推荐指数当前值'"); log.info("已创建/确认 food_recommend_idx 表"); } catch (Exception e) { log.warn("创建 food_recommend_idx 表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `food_recommend_idx_log` (" + "`id` BIGINT NOT NULL AUTO_INCREMENT, " + "`user_id` BIGINT NOT NULL COMMENT '用户ID', " + "`food_id` BIGINT NOT NULL COMMENT '食材ID', " + "`food_name` VARCHAR(100) COMMENT '食材名称', " + "`previous_index` INT COMMENT '变更前推荐指数', " + "`new_index` INT COMMENT '变更后推荐指数', " + "`health_report_id` BIGINT COMMENT '触发的健康报告ID', " + "`change_reason` VARCHAR(200) COMMENT '变更原因', " + "`changed_at` DATETIME DEFAULT CURRENT_TIMESTAMP, " + "PRIMARY KEY (`id`), " + "INDEX `idx_fril_user` (`user_id`), " + "INDEX `idx_fril_food` (`food_id`) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='食材推荐指数变更历史日志'"); log.info("已创建/确认 food_recommend_idx_log 表"); } catch (Exception e) { log.warn("创建 food_recommend_idx_log 表失败: {}", e.getMessage()); } // 迁移25: activities表添加审核相关字段(活动工作流:待审核/驳回/发布) try { jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN audit_status VARCHAR(20) DEFAULT NULL COMMENT '审核状态:approved/pending/rejected'"); log.info("已添加audit_status列到activities表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN audit_reason VARCHAR(500) DEFAULT NULL COMMENT '驳回原因'"); log.info("已添加audit_reason列到activities表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN auditor_id BIGINT DEFAULT NULL COMMENT '审核人ID'"); log.info("已添加auditor_id列到activities表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN audited_at DATETIME DEFAULT NULL COMMENT '审核时间'"); log.info("已添加audited_at列到activities表"); } catch (Exception e) { } // 家庭挑战:目标模式与目标值(aggregate=集体总合目标 / all_members=全员个人目标) ensureColumn("family_challenge", "target_mode", "VARCHAR(20) DEFAULT 'aggregate' COMMENT '目标模式: aggregate=集体总合/all_members=全员个人目标'"); ensureColumn("family_challenge", "target_value", "INT DEFAULT 0 COMMENT '目标值(aggregate=家庭合计值, all_members=每人目标值)'"); // 迁移: family_challenge 表添加回复时效字段(待同意成员超时未回复自动拒绝) ensureColumn("family_challenge", "respond_deadline", "DATETIME NULL COMMENT '回复时效截止时间(超过后未回复的 pending 成员自动置为 rejected)'"); // 迁移248: 创建 health_report_edit_logs 表(健康报告修改留痕,原迁移222误置于initializeDefaultData中仅测试环境执行) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_report_edit_logs (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "report_id BIGINT NOT NULL, " + "user_id BIGINT, " + "report_type VARCHAR(32), " + "action VARCHAR(20) COMMENT 'create/edit', " + "before_payload LONGTEXT, " + "after_payload LONGTEXT, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_report_id (report_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康报告修改留痕'"); log.info("已创建health_report_edit_logs表"); } catch (Exception e) { // 表已存在,忽略错误 } // 迁移249: file_record 表添加 file_type 和 description 列(原迁移221/228误置于seedFullRegionData中,区域数据非空时永不执行) // 迁移250: health_plans 添加 plan_json 和 status 列(支持结构化方案编辑) try { ensureColumn("health_plans", "plan_json", "JSON COMMENT '结构化方案内容(总览+营养+饮食+运动)'"); } catch (Exception e) { log.warn("health_plans.plan_json 添加失败(可能已存在): " + e.getMessage()); } try { ensureColumn("health_plans", "status", "VARCHAR(20) DEFAULT 'draft' COMMENT '方案状态: draft(草稿)/pending_review(待审核)/published(已发布)/rejected(已驳回)'"); } catch (Exception e) { log.warn("health_plans.status 添加失败(可能已存在): " + e.getMessage()); } // 迁移250: health_plans 新增审核字段(规划师/营养师方案维护流程) try { ensureColumn("health_plans", "reviewed_by", "BIGINT DEFAULT NULL COMMENT '审核人ID(规划师/营养师)'"); ensureColumn("health_plans", "reviewed_at", "DATETIME DEFAULT NULL COMMENT '审核时间'"); ensureColumn("health_plans", "review_comment", "TEXT DEFAULT NULL COMMENT '审核意见'"); } catch (Exception e) { log.warn("health_plans 审核字段添加失败(可能已存在): " + e.getMessage()); } // 迁移251: health_plans 新增 teacher_id 列(用户指定规划师) try { ensureColumn("health_plans", "teacher_id", "BIGINT DEFAULT NULL COMMENT '指定规划师ID(用户选择)'"); } catch (Exception ex) { log.warn("health_plans.teacher_id 添加失败(可能已存在): " + ex.getMessage()); } // 迁移251b: health_plans 新增 updated_at 列(实体HealthPlan含该字段,缺失导致selectById报Unknown column) try { ensureColumn("health_plans", "updated_at", "DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'"); } catch (Exception ex) { log.warn("health_plans.updated_at 添加失败(可能已存在): " + ex.getMessage()); } // 迁移251c: finance_checkins.child_id 允许NULL(家长本人打卡不选孩子,Service已支持memberId为空;原NOT NULL无默认值导致INSERT报错) try { jdbcTemplate.execute("ALTER TABLE finance_checkins MODIFY COLUMN child_id BIGINT NULL COMMENT '孩子ID,家长本人打卡时为NULL'"); log.info("已修改finance_checkins.child_id字段允许NULL"); } catch (Exception e) { log.warn("finance_checkins.child_id 修改失败: " + e.getMessage()); } // 迁移251c: finance_checkins 补齐 type 列(initGuideModuleTables建表版本缺失,实体FinanceCheckin含type字段且收支统计依赖) try { ensureColumn("finance_checkins", "type", "VARCHAR(10) DEFAULT 'expense' COMMENT '类型: income(收入)/expense(支出)'"); } catch (Exception e) { log.warn("finance_checkins.type 添加失败(可能已存在): " + e.getMessage()); } // 迁移258: 创建energy_daily_limit表(每日能量上限配置持久化) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS energy_daily_limit (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "dimension_code VARCHAR(20) NOT NULL COMMENT '维度代码: body/mind/wisdom/action/wealth', " + "daily_limit INT NOT NULL DEFAULT 50 COMMENT '每日能量上限', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_dimension_code (dimension_code)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日能量上限配置'"); log.info("已创建energy_daily_limit表"); // 插入默认数据(幂等) jdbcTemplate.execute("INSERT IGNORE INTO energy_daily_limit (dimension_code, daily_limit) VALUES " + "('body', 50), ('mind', 50), ('wisdom', 80), ('action', 60), ('wealth', 50)"); log.info("已插入energy_daily_limit默认数据"); } catch (Exception ex) { log.warn("energy_daily_limit 表创建失败(可能已存在): " + ex.getMessage()); } // 迁移251d: diet_preferences 补齐 must_eat 列(实体DietPreferences含mustEat字段,建表时两处均遗漏,查询报Unknown column) try { ensureColumn("diet_preferences", "must_eat", "JSON COMMENT '必吃食物列表'"); } catch (Exception e) { log.warn("diet_preferences.must_eat 添加失败(可能已存在): " + e.getMessage()); } ensureColumn("file_record", "file_type", "VARCHAR(50) DEFAULT NULL COMMENT '文件类型: image/pdf/video/audio/other'"); ensureColumn("file_record", "description", "TEXT COMMENT '文件描述'"); log.info("数据库迁移完成"); // 迁移252: 体素/心理/认知指标定义种子数据 try { Object[][] rows = { {"body", "nutrition", "body_fat", "体脂率(%)", "18-28", "1"}, {"body", "nutrition", "bmi", "BMI指数", "18.5-24", "2"}, {"body", "nutrition", "protein", "蛋白质摄入(g/日)", "50-80", "3"}, {"body", "nutrition", "calcium", "钙摄入(mg/日)", "800-1000", "4"}, {"body", "nutrition", "vitamin_d", "维生素D(ng/ml)", "30-100", "5"}, {"body", "chronic", "blood_pressure_systolic", "收缩压(mmHg)", "90-120", "1"}, {"body", "chronic", "blood_pressure_diastolic", "舒张压(mmHg)", "60-80", "2"}, {"body", "chronic", "blood_sugar_fasting", "空腹血糖(mmol/L)", "3.9-6.1", "3"}, {"body", "chronic", "cholesterol_total", "总胆固醇(mmol/L)", "3.1-5.2", "4"}, {"body", "chronic", "triglyceride", "甘油三酯(mmol/L)", "0.56-1.7", "5"}, {"body", "exercise", "step_count", "日均步数", "6000-10000", "1"}, {"body", "exercise", "sleep_duration", "睡眠时长(小时)", "7-9", "2"}, {"mind", "emotion", "emotion_stability", "情绪稳定性", "0-100", "1"}, {"mind", "emotion", "anxiety_level", "焦虑水平", "0-100", "2"}, {"mind", "emotion", "sleep_quality", "睡眠质量", "0-100", "3"}, {"mind", "personality", "openness", "开放性", "0-100", "1"}, {"mind", "personality", "conscientiousness", "尽责性", "0-100", "2"}, {"mind", "personality", "extraversion", "外向性", "0-100", "3"}, {"mind", "personality", "agreeableness", "宜人性", "0-100", "4"}, {"mind", "personality", "neuroticism", "神经质(反向)", "0-100", "5"}, {"wisdom", "cognitive", "perception", "感知能力", "0-100", "1"}, {"wisdom", "cognitive", "focus", "专注力", "0-100", "2"}, {"wisdom", "cognitive", "memory", "记忆力", "0-100", "3"}, {"wisdom", "cognitive", "logic", "逻辑思维", "0-100", "4"}, {"wisdom", "cognitive", "spatial", "空间思维", "0-100", "5"}, {"wisdom", "cognitive", "processing_speed", "加工速度", "0-100", "6"}, {"wisdom", "cognitive", "growth_mindset", "成长型思维", "0-100", "7"} }; for (Object[] r : rows) { jdbcTemplate.update( "INSERT IGNORE INTO indicator_definitions (domain, category, code, name, data_type, ref_range, sort_order, status) VALUES (?,?,?,?,?,?,?,1)", r[0], r[1], r[2], r[3], "number", r[4], Integer.parseInt((String) r[5]) ); } log.info("迁移252: indicator_definitions 种子数据已加载 ({}条)", rows.length); } catch (Exception e) { log.warn("迁移252失败: {}", e.getMessage()); } log.info("数据库迁移完成"); // ==================== 健康维度 Phase 1: health_dimension_score / health_data_source_record / health_norm_reference ============= migrateHealthDimensionsTable(); seedHealthNorms(); seedMindActionWealthNorms(); runLateMigrations(); migration102(); // 迁移226-247: 原seedFullRegionData内的DDL迁移,确保不因省市区数据已存在而跳过 runFullRegionDataMigrations(); // seedFullRegionData(); 已废弃,区域数据由独立迁移处理 // 迁移269: sleep_record 修改 sleep_time/wake_time 为 TIME(前端按 HH:mm 打卡,java.sql.Time 三段格式) try { jdbcTemplate.execute("ALTER TABLE sleep_record MODIFY COLUMN sleep_time TIME, MODIFY COLUMN wake_time TIME"); log.info("已修改 sleep_record 时间为 TIME 类型"); } catch (Exception e) { log.warn("sleep_record 时间列修改失败(可能已为 TIME): {}", e.getMessage()); } // ==================== 迁移270: 先天画像来源权重配置表 ============= try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS innate_portrait_config (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY," + "source_type VARCHAR(20) NOT NULL," + "source_name VARCHAR(50) NOT NULL," + "weight INT DEFAULT 25," + "enabled TINYINT(1) DEFAULT 1," + "ai_enabled TINYINT(1) DEFAULT 1," + "sort_order INT DEFAULT 0," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," + "UNIQUE KEY uk_source (source_type)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='先天画像来源权重配置'"); Integer cnt = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM innate_portrait_config", Integer.class); if (cnt == null || cnt == 0) { jdbcTemplate.batchUpdate( "INSERT INTO innate_portrait_config (source_type, source_name, weight, sort_order) VALUES (?, ?, ?, ?)", java.util.Arrays.asList(new Object[][]{ {"zodiac", "星座", 2500, 1}, {"bazi", "八字", 2500, 2}, {"blood", "血型", 2500, 3}, {"numsoul", "数字能量", 2500, 4}, }) ); log.info("迁移270: innate_portrait_config 种子数据已加载 (4条)"); } log.info("迁移270: innate_portrait_config 表创建完成"); } catch (Exception e) { log.warn("迁移270失败: {}", e.getMessage()); } // ==================== 迁移271: 先天画像报告缓存表 ============= try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS innate_portrait_report (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY," + "member_id BIGINT NOT NULL," + "member_type VARCHAR(10) NOT NULL," + "family_id BIGINT NOT NULL," + "portrait_json TEXT," + "ai_reading TEXT," + "generated_at DATETIME," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," + "UNIQUE KEY uk_member (member_id, member_type)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='先天画像报告缓存'"); log.info("迁移271: innate_portrait_report 表创建完成"); } catch (Exception e) { log.warn("迁移271失败: {}", e.getMessage()); } // ==================== 迁移272: 数字能量详细配置表 ============= try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS numsoul_detail_config (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY," + "number_type VARCHAR(20) NOT NULL," + "number_value INT NOT NULL," + "title VARCHAR(50)," + "keywords VARCHAR(200)," + "mind_base INT DEFAULT 0," + "mind_advice TEXT," + "color_hex VARCHAR(10)," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," + "UNIQUE KEY uk_type_num (number_type, number_value)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数字能量详细配置'"); Integer cnt2 = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM numsoul_detail_config", Integer.class); if (cnt2 == null || cnt2 == 0) { jdbcTemplate.batchUpdate( "INSERT INTO numsoul_detail_config (number_type, number_value, title, keywords, mind_base, mind_advice, color_hex) VALUES (?, ?, ?, ?, ?, ?, ?)", java.util.Arrays.asList(new Object[][]{ {"life_path", 1, "开创者", "独立、领导、创新", 68, "培养团队协作意识,避免独断专行", "#FF4444"}, {"life_path", 2, "和平者", "温和、合作、敏感", 72, "增强自我主张,避免过度迎合", "#FF8C00"}, {"life_path", 3, "表达者", "创意、社交、乐观", 78, "保持专注,避免浅尝辄止", "#FFD700"}, {"life_path", 4, "建设者", "务实、稳定、有序", 70, "学会变通,避免过于固执", "#4CAF50"}, {"life_path", 5, "自由者", "冒险、变化、好奇", 75, "培养持久力,避免缺乏恒心", "#2196F3"}, {"life_path", 6, "关怀者", "责任、关爱、奉献", 80, "学会自我关爱,避免过度付出", "#9C27B0"}, {"life_path", 7, "探索者", "智慧、分析、内省", 76, "与人分享见解,避免过度封闭", "#673AB7"}, {"life_path", 8, "成就者", "雄心、管理、决策", 72, "关注内在成长,避免过度追求外在", "#3F51B5"}, {"life_path", 9, "博爱者", "包容、智慧、善良", 82, "设定个人边界,避免过度牺牲", "#E91E63"}, {"life_path", 11, "启明者", "直觉、灵感、理想", 85, "落地执行,避免空想", "#7C4DFF"}, {"life_path", 22, "建造师", "远见、实践、宏图", 88, "分步落地大目标", "#536DFE"}, {"life_path", 33, "引路人", "大爱、奉献、疗愈", 90, "先照顾好自己再照亮他人", "#FF4081"}, }) ); log.info("迁移272: numsoul_detail_config 生命灵数种子已加载"); } log.info("迁移272: numsoul_detail_config 表创建完成"); } catch (Exception e) { log.warn("迁移272失败: {}", e.getMessage()); } // 迁移273: dan_report_uploads.file_id 加宽(OSS 完整 URL 超过原 VARCHAR(100) 导致截断报错) try { jdbcTemplate.execute("ALTER TABLE dan_report_uploads MODIFY COLUMN file_id VARCHAR(500) COMMENT '媒资文件ID或相对路径'"); log.info("迁移273: 已加宽 dan_report_uploads.file_id 为 VARCHAR(500)"); } catch (Exception e) { log.warn("迁移273失败: {}", e.getMessage()); } // 迁移274: dan_assessment_results.report_file_id 加宽(确认入库写入 OSS URL 超长截断) try { jdbcTemplate.execute("ALTER TABLE dan_assessment_results MODIFY COLUMN report_file_id VARCHAR(500) COMMENT '上传文件媒资ID或相对路径'"); log.info("迁移274: 已加宽 dan_assessment_results.report_file_id 为 VARCHAR(500)"); } catch (Exception e) { log.warn("迁移274失败: {}", e.getMessage()); } // 迁移275: dan_assessment_results.dan_level 兼容旧库(NOT NULL 无默认导致家长上传确认失败) ensureColumn("dan_assessment_results", "dan_level", "VARCHAR(10) NULL DEFAULT NULL COMMENT 'DAN等级A/B/C/D'"); try { jdbcTemplate.execute("ALTER TABLE dan_assessment_results MODIFY COLUMN dan_level VARCHAR(10) NULL DEFAULT NULL COMMENT 'DAN等级A/B/C/D'"); log.info("迁移275: 已将 dan_assessment_results.dan_level 改为可空并带默认"); } catch (Exception e) { log.warn("迁移275失败: {}", e.getMessage()); } runSystemMigrations(); registerCollectExpectedIndicators(); // 减脂专题表创建(远程 fatloss 功能) createFatWeightRecordTable(); createFatDietRecordTable(); createFatExerciseRecordTable(); createFatGoalTable(); addProductVendorVisibleColumn(); // 迁移367: products 表添加健康约束字段(基础疾病禁忌/过敏原/药物相互作用标记) addProductHealthConstraintColumns(); // 迁移368: 创建 drug_interactions 表(药物-食物禁忌对照表) createDrugInteractionsTable(); // 迁移369: 创建 disease_intake_limits 表(疾病-饮食摄入阈值) createDiseaseIntakeLimitsTable(); // 迁移370: 初始化 drug_interactions 种子数据 seedDrugInteractionsData(); // 迁移371: 初始化 disease_intake_limits 种子数据 seedDiseaseIntakeLimitsData(); // 指标统一层迁移(356~362) migrateIndicatorDefinitionsColumns(); migrateIndicatorValuesColumns(); addIndicatorValuesKeys(); createIndicatorMappingRulesTable(); createIndicatorCorrelationsTable(); ensureIndicatorCollectionDate(); seedDimensionDefinitions(); // 商品组合推荐与采购任务迁移(363) createProcurementTaskTable(); // 迁移364: health_report_drafts 添加解析进度/重试字段(known-type 解析失败退避重试,不再静默回退 opencode) addDraftParseProgressColumns(); // 迁移365: diet_preferences 添加无特殊偏好标记(问卷第3步「无特殊偏好」一键跳过落库) ensureColumn("diet_preferences", "no_preference", "TINYINT(1) DEFAULT 0 COMMENT '无特殊偏好标记(1=用户明确选择无特殊偏好)'"); // 迁移366: 9.9 元/月健康会员复用既有 BASIC「体验会员」等级(迁移360/schema.sql 已创建), // 移除早期误建的重复等级 LITE(与 BASIC 同为 9.9 元/月),并迁移可能已落库的 LITE 记录。 // 三段独立 try,确保任一段异常都不会阻断后续的等级删除。 try { jdbcTemplate.update("UPDATE family_memberships SET level_code = 'BASIC' WHERE level_code = 'LITE'"); } catch (Exception e) { log.warn("迁移366: 迁移 family_memberships 中的 LITE 记录失败: {}", e.getMessage()); } try { jdbcTemplate.update("UPDATE users SET member_level = 'BASIC' WHERE member_level = 'LITE'"); } catch (Exception e) { log.warn("迁移366: 迁移 users.member_level 中的 LITE 记录失败: {}", e.getMessage()); } try { int removed = jdbcTemplate.update("DELETE FROM membership_levels WHERE level_code = 'LITE'"); log.info("迁移366: 9.9 元/月健康会员统一使用 BASIC 体验会员,已移除重复的 LITE 等级({} 行)", removed); } catch (Exception e) { log.warn("迁移366: 清理 LITE 等级失败或不存在: {}", e.getMessage()); } // 迁移372: nutrition_product 添加 efficacy_tags 列(成分功效标签) addNutritionProductEfficacyTags(); // 迁移373: products 添加 tags 列(文章-商品标签关联推荐) addProductTagsColumn(); // 迁移374: 创建 user_content_weights 表(用户内容偏好权重表) addUserContentWeightsTable(); // 迁移375: 修复 article_quiz_records 表结构(旧表缺 member_id/total_questions/cf_earned/energy_earned,questions/answers 需为 TEXT) fixArticleQuizRecordsTable(); // 迁移376: health_status 添加 lifestyle JSON 列(心情/睡眠/排便/抗生素/过敏表现) ensureColumn("health_status", "lifestyle", "JSON DEFAULT NULL COMMENT '生活方式与肠道健康 {mood,sleepDuration,bedtime,bowelFrequency,bowelForm,antibioticHistory,allergySymptoms}'"); // 迁移377: diet_preferences 添加 diet_structure JSON 列(蔬果量/乳制品/含糖饮料频率) ensureColumn("diet_preferences", "diet_structure", "JSON DEFAULT NULL COMMENT '饮食结构现状 {vegetableFruit,dairy,sugaryDrinks}'"); // 迁移378: 创建 member_indicator_current 表(成员指标当前值 + 相对上次变化 delta) addMemberIndicatorCurrentTable(); // 迁移379: 创建 report_member_permissions 表(管理员授权某用户查看某成员的全部报告) addReportMemberPermissionsTable(); // 迁移380: 修正 FAMILY 会员虚拟道具映射 member1314 → member1980(会员等级表 FAMILY 年费 ¥1980,原道具价 ¥1314 与下单金额不一致,会被微信"下单金额=道具价"校验拒付) fixFamilyMembershipVirtualGoodsMapping(); // 迁移381: 创建 activity_poster_templates 表 + 初始化 3 套默认海报模板(Web 管理端定义 → 小程序端海报模板选择) createActivityPosterTemplatesTable(); // 迁移385: 活动报名添加驳回原因列(审核中心详情页驳回时写入) addActivityRegistrationRejectReasonColumn(); // 迁移386: 清理规划师占位假数据(teacher_status='pending' 且 role<>'teacher'),默认值改 NULL cleanPlannerPlaceholderData(); // 迁移387: 下线独立“服务角色申请审核”菜单(申请按角色分流合并进审核中心) hideServiceRoleApplicationMenu(); // 迁移388: activities表添加intro列(活动简介,纯文本短摘要,用于活动列表卡片展示) ensureColumn("activities", "intro", "VARCHAR(200) DEFAULT NULL COMMENT '活动简介(列表卡片展示)'"); // 迁移389: activities表添加tags列(活动标签,逗号分隔,根据活动内容自动生成) ensureColumn("activities", "tags", "VARCHAR(255) DEFAULT NULL COMMENT '活动标签,逗号分隔'"); // 迁移382: health_status 添加 signature 列(用户手签名,base64 PNG data URL,用于问卷回答确认) ensureColumn("health_status", "signature", "MEDIUMTEXT DEFAULT NULL COMMENT '用户手签名(base64 PNG data URL),确认问卷回答准确性'"); // 迁移383: health_status 添加 signed_at 列(手签名时间;问卷内容变更后置空,需重新签署) ensureColumn("health_status", "signed_at", "DATETIME DEFAULT NULL COMMENT '手签名时间(问卷变更后置空,需重新签署)'"); // 迁移384: water_record 添加 task_id 列(喝水打卡任务回写,与 health_meal_record.task_id 同款) ensureColumn("water_record", "task_id", "BIGINT NULL COMMENT '关联任务ID(喝水打卡任务回写)'"); // 迁移385: family_platform_exchange_record 添加 family_coupon_id 列(把 CF 兑换记录与发出的券实例绑定) // 目的:订单取消/退款时,对用户主动选择使用的券实例能精确判断「是否为 CF 兑换而来」并退还 CF 值; // 平台赠送券(JOIN/POPULATION/MANUAL)无对应兑换记录,退款时不退 CF。 ensureColumn("family_platform_exchange_record", "family_coupon_id", "BIGINT DEFAULT NULL COMMENT '兑换发放的优惠券实例ID(family_coupon.id), 退款时据此退还CF值'"); // 迁移388: activities 添加 poster_template_id 列(小程序端创建活动时选择海报模板并保存,详情页生成海报时默认使用) ensureColumn("activities", "poster_template_id", "BIGINT NULL COMMENT '创建时选择的海报模板ID(activity_poster_templates.id, 小程序端海报模板选择)'"); // 迁移389: 创建 special_price_deals 表(限时特价活动/扫码闪购,见 specs/2026-10-20-limited-time-special-price-design.md) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS special_price_deals (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY," + "product_id BIGINT NOT NULL," + "name VARCHAR(100) NOT NULL," + "special_price INT NOT NULL COMMENT '特价(分)'," + "start_time DATETIME," + "end_time DATETIME," + "total_quantity INT NOT NULL DEFAULT 0," + "sold_quantity INT NOT NULL DEFAULT 0," + "per_user_limit INT NOT NULL DEFAULT 1," + "allow_coupon TINYINT NOT NULL DEFAULT 0," + "status VARCHAR(20) NOT NULL DEFAULT 'draft'," + "created_at DATETIME," + "updated_at DATETIME," + "INDEX idx_product (product_id)," + "INDEX idx_status (status, start_time, end_time)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='限时特价活动'"); log.info("迁移389: 已创建 special_price_deals 表"); } catch (Exception e) { log.warn("迁移389: 创建 special_price_deals 表失败(可能已存在): {}", e.getMessage()); } // 迁移390: product_orders 添加 special_deal_id 列(限时特价订单关联) ensureColumn("product_orders", "special_deal_id", "BIGINT NULL COMMENT '限时特价活动ID(非空=特价单)'"); // 迁移391: activity_registrations 添加取消时间/原因列(P0-2 取消报名支持 approved + 退款链路,取消时记录) ensureColumn("activity_registrations", "cancelled_at", "DATETIME DEFAULT NULL COMMENT '取消报名时间'"); ensureColumn("activity_registrations", "cancel_reason", "VARCHAR(200) DEFAULT NULL COMMENT '取消原因'"); // 迁移392: activity_feedback 添加 updated_at 列 + activity_id+child_id 唯一索引(P1-3 评价去重:同一孩子只能评一次,重复提交改为更新) try { ensureColumn("activity_feedback", "updated_at", "DATETIME DEFAULT NULL COMMENT '更新时间(重复提交评价时更新)'"); jdbcTemplate.execute("ALTER TABLE activity_feedback ADD UNIQUE KEY uk_activity_child (activity_id, child_id)"); log.info("迁移392: 已为 activity_feedback 添加 updated_at 列和 uk_activity_child 唯一索引"); } catch (Exception e) { log.warn("迁移392: 添加 activity_feedback.updated_at / uk_activity_child 失败(可能已存在): {}", e.getMessage()); } // 迁移393: 创建 organizations 表(机构/企业组织主表) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `organizations` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`name` VARCHAR(128) NOT NULL COMMENT '组织名称', " + "`type` VARCHAR(32) COMMENT 'enterprise/institution/other', " + "`code` VARCHAR(64) UNIQUE COMMENT '组织编号(唯一标识)', " + "`admin_user_id` BIGINT COMMENT '组织管理员 userId(关联 users.id)', " + "`contact_name` VARCHAR(64) COMMENT '联系人姓名', " + "`contact_phone` VARCHAR(32) COMMENT '联系人电话', " + "`description` TEXT COMMENT '组织描述', " + "`status` VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT 'active/disabled', " + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX `idx_admin_user_id` (`admin_user_id`), " + "INDEX `idx_status` (`status`) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='机构/企业组织主表'"); log.info("迁移393: 已创建 organizations 表"); } catch (Exception e) { log.warn("迁移393: 创建 organizations 表失败(可能已存在): {}", e.getMessage()); } // 迁移393b: 创建 org_members 表(组织成员关联表) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `org_members` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`org_id` BIGINT NOT NULL COMMENT '组织ID', " + "`user_id` BIGINT NOT NULL COMMENT '用户ID(users.id)', " + "`member_type` VARCHAR(16) NOT NULL COMMENT 'planner/user', " + "`role_in_org` VARCHAR(32) COMMENT '组织内角色(teacher/student/member等)', " + "`status` VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT 'active/inactive', " + "`joined_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "UNIQUE KEY `uk_org_user` (`org_id`, `user_id`), " + "INDEX `idx_org_id` (`org_id`), " + "INDEX `idx_user_id` (`user_id`), " + "INDEX `idx_status` (`status`) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='组织成员关联表'"); log.info("迁移393b: 已创建 org_members 表"); } 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(); // 迁移402: delivery_method COMMENT 增加 6=同城配送(履约多渠道,规格 2026-10-22-fulfillment-multi-mode-design) addDeliveryMethod6Comments(); // 迁移403: withdrawal_requests 增加 reject_reason 列(审核中心统一详情:提现驳回原因与活动报名/服务角色申请口径一致) addWithdrawalRejectReasonColumn(); // 迁移404: articles.audio_url 由 varchar(500) 改为 MEDIUMTEXT(多段音频JSON数组可超500字符) alterArticleAudioUrlToMediumtext(); // 迁移406: activity_poster_templates 增加 layout_config 列(海报模板元素位置/样式可配置,需求:活动详情海报预览重设计) addActivityPosterTemplateLayoutConfigColumn(); // 迁移407: 创建 cf_balance / cf_balance_log(个人 CF 值账本,成员维度;F-1) createCfBalanceTables(); // 迁移408: 创建 cf_earn_rule / cf_earn_rule_history + 24 行规则种子(F02–F24;F-1) createCfEarnRuleTables(); seedCfEarnRules(); // 迁移409: 创建 cf_direct_sales_tier / cf_indirect_sales_tier(两级销售奖档位;F-1) createCfSalesTierTables(); // 迁移410: 创建 cf_earn_daily_limit + 会员等级日限种子(N11;F-1) createCfEarnDailyLimitTable(); seedCfEarnDailyLimits(); // 迁移411: coupon / user_coupon 补 family_id(优惠券家庭化 N3;F-1) addCouponFamilyIdColumns(); // 迁移412: 创建 service_config + 散落参数迁入 + CF 通用运营参数种子(W0.1) // 顺序:先迁旧位置实际值,再补种子默认值(避免默认值覆盖 sys_config 里的真实配置) createServiceConfigTable(); migrateScatteredCfConfigs(); seedCfServiceConfig(); // 迁移413: service_config 收编 product_config(W0.2 双配置中心合并) // 幂等:product_key 列/索引已存在则跳过;product_config 表不存在时数据并入为空跑 addServiceConfigProductKeyColumn(); migrateProductConfigToServiceConfig(); // 迁移414: 创建 audit_log 表(管理端写操作统一审计,W0.3;表只增不删改) createAuditLogTable(); // 迁移415: 创建 family_food_overview 表(家庭×食材 持久化最小值总览;T28) // 家庭总览取"家庭成员该项食材最新推荐指数的最小值"并持久化,由 recalculateForUser 触发重算 createFamilyFoodOverviewTable(); // 迁移416: cf_balance_log 补 operator_user_id + 幂等键升级 (member_id,type,ref_type,ref_id) // + CF 提现开关/汇率种子(F-2) migrateCfBalanceLogForLedger(); // 迁移417: 三套旧 CF 账本一次性迁入 cf_balance(F-6 存量迁移) // 顺序:必须在迁移416(uk_ref_type 唯一键升级)之后执行,幂等守卫与新唯一键保持一致。 // 幂等 ref_type 分别为 migration_family_pool / migration_system_points / // migration_user_wallet,重复启动不会重复入账。 try { int migrated = cfLegacyLedgerMigrationService.migrate(); log.info("迁移417: 旧账本存量迁移完成,本次新增 {} 笔", migrated); } catch (Exception e) { // 事务已整体回滚;保留启动可用性,下次启动可安全重试。 log.warn("迁移417: 旧账本存量迁移失败,本批已回滚,下次启动重试: {}", e.getMessage()); } // 迁移418: 两级销售奖档位种子 + F03a/F03b 幂等 ref_type 对齐(F-4 分佣收口) seedCfSalesTiers(); alignSalesRuleRefTypes(); // 迁移419: membership_levels 补 max_daily_analyze(会员等级每日报告解析次数上限) // 背景:ReportAnalyzeQuotaService(报告解析配额)已入库,但 MembershipLevel 实体字段与 // membership_levels 表列在历史提交中丢失,导致编译失败 + 运行时 Unknown column。 // 此处补列(幂等);0/NULL 表示不限次。 ensureColumn("membership_levels", "max_daily_analyze", "INT DEFAULT NULL COMMENT '每日报告解析次数上限(0/NULL=不限)'"); // 迁移420: 创建 ppoint_consumption_log(滚动一年销售奖档位聚合数据源;F-4) // 注:迁移419 已被 membership_levels.max_daily_analyze 占用,本表顺延为 420。 createPpointConsumptionLogTable(); // 迁移421: activities 表新增适用人群字段 audience_scope / audience_type(A2 活动人群与费用模型重构) // 说明:沿用 visible_scope 迁移的 information_schema.COLUMNS 存在性检查范式, // 以便用 AFTER visible_to 精确定位列顺序(ensureColumn 不支持列定位)。 addActivityAudienceColumns(); // 迁移422: 指标多期展示(DS-2026-10-25-01) // 顺序红线:先确保 health_indicators.collection_date 物理列存在,才能放开实体映射; // 顺序颠倒会导致缺列环境所有 health_indicators 查询报 Unknown column。 ensureHealthIndicatorsCollectionDateColumn(); migrateIndicatorValuesTimelineColumns(); // 迁移423: 体脂秤测量记录表(sourceId 由 memberId 改为 recordId 的前置表) createBodyFatScaleRecordsTable(); // 迁移424: health_plans 加 plan_type(区分健康方案/家庭归因干预方案;G5) migrateHealthPlansPlanType(); // 迁移425: 家庭归因常模值表(G10,菌群 710 常模落点) createAttributionNormValuesTable(); // 迁移425-导入: 菌群常模可配置 JSON 导入(空壳文件导入 0 行,不报错) importAttributionNormGutFlora(); // 迁移425-种子: 家庭归因确认问卷模板种子(第八章 16 条,幂等) seedFamilyAttributionSurveyTemplate(); // 迁移426: 家庭归因报告表 attribution_reports(B1 核心闭环③,report_json+intermediate_state+redline_hits+status) createAttributionReportsTable(); // 迁移427: 创建 content_dimension_threshold 表(A-后端 内容维度阈值显隐配置:商品/文章/活动/游戏 五维+子项+自定义指标) createContentDimensionThresholdTable(); // 迁移428: 会员优惠券转赠表 coupon_gift_transfer + family_coupon.is_gift 列 // (用户可把自己的会员券转赠给推荐下级,用于购买会员,可撤回) createCouponGiftTransferTable(); // 迁移429: 用户关注指标收藏表 indicator_favorite(T04 方案A:后端持久化,跨端同步) createIndicatorFavoriteTable(); // 迁移430: 商品成本价改为整型(分) —— P点口径:P点 = 实际成交额 - 成本价(分) migrateProductsPurchasePriceToCents(); // 迁移431: 销售奖档位门槛 ×100(元→分)+ 档位名改为 新秀/小成/中富/大成/至富 migrateSalesAwardTierThresholds(); // 迁移429(家庭): 存量家庭名回填(家庭主账号已填真实姓名但家庭名仍为旧规则 → 按新规则回填「XX家庭」;DML,无 schema 变更,幂等) migrateFamilyNameBackfill(); // 迁移429(家庭): 存量家庭名回填(家庭主账号已填真实姓名但家庭名仍为旧规则 → 按新规则回填「XX家庭」;DML,无 schema 变更,幂等) migrateFamilyNameBackfill(); // 迁移432: report_type_registry 添加 linked_product_id 列(报告类型→商城商品链接配置,规格 2026-10-30-report-type-product-link-design) ensureColumn("report_type_registry", "linked_product_id", "BIGINT DEFAULT NULL COMMENT '关联商城商品 products.id,NULL=不可链接'"); // 迁移433: 家庭指标聚合方式配置表(五维能量汇总/珍珠图/章鱼图汇集配置) createFamilyMetricAggConfigTable(); // 迁移434: SKU商品组合明细表 + 订单记录下单SKU(组合SKU识别) migrateSkuCombo(); // 迁移436: products 表添加营养数据字段(热量/蛋白质/脂肪/碳水/膳食纤维/营养标签) migrateProductNutritionFields(); // 迁移437: T35 活动管理团队 —— activities.team_id + activity_team/activity_team_member/service_assignment 表 migrateActivityTeam(); // 迁移440: 套餐内容明细表(套餐=独立商品,内容=子商品SKU×数量,挂套餐SKU级) migrateProductComboItems(); } /** * 迁移430: 商品成本价改为整型(分)(P点口径调整)。 * *

原 products.purchase_price DECIMAL(10,2),值与 price 同量级(分);改为 INT 存分。 * 仅改类型不做数值换算(幂等:已是 INT 则跳过)。

*/ private void migrateProductsPurchasePriceToCents() { try { String colType = jdbcTemplate.queryForObject( "SELECT DATA_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() " + "AND TABLE_NAME='products' AND COLUMN_NAME='purchase_price'", String.class); if ("decimal".equalsIgnoreCase(colType)) { // 值本身已是分(与 price 同量级、低于售价),只改类型不做数值换算 jdbcTemplate.execute("ALTER TABLE products MODIFY COLUMN purchase_price INT NOT NULL DEFAULT 0 COMMENT '成本价(分)'"); log.info("迁移430完成:products.purchase_price decimal→int(分)(值已是分,仅改类型)"); } else { log.info("迁移430跳过:products.purchase_price 已是 {} 类型", colType); } } catch (Exception e) { log.warn("迁移430失败(幂等容忍): {}", e.getMessage()); } } /** * 迁移431: 销售奖档位门槛由元改为分(×100),并按 id 重命名为新档位名(费率不变)。 * *

档位:新秀(1w)/小成(5w)/中富(20w)/大成(50w)/至富(100w),单位分。

*/ private void migrateSalesAwardTierThresholds() { try { // 唯一键 uk_min 冲突规避:先设临时值(唯一且不撞最终值),再设最终值。 // 单条 CASE UPDATE 逐行执行时,若直接改 100→10000 会与尚未更新的 10000(原钻石)撞 uk_min。 jdbcTemplate.execute( "UPDATE cf_direct_sales_tier SET min_annual_ppoints = 9000000 + id, tier_name = CONCAT('TMP', id) " + "WHERE id BETWEEN 1 AND 5 AND tier_name IN ('青铜','白银','黄金','铂金','钻石')"); jdbcTemplate.execute( "UPDATE cf_direct_sales_tier SET min_annual_ppoints = CASE id " + "WHEN 1 THEN 10000 WHEN 2 THEN 50000 WHEN 3 THEN 200000 WHEN 4 THEN 500000 WHEN 5 THEN 1000000 END, " + "tier_name = CASE id WHEN 1 THEN '新秀' WHEN 2 THEN '小成' WHEN 3 THEN '中富' " + "WHEN 4 THEN '大成' WHEN 5 THEN '至富' END " + "WHERE id BETWEEN 1 AND 5 AND min_annual_ppoints BETWEEN 9000001 AND 9000005"); jdbcTemplate.execute( "UPDATE cf_indirect_sales_tier SET min_self_ppoints = 9000000 + id, tier_name = CONCAT('TMP', id) " + "WHERE id BETWEEN 1 AND 5 AND tier_name IN ('青铜','白银','黄金','铂金','钻石')"); jdbcTemplate.execute( "UPDATE cf_indirect_sales_tier SET min_self_ppoints = CASE id " + "WHEN 1 THEN 10000 WHEN 2 THEN 50000 WHEN 3 THEN 200000 WHEN 4 THEN 500000 WHEN 5 THEN 1000000 END, " + "tier_name = CASE id WHEN 1 THEN '新秀' WHEN 2 THEN '小成' WHEN 3 THEN '中富' " + "WHEN 4 THEN '大成' WHEN 5 THEN '至富' END " + "WHERE id BETWEEN 1 AND 5 AND min_self_ppoints BETWEEN 9000001 AND 9000005"); log.info("迁移431完成:销售奖档位门槛×100(分),档位名新秀/小成/中富/大成/至富"); } catch (Exception e) { log.warn("迁移431失败(幂等容忍): {}", e.getMessage()); } } /** 迁移:家庭主账号已填真实姓名但家庭名仍为旧规则 → 回填为「XX家庭」(幂等,重复执行无副作用) */ private void migrateFamilyNameBackfill() { String sql = "UPDATE families f JOIN users u ON u.id = f.creator_id " + "SET f.name = CONCAT(TRIM(u.real_name), '家庭'), f.updated_at = NOW() " + "WHERE u.real_name IS NOT NULL AND TRIM(u.real_name) <> '' " + "AND f.name <> CONCAT(TRIM(u.real_name), '家庭')"; try { int n = jdbcTemplate.update(sql); log.info("迁移[家庭名回填XX家庭] 影响行数={}", n); } catch (Exception e) { log.warn("迁移[家庭名回填XX家庭] 执行异常: {}", e.getMessage()); } } /** * 迁移421: activities 表新增 audience_scope / audience_type 两列(A2) * - audience_scope: all(默认,全部用户可参加) / member_only(仅会员可参加,后端硬门禁) * - audience_type : adult(默认,仅成人) / include_child / include_elder / include_child_elder * 长者档不新增独立价格列(决策D1),长者复用成人价,audience_type 仅作展示与筛选标签。 * 幂等:information_schema 检查列是否存在,存在则跳过。 */ private void addActivityAudienceColumns() { // 迁移421-1: activities表添加audience_scope列 try { Integer colExists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activities' AND COLUMN_NAME = 'audience_scope'", Integer.class); if (colExists == null || colExists == 0) { jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN audience_scope VARCHAR(20) DEFAULT 'all' COMMENT '参与人群范围: all(全部用户)/member_only(仅会员可参加)' AFTER visible_to"); log.info("已添加audience_scope列到activities表"); } else { log.info("activities表audience_scope列已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加activities表audience_scope列失败: {}", e.getMessage()); } // 迁移421-2: activities表添加audience_type列 try { Integer colExists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activities' AND COLUMN_NAME = 'audience_type'", Integer.class); if (colExists == null || colExists == 0) { jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN audience_type VARCHAR(20) DEFAULT 'adult' COMMENT '参与人群构成: adult(仅成人)/include_child/include_elder/include_child_elder' AFTER audience_scope"); log.info("已添加audience_type列到activities表"); } else { log.info("activities表audience_type列已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加activities表audience_type列失败: {}", e.getMessage()); } } /** * 迁移436:products 表添加营养数据字段。 *

商品营养数据用于管理端录入、小程序详情页展示与推荐按营养标签筛选。 * 全部字段允许为 NULL(NULL = 未配置),迁移幂等可重复执行。 *

编号说明:原分支为迁移417,合入主线时 417 已被「旧 CF 账本存量迁移」占用,顺延至主线最新 435 (product_bundle_items放开可空) + 1 = 436。 */ private void migrateProductNutritionFields() { if (!tableExists("products")) { log.warn("迁移436: products 表不存在,跳过营养数据字段迁移"); return; } try { ensureColumn("products", "calories_kcal", "DECIMAL(10,2) DEFAULT NULL COMMENT '热量(kcal/100g),NULL=未配置'"); ensureColumn("products", "protein_g", "DECIMAL(10,2) DEFAULT NULL COMMENT '蛋白质(g/100g),NULL=未配置'"); ensureColumn("products", "fat_g", "DECIMAL(10,2) DEFAULT NULL COMMENT '脂肪(g/100g),NULL=未配置'"); ensureColumn("products", "carb_g", "DECIMAL(10,2) DEFAULT NULL COMMENT '碳水化合物(g/100g),NULL=未配置'"); ensureColumn("products", "fiber_g", "DECIMAL(10,2) DEFAULT NULL COMMENT '膳食纤维(g/100g),NULL=未配置'"); ensureColumn("products", "nutrition_tags", "VARCHAR(255) DEFAULT NULL COMMENT '营养标签,逗号分隔(如\"低糖,高蛋白\"),NULL=无标签'"); log.info("迁移436: products 表营养数据字段已就绪"); } catch (Exception e) { log.warn("迁移436: products 表营养数据字段迁移失败: {}", e.getMessage()); } } /** * 迁移437:T35 活动管理团队。 * *

activities.team_id 关联 activity_team;activity_team/activity_team_member 承载团队与成员; * service_assignment 为通用服务指派表(service_type=ACTIVITY_SUPPORT),供活动运营服务指派使用, * 与 T34 服务指派机制同一张表,后续服务类型只需新增 service_type 取值。 * 全部 DDL 幂等(ensureColumn / CREATE TABLE IF NOT EXISTS)。

*/ private void migrateActivityTeam() { try { ensureColumn("activities", "team_id", "BIGINT DEFAULT NULL COMMENT '活动管理团队ID(activity_team.id), NULL=无团队; 团队成员可协作运营(迁移437/T35)'"); log.info("迁移437: activities.team_id 列已就绪"); } catch (Exception e) { log.warn("迁移437: activities.team_id 列检查失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS activity_team (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "name VARCHAR(100) NOT NULL COMMENT '团队名称', " + "description VARCHAR(500) DEFAULT NULL COMMENT '团队简介', " + "owner_id BIGINT NOT NULL COMMENT '团长userId', " + "status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active/disabled', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_owner (owner_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动管理团队'"); log.info("迁移437: 已创建 activity_team 表"); } catch (Exception e) { log.warn("迁移437: 创建 activity_team 表失败(可能已存在): {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS activity_team_member (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "team_id BIGINT NOT NULL COMMENT '团队ID(activity_team.id)', " + "user_id BIGINT NOT NULL COMMENT '成员userId', " + "role VARCHAR(32) NOT NULL DEFAULT 'operator' COMMENT 'owner(团长)/manager(运营主管)/operator(运营成员)', " + "status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active(在团)/removed(已移出)', " + "joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_team_user (team_id, user_id), " + "INDEX idx_user (user_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动团队成员'"); log.info("迁移437: 已创建 activity_team_member 表"); } catch (Exception e) { log.warn("迁移437: 创建 activity_team_member 表失败(可能已存在): {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS service_assignment (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "service_type VARCHAR(32) NOT NULL COMMENT '服务类型: ACTIVITY_SUPPORT(活动运营)', " + "target_type VARCHAR(32) NOT NULL COMMENT '目标类型: ACTIVITY(具体活动)/ACTIVITY_TEAM(团队整体)', " + "target_id BIGINT NOT NULL COMMENT '目标ID', " + "team_id BIGINT DEFAULT NULL COMMENT '关联团队ID(activity_team.id)', " + "assignee_id BIGINT NOT NULL COMMENT '被指派人userId', " + "role VARCHAR(32) NOT NULL DEFAULT 'support' COMMENT 'lead/support/checkin/content', " + "status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active/revoked', " + "remark VARCHAR(255) DEFAULT NULL COMMENT '备注', " + "assigned_by BIGINT DEFAULT NULL COMMENT '指派操作人userId', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_service_assign (service_type, target_type, target_id, assignee_id, role), " + "INDEX idx_team (team_id), " + "INDEX idx_assignee (assignee_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务指派(活动运营ACTIVITY_SUPPORT, T34机制复用)'"); log.info("迁移437: 已创建 service_assignment 表"); } catch (Exception e) { log.warn("迁移437: 创建 service_assignment 表失败(可能已存在): {}", e.getMessage()); } } /** * 迁移440:套餐内容明细表 product_combo_items。 *

套餐=独立商品(product_type='bundle'),内容=套餐包含哪些子商品(精确到子商品SKU)×数量; * 内容挂套餐SKU级(combo_sku_id)。下单/支付只动套餐SKU自身库存,不扣子商品库存。 *

幂等:CREATE TABLE IF NOT EXISTS + 异常兜底。 */ private void migrateProductComboItems() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `product_combo_items` (" + "`id` BIGINT NOT NULL AUTO_INCREMENT, " + "`combo_sku_id` BIGINT NOT NULL COMMENT '套餐SKU ID(product_skus.id),内容挂SKU级', " + "`child_product_id` BIGINT NOT NULL COMMENT '子商品ID', " + "`child_sku_id` BIGINT NOT NULL COMMENT '子商品SKU ID(精确到规格,必填)', " + "`quantity` INT NOT NULL DEFAULT 1 COMMENT '该子商品数量', " + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, " + "PRIMARY KEY (`id`), " + "UNIQUE KEY `uk_combo_sku_child` (`combo_sku_id`, `child_sku_id`) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='套餐内容明细(套餐商品包含的子商品SKU×数量)'"); log.info("迁移440: 已创建 product_combo_items 表"); } catch (Exception e) { log.warn("迁移440: 创建 product_combo_items 表失败(可能已存在): {}", e.getMessage()); } } private void migrateHealthDimensionsTable() { try { if (tableExists("health_dimension_score")) { jdbcTemplate.execute("RENAME TABLE `health_dimension_score` TO `health_dimension_score_old`"); } } catch (Exception e) { log.warn("health_dimension_score rename skipped: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `health_dimension_scores` (\n" + " `id` bigint NOT NULL AUTO_INCREMENT,\n" + " `member_id` bigint NOT NULL COMMENT '家庭成员ID',\n" + " `dimension` varchar(20) NOT NULL COMMENT '维度: growth/sleep/vision/immunity/nutrition/gut/exercise',\n" + " `score` int NOT NULL COMMENT '0-100分',\n" + " `percentile` int DEFAULT NULL COMMENT '人群百分位 0-100',\n" + " `data_source` varchar(20) NOT NULL COMMENT '数据源: REPORT/PHOTO/VOICE/MANUAL',\n" + " `tier` tinyint NOT NULL DEFAULT 3 COMMENT '数据层级: 1自动/2语音/3手动',\n" + " `raw_data` json DEFAULT NULL COMMENT '原始数据快照',\n" + " `assess_date` date NOT NULL COMMENT '评估日期',\n" + " `expire_date` date DEFAULT NULL COMMENT '数据过期日',\n" + " `created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n" + " `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n" + " PRIMARY KEY (`id`),\n" + " UNIQUE KEY `uk_member_dim_date` (`member_id`, `dimension`, `assess_date`)\n" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康维度评分表'"); } catch (Exception e) { log.warn("health_dimension_scores 表创建失败: {}", e.getMessage()); } try { if (tableExists("health_data_source_record")) { jdbcTemplate.execute("RENAME TABLE `health_data_source_record` TO `health_data_source_record_old`"); } } catch (Exception e) { log.warn("health_data_source_record rename skipped: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `health_data_sources` (\n" + " `id` bigint NOT NULL AUTO_INCREMENT,\n" + " `member_id` bigint NOT NULL,\n" + " `source_type` varchar(20) NOT NULL COMMENT 'PDF/PHOTO/VOICE/MANUAL/WEIXIN_STEP',\n" + " `dimension_codes` json NOT NULL COMMENT '影响的维度列表',\n" + " `parsed_result` json DEFAULT NULL COMMENT '解析结果全量',\n" + " `file_url` varchar(500) DEFAULT NULL COMMENT '原始文件URL',\n" + " `confidence` decimal(3,2) DEFAULT NULL COMMENT 'AI解析置信度',\n" + " `created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n" + " PRIMARY KEY (`id`),\n" + " KEY `idx_member_source` (`member_id`, `source_type`)\n" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康数据源记录表'"); } catch (Exception e) { log.warn("health_data_sources 表创建失败: {}", e.getMessage()); } try { if (tableExists("health_norm_reference")) { jdbcTemplate.execute("RENAME TABLE `health_norm_reference` TO `health_norm_reference_old`"); } } catch (Exception e) { log.warn("health_norm_reference rename skipped: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `health_norm_reference` (\n" + " `id` bigint NOT NULL AUTO_INCREMENT,\n" + " `dimension` varchar(20) NOT NULL COMMENT '维度',\n" + " `gender` varchar(10) NOT NULL COMMENT 'male/female/all',\n" + " `age_min` int NOT NULL COMMENT '年龄下限(月)',\n" + " `age_max` int NOT NULL COMMENT '年龄上限(月)',\n" + " `percentile_5` int DEFAULT NULL,\n" + " `percentile_15` int DEFAULT NULL,\n" + " `percentile_25` int DEFAULT NULL,\n" + " `percentile_50` int DEFAULT NULL COMMENT '中位值',\n" + " `percentile_75` int DEFAULT NULL,\n" + " `percentile_85` int DEFAULT NULL,\n" + " `percentile_95` int DEFAULT NULL,\n" + " `source` varchar(50) DEFAULT NULL COMMENT '常模来源',\n" + " `created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n" + " PRIMARY KEY (`id`),\n" + " UNIQUE KEY `uk_dim_gender_age` (`dimension`, `gender`, `age_min`, `age_max`)\n" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康常模对照表'"); } catch (Exception e) { log.warn("health_norm_reference 表重建失败: {}", e.getMessage()); } try { jdbcTemplate.execute("INSERT IGNORE INTO health_dimension_scores (member_id, dimension, score, data_source, tier, assess_date, created_at)\n" + " SELECT member_id,\n" + " CASE dimension\n" + " WHEN 'body' THEN 'growth'\n" + " WHEN 'fitness' THEN 'exercise'\n" + " WHEN 'cardio' THEN 'exercise'\n" + " WHEN 'blood' THEN 'immunity'\n" + " WHEN 'sleep' THEN 'sleep'\n" + " WHEN 'psyche' THEN 'growth'\n" + " WHEN 'diet' THEN 'nutrition'\n" + " ELSE dimension\n" + " END,\n" + " score,\n" + " UPPER(data_source),\n" + " 3,\n" + " record_date,\n" + " NOW()\n" + " FROM health_dimension_score_old WHERE is_latest = 1"); } catch (Exception e) { log.warn("旧维度评分数据迁移跳过: {}", e.getMessage()); } } private void seedHealthNorms() { try { int count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM health_norm_reference", Integer.class); if (count == 0) { jdbcTemplate.execute("INSERT INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES\n" + "('growth', 'male', 72, 84, 25, 35, 45, 55, 70, 80, 90, 'BMI-for-age→百分制'),\n" + "('growth', 'female', 72, 84, 25, 35, 45, 55, 70, 80, 90, 'BMI-for-age→百分制'),\n" + "('growth', 'male', 84, 96, 25, 35, 45, 55, 70, 80, 90, 'BMI-for-age→百分制'),\n" + "('growth', 'female', 84, 96, 25, 35, 45, 55, 70, 80, 90, 'BMI-for-age→百分制'),\n" + "('growth', 'male', 96, 120, 25, 35, 45, 55, 70, 80, 90, 'BMI-for-age→百分制'),\n" + "('growth', 'female', 96, 120, 25, 35, 45, 55, 70, 80, 90, 'BMI-for-age→百分制'),\n" + "('sleep', 'all', 0, 12, 40, 50, 60, 70, 80, 88, 95, 'NSF婴幼儿睡眠→百分制'),\n" + "('sleep', 'all', 12, 36, 40, 50, 60, 70, 80, 88, 95, 'NSF幼儿睡眠→百分制'),\n" + "('sleep', 'all', 36, 72, 40, 50, 60, 70, 80, 88, 95, 'NSF学龄前睡眠→百分制'),\n" + "('sleep', 'all', 72, 156, 40, 50, 60, 70, 80, 88, 95, 'NSF学龄儿童睡眠→百分制'),\n" + "('vision', 'male', 72, 144, 45, 55, 65, 75, 85, 90, 95, '视力发育标准→百分制'),\n" + "('vision', 'female', 72, 144, 45, 55, 65, 75, 85, 90, 95, '视力发育标准→百分制'),\n" + "('immunity', 'all', 0, 72, 15, 30, 45, 65, 80, 90, 95, '年患病次数→百分制'),\n" + "('immunity', 'all', 72, 192, 15, 35, 50, 70, 85, 92, 98, '年患病次数→百分制'),\n" + "('nutrition', 'all', 36, 192, 20, 30, 40, 50, 60, 70, 80, '膳食多样性→百分制'),\n" + "('gut', 'all', 0, 192, 40, 50, 60, 70, 80, 85, 95, '菌群人群分布(平台自建)'),\n" + "('exercise', 'male', 72, 144, 30, 40, 50, 60, 70, 80, 90, '国家学生体质健康标准'),\n" + "('exercise', 'female', 72, 144, 25, 35, 45, 55, 65, 75, 85, '国家学生体质健康标准')"); log.info("health_norm_reference 常模种子数据已加载 (18条, 统一0-100百分制)"); } else { log.info("health_norm_reference 常模数据已存在 ({}条), 跳过", count); } } catch (Exception e) { log.warn("health_norm_reference 常模种子数据初始化失败: {}", e.getMessage()); } // 迁移: articles表添加审核和作者类型字段 try { Integer exists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'articles' AND COLUMN_NAME = 'audit_status'", Integer.class); if (exists == null || exists == 0) { jdbcTemplate.execute("ALTER TABLE articles " + "ADD COLUMN author_type VARCHAR(20) DEFAULT 'admin' COMMENT '作者类型:admin/parent/child' AFTER created_by, " + "ADD COLUMN audit_status VARCHAR(20) DEFAULT 'approved' COMMENT '审核状态:approved/pending/rejected' AFTER status, " + "ADD COLUMN audit_reason TEXT COMMENT '驳回原因' AFTER audit_status, " + "ADD COLUMN auditor_id BIGINT DEFAULT 0 COMMENT '审核人 ID' AFTER audit_reason, " + "ADD COLUMN audited_at DATETIME COMMENT '审核时间' AFTER auditor_id"); log.info("已添加审核和作者类型字段到articles表"); } else { log.info("articles表审核字段已存在,跳过"); } } catch (Exception e) { log.warn("检查/添加articles表审核字段失败: {}", e.getMessage()); } // 迁移20240709-01: content字段扩为TEXT - 用户输入内容可能超VARCHAR上限 // activities.description modifyColumnToText("activities", "description", "活动描述"); // tasks.description modifyColumnToText("tasks", "description", "任务描述"); // wishes.description, reject_reason, cancel_reason modifyColumnToText("wishes", "description", "心愿描述"); modifyColumnToText("wishes", "reject_reason", "拒绝原因"); modifyColumnToText("wishes", "cancel_reason", "取消原因"); // reward_wishlists.description modifyColumnToText("reward_wishlists", "description", "心愿描述"); // products.description, reject_reason modifyColumnToText("products", "description", "商品描述"); modifyColumnToText("products", "reject_reason", "审核拒绝原因"); // product_orders.remark modifyColumnToText("product_orders", "remark", "备注"); // articles.summary modifyColumnToText("articles", "summary", "摘要"); // activities.audit_reason modifyColumnToText("activities", "audit_reason", "驳回原因"); // guide_applications.reject_reason modifyColumnToText("guide_applications", "reject_reason", "拒绝原因"); // guide_application_records.remark modifyColumnToText("guide_application_records", "remark", "备注/原因"); // user_operation_log.description modifyColumnToText("user_operation_log", "description", "操作描述"); // follow_up.description, teacher_review modifyColumnToText("follow_up", "description", "描述"); modifyColumnToText("follow_up", "teacher_review", "规划师点评"); // energy_log.description modifyColumnToText("energy_log", "description", "描述"); // sys_config.description modifyColumnToText("sys_config", "description", "配置说明"); // points_log.description modifyColumnToText("points_log", "description", "描述"); // insurance_policies.remark modifyColumnToText("insurance_policies", "remark", "备注"); // health_reports.remark modifyColumnToText("health_reports", "remark", "备注"); // guide_service_logs.notes modifyColumnToText("guide_service_logs", "notes", "服务记录"); // guide_packages.description modifyColumnToText("guide_packages", "description", "套餐说明"); // guide_package_templates.description modifyColumnToText("guide_package_templates", "description", "说明"); // service_types.description modifyColumnToText("service_types", "description", "描述"); // commission_records.remark modifyColumnToText("commission_records", "remark", "备注"); // zodiac_pairing.description, advice modifyColumnToText("zodiac_pairing", "description", "配对解读"); modifyColumnToText("zodiac_pairing", "advice", "相处建议"); // blood_type_pairing.description, advice modifyColumnToText("blood_type_pairing", "description", "配对描述"); modifyColumnToText("blood_type_pairing", "advice", "相处建议"); // life_number_relationship.description, advice modifyColumnToText("life_number_relationship", "description", "关系解读"); modifyColumnToText("life_number_relationship", "advice", "相处建议"); // daily_health_checkin.remark modifyColumnToText("daily_health_checkin", "remark", "备注"); // health_sleep_record.remark modifyColumnToText("health_sleep_record", "remark", "备注"); // health_exercise_record.remark modifyColumnToText("health_exercise_record", "remark", "备注"); // finance_checkins.note modifyColumnToText("finance_checkins", "note", "备注"); // guide_packages.description (old duplicate) // assessment_appointments.remark modifyColumnToText("assessment_appointments", "remark", "备注"); // withdrawal_requests.remark modifyColumnToText("withdrawal_requests", "remark", "备注"); // supply_settlements.remark modifyColumnToText("supply_settlements", "remark", "备注"); // content_sections.description ensureColumn("content_sections", "description", "TEXT COMMENT '描述说明'"); // education_systems.description modifyColumnToText("education_systems", "description", "描述"); // dimension_config.description modifyColumnToText("dimension_config", "description", "描述"); // energy_rules.description modifyColumnToText("energy_rules", "description", "规则描述"); // team_goals.description modifyColumnToText("team_goals", "description", "团队描述"); // product_dimension_config.remark modifyColumnToText("product_dimension_config", "remark", "备注"); // indicator_values.remark modifyColumnToText("indicator_values", "remark", "备注"); // tongue_records.overall_assessment modifyColumnToText("tongue_records", "overall_assessment", "总体评估"); // system_config.description, config_value modifyColumnToText("system_config", "description", "说明"); modifyColumnToText("system_config", "config_value", "配置值"); // supply_system.description modifyColumnToText("supply_system", "description", "描述"); // supply_settlement.remark modifyColumnToText("supply_settlement", "remark", "备注"); // growth_task.description modifyColumnToText("growth_task", "description", "描述"); // distribution_systems.description modifyColumnToText("distribution_systems", "description", "团队描述"); // mini_games.description modifyColumnToText("mini_games", "description", "游戏描述"); // blood_type_config.description modifyColumnToText("blood_type_config", "description", "性格描述"); // 迁移20240709-02: 北京菌群报告采集增强(P0 疾病关联菌 / P1 菌群状态) // health_disease_risk.related_bacteria: 疾病关联菌(异常菌)明细 JSON ensureColumn("health_disease_risk", "related_bacteria", "TEXT NULL COMMENT '关联菌明细JSON:[{bacteria,correlation,value,status}]'"); // health_reports.gut_status_level: 肠道菌群状态(0度~4度) ensureColumn("health_reports", "gut_status_level", "VARCHAR(20) NULL COMMENT '肠道菌群状态(0度~4度)'"); // 迁移248: 任务系统升级(行为调度中心)——tasks 新增字段 ensureColumn("tasks", "prerequisite_task_id", "BIGINT NULL COMMENT '前置任务ID:完成后才解锁本任务'"); ensureColumn("tasks", "action_type", "VARCHAR(32) NULL COMMENT '联动动作: minigame/focus/survey/article/activity/cart/order'"); ensureColumn("tasks", "action_config", "JSON NULL COMMENT '联动参数: {\"gameCode\":\"schulte\",\"productId\":123,\"minSeconds\":30,\"completeMilestone\":\"paid\"/\"received\",\"surveyId\":99}'"); ensureColumn("tasks", "require_input", "TINYINT DEFAULT 0 COMMENT '1=需填写内容才能完成'"); ensureColumn("tasks", "start_required", "TINYINT DEFAULT 0 COMMENT '1=需先开始再完成(两阶段)'"); ensureColumn("tasks", "start_count", "INT DEFAULT 0 COMMENT '已开始次数(累计)'"); ensureColumn("tasks", "min_duration_seconds", "INT DEFAULT 0 COMMENT '最短持续秒数(专注/游戏类,未达标不计完成)'"); // 迁移248: 任务执行进度表(两阶段/里程碑/填写内容记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS task_executions (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "task_id BIGINT NOT NULL COMMENT '任务实例ID', " + "member_id BIGINT NOT NULL COMMENT '执行成员ID(family_members.id)', " + "status VARCHAR(20) NOT NULL DEFAULT 'started' COMMENT 'started/finished/failed', " + "started_at DATETIME NOT NULL COMMENT '开始时间', " + "finished_at DATETIME COMMENT '完成时间', " + "milestone VARCHAR(32) COMMENT '购物任务里程碑: cart_added/paid/received(当前进度)', " + "content TEXT COMMENT '填写内容(require_input 任务)', " + "content_type VARCHAR(20) COMMENT 'content 类型: text/image/audio/video', " + "extra JSON COMMENT '扩展数据: {gameScore, focusMinutes, orderId, articleId, surveyId}', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_task_member (task_id, member_id), " + "INDEX idx_status (status)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务执行进度表'"); log.info("已创建task_executions表"); } catch (Exception e) { log.warn("创建task_executions表失败: {}", e.getMessage()); } // 迁移248: focus_sessions 添加 task_id 字段(专注任务挂钩) ensureColumn("focus_sessions", "task_id", "BIGINT NULL COMMENT '关联的任务ID(专注任务挂钩)'"); } private void seedMindActionWealthNorms() { try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM health_norm_reference WHERE dimension LIKE 'bigFive%' OR dimension LIKE 'emotion%' OR dimension LIKE 'drive%' OR dimension LIKE 'selfConcept%' OR dimension LIKE 'action%' OR dimension LIKE 'wealth%'", Integer.class); if (count != null && count > 0) { log.info("mind/action/wealth 常模数据已存在 ({}条), 跳过", count); return; } java.util.List sqls = new java.util.ArrayList<>(); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('bigFiveOpenness','all',0,84,20,27,35,45,55,63,70,'心理常模'),\n" + "('bigFiveOpenness','all',84,144,27,34,42,52,62,70,77,'心理常模'),\n" + "('bigFiveOpenness','all',144,216,30,37,45,55,65,73,80,'心理常模'),\n" + "('bigFiveOpenness','all',216,1200,25,32,40,50,60,68,75,'心理常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('bigFiveConscientiousness','all',0,84,15,22,30,40,50,58,65,'心理常模'),\n" + "('bigFiveConscientiousness','all',84,144,23,30,38,48,58,66,73,'心理常模'),\n" + "('bigFiveConscientiousness','all',144,216,30,37,45,55,65,73,80,'心理常模'),\n" + "('bigFiveConscientiousness','all',216,1200,33,40,48,58,68,76,83,'心理常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('bigFiveExtraversion','all',0,84,30,37,45,55,65,73,80,'心理常模'),\n" + "('bigFiveExtraversion','all',84,144,27,34,42,52,62,70,77,'心理常模'),\n" + "('bigFiveExtraversion','all',144,216,25,32,40,50,60,68,75,'心理常模'),\n" + "('bigFiveExtraversion','all',216,1200,25,32,40,50,60,68,75,'心理常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('bigFiveAgreeableness','all',0,84,33,40,48,58,68,76,83,'心理常模'),\n" + "('bigFiveAgreeableness','all',84,144,30,37,45,55,65,73,80,'心理常模'),\n" + "('bigFiveAgreeableness','all',144,216,28,35,43,53,63,71,78,'心理常模'),\n" + "('bigFiveAgreeableness','all',216,1200,30,37,45,55,65,73,80,'心理常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('bigFiveNeuroticism','all',0,84,30,37,45,55,65,73,80,'心理常模'),\n" + "('bigFiveNeuroticism','all',84,144,23,30,38,48,58,66,73,'心理常模'),\n" + "('bigFiveNeuroticism','all',144,216,20,27,35,45,55,63,70,'心理常模'),\n" + "('bigFiveNeuroticism','all',216,1200,30,37,45,55,65,73,80,'心理常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('emotionStability','all',0,84,17,24,32,42,52,60,67,'心理常模'),\n" + "('emotionStability','all',84,144,27,34,42,52,62,70,77,'心理常模'),\n" + "('emotionStability','all',144,216,33,40,48,58,68,76,83,'心理常模'),\n" + "('emotionStability','all',216,1200,37,44,52,62,72,80,87,'心理常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('driveAutonomy','all',0,84,10,17,25,35,45,53,60,'心理常模'),\n" + "('driveAutonomy','all',84,144,25,32,40,50,60,68,75,'心理常模'),\n" + "('driveAutonomy','all',144,216,37,44,52,62,72,80,87,'心理常模'),\n" + "('driveAutonomy','all',216,1200,43,50,58,68,78,86,93,'心理常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('driveCompetence','all',0,84,15,22,30,40,50,58,65,'心理常模'),\n" + "('driveCompetence','all',84,144,27,34,42,52,62,70,77,'心理常模'),\n" + "('driveCompetence','all',144,216,35,42,50,60,70,78,85,'心理常模'),\n" + "('driveCompetence','all',216,1200,37,44,52,62,72,80,87,'心理常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('driveRelatedness','all',0,84,30,37,45,55,65,73,80,'心理常模'),\n" + "('driveRelatedness','all',84,144,27,34,42,52,62,70,77,'心理常模'),\n" + "('driveRelatedness','all',144,216,25,32,40,50,60,68,75,'心理常模'),\n" + "('driveRelatedness','all',216,1200,30,37,45,55,65,73,80,'心理常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('selfConceptOverall','all',0,84,20,27,35,45,55,63,70,'心理常模'),\n" + "('selfConceptOverall','all',84,144,30,37,45,55,65,73,80,'心理常模'),\n" + "('selfConceptOverall','all',144,216,33,40,48,58,68,76,83,'心理常模'),\n" + "('selfConceptOverall','all',216,1200,33,40,48,58,68,76,83,'心理常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('actionChildParentChild','all',0,84,35,42,50,60,70,78,85,'关系质量常模'),\n" + "('actionChildParentChild','all',84,144,33,40,48,58,68,76,83,'关系质量常模'),\n" + "('actionChildParentChild','all',144,216,30,37,45,55,65,73,80,'关系质量常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('actionSibling','all',0,84,20,27,35,45,55,63,70,'关系质量常模'),\n" + "('actionSibling','all',84,144,25,32,40,50,60,68,75,'关系质量常模'),\n" + "('actionSibling','all',144,216,30,37,45,55,65,73,80,'关系质量常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('actionRelative','all',0,84,25,32,40,50,60,68,75,'关系质量常模'),\n" + "('actionRelative','all',84,144,30,37,45,55,65,73,80,'关系质量常模'),\n" + "('actionRelative','all',144,216,33,40,48,58,68,76,83,'关系质量常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('actionTeacherStudent','all',0,84,25,32,40,50,60,68,75,'关系质量常模'),\n" + "('actionTeacherStudent','all',84,144,35,42,50,60,70,78,85,'关系质量常模'),\n" + "('actionTeacherStudent','all',144,216,37,44,52,62,72,80,87,'关系质量常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('actionChildSociality','all',0,84,23,30,38,48,58,66,73,'关系质量常模'),\n" + "('actionChildSociality','all',84,144,30,37,45,55,65,73,80,'关系质量常模'),\n" + "('actionChildSociality','all',144,216,35,42,50,60,70,78,85,'关系质量常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('actionChildInterpersonal','all',0,84,20,27,35,45,55,63,70,'关系质量常模'),\n" + "('actionChildInterpersonal','all',84,144,27,34,42,52,62,70,77,'关系质量常模'),\n" + "('actionChildInterpersonal','all',144,216,33,40,48,58,68,76,83,'关系质量常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('actionParentChild','all',216,1200,33,40,48,58,68,76,83,'关系质量常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('actionMarital','all',216,1200,30,37,45,55,65,73,80,'关系质量常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('actionParental','all',216,1200,27,34,42,52,62,70,77,'关系质量常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('actionLeadership','all',216,1200,30,37,45,55,65,73,80,'关系质量常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('actionSociality','all',216,1200,35,42,50,60,70,78,85,'关系质量常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('actionInterpersonal','all',216,1200,33,40,48,58,68,76,83,'关系质量常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('wealthSkill','all',0,84,10,17,25,35,45,53,60,'财富素养常模'),\n" + "('wealthSkill','all',84,144,20,27,35,45,55,63,70,'财富素养常模'),\n" + "('wealthSkill','all',144,216,30,37,45,55,65,73,80,'财富素养常模'),\n" + "('wealthSkill','all',216,1200,35,42,50,60,70,78,85,'财富素养常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('wealthAltruism','all',0,84,5,12,20,30,40,48,55,'财富素养常模'),\n" + "('wealthAltruism','all',84,144,15,22,30,40,50,58,65,'财富素养常模'),\n" + "('wealthAltruism','all',144,216,25,32,40,50,60,68,75,'财富素养常模'),\n" + "('wealthAltruism','all',216,1200,30,37,45,55,65,73,80,'财富素养常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('wealthIncome','all',0,84,0,7,15,25,35,43,50,'财富素养常模'),\n" + "('wealthIncome','all',84,144,10,17,25,35,45,53,60,'财富素养常模'),\n" + "('wealthIncome','all',144,216,20,27,35,45,55,63,70,'财富素养常模'),\n" + "('wealthIncome','all',216,1200,30,37,45,55,65,73,80,'财富素养常模')"); sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" + "('wealthFx','all',0,84,0,2,10,20,30,38,45,'财富素养常模'),\n" + "('wealthFx','all',84,144,5,12,20,30,40,48,55,'财富素养常模'),\n" + "('wealthFx','all',144,216,15,22,30,40,50,58,65,'财富素养常模'),\n" + "('wealthFx','all',216,1200,25,32,40,50,60,68,75,'财富素养常模')"); for (String sql : sqls) { jdbcTemplate.execute(sql); } log.info("mind/action/wealth 常模种子数据已加载 ({}条)", sqls.size()); } catch (Exception e) { log.warn("mind/action/wealth 常模种子数据初始化失败: {}", e.getMessage()); } } private void modifyColumnToText(String table, String column, String comment) { if (!tableExists(table)) { return; } try { jdbcTemplate.execute("ALTER TABLE " + table + " MODIFY COLUMN " + column + " TEXT COMMENT '" + comment + "'"); log.info("已修改{}.{}为TEXT类型", table, column); } catch (Exception e) { log.warn("修改{}.{}为TEXT失败: {}", table, column, e.getMessage()); } } private void initializeDefaultData() { // 初始化预设奖励模板 try { // 检查是否已有预设奖励 Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM rewards WHERE is_template = 1", Integer.class); if (count == null || count == 0) { // 插入预设奖励模板(child_id为NULL表示模板) jdbcTemplate.execute("INSERT INTO rewards (family_id, child_id, title, points_required, category, is_template) VALUES " + "(0, NULL, \'看动画片30分钟\', 30, \'精神类\', 1), " + "(0, NULL, \'玩平板游戏30分钟\', 40, \'特权类\', 1), " + "(0, NULL, \'去游乐场\', 100, \'物质类\', 1), " + "(0, NULL, \'买玩具\', 150, \'物质类\', 1), " + "(0, NULL, \'吃冰淇淋\', 20, \'物质类\', 1), " + "(0, NULL, \'选择晚餐\', 25, \'特权类\', 1)"); log.info("预设奖励模板已初始化"); } } catch (Exception e) { log.warn("初始化预设奖励失败: {}", e.getMessage()); } // 初始化奖励分类 try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM reward_categories WHERE family_id = 0", Integer.class); if (count == null || count == 0) { jdbcTemplate.execute("INSERT INTO reward_categories (family_id, name, icon, sort_order) VALUES " + "(0, '物质类', 'gift', 1), " + "(0, '精神类', 'star', 2), " + "(0, '特权类', 'crown', 3)"); log.info("奖励分类已初始化"); } } catch (Exception e) { log.warn("初始化奖励分类失败: {}", e.getMessage()); } // 初始化打卡里程碑 try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM streak_milestones", Integer.class); if (count == null || count == 0) { jdbcTemplate.execute("INSERT INTO streak_milestones (days, reward_points) VALUES " + "(3, 5), (7, 10), (30, 30), (100, 100)"); log.info("打卡里程碑已初始化"); } } catch (Exception e) { log.warn("初始化打卡里程碑失败: {}", e.getMessage()); } // 初始化勋章定义 try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM badge_definitions", Integer.class); if (count == null || count == 0) { jdbcTemplate.execute("INSERT INTO badge_definitions (badge_type, name, description, icon, condition_type, condition_value, reward_points) VALUES " + "('first_task', '初次完成任务', '首次完成任意任务', 'star', 'task_count', 1, 0), " + "('streak_3', '连续3天', '连续3天按时完成任务', 'fire', 'streak_days', 3, 5), " + "('streak_7', '连续7天', '连续7天按时完成任务', 'fire', 'streak_days', 7, 10), " + "('points_50', '积分达人', '一周获得50积分', 'trophy', 'weekly_points', 50, 0), " + "('task_100', '任务达人', '完成100个任务', 'medal', 'task_count', 100, 20)"); log.info("勋章定义已初始化"); } } catch (Exception e) { log.warn("初始化勋章定义失败: {}", e.getMessage()); } // 初始化小游戏数据 try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM mini_games", Integer.class); if (count == null || count == 0) { jdbcTemplate.execute("INSERT INTO mini_games (game_code, game_name, description, icon, default_duration, default_points, game_url, sort_order, status) VALUES " + "('schulte', '舒尔特方格', '训练专注力和眼动能力,找到1-25按顺序点击', '🎯', 5, 5, '/pages/games/schulte', 1, 1), " + "('1a2b', '猜数字', '猜对数字和位置获得积分,锻炼逻辑思维', '🔢', 10, 5, '/pages/games/1a2b', 2, 1), " + "('sudoku', '数独', '填充数字使每行每列每个宫格都包含1-9', '🔳', 15, 8, '/pages/games/sudoku', 3, 1)"); log.info("小游戏数据已初始化"); } } catch (Exception e) { log.warn("初始化小游戏数据失败: {}", e.getMessage()); } // 初始化默认管理员 try { // 先检查并添加role列(如果不存在) try { jdbcTemplate.execute("ALTER TABLE admins ADD COLUMN role VARCHAR(20) DEFAULT \'admin\' AFTER email"); log.info("已添加role列到admins表"); } catch (Exception e) { // 列已存在,忽略错误 } Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM admins", Integer.class); if (count == null || count == 0) { // 默认管理员: 手机号 13800138000, 密码 admin123 String encryptedPassword = org.springframework.util.DigestUtils.md5DigestAsHex("xzyj_admin_salt_admin123".getBytes()); jdbcTemplate.execute("INSERT INTO admins (username, password, real_name, phone, email, role, status) VALUES " + "(\'admin\', \'" + encryptedPassword + "\', \'系统管理员\', \'13800138000\', \'admin@xzyj.com\', \'admin\', 1)"); log.info("默认管理员已初始化: 手机号 13800138000, 密码 admin123"); } } catch (Exception e) { log.warn("初始化默认管理员失败: {}", e.getMessage()); } // 初始化成长规划师WEB管理账号 try { Integer teacherCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM admins WHERE username = 'teacher'", Integer.class); if (teacherCount == null || teacherCount == 0) { String teacherPassword = org.springframework.util.DigestUtils.md5DigestAsHex("xzyj_admin_salt_teacher123".getBytes()); jdbcTemplate.execute("INSERT INTO admins (username, password, real_name, phone, role, status) VALUES " + "('teacher', '" + teacherPassword + "', '成长规划师', '13800138003', 'teacher', 1)"); log.info("成长规划师管理账号已初始化: 用户名 teacher, 密码 teacher123"); } } catch (Exception e) { log.warn("初始化成长规划师管理账号失败: {}", e.getMessage()); } // 初始化任务模板示例数据 try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM admin_task_templates", Integer.class); if (count == null || count == 0) { jdbcTemplate.execute("INSERT INTO admin_task_templates (title, description, points, category, need_review, recommended_age, difficulty, is_active) VALUES " + "('完成作业', '按时完成学校布置的家庭作业', 10, '学习', 1, 6, 'medium', 1), " + "('阅读30分钟', '自主阅读课外书籍30分钟', 8, '阅读', 0, 5, 'easy', 1), " + "('整理房间', '收拾整理自己的房间和书桌', 6, '家务', 0, 6, 'easy', 1), " + "('洗碗', '饭后帮忙清洗餐具', 5, '家务', 1, 8, 'medium', 1), " + "('户外运动1小时', '进行跑步、球类等户外运动', 12, '运动', 0, 7, 'hard', 1), " + "('练习书法', '练习毛笔或硬笔书法20分钟', 7, '学习', 0, 7, 'medium', 1), " + "('早睡早起', '晚上9点前睡觉,早上7点前起床', 5, '生活习惯', 0, 5, 'easy', 1), " + "('背诵古诗', '背诵一首古诗词', 8, '学习', 1, 6, 'medium', 1)"); log.info("任务模板示例数据已初始化"); } } catch (Exception e) { log.warn("初始化任务模板失败: {}", e.getMessage()); } // 初始化街道数据示例(广东省广州市) try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM streets", Integer.class); if (count == null || count == 0) { // 省 jdbcTemplate.execute("INSERT INTO streets (province, province_code, full_name, level, parent_id, sort_order) VALUES " + "('广东省', '44', '广东省', 1, NULL, 1)"); // 市 jdbcTemplate.execute("INSERT INTO streets (province, province_code, city, city_code, full_name, level, parent_id, sort_order) VALUES " + "('广东省', '44', '广州市', '4401', '广东省广州市', 2, 1, 1), " + "('广东省', '44', '深圳市', '4403', '广东省深圳市', 2, 1, 2), " + "('广东省', '44', '珠海市', '4404', '广东省珠海市', 2, 1, 3)"); // 区 jdbcTemplate.execute("INSERT INTO streets (province, province_code, city, city_code, district, district_code, full_name, level, parent_id, sort_order) VALUES " + "('广东省', '44', '广州市', '4401', '天河区', '440106', '广东省广州市天河区', 3, 2, 1), " + "('广东省', '44', '广州市', '4401', '越秀区', '440104', '广东省广州市越秀区', 3, 2, 2), " + "('广东省', '44', '广州市', '4401', '番禺区', '440113', '广东省广州市番禺区', 3, 2, 3), " + "('广东省', '44', '深圳市', '4403', '南山区', '440305', '广东省深圳市南山区', 3, 3, 1), " + "('广东省', '44', '深圳市', '4403', '福田区', '440304', '广东省深圳市福田区', 3, 3, 2)"); // 街道 jdbcTemplate.execute("INSERT INTO streets (province, province_code, city, city_code, district, district_code, street, street_code, full_name, level, parent_id, sort_order) VALUES " + "('广东省', '44', '广州市', '4401', '天河区', '440106', '天河南街道', '440106001', '广东省广州市天河区天河南街道', 4, 4, 1), " + "('广东省', '44', '广州市', '4401', '天河区', '440106', '沙河街道', '440106002', '广东省广州市天河区沙河街道', 4, 4, 2), " + "('广东省', '44', '广州市', '4401', '越秀区', '440104', '东山街道', '440104001', '广东省广州市越秀区东山街道', 4, 5, 1), " + "('广东省', '44', '广州市', '4401', '番禺区', '440113', '市桥街道', '440113001', '广东省广州市番禺区市桥街道', 4, 6, 1), " + "('广东省', '44', '深圳市', '4403', '南山区', '440305', '南山街道', '440305001', '广东省深圳市南山区南山街道', 4, 7, 1), " + "('广东省', '44', '深圳市', '4403', '福田区', '440304', '福田街道', '440304001', '广东省深圳市福田区福田街道', 4, 8, 1)"); log.info("街道示例数据已初始化"); } } catch (Exception e) { log.warn("初始化街道数据失败: {}", e.getMessage()); } try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM admins", Integer.class); if (count == null || count == 0) { // 默认管理员: 手机号 13800138000, 密码 admin123 String encryptedPassword = org.springframework.util.DigestUtils.md5DigestAsHex("xzyj_admin_salt_admin123".getBytes()); jdbcTemplate.execute("INSERT INTO admins (username, password, real_name, phone, email, role, status) VALUES " + "('admin', '" + encryptedPassword + "', '系统管理员', '13800138000', 'admin@xzyj.com', 'admin', 1)"); log.info("默认管理员已初始化: 手机号 13800138000, 密码 admin123"); } } catch (Exception e) { log.warn("初始化任务模板失败: {}", e.getMessage()); } // 初始化 users 表测试用户 try { // 创建默认家庭(如果不存在) Integer familyCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM families WHERE id = 1", Integer.class); if (familyCount == null || familyCount == 0) { jdbcTemplate.execute("INSERT INTO families (id, name, invite_code) VALUES (1, '默认家庭', '888888')"); log.info("默认家庭已创建"); } // 密码加密: MD5("xzyj_salt_" + password) String adminPassword = org.springframework.util.DigestUtils.md5DigestAsHex("xzyj_salt_admin123".getBytes()); String teacherPassword = org.springframework.util.DigestUtils.md5DigestAsHex("xzyj_salt_teacher123".getBytes()); // 创建管理员用户(管理员角色,密码 admin123) Integer adminUserCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM users WHERE phone = '13800138000'", Integer.class); if (adminUserCount == null || adminUserCount == 0) { jdbcTemplate.execute("INSERT INTO users (openid, family_id, role, phone, nickname, real_name, password) VALUES " + "('seed_admin_13800138000', 1, 'admin', '13800138000', '系统管理员', '系统管理员', '" + adminPassword + "')"); log.info("测试管理员用户已创建: 13800138000 (admin角色, 密码 admin123)"); } // 创建测试教师用户 Integer teacherUserCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM users WHERE phone = '13800138001'", Integer.class); if (teacherUserCount == null || teacherUserCount == 0) { jdbcTemplate.execute("INSERT INTO users (openid, family_id, role, phone, nickname, real_name, password) VALUES " + "('seed_teacher_13800138001', 1, 'teacher', '13800138001', '测试教师', '测试教师', '" + teacherPassword + "')"); log.info("测试教师用户已创建: 13800138001 (教师角色, 密码 teacher123)"); } } catch (Exception e) { log.warn("初始化测试用户失败: {}", e.getMessage()); } // 初始化星座配置种子数据 try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM zodiac_config", Integer.class); if (count == null || count == 0) { jdbcTemplate.execute("INSERT INTO zodiac_config (zodiac_code, zodiac_name, element, start_month, start_day, end_month, end_day, mind_base, wisdom_base, body_modifier, action_modifier, wealth_modifier) VALUES " + "('aries', '白羊座', '火', 3, 21, 4, 19, 65, 70, 1.10, 1.05, 0.95), " + "('taurus', '金牛座', '土', 4, 20, 5, 20, 60, 65, 1.05, 1.10, 1.15), " + "('gemini', '双子座', '金', 5, 21, 6, 21, 70, 80, 0.95, 1.15, 0.90), " + "('cancer', '巨蟹座', '水', 6, 22, 7, 22, 75, 60, 1.00, 0.90, 1.05), " + "('leo', '狮子座', '火', 7, 23, 8, 22, 65, 75, 1.15, 1.00, 1.10), " + "('virgo', '处女座', '土', 8, 23, 9, 22, 55, 85, 0.95, 1.05, 1.00), " + "('libra', '天秤座', '金', 9, 23, 10, 23, 70, 75, 0.90, 0.95, 1.10), " + "('scorpio', '天蝎座', '水', 10, 24, 11, 22, 80, 70, 1.10, 1.00, 1.15), " + "('sagittarius', '射手座', '火', 11, 23, 12, 21, 60, 65, 1.15, 1.15, 0.95), " + "('capricorn', '摩羯座', '土', 12, 22, 1, 19, 55, 75, 1.05, 1.10, 1.20), " + "('aquarius', '水瓶座', '金', 1, 20, 2, 18, 75, 85, 0.95, 0.90, 1.00), " + "('pisces', '双鱼座', '水', 2, 19, 3, 20, 80, 60, 0.90, 0.95, 0.95)"); log.info("星座配置种子数据已初始化"); } } catch (Exception e) { log.warn("初始化星座配置失败: {}", e.getMessage()); } // 初始化八字配置种子数据(十天干 + 十二地支) try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM bazi_config", Integer.class); if (count == null || count == 0) { jdbcTemplate.execute("INSERT INTO bazi_config (stem_or_branch, type, element, yin_yang, mind_base, wisdom_base, body_modifier, action_modifier, wealth_modifier) VALUES " + "('甲', 'stem', '木', 'yang', 60, 75, 1.05, 1.10, 1.00), " + "('乙', 'stem', '木', 'yin', 65, 70, 0.95, 1.05, 1.00), " + "('丙', 'stem', '火', 'yang', 75, 65, 1.10, 1.00, 0.95), " + "('丁', 'stem', '火', 'yin', 70, 60, 1.05, 0.95, 1.00), " + "('戊', 'stem', '土', 'yang', 60, 65, 1.15, 1.05, 1.10), " + "('己', 'stem', '土', 'yin', 55, 60, 1.10, 1.00, 1.15), " + "('庚', 'stem', '金', 'yang', 65, 80, 1.05, 1.10, 1.05), " + "('辛', 'stem', '金', 'yin', 60, 75, 0.95, 1.00, 1.10), " + "('壬', 'stem', '水', 'yang', 70, 70, 1.00, 0.95, 1.15), " + "('癸', 'stem', '水', 'yin', 75, 65, 0.95, 0.90, 1.10), " + "('子', 'branch', '水', 'yang', 70, 65, 1.00, 0.95, 1.05), " + "('丑', 'branch', '土', 'yin', 55, 60, 1.10, 1.05, 1.10), " + "('寅', 'branch', '木', 'yang', 65, 75, 1.15, 1.10, 1.00), " + "('卯', 'branch', '木', 'yin', 60, 70, 1.05, 1.05, 0.95), " + "('辰', 'branch', '土', 'yang', 60, 65, 1.10, 1.00, 1.10), " + "('巳', 'branch', '火', 'yin', 75, 70, 1.05, 0.95, 1.00), " + "('午', 'branch', '火', 'yang', 80, 65, 1.15, 1.00, 0.95), " + "('未', 'branch', '土', 'yin', 55, 60, 1.05, 1.00, 1.05), " + "('申', 'branch', '金', 'yang', 65, 80, 1.00, 1.10, 1.05), " + "('酉', 'branch', '金', 'yin', 60, 75, 0.95, 1.05, 1.10), " + "('戌', 'branch', '土', 'yang', 60, 65, 1.10, 1.00, 1.15), " + "('亥', 'branch', '水', 'yin', 70, 60, 1.00, 0.95, 1.05)"); log.info("八字配置种子数据已初始化"); } } catch (Exception e) { log.warn("初始化八字配置失败: {}", e.getMessage()); } // 初始化血型配置种子数据 try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM blood_type_config", Integer.class); if (count == null || count == 0) { jdbcTemplate.execute("INSERT INTO blood_type_config (blood_type, description, mind_base, wisdom_base, body_modifier, action_modifier, wealth_modifier) VALUES " + "('A', '认真负责、严谨细致,但易焦虑', 70, 75, 0.95, 1.00, 1.05), " + "('B', '乐观开朗、好奇心强,但缺乏耐心', 65, 70, 1.05, 1.10, 0.95), " + "('AB', '理性冷静、适应力强,但优柔寡断', 75, 80, 1.00, 0.95, 1.10), " + "('O', '自信果断、行动力强,但固执己见', 60, 65, 1.15, 1.15, 1.15)"); log.info("血型配置种子数据已初始化"); } } catch (Exception e) { log.warn("初始化血型配置失败: {}", e.getMessage()); } // 初始化优惠券种子数据 try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM coupon", Integer.class); if (count == null || count == 0) { jdbcTemplate.update( "INSERT INTO coupon (name, type, value, min_spend, applicable_to, valid_from, valid_until, total_count, used_count, grant_type, grant_level_code) " + "VALUES (?, ?, ?, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), -1, 0, 'REGISTER', NULL)", "新用户专享10元券", "FIXED", 1000, 0, "ALL"); jdbcTemplate.update( "INSERT INTO coupon (name, type, value, min_spend, applicable_to, valid_from, valid_until, total_count, used_count, grant_type, grant_level_code) " + "VALUES (?, ?, ?, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), -1, 0, NULL, NULL)", "会员升级50元券", "FIXED", 5000, 9900, "MEMBERSHIP"); // JOIN 赠券模板:开通家庭会员赠 20 元券 jdbcTemplate.update( "INSERT INTO coupon (name, type, value, min_spend, applicable_to, valid_from, valid_until, total_count, used_count, grant_type, grant_level_code, grant_quantity) " + "VALUES (?, ?, ?, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), -1, 0, 'JOIN', 'FAMILY', 1)"); // JOIN 赠券模板:开通 Premium 会员赠 50 元券 jdbcTemplate.update( "INSERT INTO coupon (name, type, value, min_spend, applicable_to, valid_from, valid_until, total_count, used_count, grant_type, grant_level_code, grant_quantity) " + "VALUES (?, ?, ?, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), -1, 0, 'JOIN', 'PREMIUM', 1)"); log.info("优惠券种子数据已初始化(含 JOIN 赠券模板)"); } // COUPON-REG:幂等修正存量库——将「新用户专享10元券」模板标记为 REGISTER 自动发放 // (存量库种子创建时 grant_type 为 NULL,改后无需 DDL,仅数据修正) int fixed = jdbcTemplate.update( "UPDATE coupon SET grant_type = 'REGISTER' " + "WHERE name = '新用户专享10元券' AND (grant_type IS NULL OR grant_type = '')"); if (fixed > 0) { log.info("已标记 {} 张存量「新用户专享10元券」模板为 REGISTER 自动发放", fixed); } } catch (Exception e) { log.warn("初始化优惠券数据失败: {}", e.getMessage()); } // 初始化成长规划师模块相关表 initGuideModuleTables(jdbcTemplate); } private void initGuideModuleTables(JdbcTemplate jdbcTemplate) { // 服务类型表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS service_types (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "name VARCHAR(100) NOT NULL COMMENT '类型名称', " + "icon VARCHAR(50) COMMENT '图标', " + "description VARCHAR(500) COMMENT '描述', " + "sort_order INT DEFAULT 0 COMMENT '排序', " + "status VARCHAR(20) DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/DISABLED', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + ")"); } catch (Exception e) { log.warn("创建service_types表失败: {}", e.getMessage()); } // 服务内容表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS service_contents (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "type_id BIGINT NOT NULL COMMENT '关联服务类型', " + "title VARCHAR(200) NOT NULL COMMENT '内容标题', " + "content TEXT COMMENT '富文本内容', " + "duration INT COMMENT '建议时长(分钟)', " + "age_range VARCHAR(50) COMMENT '适用年龄', " + "price INT COMMENT '参考价格(分)', " + "status VARCHAR(20) DEFAULT 'DRAFT' COMMENT '状态:DRAFT/ACTIVE', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + ")"); } catch (Exception e) { log.warn("创建service_contents表失败: {}", e.getMessage()); } // 套餐模板表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_package_templates (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "name VARCHAR(100) NOT NULL COMMENT '模板名称', " + "validity_months INT COMMENT '有效期(月)', " + "reference_price INT COMMENT '参考价格(分)', " + "min_price INT COMMENT '最低售价(分)', " + "description VARCHAR(500) COMMENT '说明', " + "status VARCHAR(20) DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/DISABLED', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + ")"); } catch (Exception e) { log.warn("创建guide_package_templates表失败: {}", e.getMessage()); } // 套餐模板服务项表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS package_template_services (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "template_id BIGINT NOT NULL COMMENT '关联模板', " + "content_id BIGINT NOT NULL COMMENT '关联服务内容', " + "quantity INT DEFAULT 1 COMMENT '数量', " + "unit VARCHAR(20) COMMENT '单位' " + ")"); } catch (Exception e) { log.warn("创建package_template_services表失败: {}", e.getMessage()); } // 成长规划师套餐表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_packages (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "teacher_id BIGINT NOT NULL COMMENT '成长规划师ID', " + "template_id BIGINT COMMENT '关联模板', " + "name VARCHAR(100) NOT NULL COMMENT '套餐名称', " + "price INT NOT NULL COMMENT '销售价格(分)', " + "validity_months INT COMMENT '有效期(月)', " + "description VARCHAR(500) COMMENT '套餐说明', " + "status VARCHAR(20) DEFAULT 'DRAFT' COMMENT '状态:DRAFT/PUBLISHED/PAUSED', " + "sales_count INT DEFAULT 0 COMMENT '销量', " + "share_code VARCHAR(50) COMMENT '分享码', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + ")"); } catch (Exception e) { log.warn("创建guide_packages表失败: {}", e.getMessage()); } // 迁移:guide_packages 表添加缺失列 try { jdbcTemplate.execute("ALTER TABLE guide_packages ADD COLUMN guide_id BIGINT COMMENT '成长规划师ID' AFTER id"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE guide_packages ADD COLUMN package_id BIGINT COMMENT '关联模板' AFTER guide_id"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE guide_packages ADD COLUMN commission_rate INT COMMENT '佣金比例(bps,100=1%)' AFTER package_id"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE guide_packages MODIFY COLUMN teacher_id BIGINT NULL"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE guide_packages MODIFY COLUMN template_id BIGINT NULL"); } catch (Exception e) { } // 迁移:guide_packages 表添加 level 列(GuidePackage 实体需要) try { jdbcTemplate.execute("ALTER TABLE guide_packages ADD COLUMN level VARCHAR(20) COMMENT '套餐等级' AFTER name"); } catch (Exception e) { } // 迁移:guide_packages 表添加 validity_days 列 try { jdbcTemplate.execute("ALTER TABLE guide_packages ADD COLUMN validity_days INT DEFAULT 0 COMMENT '有效期天数' AFTER validity_months"); } catch (Exception e) { } // 迁移:guide_packages 表添加 sort_order 列 try { jdbcTemplate.execute("ALTER TABLE guide_packages ADD COLUMN sort_order INT DEFAULT 0 COMMENT '排序' AFTER status"); } catch (Exception e) { } // 成长规划师套餐服务项表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_package_services (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "package_id BIGINT NOT NULL COMMENT '关联套餐', " + "content_id BIGINT NOT NULL COMMENT '关联服务内容', " + "quantity INT DEFAULT 1 COMMENT '数量', " + "unit VARCHAR(20) COMMENT '单位' " + ")"); } catch (Exception e) { log.warn("创建guide_package_services表失败: {}", e.getMessage()); } // 订单表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_orders (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "order_no VARCHAR(50) NOT NULL COMMENT '订单号', " + "teacher_id BIGINT NOT NULL COMMENT '成长规划师ID', " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "package_id BIGINT NOT NULL COMMENT '套餐ID', " + "amount INT NOT NULL COMMENT '金额(分)', " + "status VARCHAR(20) DEFAULT 'PENDING' COMMENT '状态:PENDING/PAID/COMPLETED/REFUNDED', " + "pay_time DATETIME COMMENT '支付时间', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + ")"); } catch (Exception e) { log.warn("创建guide_orders表失败: {}", e.getMessage()); } // 佣金明细表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_commissions (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "teacher_id BIGINT NOT NULL COMMENT '成长规划师ID', " + "order_id BIGINT NOT NULL COMMENT '订单ID', " + "amount INT NOT NULL COMMENT '佣金金额(分)', " + "rate INT COMMENT '佣金比例(bps,100=1%)', " + "status VARCHAR(20) DEFAULT 'PENDING' COMMENT '状态:PENDING/COMPLETED', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP" + ")"); } catch (Exception e) { log.warn("创建guide_commissions表失败: {}", e.getMessage()); } // 服务记录表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_service_logs (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "teacher_id BIGINT NOT NULL COMMENT '成长规划师ID', " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "order_id BIGINT COMMENT '订单ID', " + "service_content_id BIGINT NOT NULL COMMENT '服务内容ID', " + "notes VARCHAR(500) COMMENT '服务记录', " + "duration INT COMMENT '服务时长(分钟)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP" + ")"); } catch (Exception e) { log.warn("创建guide_service_logs表失败: {}", e.getMessage()); } // 成长规划师邀请码表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_invite_codes (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "guide_id BIGINT NOT NULL COMMENT '成长规划师用户ID', " + "code VARCHAR(50) NOT NULL UNIQUE COMMENT '邀请码', " + "status VARCHAR(20) DEFAULT 'active' COMMENT '状态', " + "used_by_user_id BIGINT COMMENT '使用该邀请码的用户ID', " + "used_at DATETIME COMMENT '使用时间', " + "expire_at DATETIME COMMENT '过期时间', " + "use_count INT DEFAULT 0 COMMENT '使用次数', " + "max_use_count INT COMMENT '最大使用次数', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + ")"); } catch (Exception e) { log.warn("创建guide_invite_codes表失败: {}", e.getMessage()); } // 迁移29: assessment_orders表添加appointment_id字段 try { jdbcTemplate.execute("ALTER TABLE assessment_orders ADD COLUMN appointment_id BIGINT COMMENT '关联的预约ID'"); log.info("已添加appointment_id列到assessment_orders表"); } catch (Exception e) { // 列已存在,忽略错误 } // 财富维度: 财商打卡记录表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS finance_checkins (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "child_id BIGINT NULL COMMENT '孩子ID,家长本人打卡时为NULL', " + "checkin_type VARCHAR(20) NOT NULL COMMENT '打卡类型: daily_plan/saving/expense/review', " + "type VARCHAR(10) DEFAULT 'expense' COMMENT '类型: income(收入)/expense(支出)', " + "checkin_date DATE NOT NULL COMMENT '打卡日期', " + "amount INT COMMENT '金额(分,收支记录)', " + "category VARCHAR(50) COMMENT '收支分类', " + "note VARCHAR(200) COMMENT '备注', " + "mood VARCHAR(20) COMMENT '心情', " + "earned_energy INT DEFAULT 0 COMMENT '获得的能量值', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_user_date (user_id, checkin_date), " + "INDEX idx_child (child_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='财商打卡记录表'"); log.info("已创建finance_checkins表"); } catch (Exception e) { log.warn("创建finance_checkins表失败: {}", e.getMessage()); } // 财富维度: 保单管理表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS insurance_policies (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "child_id BIGINT COMMENT '关联孩子ID', " + "policy_name VARCHAR(100) NOT NULL COMMENT '保单名称', " + "insurance_company VARCHAR(100) COMMENT '保险公司', " + "policy_type VARCHAR(50) COMMENT '保单类型: medical/life/accident/education/endowment', " + "insured_person VARCHAR(50) NOT NULL COMMENT '被保人姓名', " + "premium INT COMMENT '保费(分)', " + "sum_insured INT COMMENT '保额(分)', " + "start_date DATE COMMENT '生效日期', " + "end_date DATE COMMENT '到期日期', " + "renewal_type VARCHAR(20) DEFAULT 'annual' COMMENT '续费类型: annual/monthly/single', " + "status VARCHAR(20) DEFAULT 'active' COMMENT '状态: active/expired/cancelled', " + "policy_no VARCHAR(100) COMMENT '保单号', " + "remark VARCHAR(500) COMMENT '备注', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_user (user_id), " + "INDEX idx_child (child_id), " + "INDEX idx_status (status)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='保单管理表'"); log.info("已创建insurance_policies表"); } catch (Exception e) { log.warn("创建insurance_policies表失败: {}", e.getMessage()); } // ==================== 健康检测报告模块 ============= // 健康报告表(体检报告/菌群检测报告) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_reports (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "family_id BIGINT COMMENT '家庭ID', " + "report_type VARCHAR(32) NOT NULL COMMENT '报告类型: physical_exam(体检)/gut_flora(菌群检测)', " + "report_date DATE COMMENT '报告日期', " + "overall_score INT COMMENT '健康总分(0-100)', " + "gut_health_score INT COMMENT '菌群健康分', " + "chronic_disease_score INT COMMENT '慢病控制分', " + "nutrition_score INT COMMENT '营养均衡分', " + "gut_age VARCHAR(32) COMMENT '肠道预测年龄', " + "gut_type VARCHAR(50) COMMENT '肠型', " + "file_url VARCHAR(500) COMMENT '报告文件URL', " + "status VARCHAR(20) DEFAULT 'active' COMMENT '状态: active/archived', " + "remark VARCHAR(500) COMMENT '备注', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_user_id (user_id), " + "INDEX idx_family_id (family_id), " + "INDEX idx_report_type (report_type)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康检测报告表'"); log.info("已创建health_reports表"); } catch (Exception e) { log.warn("创建health_reports表可能已存在: {}", e.getMessage()); } // 健康指标明细表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_indicators (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "report_id BIGINT NOT NULL COMMENT '关联报告ID', " + "category VARCHAR(50) COMMENT '指标分类: 营养状况/疾病风险/肠道屏障/神经递质/...', " + "indicator_name VARCHAR(100) NOT NULL COMMENT '指标名称', " + "indicator_value VARCHAR(100) COMMENT '检测值', " + "unit VARCHAR(50) COMMENT '单位', " + "ref_range VARCHAR(200) COMMENT '参考范围', " + "status VARCHAR(50) COMMENT '评估状态: 正常/偏低/偏高/过多/注意/低风险/未检出', " + "sort_order INT DEFAULT 0 COMMENT '显示排序', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_report_id (report_id), " + "INDEX idx_category (category)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康指标明细表'"); log.info("已创建health_indicators表"); } catch (Exception e) { log.warn("创建health_indicators表可能已存在: {}", e.getMessage()); } // ---------- 健康报告表 新增菌群摘要字段 ---------- try { jdbcTemplate.execute("ALTER TABLE health_reports " + "ADD COLUMN gut_balance_score INT COMMENT '菌群平衡分(0-100)', " + "ADD COLUMN gut_diversity_score INT COMMENT '菌群多样性分(0-100)', " + "ADD COLUMN beneficial_bacteria_score INT COMMENT '有益菌分(0-100)', " + "ADD COLUMN harmful_bacteria_score INT COMMENT '有害菌分(0-100)', " + "ADD COLUMN core_species_score INT COMMENT '核心菌属分(0-100)', " + "ADD COLUMN health_rating VARCHAR(100) COMMENT '总分评级,如亚健康/优秀等'"); log.info("已为health_reports表添加6个菌群摘要字段"); } catch (Exception e) { log.warn("为health_reports表添加字段可能已存在: {}", e.getMessage()); } ensureColumn("health_reports", "subject_id", "BIGINT COMMENT '报告主体用户ID(关联users表,为null表示未绑定)'"); log.info("成长规划师模块、财富维度及健康检测报告数据库表初始化完成"); // 健康打卡表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_checkins (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT COMMENT '用户ID(家长)', " + "child_id BIGINT COMMENT '孩子ID', " + "checkin_date DATE COMMENT '打卡日期', " + "behaviors TEXT COMMENT '打卡行为JSON数组', " + "note VARCHAR(500) COMMENT '备注', " + "mood VARCHAR(20) COMMENT '心情: happy/normal/sad/angry/sick', " + "earned_energy INT DEFAULT 5 COMMENT '获得能量值', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_child_id (child_id), " + "INDEX idx_checkin_date (checkin_date)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康打卡表'"); log.info("已创建health_checkins表"); } catch (Exception e) { log.warn("创建health_checkins表可能已存在: {}", e.getMessage()); } // 迁移31: 创建联系人表 // 创建文章阅读记录表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS article_reading_records (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT COMMENT '用户ID', " + "child_id BIGINT NOT NULL COMMENT '孩子ID', " + "content VARCHAR(500) COMMENT '阅读内容摘要', " + "duration_seconds INT DEFAULT 0 COMMENT '阅读时长(秒)', " + "read_at DATETIME COMMENT '阅读时间', " + "created_at DATETIME, " + "INDEX idx_child_id (child_id), " + "INDEX idx_read_at (read_at)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章阅读记录表'"); log.info("已创建article_reading_records表"); } catch (Exception e) { log.warn("创建article_reading_records表可能已存在: {}", e.getMessage()); } // 迁移32: products表添加配送/类目/销售字段(商品编辑表单对齐) ensureColumn("products", "delivery_method", "TINYINT DEFAULT 1 COMMENT '配送方式: 1=快递 2=自提 3=两者皆可'"); ensureColumn("products", "category_id", "BIGINT COMMENT '商品类目ID'"); ensureColumn("products", "distribution_system_id", "BIGINT COMMENT '销售体系ID'"); // 迁移33: 创建供应商销售体系表 + product_orders扩展字段 // 迁移222: health_reports 添加 shannon_index, payload_json 列并创建 health_report_edit_logs 表 try { jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN shannon_index VARCHAR(32) DEFAULT NULL COMMENT '香农指数'"); log.info("已添加shannon_index列到health_reports表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN payload_json LONGTEXT COMMENT '报告完整载荷快照'"); log.info("已添加payload_json列到health_reports表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_report_edit_logs (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "report_id BIGINT NOT NULL, " + "user_id BIGINT, " + "report_type VARCHAR(32), " + "action VARCHAR(20) COMMENT 'create/edit', " + "before_payload LONGTEXT, " + "after_payload LONGTEXT, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康报告修改留痕'"); log.info("已创建health_report_edit_logs表"); } catch (Exception e) { // 表已存在,忽略错误 } ensureColumn("product_orders", "supply_system_id", "BIGINT COMMENT '所属供应商体系ID'"); ensureColumn("product_orders", "supplier_id", "BIGINT COMMENT '收款供应商用户ID'"); ensureColumn("product_orders", "supply_commission", "INT COMMENT '销售佣金(分)'"); try { jdbcTemplate.execute("ALTER TABLE product_orders ADD KEY idx_supply_system_id (supply_system_id)"); log.info("已添加idx_supply_system_id索引到product_orders表"); } catch (Exception e) { log.warn("添加idx_supply_system_id索引可能已存在: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_system (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "name VARCHAR(100) NOT NULL COMMENT '体系名称', " + "admin_id BIGINT NOT NULL COMMENT '管理员用户ID', " + "settlement_period_days INT NOT NULL DEFAULT 30 COMMENT '账期(天)', " + "platform_profit_rate DECIMAL(5,2) NOT NULL DEFAULT 0 COMMENT '平台留利比例(%)', " + "description VARCHAR(500) COMMENT '描述', " + "status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active/disabled', " + "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "KEY idx_admin_id (admin_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商体系'"); log.info("已创建supply_system表"); } catch (Exception e) { log.warn("创建supply_system表可能已存在: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_system_member (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "system_id BIGINT NOT NULL COMMENT '体系ID', " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "role VARCHAR(20) NOT NULL DEFAULT 'member' COMMENT '角色: admin/member', " + "status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active/inactive', " + "joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_system_user (system_id, user_id), " + "KEY idx_user_id (user_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='体系成员'"); log.info("已创建supply_system_member表"); } catch (Exception e) { log.warn("创建supply_system_member表可能已存在: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_settlement (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "system_id BIGINT NOT NULL COMMENT '体系ID', " + "period_start DATE NOT NULL COMMENT '结算周期开始', " + "period_end DATE NOT NULL COMMENT '结算周期结束', " + "total_sales INT NOT NULL DEFAULT 0 COMMENT '销售总额(分)', " + "platform_profit INT NOT NULL DEFAULT 0 COMMENT '平台留利金额(分)', " + "supplier_payout INT NOT NULL DEFAULT 0 COMMENT '供应商实付(分)', " + "status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '状态: pending/settled', " + "settled_at DATETIME COMMENT '结算时间', " + "remark VARCHAR(500) COMMENT '备注', " + "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "KEY idx_system_id (system_id), " + "KEY idx_period (period_start, period_end)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='结算记录'"); log.info("已创建supply_settlement表"); } catch (Exception e) { log.warn("创建supply_settlement表可能已存在: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_settlement_detail (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "settlement_id BIGINT NOT NULL COMMENT '结算ID', " + "order_id BIGINT NOT NULL COMMENT '订单ID', " + "order_no VARCHAR(64) NOT NULL COMMENT '订单号', " + "product_name VARCHAR(200) NOT NULL COMMENT '商品名称', " + "amount INT NOT NULL COMMENT '订单金额(分)', " + "platform_fee INT NOT NULL COMMENT '平台留利(分)', " + "supplier_id BIGINT COMMENT '收款供应商用户ID', " + "supplier_name VARCHAR(100) COMMENT '收款供应商名称', " + "payout INT NOT NULL COMMENT '供应商实付(分)', " + "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "KEY idx_settlement_id (settlement_id), " + "KEY idx_order_id (order_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='结算明细'"); log.info("已创建supply_settlement_detail表"); } catch (Exception e) { log.warn("创建supply_settlement_detail表可能已存在: {}", e.getMessage()); } // 迁移: growth_records表添加来源类型/去重/补充字段 try { jdbcTemplate.execute("ALTER TABLE growth_records ADD COLUMN source_type VARCHAR(20) DEFAULT 'manual' COMMENT '来源类型: manual/platform_purchase/self_upload/supplement'"); log.info("已添加source_type列到growth_records表"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE growth_records ADD COLUMN source_file_url VARCHAR(500) COMMENT '原始报告文件URL'"); log.info("已添加source_file_url列到growth_records表"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE growth_records ADD COLUMN order_id BIGINT COMMENT '关联订单ID'"); log.info("已添加order_id列到growth_records表"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE growth_records ADD COLUMN report_date DATETIME COMMENT '测评/报告日期'"); log.info("已添加report_date列到growth_records表"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE growth_records ADD COLUMN parent_record_id BIGINT COMMENT '关联的原始档案ID(补充档案专用)'"); log.info("已添加parent_record_id列到growth_records表"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE growth_records ADD COLUMN is_latest TINYINT(1) DEFAULT 1 COMMENT '是否同组最新记录'"); log.info("已添加is_latest列到growth_records表"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE growth_records ADD COLUMN dedup_hash VARCHAR(64) COMMENT '去重哈希值(SHA256)'"); log.info("已添加dedup_hash列到growth_records表"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE growth_records ADD COLUMN parsed_data TEXT COMMENT '上传报告的解析结果(JSON)'"); log.info("已添加parsed_data列到growth_records表"); } catch (Exception e) { } try { jdbcTemplate.execute("CREATE UNIQUE INDEX IF NOT EXISTS uk_growth_dedup_hash ON growth_records(dedup_hash)"); log.info("已添加uk_growth_dedup_hash唯一索引到growth_records表"); } catch (Exception e) { try { jdbcTemplate.execute("ALTER TABLE growth_records ADD UNIQUE INDEX uk_growth_dedup_hash (dedup_hash)"); log.info("已通过ALTER TABLE添加uk_growth_dedup_hash唯一索引"); } catch (Exception e2) { log.warn("添加uk_growth_dedup_hash索引可能已存在: {}", e2.getMessage()); } } // 初始化成长任务种子数据 try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM growth_task", Integer.class); if (count == null || count == 0) { jdbcTemplate.execute("INSERT INTO growth_task (type, title, description, reward_points, reward_energy, target_value, task_key, enabled, sort_order, created_at) VALUES " + "('DAILY', '每日签到', '完成健康打卡签到', 20, 3, 1, 'DAILY_CHECKIN', 1, 1, NOW()), " + "('DAILY', '完成任务', '完成一个任务', 30, 5, 1, 'DAILY_TASK', 1, 2, NOW()), " + "('DAILY', '每日分享', '分享小程序给好友', 10, 2, 1, 'DAILY_SHARE', 1, 3, NOW()), " + "('DAILY', 'AI对话', '与AI助手对话一次', 15, 3, 1, 'DAILY_AI', 1, 4, NOW())"); log.info("成长任务种子数据已初始化"); } } catch (Exception e) { log.warn("初始化成长任务种子数据失败: {}", e.getMessage()); } // 迁移34: dan_assessment_results表添加家庭成员/EMI心理/大五人格/孩子快照字段 ensureColumn("dan_assessment_results", "family_member_id", "BIGINT COMMENT '家庭成员ID'"); ensureColumn("dan_assessment_results", "family_member_name", "VARCHAR(50) COMMENT '成员姓名快照'"); ensureColumn("dan_assessment_results", "family_member_birthday", "DATE COMMENT '成员出生日期快照'"); ensureColumn("dan_assessment_results", "family_member_gender", "VARCHAR(10) COMMENT '成员性别快照'"); ensureColumn("dan_assessment_results", "family_member_age", "INT COMMENT '成员年龄快照'"); ensureColumn("dan_assessment_results", "emotion_score", "INT COMMENT '情绪商数(EMI)'"); ensureColumn("dan_assessment_results", "resilience_score", "INT COMMENT '心理韧性(EMI)'"); ensureColumn("dan_assessment_results", "stress_coping_score", "INT COMMENT '压力应对(EMI)'"); ensureColumn("dan_assessment_results", "self_awareness_score", "INT COMMENT '自我认知(EMI)'"); ensureColumn("dan_assessment_results", "openness_score", "INT COMMENT '开放性(大五人格)'"); ensureColumn("dan_assessment_results", "conscientiousness_score", "INT COMMENT '尽责性(大五人格)'"); ensureColumn("dan_assessment_results", "extraversion_score", "INT COMMENT '外向性(大五人格)'"); ensureColumn("dan_assessment_results", "agreeableness_score", "INT COMMENT '宜人性(大五人格)'"); ensureColumn("dan_assessment_results", "neuroticism_score", "INT COMMENT '神经质(大五人格)'"); ensureColumn("dan_assessment_results", "big_five_overall_score", "INT COMMENT '大五人格综合分'"); ensureColumn("dan_assessment_results", "child_name", "VARCHAR(50) COMMENT '孩子姓名快照'"); ensureColumn("dan_assessment_results", "child_birthday", "DATE COMMENT '出生日期快照'"); ensureColumn("dan_assessment_results", "child_gender", "VARCHAR(10) COMMENT '性别快照'"); ensureColumn("dan_assessment_results", "child_age", "INT COMMENT '年龄快照'"); ensureColumn("dan_assessment_results", "school", "VARCHAR(100) COMMENT '学校快照'"); ensureColumn("dan_assessment_results", "grade", "VARCHAR(20) COMMENT '年级快照'"); ensureColumn("dan_assessment_results", "grade_class", "VARCHAR(20) COMMENT '班级快照'"); ensureColumn("dan_assessment_results", "height", "INT COMMENT '身高(CM)快照'"); ensureColumn("dan_assessment_results", "weight", "INT COMMENT '体重(KG)快照'"); ensureColumn("dan_assessment_results", "family_status", "VARCHAR(50) COMMENT '家庭状况快照'"); ensureColumn("dan_assessment_results", "parent_degree", "VARCHAR(50) COMMENT '父母最高学历快照'"); ensureColumn("dan_assessment_results", "nation", "VARCHAR(20) COMMENT '民族快照'"); ensureColumn("dan_assessment_results", "province", "VARCHAR(50) COMMENT '省快照'"); ensureColumn("dan_assessment_results", "city", "VARCHAR(50) COMMENT '市快照'"); ensureColumn("dan_assessment_results", "district", "VARCHAR(50) COMMENT '区快照'"); ensureColumn("dan_assessment_results", "address", "VARCHAR(200) COMMENT '详细地址快照'"); ensureColumn("dan_assessment_results", "single_child", "VARCHAR(10) COMMENT '是否独生子女快照'"); ensureColumn("dan_assessment_results", "school_score", "VARCHAR(20) COMMENT '在校成绩快照'"); ensureColumn("dan_assessment_results", "urgent_name", "VARCHAR(50) COMMENT '紧急联系人姓名'"); ensureColumn("dan_assessment_results", "urgent_phone", "VARCHAR(20) COMMENT '紧急联系人电话'"); ensureColumn("dan_assessment_results", "pics", "TEXT COMMENT '图片IDs'"); ensureColumn("dan_assessment_results", "videos", "TEXT COMMENT '视频IDs'"); ensureColumn("dan_assessment_results", "audio", "VARCHAR(100) COMMENT '音频ID'"); ensureColumn("dan_assessment_results", "audio_url", "VARCHAR(255) COMMENT '音频URL'"); ensureColumn("dan_assessment_results", "process_desc", "TEXT COMMENT '过程描述'"); ensureColumn("dan_assessment_results", "result_desc", "TEXT COMMENT '结果简评'"); // 迁移35: points_log表添加family_member_id列(EnergyService.calcChildWealth家庭能量计算需要) ensureColumn("points_log", "family_member_id", "BIGINT COMMENT '家庭成员ID'"); // 迁移36: family_members表添加generation + is_spouse列(辈分重构) ensureColumn("family_members", "generation", "INT COMMENT '辈分值(0=家庭创建者,+n=向上n代,-n=向下n代)'"); ensureColumn("family_members", "is_spouse", "TINYINT(1) DEFAULT 0 COMMENT '是否配偶(同辈)'"); // 迁移37: relationship_type列已删除,旧数据已迁移完成 // 迁移38: product_orders表添加points_used/points_cost/money_amount列(混合支付支持) ensureColumn("product_orders", "points_used", "INT DEFAULT 0 COMMENT '使用的积分数量'"); ensureColumn("product_orders", "points_cost", "INT DEFAULT 0 COMMENT '积分抵扣金额(分)'"); ensureColumn("product_orders", "money_amount", "INT DEFAULT 0 COMMENT '现金支付金额(分)'"); // 迁移39: 创建供应商体系相关表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_system (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "name VARCHAR(100) NOT NULL COMMENT '体系名称', " + "admin_id BIGINT COMMENT '管理员用户ID', " + "settlement_period_days INT DEFAULT 30 COMMENT '账期天数', " + "platform_profit_rate DECIMAL(10,2) DEFAULT 0.00 COMMENT '平台留利比例(%)', " + "description VARCHAR(500) COMMENT '描述', " + "status VARCHAR(20) DEFAULT 'active' COMMENT '状态 active/disabled', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_admin (admin_id), " + "INDEX idx_status (status)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商体系'"); log.info("已创建supply_system表"); } catch (Exception e) { log.warn("创建supply_system表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_system_member (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "system_id BIGINT NOT NULL COMMENT '体系ID', " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "role VARCHAR(20) DEFAULT 'member' COMMENT '角色 admin/member', " + "status VARCHAR(20) DEFAULT 'active' COMMENT '状态 active/disabled', " + "joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_system (system_id), " + "INDEX idx_user (user_id), " + "UNIQUE INDEX idx_system_user (system_id, user_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商体系成员'"); log.info("已创建supply_system_member表"); } catch (Exception e) { log.warn("创建supply_system_member表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_settlement (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "system_id BIGINT NOT NULL COMMENT '体系ID', " + "period_start DATE COMMENT '结算周期开始', " + "period_end DATE COMMENT '结算周期结束', " + "total_sales INT DEFAULT 0 COMMENT '销售总额(分)', " + "platform_profit INT DEFAULT 0 COMMENT '平台留利(分)', " + "supplier_payout INT DEFAULT 0 COMMENT '供应商实付(分)', " + "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态 pending/settled', " + "settled_at DATETIME COMMENT '结算时间', " + "remark VARCHAR(500) COMMENT '备注', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_system (system_id), " + "INDEX idx_status (status), " + "INDEX idx_period (period_start, period_end)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商结算单'"); log.info("已创建supply_settlement表"); } catch (Exception e) { log.warn("创建supply_settlement表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_settlement_detail (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "settlement_id BIGINT NOT NULL COMMENT '结算单ID', " + "order_id BIGINT COMMENT '订单ID', " + "order_no VARCHAR(64) COMMENT '订单号', " + "product_name VARCHAR(200) COMMENT '商品名称', " + "amount INT DEFAULT 0 COMMENT '订单金额(分)', " + "platform_fee INT DEFAULT 0 COMMENT '平台留利(分)', " + "supplier_id BIGINT COMMENT '收款供应商ID', " + "supplier_name VARCHAR(100) COMMENT '收款供应商名称', " + "payout INT DEFAULT 0 COMMENT '实付(分)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_settlement (settlement_id), " + "INDEX idx_order (order_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商结算明细'"); log.info("已创建supply_settlement_detail表"); } catch (Exception e) { log.warn("创建supply_settlement_detail表失败: {}", e.getMessage()); } // 迁移40: 添加supplier_admin角色到users.role(列已为VARCHAR,直接可用) // 当前role列为VARCHAR(20),无需ALTER ENUM // 迁移40: users表添加mascot字段(AI助手形象: xibao浠宝/fubao福宝) ensureColumn("users", "mascot", "VARCHAR(20) DEFAULT NULL COMMENT 'AI助手形象: xibao(浠宝)/fubao(福宝)'"); // 迁移41: 创建family_relationships表(家庭成员双向关系,替代relationship_type/role_override) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_relationships (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '所属家庭ID', " + "from_member_id BIGINT NOT NULL COMMENT '关系起点成员ID', " + "to_member_id BIGINT NOT NULL COMMENT '关系终点成员ID', " + "generation_value INT NOT NULL COMMENT '辈分值: -1=子女, 0=同辈, 1=父母, 2=祖父母, 3=曾祖父母', " + "relationship_key VARCHAR(50) COMMENT '关系标签key(冗余用于显示)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_from_to (from_member_id, to_member_id), " + "INDEX idx_family_id (family_id), " + "INDEX idx_from (from_member_id), " + "INDEX idx_to (to_member_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭成员双向关系表'"); log.info("已创建family_relationships表"); } catch (Exception e) { log.warn("创建family_relationships表可能已存在: {}", e.getMessage()); } // 迁移42: 创建butler_sessions表(AI管家会话管理) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS butler_sessions (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "title VARCHAR(200) DEFAULT '新会话' COMMENT '会话标题', " + "conversation_id VARCHAR(100) COMMENT 'Dify对话ID', " + "context TEXT COMMENT '上下文信息(JSON)', " + "status VARCHAR(20) DEFAULT 'active' COMMENT '状态: active(进行中)/archived(已归档)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_user_id (user_id), " + "INDEX idx_status (status), " + "INDEX idx_user_status (user_id, status)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI管家会话表'"); log.info("已创建butler_sessions表"); } catch (Exception e) { log.warn("创建butler_sessions表可能已存在: {}", e.getMessage()); } // 迁移 79: 创建 tianpan_daily_fortune 表(家庭每日运势) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS tianpan_daily_fortune (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "date VARCHAR(20) NOT NULL COMMENT '日期 YYYY-MM-DD', " + "lunar_date VARCHAR(50) COMMENT '农历日期', " + "day_ganzhi VARCHAR(10) COMMENT '日柱', " + "five_elements_main VARCHAR(20) COMMENT '当日五行主气', " + "five_elements_detail VARCHAR(100) COMMENT '五行详情', " + "auspicious TEXT COMMENT '宜做的事(JSON数组)', " + "inauspicious TEXT COMMENT '忌做的事(JSON数组)', " + "family_advice TEXT COMMENT '家庭建议', " + "focus_member_name VARCHAR(50) COMMENT '关注成员', " + "focus_member_tip TEXT COMMENT '成员提示', " + "wuxing_tip TEXT COMMENT '五行提示', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_family_date (family_id, date), " + "INDEX idx_family_id (family_id), " + "INDEX idx_date (date)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭每日运势表'"); log.info("已创建 tianpan_daily_fortune 表"); } catch (Exception e) { log.warn("创建 tianpan_daily_fortune 表可能已存在: {}", e.getMessage()); } // 迁移 80: 创建 tianpan_member_annual_energy 表(成员年度能量) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS tianpan_member_annual_energy (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "member_id BIGINT NOT NULL COMMENT '成员ID', " + "year INT NOT NULL COMMENT '年份', " + "dimensions_body INT DEFAULT 0 COMMENT '身能量', " + "dimensions_mind INT DEFAULT 0 COMMENT '心能量', " + "dimensions_wisdom INT DEFAULT 0 COMMENT '智能量', " + "dimensions_action INT DEFAULT 0 COMMENT '行能量', " + "dimensions_wealth INT DEFAULT 0 COMMENT '富能量', " + "jupiter_phase VARCHAR(50) COMMENT '木星相位', " + "saturn_phase VARCHAR(50) COMMENT '土星相位', " + "highlights TEXT COMMENT '年度亮点(JSON数组)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_member_year (member_id, year), " + "INDEX idx_member_id (member_id), " + "INDEX idx_year (year)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成员年度能量表'"); log.info("已创建 tianpan_member_annual_energy 表"); } catch (Exception e) { log.warn("创建 tianpan_member_annual_energy 表可能已存在: {}", e.getMessage()); } } private void insertSysConfigSeed(String key, String value, String desc) { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM sys_config WHERE config_key = ?", Integer.class, key); if (count == null || count == 0) { jdbcTemplate.update( "INSERT INTO sys_config (config_key, config_value, description, updated_at) VALUES (?, ?, ?, NOW())", key, value, desc); } } private void initFoodTables() { log.info("开始初始化食品相关表..."); // 4张新表 (CREATE TABLE IF NOT EXISTS) String[] ddl = { "CREATE TABLE IF NOT EXISTS foods (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "name VARCHAR(100) NOT NULL COMMENT '食材名称', " + "category VARCHAR(50) COMMENT '分类: 蔬菜/水果/肉禽/水产/蛋奶/豆类/谷物/调味/其他', " + "unit VARCHAR(20) DEFAULT 'g' COMMENT '计量单位', " + "calories DECIMAL(8,2) COMMENT '热量(kcal/100g)', " + "protein DECIMAL(8,2) COMMENT '蛋白质(g/100g)', " + "fat DECIMAL(8,2) COMMENT '脂肪(g/100g)', " + "carbs DECIMAL(8,2) COMMENT '碳水(g/100g)', " + "fiber DECIMAL(8,2) COMMENT '膳食纤维(g/100g)', " + "energy_kj DECIMAL(8,2) COMMENT '能量(kJ/100g)', " + "starch DECIMAL(8,2) COMMENT '淀粉(g/100g)', " + "cholesterol DECIMAL(8,2) COMMENT '胆固醇(mg/100g)', " + "price_level TINYINT COMMENT '价格等级 1-5', " + "nutrition_tags VARCHAR(500) COMMENT '营养标签JSON', " + "suitable_for VARCHAR(200) COMMENT '适宜人群JSON', " + "allergens VARCHAR(200) COMMENT '过敏原JSON', " + "external_source VARCHAR(100) COMMENT '数据来源', " + "external_id VARCHAR(100) COMMENT '外部ID', " + "cover_image VARCHAR(500) COMMENT '食材图片', " + "report_id BIGINT COMMENT '关联报告ID(从PDF解析的食物推荐)', " + "score INT COMMENT '食物评分', " + "diet_type VARCHAR(50) COMMENT '饮食类型', " + "status VARCHAR(20) DEFAULT 'active', " + "sort_order INT DEFAULT 0, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_category (category), " + "INDEX idx_price (price_level)" + ");", "CREATE TABLE IF NOT EXISTS recipes (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "name VARCHAR(200) NOT NULL COMMENT '食谱名称', " + "meal_type VARCHAR(20) COMMENT '早餐/午餐/晚餐/加餐', " + "description TEXT COMMENT '简介', " + "ingredients TEXT NOT NULL COMMENT '食材清单JSON', " + "steps TEXT COMMENT '烹饪步骤JSON', " + "cook_time INT COMMENT '烹饪时间(分钟)', " + "difficulty VARCHAR(20) COMMENT '难度: 简单/中等/困难', " + "total_calories DECIMAL(8,2) COMMENT '总热量', " + "total_protein DECIMAL(8,2) COMMENT '总蛋白质', " + "total_fat DECIMAL(8,2) COMMENT '总脂肪', " + "total_carbs DECIMAL(8,2) COMMENT '总碳水', " + "total_fiber DECIMAL(8,2) COMMENT '总膳食纤维', " + "suitable_for VARCHAR(200) COMMENT '适宜场景', " + "nutrition_tags VARCHAR(500) COMMENT '营养标签', " + "external_source VARCHAR(100), " + "external_id VARCHAR(100), " + "cover_image VARCHAR(500), " + "status VARCHAR(20) DEFAULT 'active', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_meal_type (meal_type), " + "INDEX idx_cook_time (cook_time)" + ");", "CREATE TABLE IF NOT EXISTS seasonal_foods (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "food_id BIGINT NOT NULL COMMENT '关联foods.id', " + "month TINYINT NOT NULL COMMENT '月份 1-12', " + "region VARCHAR(50) DEFAULT '全国' COMMENT '产地', " + "peak_flag TINYINT DEFAULT 0 COMMENT '盛产标志', " + "sort_order INT DEFAULT 0, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_food_month (food_id, month), " + "INDEX idx_month (month)" + ");", "CREATE TABLE IF NOT EXISTS meal_logs (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "child_id BIGINT COMMENT '孩子ID', " + "meal_type VARCHAR(20) NOT NULL COMMENT '早餐/午餐/晚餐/加餐', " + "meal_date DATE NOT NULL COMMENT '用餐日期', " + "meal_time DATETIME COMMENT '具体时间', " + "foods TEXT NOT NULL COMMENT '食物清单JSON', " + "total_calories DECIMAL(8,2) COMMENT '总热量', " + "total_protein DECIMAL(8,2) COMMENT '总蛋白质', " + "total_fat DECIMAL(8,2) COMMENT '总脂肪', " + "total_carbs DECIMAL(8,2) COMMENT '总碳水', " + "total_fiber DECIMAL(8,2) COMMENT '总膳食纤维', " + "note TEXT COMMENT '备注', " + "images VARCHAR(1000) COMMENT '图片JSON', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_user_date (user_id, meal_date), " + "INDEX idx_child_date (child_id, meal_date)" + ");", "CREATE TABLE IF NOT EXISTS report_food_suitability (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "report_id BIGINT NOT NULL COMMENT '健康报告ID', " + "food_id BIGINT COMMENT '食材ID', " + "food_name VARCHAR(100) NOT NULL COMMENT '食材名称', " + "category VARCHAR(50) COMMENT '分类', " + "score INT NOT NULL COMMENT '适宜度评分', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_report (report_id), " + "INDEX idx_food (food_id)" + ");", "CREATE TABLE IF NOT EXISTS user_food_preference (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "food_id BIGINT COMMENT '食材ID', " + "food_name VARCHAR(100) NOT NULL COMMENT '食材名称', " + "score INT NOT NULL DEFAULT 0 COMMENT '评分', " + "reason VARCHAR(500) COMMENT '原因', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_user (user_id)" + ");", "CREATE TABLE IF NOT EXISTS food_nutrients (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "food_id BIGINT NOT NULL COMMENT '食材ID', " + "nutrient_code VARCHAR(20) NOT NULL COMMENT '营养素编码', " + "amount_per_100g DECIMAL(10,4) NOT NULL COMMENT '每100g含量', " + "unit VARCHAR(20) NOT NULL COMMENT '单位', " + "source VARCHAR(50) DEFAULT 'importer' COMMENT '数据来源', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_food_nutrient (food_id, nutrient_code), " + "INDEX idx_nutrient (nutrient_code)" + ");", "CREATE TABLE IF NOT EXISTS rdi_standards (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "nutrient_code VARCHAR(20) NOT NULL COMMENT '营养素编码', " + "age_group VARCHAR(10) NOT NULL COMMENT '年龄段', " + "gender VARCHAR(6) DEFAULT 'all' COMMENT '性别', " + "rdi_type VARCHAR(10) NOT NULL COMMENT '参考类型', " + "amount DECIMAL(10,4) NOT NULL COMMENT '推荐量', " + "unit VARCHAR(20) NOT NULL COMMENT '单位', " + "source VARCHAR(100) COMMENT '数据来源', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_rdi (nutrient_code, age_group, gender, rdi_type), " + "INDEX idx_age (age_group)" + ");", "CREATE TABLE IF NOT EXISTS shopping_lists (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "child_id BIGINT COMMENT '孩子ID', " + "report_id BIGINT COMMENT '健康报告ID', " + "title VARCHAR(200) COMMENT '标题', " + "source VARCHAR(20) NOT NULL COMMENT '来源', " + "status VARCHAR(20) DEFAULT 'active' COMMENT '状态', " + "total_items INT DEFAULT 0 COMMENT '总项数', " + "purchased_items INT DEFAULT 0 COMMENT '已购项数', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_user (user_id), " + "INDEX idx_report (report_id), " + "INDEX idx_status (status)" + ");", "CREATE TABLE IF NOT EXISTS shopping_list_items (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "list_id BIGINT NOT NULL COMMENT '购物清单ID', " + "food_id BIGINT COMMENT '食材ID', " + "food_name VARCHAR(100) NOT NULL COMMENT '食材名称', " + "category VARCHAR(50) COMMENT '分类', " + "quantity DECIMAL(8,2) DEFAULT 1 COMMENT '数量', " + "unit VARCHAR(20) DEFAULT '份' COMMENT '单位', " + "priority VARCHAR(10) DEFAULT 'medium' COMMENT '优先级', " + "reason VARCHAR(200) COMMENT '推荐原因', " + "score INT COMMENT '评分', " + "source VARCHAR(20) COMMENT '来源', " + "purchased TINYINT DEFAULT 0 COMMENT '是否已购', " + "purchased_at DATETIME COMMENT '购买时间', " + "sort_order INT DEFAULT 0 COMMENT '排序', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_list (list_id), " + "INDEX idx_purchased (purchased)" + ");", "CREATE TABLE IF NOT EXISTS follow_up (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "child_id BIGINT COMMENT '孩子ID', " + "teacher_id BIGINT COMMENT '规划师ID', " + "deficiency_id BIGINT COMMENT '缺乏项ID', " + "message_id BIGINT COMMENT '消息ID', " + "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态', " + "priority VARCHAR(10) DEFAULT 'medium' COMMENT '优先级', " + "description VARCHAR(500) COMMENT '描述', " + "parent_feedback TEXT COMMENT '家长反馈', " + "teacher_review VARCHAR(500) COMMENT '规划师点评', " + "due_date DATETIME COMMENT '截止日期', " + "completed_at DATETIME COMMENT '完成时间', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_family (family_id), " + "INDEX idx_teacher (teacher_id), " + "INDEX idx_status (status), " + "INDEX idx_due (due_date)" + ");" }; for (String sql : ddl) { try { jdbcTemplate.execute(sql); } catch (Exception e) { log.warn("食品表DDL执行失败(可能已存在): {}", e.getMessage()); } } // 扩展 user_nutrition_profile(如果表不存在则跳过) ensureColumn("user_nutrition_profile", "budget_monthly", "INT COMMENT '月食品预算(元)'"); ensureColumn("user_nutrition_profile", "family_taste_preferences", "TEXT COMMENT '家庭成员口味偏好JSON'"); ensureColumn("user_nutrition_profile", "cuisine_style", "VARCHAR(100) COMMENT '偏好菜系'"); ensureColumn("user_nutrition_profile", "meal_count", "INT DEFAULT 3 COMMENT '每日餐数'"); ensureColumn("user_nutrition_profile", "cooking_ability", "VARCHAR(20) DEFAULT 'simple' COMMENT '烹饪能力: simple/medium/advanced'"); log.info("食品相关表初始化完成"); } private boolean tableExists(String tableName) { try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?", Integer.class, tableName); return count != null && count > 0; } catch (Exception e) { return false; } } private boolean columnExists(String table, String column) { try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?", Integer.class, table, column); return count != null && count > 0; } catch (Exception e) { return false; } } private void ensureColumn(String table, String column, String definition) { try { jdbcTemplate.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition); log.info("已添加列 {}.{}", table, column); } catch (Exception e) { String msg = e.getMessage(); if (msg != null && (msg.contains("Duplicate") || msg.contains("already exists"))) { log.info("列 {}.{} 已存在,跳过", table, column); } else { log.error("添加列 {}.{} 失败:{}", table, column, msg); throw e; } } } /** 幂等创建任务模板包,返回 package id */ private Long ensureTemplatePackage(String name, String desc, int cycleDays, String category) { Integer cnt = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM task_template_packages WHERE name = ?", Integer.class, name); if (cnt != null && cnt > 0) { return jdbcTemplate.queryForObject( "SELECT id FROM task_template_packages WHERE name = ? LIMIT 1", Long.class, name); } jdbcTemplate.update( "INSERT INTO task_template_packages (creator_id, name, description, cycle_days, category, is_public, status, created_at, updated_at) " + "VALUES (1, ?, ?, ?, ?, 1, 'published', NOW(), NOW())", name, desc, cycleDays, category); return jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class); } /** 幂等写入模板包任务项 */ private void ensureTemplateItems(Long packageId, String[][] items) { Integer cnt = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM task_template_items WHERE package_id = ?", Integer.class, packageId); if (cnt != null && cnt > 0) return; for (String[] it : items) { jdbcTemplate.update( "INSERT INTO task_template_items (package_id, task_name, description, points, frequency, sort_order, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, 0, NOW(), NOW())", packageId, it[0], it[1], Integer.valueOf(it[2]), it[3]); } } /** 幂等写入方案生成规则 */ private void ensurePlanRule(String dimension, int scoreMin, int scoreMax, Long templateId, int priority) { Integer cnt = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM assessment_plan_rules WHERE dimension = ? AND score_min = ? AND score_max = ?", Integer.class, dimension, scoreMin, scoreMax); if (cnt != null && cnt > 0) return; jdbcTemplate.update( "INSERT INTO assessment_plan_rules (dimension, score_min, score_max, template_id, priority, is_active, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, 1, NOW(), NOW())", dimension, scoreMin, scoreMax, templateId, priority); } private void insertProductSeed(String name, String desc, String type, Integer price, String domain, String memberEligible) { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM products WHERE name = ? AND product_type = ?", Integer.class, name, type); if (count == null || count == 0) { jdbcTemplate.update( "INSERT INTO products (name, description, price, product_type, vendor_id, vendor_name, status, domain, member_eligible, created_at, updated_at) " + "VALUES (?, ?, ?, ?, NULL, '平台官方', 'on_shelf', ?, ?, NOW(), NOW())", name, desc, price, type, domain, memberEligible); } } private void insertActivitySeed(String title, String desc, String dimensionCode, String activityType, String status, String startTime, String endTime, String location, int maxParticipants, int price, int memberPrice, String dimensionWeights) { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM activities WHERE title = ?", Integer.class, title); if (count == null || count == 0) { jdbcTemplate.update( "INSERT INTO activities (title, description, dimension_code, dimension_weights, activity_type, status, " + "start_time, end_time, location, max_participants, current_participants, price, member_price, " + "require_registration, checkin_points, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 1, 10, NOW(), NOW())", title, desc, dimensionCode, dimensionWeights, activityType, status, startTime, endTime, location, maxParticipants, price, memberPrice); } } private void insertArticleSeed(String title, String summary, String contentType, Long categoryId, String dimensionCode, String status, String author, String articleType, int readTime, int wordCount, String dimensionWeights) { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM articles WHERE title = ?", Integer.class, title); if (count == null || count == 0) { jdbcTemplate.update( "INSERT INTO articles (title, summary, content, content_type, category_id, " + "related_dimensions, dimension_weights, status, author, article_type, " + "read_time, word_count, is_featured, view_count, author_type, audit_status, " + "visibility, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, 'admin', 'approved', 'public', NOW(), NOW())", title, summary, "(此处为示例文章正文内容,实际数据应在运营后台编辑发布。)", contentType, categoryId, "[\"" + dimensionCode + "\"]", dimensionWeights, status, author, articleType, readTime, wordCount); } } 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); } } private void runLateMigrations() { // 迁移51: 创建 member_subscription 表(家庭订阅记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS member_subscription (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "`level` VARCHAR(10) NOT NULL DEFAULT 'L1' COMMENT 'L1=一生一世, L2=久久一生', " + "status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active/expired/cancelled', " + "start_time DATETIME DEFAULT NULL COMMENT '开始时间', " + "expire_time DATETIME DEFAULT NULL COMMENT '到期时间', " + "payment_id BIGINT DEFAULT NULL COMMENT '关联订单ID', " + "auto_renew TINYINT(1) DEFAULT 0 COMMENT '自动续费(L2)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_family_id (family_id), " + "INDEX idx_status (status), " + "INDEX idx_expire_time (expire_time)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭订阅记录'"); log.info("已创建member_subscription表"); } catch (Exception e) { log.warn("创建member_subscription表失败: {}", e.getMessage()); } // 迁移52: 创建 member_subscription_order 表(订阅订单) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS member_subscription_order (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "`level` VARCHAR(10) NOT NULL COMMENT 'L1/L2', " + "amount INT NOT NULL COMMENT '金额(分)', " + "status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending/paid/cancelled/refunded', " + "payment_type VARCHAR(20) DEFAULT NULL COMMENT '支付方式 wechat/alipay', " + "transaction_id VARCHAR(64) DEFAULT NULL COMMENT '微信支付订单号', " + "order_no VARCHAR(64) NOT NULL COMMENT '订单号', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "paid_at DATETIME DEFAULT NULL, " + "UNIQUE KEY uk_order_no (order_no), " + "INDEX idx_family_id (family_id), " + "INDEX idx_status (status)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订阅订单'"); log.info("已创建member_subscription_order表"); } catch (Exception e) { log.warn("创建member_subscription_order表失败: {}", e.getMessage()); } // 迁移53: 创建 subscription_benefit_log 表(权益使用日志) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS subscription_benefit_log (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "subscription_id BIGINT NOT NULL COMMENT '订阅ID', " + "benefit_code VARCHAR(50) NOT NULL COMMENT '权益代码', " + "used_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "detail TEXT COMMENT '使用详情(JSON)', " + "INDEX idx_subscription_id (subscription_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权益使用日志'"); log.info("已创建subscription_benefit_log表"); } catch (Exception e) { log.warn("创建subscription_benefit_log表失败: {}", e.getMessage()); } // 迁移54: 创建 promotion_tier 表(推广等级 R0-R4) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS promotion_tier (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "tier VARCHAR(20) NOT NULL DEFAULT 'R0' COMMENT 'R0利他者/R1传福人/R2聚能师/R3启慧者/R4传承者', " + "team_size_1st INT DEFAULT 0 COMMENT '一层团队人数', " + "team_size_2nd INT DEFAULT 0 COMMENT '二层团队人数', " + "team_size_3rd INT DEFAULT 0 COMMENT '三层团队人数', " + "total_team_size INT DEFAULT 0 COMMENT '三层累计总人数', " + "total_referral_earnings INT DEFAULT 0 COMMENT '累计推荐佣金(分)', " + "total_share_earnings INT DEFAULT 0 COMMENT '累计消费返利(分)', " + "promoted_at DATETIME DEFAULT NULL COMMENT '最近晋级时间', " + "last_change_at DATETIME DEFAULT NULL COMMENT '最后变更时间', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_user_id (user_id), " + "INDEX idx_tier (tier)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推广等级'"); log.info("已创建promotion_tier表"); } catch (Exception e) { log.warn("创建promotion_tier表失败: {}", e.getMessage()); } // 迁移55: 创建 promotion_tier_change_log 表(推广等级变更记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS promotion_tier_change_log (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "old_tier VARCHAR(20) DEFAULT NULL COMMENT '变更前等级', " + "new_tier VARCHAR(20) NOT NULL COMMENT '变更后等级', " + "reason VARCHAR(50) DEFAULT NULL COMMENT 'qualify升级/demote降级/admin管理员调整', " + "changed_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_user_id (user_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推广等级变更记录'"); log.info("已创建promotion_tier_change_log表"); } catch (Exception e) { log.warn("创建promotion_tier_change_log表失败: {}", e.getMessage()); } // 迁移56: 创建 referral_tree 表(推荐关系树三层) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS referral_tree (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "parent_id BIGINT NOT NULL COMMENT '推荐人ID', " + "child_id BIGINT NOT NULL COMMENT '被推荐人ID', " + "level INT NOT NULL COMMENT '层级深度(1/2/3)', " + "path VARCHAR(255) DEFAULT NULL COMMENT 'materialized path如/1/2/3/', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_child_id (child_id), " + "INDEX idx_parent_id (parent_id), " + "INDEX idx_level (level)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推荐关系树'"); log.info("已创建referral_tree表"); } catch (Exception e) { log.warn("创建referral_tree表失败: {}", e.getMessage()); } // 迁移57: 创建 product_ppoint 表(产品P点配置) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_ppoint (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "product_id BIGINT NOT NULL COMMENT '产品ID', " + "ppoint INT NOT NULL DEFAULT 0 COMMENT 'P点值(平台利润分)', " + "start_date DATE DEFAULT NULL COMMENT '生效日期', " + "end_date DATE DEFAULT NULL COMMENT '失效日期', " + "created_by BIGINT DEFAULT NULL COMMENT '创建人', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_product_id (product_id), " + "INDEX idx_dates (start_date, end_date)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品P点配置'"); log.info("已创建product_ppoint表"); } catch (Exception e) { log.warn("创建product_ppoint表失败: {}", e.getMessage()); } // 迁移58: 创建 assessment_products 表(测评商品扩展信息) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS assessment_products (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "product_id BIGINT NOT NULL COMMENT '关联products表', " + "assessment_type VARCHAR(20) NOT NULL COMMENT 'single=单包, bundle=套餐', " + "total_sessions INT NOT NULL DEFAULT 1 COMMENT '包含测评次数', " + "validity_days INT NOT NULL DEFAULT 365 COMMENT '有效期(天)', " + "guide_scope VARCHAR(50) DEFAULT 'all' COMMENT '规划师范围: all/assigned/auto', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_product_id (product_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='测评商品扩展信息'"); log.info("已创建assessment_products表"); } catch (Exception e) { log.warn("创建assessment_products表失败: {}", e.getMessage()); } // 迁移59: 创建 assessment_quotas 表(测评额度) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS assessment_quotas (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "quota_no VARCHAR(32) NOT NULL COMMENT '额度编号', " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "child_id BIGINT COMMENT '指定孩子ID(NULL=不限)', " + "product_order_id BIGINT NOT NULL COMMENT '来源订单ID', " + "product_id BIGINT NOT NULL COMMENT '商品ID', " + "product_name VARCHAR(100) NOT NULL COMMENT '商品名快照', " + "total_sessions INT NOT NULL DEFAULT 1 COMMENT '总次数', " + "remaining_sessions INT NOT NULL DEFAULT 1 COMMENT '剩余次数', " + "validity_start DATETIME NOT NULL COMMENT '有效期开始', " + "validity_end DATETIME NOT NULL COMMENT '有效期截止', " + "status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active/expired/used_all/cancelled', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_family (family_id), " + "INDEX idx_order (product_order_id), " + "UNIQUE KEY uk_quota_no (quota_no)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='测评额度表'"); log.info("已创建assessment_quotas表"); } catch (Exception e) { log.warn("创建assessment_quotas表失败: {}", e.getMessage()); } // 迁移60: 创建 assessment_executions 表(测评执行记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS assessment_executions (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "quota_id BIGINT NOT NULL COMMENT '关联额度ID', " + "child_id BIGINT NOT NULL COMMENT '被测评孩子', " + "guide_id BIGINT COMMENT '执行规划师ID', " + "assessment_type VARCHAR(30) COMMENT '本次具体测评类型', " + "appointment_id BIGINT COMMENT '关联预约ID', " + "result_id BIGINT COMMENT '关联测评结果ID', " + "status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending/in_progress/completed/cancelled', " + "scheduled_date DATETIME COMMENT '预约执行时间', " + "completed_at DATETIME COMMENT '实际完成时间', " + "notes VARCHAR(500) COMMENT '备注', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_quota (quota_id), " + "INDEX idx_child (child_id), " + "INDEX idx_appointment (appointment_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='测评执行记录表'"); log.info("已创建assessment_executions表"); } catch (Exception e) { log.warn("创建assessment_executions表失败: {}", e.getMessage()); } // 迁移61: dan_assessment_results表添加source字段(报告来源) ensureColumn("dan_assessment_results", "source", "VARCHAR(20) DEFAULT 'planner_entry' COMMENT '报告来源: planner_entry/parent_upload/auto_fetch'"); // 迁移62: 创建 growth_guidances 表(结构化指导意见) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS growth_guidances (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "result_id BIGINT NOT NULL COMMENT '关联测评结果ID', " + "dimension VARCHAR(30) NOT NULL COMMENT '维度: attention/focus/memory/logic/emotion/general', " + "score INT COMMENT '该维度得分(0-100)', " + "suggestion TEXT NOT NULL COMMENT '建议内容', " + "priority INT DEFAULT 0 COMMENT '优先级(0=普通, 1=重要, 2=紧急)', " + "sort_order INT DEFAULT 0 COMMENT '排序', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_result (result_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='测评指导意见(结构化)'"); log.info("已创建growth_guidances表"); } catch (Exception e) { log.warn("创建growth_guidances表失败: {}", e.getMessage()); } // 迁移63: 创建 assessment_plan_rules 表(方案生成规则) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS assessment_plan_rules (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "dimension VARCHAR(30) NOT NULL COMMENT '测评维度: attention/focus/memory/logic/emotion', " + "score_min INT NOT NULL COMMENT '分数下限(含)', " + "score_max INT NOT NULL COMMENT '分数上限(含)', " + "template_id BIGINT NOT NULL COMMENT '关联TaskTemplatePackage ID', " + "priority INT DEFAULT 0 COMMENT '优先级', " + "is_active TINYINT(1) DEFAULT 1 COMMENT '是否启用', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_dimension (dimension), " + "INDEX idx_score (score_min, score_max)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='方案生成规则'"); log.info("已创建assessment_plan_rules表"); } catch (Exception e) { log.warn("创建assessment_plan_rules表失败: {}", e.getMessage()); } // 迁移64: task_plan_instances表添加source字段(方案来源) ensureColumn("task_plan_instances", "source", "VARCHAR(20) DEFAULT 'manual' COMMENT '方案来源: manual/assessment/template'"); // 迁移65: task_plan_instances表添加source_result_id和remark字段 ensureColumn("task_plan_instances", "source_result_id", "BIGINT COMMENT '关联测评结果ID(来源为assessment时)'"); ensureColumn("task_plan_instances", "remark", "TEXT COMMENT '方案备注(含指导意见摘要)'"); // 迁移66: task_plan_instances表添加审核确认字段(阶段五) ensureColumn("task_plan_instances", "reviewed_by", "BIGINT COMMENT '审核人(规划师ID)'"); ensureColumn("task_plan_instances", "reviewed_at", "DATETIME COMMENT '审核时间'"); ensureColumn("task_plan_instances", "review_comment", "VARCHAR(500) COMMENT '审核意见'"); ensureColumn("task_plan_instances", "confirmed_by", "BIGINT COMMENT '确认人(家长ID)'"); ensureColumn("task_plan_instances", "confirmed_at", "DATETIME COMMENT '确认时间'"); ensureColumn("task_plan_instances", "activated_at", "DATETIME COMMENT '激活时间'"); // 迁移67: family_access_logs 表(G7 familyId 越权访问日志,可选,用于安全审计) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_access_logs (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '访问方用户ID', " + "family_id BIGINT NOT NULL COMMENT '被访问的家庭ID', " + "request_uri VARCHAR(500) NOT NULL COMMENT '请求路径', " + "request_method VARCHAR(10) NOT NULL, " + "access_type VARCHAR(20) NOT NULL COMMENT 'check_type: query/path/body', " + "result VARCHAR(10) NOT NULL COMMENT 'allowed/denied/log_only', " + "client_ip VARCHAR(50), " + "user_agent VARCHAR(500), " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_user_time (user_id, created_at), " + "INDEX idx_family_time (family_id, created_at)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭访问安全日志'"); log.info("已创建family_access_logs表"); } catch (Exception e) { log.warn("创建family_access_logs表失败: {}", e.getMessage()); } // 迁移68: 创建 five_dimension_scores 表(G1 五维能量分数持久化,记录每次计算的维度分数快照) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS five_dimension_scores (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "member_id BIGINT NOT NULL COMMENT '成员ID(user或family_member)', " + "member_type VARCHAR(20) NOT NULL COMMENT '成员类型: user/member', " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "dimension_code VARCHAR(20) NOT NULL COMMENT '维度编码: body/mind/wisdom/action/wealth', " + "score INT NOT NULL COMMENT '分数(0-100)', " + "assessed_at DATETIME NOT NULL COMMENT '评估时间', " + "source_type VARCHAR(50) COMMENT '来源类型: periodic/task/assessment/manual', " + "source_id BIGINT COMMENT '来源记录ID', " + "detail_json TEXT COMMENT '详细数据JSON', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " + "INDEX idx_member_dim (member_id, member_type, dimension_code), " + "INDEX idx_family_time (family_id, assessed_at), " + "INDEX idx_source (source_type, source_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五维能量分数历史记录'"); log.info("已创建five_dimension_scores表"); // 迁移69: 创建 promotion_tier_config 表(推广等级R0-R4阈值配置) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS promotion_tier_config (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "tier_code VARCHAR(10) NOT NULL UNIQUE COMMENT '等级编码: R0/R1/R2/R3/R4', " + "tier_name VARCHAR(50) NOT NULL COMMENT '等级名称', " + "min_team_size_1st INT NOT NULL DEFAULT 0 COMMENT '一级团队人数下限', " + "min_team_size_2nd INT NOT NULL DEFAULT 0 COMMENT '二级团队人数下限', " + "min_total_referral_earnings INT NOT NULL DEFAULT 0 COMMENT '累计推荐佣金下限(分)', " + "min_total_share_earnings INT NOT NULL DEFAULT 0 COMMENT '累计消费返利下限(分)', " + "commission_rate_l1 INT NOT NULL DEFAULT 0 COMMENT 'L1佣金比例(%)', " + "commission_rate_l2 INT NOT NULL DEFAULT 0 COMMENT 'L2佣金比例(%)', " + "sort_order INT NOT NULL DEFAULT 0 COMMENT '排序', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推广等级阈值配置'"); log.info("已创建promotion_tier_config表"); } catch (Exception e) { log.warn("创建promotion_tier_config表失败: {}", e.getMessage()); } try { Integer configCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM promotion_tier_config", Integer.class); if (configCount == null || configCount == 0) { jdbcTemplate.execute("INSERT INTO promotion_tier_config (tier_code, tier_name, min_team_size_1st, min_team_size_2nd, min_total_referral_earnings, min_total_share_earnings, commission_rate_l1, commission_rate_l2, sort_order) VALUES " + "('R0', '初始用户', 0, 0, 0, 0, 0, 0, 0), " + "('R1', '推广达人', 3, 0, 0, 0, 20, 5, 1), " + "('R2', '高级推广', 10, 5, 50000, 20000, 25, 8, 2), " + "('R3', '资深推广', 30, 20, 200000, 80000, 30, 10, 3), " + "('R4', '首席推广', 100, 50, 500000, 200000, 35, 15, 4)"); log.info("R0-R4推广等级阈值种子数据已初始化"); } } catch (Exception e) { log.warn("初始化推广等级阈值种子数据失败: {}", e.getMessage()); } } catch (Exception e) { log.warn("创建five_dimension_scores表失败: {}", e.getMessage()); } // 迁移70: package_orders表添加user_coupon_id列 try { jdbcTemplate.execute("ALTER TABLE package_orders ADD COLUMN user_coupon_id BIGINT COMMENT '使用的用户优惠券ID'"); log.info("已添加user_coupon_id列到package_orders表"); } catch (Exception e) { log.warn("添加user_coupon_id列失败(可能已存在): {}", e.getMessage()); } // 迁移71: family_invitations表添加max_use_count和used_count列(G9 邀请次数控制) ensureColumn("family_invitations", "max_use_count", "INT DEFAULT 1 COMMENT '最大使用次数'"); ensureColumn("family_invitations", "used_count", "INT DEFAULT 0 COMMENT '已使用次数'"); // 迁移72: 创建teacher_family_binding_requests表(G10 规划师绑定家庭审批) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS teacher_family_binding_requests (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "teacher_id BIGINT NOT NULL COMMENT '规划师用户ID', " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "invitation_token VARCHAR(128) COMMENT '关联邀请码/令牌', " + "status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending/approved/rejected/cancelled', " + "requested_at DATETIME COMMENT '申请时间', " + "decided_by BIGINT COMMENT '审批人用户ID', " + "decided_at DATETIME COMMENT '审批时间', " + "reject_reason VARCHAR(200) COMMENT '拒绝原因', " + "message VARCHAR(500) COMMENT '申请留言', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_teacher_family (teacher_id, family_id), " + "INDEX idx_family_status (family_id, status) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规划师绑定家庭申请表'"); log.info("已创建teacher_family_binding_requests表"); } catch (Exception e) { log.warn("创建teacher_family_binding_requests表失败: {}", e.getMessage()); } // 迁移73: 创建chat_conversations表(G14 Dify会话本地镜像) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS chat_conversations (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL, " + "dify_conversation_id VARCHAR(64), " + "assistant_type VARCHAR(32) COMMENT 'family/nutrition/tongue', " + "title VARCHAR(200), " + "last_message_at DATETIME, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_dify_conv (dify_conversation_id), " + "INDEX idx_user (user_id) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); log.info("已创建chat_conversations表"); } catch (Exception e) { log.warn("创建chat_conversations表失败: {}", e.getMessage()); } // 迁移74: 创建chat_messages表(G14 Dify会话本地镜像) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS chat_messages (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "conversation_id BIGINT NOT NULL, " + "dify_message_id VARCHAR(64), " + "role VARCHAR(20) NOT NULL COMMENT 'user/assistant', " + "content TEXT NOT NULL, " + "inputs_json TEXT, " + "metadata TEXT, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_conv (conversation_id) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); log.info("已创建chat_messages表"); } catch (Exception e) { log.warn("创建chat_messages表失败: {}", e.getMessage()); } // 迁移75: 创建schema_versions表(G23 迁移版本追踪) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS schema_versions (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "version_code VARCHAR(64) NOT NULL UNIQUE, " + "description VARCHAR(500), " + "checksum VARCHAR(64), " + "applied_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "applied_by VARCHAR(100) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='迁移版本追踪表'"); log.info("已创建schema_versions表"); } catch (Exception e) { log.warn("创建schema_versions表失败: {}", e.getMessage()); } // 迁移76: DanAssessmentResult添加structured_analysis字段(G3) ensureColumn("dan_assessment_results", "structured_analysis", "TEXT COMMENT '结构化分析JSON'"); // 迁移77: 创建 pending_refund 表(待退款记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS pending_refund (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "withdrawal_request_id BIGINT COMMENT '提现申请ID', " + "amount INT NOT NULL COMMENT '退款金额(分)', " + "attempt_count INT DEFAULT 0 COMMENT '当前尝试次数', " + "max_attempts INT DEFAULT 10 COMMENT '最大重试次数', " + "next_retry_time DATETIME COMMENT '下次重试时间', " + "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/processing/success/failed', " + "failure_reason VARCHAR(500) COMMENT '失败原因', " + "refunded_amount INT COMMENT '实际退款金额(分)', " + "refunded_at DATETIME COMMENT '退款时间', " + "remark VARCHAR(500) COMMENT '备注', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_user_id (user_id), " + "INDEX idx_status (status), " + "INDEX idx_next_retry (next_retry_time), " + "INDEX idx_withdrawal_request (withdrawal_request_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='待退款记录表'"); log.info("已创建pending_refund表"); } catch (Exception e) { log.warn("创建pending_refund表失败: {}", e.getMessage()); } // 迁移78: product_orders添加consignee_id字段(OrderTimeoutCancelTask查询需要) ensureColumn("product_orders", "consignee_id", "BIGINT COMMENT '收货人ID'"); // 迁移79: pending_refund添加order_type/order_no字段(商品退款走待退款队列) ensureColumn("pending_refund", "order_type", "VARCHAR(20) DEFAULT 'withdrawal' COMMENT '退款类型: withdrawal/product'"); ensureColumn("pending_refund", "order_no", "VARCHAR(64) COMMENT '商品订单号'"); try { jdbcTemplate.execute("ALTER TABLE pending_refund ADD INDEX idx_order_type (order_type)"); } catch (Exception e) { } try { jdbcTemplate.execute("ALTER TABLE pending_refund ADD INDEX idx_order_no (order_no)"); } catch (Exception e) { } // 迁移80: dan_assessment_results添加dimension字段(区分mind/wisdom报告上传) ensureColumn("dan_assessment_results", "dimension", "VARCHAR(10) DEFAULT 'mind' COMMENT '报告维度: mind(心-A2)/wisdom(智-B4)'"); ensureColumn("dan_assessment_results", "report_file_id", "VARCHAR(100) COMMENT '上传文件媒资ID'"); ensureColumn("dan_assessment_results", "draft_status", "VARCHAR(10) DEFAULT 'pending' COMMENT '草稿状态: pending(待确认)/confirmed(已确认)'"); // 迁移81: activities添加dimension_weights JSON列(五维权重百分比) ensureColumn("activities", "dimension_weights", "JSON DEFAULT NULL COMMENT '{\"body\":0,\"mind\":0,\"wisdom\":0,\"action\":0,\"wealth\":0}'"); ensureColumn("products", "dimension_weights", "JSON DEFAULT NULL COMMENT '{\"body\":0,\"mind\":0,\"wisdom\":0,\"action\":0,\"wealth\":0}'"); ensureColumn("articles", "dimension_weights", "JSON DEFAULT NULL COMMENT '{\"body\":0,\"mind\":0,\"wisdom\":0,\"action\":0,\"wealth\":0}'"); // 迁移82: 存量数据回填 — 根据dimension_code/domain/relatedDimensions初始化dimension_weights try { // 回填activities: 已有dimension_code的记录,设对应维度为100 int actCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM activities WHERE dimension_code IS NOT NULL AND dimension_code != '' AND dimension_weights IS NULL", Integer.class); if (actCount > 0) { jdbcTemplate.update( "UPDATE activities SET dimension_weights = " + "JSON_OBJECT(" + " 'body', IF(dimension_code='body',100,0)," + " 'mind', IF(dimension_code='mind',100,0)," + " 'wisdom', IF(dimension_code='wisdom',100,0)," + " 'action', IF(dimension_code='action',100,0)," + " 'wealth', IF(dimension_code='wealth',100,0)" + ") WHERE dimension_code IS NOT NULL AND dimension_code != '' AND dimension_weights IS NULL"); log.info("已回填{}条activities的dimension_weights", actCount); } } catch (Exception e) { log.warn("回填activities.dimension_weights失败: {}", e.getMessage()); } try { // 回填products: 已有domain的记录,设对应维度为100 int prodCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM products WHERE domain IS NOT NULL AND domain != '' AND dimension_weights IS NULL", Integer.class); if (prodCount > 0) { jdbcTemplate.update( "UPDATE products SET dimension_weights = " + "JSON_OBJECT(" + " 'body', IF(domain='body',100,0)," + " 'mind', IF(domain='mind',100,0)," + " 'wisdom', IF(domain='wisdom',100,0)," + " 'action', IF(domain='action',100,0)," + " 'wealth', IF(domain='wealth',100,0)" + ") WHERE domain IS NOT NULL AND domain != '' AND dimension_weights IS NULL"); log.info("已回填{}条products的dimension_weights", prodCount); } } catch (Exception e) { log.warn("回填products.dimension_weights失败: {}", e.getMessage()); } try { // 回填articles: 已有relatedDimensions的记录,尝试解析维度名并设权重 int artCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM articles WHERE related_dimensions IS NOT NULL AND related_dimensions != '' AND dimension_weights IS NULL", Integer.class); if (artCount > 0) { // 用MySQL函数解析relatedDimensions中的维度关键词 jdbcTemplate.update("UPDATE articles SET dimension_weights = JSON_OBJECT(" + "'body', IF(INSTR(COALESCE(related_dimensions,''),'body')>0 OR INSTR(COALESCE(related_dimensions,''),'身')>0, 100, 0)," + "'mind', IF(INSTR(COALESCE(related_dimensions,''),'mind')>0 OR INSTR(COALESCE(related_dimensions,''),'心')>0, 100, 0)," + "'wisdom', IF(INSTR(COALESCE(related_dimensions,''),'wisdom')>0 OR INSTR(COALESCE(related_dimensions,''),'智')>0, 100, 0)," + "'action', IF(INSTR(COALESCE(related_dimensions,''),'action')>0 OR INSTR(COALESCE(related_dimensions,''),'行')>0, 100, 0)," + "'wealth', IF(INSTR(COALESCE(related_dimensions,''),'wealth')>0 OR INSTR(COALESCE(related_dimensions,''),'富')>0, 100, 0)" + ") WHERE related_dimensions IS NOT NULL AND related_dimensions != '' AND dimension_weights IS NULL"); log.info("已回填{}条articles的dimension_weights", artCount); } } catch (Exception e) { log.warn("回填articles.dimension_weights失败: {}", e.getMessage()); } // 迁移83: 创建dimension_weights表(管理端保存维度权重使用) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS dimension_weights (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY," + "target_type VARCHAR(20) NOT NULL COMMENT '目标类型: activity/product/article'," + "target_id BIGINT NOT NULL COMMENT '目标ID'," + "dimension VARCHAR(20) NOT NULL COMMENT '维度: body/mind/wisdom/action/wealth'," + "weight INT DEFAULT 0 COMMENT '权重百分比'," + "enabled TINYINT(1) DEFAULT 0 COMMENT '是否启用'," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," + "INDEX idx_target (target_type, target_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='维度权重表'"); log.info("已创建dimension_weights表"); } catch (Exception e) { log.warn("创建dimension_weights表失败: {}", e.getMessage()); } // 迁移84: cart表添加skuId和specOptionIds字段(支持SKU选择) ensureColumn("cart", "sku_id", "BIGINT COMMENT '选中的SKU ID'"); ensureColumn("cart", "spec_option_ids", "VARCHAR(500) COMMENT '选中的规格选项IDs JSON'"); // 迁移86: product_orders表添加purchase_info字段(购买信息快照JSON,OrderTimeoutCancelTask查询需要) ensureColumn("product_orders", "purchase_info", "TEXT COMMENT '购买信息JSON: {id_card, hand_signature, ...}'"); // 迁移87: articles表添加content_type/difficulty_level/dimension_ids/dimension_weights字段 ensureColumn("articles", "content_type", "VARCHAR(20) DEFAULT 'text' COMMENT '内容格式:text/html/markdown'"); ensureColumn("articles", "difficulty_level", "INT DEFAULT 0 COMMENT '难度等级:1-5'"); ensureColumn("articles", "dimension_ids", "VARCHAR(100) DEFAULT '' COMMENT '关联维度IDs JSON'"); ensureColumn("articles", "dimension_weights", "TEXT COMMENT '五维能量权重JSON'"); // 迁移88: activities表添加dimension_weights列(权重感知活动查询) ensureColumn("activities", "dimension_weights", "TEXT COMMENT '五维能量权重JSON'"); // 迁移89: products表添加dimension_weights列(权重感知商品查询) ensureColumn("products", "dimension_weights", "TEXT COMMENT '五维能量权重JSON'"); // 迁移85: 创建社交圈子表(珍珠图/圈子系统) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS social_circle (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "name VARCHAR(100) NOT NULL COMMENT '圈子名称', " + "type VARCHAR(50) NOT NULL COMMENT '类型: topic/activity/hobby/ability/product/health/provider', " + "match_source VARCHAR(50) NOT NULL COMMENT '匹配源', " + "source_id BIGINT COMMENT '匹配源ID', " + "member_count INT DEFAULT 0 COMMENT '成员数', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='社交圈/珍珠'"); log.info("已创建social_circle表"); } catch (Exception e) { /* 表已存在忽略 */ } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS social_circle_member (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "circle_id BIGINT NOT NULL, " + "member_id BIGINT NOT NULL COMMENT '用户ID', " + "member_type VARCHAR(20) NOT NULL COMMENT 'parent/child', " + "joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_circle_member (circle_id, member_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈子成员'"); log.info("已创建social_circle_member表"); } catch (Exception e) { /* 表已存在忽略 */ } // 迁移96: 创建活动订单评价表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS review_orders (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "order_id BIGINT NOT NULL COMMENT '订单ID', " + "reviewer_id BIGINT NOT NULL COMMENT '评价人ID', " + "rating INT COMMENT '总体评分', " + "review_text TEXT COMMENT '评价内容', " + "review_at DATETIME COMMENT '评价时间', " + "organization_rating INT COMMENT '活动组织评分(0-5)', " + "content_rating INT COMMENT '活动内容评分(0-5)', " + "experience_rating INT COMMENT '活动体验评分(0-5)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_order_id (order_id), " + "INDEX idx_reviewer_id (reviewer_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动订单评价表'"); log.info("已创建review_orders表"); } catch (Exception e) { log.error("迁移96 失败: " + e.getMessage()); } // 迁移97: growth_task表扩展 — 支持AI_GENERATED类型 + 五维维度/来源会话字段 try { jdbcTemplate.execute("ALTER TABLE growth_task MODIFY COLUMN type " + "VARCHAR(16) NOT NULL COMMENT 'DAILY/NEWBIE/AI_GENERATED'"); log.info("已扩展growth_task.type为支持AI_GENERATED"); } catch (Exception e) { log.warn("扩展growth_task.type失败(可能已经扩展): " + e.getMessage()); } ensureColumn("growth_task", "dimension", "VARCHAR(20) NULL COMMENT '五维维度(body/mind/wisdom/action/wealth)'"); ensureColumn("growth_task", "source_conversation_id", "VARCHAR(100) NULL COMMENT '来源AI会话ID'"); log.info("迁移97: growth_task 表扩展完成"); // 迁移98: health_gut_flora 表扩展 level 字段长度(支持 CLASS/ORDER/FAMILY) try { jdbcTemplate.execute("ALTER TABLE health_gut_flora MODIFY COLUMN level VARCHAR(20) COMMENT '级别: CLASS/ORDER/FAMILY/GENUS/SPECIES'"); log.info("已扩展health_gut_flora.level字段长度"); } catch (Exception e) { log.warn("扩展health_gut_flora.level失败: {}", e.getMessage()); } log.info("迁移 98: health_gut_flora 表扩展完成"); // 迁移 100: health_gut_flora 表扩展 population_level 和 detection_rate 字段长度(支持更长百分比) try { jdbcTemplate.execute("ALTER TABLE health_gut_flora MODIFY COLUMN population_level VARCHAR(100) DEFAULT NULL COMMENT '处于人群%水平'"); log.info("已扩展 health_gut_flora.population_level 字段长度至 VARCHAR(100)"); } catch (Exception e) { log.warn("扩展 health_gut_flora.population_level 失败:{}", e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE health_gut_flora MODIFY COLUMN detection_rate VARCHAR(100) DEFAULT NULL COMMENT '%正常人有检出'"); log.info("已扩展 health_gut_flora.detection_rate 字段长度至 VARCHAR(100)"); } catch (Exception e) { log.warn("扩展 health_gut_flora.detection_rate 失败:{}", e.getMessage()); } log.info("迁移 100: health_gut_flora 表扩展完成"); // 迁移99: users.role 从 ENUM 改为 VARCHAR(20)(支持动态角色值) try { jdbcTemplate.execute("ALTER TABLE users MODIFY COLUMN role VARCHAR(20) NOT NULL COMMENT '角色: parent/child/teacher'"); log.info("已修改users.role为VARCHAR(20)"); } catch (Exception e) { log.warn("修改users.role失败(可能已修改): " + e.getMessage()); } // 迁移100: assessment_appointments表添加assessor_id(测评师角色分离) ensureColumn("assessment_appointments", "assessor_id", "BIGINT COMMENT '测评师ID(供应商体系), 替代guideId语义'"); // 迁移100: dan_assessment_executions表添加appointment_id ensureColumn("dan_assessment_executions", "appointment_id", "BIGINT COMMENT '关联assessment_appointments.id'"); log.info("迁移100: 已添加assessor_id和appointment_id字段"); // 迁移101: 创建 micro_actions 表(12个预定义1秒微行动) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS micro_actions (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "code VARCHAR(32) NOT NULL COMMENT '行动编码: DRINK_WATER, DEEP_BREATH, ...', " + "name VARCHAR(50) NOT NULL COMMENT '行动名称', " + "icon VARCHAR(10) COMMENT '图标emoji', " + "description VARCHAR(200) COMMENT '行动描述', " + "sort_order INT DEFAULT 0 COMMENT '排序号', " + "status TINYINT DEFAULT 1 COMMENT '状态: 1启用 0禁用', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_code (code), " + "INDEX idx_status (status)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微行动定义(12个1秒健康行动)'"); log.info("已创建micro_actions表"); // 插入12个预定义微行动 jdbcTemplate.execute("INSERT IGNORE INTO micro_actions (code, name, icon, description, sort_order) VALUES " + "('DRINK_WATER', '喝一杯水', '☕️', '喝一杯温开水,补充水分', 1), " + "('DEEP_BREATH', '深呼吸3次', '🫁', '深吸慢呼3次,放松身心', 2), " + "('STRETCH', '站起来伸懒腰', '🧘', '站起来伸个懒腰,活动筋骨', 3), " + "('LOOK_FAR', '看远处10秒', '🌿', '看远处绿色植物10秒,缓解眼疲劳', 4), " + "('SMILE', '微笑一下', '😊', '对自己微笑,心情会变好', 5), " + "('NECK_ROLL', '转转头颈5圈', '🔄', '缓慢转动头颈5圈,放松颈椎', 6), " + "('FIST_RELAX', '握拳放松3次', '✊', '用力握拳3秒后放松,重复3次', 7), " + "('SIP_WATER', '喝口水润喉', '💧', '小口喝水润润喉咙', 8), " + "('BLINK', '眨眨眼5次', '👁️', '快速眨眼5次,湿润眼球', 9), " + "('ACUPRESSURE', '按揉合谷穴3下', '🖐️', '按揉虎口合谷穴3下,提神醒脑', 10), " + "('TIPTOE', '踮脚尖5次', '⬆️', '踮起脚尖再放下,重复5次', 11), " + "('SHRUG', '耸肩放松3次', '↕️', '耸肩至耳朵再放松,重复3次', 12)"); log.info("已初始化12个微行动"); } catch (Exception e) { log.warn("创建micro_actions表或初始化数据失败: {}", e.getMessage()); } // 迁移102: 创建 micro_action_records 表(微行动完成记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS micro_action_records (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " + "action_id BIGINT NOT NULL COMMENT '行动ID', " + "completed_date DATETIME NOT NULL COMMENT '完成日期', " + "earned_energy INT DEFAULT 1 COMMENT '获得能量值', " + "note VARCHAR(200) COMMENT '备注', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_child_date (child_id, completed_date), " + "INDEX idx_user_id (user_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微行动完成记录'"); log.info("已创建micro_action_records表"); } catch (Exception e) { log.warn("创建micro_action_records表失败: {}", e.getMessage()); } // 迁移103: 创建 daily_feedback 表(AI/预设每日健康反馈) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS daily_feedback (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " + "feedback_date DATETIME NOT NULL COMMENT '反馈日期', " + "feedback_type VARCHAR(20) DEFAULT 'daily' COMMENT '反馈类型: daily/weekly/milestone', " + "content VARCHAR(500) NOT NULL COMMENT 'AI生成的反馈文本', " + "milestone_event VARCHAR(100) COMMENT '关联的里程碑事件', " + "is_read TINYINT DEFAULT 0 COMMENT '是否已读: 0未读 1已读', " + "source VARCHAR(20) DEFAULT 'preset' COMMENT '来源: ai_generated / preset', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_child_date (child_id, feedback_date), " + "INDEX idx_feedback_type (feedback_type)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日健康反馈'"); log.info("已创建daily_feedback表"); } catch (Exception e) { log.warn("创建daily_feedback表失败: {}", e.getMessage()); } // 迁移104: 创建 health_alerts 表(健康行为智能预警) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_alerts (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT COMMENT '用户ID', " + "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " + "rule_code VARCHAR(10) NOT NULL COMMENT '规则编码: R001-R006', " + "alert_text VARCHAR(200) NOT NULL COMMENT '预警文案', " + "action_suggestion VARCHAR(200) COMMENT '行动建议', " + "severity VARCHAR(10) DEFAULT 'medium' COMMENT '严重程度: high/medium/low', " + "dismissed TINYINT DEFAULT 0 COMMENT '是否已关闭: 0未关闭 1已关闭', " + "dismissed_at DATETIME COMMENT '关闭时间', " + "alert_date DATETIME NOT NULL COMMENT '预警日期', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_child_date (child_id, alert_date), " + "INDEX idx_rule (rule_code)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康预警记录'"); log.info("已创建health_alerts表"); } catch (Exception e) { log.warn("创建health_alerts表失败: {}", e.getMessage()); } // 迁移105: 创建 health_level 表(身份重塑等级定义) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_level (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "level_num INT NOT NULL COMMENT '等级编号 1-5', " + "title VARCHAR(50) NOT NULL COMMENT '称号', " + "badge_icon VARCHAR(20) COMMENT '徽章图标', " + "min_score INT DEFAULT 0 COMMENT '所需最低健康积分', " + "max_score INT DEFAULT 99999 COMMENT '最高积分上限'" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康等级定义'"); log.info("已创建health_level表"); } catch (Exception e) { log.warn("创建health_level表失败: {}", e.getMessage()); } // 迁移106: 创建 health_timeline_event 表(健康故事时间线) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_timeline_event (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT COMMENT '用户ID', " + "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " + "event_type VARCHAR(30) NOT NULL COMMENT '事件类型: streak_7/level_2/first_report', " + "event_text VARCHAR(200) NOT NULL COMMENT '事件描述', " + "ref_id VARCHAR(50) COMMENT '关联ID', " + "event_date DATETIME NOT NULL COMMENT '事件日期', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_child_date (child_id, event_date), " + "INDEX idx_event_type (event_type)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康时间线事件'"); log.info("已创建health_timeline_event表"); } catch (Exception e) { log.warn("创建health_timeline_event表失败: {}", e.getMessage()); } // 迁移107: 创建 family_challenge 表(P2-2家庭挑战) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_challenge (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "challenge_type VARCHAR(50) NOT NULL COMMENT 'full_checkin/sports/health_week', " + "title VARCHAR(200) NOT NULL COMMENT '挑战标题', " + "description TEXT COMMENT '挑战描述', " + "duration_days INT DEFAULT 7 COMMENT '持续天数', " + "reward_points INT DEFAULT 0 COMMENT '奖励积分', " + "reward_badge VARCHAR(100) COMMENT '奖励徽章', " + "start_date DATE COMMENT '开始日期', " + "end_date DATE COMMENT '结束日期', " + "status VARCHAR(20) DEFAULT 'active' COMMENT 'active/completed/cancelled', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " + "INDEX idx_family_id (family_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战'"); log.info("已创建family_challenge表"); } catch (Exception e) { log.warn("创建family_challenge表失败: {}", e.getMessage()); } // 迁移108: 创建 family_challenge_progress 表(P2-2挑战进度) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_challenge_progress (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "challenge_id BIGINT NOT NULL COMMENT '挑战ID', " + "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " + "progress_value INT DEFAULT 0 COMMENT '进度值', " + "completed TINYINT(1) DEFAULT 0 COMMENT '是否完成', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', " + "INDEX idx_challenge_id (challenge_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战进度'"); log.info("已创建family_challenge_progress表"); } catch (Exception e) { log.warn("创建family_challenge_progress表失败: {}", e.getMessage()); } // 迁移109: 创建 health_interaction 表(P2-2点赞记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_interaction (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "from_user_id BIGINT NOT NULL COMMENT '点赞用户ID', " + "to_user_id BIGINT NOT NULL COMMENT '被赞用户ID', " + "action_type VARCHAR(50) NOT NULL COMMENT '行为类型', " + "action_id BIGINT COMMENT '关联行为记录ID', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " + "INDEX idx_family_action (family_id, action_type, action_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康互动记录(点赞)'"); log.info("已创建health_interaction表"); } catch (Exception e) { log.warn("创建health_interaction表失败: {}", e.getMessage()); } // 迁移110: 创建 health_circle 表(P3 健康圈) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_circle (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "name VARCHAR(100) NOT NULL COMMENT '圈名称', " + "description VARCHAR(500) COMMENT '圈描述', " + "creator_family_id BIGINT COMMENT '创建家庭ID', " + "invite_code VARCHAR(32) COMMENT '邀请码', " + "member_limit INT DEFAULT 50 COMMENT '成员上限', " + "status VARCHAR(20) DEFAULT 'active' COMMENT '状态 active/closed', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " + "INDEX idx_invite_code (invite_code), " + "INDEX idx_creator (creator_family_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康圈'"); log.info("已创建health_circle表"); } catch (Exception e) { log.warn("创建health_circle表失败: {}", e.getMessage()); } // 迁移111: 创建 health_circle_member 表(P3 健康圈成员) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_circle_member (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "circle_id BIGINT NOT NULL COMMENT '圈ID', " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "role VARCHAR(20) DEFAULT 'member' COMMENT '角色 member/admin', " + "joined_at DATETIME COMMENT '加入时间', " + "left_at DATETIME COMMENT '退出时间', " + "status VARCHAR(20) DEFAULT 'active' COMMENT '状态 active/inactive', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " + "INDEX idx_circle_id (circle_id), " + "INDEX idx_family_id (family_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康圈成员'"); log.info("已创建health_circle_member表"); } catch (Exception e) { log.warn("创建health_circle_member表失败: {}", e.getMessage()); } // 迁移112: 创建 circle_challenge 表(P3 圈挑战) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS circle_challenge (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "circle_id BIGINT NOT NULL COMMENT '圈ID', " + "challenge_type VARCHAR(50) NOT NULL COMMENT '挑战类型 checkin/step/meditation', " + "title VARCHAR(200) NOT NULL COMMENT '挑战标题', " + "description VARCHAR(500) COMMENT '挑战描述', " + "duration_days INT DEFAULT 7 COMMENT '持续天数', " + "period VARCHAR(20) DEFAULT 'weekly' COMMENT '周期 weekly/monthly', " + "start_date DATETIME COMMENT '开始日期', " + "end_date DATETIME COMMENT '结束日期', " + "status VARCHAR(20) DEFAULT 'active' COMMENT '状态 active/completed/cancelled', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " + "INDEX idx_circle_id (circle_id), " + "INDEX idx_status (status)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈挑战'"); log.info("已创建circle_challenge表"); } catch (Exception e) { log.warn("创建circle_challenge表失败: {}", e.getMessage()); } // 迁移113: 创建 family_commission 表(P2 推广佣金) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_commission (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "promoter_family_id BIGINT NOT NULL COMMENT '推广方家庭ID', " + "buyer_family_id BIGINT NOT NULL COMMENT '购买方家庭ID', " + "order_id BIGINT COMMENT '关联订单ID', " + "amount DECIMAL(10,2) DEFAULT 0.00 COMMENT '订单金额', " + "commission_rate DECIMAL(5,2) DEFAULT 10.00 COMMENT '佣金比例%', " + "commission_amount DECIMAL(10,2) DEFAULT 0.00 COMMENT '佣金金额', " + "status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/paid/cancelled', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " + "paid_at DATETIME COMMENT '支付时间', " + "INDEX idx_promoter (promoter_family_id), " + "INDEX idx_buyer (buyer_family_id), " + "INDEX idx_order (order_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭推广佣金记录'"); log.info("已创建family_commission表"); } catch (Exception e) { log.warn("创建family_commission表失败: {}", e.getMessage()); } // 迁移114: 创建 promotion_binding 表(P2 推广绑定) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS promotion_binding (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "promoter_family_id BIGINT NOT NULL COMMENT '推广方家庭ID', " + "invitee_family_id BIGINT NOT NULL COMMENT '被邀请方家庭ID', " + "invite_code VARCHAR(32) COMMENT '使用的邀请码', " + "binding_type VARCHAR(20) DEFAULT 'direct' COMMENT 'direct/indirect', " + "status VARCHAR(20) DEFAULT 'active' COMMENT 'active/inactive', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " + "UNIQUE KEY uk_promoter_invitee (promoter_family_id, invitee_family_id), " + "INDEX idx_promoter (promoter_family_id), " + "INDEX idx_invitee (invitee_family_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推广绑定关系'"); log.info("已创建promotion_binding表"); } catch (Exception e) { log.warn("创建promotion_binding表失败: {}", e.getMessage()); } // 迁移115: 创建 activity_feedback 表(活动评价功能) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS activity_feedback (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', " + "activity_id BIGINT NOT NULL COMMENT '活动ID', " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "child_id BIGINT COMMENT '孩子ID', " + "ratings TEXT COMMENT '评分JSON: {\"组织形式\":5,\"会场气氛\":4}', " + "comment TEXT COMMENT '文字评价', " + "status TINYINT DEFAULT 0 COMMENT '状态: 0=待审核 1=已发布', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " + "INDEX idx_activity_id (activity_id), " + "INDEX idx_user_id (user_id), " + "INDEX idx_child_id (child_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动评价表'"); log.info("已创建activity_feedback表"); } catch (Exception e) { // 表已存在,忽略 } // 迁移115b: activity表添加feedback_dimensions字段 ensureColumn("activities", "feedback_dimensions", "TEXT COMMENT '评价维度列表JSON: [\"组织形式\",\"会场气氛\",\"主持人\",\"专家\"]'"); // 迁移116: growth_records表添加family_member_id和family_member_age列(修复/record/list 500错误) ensureColumn("growth_records", "family_member_id", "BIGINT COMMENT '关联家庭成员ID'"); ensureColumn("growth_records", "family_member_age", "INT COMMENT '成员年龄快照'"); // 迁移117: 创建 emotion_checkin 表情绪打卡表(Fix 心理与情绪模块 500 错误) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS emotion_checkin (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " + "mood_weather VARCHAR(50) COMMENT '心情晴雨表: sunny/cloudy/rainy/stormy', " + "emotion_tags VARCHAR(200) COMMENT '情绪标签(逗号分隔)', " + "photo_url VARCHAR(500) COMMENT '配图URL', " + "note TEXT COMMENT '文字备注', " + "energy_awarded INT DEFAULT 0 COMMENT '奖励能量值', " + "checkin_date DATE COMMENT '打卡日期', " + "mood_score INT COMMENT '心情分数(1-10)', " + "emotion_type VARCHAR(50) COMMENT '情绪类型', " + "stress_level INT COMMENT '压力等级(1-10)', " + "arousal_level INT COMMENT '激活水平(1-10)', " + "energy_level INT COMMENT '精力水平(1-10)', " + "sleep_hours DECIMAL(4,1) COMMENT '睡眠时长', " + "sleep_quality INT COMMENT '睡眠质量(1-10)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_child_date (child_id, checkin_date)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='情绪打卡记录'"); log.info("已创建emotion_checkin表"); } catch (Exception e) { log.warn("创建emotion_checkin表可能已存在: {}", e.getMessage()); } // 迁移118: 创建 emotion_alert 表情绪告警表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS emotion_alert (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " + "alert_type VARCHAR(50) NOT NULL COMMENT '告警类型: consecutive_low_mood/crisis_keyword/sharp_decline', " + "severity VARCHAR(20) NOT NULL DEFAULT 'low' COMMENT '严重级别: low/medium/high', " + "title VARCHAR(200) COMMENT '告警标题', " + "message TEXT COMMENT '告警具体描述', " + "related_checkin_id BIGINT COMMENT '关联情绪打卡ID', " + "resolved TINYINT(1) DEFAULT 0 COMMENT '是否已处理', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "resolved_at DATETIME COMMENT '处理时间', " + "INDEX idx_child (child_id), " + "INDEX idx_alert_type (alert_type)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='情绪告警'"); log.info("已创建emotion_alert表"); } catch (Exception e) { log.warn("创建emotion_alert表可能已存在: {}", e.getMessage()); } // 迁移119: 创建 membership_levels 表(会员等级配置) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS membership_levels (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "level_code VARCHAR(50) NOT NULL COMMENT '等级编码: FREE/PROFESSIONAL/ENTERPRISE', " + "level_name VARCHAR(100) NOT NULL COMMENT '等级名称', " + "level_desc TEXT COMMENT '等级描述', " + "price_monthly INT COMMENT '月费(分)', " + "price_quarterly INT COMMENT '季费(分)', " + "price_yearly INT COMMENT '年费(分)', " + "features TEXT COMMENT '功能权限列表(JSON)', " + "max_children INT DEFAULT 0 COMMENT '可添加孩子数量', " + "max_tasks_per_day INT DEFAULT 0 COMMENT '每日任务上限', " + "ai_review_enabled TINYINT(1) DEFAULT 0 COMMENT 'AI审核功能', " + "priority_support TINYINT(1) DEFAULT 0 COMMENT '优先客服', " + "teacher_consultation TINYINT(1) DEFAULT 0 COMMENT '老师咨询', " + "level_order INT DEFAULT 1 COMMENT '级别数字(越小级别越低)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_level_code (level_code)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员等级配置'"); log.info("已创建membership_levels表"); } catch (Exception e) { log.warn("创建membership_levels表可能已存在: {}", e.getMessage()); } // 迁移120: 插入 membership_levels 种子数据 try { jdbcTemplate.execute("INSERT IGNORE INTO membership_levels (level_code, level_name, level_desc, price_monthly, price_quarterly, price_yearly, max_children, max_tasks_per_day, ai_review_enabled, priority_support, teacher_consultation) VALUES " + "('FREE', '免费会员', '基础免费功能', 0, 0, 0, 2, 5, 0, 0, 0), " + "('FAMILY', '家庭会员', '家庭会员全部功能', 13140, 32850, 131400, 6, 20, 1, 1, 1), " + "('PROVIDER', '标准会员', '标准会员服务', 13140, 32850, 131400, 10, 50, 1, 1, 1)"); log.info("已插入membership_levels种子数据"); } catch (Exception e) { log.warn("插入membership_levels种子数据可能已存在: {}", e.getMessage()); } // 迁移121: 创建 family_memberships 表(家庭会员订阅记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_memberships (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "level_code VARCHAR(50) NOT NULL DEFAULT 'FREE' COMMENT '会员等级编码', " + "start_date DATETIME COMMENT '开始时间', " + "end_date DATETIME COMMENT '结束时间', " + "payment_method VARCHAR(50) COMMENT '支付方式: wechat/alipay/card', " + "payment_status VARCHAR(20) DEFAULT 'pending' COMMENT '支付状态: pending/paid/expired/refunded', " + "order_no VARCHAR(100) COMMENT '订单号', " + "transaction_id VARCHAR(200) COMMENT '交易流水号', " + "amount INT COMMENT '支付金额(分)', " + "auto_renew TINYINT(1) DEFAULT 0 COMMENT '自动续费', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_family_id (family_id), " + "INDEX idx_end_date (end_date)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭会员订阅'"); log.info("已创建family_memberships表"); } catch (Exception e) { log.warn("创建family_memberships表可能已存在: {}", e.getMessage()); } // 迁移121: 创建 share_events 表(分享事件记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS share_events (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '分享用户ID', " + "target_type VARCHAR(50) NOT NULL COMMENT '分享目标类型: article/growth/product', " + "target_id BIGINT NOT NULL COMMENT '分享目标ID', " + "share_channel VARCHAR(50) COMMENT '分享渠道: wechat/timeline/qq/url', " + "share_title VARCHAR(200) COMMENT '分享标题', " + "share_url VARCHAR(500) COMMENT '分享URL', " + "click_count INT DEFAULT 0 COMMENT '点击次数', " + "read_count INT DEFAULT 0 COMMENT '阅读次数', " + "reward_points INT DEFAULT 0 COMMENT '奖励积分', " + "status VARCHAR(20) DEFAULT 'active' COMMENT '状态: active/expired', " + "expires_at DATETIME COMMENT '过期时间', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_user (user_id), " + "INDEX idx_target (target_type, target_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分享事件'"); log.info("已创建share_events表"); } catch (Exception e) { log.warn("创建share_events表可能已存在: {}", e.getMessage()); } // 迁移122: 创建 share_reads 表(分享阅读记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS share_reads (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "share_event_id BIGINT NOT NULL COMMENT '分享事件ID', " + "reader_id BIGINT COMMENT '阅读者用户ID', " + "reader_ip VARCHAR(50) COMMENT '阅读者IP', " + "read_duration INT COMMENT '阅读时长(秒)', " + "read_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '阅读时间', " + "INDEX idx_share_event (share_event_id), " + "INDEX idx_reader (reader_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分享阅读记录'"); log.info("已创建share_reads表"); } catch (Exception e) { log.warn("创建share_reads表可能已存在: {}", e.getMessage()); } // 迁移123: 创建 food_recommend_idx 表(饮食推荐指数) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS food_recommend_idx (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "food_id BIGINT NOT NULL COMMENT '食材ID', " + "food_name VARCHAR(100) COMMENT '食材名称', " + "index_score INT COMMENT '推荐指数(0-100)', " + "health_report_id BIGINT COMMENT '关联健康报告ID', " + "calculated_at DATETIME COMMENT '计算时间', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_user (user_id), " + "INDEX idx_food (food_id), " + "INDEX idx_report (health_report_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='饮食推荐指数'"); log.info("已创建food_recommend_idx表"); } catch (Exception e) { log.warn("创建food_recommend_idx表可能已存在: {}", e.getMessage()); } // 迁移124: 创建 points_exchange_products 表(积分兑换商品) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS points_exchange_products (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "name VARCHAR(200) NOT NULL COMMENT '商品名称', " + "description TEXT COMMENT '商品描述', " + "cover_image VARCHAR(500) COMMENT '封面图片', " + "points_price INT NOT NULL COMMENT '积分价格', " + "stock INT DEFAULT 0 COMMENT '库存', " + "product_type VARCHAR(50) COMMENT '商品类型', " + "category VARCHAR(50) COMMENT '分类', " + "expiry_days INT COMMENT '有效期(天)', " + "sort_order INT DEFAULT 0 COMMENT '排序', " + "status VARCHAR(20) DEFAULT 'active' COMMENT '状态: active/disabled', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_status (status), " + "INDEX idx_category (category)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='积分兑换商品'"); log.info("已创建points_exchange_products表"); } catch (Exception e) { log.warn("创建points_exchange_products表可能已存在: {}", e.getMessage()); } // 迁移125: 创建 points_exchange_records 表(积分兑换记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS points_exchange_records (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT COMMENT '用户ID', " + "child_id BIGINT COMMENT '孩子ID', " + "family_member_id BIGINT COMMENT '家庭成员ID', " + "product_id BIGINT NOT NULL COMMENT '商品ID', " + "product_name VARCHAR(200) COMMENT '商品名称快照', " + "points_cost INT NOT NULL COMMENT '消耗积分', " + "quantity INT DEFAULT 1 COMMENT '数量', " + "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/completed/cancelled', " + "redeem_code VARCHAR(100) COMMENT '兑换码', " + "expired_at DATETIME COMMENT '过期时间', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_user (user_id), " + "INDEX idx_product (product_id), " + "INDEX idx_status (status)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='积分兑换记录'"); log.info("已创建points_exchange_records表"); } catch (Exception e) { log.warn("创建points_exchange_records表可能已存在: {}", e.getMessage()); } // 迁移126: emotion_checkin表修复列类型与添加新列 ensureColumn("emotion_checkin", "mood_weather", "VARCHAR(20) DEFAULT NULL COMMENT '心情天气: sunny/cloudy/rainy/stormy'"); ensureColumn("emotion_checkin", "emotion_tags", "VARCHAR(200) DEFAULT NULL COMMENT '情绪标签,逗号分隔'"); ensureColumn("emotion_checkin", "energy_awarded", "INT DEFAULT 0 COMMENT '发放能量值'"); ensureColumn("emotion_checkin", "mood_score", "INT DEFAULT NULL COMMENT '心情分数(0-100)'"); ensureColumn("emotion_checkin", "stress_level", "INT DEFAULT NULL COMMENT '压力等级(1-5)'"); ensureColumn("emotion_checkin", "arousal_level", "INT DEFAULT NULL COMMENT '唤醒等级(1-5)'"); ensureColumn("emotion_checkin", "sleep_hours", "DECIMAL(4,1) DEFAULT NULL COMMENT '睡眠时长(小时)'"); ensureColumn("emotion_checkin", "sleep_quality", "VARCHAR(20) DEFAULT NULL COMMENT '睡眠质量: good/fair/poor'"); try { jdbcTemplate.execute("ALTER TABLE emotion_checkin MODIFY COLUMN user_id BIGINT NULL COMMENT '用户ID(旧字段,可为空)'"); log.info("已修改emotion_checkin.user_id为可空"); } catch (Exception e) { log.warn("修改emotion_checkin.user_id可能已处理: {}", e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE emotion_checkin MODIFY COLUMN emotion_type VARCHAR(50) DEFAULT 'custom' COMMENT '情绪类型'"); log.info("已修改emotion_checkin.emotion_type为VARCHAR"); } catch (Exception e) { log.warn("修改emotion_checkin.emotion_type可能已处理: {}", e.getMessage()); } ensureColumn("family_fortune", "fortune_level_text", "VARCHAR(50) DEFAULT NULL COMMENT '运势等级文字'"); ensureColumn("family_fortune", "fortune_description", "VARCHAR(500) DEFAULT NULL COMMENT '运势描述'"); ensureColumn("family_fortune", "mood_emoji", "VARCHAR(20) DEFAULT NULL COMMENT '心情表情'"); ensureColumn("family_fortune", "mood_text", "VARCHAR(100) DEFAULT NULL COMMENT '心情描述文字'"); ensureColumn("family_fortune", "weekly_tip", "VARCHAR(500) DEFAULT NULL COMMENT '本周建议'"); // 迁移129: membership_levels表添加price_quarterly列 ensureColumn("membership_levels", "price_quarterly", "DECIMAL(10,2) DEFAULT NULL COMMENT '季度价格'"); // 迁移130: share_events表添加缺失列 ensureColumn("share_events", "target_type", "VARCHAR(20) DEFAULT NULL COMMENT '分享目标类型'"); ensureColumn("share_events", "share_channel", "VARCHAR(20) DEFAULT NULL COMMENT '分享渠道'"); ensureColumn("share_events", "share_title", "VARCHAR(200) DEFAULT NULL COMMENT '分享标题'"); ensureColumn("share_events", "share_url", "VARCHAR(500) DEFAULT NULL COMMENT '分享URL'"); ensureColumn("share_events", "read_count", "INT DEFAULT 0 COMMENT '阅读次数'"); ensureColumn("share_events", "reward_points", "INT DEFAULT 0 COMMENT '奖励积分'"); ensureColumn("share_events", "status", "VARCHAR(20) DEFAULT 'active' COMMENT '状态'"); ensureColumn("share_events", "expires_at", "DATETIME DEFAULT NULL COMMENT '过期时间'"); ensureColumn("share_events", "updated_at", "DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'"); // 迁移131: share_reads表添加缺失列 ensureColumn("share_reads", "read_duration", "INT DEFAULT NULL COMMENT '阅读时长(秒)'"); ensureColumn("share_reads", "read_at", "DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '阅读时间'"); // 迁移132: points_exchange_records表确保family_member_id列 ensureColumn("points_exchange_records", "family_member_id", "BIGINT COMMENT '家庭成员ID'"); // 迁移133: products表列对齐 — rename min_quantity→purchase_count_threshold, add missing columns if (columnExists("products", "min_quantity")) { try { jdbcTemplate.execute("ALTER TABLE products CHANGE COLUMN min_quantity purchase_count_threshold INT DEFAULT 0 COMMENT '最小购买数量限制'"); log.info("已重命名products.min_quantity→purchase_count_threshold"); } catch (Exception e) { log.warn("重命名products列失败(可能已处理): {}", e.getMessage()); } } // 迁移134: products表添加sort_order列(商品按排序字段排序展示) ensureColumn("products", "sort_order", "INT DEFAULT 0 COMMENT '排序(值越小越靠前)'"); ensureColumn("products", "recommendation_tags", "VARCHAR(500) DEFAULT NULL COMMENT '推荐标签'"); ensureColumn("products", "is_recommended", "TINYINT(1) DEFAULT 0 COMMENT '是否推荐商品 0否 1是'"); ensureColumn("products", "repurchase_interval_days", "INT DEFAULT 30 COMMENT '复购间隔天数'"); // 迁移134: 创建 category_ppoint 表(品类P点比例配置) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS category_ppoint (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "category_id BIGINT NOT NULL COMMENT '商品类目ID', " + "ratio INT DEFAULT 0 COMMENT 'P点比例(千分比)', " + "start_date DATETIME COMMENT '生效日期', " + "end_date DATETIME COMMENT '失效日期(null=永不过期)', " + "created_by BIGINT COMMENT '创建人', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " + "INDEX idx_category_id (category_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='品类P点比例配置'"); log.info("已创建category_ppoint表"); } catch (Exception e) { log.warn("创建category_ppoint表失败: {}", e.getMessage()); } // 迁移135: 创建 commission_dist_log 表(P点分润记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS commission_dist_log (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "buyer_id BIGINT NOT NULL COMMENT '买家用户ID', " + "order_id BIGINT COMMENT '订单ID', " + "product_id BIGINT COMMENT '商品ID', " + "source_ppoint INT DEFAULT 0 COMMENT '本次产生的原始P点数量', " + "level1_user_id BIGINT COMMENT '一级推荐人用户ID', " + "level1_ppoint INT DEFAULT 0 COMMENT '一级推荐人获得P点', " + "level1_wealth_percent INT DEFAULT 0 COMMENT '一级推荐人当时财富等级(profitSharePercent)', " + "level2_user_id BIGINT COMMENT '二级推荐人用户ID', " + "level2_ppoint INT DEFAULT 0 COMMENT '二级推荐人获得P点', " + "level2_wealth_percent INT DEFAULT 0 COMMENT '二级推荐人当时财富等级(profitSharePercent)', " + "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/completed/failed', " + "remark VARCHAR(500) COMMENT '备注', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', " + "INDEX idx_buyer (buyer_id), " + "INDEX idx_order (order_id), " + "INDEX idx_level1 (level1_user_id), " + "INDEX idx_level2 (level2_user_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='P点分润记录'"); log.info("已创建commission_dist_log表"); } catch (Exception e) { log.warn("创建commission_dist_log表失败: {}", e.getMessage()); } // 迁移136: payment_orders表添加period列(会员订阅周期) ensureColumn("payment_orders", "period", "VARCHAR(20) DEFAULT 'yearly' COMMENT '订阅周期: monthly/quarterly/yearly'"); // 迁移137: 扩大payment_orders.payment_type列长度(原列可能为VARCHAR(5),存不下"yearly") try { jdbcTemplate.execute("ALTER TABLE payment_orders MODIFY COLUMN payment_type VARCHAR(20) DEFAULT 'yearly' COMMENT '支付类型: yearly/monthly/trial'"); log.info("已修改payment_orders.payment_type列长度为VARCHAR(20)"); } catch (Exception e) { log.warn("修改payment_orders.payment_type列长度失败: {}", e.getMessage()); } // 迁移138: tasks表添加dimension_code和dimension_weights列(原migration102内从未被调用) ensureColumn("tasks", "dimension_code", "VARCHAR(20) COMMENT '五维维度: body/mind/wisdom/action/wealth'"); ensureColumn("tasks", "dimension_weights", "JSON COMMENT '五维权重: {\"body\":0,\"mind\":0,\"wisdom\":0,\"action\":0,\"wealth\":0}'"); // 迁移139: products表添加family_scope列(推荐系统-家庭消费去重,原migration102内从未被调用) ensureColumn("products", "family_scope", "TINYINT DEFAULT 1 COMMENT '是否面向家庭: 1=家庭成员消费过即排除, 0=仅按个人消费排除'"); // 迁移140: activities表添加family_scope列(推荐系统-家庭参与去重,原migration102内从未被调用) ensureColumn("activities", "family_scope", "TINYINT DEFAULT 1 COMMENT '是否面向家庭: 1=家庭成员参与过即排除, 0=仅按个人参与排除'"); // 迁移141: 修正五维权重维度code体系(后台DimensionWeightPicker曾用mind=智/heart=心,标准体系为wisdom=智/mind=心) // 旧体系写入的数据导致小程序端按wisdom/mind查询JSON_EXTRACT匹配不到,新活动显示不出来 try { // 1. dimension_weights 表:仅处理含 heart 的目标(旧体系写入的),mind(智)→wisdom,heart(心)→mind int dwHeartCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM dimension_weights WHERE dimension = 'heart'", Integer.class); if (dwHeartCount > 0) { jdbcTemplate.update("UPDATE dimension_weights dw " + "JOIN (SELECT DISTINCT target_type, target_id FROM dimension_weights WHERE dimension = 'heart') h " + "ON dw.target_type = h.target_type AND dw.target_id = h.target_id " + "SET dw.dimension = 'wisdom' WHERE dw.dimension = 'mind'"); jdbcTemplate.update("UPDATE dimension_weights SET dimension = 'mind' WHERE dimension = 'heart'"); log.info("已修正dimension_weights表维度code: {}条heart记录", dwHeartCount); } } catch (Exception e) { log.warn("修正dimension_weights表维度code失败: {}", e.getMessage()); } try { // 2. 主表 dimension_weights JSON 列:含 heart 键的记录,mind键→wisdom键,heart键→mind键 String[] jsonTables = {"activities", "products", "articles"}; for (String table : jsonTables) { int jsonCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM " + table + " WHERE dimension_weights IS NOT NULL " + "AND JSON_CONTAINS_PATH(dimension_weights, 'one', '$.heart')", Integer.class); if (jsonCount > 0) { jdbcTemplate.update("UPDATE " + table + " SET dimension_weights = JSON_SET(" + "JSON_REMOVE(JSON_REMOVE(dimension_weights, '$.mind'), '$.heart'), " + "'$.wisdom', JSON_EXTRACT(dimension_weights, '$.mind'), " + "'$.mind', JSON_EXTRACT(dimension_weights, '$.heart')) " + "WHERE dimension_weights IS NOT NULL " + "AND JSON_CONTAINS_PATH(dimension_weights, 'one', '$.heart')"); log.info("已修正{}表dimension_weights JSON: {}条", table, jsonCount); } } } catch (Exception e) { log.warn("修正主表dimension_weights JSON失败: {}", e.getMessage()); } // 迁移142: activities表添加报名截止时间与会员/非会员双收费字段(活动升级:报名时间窗口+孩子/家长双收费+会员区分,需求#3#6) ensureColumn("activities", "registration_deadline", "DATETIME COMMENT '报名截止时间(NULL=不限制, 默认=活动开始时间)'"); ensureColumn("activities", "child_fee", "INT DEFAULT 0 COMMENT '孩子·非会员价(分)'"); ensureColumn("activities", "child_member_fee", "INT DEFAULT 0 COMMENT '孩子·会员价(分), 0=免费'"); ensureColumn("activities", "parent_fee", "INT DEFAULT 0 COMMENT '家长·非会员价(分)'"); ensureColumn("activities", "parent_member_fee", "INT DEFAULT 0 COMMENT '家长·会员价(分), 0=免费'"); ensureColumn("activities", "creator_id", "BIGINT COMMENT '创建人userId(普通用户发布者, admin创建可为空)'"); // 迁移143: 创建活动报名订单表(活动付费报名:先支付后报名,需求#4) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS activity_orders (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "order_no VARCHAR(64) NOT NULL UNIQUE COMMENT '订单号(ACT+时间戳+随机)', " + "activity_id BIGINT NOT NULL COMMENT '活动ID', " + "user_id BIGINT NOT NULL COMMENT '下单人userId', " + "total_amount INT NOT NULL COMMENT '实付金额(分)', " + "registrants_json TEXT COMMENT '报名人快照JSON: [{name, phone, childId, type: child/parent, fee}]', " + "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/paid/cancelled', " + "transaction_id VARCHAR(64) COMMENT '微信支付单号', " + "paid_at DATETIME COMMENT '支付时间', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " + "INDEX idx_activity (activity_id), " + "INDEX idx_user (user_id), " + "INDEX idx_status (status)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动报名订单表'"); log.info("已创建activity_orders表"); } catch (Exception e) { log.warn("创建activity_orders表失败: {}", e.getMessage()); } // 迁移144: 创建活动总结资料表(总结中阶段发布人上传资料,需求#9) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS activity_materials (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "activity_id BIGINT NOT NULL COMMENT '活动ID', " + "title VARCHAR(200) COMMENT '标题', " + "file_url VARCHAR(500) NOT NULL COMMENT '文件地址(图片/PDF/外链URL)', " + "file_type VARCHAR(20) DEFAULT 'image' COMMENT '类型: image/pdf/link', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " + "INDEX idx_activity (activity_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动总结资料表'"); log.info("已创建activity_materials表"); } catch (Exception e) { log.warn("创建activity_materials表失败: {}", e.getMessage()); } } private void runMigration82() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS ai_conversation_summary (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "conversation_id VARCHAR(100) NOT NULL COMMENT 'Dify会话ID', " + "summary_text TEXT COMMENT '对话摘要文本', " + "summary_tokens INT DEFAULT 0 COMMENT '摘要token数', " + "message_count INT DEFAULT 0 COMMENT '本次对话消息数', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " + "INDEX idx_conversation (conversation_id), " + "INDEX idx_user (user_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI对话摘要'"); log.info("已创建ai_conversation_summary表"); } catch (Exception e) { log.warn("创建ai_conversation_summary表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS ai_user_facts (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "fact_key VARCHAR(100) NOT NULL COMMENT '事实键', " + "fact_value VARCHAR(500) COMMENT '事实值', " + "source_conversation VARCHAR(100) COMMENT '来源会话ID', " + "confidence DECIMAL(3,2) DEFAULT 1.00 COMMENT '置信度0-1', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', " + "INDEX idx_user_key (user_id, fact_key), " + "INDEX idx_conversation (source_conversation)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI用户关键事实'"); log.info("已创建ai_user_facts表"); } catch (Exception e) { log.warn("创建ai_user_facts表失败: {}", e.getMessage()); } } private void runMigration86() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_hierarchy (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "system_id BIGINT NOT NULL COMMENT '供应商体系ID', " + "parent_id BIGINT NOT NULL COMMENT '上级供应商ID', " + "child_id BIGINT NOT NULL COMMENT '下级供应商ID', " + "level INT DEFAULT 1 COMMENT '层级深度', " + "status VARCHAR(20) DEFAULT 'active', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_system_parent_child (system_id, parent_id, child_id), " + "KEY idx_system_child (system_id, child_id), " + "KEY idx_parent (parent_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商上下级关系表'"); log.info("已创建supply_hierarchy表"); } catch (Exception e) { log.warn("创建supply_hierarchy表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_hierarchy_change_request (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "system_id BIGINT NOT NULL, " + "applicant_id BIGINT NOT NULL COMMENT '申请人ID', " + "target_parent_id BIGINT NOT NULL COMMENT '目标上级ID', " + "status VARCHAR(20) DEFAULT 'pending', " + "reason VARCHAR(500), " + "reject_reason VARCHAR(500), " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "reviewed_at DATETIME, " + "reviewed_by BIGINT, " + "KEY idx_system_applicant (system_id, applicant_id), " + "KEY idx_status (status)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商更换上级申请表'"); log.info("已创建supply_hierarchy_change_request表"); } catch (Exception e) { log.warn("创建supply_hierarchy_change_request表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS butler_profiles (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL, " + "tier VARCHAR(10), " + "status VARCHAR(20) DEFAULT 'pending', " + "member_count INT DEFAULT 0, " + "max_members INT DEFAULT 50, " + "annual_fee_l1 BIGINT, " + "annual_fee_l2 BIGINT, " + "description TEXT, " + "certificates TEXT, " + "reject_reason VARCHAR(500), " + "commission_rate_l1 INT DEFAULT 1000, " + "commission_rate_l2 INT DEFAULT 5000, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "approved_at DATETIME, " + "UNIQUE KEY uk_user_id (user_id), " + "INDEX idx_status (status), " + "INDEX idx_tier (tier)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管家服务提供商档案'"); log.info("已创建butler_profiles表"); } catch (Exception e) { log.warn("创建butler_profiles表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS butler_service_records (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "butler_id BIGINT NOT NULL, " + "member_user_id BIGINT NOT NULL, " + "subscription_id BIGINT, " + "service_type VARCHAR(20) NOT NULL, " + "amount_p BIGINT NOT NULL, " + "platform_fee_p BIGINT DEFAULT 0, " + "butler_amount_p BIGINT DEFAULT 0, " + "member_tier VARCHAR(10), " + "`year_month` VARCHAR(7), " + "remark VARCHAR(500), " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_butler_id (butler_id), " + "INDEX idx_member_user_id (member_user_id), " + "INDEX idx_year_month (`year_month`)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管家服务记录'"); log.info("已创建butler_service_records表"); } catch (Exception e) { log.warn("创建butler_service_records表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS butler_commission_settlements (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "butler_id BIGINT NOT NULL, " + "`year_month` VARCHAR(7) NOT NULL, " + "member_count INT DEFAULT 0, " + "annual_base_amount_p BIGINT DEFAULT 0, " + "annual_butler_amount_p BIGINT DEFAULT 0, " + "annual_platform_amount_p BIGINT DEFAULT 0, " + "per_session_total_p BIGINT DEFAULT 0, " + "per_session_platform_total_p BIGINT DEFAULT 0, " + "per_session_butler_total_p BIGINT DEFAULT 0, " + "total_butler_amount_p BIGINT DEFAULT 0, " + "status VARCHAR(20) DEFAULT 'pending', " + "settled_at DATETIME, " + "settled_by BIGINT, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_butler_month (butler_id, `year_month`), " + "INDEX idx_status (status), " + "INDEX idx_year_month (`year_month`)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管家月度佣金结算单'"); log.info("已创建butler_commission_settlements表"); } catch (Exception e) { log.warn("创建butler_commission_settlements表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE users ADD COLUMN butler_status VARCHAR(20)"); jdbcTemplate.execute("ALTER TABLE users ADD COLUMN butler_info TEXT"); jdbcTemplate.execute("ALTER TABLE users ADD COLUMN butler_approved_at DATETIME"); log.info("已添加 butler 相关列到 users 表"); } catch (Exception e) { log.warn("Migration skipped (already exists): {}", e.getMessage()); } // 迁移 86: 创建 butler_assignments 表(会员 - 管家分配记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS butler_assignments (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '家庭 ID', " + "butler_user_id BIGINT NOT NULL COMMENT '管家用户 ID', " + "subscription_id BIGINT COMMENT '关联订阅 ID', " + "level VARCHAR(10) NOT NULL COMMENT '订阅级别 L1/L2', " + "assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_family_assignment (family_id), " + "INDEX idx_butler_user_id (butler_user_id), " + "INDEX idx_subscription_id (subscription_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员 - 管家分配记录'"); log.info("已创建 butler_assignments 表"); } catch (Exception e) { log.warn("创建 butler_assignments 表失败:{}", e.getMessage()); } } private void runMigration88() { // 迁移 88: 创建家庭公共账户体系(family_earnings / family_earnings_member / family_earnings_record) // 表 1: family_earnings(家庭总账) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_earnings (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL UNIQUE COMMENT '家庭 ID', " + "total_earned BIGINT NOT NULL DEFAULT 0 COMMENT '累计推荐收入(分)', " + "available_amount BIGINT NOT NULL DEFAULT 0 COMMENT '当前可支配余额(分)', " + "withdrawn_amount BIGINT NOT NULL DEFAULT 0 COMMENT '已提现金额(分)', " + "distributed_amount BIGINT NOT NULL DEFAULT 0 COMMENT '已颁发给成员的金额(分)', " + "updated_at DATETIME, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_family_id (family_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭公共账户'"); log.info("已创建 family_earnings 表"); } catch (Exception e) { log.warn("创建 family_earnings 表失败:{}", e.getMessage()); } // 表 2: family_earnings_member(成员已分配余额) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_earnings_member (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL, " + "user_id BIGINT NOT NULL, " + "allocated_amount BIGINT NOT NULL DEFAULT 0 COMMENT '管理员累计分配(分)', " + "consumed_amount BIGINT NOT NULL DEFAULT 0 COMMENT '已消费金额(分)', " + "available_amount BIGINT NOT NULL DEFAULT 0 COMMENT '可消费余额=allocated-consumed(分)', " + "can_use_family_balance TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否允许使用家庭公共池', " + "updated_at DATETIME, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "UNIQUE KEY idx_family_user (family_id, user_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭成员已分配余额'"); log.info("已创建 family_earnings_member 表"); } catch (Exception e) { log.warn("创建 family_earnings_member 表失败:{}", e.getMessage()); } // 表 3: family_earnings_record(收支流水) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_earnings_record (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL, " + "type VARCHAR(20) NOT NULL COMMENT 'earn 收入/withdraw 提现/distribute 颁发/consume 消费', " + "amount BIGINT NOT NULL COMMENT '金额(分)', " + "from_user_id BIGINT COMMENT '操作人', " + "to_user_id BIGINT COMMENT '受益人(颁发/消费时填写)', " + "note VARCHAR(255) COMMENT '备注', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_family_id (family_id), " + "INDEX idx_type (type)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭收益收支流水'"); log.info("已创建 family_earnings_record 表"); } catch (Exception e) { log.warn("创建 family_earnings_record 表失败:{}", e.getMessage()); } // commission_records 加 family_id 列 ensureColumn("commission_records", "family_id", "BIGINT COMMENT '归属家庭 ID'"); // 迁移90: contacts表添加家庭成员关联字段(联系人→家庭成员升级路径) ensureColumn("contacts", "family_member_id", "BIGINT DEFAULT NULL COMMENT '关联的家庭成员ID'"); ensureColumn("contacts", "invited_status", "VARCHAR(16) DEFAULT 'none' COMMENT '邀请加入家庭状态: none/invited/accepted/declined'"); // 迁移91: 创建family_member_logs表(家庭成员变更日志) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_member_logs (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "member_id BIGINT NOT NULL COMMENT '成员ID', " + "action VARCHAR(32) NOT NULL COMMENT '操作: join/leave/update/kick', " + "old_value VARCHAR(500) COMMENT '旧值', " + "new_value VARCHAR(500) COMMENT '新值', " + "operator_id BIGINT COMMENT '操作人用户ID', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_family_id (family_id), " + "INDEX idx_member_id (member_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭成员变更日志'"); log.info("已创建family_member_logs表"); } catch (Exception e) { // 表已存在,忽略错误 } // 迁移92: 创建health_meal_record表(饮食打卡记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_meal_record (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "member_id BIGINT NOT NULL COMMENT '家庭成员ID', " + "checkin_id BIGINT COMMENT '关联打卡ID', " + "meal_type VARCHAR(32) COMMENT '餐次: breakfast/lunch/dinner/snack', " + "food_items TEXT COMMENT '食物清单JSON', " + "total_calories INT COMMENT '总卡路里', " + "protein_g INT COMMENT '蛋白质克数', " + "fat_g INT COMMENT '脂肪克数', " + "carbs_g INT COMMENT '碳水克数', " + "fiber_g INT COMMENT '膳食纤维克数', " + "meal_time DATETIME COMMENT '用餐时间', " + "remark VARCHAR(500) COMMENT '备注', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_member_id (member_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='饮食打卡记录'"); log.info("已创建health_meal_record表"); } catch (Exception e) { // 表已存在,忽略错误 } // 迁移93: 创建health_meditation_record表(冥想打卡记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_meditation_record (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "member_id BIGINT NOT NULL COMMENT '家庭成员ID', " + "checkin_id BIGINT COMMENT '关联打卡ID', " + "duration_minutes INT COMMENT '时长(分钟)', " + "type VARCHAR(32) COMMENT '类型: mindfulness/guided/breathing/body_scan', " + "mood VARCHAR(32) COMMENT '冥想后心情', " + "remark VARCHAR(500) COMMENT '备注', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_member_id (member_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='冥想打卡记录'"); log.info("已创建health_meditation_record表"); } catch (Exception e) { // 表已存在,忽略错误 } // 迁移94: 创建ecom_supplier表(电商供应商管理) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS ecom_supplier (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "name VARCHAR(100) NOT NULL COMMENT '供应商名称', " + "contact_name VARCHAR(50) COMMENT '联系人', " + "contact_phone VARCHAR(20) COMMENT '联系电话', " + "status TINYINT DEFAULT 1 COMMENT '启用状态 1启用 0禁用', " + "remark VARCHAR(500) COMMENT '备注', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='电商供应商'"); log.info("已创建ecom_supplier表"); } catch (Exception e) { // 表已存在,忽略错误 } // 迁移95: 创建rebate_commission_config表(佣金返利配置) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS rebate_commission_config (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "name VARCHAR(100) NOT NULL COMMENT '配置名称', " + "rate INT COMMENT '佣金比例(万分比)', " + "rate_type TINYINT DEFAULT 1 COMMENT '比例类型 1百分比 2固定金额', " + "min_amount INT COMMENT '最低金额(分)', " + "max_amount INT COMMENT '最高金额(分)', " + "remark VARCHAR(500) COMMENT '备注', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='佣金返利配置'"); log.info("已创建rebate_commission_config表"); } catch (Exception e) { // 表已存在,忽略错误 } } private void runMigration89() { // 迁移 89: supply_system表添加models字段(AI模型供应商配置) ensureColumn("supply_system", "models", "TEXT COMMENT 'AI模型列表JSON,如[\"agnes-image-2.0-flash\",\"agnes-2.5-flash\"]'"); log.info("已添加models列到supply_system表"); } private void runMigration90() { // 迁移 90: supply_system表添加api_endpoint和api_key字段 ensureColumn("supply_system", "api_endpoint", "VARCHAR(512) COMMENT 'API端点'"); ensureColumn("supply_system", "api_key", "VARCHAR(256) COMMENT 'API密钥'"); log.info("已添加api_endpoint和api_key列到supply_system表"); } private void runMigration91() { // 迁移 91: users表添加营养师审核字段 ensureColumn("users", "nutritionist_status", "VARCHAR(20) DEFAULT NULL COMMENT '营养师审核状态: pending/approved/rejected'"); ensureColumn("users", "nutritionist_reject_reason", "VARCHAR(500) DEFAULT NULL COMMENT '营养师审核拒绝原因'"); log.info("已添加nutritionist_status和nutritionist_reject_reason列到users表"); } private void runMigration92() { // 迁移 92: 创建dan_assessment_executions表(DAN测评服务执行记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `dan_assessment_executions` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`assessment_execution_id` BIGINT NOT NULL COMMENT '执行记录ID', " + "`assessment_order_id` BIGINT NOT NULL COMMENT '订单ID', " + "`child_id` BIGINT NOT NULL COMMENT '孩子ID', " + "`family_id` BIGINT NOT NULL COMMENT '家庭ID', " + "`quota_id` BIGINT COMMENT '额度ID', " + "`assessor_id` BIGINT NOT NULL COMMENT '测评师userId', " + "`assessor_status` ENUM('pending','completed','rejected') DEFAULT 'pending' COMMENT '测评师状态', " + "`assessor_completed_at` DATETIME COMMENT '测评师完成时间', " + "`assessor_notes` VARCHAR(200) COMMENT '测评摘要/服务备注', " + "`planner_id` BIGINT COMMENT '规划师userId', " + "`planner_status` ENUM('pending','completed','rejected') DEFAULT 'pending' COMMENT '规划师状态', " + "`planner_completed_at` DATETIME COMMENT '规划师完成时间', " + "`planner_notes` VARCHAR(200) COMMENT '规划服务备注', " + "`user_confirmed` TINYINT(1) DEFAULT 0 COMMENT '用户确认:0未确认,1已确认', " + "`user_confirmed_at` DATETIME COMMENT '用户确认时间', " + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, " + "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "KEY `idx_assessment_execution_id` (`assessment_execution_id`), " + "KEY `idx_assessment_order_id` (`assessment_order_id`), " + "KEY `idx_assessor_id` (`assessor_id`), " + "KEY `idx_planner_id` (`planner_id`), " + "KEY `idx_child_id` (`child_id`), " + "KEY `idx_family_id` (`family_id`)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DAN测评服务执行记录'"); log.info("已创建dan_assessment_executions表"); } catch (Exception e) { // 表已存在,忽略错误 } // 迁移 92: 创建service_ratings表(服务评分记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `service_ratings` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`execution_id` BIGINT NOT NULL COMMENT '执行记录ID', " + "`assessment_order_id` BIGINT NOT NULL COMMENT '订单ID', " + "`user_id` BIGINT NOT NULL COMMENT '评分用户ID', " + "`assessor_id` BIGINT NOT NULL COMMENT '测评师userId', " + "`assessor_rating` TINYINT DEFAULT 0 COMMENT '测评师评分 1-5', " + "`assessor_comment` VARCHAR(500) COMMENT '评价内容', " + "`planner_id` BIGINT COMMENT '规划师userId', " + "`planner_rating` TINYINT COMMENT '规划师评分 1-5', " + "`planner_comment` VARCHAR(500) COMMENT '评价内容', " + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, " + "UNIQUE KEY `uniq_execution_user` (`execution_id`, `user_id`)," + "KEY `idx_assessor_id` (`assessor_id`)," + "KEY `idx_planner_id` (`planner_id`)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务评分记录'"); log.info("已创建service_ratings表"); } catch (Exception e) { // 表已存在,忽略错误 } // 迁移 92: 创建service_settlements表(服务结算记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `service_settlements` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`execution_id` BIGINT NOT NULL COMMENT '执行记录ID', " + "`assessment_order_id` BIGINT NOT NULL COMMENT '订单ID', " + "`provider_id` BIGINT NOT NULL COMMENT '服务提供方userId', " + "`provider_type` ENUM('assessor','planner') NOT NULL COMMENT '服务方类型', " + "`service_fee` BIGINT DEFAULT 0 COMMENT '服务费用(分)', " + "`commission_rate` INT COMMENT '佣金比例(bps)', " + "`commission_amount` BIGINT DEFAULT 0 COMMENT '结算金额(分)', " + "`status` ENUM('pending','settled','failed') DEFAULT 'pending' COMMENT '结算状态', " + "`settled_at` DATETIME COMMENT '结算时间', " + "`transaction_id` VARCHAR(100) COMMENT '转账交易ID', " + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, " + "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "KEY `idx_execution_id` (`execution_id`), " + "KEY `idx_provider_id` (`provider_id`), " + "KEY `idx_status` (`status`)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务结算记录'"); log.info("已创建service_settlements表"); } catch (Exception e) { // 表已存在,忽略错误 } // 迁移 96: product_orders 添加活动provision字段 + activity_provisions 表(活动商品统一管理) try { jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN activity_id BIGINT DEFAULT NULL COMMENT '关联活动ID(product_type=activity时有效)'"); log.info("已添加activity_id列到product_orders表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN provision_status VARCHAR(32) DEFAULT NULL COMMENT '活动provision状态: unprovisioned(待审核)/provisioned(已发货)/signed_in(已签到)/completed(已完成)/cancelled(审核拒绝)'"); log.info("已添加provision_status列到product_orders表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN auditor_id BIGINT DEFAULT NULL COMMENT '审核人ID'"); log.info("已添加auditor_id列到product_orders表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN audited_at DATETIME DEFAULT NULL COMMENT '审核时间'"); log.info("已添加audited_at列到product_orders表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN audit_reason TEXT DEFAULT NULL COMMENT '审核拒绝原因'"); log.info("已添加audit_reason列到product_orders表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS activity_provisions (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "order_id BIGINT NOT NULL COMMENT '商品订单ID', " + "order_no VARCHAR(64) NOT NULL COMMENT '订单编号', " + "product_id BIGINT NOT NULL COMMENT '商品ID', " + "activity_id BIGINT NOT NULL COMMENT '活动ID', " + "buyer_id BIGINT NOT NULL COMMENT '购买者用户ID', " + "family_id BIGINT COMMENT '家庭ID', " + "child_id BIGINT COMMENT '报名孩子ID(签到时填写)', " + "status VARCHAR(32) NOT NULL DEFAULT 'unprovisioned' COMMENT 'unprovisioned/provisioned/signed_in/completed/cancelled', " + "qr_code VARCHAR(512) COMMENT '签到二维码', " + "checked_in_at DATETIME COMMENT '签到时间', " + "completed_at DATETIME COMMENT '完成时间', " + "refund_amount INT COMMENT '退款金额(分)', " + "refund_at DATETIME COMMENT '退款时间', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_order (order_id), " + "INDEX idx_activity (activity_id), " + "INDEX idx_buyer (buyer_id), " + "INDEX idx_status (status)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动provision记录表'"); log.info("已创建activity_provisions表"); } catch (Exception e) { // 表已存在,忽略错误 } // 迁移 97: DAN测评服务佣金配置 seedDanCommissionConfig(); // 迁移 98: 创建 health_knowledge_base 表(菌群/营养知识库) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `health_knowledge_base` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`item_type` VARCHAR(50) NOT NULL COMMENT '类型: bacteria/nutrient/indicator/vitamin/amino_acid/disease', " + "`item_name` VARCHAR(200) NOT NULL COMMENT '项目名称(精确匹配)', " + "`category` VARCHAR(100) COMMENT '分类(如 有益菌/有害菌)', " + "`normal_range` VARCHAR(200) COMMENT '正常范围参考', " + "`description` TEXT COMMENT '详细说明', " + "`suggestion` TEXT COMMENT '相关建议', " + "`source` VARCHAR(100) COMMENT '数据来源', " + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, " + "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY `uk_item_type_name` (`item_type`, `item_name`), " + "KEY `idx_item_type` (`item_type`)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康知识库 - 菌属/营养素/指标说明'"); log.info("已创建health_knowledge_base表"); } catch (Exception e) { // 表已存在,忽略错误 } } private void seedDanCommissionConfig() { try { jdbcTemplate.execute("INSERT IGNORE INTO sys_config (config_key, config_value, description, created_at) VALUES " + "('dan_commission_rate', '10', 'DAN测评服务通用佣金比例(%)', NOW()), " + "('dan_commission_rate_assessor', '10', 'DAN测评评估师佣金比例(%)', NOW()), " + "('dan_commission_rate_planner', '10', 'DAN测评规划师佣金比例(%)', NOW())"); log.info("已初始化DAN测评佣金配置"); } catch (Exception e) { log.warn("DAN佣金配置种子数据可能已存在: " + e.getMessage()); } // 迁移 98: users 表添加软删除标记(@TableLogic) try { jdbcTemplate.execute("ALTER TABLE users ADD COLUMN deleted TINYINT DEFAULT 0 COMMENT '软删除标记: 0=未删除, 1=已删除'"); log.info("已添加deleted列到users表"); } catch (Exception e) { // 列已存在,忽略错误 } // 迁移 103: products表添加min_quantity列(商品最小购买数限制) ensureColumn("products", "min_quantity", "INT DEFAULT 1 COMMENT '最小购买数量限制'"); // 迁移 104: tianpan_daily_fortune表添加缺失字段(FamilyFortune实体对齐) ensureColumn("tianpan_daily_fortune", "lucky_direction", "VARCHAR(20) COMMENT '幸运方向'"); ensureColumn("tianpan_daily_fortune", "fortune_level", "VARCHAR(20) COMMENT '运势等级'"); ensureColumn("tianpan_daily_fortune", "fortune_level_text", "VARCHAR(100) COMMENT '运势等级描述'"); ensureColumn("tianpan_daily_fortune", "fortune_description", "TEXT COMMENT '运势描述'"); ensureColumn("tianpan_daily_fortune", "mood_score", "INT DEFAULT 50 COMMENT '心情分数'"); ensureColumn("tianpan_daily_fortune", "mood_emoji", "VARCHAR(10) COMMENT '心情emoji'"); ensureColumn("tianpan_daily_fortune", "mood_text", "VARCHAR(50) COMMENT '心情文本'"); ensureColumn("tianpan_daily_fortune", "weekly_tip", "TEXT COMMENT '每周提示'"); // 验证迁移104: 确认tianpan_daily_fortune关键列存在 try { jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='tianpan_daily_fortune' AND COLUMN_NAME='fortune_level_text'", Integer.class); log.info("验证通过: tianpan_daily_fortune.fortune_level_text 列存在"); } catch (Exception e) { log.error("验证失败: tianpan_daily_fortune.fortune_level_text 列仍然不存在,迁移可能失败: {}", e.getMessage()); } // 验证迁移103: 确认products.min_quantity列存在 try { jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='products' AND COLUMN_NAME='min_quantity'", Integer.class); log.info("验证通过: products.min_quantity 列存在"); } catch (Exception e) { log.error("验证失败: products.min_quantity 列仍然不存在,迁移可能失败: {}", e.getMessage()); } // 迁移 105: users表添加article_admin_status和activity_provider_status字段 ensureColumn("users", "article_admin_status", "VARCHAR(20) DEFAULT 'pending' COMMENT '文章管理员审核状态'"); ensureColumn("users", "activity_provider_status", "VARCHAR(20) DEFAULT 'pending' COMMENT '活动供应商审核状态'"); } private void runMigration93() { // 迁移 93: 创建 dan_report_uploads 表(DAN PDF报告独立化) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS dan_report_uploads (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "family_member_id BIGINT NOT NULL COMMENT '家庭成员ID', " + "uploader_user_id BIGINT NOT NULL COMMENT '上传者用户ID', " + "dimension VARCHAR(16) NOT NULL COMMENT '维度: mind/wisdom', " + "report_type VARCHAR(16) COMMENT 'A2/B4', " + "file_url VARCHAR(500) NOT NULL COMMENT 'PDF文件URL', " + "file_id VARCHAR(500) COMMENT '媒资文件ID或相对路径', " + "file_name VARCHAR(200) COMMENT '原始文件名', " + "file_size BIGINT COMMENT '文件大小(字节)', " + "uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "parsed_items JSON COMMENT '解析的指标项', " + "parsed_extra JSON COMMENT '解析的额外信息', " + "parsed_summary TEXT COMMENT '解析的总结文本', " + "parsed_suggestions TEXT COMMENT '解析的建议文本', " + "parser_version VARCHAR(32) COMMENT '解析器版本', " + "parse_status VARCHAR(16) DEFAULT 'pending' COMMENT 'pending/success/failed', " + "parse_error VARCHAR(500) COMMENT '解析失败原因', " + "parsed_at DATETIME COMMENT '解析完成时间', " + "draft_status VARCHAR(16) DEFAULT 'pending' COMMENT 'pending/confirmed/discarded', " + "confirmed_at DATETIME COMMENT '确认时间', " + "confirmed_by BIGINT COMMENT '确认操作人用户ID', " + "edited_items JSON COMMENT '编辑后的指标项', " + "edited_extra JSON COMMENT '编辑后的额外信息', " + "edited_summary TEXT COMMENT '编辑后的总结', " + "edited_suggestions TEXT COMMENT '编辑后的建议', " + "edit_count INT DEFAULT 0 COMMENT '编辑次数', " + "linked_result_id BIGINT COMMENT '关联 dan_assessment_results.id', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "deleted_at DATETIME COMMENT '软删除时间', " + "INDEX idx_family_id (family_id), " + "INDEX idx_member_id (family_member_id), " + "INDEX idx_draft_status (draft_status), " + "INDEX idx_dimension (dimension), " + "INDEX idx_parse_status (parse_status), " + "INDEX idx_linked_result (linked_result_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DAN测评PDF报告独立记录'"); log.info("已创建dan_report_uploads表"); } catch (Exception e) { // 表已存在,忽略 } } private void runMigration94() { // 迁移 94: dan_assessment_results表添加独立化扩展字段(关联新表) ensureColumn("dan_assessment_results", "source_report_id", "BIGINT DEFAULT NULL COMMENT '关联 dan_report_uploads.id'"); ensureColumn("dan_assessment_results", "analysis_status", "VARCHAR(16) DEFAULT 'pending' COMMENT '分析状态: pending/confirmed'"); log.info("已添加独立化扩展列到dan_assessment_results表"); } private void runMigration95() { // 迁移 95: 历史 dan_assessment_results 数据 ETL 回填到 dan_report_uploads // 条件: source='parent_upload' AND source_report_id IS NULL(真正的历史遗留数据) // 注意: 新流程的记录通过 confirmUpload 已正确设置 source_report_id,无需处理 try { // Step 1: 插入 dan_report_uploads 记录(来自 dan_assessment_results 的历史数据) jdbcTemplate.execute("INSERT IGNORE INTO dan_report_uploads " + "(family_id, family_member_id, uploader_user_id, dimension, report_type, " + "file_url, file_id, draft_status, confirmed_at, confirmed_by, " + "edited_items, edited_summary, edited_suggestions, linked_result_id, " + "parsed_status, parse_status, created_at, updated_at) " + "SELECT " + " fm.family_id, " + " r.family_member_id, " + " r.teacher_id, " + " r.dimension, " + " r.source, " + " COALESCE(r.report_file_id, ''), " + " r.report_file_id, " + " 'confirmed', " + " r.created_at, " + " r.teacher_id, " + " r.structured_analysis, " + " r.analysis_report, " + " r.growth_suggestions, " + " r.id, " + " 'success', " + " 'confirmed', " + " r.created_at, " + " r.updated_at " + "FROM dan_assessment_results r " + "LEFT JOIN family_members fm ON fm.id = r.family_member_id " + "WHERE r.source = 'parent_upload' " + " AND r.source_report_id IS NULL " + " AND r.draft_status = 'confirmed' " + " AND NOT EXISTS (SELECT 1 FROM dan_report_uploads u WHERE u.linked_result_id = r.id)" ); log.info("迁移95 Step1: 已将历史 dan_assessment_results 回填到 dan_report_uploads"); } catch (Exception e) { log.error("迁移95 Step1 失败: " + e.getMessage()); } // Step 2: 回填 dan_assessment_results.source_report_id(关联新建的 dan_report_uploads 记录) try { jdbcTemplate.execute("UPDATE dan_assessment_results r " + "INNER JOIN dan_report_uploads u ON u.linked_result_id = r.id " + "SET r.source_report_id = u.id " + "WHERE r.source = 'parent_upload' " + " AND r.source_report_id IS NULL " + " AND r.draft_status = 'confirmed'" ); log.info("迁移95 Step2: 已回填 dan_assessment_results.source_report_id"); } catch (Exception e) { log.error("迁移95 Step2 失败: " + e.getMessage()); } } private void runMigration97() { // 迁移93: users表添加roles字段 ensureColumn("users", "roles", "VARCHAR(100) COMMENT '角色列表'"); } private void runMigration96() { // 迁移96: 修复assessment_appointments.status列类型为TINYINT(原库可能为VARCHAR/ENUM导致Data truncated) try { jdbcTemplate.execute("ALTER TABLE assessment_appointments MODIFY COLUMN status TINYINT DEFAULT 0 COMMENT '0=待确认,1=已确认,2=已完成,3=已取消,4=未到'"); log.info("已修复assessment_appointments.status列类型为TINYINT"); } catch (Exception e) { log.warn("Migration 96 skipped: {}", e.getMessage()); } } private void runMigration98() { // 迁移98: family_members表新增辈分字段(TASK-P0-001 辈分重构) try { jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN generation INT COMMENT '辈分值(0=家庭创建者,+n=向上,-n=向下)'"); log.info("已添加generation列到family_members表"); } catch (Exception e) { // 列已存在,忽略错误 } try { jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN is_spouse TINYINT(1) DEFAULT 0 COMMENT '是否配偶(同辈中)'"); log.info("已添加is_spouse列到family_members表"); } catch (Exception e) { // 列已存在,忽略错误 } // 迁移: products表添加min_quantity列(商品最小购买数限制) ensureColumn("products", "min_quantity", "INT DEFAULT 1 COMMENT '最小购买数量限制'"); // 迁移: 素材库历史数据修复(file_type 回填 + 按分类补标签,幂等) backfillAssetLibrary(); } /** * 素材库历史数据修复(幂等): *

    *
  1. 回填 file_record.file_type(历史记录可能为 NULL,导致素材库类型筛选失效)
  2. *
  3. 按分类名给记录补自动标签(规则与上传时一致;文件类型不参与标签)
  4. *
*/ private void backfillAssetLibrary() { try { int n = jdbcTemplate.update( "UPDATE file_record SET file_type = CASE " + "WHEN content_type LIKE 'image/%' THEN 'image' " + "WHEN content_type LIKE 'video/%' THEN 'video' " + "WHEN content_type LIKE 'audio/%' THEN 'audio' " + "WHEN content_type LIKE 'text/%' THEN 'document' " + "WHEN content_type IN ('application/pdf','application/msword'," + "'application/vnd.openxmlformats-officedocument.wordprocessingml.document'," + "'application/vnd.ms-excel'," + "'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'," + "'application/vnd.ms-powerpoint'," + "'application/vnd.openxmlformats-officedocument.presentationml.presentation') THEN 'document' " + "WHEN SUBSTRING_INDEX(LOWER(original_filename), '.', -1) " + "IN ('jpg','jpeg','png','gif','webp','bmp','svg','ico','heic') THEN 'image' " + "WHEN SUBSTRING_INDEX(LOWER(original_filename), '.', -1) " + "IN ('mp4','webm','ogg','mov','m4v','avi','mkv') THEN 'video' " + "WHEN SUBSTRING_INDEX(LOWER(original_filename), '.', -1) " + "IN ('mp3','wav','m4a','aac','flac','amr') THEN 'audio' " + "WHEN SUBSTRING_INDEX(LOWER(original_filename), '.', -1) " + "IN ('pdf','doc','docx','xls','xlsx','ppt','pptx','txt','md','csv','rtf','odt','ods') THEN 'document' " + "ELSE 'other' END " + "WHERE file_type IS NULL OR file_type = '' " + "OR file_type NOT IN ('image','video','audio','document','other')"); if (n > 0) { log.info("素材库回填 file_type: {} 条", n); } } catch (Exception e) { log.warn("素材库回填 file_type 失败: {}", e.getMessage()); } try { List> cats = jdbcTemplate.queryForList( "SELECT category FROM file_record WHERE category IS NOT NULL AND category <> '' GROUP BY category"); int tagged = 0; for (Map row : cats) { String category = String.valueOf(row.get("category")); String tagName = com.etotem.cfc.util.AssetCategoryTag.label(category); String color = com.etotem.cfc.util.AssetCategoryTag.color(category); List> found = jdbcTemplate.queryForList( "SELECT id FROM file_tag WHERE name = ? LIMIT 1", tagName); Long tagId; if (found.isEmpty()) { jdbcTemplate.update( "INSERT INTO file_tag (name, color, sort_order, created_at) VALUES (?, ?, 0, NOW())", tagName, color); tagId = jdbcTemplate.queryForObject( "SELECT id FROM file_tag WHERE name = ? LIMIT 1", Long.class, tagName); } else { tagId = ((Number) found.get(0).get("id")).longValue(); } tagged += jdbcTemplate.update( "INSERT IGNORE INTO file_tag_rel (file_id, tag_id) " + "SELECT id, ? FROM file_record WHERE category = ?", tagId, category); } if (tagged > 0) { log.info("素材库按分类补标签: {} 条", tagged); } } catch (Exception e) { log.warn("素材库按分类补标签失败: {}", e.getMessage()); } } private void runMigration99() { // 迁移99: 旧关系类型数据回填到辈分字段(TASK-P0-001 辈分重构) try { jdbcTemplate.execute("UPDATE family_members SET generation = 0, is_spouse = 1 WHERE relationship_type = 'spouse'"); jdbcTemplate.execute("UPDATE family_members SET generation = 1 WHERE relationship_type = 'parent'"); jdbcTemplate.execute("UPDATE family_members SET generation = -1 WHERE relationship_type = 'child'"); jdbcTemplate.execute("UPDATE family_members SET generation = 0 WHERE relationship_type = 'sibling'"); jdbcTemplate.execute("UPDATE family_members SET generation = 0 WHERE relationship_type IS NULL"); log.info("迁移99: 已回填generation/is_spouse旧数据"); } catch (Exception e) { log.error("迁移99 失败: " + e.getMessage()); } ensureColumn("products", "min_quantity", "INT DEFAULT 1 COMMENT '最小购买数量限制'"); ensureColumn("growth_records", "family_member_id", "BIGINT COMMENT '家庭成员ID'"); ensureColumn("growth_records", "family_member_age", "INT COMMENT '家庭成员年龄(月)'"); ensureColumn("growth_records", "school", "VARCHAR(100) COMMENT '学校名称'"); ensureColumn("growth_records", "grade", "INT COMMENT '年级(数字)'"); ensureColumn("growth_records", "academic_performance", "VARCHAR(200) COMMENT '成绩/学业表现'"); ensureColumn("growth_records", "school_province", "VARCHAR(50) COMMENT '学校所在省'"); ensureColumn("growth_records", "school_city", "VARCHAR(50) COMMENT '学校所在市'"); ensureColumn("growth_records", "school_district", "VARCHAR(50) COMMENT '学校所在区'"); ensureColumn("growth_records", "school_street", "VARCHAR(100) COMMENT '学校所在街道'"); } private void runMigration100() { // 迁移100: 删除废弃列和关系类型配置表(TASK-P0-001 辈分重构) try { jdbcTemplate.execute("ALTER TABLE family_members DROP COLUMN relationship_type"); log.info("已删除family_members.relationship_type列"); } catch (Exception e) { // 列已不存在,忽略 } try { jdbcTemplate.execute("ALTER TABLE family_members DROP COLUMN role_override"); log.info("已删除family_members.role_override列"); } catch (Exception e) { // 列已不存在,忽略 } } // 迁移 101: product_skus 表添加 min_quantity 列(商品规格最小购买数限制) private void runMigration101() { ensureColumn("product_skus", "min_quantity", "INT DEFAULT 1 COMMENT '最小购买数量限制'"); } // 迁移 102: health_gut_flora 表扩大 population_level 和 detection_rate 字段长度 private void runMigration102() { // 将 VARCHAR(50) 扩大到 TEXT(处理 AI 解析出的长文本) ensureColumn("health_gut_flora", "population_level", "TEXT DEFAULT NULL COMMENT '处于人群%水平'"); ensureColumn("health_gut_flora", "detection_rate", "TEXT DEFAULT NULL COMMENT '%正常人有检出'"); } private void migration102() { // 迁移 102: health_gut_flora 表扩大 population_level 和 detection_rate 字段长度(TEXT) ensureColumn("health_gut_flora", "population_level", "TEXT DEFAULT NULL COMMENT '处于人群%水平'"); ensureColumn("health_gut_flora", "detection_rate", "TEXT DEFAULT NULL COMMENT '%正常人有检出'"); // 迁移 102: 创建 health_dimension_scores 表(7 维健康模型) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_dimension_scores (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "member_id BIGINT NOT NULL COMMENT '家庭成员ID', " + "dimension VARCHAR(20) NOT NULL COMMENT '维度编码', " + "score INT NOT NULL COMMENT '评分(0-100)', " + "percentile INT COMMENT '百分位', " + "data_source VARCHAR(20) COMMENT '数据来源', " + "tier INT DEFAULT 3 COMMENT '数据可信度层级', " + "raw_data TEXT COMMENT '原始数据JSON', " + "assess_date DATE COMMENT '评估日期', " + "expire_date DATE COMMENT '过期日期', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_member_dim (member_id, dimension), " + "INDEX idx_assess_date (assess_date)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康维度评分'"); log.info("已创建health_dimension_scores表"); } catch (Exception e) { log.warn("创建health_dimension_scores表可能已存在: {}", e.getMessage()); } // 迁移102b: 创建 health_data_sources 表(多媒体数据源记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_data_sources (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "member_id BIGINT NOT NULL COMMENT '家庭成员ID', " + "source_type VARCHAR(20) NOT NULL COMMENT '数据源类型: VOICE/PHOTO/REPORT/MANUAL', " + "dimension_codes VARCHAR(100) COMMENT '关联维度编码(逗号分隔)', " + "parsed_result TEXT COMMENT '解析结果JSON', " + "file_url VARCHAR(500) COMMENT '原始文件URL', " + "confidence DECIMAL(5,4) COMMENT 'AI解析置信度(0-1)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_member_source (member_id, source_type), " + "INDEX idx_member_id (member_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康数据源记录'"); log.info("已创建health_data_sources表"); } catch (Exception e) { log.warn("创建health_data_sources表可能已存在: {}", e.getMessage()); } // 迁移103: health_indicators表添加collection_date列(指标采集日期) ensureColumn("health_indicators", "collection_date", "DATETIME COMMENT '指标采集日期(报告检测日期)'"); // 迁移104: promotion_tier表确保team_size_1st/2nd/3rd列存在(MyBatis-Plus camelCase映射修正) ensureColumn("promotion_tier", "team_size_1st", "INT DEFAULT 0 COMMENT '一层团队人数'"); ensureColumn("promotion_tier", "team_size_2nd", "INT DEFAULT 0 COMMENT '二层团队人数'"); ensureColumn("promotion_tier", "team_size_3rd", "INT DEFAULT 0 COMMENT '三层团队人数'"); // 迁移105: article_reading_records表添加article_id和updated_at列 try { jdbcTemplate.execute("ALTER TABLE article_reading_records ADD COLUMN article_id BIGINT DEFAULT NULL COMMENT '文章ID' AFTER id"); log.info("已添加article_id列到article_reading_records表"); } catch (Exception e) { // 列已存在,忽略 } try { jdbcTemplate.execute("ALTER TABLE article_reading_records ADD COLUMN updated_at DATETIME DEFAULT NULL COMMENT '更新时间' AFTER read_at"); log.info("已添加updated_at列到article_reading_records表"); } catch (Exception e) { // 列已存在,忽略 } try { jdbcTemplate.execute("ALTER TABLE article_reading_records ADD INDEX idx_article_child_date (article_id, child_id, read_at)"); log.info("已添加idx_article_child_date索引"); } catch (Exception e) { // 索引已存在,忽略 } // 迁移106: 创建 report_summary 统一报告汇总表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_summary (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', " + "report_type VARCHAR(32) NOT NULL COMMENT '报告类型', " + "source_table VARCHAR(32) NOT NULL COMMENT '来源表', " + "source_id BIGINT NOT NULL COMMENT '来源表记录ID', " + "user_id BIGINT NOT NULL COMMENT '创建者用户ID', " + "family_id BIGINT COMMENT '家庭ID', " + "subject_id BIGINT COMMENT '报告主体ID', " + "subject_name VARCHAR(50) COMMENT '报告主体名称', " + "title VARCHAR(200) COMMENT '报告标题', " + "summary TEXT COMMENT '摘要', " + "file_url VARCHAR(500) COMMENT '文件URL', " + "file_size BIGINT COMMENT '文件大小', " + "file_name VARCHAR(200) COMMENT '文件名', " + "has_file TINYINT DEFAULT 0 COMMENT '是否有文件', " + "overall_score INT COMMENT '综合评分', " + "report_date DATE COMMENT '报告日期', " + "status VARCHAR(20) COMMENT '状态', " + "detail_route VARCHAR(100) COMMENT '详情页路由', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_user_id (user_id), " + "INDEX idx_family_id (family_id), " + "INDEX idx_subject_id (subject_id), " + "INDEX idx_report_type (report_type), " + "INDEX idx_report_date (report_date), " + "INDEX idx_status (status), " + "UNIQUE KEY uk_source (source_table, source_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='统一报告汇总表'"); log.info("已创建 report_summary 表"); } catch (Exception e) { // 表已存在,忽略 } // 迁移107: tasks表添加dimension_code和dimension_weights列(五维维度支持) try { ensureColumn("tasks", "dimension_code", "VARCHAR(20) COMMENT '五维维度: body/mind/wisdom/action/wealth'"); ensureColumn("tasks", "dimension_weights", "JSON COMMENT '五维权重: {\"body\":0,\"mind\":0,\"wisdom\":0,\"action\":0,\"wealth\":0}'"); log.info("已完成tasks表维度字段添加"); } catch (Exception e) { log.warn("添加tasks表维度字段失败: {}", e.getMessage()); } // 迁移108: 创建 energy_behavior_config 表(能量行为配置) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS energy_behavior_config (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "behavior_code VARCHAR(32) NOT NULL COMMENT '行为编码', " + "behavior_name VARCHAR(50) NOT NULL COMMENT '行为名称', " + "description VARCHAR(200) COMMENT '行为描述', " + "default_amount INT DEFAULT 5 COMMENT '默认能量值', " + "amount_source VARCHAR(32) DEFAULT 'fixed' COMMENT '能量值来源', " + "dimension_assign JSON COMMENT '维度分配JSON', " + "daily_limit INT DEFAULT 0 COMMENT '每日上限', " + "cooldown_seconds INT DEFAULT 0 COMMENT '冷却时间(秒)', " + "expire_days INT DEFAULT 0 COMMENT '过期天数', " + "enabled TINYINT DEFAULT 1 COMMENT '是否启用', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_behavior_code (behavior_code)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='能量行为配置表'"); log.info("已创建 energy_behavior_config 表"); // 插入预置数据(仅当表为空时插入) Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM energy_behavior_config", Integer.class); if (count == null || count == 0) { String[][] seedData = { {"task_complete", "完成任务", "完成任务获得的能量", "5", "points", "[{\"dim\":\"action\",\"ratio\":100}]", "200", "0", "0"}, {"game_finish", "完成游戏", "完成小游戏获得的能量", "5", "points", "[{\"dim\":\"mind\",\"ratio\":30},{\"dim\":\"wisdom\",\"ratio\":50},{\"dim\":\"action\",\"ratio\":20}]", "200", "0", "0"}, {"activity_checkin", "活动签到", "活动签到获得的能量", "5", "fixed", "[{\"dim\":\"body\",\"ratio\":30},{\"dim\":\"action\",\"ratio\":70}]", "100", "0", "0"}, {"article_read", "阅读文章", "阅读文章获得的能量", "5", "points", "[{\"dim\":\"mind\",\"ratio\":30},{\"dim\":\"wisdom\",\"ratio\":50},{\"dim\":\"action\",\"ratio\":20}]", "100", "0", "0"}, {"product_purchase", "商品购买", "购买商品获得的能量", "10", "order_amount", "[{\"dim\":\"wealth\",\"ratio\":100}]", "500", "0", "30"}, {"health_checkin", "健康打卡", "健康打卡获得的能量", "5", "fixed", "[{\"dim\":\"body\",\"ratio\":100}]", "50", "0", "30"}, {"finance_checkin", "理财打卡", "理财打卡获得的能量", "5", "fixed", "[{\"dim\":\"wealth\",\"ratio\":100}]", "50", "0", "0"}, {"emotion_checkin", "情绪打卡", "情绪打卡获得的能量", "5", "fixed", "[{\"dim\":\"mind\",\"ratio\":100}]", "40", "0", "0"}, {"appointment_submit", "提交测评预约", "提交测评预约获得的能量", "10", "fixed", "[{\"dim\":\"action\",\"ratio\":100}]", "50", "0", "0"}, {"appointment_complete", "完成测评", "完成测评获得的能量", "30", "fixed", "[{\"dim\":\"mind\",\"ratio\":50},{\"dim\":\"wisdom\",\"ratio\":50}]", "100", "0", "0"}, {"streak_daily", "连续打卡每日", "每日连续打卡获得的能量", "5", "fixed", "[{\"dim\":\"body\",\"ratio\":30},{\"dim\":\"mind\",\"ratio\":20},{\"dim\":\"action\",\"ratio\":50}]", "50", "86400", "0"}, {"streak_milestone", "打卡里程碑", "达到打卡里程碑获得的能量", "20", "config", "[{\"dim\":\"body\",\"ratio\":30},{\"dim\":\"mind\",\"ratio\":20},{\"dim\":\"action\",\"ratio\":50}]", "100", "0", "0"}, {"growth_task", "成长任务", "完成每日成长任务获得的能量", "10", "config", "[{\"dim\":\"body\",\"ratio\":20},{\"dim\":\"mind\",\"ratio\":20},{\"dim\":\"wisdom\",\"ratio\":20},{\"dim\":\"action\",\"ratio\":20},{\"dim\":\"wealth\",\"ratio\":20}]", "100", "0", "1"}, {"micro_action", "微行动", "完成微行动获得的能量", "1", "fixed", "[{\"dim\":\"action\",\"ratio\":100}]", "20", "60", "30"}, {"onboarding", "新手引导", "完成新手引导任务获得的能量", "10", "config", "[{\"dim\":\"body\",\"ratio\":20},{\"dim\":\"mind\",\"ratio\":20},{\"dim\":\"wisdom\",\"ratio\":20},{\"dim\":\"action\",\"ratio\":20},{\"dim\":\"wealth\",\"ratio\":20}]", "50", "0", "30"}, {"invite_milestone", "邀请里程碑", "达到邀请人数里程碑获得的能量", "30", "config", "[{\"dim\":\"action\",\"ratio\":40},{\"dim\":\"wealth\",\"ratio\":60}]", "200", "0", "30"}, {"dimension_sync", "维度同步", "健康维度数据上传同步获得的能量", "10", "config", "[{\"dim\":\"body\",\"ratio\":100}]", "100", "0", "30"}, {"invite_friend", "邀请好友加入", "邀请好友注册加入获得的能量", "20", "fixed", "[{\"dim\":\"action\",\"ratio\":50},{\"dim\":\"wealth\",\"ratio\":50}]", "200", "0", "0"}, {"invite_family", "邀请家庭成员", "邀请家庭成员加入家庭获得的能量", "10", "fixed", "[{\"dim\":\"mind\",\"ratio\":30},{\"dim\":\"action\",\"ratio\":70}]", "100", "0", "0"}, {"report_upload", "上传体检报告", "上传体检报告获得的能量", "30", "fixed", "[{\"dim\":\"body\",\"ratio\":100}]", "100", "0", "30"}, {"plan_complete", "完成成长计划", "完成成长计划复盘获得的能量", "20", "fixed", "[{\"dim\":\"body\",\"ratio\":20},{\"dim\":\"mind\",\"ratio\":20},{\"dim\":\"wisdom\",\"ratio\":20},{\"dim\":\"action\",\"ratio\":20},{\"dim\":\"wealth\",\"ratio\":20}]", "100", "0", "0"}, {"wisdom_report", "上传智测评报告", "上传智慧测评报告获得的能量", "30", "fixed", "[{\"dim\":\"wisdom\",\"ratio\":100}]", "100", "0", "30"}, }; String sql = "INSERT INTO energy_behavior_config (behavior_code, behavior_name, description, default_amount, amount_source, dimension_assign, daily_limit, cooldown_seconds, expire_days, enabled) VALUES (?,?,?,?,?,?,?,?,?,1)"; for (String[] row : seedData) { try { jdbcTemplate.update(sql, row[0], row[1], row[2], Integer.parseInt(row[3]), row[4], row[5], Integer.parseInt(row[6]), Integer.parseInt(row[7]), Integer.parseInt(row[8])); } catch (Exception e) { log.warn("插入能量行为配置失败: behavior_code={}, error={}", row[0], e.getMessage()); } } log.info("已插入{}条能量行为配置", seedData.length); } } catch (Exception e) { log.warn("创建 energy_behavior_config 表失败: {}", e.getMessage()); } // 迁移109: finance_checkins表添加type列(记账类型: income/expense) try { ensureColumn("finance_checkins", "type", "VARCHAR(10) DEFAULT 'expense' COMMENT '类型: income(收入)/expense(支出)'"); log.info("已完成finance_checkins.type字段添加"); } catch (Exception e) { log.warn("添加finance_checkins.type字段失败: {}", e.getMessage()); } // 迁移110: 创建 article_comments 表(文章评论) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS article_comments (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "article_id BIGINT NOT NULL COMMENT '文章ID', " + "parent_id BIGINT COMMENT '父评论ID', " + "reply_to_id BIGINT COMMENT '引用的评论ID', " + "user_id BIGINT NOT NULL COMMENT '评论用户ID', " + "content TEXT NOT NULL COMMENT '评论内容', " + "status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/approved/rejected', " + "audit_by BIGINT COMMENT '审核人ID', " + "audit_at DATETIME COMMENT '审核时间', " + "earned_energy INT DEFAULT 0 COMMENT '获得的能量', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_article (article_id), " + "INDEX idx_article_status (article_id, status), " + "INDEX idx_parent (parent_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章评论'"); log.info("已创建 article_comments 表"); } catch (Exception e) { log.warn("创建 article_comments 表失败: {}", e.getMessage()); } // 迁移111: 创建 service_role_applications 表(服务角色统一申请) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS service_role_applications (" + "id int(11) NOT NULL AUTO_INCREMENT, " + "user_id int(11) NOT NULL COMMENT '申请人ID', " + "roles varchar(255) NOT NULL COMMENT '申请角色,JSON数组', " + "id_card varchar(18) DEFAULT '' COMMENT '身份证号', " + "id_card_images text COMMENT '身份证照片,JSON数组', " + "certificate_images text COMMENT '资质证书照片,JSON数组', " + "province varchar(50) DEFAULT '' COMMENT '省', " + "city varchar(50) DEFAULT '' COMMENT '市', " + "district varchar(50) DEFAULT '' COMMENT '区', " + "street varchar(50) DEFAULT '' COMMENT '街道', " + "address_detail varchar(200) DEFAULT '' COMMENT '详细地址', " + "status tinyint(4) NOT NULL DEFAULT 0 COMMENT '0-pending 1-approved 2-rejected', " + "reject_reason varchar(500) DEFAULT '' COMMENT '驳回原因', " + "reviewed_by int(11) DEFAULT NULL COMMENT '审核人ID', " + "reviewed_at datetime DEFAULT NULL COMMENT '审核时间', " + "created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "PRIMARY KEY (id), " + "KEY idx_user_id (user_id), " + "KEY idx_status (status)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务角色申请表'"); log.info("已创建service_role_applications表"); } catch (Exception e) { // 表已存在,忽略错误 } // 迁移112: users表添加article_admin_status字段(文章管理员状态) log.info("迁移112: 开始添加article_admin_status列到users表"); try { jdbcTemplate.execute("ALTER TABLE users ADD COLUMN article_admin_status VARCHAR(20) DEFAULT NULL COMMENT '文章管理员状态: pending/approved/rejected'"); log.info("迁移112: 已添加article_admin_status列到users表"); } catch (Exception e) { // 列已存在,幂等忽略(用warn而非error避免日志噪声) log.warn("迁移112: 添加article_admin_status列失败(可能已存在): {}", e.getMessage()); } // 迁移113: users表添加activity_provider_status字段(活动提供商状态) try { jdbcTemplate.execute("ALTER TABLE users ADD COLUMN activity_provider_status VARCHAR(20) DEFAULT NULL COMMENT '活动提供商状态: pending/approved/rejected'"); log.info("已添加activity_provider_status列到users表"); } catch (Exception e) { // 列已存在,忽略错误 } // 迁移114: 创建 periodic_services 表(周期性服务续费配置) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS periodic_services (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "product_id BIGINT NOT NULL COMMENT '关联商品ID', " + "service_name VARCHAR(100) COMMENT '服务名称', " + "cycle_days INT NOT NULL COMMENT '周期天数(如365=年度服务)', " + "remind_before_days INT DEFAULT 30 COMMENT '提前提醒天数', " + "auto_renew TINYINT DEFAULT 0 COMMENT '是否支持自动续费: 0=否 1=是', " + "enabled TINYINT DEFAULT 1 COMMENT '是否启用: 0=禁用 1=启用', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_product (product_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='周期性服务续费配置'"); log.info("已创建 periodic_services 表"); } catch (Exception e) { log.warn("创建 periodic_services 表失败: {}", e.getMessage()); } // 迁移115: 创建 recommendation_logs 表(推荐行为记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS recommendation_logs (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT COMMENT '用户ID', " + "family_id BIGINT COMMENT '家庭ID', " + "child_id BIGINT COMMENT '孩子ID', " + "content_type VARCHAR(20) NOT NULL COMMENT '内容类型: article/task/product/activity', " + "content_id BIGINT NOT NULL COMMENT '内容ID', " + "recommend_score INT COMMENT '推荐分值', " + "action VARCHAR(20) COMMENT '行为: show/click/consume', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_user_content (user_id, content_type, content_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推荐行为记录'"); log.info("已创建 recommendation_logs 表"); } catch (Exception e) { log.warn("创建 recommendation_logs 表失败: {}", e.getMessage()); } // 迁移116: products表添加family_scope列(推荐系统-家庭消费去重) try { ensureColumn("products", "family_scope", "TINYINT DEFAULT 1 COMMENT '是否面向家庭: 1=家庭成员消费过即排除, 0=仅按个人消费排除'"); log.info("已完成products.family_scope字段添加"); } catch (Exception e) { log.warn("添加products.family_scope字段失败: {}", e.getMessage()); } // 迁移117: activities表添加family_scope列(推荐系统-家庭参与去重) try { ensureColumn("activities", "family_scope", "TINYINT DEFAULT 1 COMMENT '是否面向家庭: 1=家庭成员参与过即排除, 0=仅按个人参与排除'"); log.info("已完成activities.family_scope字段添加"); } catch (Exception e) { log.warn("添加activities.family_scope字段失败: {}", e.getMessage()); } // 迁移118: activity_registrations表child_id改为可空 + 添加registrant_name/phone(报名表单-多人报名) try { ensureColumn("activity_registrations", "registrant_name", "VARCHAR(100) COMMENT '报名人姓名'"); log.info("已完成activity_registrations.registrant_name字段添加"); } catch (Exception e) { log.warn("添加activity_registrations.registrant_name字段失败: {}", e.getMessage()); } try { ensureColumn("activity_registrations", "registrant_phone", "VARCHAR(20) COMMENT '报名人联系方式'"); log.info("已完成activity_registrations.registrant_phone字段添加"); } catch (Exception e) { log.warn("添加activity_registrations.registrant_phone字段失败: {}", e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE activity_registrations MODIFY COLUMN child_id BIGINT COMMENT '孩子ID(手动输入报名人为空)'"); log.info("已完成activity_registrations.child_id改为可空"); } catch (Exception e) { log.warn("修改activity_registrations.child_id失败: {}", e.getMessage()); } // 迁移119: package_orders表添加联系人字段(产品页下单) try { ensureColumn("package_orders", "contact_name", "VARCHAR(50) COMMENT '联系人姓名'"); ensureColumn("package_orders", "contact_phone", "VARCHAR(20) COMMENT '联系人手机号'"); ensureColumn("package_orders", "contact_address", "VARCHAR(500) COMMENT '收件地址'"); log.info("已完成package_orders表联系人字段添加"); } catch (Exception e) { log.warn("添加package_orders联系人字段失败: {}", e.getMessage()); } // 迁移120: activities表添加registration_deadline列(活动中心-报名截止时间展示) try { ensureColumn("activities", "registration_deadline", "DATETIME COMMENT '报名截止时间'"); log.info("已完成activities.registration_deadline字段添加"); } catch (Exception e) { log.warn("添加activities.registration_deadline字段失败: {}", e.getMessage()); } // 迁移143: 创建小程序虚拟支付道具映射表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS virtual_goods_config (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "goods_type VARCHAR(32) NOT NULL COMMENT 'ASSESSMENT_PACKAGE / MEMBERSHIP / SUBSCRIPTION', " + "biz_key VARCHAR(64) NOT NULL COMMENT '业务SKU标识: 测评套餐ID / 会员等级code / 订阅档位code', " + "product_id VARCHAR(64) NOT NULL COMMENT '微信商户后台道具ID', " + "goods_name VARCHAR(128) NOT NULL COMMENT '道具名称', " + "status TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=停用', " + "create_time DATETIME DEFAULT CURRENT_TIMESTAMP, " + "update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_goods_type_biz (goods_type, biz_key)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小程序虚拟支付道具映射'"); log.info("已创建virtual_goods_config表"); } catch (Exception e) { log.warn("创建virtual_goods_config表失败: {}", e.getMessage()); } // 迁移144: users表添加session_key列(小程序虚拟支付signature签名会话密钥) try { ensureColumn("users", "session_key", "VARCHAR(64) COMMENT '微信登录会话密钥(虚拟支付签名用)'"); log.info("已完成users.session_key字段添加"); } catch (Exception e) { log.warn("添加users.session_key字段失败: {}", e.getMessage()); } // 迁移145: 创建家庭天盘周报导出记录表(ReportSummaryService 全量同步依赖,此前从未建表) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_fortune_report (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id INT NOT NULL COMMENT '家庭ID', " + "element VARCHAR(32) COMMENT '主导五行元素', " + "lucky_direction VARCHAR(64) COMMENT '本周吉位', " + "pdf_path VARCHAR(255) COMMENT 'PDF周报文件路径', " + "file_size BIGINT COMMENT '文件大小(字节)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_family_id (family_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭天盘周报导出记录'"); log.info("已创建family_fortune_report表"); } catch (Exception e) { log.warn("创建family_fortune_report表失败: {}", e.getMessage()); } // 迁移146: 创建五维家庭自检记录表(P0-1 五维自检问卷,书稿终章15题) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS five_dimension_self_checks (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '答卷人用户ID', " + "family_member_id BIGINT COMMENT '关联家庭成员ID(可选)', " + "answers_json TEXT COMMENT '用户答案JSON', " + "scores_json VARCHAR(512) COMMENT '各维度得分JSON', " + "total_score INT COMMENT '五维总分(满分45)', " + "advice_json TEXT COMMENT '五行相生寻源建议JSON', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_user_id (user_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五维家庭自检记录'"); log.info("已创建five_dimension_self_checks表"); } catch (Exception e) { log.warn("创建five_dimension_self_checks表失败: {}", e.getMessage()); } // 迁移147: virtual_goods_config 插入会员年卡道具映射(虚拟支付上线准备) try { jdbcTemplate.execute("INSERT IGNORE INTO virtual_goods_config (goods_type, biz_key, product_id, goods_name, status) " + "VALUES ('MEMBERSHIP', 'member1314', 'TBD', '会员年卡', 1)"); log.info("已插入会员年卡道具映射: member1314"); } catch (Exception e) { log.warn("插入会员年卡道具映射失败: {}", e.getMessage()); } // 迁移148: 修复 membership_levels 表重复行 + 添加 UNIQUE KEY uk_level_code // 原因:早期建表缺少 UNIQUE KEY uk_level_code,加上 INSERT IGNORE 在无唯一键时反复插入, // 导致生产环境 FREE/FAMILY/PROVIDER 三个等级各 69 行重复, // MembershipService.createOrder() 调用 selectOne(level_code) 触发 TooManyResultsException。 try { // 1) 删除重复行:保留每个 level_code 最早插入的(id 最小),其余删除 int deleted = jdbcTemplate.update( "DELETE m1 FROM membership_levels m1 " + "INNER JOIN membership_levels m2 " + "ON m1.level_code = m2.level_code AND m1.id > m2.id"); log.info("已清理 membership_levels 重复行: 删除{}条", deleted); } catch (Exception e) { log.warn("清理 membership_levels 重复行失败: {}", e.getMessage()); } try { // 2) 添加 UNIQUE KEY(幂等:先查再 ADD) Integer idxCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.statistics " + "WHERE table_schema = DATABASE() AND table_name = 'membership_levels' AND index_name = 'uk_level_code'", Integer.class); if (idxCount == null || idxCount == 0) { jdbcTemplate.execute("ALTER TABLE membership_levels ADD UNIQUE KEY uk_level_code (level_code)"); log.info("已添加 uk_level_code 唯一索引到 membership_levels 表"); } else { log.info("uk_level_code 唯一索引已存在,跳过添加"); } } catch (Exception e) { log.warn("添加 uk_level_code 唯一索引失败: {}", e.getMessage()); } // 迁移149: payment_orders表添加user_coupon_id列(VirtualPayTimeoutTask selectList 触发 Unknown column) // 原因:旧库建表时缺少此列,entity PaymentOrder 已声明 userCouponId,导致 MyBatis-Plus 全列查询失败。 // 本地与远端新库已有,本迁移对它们幂等。 try { ensureColumn("payment_orders", "user_coupon_id", "BIGINT COMMENT '使用的用户优惠券ID'"); log.info("已确保payment_orders.user_coupon_id列存在"); } catch (Exception e) { log.warn("添加payment_orders.user_coupon_id列失败: {}", e.getMessage()); } // 迁移150: activities表添加member_price列(会员价支持) try { ensureColumn("activities", "member_price", "INT COMMENT '会员价(分)' AFTER price"); log.info("已确保activities.member_price列存在"); } catch (Exception e) { log.warn("添加activities.member_price列失败: {}", e.getMessage()); } // 迁移151: growth_records表补齐学校/年级字段(GrowthRecord 实体全列查询 Unknown column 'school') // 原因:实体已声明 school/grade/academicPerformance/schoolProvince/schoolCity/schoolDistrict/schoolStreet, // 且 @TableField 为 camelCase 列名,但旧库建表时缺失这些列; // runMigration99() 为死代码从未执行,且其 snake_case 列名与实体 camelCase 不匹配。 ensureColumn("growth_records", "school", "VARCHAR(100) COMMENT '学校名称'"); ensureColumn("growth_records", "grade", "INT COMMENT '年级(数字)'"); ensureColumn("growth_records", "academicPerformance", "VARCHAR(200) COMMENT '成绩/学业表现'"); ensureColumn("growth_records", "schoolProvince", "VARCHAR(50) COMMENT '学校所在省'"); ensureColumn("growth_records", "schoolCity", "VARCHAR(50) COMMENT '学校所在市'"); ensureColumn("growth_records", "schoolDistrict", "VARCHAR(50) COMMENT '学校所在区'"); ensureColumn("growth_records", "schoolStreet", "VARCHAR(100) COMMENT '学校所在街道'"); log.info("已确保growth_records学校/年级相关列存在"); // 迁移152: 创建 product_gift_rules 表(商品赠送会员规则) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_gift_rules (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "product_id BIGINT NOT NULL COMMENT '关联products表', " + "level_code VARCHAR(50) NOT NULL COMMENT '赠送会员等级编码(FAMILY/PREMIUM等)', " + "status TINYINT(1) DEFAULT 1 COMMENT '1=启用 0=停用', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_product_level (product_id, level_code), " + "INDEX idx_product_id (product_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品赠送会员规则'"); log.info("创建product_gift_rules表成功"); } catch (Exception e) { log.warn("创建product_gift_rules表失败: {}", e.getMessage()); } // 迁移153: 创建 growth_archive_products 表(成长档案关联商品配置) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS growth_archive_products (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "product_id BIGINT NOT NULL COMMENT '关联products表', " + "status TINYINT(1) DEFAULT 1 COMMENT '1=启用 0=停用', " + "sort_order INT DEFAULT 0 COMMENT '展示排序(升序)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', " + "UNIQUE KEY uk_product_id (product_id), " + "KEY idx_status (status)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成长档案关联商品配置'"); log.info("已创建growth_archive_products表"); } catch (Exception e) { log.warn("创建growth_archive_products表失败: {}", e.getMessage()); } // 迁移154: membership_levels 表插入 PREMIUM 等级种子数据(前端升级页可选"高级家庭会员",createOrder 查表为 null 抛"会员等级不存在") try { jdbcTemplate.execute("INSERT IGNORE INTO membership_levels (level_code, level_name, level_desc, price_monthly, price_quarterly, price_yearly, max_children, max_tasks_per_day, ai_review_enabled, priority_support, teacher_consultation) VALUES " + "('PREMIUM', '高级家庭会员', '高级家庭会员专属功能', 131400, NULL, 1316800, 10, 50, 1, 1, 1)"); log.info("已插入PREMIUM等级种子数据"); } catch (Exception e) { log.warn("插入PREMIUM等级种子数据失败: {}", e.getMessage()); } // 迁移155: 创建 interaction_logs 表(关系互动记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS interaction_logs (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '所属家庭ID', " + "from_member_id BIGINT NOT NULL COMMENT '互动发起方成员ID', " + "to_member_id BIGINT NOT NULL COMMENT '互动接收方成员ID', " + "interaction_type VARCHAR(20) NOT NULL COMMENT '互动类型: dining/outgoing/call/video/chat/play/gift', " + "description VARCHAR(500) COMMENT '互动描述', " + "happened_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '互动发生时间', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_ilog_family (family_id), " + "INDEX idx_ilog_to_member (to_member_id) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭成员互动记录'"); log.info("已创建interaction_logs表"); } catch (Exception e) { // 表已存在,忽略 } // 迁移156: 创建 relationship_questionnaire_snapshots 表(关系问卷快照) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS relationship_questionnaire_snapshots (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_member_id BIGINT NOT NULL COMMENT '家庭成员ID', " + "respondent_id BIGINT NOT NULL COMMENT '答卷人用户ID', " + "member_name VARCHAR(50) COMMENT '成员名称', " + "relationship_type VARCHAR(20) COMMENT '关系类型: parent/child', " + "ai_generated_json TEXT COMMENT 'AI生成的题目JSON', " + "version VARCHAR(20) COMMENT '版本号', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_q_snap_member (family_member_id) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关系问卷快照'"); log.info("已创建relationship_questionnaire_snapshots表"); } catch (Exception e) { // 表已存在,忽略 } // 迁移158: users.family_id 允许 NULL(新用户微信登录时无家庭) try { jdbcTemplate.execute("ALTER TABLE users MODIFY COLUMN family_id BIGINT COMMENT '家庭ID'"); log.info("已修改users.family_id允许NULL"); } catch (Exception e) { // 忽略错误 } // 迁移157: 创建 relationship_questionnaire_responses 表(关系问卷回答) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS relationship_questionnaire_responses (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "snapshot_id BIGINT NOT NULL COMMENT '问卷快照ID', " + "respondent_id BIGINT NOT NULL COMMENT '答卷人用户ID', " + "answers_json TEXT COMMENT '用户回答JSON', " + "trust_score DECIMAL(5,2) DEFAULT 0 COMMENT '信任度得分', " + "intimacy_score DECIMAL(5,2) DEFAULT 0 COMMENT '亲密度得分', " + "communication_score DECIMAL(5,2) DEFAULT 0 COMMENT '沟通质量得分', " + "interaction_bonus DECIMAL(5,2) DEFAULT 0 COMMENT '互动记录加分', " + "total_score DECIMAL(5,2) DEFAULT 0 COMMENT '综合得分', " + "calculated_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_q_resp_snap (snapshot_id) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关系问卷回答记录'"); log.info("已创建relationship_questionnaire_responses表"); } catch (Exception e) { // 表已存在,忽略 } // 迁移158: family_challenge 添加 creator_id 列(用户自定义挑战) ensureColumn("family_challenge", "creator_id", "BIGINT COMMENT '创建者ID(NULL=系统创建)'"); // 迁移159: guide_orders表order_no加唯一约束(防重复下单,整改R1) try { // 先清理历史重复数据:每组order_no保留id最小的一行 jdbcTemplate.execute("DELETE g1 FROM guide_orders g1 INNER JOIN guide_orders g2 " + "ON g1.order_no = g2.order_no AND g1.id > g2.id"); log.info("已清理guide_orders重复order_no"); } catch (Exception e) { // 忽略:无重复数据时删除0行,不报错 } try { jdbcTemplate.execute("ALTER TABLE guide_orders ADD UNIQUE KEY uk_order_no (order_no)"); log.info("已为guide_orders.order_no添加唯一约束"); } catch (Exception e) { // 约束已存在,忽略 } // 迁移160: payment_orders表order_no升级为唯一约束(防支付回调重复,整改R2) try { jdbcTemplate.execute("DELETE p1 FROM payment_orders p1 INNER JOIN payment_orders p2 " + "ON p1.order_no = p2.order_no AND p1.id > p2.id"); log.info("已清理payment_orders重复order_no"); } catch (Exception e) { // 忽略 } try { // 先删除原普通索引,再加唯一索引(同名列不可并存) jdbcTemplate.execute("ALTER TABLE payment_orders DROP INDEX idx_order_no"); jdbcTemplate.execute("ALTER TABLE payment_orders ADD UNIQUE KEY uk_order_no (order_no)"); log.info("已为payment_orders.order_no添加唯一约束"); } catch (Exception e) { // 索引已存在或已处理,忽略 } // 迁移161: 创建 daily_checkin 表(每日健康综合打卡,含饮食/运动/睡眠/饮水/心情评分) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS daily_checkin (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "member_id BIGINT NOT NULL COMMENT '家庭成员ID', " + "checkin_date DATE COMMENT '打卡日期', " + "diet_score INT COMMENT '饮食评分', " + "exercise_score INT COMMENT '运动评分', " + "sleep_score INT COMMENT '睡眠评分', " + "water_intake INT COMMENT '饮水量(ml)', " + "mood_score INT COMMENT '心情评分', " + "weight_kg DECIMAL(5,2) COMMENT '体重(kg)', " + "height_cm DECIMAL(5,2) COMMENT '身高(cm)', " + "remark TEXT COMMENT '备注', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_member_id (member_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日健康打卡表'"); log.info("已创建daily_checkin表"); } catch (Exception ex) { log.warn("创建daily_checkin表失败: {}", ex.getMessage()); } // 迁移162: 创建 exercise_record 表(运动打卡记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS exercise_record (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "member_id BIGINT NOT NULL COMMENT '家庭成员ID', " + "checkin_id BIGINT COMMENT '关联打卡ID', " + "exercise_type VARCHAR(50) COMMENT '运动类型', " + "duration_minutes INT COMMENT '时长(分钟)', " + "intensity VARCHAR(20) COMMENT '强度: low/medium/high', " + "calories_burned INT COMMENT '消耗卡路里', " + "heart_rate_avg INT COMMENT '平均心率', " + "heart_rate_max INT COMMENT '最大心率', " + "step_count INT COMMENT '微信运动步数', " + "start_time DATETIME COMMENT '开始时间', " + "end_time DATETIME COMMENT '结束时间', " + "remark TEXT COMMENT '备注', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_member_id (member_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运动打卡记录'"); log.info("已创建exercise_record表"); } catch (Exception ex) { log.warn("创建exercise_record表失败: {}", ex.getMessage()); } // 迁移163: family_challenge 添加 creator_id 列(用户自定义挑战,原迁移158在runMigration100死代码中从未执行) ensureColumn("family_challenge", "creator_id", "BIGINT COMMENT '创建者ID(NULL=系统创建)'"); // 迁移164: coupon表扩展会员券字段 + 创建coupon_grant_log表(会员体系重构: 统一价+优惠券发放/积分兑换) try { ensureColumn("coupon", "discount_rate", "INT COMMENT '折扣券折扣率(千分比,9000=9折)'"); ensureColumn("coupon", "product_id", "BIGINT COMMENT '绑定商品ID,NULL=全场通用'"); ensureColumn("coupon", "points_price", "INT DEFAULT 0 COMMENT '积分兑换价,0=不可积分兑换'"); ensureColumn("coupon", "grant_type", "VARCHAR(16) COMMENT '发放规则:JOIN/PERIODIC/POPULATION,NULL=不自动发放'"); ensureColumn("coupon", "grant_level_code", "VARCHAR(32) COMMENT '发放规则对应会员等级'"); ensureColumn("coupon", "grant_period", "VARCHAR(16) COMMENT 'PERIODIC周期:MONTHLY/QUARTERLY'"); ensureColumn("coupon", "grant_quantity", "INT DEFAULT 1 COMMENT '每次发放数量'"); log.info("已为coupon表添加会员券字段"); } catch (Exception e) { log.warn("添加coupon表会员券字段失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS coupon_grant_log (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '收券用户ID', " + "coupon_id BIGINT NOT NULL COMMENT '券模板ID', " + "grant_type VARCHAR(16) NOT NULL COMMENT 'JOIN/PERIODIC/POPULATION/EXCHANGE', " + "period VARCHAR(16) COMMENT '周期标识(YYYY-MM或YYYY-Qn),PERIODIC防重用', " + "quantity INT DEFAULT 1 COMMENT '发放数量', " + "source VARCHAR(64) COMMENT '触发来源(订单号/成员ID等)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_grant (user_id, coupon_id, grant_type, period), " + "INDEX idx_coupon (coupon_id), " + "INDEX idx_user (user_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券发放流水表'"); log.info("已创建coupon_grant_log表"); } catch (Exception e) { log.warn("创建coupon_grant_log表失败: {}", e.getMessage()); } // 迁移165: virtual_goods_config 添加 goods_price 列(会员道具价格,用于拆单判断) ensureColumn("virtual_goods_config", "goods_price", "INT COMMENT '道具价格(分), 用于拆单判断'"); // 迁移166: payment_orders 添加 group_no/period_no 列(会员拆单支付,配置驱动拆单) ensureColumn("payment_orders", "group_no", "VARCHAR(64) COMMENT '拆单组号(同一拆单组共享)'"); ensureColumn("payment_orders", "period_no", "INT DEFAULT 1 COMMENT '期数序号(1开始)'"); // 迁移167: 回填 families.creator_id(早期版本从未写入,导致会员开通/虚拟支付兜底失败) // 优先 is_family_admin=true 的用户 try { jdbcTemplate.execute("UPDATE families f JOIN users u ON f.id = u.family_id AND u.is_family_admin = 1 " + "SET f.creator_id = u.id WHERE f.creator_id IS NULL"); log.info("已回填families.creator_id(优先家庭管理员)"); } catch (Exception e) { log.warn("回填families.creator_id(管理员)失败: {}", e.getMessage()); } // 剩余家庭:任一 openid 非空用户(子查询确定性取一条) try { jdbcTemplate.execute("UPDATE families f SET f.creator_id = (" + "SELECT u.id FROM users u WHERE u.family_id = f.id " + "AND u.openid IS NOT NULL AND u.openid <> '' " + "ORDER BY (u.is_family_admin = 1) DESC, u.id ASC LIMIT 1" + ") WHERE f.creator_id IS NULL"); log.info("已回填families.creator_id(任一成员)"); } catch (Exception e) { log.warn("回填families.creator_id(任一成员)失败: {}", e.getMessage()); } // 迁移168: 修复文章表 null status 为 'draft'(旧版新建文章未设默认值导致无法编辑) try { jdbcTemplate.execute("UPDATE articles SET status = 'draft' WHERE status IS NULL"); log.info("已修复articles表中status为null的记录为draft"); } catch (Exception e) { log.warn("修复articles表null status失败: {}", e.getMessage()); } // 迁移169: 清理已废弃的 relationship_types 表(schema.sql 已移除,迁移代码中 ALTER 语句已清理) try { jdbcTemplate.execute("DROP TABLE IF EXISTS relationship_types"); log.info("已清理废弃的relationship_types表"); } catch (Exception e) { log.warn("清理relationship_types表失败: {}", e.getMessage()); } // 迁移170: 创建 family_join_requests 表(家庭加入请求待审批) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_join_requests (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "requester_user_id BIGINT NOT NULL COMMENT '申请加入的用户ID', " + "target_family_id BIGINT NOT NULL COMMENT '目标家庭ID', " + "join_method VARCHAR(20) NOT NULL COMMENT '加入方式: by_code/by_token', " + "invite_ref VARCHAR(50) COMMENT '邀请码或令牌', " + "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/approved/rejected/cancelled', " + "approver_user_id BIGINT COMMENT '审批人ID', " + "comment TEXT COMMENT '审批备注', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_requester (requester_user_id), " + "INDEX idx_target_family (target_family_id), " + "INDEX idx_status (status) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭加入请求(待审批)'"); log.info("已创建family_join_requests表"); } catch (Exception e) { // 表已存在,忽略 } // 迁移171: 创建 knowledge_base_section 表(统一知识库按模块存储JSON,DB为查询源,classpath种子兜底) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS knowledge_base_section (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "module_key VARCHAR(100) NOT NULL COMMENT '模块键(如 食物营养库/菌属功能库/报告指标分级标准)', " + "module_name VARCHAR(200) COMMENT '模块中文名', " + "content LONGTEXT COMMENT '模块内容(JSON序列化)', " + "version VARCHAR(50) COMMENT '数据版本(来自元数据.版本)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_module_key (module_key) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='统一知识库 - 按模块存储JSON'"); log.info("已创建knowledge_base_section表"); } catch (Exception e) { // 表已存在,忽略 } // 迁移172: products 添加 min_stock_alert 列(库存预警阈值,配合库存管理模块) // 迁移173: 商品配送与履约扩展(自取/线上服务),需求: 配送方式1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人 // 173a: product_orders 新增履约字段 ensureColumn("product_orders", "pickup_location_id", "BIGINT COMMENT '自取地点ID'"); ensureColumn("product_orders", "pickup_location_snapshot", "TEXT COMMENT '自取地点快照(JSON)'"); ensureColumn("product_orders", "time_slot_id", "BIGINT COMMENT '所选时间段ID'"); ensureColumn("product_orders", "time_slot_snapshot", "TEXT COMMENT '时间段快照(JSON)'"); ensureColumn("product_orders", "service_person_id", "BIGINT COMMENT '所选服务者ID(deliveryMethod=4)'"); ensureColumn("product_orders", "service_person_snapshot", "TEXT COMMENT '服务者快照(JSON)'"); ensureColumn("product_orders", "fulfill_status", "VARCHAR(32) DEFAULT 'pending' COMMENT '履约状态: pending/confirmed_by_vendor/received_by_user/completed'"); ensureColumn("product_orders", "fulfill_confirmed_at", "DATETIME COMMENT '供应商确认时间'"); ensureColumn("product_orders", "received_at", "DATETIME COMMENT '用户确认收货时间'"); // 173b: 自取地点表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_pickup_locations (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', " + "product_id BIGINT NOT NULL COMMENT '所属商品ID', " + "name VARCHAR(100) NOT NULL COMMENT '地点名称', " + "address VARCHAR(500) NOT NULL COMMENT '详细地址', " + "longitude DECIMAL(10,7) COMMENT '经度', " + "latitude DECIMAL(10,7) COMMENT '纬度', " + "contact_name VARCHAR(50) COMMENT '联系人', " + "contact_phone VARCHAR(20) COMMENT '联系电话', " + "sort_order INT DEFAULT 0 COMMENT '排序', " + "enabled TINYINT DEFAULT 1 COMMENT '启用: 0/1', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_product_id (product_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品自取地点'"); log.info("已创建product_pickup_locations表"); } catch (Exception ex) { log.warn("创建product_pickup_locations表失败: {}", ex.getMessage()); } // 173c: 自取时间段模板表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_pickup_time_slots (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', " + "location_id BIGINT NOT NULL COMMENT '所属地点ID', " + "start_time TIME NOT NULL COMMENT '开始时间 HH:MM', " + "end_time TIME NOT NULL COMMENT '结束时间 HH:MM', " + "weekday TINYINT COMMENT '周几(1-7), null=按具体日期', " + "specific_date DATE COMMENT '具体日期, null=按周循环', " + "max_capacity INT DEFAULT 1 COMMENT '该时段最大接待人数', " + "booked_count INT DEFAULT 0 COMMENT '已预订数', " + "enabled TINYINT DEFAULT 1, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_location_id (location_id), " + "INDEX idx_specific_date (specific_date)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自取时间段模板'"); log.info("已创建product_pickup_time_slots表"); } catch (Exception ex) { log.warn("创建product_pickup_time_slots表失败: {}", ex.getMessage()); } // 173d: 线上服务者表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_service_persons (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', " + "product_id BIGINT NOT NULL COMMENT '所属商品ID', " + "user_id BIGINT NOT NULL COMMENT '服务者用户ID(关联users表)', " + "name VARCHAR(50) NOT NULL COMMENT '展示名称', " + "title VARCHAR(100) COMMENT '头衔(如高级营养师)', " + "intro TEXT COMMENT '个人介绍', " + "avatar VARCHAR(500) COMMENT '头像URL', " + "price INT NOT NULL COMMENT '该人专属价格(分)', " + "original_price INT COMMENT '原价(分)', " + "sort_order INT DEFAULT 0, " + "enabled TINYINT DEFAULT 1, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_product_id (product_id), " + "INDEX idx_user_id (user_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品线上服务者'"); log.info("已创建product_service_persons表"); } catch (Exception ex) { log.warn("创建product_service_persons表失败: {}", ex.getMessage()); } // 173e: 服务者可预约时间段表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_service_person_slots (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', " + "person_id BIGINT NOT NULL COMMENT '所属服务者ID', " + "start_time DATETIME NOT NULL COMMENT '开始时间', " + "end_time DATETIME NOT NULL COMMENT '结束时间', " + "max_capacity INT DEFAULT 1 COMMENT '容量', " + "booked_count INT DEFAULT 0 COMMENT '已预订数', " + "enabled TINYINT DEFAULT 1, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_person_id (person_id), " + "INDEX idx_start_time (start_time)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务者可预约时间段'"); log.info("已创建product_service_person_slots表"); } catch (Exception ex) { log.warn("创建product_service_person_slots表失败: {}", ex.getMessage()); } // 173f: 单人线上服务时间段表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_online_slots (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', " + "product_id BIGINT NOT NULL COMMENT '所属商品ID', " + "start_time DATETIME NOT NULL COMMENT '开始时间', " + "end_time DATETIME NOT NULL COMMENT '结束时间', " + "max_capacity INT DEFAULT 1 COMMENT '容量', " + "booked_count INT DEFAULT 0 COMMENT '已预订数', " + "enabled TINYINT DEFAULT 1, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_product_id (product_id), " + "INDEX idx_start_time (start_time)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='单人线上服务时间段'"); log.info("已创建product_online_slots表"); } catch (Exception ex) { log.warn("创建product_online_slots表失败: {}", ex.getMessage()); } // 迁移166: 创建 membership_benefits 表(会员权益配置) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS membership_benefits (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "level_code VARCHAR(50) NOT NULL COMMENT '会员等级: FREE/PROFESSIONAL/ENTERPRISE', " + "benefit_code VARCHAR(50) NOT NULL COMMENT '权益编码: AI_CHAT,FAMILY_MEMBERS等', " + "benefit_name VARCHAR(100) NOT NULL COMMENT '权益名称', " + "benefit_desc VARCHAR(500) COMMENT '权益描述', " + "icon VARCHAR(50) COMMENT '图标emoji', " + "bg_color VARCHAR(20) COMMENT '背景色', " + "sort_order INT DEFAULT 0 COMMENT '排序顺序', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_level_benefit (level_code, benefit_code), " + "INDEX idx_level (level_code)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员权益配置'"); log.info("已创建membership_benefits表"); } catch (Exception ex) { log.warn("创建membership_benefits表失败: {}", ex.getMessage()); } // 迁移167: membership_benefits 种子数据 try { jdbcTemplate.execute("INSERT IGNORE INTO membership_benefits (level_code, benefit_code, benefit_name, benefit_desc, icon, bg_color, sort_order) VALUES " + "('FREE', 'NO_BENEFIT', '暂无会员权益', '开通会员解锁更多权益', '🔒', '#F3F4F6', 0), " + "('FREE', 'UPGRADE_HINT', '升级会员', '开通会员享专属权益', '👑', '#FEF3C7', 99), " + "('FAMILY', 'AI_CHAT', 'AI 健康顾问', '7×24 小时不限次数问答', '🤖', '#DBEAFE', 1), " + "('FAMILY', 'FAMILY_MEMBERS', '家庭档案管理', '管理最多 10 位家庭成员', '👨‍👩‍👧‍👦', '#D1FAE5', 2), " + "('FAMILY', 'HEALTH_PLAN', '月度健康计划', '个性化健康改善方案', '📋', '#FEF3C7', 3), " + "('FAMILY', 'REPORT_READING', '报告智能解读', '体检/菌群/测评报告 AI 解读', '📊', '#FCE7F3', 4), " + "('FAMILY', 'MANAGER_SERVICE', '专属健康教练服务', '认证健康教练月度随访', '👨‍🏫', '#FFEDD5', 5), " + "('FAMILY', 'PRODUCT_DISCOUNT', '商品会员折扣', '全场商品 8-9 折优惠', '🛒', '#E0E7FF', 6), " + "('FAMILY', 'COUPON_ACCESS', '优惠券使用权限', '可使用平台优惠券享更多优惠', '🎫', '#FEE2E2', 7), " + "('PREMIUM', 'AI_CHAT', 'AI 健康顾问', '7×24 小时不限次数问答', '🤖', '#DBEAFE', 1), " + "('PREMIUM', 'FAMILY_MEMBERS', '家庭档案管理', '管理最多 20 位家庭成员', '👨‍👩‍👧‍👦', '#D1FAE5', 2), " + "('PREMIUM', 'HEALTH_PLAN', '月度健康计划', '个性化健康改善方案 + 定制营养建议', '📋', '#FEF3C7', 3), " + "('PREMIUM', 'REPORT_READING', '报告智能解读', '体检/菌群/测评报告 AI 深度解读', '📊', '#FCE7F3', 4), " + "('PREMIUM', 'MANAGER_SERVICE', '专属健康教练服务', '每周 1 次电话随访 + 在线答疑', '👨‍🏫', '#FFEDD5', 5), " + "('PREMIUM', 'PRODUCT_DISCOUNT', '商品会员折扣', '全场商品 6-8 折优惠', '🛒', '#E0E7FF', 6), " + "('PREMIUM', 'COUPON_ACCESS', '优惠券使用权限', '可使用平台优惠券 + 会员专属券', '🎫', '#FEE2E2', 7), " + "('PREMIUM', 'PRIORITY_SUPPORT', '优先客服', '专属客服通道,快速响应', '🎧', '#EDE9FE', 8), " + "('PREMIUM', 'TEACHER_CONSULT', '规划师一对一咨询', '每月 1 次成长规划师专业咨询', '👩‍🏫', '#FBCFE8', 9)"); log.info("已插入membership_benefits种子数据"); } catch (Exception ex) { log.warn("插入membership_benefits种子数据失败: {}", ex.getMessage()); } // 迁移168: 创建用户同意记录表(法律合规) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_consent_records (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "consent_type VARCHAR(50) NOT NULL COMMENT '同意类型: privacy/agreement/children_data/ai_processing', " + "version VARCHAR(20) NOT NULL COMMENT '协议版本号', " + "status VARCHAR(20) NOT NULL DEFAULT 'agreed' COMMENT 'agreed/withdrawn/archived', " + "ip_address VARCHAR(45) COMMENT '用户IP地址', " + "created_at DATETIME NOT NULL, " + "updated_at DATETIME, " + "INDEX idx_user_consent (user_id, consent_type), " + "INDEX idx_consent_type (consent_type)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户同意记录'"); log.info("已创建user_consent_records表"); } catch (Exception e) { log.warn("创建user_consent_records表失败(可能已存在): " + e.getMessage()); } // 迁移169: 创建用户数据授权表(法律合规) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_data_permissions (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "allow_ai_analysis TINYINT(1) DEFAULT 0 COMMENT '允许AI分析对话数据', " + "allow_personalized_recommend TINYINT(1) DEFAULT 1 COMMENT '允许个性化推荐', " + "allow_share_teacher TINYINT(1) DEFAULT 1 COMMENT '允许向成长规划师共享数据', " + "allow_third_party_share TINYINT(1) DEFAULT 0 COMMENT '允许与第三方共享数据', " + "created_at DATETIME NOT NULL, " + "updated_at DATETIME, " + "INDEX idx_user_perm (user_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户数据使用授权'"); log.info("已创建user_data_permissions表"); } catch (Exception e) { log.warn("创建user_data_permissions表失败(可能已存在): " + e.getMessage()); } // 迁移174: family_members 添加饮食相关字段(饮食模块需求) try { jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN is_living_together TINYINT(1) DEFAULT 1 COMMENT '是否同住(0=不同住,不出现在食谱推荐中)'"); log.info("已添加is_living_together列到family_members表"); } catch (Exception e) { log.warn("family_members 添加 is_living_together 列失败(可能已存在): " + e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN meal_with_breakfast_weekday TINYINT(1) DEFAULT 0 COMMENT '工作日早餐同餐'"); log.info("已添加meal_with_breakfast_weekday列到family_members表"); } catch (Exception e) { log.warn("family_members 添加 meal_with_breakfast_weekday 列失败(可能已存在): " + e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN meal_with_lunch_weekday TINYINT(1) DEFAULT 0 COMMENT '工作日午餐同餐'"); log.info("已添加meal_with_lunch_weekday列到family_members表"); } catch (Exception e) { log.warn("family_members 添加 meal_with_lunch_weekday 列失败(可能已存在): " + e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN meal_with_dinner_weekday TINYINT(1) DEFAULT 0 COMMENT '工作日晚餐同餐'"); log.info("已添加meal_with_dinner_weekday列到family_members表"); } catch (Exception e) { log.warn("family_members 添加 meal_with_dinner_weekday 列失败(可能已存在): " + e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN meal_with_breakfast_weekend TINYINT(1) DEFAULT 0 COMMENT '周末早餐同餐'"); log.info("已添加meal_with_breakfast_weekend列到family_members表"); } catch (Exception e) { log.warn("family_members 添加 meal_with_breakfast_weekend 列失败(可能已存在): " + e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN meal_with_lunch_weekend TINYINT(1) DEFAULT 0 COMMENT '周末午餐同餐'"); log.info("已添加meal_with_lunch_weekend列到family_members表"); } catch (Exception e) { log.warn("family_members 添加 meal_with_lunch_weekend 列失败(可能已存在): " + e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN meal_with_dinner_weekend TINYINT(1) DEFAULT 0 COMMENT '周末晚餐同餐'"); log.info("已添加meal_with_dinner_weekend列到family_members表"); } catch (Exception e) { log.warn("family_members 添加 meal_with_dinner_weekend 列失败(可能已存在): " + e.getMessage()); } // 迁移175: 创建 diet_preferences 表(饮食偏好调研表) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS diet_preferences (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_member_id BIGINT NOT NULL COMMENT '绑定家庭成员', " + "allergies JSON COMMENT '过敏原列表', " + "absolute_avoid JSON COMMENT '绝对忌口列表', " + "must_eat JSON COMMENT '必吃食物列表', " + "religious_diet VARCHAR(50) DEFAULT 'none' COMMENT '宗教饮食', " + "spice_level TINYINT DEFAULT NULL COMMENT '辣度 0-5', " + "flavor_pref JSON COMMENT '口味偏好', " + "cuisine_pref JSON COMMENT '菜系偏好', " + "cooking_methods JSON COMMENT '烹饪方式偏好', " + "health_goals JSON COMMENT '健康目标', " + "goal_source VARCHAR(20) DEFAULT NULL COMMENT 'manual/system_recommend', " + "goal_confirmed TINYINT(1) DEFAULT 0 COMMENT '用户已确认', " + "filled_at TIMESTAMP NULL COMMENT '首次填写时间', " + "updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_member (family_member_id), " + "INDEX idx_updated (updated_at)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='饮食偏好调研表'"); log.info("已创建diet_preferences表"); } catch (Exception e) { log.warn("创建 diet_preferences 表失败(可能已存在): " + e.getMessage()); } // 迁移176: 创建 meal_configs 表(共餐配置表) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS meal_configs (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "config_date_type VARCHAR(20) NOT NULL COMMENT '工作日/周末/节假日', " + "meal_type VARCHAR(20) NOT NULL COMMENT 'breakfast/lunch/dinner', " + "participant_member_ids JSON NOT NULL COMMENT '参与成员ID列表', " + "notes VARCHAR(255) DEFAULT NULL COMMENT '备注', " + "created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, " + "updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_config (family_id, config_date_type, meal_type), " + "INDEX idx_family (family_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='共餐配置表'"); log.info("已创建meal_configs表"); } catch (Exception e) { log.warn("创建 meal_configs 表失败(可能已存在): " + e.getMessage()); } // 迁移177: 创建 diet_recommendations 表(食谱推荐方案表) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS diet_recommendations (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "recommendation_date DATE NOT NULL COMMENT '推荐日期', " + "meal_type VARCHAR(20) NOT NULL COMMENT 'breakfast/lunch/dinner', " + "participant_member_ids JSON COMMENT '参与成员ID列表', " + "menu_json MEDIUMTEXT NOT NULL COMMENT '菜品列表JSON', " + "nutrition_summary JSON COMMENT '营养汇总', " + "status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/accepted/adjusted/completed/skipped', " + "version INT DEFAULT 1 COMMENT '版本号', " + "created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, " + "updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_family_date (family_id, recommendation_date), " + "INDEX idx_meal (meal_type, recommendation_date)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='食谱推荐方案表'"); log.info("已创建diet_recommendations表"); } catch (Exception e) { log.warn("创建 diet_recommendations 表失败(可能已存在): " + e.getMessage()); } // 迁移178: 创建 diet_records 表(饮食记录表) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS diet_records (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "member_id BIGINT NOT NULL COMMENT '吃的成员ID', " + "meal_type VARCHAR(20) NOT NULL COMMENT 'breakfast/lunch/dinner/snack', " + "record_date DATE NOT NULL COMMENT '记录日期', " + "record_method VARCHAR(20) DEFAULT NULL COMMENT 'photo/manual/from_recommendation', " + "image_url VARCHAR(500) DEFAULT NULL COMMENT '原图OSS路径', " + "ai_recognized_foods JSON DEFAULT NULL COMMENT 'AI识别结果', " + "user_confirmed_foods JSON NOT NULL COMMENT '用户最终确认食材', " + "notes VARCHAR(500) DEFAULT NULL COMMENT '备注', " + "created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_member_date (member_id, record_date), " + "INDEX idx_family_date (family_id, record_date)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='饮食记录表'"); log.info("已创建diet_records表"); } catch (Exception e) { log.warn("创建 diet_records 表失败(可能已存在): " + e.getMessage()); } // 迁移179: 创建 diet_record_items 表(饮食记录食材明细表) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS diet_record_items (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "record_id BIGINT NOT NULL COMMENT 'diet_records.id', " + "food_name VARCHAR(100) NOT NULL COMMENT '食材名称', " + "food_id BIGINT DEFAULT NULL COMMENT '关联foods表ID', " + "confidence DECIMAL(3,2) DEFAULT NULL COMMENT 'AI置信度0-1', " + "source VARCHAR(20) DEFAULT NULL COMMENT 'ai_recognized/manual/from_recommendation', " + "INDEX idx_record (record_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='饮食记录食材明细表'"); log.info("已创建diet_record_items表"); } catch (Exception e) { log.warn("创建 diet_record_items 表失败(可能已存在): " + e.getMessage()); } // 迁移180: health_reports 添加代上传字段(规划师/营养师代用户提交报告,修复 Unknown column 'proxy_uploader_id' 错误) ensureColumn("health_reports", "proxy_uploader_id", "BIGINT COMMENT '代上传人ID(规划师/营养师)'"); ensureColumn("health_reports", "proxy_upload_note", "TEXT COMMENT '代上传备注'"); ensureColumn("health_reports", "ai_analysis", "TEXT COMMENT 'AI解读结果JSON'"); // 迁移180b: health_reports 添加报告来源格式(beijing/guhe,两种菌群报告数据结构不兼容,需区分) ensureColumn("health_reports", "report_format", "VARCHAR(32) COMMENT '报告来源格式: beijing(北京菌群)/guhe(谷禾)'"); // 迁移181: health_report_drafts 添加备注字段(代上传说明文字) ensureColumn("health_report_drafts", "remark", "VARCHAR(500) DEFAULT NULL COMMENT '备注(如代上传时的说明文字)'"); // 迁移182: 创建 family_challenge_participant 表(挑战参与成员,任意成员发起/可选参与) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_challenge_participant (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "challenge_id BIGINT NOT NULL COMMENT '挑战ID', " + "member_id BIGINT NOT NULL COMMENT '家庭成员ID', " + "status VARCHAR(20) NOT NULL COMMENT 'pending/agreed/rejected', " + "agreed_at DATETIME NULL COMMENT '同意/拒绝时间', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_challenge_member (challenge_id, member_id), " + "INDEX idx_member (member_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战参与成员'"); log.info("已创建family_challenge_participant表"); } catch (Exception e) { log.warn("创建 family_challenge_participant 表失败(可能已存在): " + e.getMessage()); } // 迁移183: 创建 family_relationship_scores 表(方向性关系分数,A对B ≠ B对A) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_relationship_scores (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "from_member_id BIGINT NOT NULL COMMENT '分数持有方(A对B的行)', " + "to_member_id BIGINT NOT NULL COMMENT '分数对象方', " + "trust_score DECIMAL(5,2) DEFAULT 0 COMMENT 'A对B的信任', " + "intimacy_score DECIMAL(5,2) DEFAULT 0 COMMENT 'A对B的亲近', " + "communication_score DECIMAL(5,2) DEFAULT 0 COMMENT 'A对B的沟通', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_from_to (from_member_id, to_member_id), " + "INDEX idx_family (family_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='方向性关系分数'"); log.info("已创建family_relationship_scores表"); } catch (Exception e) { log.warn("创建 family_relationship_scores 表失败(可能已存在): " + e.getMessage()); } // 迁移184: 创建 family_challenge_score_config 表(挑战评分配置,后台可配置)+ 默认数据 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_challenge_score_config (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "action VARCHAR(20) NOT NULL COMMENT 'initiate/agree/reject', " + "score_type VARCHAR(20) NOT NULL COMMENT 'energy/trust/intimacy/communication', " + "value INT NOT NULL DEFAULT 0 COMMENT '增减值(可正可负,默认±1)', " + "enabled TINYINT DEFAULT 1, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_action_type (action, score_type)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战评分配置'"); jdbcTemplate.execute("INSERT IGNORE INTO family_challenge_score_config (action, score_type, value, enabled) VALUES " + "('initiate','energy',1,1),('initiate','trust',0,1),('initiate','intimacy',1,1),('initiate','communication',1,1)," + "('agree','energy',1,1),('agree','trust',1,1),('agree','intimacy',1,1),('agree','communication',1,1)," + "('reject','energy',-1,1),('reject','trust',-1,1),('reject','intimacy',-1,1),('reject','communication',1,1)"); log.info("已创建family_challenge_score_config表并写入默认配置"); } catch (Exception e) { log.warn("创建 family_challenge_score_config 表失败(可能已存在): " + e.getMessage()); } // 迁移185: 存量挑战补全 participant 行(全员 agreed,兼容旧数据,不破坏) try { jdbcTemplate.execute("INSERT IGNORE INTO family_challenge_participant (challenge_id, member_id, status, agreed_at) " + "SELECT fc.id, fm.id, 'agreed', NOW() FROM family_challenge fc " + "JOIN family_members fm ON fm.family_id = fc.family_id " + "WHERE NOT EXISTS (SELECT 1 FROM family_challenge_participant fcp WHERE fcp.challenge_id = fc.id)"); log.info("已为存量挑战补全participant行"); } catch (Exception e) { log.warn("存量挑战补全participant失败(可能已处理): " + e.getMessage()); } // ===== 报告指纹检测与解析器调度系统 ===== // 迁移186: 创建 report_type_registry 表(报告类型注册表) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_type_registry (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "type_id VARCHAR(64) NOT NULL COMMENT '类型标识,如 dan_a1/gut_beijing', " + "family VARCHAR(32) DEFAULT NULL COMMENT '报告家族: dan/gut_flora/其他', " + "display_name VARCHAR(100) NOT NULL COMMENT '显示名称', " + "description VARCHAR(500) DEFAULT NULL COMMENT '描述', " + "parser_path VARCHAR(255) DEFAULT NULL COMMENT 'Python解析器脚本路径', " + "min_confidence INT DEFAULT 10 COMMENT '识别阈值', " + "is_active TINYINT(1) DEFAULT 1 COMMENT '启用状态', " + "source VARCHAR(20) DEFAULT 'prebuilt' COMMENT '来源: prebuilt/imported/auto_generated', " + "review_status VARCHAR(20) DEFAULT 'approved' COMMENT '审核状态: approved/pending/rejected', " + "version VARCHAR(20) DEFAULT '1.0' COMMENT '版本', " + "created_by BIGINT DEFAULT NULL COMMENT '创建人ID', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_type_id (type_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告类型注册表'"); log.info("已创建report_type_registry表"); } catch (Exception e) { log.warn("创建 report_type_registry 表失败(可能已存在): " + e.getMessage()); } // 迁移187: 创建 report_fingerprint 表(指纹规则) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_fingerprint (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "type_id VARCHAR(64) NOT NULL COMMENT '关联report_type_registry.type_id', " + "pattern VARCHAR(200) NOT NULL COMMENT '文本特征', " + "weight INT DEFAULT 10 COMMENT '权重(正=加分,负=排除扣分)', " + "is_exclusion TINYINT(1) DEFAULT 0 COMMENT '是否为排除规则', " + "sort_order INT DEFAULT 0 COMMENT '排序', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_fp_type (type_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告指纹规则表'"); log.info("已创建report_fingerprint表"); } catch (Exception e) { log.warn("创建 report_fingerprint 表失败(可能已存在): " + e.getMessage()); } // 迁移188: 创建 report_unknown_cluster 表(未知报告聚类,自学习用) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_unknown_cluster (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "cluster_key VARCHAR(64) NOT NULL COMMENT '聚类哈希键(同类报告分组)', " + "common_signatures TEXT COMMENT '公共文本特征JSON(用于生成指纹)', " + "llm_template TEXT COMMENT 'LLM解析结果模板JSON', " + "report_count INT DEFAULT 0 COMMENT '累计报告数', " + "status VARCHAR(20) DEFAULT 'collecting' COMMENT '状态: collecting/ready/generated/imported', " + "generated_type_id VARCHAR(64) DEFAULT NULL COMMENT '生成的类型ID', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_cluster_key (cluster_key)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='未知报告聚类表'"); log.info("已创建report_unknown_cluster表"); } catch (Exception e) { log.warn("创建 report_unknown_cluster 表失败(可能已存在): " + e.getMessage()); } // 迁移189: 创建 report_unknown_upload 表(未知报告明细) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_unknown_upload (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "cluster_id BIGINT DEFAULT NULL COMMENT '关联report_unknown_cluster.id', " + "file_url VARCHAR(500) DEFAULT NULL COMMENT '文件路径', " + "file_hash VARCHAR(64) DEFAULT NULL COMMENT '文件哈希', " + "llm_result TEXT COMMENT 'LLM解析结果JSON', " + "uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_unknown_cluster (cluster_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='未知报告上传明细表'"); log.info("已创建report_unknown_upload表"); } catch (Exception e) { log.warn("创建 report_unknown_upload 表失败(可能已存在): " + e.getMessage()); } // 迁移190: 创建 report_parser_import 表(外部导入记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_parser_import (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "package_name VARCHAR(100) NOT NULL COMMENT '导入包名称', " + "package_file_url VARCHAR(500) DEFAULT NULL COMMENT '导入包文件路径', " + "fingerprint_json TEXT COMMENT '指纹规则JSON', " + "parser_script TEXT COMMENT '解析器脚本内容', " + "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/approved/rejected', " + "imported_by BIGINT DEFAULT NULL COMMENT '导入人ID', " + "reviewed_by BIGINT DEFAULT NULL COMMENT '审核人ID', " + "review_note VARCHAR(500) DEFAULT NULL COMMENT '审核意见', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告解析器导入记录表'"); log.info("已创建report_parser_import表"); } catch (Exception e) { log.warn("创建 report_parser_import 表失败(可能已存在): " + e.getMessage()); } // 迁移191: foods 添加 report_id/score 列(Food 实体已含字段,生产库缺列导致 Unknown column 'report_id' 错误) ensureColumn("foods", "report_id", "BIGINT DEFAULT NULL COMMENT '关联报告ID(从PDF解析的食物推荐)'"); ensureColumn("foods", "score", "INT DEFAULT NULL COMMENT '食物评分'"); // 迁移263: foods 补齐 energy_kj/starch/cholesterol/diet_type 列(前三列原迁移在文件重组时丢失、生产靠历史执行残留; // dietType 为实体孤立字段从未建列,MyBatis-Plus 全字段 SELECT 报 Unknown column) ensureColumn("foods", "energy_kj", "DECIMAL(8,2) DEFAULT NULL COMMENT '能量(kJ/100g)'"); ensureColumn("foods", "starch", "DECIMAL(8,2) DEFAULT NULL COMMENT '淀粉(g/100g)'"); ensureColumn("foods", "cholesterol", "DECIMAL(8,2) DEFAULT NULL COMMENT '胆固醇(mg/100g)'"); ensureColumn("foods", "diet_type", "VARCHAR(50) DEFAULT NULL COMMENT '饮食类型'"); // 迁移192: verification_codes.type 添加默认值(旧版插入未携带 type 字段,DB 列 NOT NULL 无默认值导致报错) try { jdbcTemplate.execute("ALTER TABLE verification_codes MODIFY COLUMN `type` VARCHAR(20) NOT NULL DEFAULT 'login' COMMENT '验证码类型: login/register/reset'"); log.info("已为verification_codes.type添加默认值"); } catch (Exception e) { log.warn("verification_codes.type 默认值修改失败(可能已修改): " + e.getMessage()); } // 迁移193: family_relationships表添加关系质量评分三列(关系条展示增强) ensureColumn("family_relationships", "trust_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '信任度(答卷人视角)'"); ensureColumn("family_relationships", "intimacy_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '亲密度(答卷人视角)'"); ensureColumn("family_relationships", "communication_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '沟通质量(答卷人视角)'"); // 迁移194: family_join_requests 添加 family_role 列(保存申请人角色) ensureColumn("family_join_requests", "family_role", "VARCHAR(20) DEFAULT NULL COMMENT '申请人在家庭中的角色:爸爸/妈妈/爷爷/奶奶等'"); // 迁移195: tasks 表新增 accept_deadline 列 + 扩展 status 枚举(支持 assigned/in_progress/rejected) ensureColumn("tasks", "accept_deadline", "DATETIME DEFAULT NULL COMMENT '接受截止时间(分配给其他家庭成员的任务)'"); try { jdbcTemplate.execute("ALTER TABLE tasks MODIFY COLUMN `status` ENUM('pending','assigned','in_progress','completed','rejected','overdue','cancelled') DEFAULT 'pending' COMMENT '状态: pending=待审核 assigned=待接受 in_progress=进行中 completed=已完成 rejected=已拒绝 overdue=已过期 cancelled=已取消'"); log.info("已更新tasks.status枚举值"); } catch (Exception e) { log.warn("tasks.status枚举修改失败(可能已修改): " + e.getMessage()); } // 迁移196: 创建 user_platform_balance 表(CF值余额) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_platform_balance (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "family_id BIGINT COMMENT '归属家庭ID', " + "total_earned INT DEFAULT 0 COMMENT '累计获得', " + "available INT DEFAULT 0 COMMENT '可用余额', " + "frozen INT DEFAULT 0 COMMENT '冻结(提现审核中)', " + "withdrawn INT DEFAULT 0 COMMENT '已提现', " + "exchanged INT DEFAULT 0 COMMENT '已兑换', " + "updated_at DATETIME, " + "created_at DATETIME, " + "UNIQUE KEY uk_user (user_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户CF值余额'"); log.info("已创建user_platform_balance表"); } catch (Exception e) { log.warn("创建user_platform_balance表失败: " + e.getMessage()); } // 迁移197: 创建 platform_balance_log 表(CF值流水) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS platform_balance_log (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "family_id BIGINT COMMENT '归属家庭ID', " + "type VARCHAR(20) NOT NULL COMMENT 'earn/spend/freeze/unfreeze/withdraw', " + "amount INT NOT NULL COMMENT '变动数量', " + "balance_after INT NOT NULL COMMENT '变动后可用余额', " + "ref_type VARCHAR(30) COMMENT 'product_order/activity/withdrawal/migration', " + "ref_id BIGINT COMMENT '关联业务ID', " + "remark VARCHAR(255) COMMENT '备注', " + "created_at DATETIME, " + "INDEX idx_user_time (user_id, created_at)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值流水'"); log.info("已创建platform_balance_log表"); } catch (Exception e) { log.warn("创建platform_balance_log表失败: " + e.getMessage()); } // 迁移198: 存量佣金迁移为CF值(settled→available, pending→frozen) try { jdbcTemplate.execute("INSERT INTO user_platform_balance (user_id, total_earned, available, created_at, updated_at) " + "SELECT referrer_id, SUM(commission_amount), SUM(commission_amount), NOW(), NOW() " + "FROM commission_records WHERE status='settled' GROUP BY referrer_id " + "ON DUPLICATE KEY UPDATE available=available+VALUES(available), " + "total_earned=total_earned+VALUES(total_earned), updated_at=NOW()"); log.info("已迁移settled佣金为可用CF值"); } catch (Exception e) { log.warn("迁移settled佣金失败: " + e.getMessage()); } // 迁移199: pending 佣金 → 冻结CF try { jdbcTemplate.execute("INSERT INTO user_platform_balance (user_id, total_earned, frozen, created_at, updated_at) " + "SELECT referrer_id, SUM(commission_amount), SUM(commission_amount), NOW(), NOW() " + "FROM commission_records WHERE status='pending' GROUP BY referrer_id " + "ON DUPLICATE KEY UPDATE frozen=frozen+VALUES(frozen), " + "total_earned=total_earned+VALUES(total_earned), updated_at=NOW()"); log.info("已迁移pending佣金为冻结CF值"); } catch (Exception e) { log.warn("迁移pending佣金失败: " + e.getMessage()); } // 迁移200: activities表添加platform_points字段 ensureColumn("activities", "platform_points", "INT DEFAULT 0 COMMENT '活动奖励CF值(0=不发)'"); // 迁移201: 新增CF值配置项 insertSysConfigSeed("product_platform_points_share", "1000", "商品CF值利润分成比例(bps)"); insertSysConfigSeed("platform_withdraw_rate", "100", "CF值提现汇率(CF数/元)"); // 迁移202: withdrawal_requests表添加 type 字段(提现来源) ensureColumn("withdrawal_requests", "type", "VARCHAR(20) DEFAULT 'platform_points' COMMENT '提现来源: platform_points(CF值) / commission(历史佣金)'"); // 迁移203: membership_levels 添加原价字段(LIFETIME 需求:价格来源统一迁移至表内) ensureColumn("membership_levels", "original_price_monthly", "INT NULL COMMENT '原价-月费(分)'"); ensureColumn("membership_levels", "original_price_quarterly", "INT NULL COMMENT '原价-季费(分)'"); ensureColumn("membership_levels", "original_price_yearly", "INT NULL COMMENT '原价-年费(分)'"); // 迁移203.1: 幂等数据同步(sys_config 原价/现值 → membership_levels,仅当表内为 0/NULL 时) try { // FAMILY 原价年费(sys_config member_fee_family_original_yearly / member_fee_family_original,默认 199900) jdbcTemplate.update("UPDATE membership_levels SET original_price_yearly = 199900 " + "WHERE level_code = 'FAMILY' AND (original_price_yearly IS NULL OR original_price_yearly = 0)"); // PREMIUM 原价年费/月费(sys_config member_fee_premium_original / member_fee_premium_original_monthly) jdbcTemplate.update("UPDATE membership_levels SET original_price_yearly = 1599900, original_price_monthly = 159900 " + "WHERE level_code = 'PREMIUM' AND (original_price_yearly IS NULL OR original_price_yearly = 0)"); // FAMILY 现价补齐(现值 13140/32850/131400) jdbcTemplate.update("UPDATE membership_levels SET price_monthly = 13140, price_quarterly = 32850, price_yearly = 131400 " + "WHERE level_code = 'FAMILY' AND (price_yearly IS NULL OR price_yearly = 0)"); // PREMIUM 现价补齐(现值 131400/1316800;季费保持 NULL=不支持) jdbcTemplate.update("UPDATE membership_levels SET price_monthly = 131400, price_yearly = 1316800 " + "WHERE level_code = 'PREMIUM' AND (price_yearly IS NULL OR price_yearly = 0)"); log.info("已同步会员价格/原价至membership_levels"); } catch (Exception e) { log.warn("同步membership_levels价格失败: {}", e.getMessage()); } // 迁移204: 插入 LIFETIME 终身会员等级行(不可购买,仅管理员授予,价格全 0) try { jdbcTemplate.execute("INSERT IGNORE INTO membership_levels " + "(level_code, level_name, level_desc, price_monthly, price_quarterly, price_yearly, " + "original_price_monthly, original_price_quarterly, original_price_yearly, " + "features, max_children, max_tasks_per_day, ai_review_enabled, priority_support, teacher_consultation) VALUES " + "('LIFETIME', '终身会员', '终身家庭会员权益,永久有效无需续费', 0, 0, 0, 0, 0, 0, " + "'[\"free_activities\",\"free_courses\",\"full_price_purchase\",\"referral_commission\"," + "\"discount_purchase\",\"member_activities\",\"create_family\",\"invite_family\",\"purchase_commission\"]', " + "6, 20, 1, 1, 1)"); log.info("已插入LIFETIME等级种子数据"); } catch (Exception e) { log.warn("插入LIFETIME等级种子数据失败: {}", e.getMessage()); } // 迁移205: 创建平台公告表 try { jdbcTemplate.execute( "CREATE TABLE IF NOT EXISTS notices (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "title VARCHAR(200) NOT NULL COMMENT '公告标题', " + "content TEXT NOT NULL COMMENT '公告内容', " + "type VARCHAR(20) DEFAULT 'platform' COMMENT '类型: platform=平台公告 activity=活动 promotion=优惠', " + "status VARCHAR(20) DEFAULT 'active' COMMENT '状态: draft=草稿 active=已发布 deleted=已删除', " + "admin_id BIGINT DEFAULT NULL COMMENT '发布管理员ID', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台公告'" ); log.info("已创建notices表"); } catch (Exception e) { log.warn("创建notices表失败: " + e.getMessage()); } // 迁移206: member_upgrade_records 添加审批字段(LIFETIME 授予审批需求,修复升级记录查询 Unknown column 500) ensureColumn("member_upgrade_records", "approve_remark", "VARCHAR(500) DEFAULT NULL COMMENT '审批备注'"); ensureColumn("member_upgrade_records", "approve_status", "VARCHAR(20) DEFAULT 'approved' COMMENT '审批状态: approved/rejected'"); ensureColumn("member_upgrade_records", "approve_user_id", "BIGINT DEFAULT NULL COMMENT '审批人ID'"); ensureColumn("member_upgrade_records", "approve_time", "DATETIME DEFAULT NULL COMMENT '审批时间'"); // 迁移207: 创建 health_plans 表(健康方案汇总记录) try { jdbcTemplate.execute( "CREATE TABLE IF NOT EXISTS health_plans (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "member_ids VARCHAR(200) NOT NULL COMMENT '目标成员ID列表(逗号分隔)', " + "dimensions VARCHAR(100) NOT NULL COMMENT '维度列表(逗号分隔)', " + "goal TEXT NOT NULL COMMENT '用户输入的目标', " + "plan_content TEXT COMMENT 'LangGraph生成的方案内容', " + "member_name VARCHAR(50) COMMENT '成员名称摘要', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_family (family_id), " + "INDEX idx_created (created_at)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康方案汇总记录'" ); log.info("已创建health_plans表"); } catch (Exception e) { log.warn("创建health_plans表失败(可能已存在): " + e.getMessage()); } // 迁移208: health_gut_flora.detection_rate 列扩容(VARCHAR(50)→VARCHAR(255),源数据超长导致 MysqlDataTruncation) try { jdbcTemplate.execute("ALTER TABLE health_gut_flora MODIFY COLUMN detection_rate VARCHAR(255) DEFAULT NULL COMMENT '检出率'"); log.info("health_gut_flora.detection_rate 已扩容为 VARCHAR(255)"); } catch (Exception e) { log.warn("health_gut_flora.detection_rate 扩容失败(可能已修改): " + e.getMessage()); } // 迁移225: articles 添加 growth_category 列(成长分类筛选,Article 实体已含字段,生产库缺列导致 Unknown column 'growth_category') ensureColumn("articles", "growth_category", "VARCHAR(50) DEFAULT NULL COMMENT '成长分类'"); // 迁移209: 将 ecom_supplier 合并至 supply_system(商品供应商管理 → 供应商体系管理统一) // 1. 给 supply_system 加联系人字段 ensureColumn("supply_system", "contact_name", "VARCHAR(50) DEFAULT NULL COMMENT '联系人'"); ensureColumn("supply_system", "contact_phone", "VARCHAR(20) DEFAULT NULL COMMENT '联系电话'"); ensureColumn("supply_system", "remark", "VARCHAR(500) DEFAULT NULL COMMENT '备注'"); log.info("迁移209: supply_system 已添加 contact_name/contact_phone/remark 列"); // 2. 将 ecom_supplier 存量数据迁移到 supply_system(以平台管理员 id=1 为默认 admin) try { jdbcTemplate.update( "INSERT INTO supply_system (name, admin_id, settlement_period_days, platform_profit_rate, status, " + "contact_name, contact_phone, remark, created_at, updated_at) " + "SELECT name, 1, 30, 0, " + "CASE WHEN status = 1 THEN 'active' ELSE 'disabled' END, " + "contact_name, contact_phone, remark, created_at, COALESCE(updated_at, created_at) " + "FROM ecom_supplier WHERE NOT EXISTS (" + " SELECT 1 FROM supply_system ss WHERE ss.name = ecom_supplier.name LIMIT 1)" ); log.info("迁移209: ecom_supplier 存量数据已迁移至 supply_system"); } catch (Exception e) { log.warn("迁移209: ecom_supplier 迁移失败(可能已存在): {}", e.getMessage()); } } private void runFullRegionDataMigrations() { // 迁移226: 创建 family_platform_balance 表 + family_platform_balance_log 表 + products.points_multiplier 列 try { jdbcTemplate.execute( "CREATE TABLE IF NOT EXISTS family_platform_balance (" + " id BIGINT AUTO_INCREMENT PRIMARY KEY, " + " family_id BIGINT NOT NULL COMMENT '家庭ID', " + " total_earned INT DEFAULT 0 COMMENT '累计获得', " + " available INT DEFAULT 0 COMMENT '可用余额', " + " frozen INT DEFAULT 0 COMMENT '冻结(提现审核中)', " + " withdrawn INT DEFAULT 0 COMMENT '已提现', " + " exchanged INT DEFAULT 0 COMMENT '已兑换', " + " created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + " updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + " UNIQUE KEY uk_family (family_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭平台积分池余额'" ); log.info("已创建family_platform_balance表"); } catch (Exception e) { log.warn("创建family_platform_balance表失败: " + e.getMessage()); } try { jdbcTemplate.execute( "CREATE TABLE IF NOT EXISTS family_platform_balance_log (" + " id BIGINT AUTO_INCREMENT PRIMARY KEY, " + " family_id BIGINT NOT NULL COMMENT '家庭ID', " + " type VARCHAR(20) NOT NULL COMMENT 'earn/spend/freeze/unfreeze/withdraw/adjust/migration', " + " amount INT NOT NULL COMMENT '变动数量', " + " balance_after INT NOT NULL COMMENT '变动后可用余额', " + " ref_type VARCHAR(50) COMMENT 'product_order/referral_dist/activity/withdrawal/adjust/migration/refund', " + " ref_id BIGINT COMMENT '关联业务ID', " + " remark VARCHAR(255) COMMENT '备注', " + " created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + " INDEX idx_family (family_id), " + " INDEX idx_ref (ref_type, ref_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭平台积分流水'" ); log.info("已创建family_platform_balance_log表"); } catch (Exception e) { log.warn("创建family_platform_balance_log表失败: " + e.getMessage()); } ensureColumn("products", "points_multiplier", "DECIMAL(10,2) NOT NULL DEFAULT 1.00 COMMENT '平台积分倍数,1元=1积分×倍数'"); log.info("已添加products.points_multiplier列"); // 迁移227: 创建 family_platform_exchange_record 表(优惠券CF兑换记录;券挂家庭,CF扣兑换人个人账本) try { jdbcTemplate.execute( "CREATE TABLE IF NOT EXISTS family_platform_exchange_record (" + " id BIGINT AUTO_INCREMENT PRIMARY KEY, " + " user_id BIGINT NOT NULL COMMENT '操作用户ID', " + " family_id BIGINT NOT NULL COMMENT '家庭ID', " + " coupon_id BIGINT NOT NULL COMMENT '关联优惠券ID', " + " coupon_name VARCHAR(200) COMMENT '优惠券名称', " + " cf_cost INT NOT NULL COMMENT '消耗CF值数量', " + " status VARCHAR(20) NOT NULL DEFAULT 'completed' COMMENT 'completed/failed', " + " redeem_code VARCHAR(32) COMMENT '兑换码', " + " remark VARCHAR(255) COMMENT '备注', " + " created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + " updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + " INDEX idx_family_time (family_id, created_at), " + " INDEX idx_user (user_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券CF兑换记录(券挂家庭,CF扣兑换人个人账本cf_balance)'" ); log.info("已创建family_platform_exchange_record表"); } catch (Exception e) { log.warn("创建family_platform_exchange_record表失败: " + e.getMessage()); } // 迁移228: nutrition_product 添加 ingredients 列 + suitable_for 升级为 TEXT(存放详情页图像提取的成份/配料表与适用说明) ensureColumn("nutrition_product", "ingredients", "TEXT DEFAULT NULL COMMENT '成份/配料表(从详情页图片提取)'"); try { jdbcTemplate.execute("ALTER TABLE nutrition_product MODIFY COLUMN suitable_for TEXT DEFAULT NULL COMMENT '适用人群/使用方法'"); log.info("已将nutrition_product.suitable_for升级为TEXT类型"); } catch (Exception e) { // 修改失败,忽略错误(幂等) } // 迁移222: 创建 file_tag 表(文件标签字典) try { jdbcTemplate.execute( "CREATE TABLE IF NOT EXISTS file_tag (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "name VARCHAR(50) NOT NULL COMMENT '标签名称', " + "color VARCHAR(7) DEFAULT '#409EFF' COMMENT '标签颜色', " + "sort_order INT DEFAULT 0 COMMENT '排序号', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_name (name)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件标签字典'" ); log.info("已创建file_tag表"); } catch (Exception e) { log.warn("创建file_tag表失败: " + e.getMessage()); } String[][] presetTags = { {"测评报告", "#409EFF"}, {"健康报告", "#67C23A"}, {"文章配图", "#E6A23C"}, {"成长档案", "#909399"}, {"身", "#FF8C42"}, {"智", "#6366F1"}, {"富", "#F59E0B"}, {"行", "#10B981"}, {"心", "#FF6B9D"}, {"重要", "#F56C6C"} }; for (int i = 0; i < presetTags.length; i++) { try { jdbcTemplate.update("INSERT IGNORE INTO file_tag (name, color, sort_order) VALUES (?, ?, ?)", presetTags[i][0], presetTags[i][1], i); } catch (Exception e) { /* 忽略重复 */ } } log.info("已预置文件标签"); // 迁移223: 创建 file_tag_rel 表 try { jdbcTemplate.execute( "CREATE TABLE IF NOT EXISTS file_tag_rel (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "file_id BIGINT NOT NULL COMMENT 'file_record.id', " + "tag_id BIGINT NOT NULL COMMENT 'file_tag.id', " + "UNIQUE KEY uk_file_tag (file_id, tag_id), " + "INDEX idx_tag (tag_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件-标签关联'" ); log.info("已创建file_tag_rel表"); } catch (Exception e) { log.warn("创建file_tag_rel表失败: " + e.getMessage()); } // 迁移229: 新增GROWTH_COACH权益(7X24小时成长教练)到FAMILY/PREMIUM,及mascot_ai功能开关 try { jdbcTemplate.execute("INSERT IGNORE INTO membership_benefits (level_code, benefit_code, benefit_name, benefit_desc, icon, bg_color, sort_order) VALUES " + "('FAMILY', 'GROWTH_COACH', '7X24小时成长教练', 'AI智能教练随时解答家庭教育问题', '🎯', '#FEF3C7', 10), " + "('PREMIUM', 'GROWTH_COACH', '7X24小时成长教练', 'AI智能教练随时解答家庭教育问题 + 优先响应', '🎯', '#FEF3C7', 10)"); log.info("已插入GROWTH_COACH权益种子数据"); } catch (Exception ex) { log.warn("插入GROWTH_COACH权益种子数据失败: {}", ex.getMessage()); } // 迁移232: recommendation_logs 追加 recommend_type 字段 ensureColumn("recommendation_logs", "recommend_type", "VARCHAR(20) DEFAULT 'tag' COMMENT '推荐方式: profile/dimension/tag'"); log.info("已添加recommendation_logs.recommend_type列"); // 迁移233: 将 Article/Activity 现有 dimension_weights JSON 同步到 dimension_weights 表 // (修正:源表名 article→articles、activity→activities;activity与article同为五维JSON对象按维度键展开; // dimension_weights表无唯一键,INSERT IGNORE不去重,用sys_config一次性标记+自连接去重兜底) try { Integer synced = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM sys_config WHERE config_key = 'dimension_weights_legacy_sync_done'", Integer.class); if (synced == null || synced == 0) { jdbcTemplate.execute( "INSERT IGNORE INTO dimension_weights (target_type, target_id, dimension, weight, enabled) " + "SELECT 'article', a.id, 'body', COALESCE(CAST(JSON_EXTRACT(a.dimension_weights, '$.body') AS SIGNED), 0), 1 " + "FROM articles a WHERE a.dimension_weights IS NOT NULL AND a.dimension_weights != '' AND JSON_EXTRACT(a.dimension_weights, '$.body') IS NOT NULL " + "UNION ALL " + "SELECT 'article', a.id, 'mind', COALESCE(CAST(JSON_EXTRACT(a.dimension_weights, '$.mind') AS SIGNED), 0), 1 " + "FROM articles a WHERE a.dimension_weights IS NOT NULL AND a.dimension_weights != '' AND JSON_EXTRACT(a.dimension_weights, '$.mind') IS NOT NULL " + "UNION ALL " + "SELECT 'article', a.id, 'wisdom', COALESCE(CAST(JSON_EXTRACT(a.dimension_weights, '$.wisdom') AS SIGNED), 0), 1 " + "FROM articles a WHERE a.dimension_weights IS NOT NULL AND a.dimension_weights != '' AND JSON_EXTRACT(a.dimension_weights, '$.wisdom') IS NOT NULL " + "UNION ALL " + "SELECT 'article', a.id, 'action', COALESCE(CAST(JSON_EXTRACT(a.dimension_weights, '$.action') AS SIGNED), 0), 1 " + "FROM articles a WHERE a.dimension_weights IS NOT NULL AND a.dimension_weights != '' AND JSON_EXTRACT(a.dimension_weights, '$.action') IS NOT NULL " + "UNION ALL " + "SELECT 'article', a.id, 'wealth', COALESCE(CAST(JSON_EXTRACT(a.dimension_weights, '$.wealth') AS SIGNED), 0), 1 " + "FROM articles a WHERE a.dimension_weights IS NOT NULL AND a.dimension_weights != '' AND JSON_EXTRACT(a.dimension_weights, '$.wealth') IS NOT NULL" ); jdbcTemplate.execute( "INSERT IGNORE INTO dimension_weights (target_type, target_id, dimension, weight, enabled) " + "SELECT 'activity', act.id, 'body', COALESCE(CAST(JSON_EXTRACT(act.dimension_weights, '$.body') AS SIGNED), 0), 1 " + "FROM activities act WHERE act.dimension_weights IS NOT NULL AND act.dimension_weights != '' AND JSON_EXTRACT(act.dimension_weights, '$.body') IS NOT NULL " + "UNION ALL " + "SELECT 'activity', act.id, 'mind', COALESCE(CAST(JSON_EXTRACT(act.dimension_weights, '$.mind') AS SIGNED), 0), 1 " + "FROM activities act WHERE act.dimension_weights IS NOT NULL AND act.dimension_weights != '' AND JSON_EXTRACT(act.dimension_weights, '$.mind') IS NOT NULL " + "UNION ALL " + "SELECT 'activity', act.id, 'wisdom', COALESCE(CAST(JSON_EXTRACT(act.dimension_weights, '$.wisdom') AS SIGNED), 0), 1 " + "FROM activities act WHERE act.dimension_weights IS NOT NULL AND act.dimension_weights != '' AND JSON_EXTRACT(act.dimension_weights, '$.wisdom') IS NOT NULL " + "UNION ALL " + "SELECT 'activity', act.id, 'action', COALESCE(CAST(JSON_EXTRACT(act.dimension_weights, '$.action') AS SIGNED), 0), 1 " + "FROM activities act WHERE act.dimension_weights IS NOT NULL AND act.dimension_weights != '' AND JSON_EXTRACT(act.dimension_weights, '$.action') IS NOT NULL " + "UNION ALL " + "SELECT 'activity', act.id, 'wealth', COALESCE(CAST(JSON_EXTRACT(act.dimension_weights, '$.wealth') AS SIGNED), 0), 1 " + "FROM activities act WHERE act.dimension_weights IS NOT NULL AND act.dimension_weights != '' AND JSON_EXTRACT(act.dimension_weights, '$.wealth') IS NOT NULL" ); jdbcTemplate.execute( "DELETE dw1 FROM dimension_weights dw1 INNER JOIN dimension_weights dw2 ON " + "dw1.target_type = dw2.target_type AND dw1.target_id = dw2.target_id AND dw1.dimension = dw2.dimension " + "AND dw1.id > dw2.id" ); jdbcTemplate.update("INSERT INTO sys_config (config_key, config_value, description) VALUES ('dimension_weights_legacy_sync_done', '1', 'Article/Activity五维权重已同步到dimension_weights表')"); log.info("已完成Article/Activity维度权重到dimension_weights表的一次性迁移"); } else { log.info("迁移233: 维度权重已同步过,跳过"); } } catch (Exception ex) { log.warn("Article/Activity维度权重同步失败: {}", ex.getMessage()); } // 迁移234: 创建 problem_domains 表(问题域定义,改版v1:需求驱动·逐步解锁) try { jdbcTemplate.execute( "CREATE TABLE IF NOT EXISTS problem_domains (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "code VARCHAR(50) NOT NULL COMMENT '唯一编码: weight_loss/sleep/constipation', " + "name VARCHAR(50) NOT NULL COMMENT '名称: 减肥/睡眠/便秘', " + "icon VARCHAR(255) DEFAULT '' COMMENT '图标', " + "description VARCHAR(500) DEFAULT '' COMMENT '一句话描述', " + "flow_intro_json TEXT COMMENT '服务流程介绍(7步) JSON', " + "survey_template_id BIGINT DEFAULT NULL COMMENT '专项调研问卷模板ID(SurveyTemplate.id)', " + "content_tags VARCHAR(500) DEFAULT '' COMMENT '内容标签,逗号分隔(过滤文章/知识库)', " + "recommended_product_ids VARCHAR(500) DEFAULT '' COMMENT '推荐产品ID,逗号分隔(Product.id)', " + "task_template_package_ids VARCHAR(500) DEFAULT '' COMMENT '套餐绑定的任务模板包ID(TaskTemplatePackage.id)', " + "reward_cf_value INT DEFAULT 0 COMMENT '全部完成奖励CF值', " + "status TINYINT DEFAULT 1 COMMENT '1启用 0停用', " + "sort_order INT DEFAULT 0, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_code (code)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='问题域定义(改版v1)'" ); log.info("已创建problem_domains表"); } catch (Exception e) { log.warn("创建problem_domains表失败: " + e.getMessage()); } // 迁移235: 创建 user_intents 表(用户意图与服务旅程) try { jdbcTemplate.execute( "CREATE TABLE IF NOT EXISTS user_intents (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "intent_type VARCHAR(20) DEFAULT '' COMMENT 'A/B/C 用户类型', " + "problem_domain_code VARCHAR(50) DEFAULT '' COMMENT '问题域编码(仅C类)', " + "journey_stage INT DEFAULT 0 COMMENT '旅程阶段 0-7', " + "stage_updated_at DATETIME DEFAULT NULL COMMENT '最近阶段变化时间', " + "survey_template_id BIGINT DEFAULT NULL COMMENT '最近作答问卷模板ID', " + "survey_submitted_at DATETIME DEFAULT NULL COMMENT '最近问卷提交时间', " + "survey_result_json TEXT COMMENT '问卷答案→推荐结果快照', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_user (user_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户意图与服务旅程(改版v1)'" ); log.info("已创建user_intents表"); } catch (Exception e) { log.warn("创建user_intents表失败: " + e.getMessage()); } // 迁移236: 创建 user_notifications 表(用户级站内主动通知,与notices平台公告并存) try { jdbcTemplate.execute( "CREATE TABLE IF NOT EXISTS user_notifications (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '接收用户ID', " + "type VARCHAR(30) DEFAULT 'report_ready' COMMENT 'report_ready/reward_granted/logistics/system', " + "title VARCHAR(200) NOT NULL, " + "content VARCHAR(1000) DEFAULT '', " + "ref_type VARCHAR(30) DEFAULT '' COMMENT '关联对象类型: report/package/task', " + "ref_id BIGINT DEFAULT NULL COMMENT '关联对象ID', " + "is_read TINYINT DEFAULT 0, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_user_read (user_id, is_read)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='站内通知(改版v1)'" ); log.info("已创建user_notifications表"); } catch (Exception e) { log.warn("创建user_notifications表失败: " + e.getMessage()); } // 迁移237: tasks 表添加 member_only 字段(1=会员任务,仅方案/套餐生成) ensureColumn("tasks", "member_only", "TINYINT DEFAULT 0 COMMENT '1=会员任务(方案/套餐生成),执行需会员; 0=普通任务可免费执行'"); // 迁移238: package_orders 表添加物流状态字段(试剂盒寄送/样本寄回) ensureColumn("package_orders", "logistics_status", "VARCHAR(20) DEFAULT 'none' COMMENT 'none/kit_shipped/kit_received/sample_shipped/sample_received'"); ensureColumn("package_orders", "tracking_no", "VARCHAR(100) DEFAULT '' COMMENT '快递单号'"); log.info("已添加package_orders物流字段"); // 迁移239: 创建 health_status 表(当前状态调研-健康现状档案,SS-2026-08-13) try { jdbcTemplate.execute( "CREATE TABLE IF NOT EXISTS health_status (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '所属账号(一个账号一份)', " + "height_cm DECIMAL(5,1) DEFAULT NULL COMMENT '身高(cm)', " + "weight_kg DECIMAL(5,1) DEFAULT NULL COMMENT '体重(kg)', " + "blood_pressure VARCHAR(20) DEFAULT NULL COMMENT '血压,如 120/80', " + "blood_glucose DECIMAL(4,1) DEFAULT NULL COMMENT '空腹血糖(mmol/L)', " + "blood_lipids JSON DEFAULT NULL COMMENT '血脂 [{name,value,unit,note}]', " + "disease_history JSON DEFAULT NULL COMMENT '疾病史 [{name,note}]', " + "medications JSON DEFAULT NULL COMMENT '在服药物/治疗 [{name,note}]', " + "notes VARCHAR(500) DEFAULT NULL COMMENT '其他补充说明', " + "filled_at DATETIME DEFAULT NULL COMMENT '首次填写时间', " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', " + "UNIQUE KEY uk_user (user_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='当前状态调研-健康现状档案'" ); log.info("已创建health_status表"); } catch (Exception e) { log.warn("创建health_status表失败: " + e.getMessage()); } // 迁移240: 创建 system_prompts 表(系统提示词统一管理,cfc-web后台可维护) try { jdbcTemplate.execute( "CREATE TABLE IF NOT EXISTS system_prompts (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "prompt_key VARCHAR(64) NOT NULL COMMENT '提示词键(如 health_coach/analysis/chat)', " + "title VARCHAR(100) NOT NULL COMMENT '提示词标题', " + "content TEXT NOT NULL COMMENT '提示词内容', " + "default_content TEXT COMMENT '出厂默认内容(重置时恢复)', " + "version INT NOT NULL DEFAULT 1 COMMENT '版本号', " + "status TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=禁用', " + "remark VARCHAR(255) COMMENT '备注', " + "updated_by VARCHAR(64) COMMENT '更新人', " + "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_prompt_key (prompt_key)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统提示词管理'" ); log.info("已创建system_prompts表"); } catch (Exception e) { log.warn("创建system_prompts表失败: " + e.getMessage()); } // 迁移240b: 插入默认系统提示词(仅当表为空时) try { jdbcTemplate.execute( "INSERT IGNORE INTO system_prompts (prompt_key, title, content, default_content, version, remark) VALUES " + "('health_butler', '家庭健康管家', " + "'你是一个家庭AI健康管家, 为用户提供专业、温暖的个性化健康建议。\\n你的能力:\\n1. 基于健康知识库(菌属功能、指标解读、营养素、食物)提供科学建议\\n2. 解读健康报告数据, 结合知识库给出改善方案\\n3. 根据菌群检测结果推荐精准营养方案\\n4. 提供可执行的健康微行动\\n回答原则:\\n1. 用中文, 像家庭医生一样亲切但专业\\n2. 引用知识库中的菌属/指标/食物信息作为证据\\n3. 优先使用知识库数据, 其次使用通用健康知识\\n4. 需要产品推荐时使用搜索工具\\n5. 生成 [TASK: {\"title\": \"任务名\", \"dimension\": \"身\", \"points\": 10, \"frequency\": \"每天\"}] 标记创建健康行动任务\\n6. 超出知识库范围的慢性病/急症问题, 建议咨询专业医生\\n7. 不要编造医学建议, 不夸大效果', " + "'你是一个家庭AI健康管家, 为用户提供专业、温暖的个性化健康建议。\\n你的能力:\\n1. 基于健康知识库(菌属功能、指标解读、营养素、食物)提供科学建议\\n2. 解读健康报告数据, 结合知识库给出改善方案\\n3. 根据菌群检测结果推荐精准营养方案\\n4. 提供可执行的健康微行动\\n回答原则:\\n1. 用中文, 像家庭医生一样亲切但专业\\n2. 引用知识库中的菌属/指标/食物信息作为证据\\n3. 优先使用知识库数据, 其次使用通用健康知识\\n4. 需要产品推荐时使用搜索工具\\n5. 生成 [TASK: {\"title\": \"任务名\", \"dimension\": \"身\", \"points\": 10, \"frequency\": \"每天\"}] 标记创建健康行动任务\\n6. 超出知识库范围的慢性病/急症问题, 建议咨询专业医生\\n7. 不要编造医学建议, 不夸大效果', " + "1, 'AI健康管家对话'), " + "('health_coach', '儿童健康教练', " + "'你是一个儿童健康教练,喜欢用提问的方式引导家长发现问题和解决方案。\\n核心原则:\\n1. 先问后答:不要直接给答案,用问题引导家长思考\\n2. 用开放式问题了解情况,再给针对性建议\\n3. 语气温暖,像一个耐心的教练在陪伴家长成长\\n你的职责:\\n1. 儿童体质调理(如挑食、瘦弱、易疲劳、反复生病)—— 用提问了解情况\\n2. 科学喂养和营养建议 —— 先问饮食现状再给建议\\n3. 日常保健(睡眠、运动、季节防护)—— 引导家长发现生活中的问题\\n4. 体质辨识基础咨询 —— 通过提问帮助家长理解孩子体质特点\\n5. 健康问题判断 —— 先收集症状信息再建议是否就医\\n回答结构: 提问-分析-建议-跟进', " + "'你是一个儿童健康教练,喜欢用提问的方式引导家长发现问题和解决方案。\\n核心原则:\\n1. 先问后答:不要直接给答案,用问题引导家长思考\\n2. 用开放式问题了解情况,再给针对性建议\\n3. 语气温暖,像一个耐心的教练在陪伴家长成长\\n你的职责:\\n1. 儿童体质调理(如挑食、瘦弱、易疲劳、反复生病)—— 用提问了解情况\\n2. 科学喂养和营养建议 —— 先问饮食现状再给建议\\n3. 日常保健(睡眠、运动、季节防护)—— 引导家长发现生活中的问题\\n4. 体质辨识基础咨询 —— 通过提问帮助家长理解孩子体质特点\\n5. 健康问题判断 —— 先收集症状信息再建议是否就医\\n回答结构: 提问-分析-建议-跟进', " + "1, '儿童健康咨询对话'), " + "('chat_assistant', '儿童成长助手', " + "'你是一个儿童成长家庭助手, 回答关于孩子成长、健康、教育的各种问题。', " + "'你是一个儿童成长家庭助手, 回答关于孩子成长、健康、教育的各种问题。', " + "1, '通用对话助手')" ); log.info("已插入默认系统提示词"); } catch (Exception e) { log.warn("插入默认系统提示词失败: " + e.getMessage()); } // 迁移242: 创建 sys_menu 表(管理后台侧边栏菜单,数据库驱动 + 角色授权) try { jdbcTemplate.execute( "CREATE TABLE IF NOT EXISTS sys_menu (" + " id BIGINT AUTO_INCREMENT PRIMARY KEY, " + " parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父菜单ID,0=顶级', " + " title VARCHAR(50) COMMENT '目录标题(有子菜单时用,对应 el-submenu)', " + " label VARCHAR(50) COMMENT '叶子菜单显示名(对应 el-menu-item)', " + " path VARCHAR(200) COMMENT '路由路径(叶子菜单必填,如 /families)', " + " icon VARCHAR(100) COMMENT 'Element UI 图标类名,如 el-icon-s-custom', " + " perm VARCHAR(100) COMMENT '权限标识,如 family:list / task:* / system:config', " + " sort INT DEFAULT 0 COMMENT '排序号,小的在前', " + " visible TINYINT DEFAULT 1 COMMENT '是否显示 1=显示 0=隐藏', " + " created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + " updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + " INDEX idx_parent (parent_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='后台侧边栏菜单表'" ); log.info("已创建sys_menu表"); } catch (Exception e) { log.warn("创建sys_menu表失败: " + e.getMessage()); } // 迁移243: 创建 sys_role_menu 表(角色-菜单授权) try { jdbcTemplate.execute( "CREATE TABLE IF NOT EXISTS sys_role_menu (" + " id BIGINT AUTO_INCREMENT PRIMARY KEY, " + " role VARCHAR(50) NOT NULL COMMENT '角色标识: admin/teacher/nutritionist/article_manager/activity_manager/supplier_admin', " + " menu_id BIGINT NOT NULL COMMENT 'sys_menu.id', " + " created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + " UNIQUE KEY uk_role_menu (role, menu_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色-菜单授权表'" ); log.info("已创建sys_role_menu表"); } catch (Exception e) { log.warn("创建sys_role_menu表失败: " + e.getMessage()); } // 迁移244: sys_menu 菜单种子数据(对应 Layout.vue 硬编码 menuItems,表为空才插入,幂等) try { Integer menuCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM sys_menu", Integer.class); if (menuCount == null || menuCount == 0) { seedSysMenus(); log.info("sys_menu 种子数据已插入"); } else { log.info("sys_menu 已有 {} 条数据,跳过种子插入", menuCount); } } catch (Exception e) { log.warn("初始化sys_menu种子数据失败: " + e.getMessage()); } // 迁移245: 创建 notification_templates 表(通知模板管理 W3) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS notification_templates (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "type VARCHAR(30) NOT NULL COMMENT '通知类型: report_ready/reward_granted/system', " + "title VARCHAR(200) NOT NULL COMMENT '默认标题(支持占位符)', " + "content VARCHAR(1000) NOT NULL COMMENT '默认内容模板(支持占位符)', " + "channels VARCHAR(100) DEFAULT 'in_app' COMMENT '推送通道: in_app,sms,wechat,phone 逗号分隔', " + "status TINYINT DEFAULT 1 COMMENT '1启用 0停用', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_type (type)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知模板(改版v1 W3)'"); log.info("已创建notification_templates表"); } catch (Exception e) { log.warn("创建notification_templates表可能已存在: {}", e.getMessage()); } // 迁移245b: notification_templates 种子数据(表为空才插入,幂等) try { Integer tplCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM notification_templates", Integer.class); if (tplCount == null || tplCount == 0) { jdbcTemplate.execute("INSERT INTO notification_templates (type, title, content, channels) VALUES " + "('report_ready', '健康报告已完成分析', '您的健康报告已完成解析,可前往查看结果与成长方案。', 'in_app,sms'), " + "('reward_granted', '问题域完成奖励已到账', '恭喜完成「{domainName}」全部任务,奖励 {reward} CF 已发放至家庭账户。', 'in_app,sms,wechat'), " + "('system', '系统通知', '您有一条新的系统通知,请前往查看。', 'in_app'), " + "('energy_change', '能量变动', '{description}获得{amount}能量', 'in_app'), " + "('points_change', '积分变动', '{description}变动{amount}积分', 'in_app'), " + "('cf_change', 'CF值变动', '{remark}获得{amount} CF值', 'in_app'), " + "('procurement_completed', '采购任务已完成', '您的【{comboTier}】套餐采购任务已完成,共{productCount}件商品,下单时间:{createTime},产品即将送达,请及时查收。', 'in_app,wechat')"); log.info("notification_templates 种子数据已插入"); } else { log.info("notification_templates 已有 {} 条数据,跳过种子插入", tplCount); } } catch (Exception e) { log.warn("初始化notification_templates种子数据失败: " + e.getMessage()); } // 迁移246: product_skus 存量价格 ×100(元→分),sys_config 幂等标记 try { Integer migrated = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM sys_config WHERE config_key = 'sku_price_to_cents_done'", Integer.class); if (migrated == null || migrated == 0) { jdbcTemplate.update("UPDATE product_skus SET price = IFNULL(price, 0) * 100, original_price = IFNULL(original_price, 0) * 100"); jdbcTemplate.update("INSERT INTO sys_config (config_key, config_value, description) VALUES ('sku_price_to_cents_done', '1', 'SKU价格已迁移为分单位')"); log.info("迁移246: product_skus 价格已×100(元→分)"); } else { log.info("迁移246: product_skus 价格已迁移过,跳过"); } } catch (Exception e) { log.warn("迁移246失败: {}", e.getMessage()); } // 迁移247: product_skus 添加 linked_product_id 和 label 列(SKU关联商品选择器) ensureColumn("product_skus", "linked_product_id", "BIGINT COMMENT '关联商品ID,null=Legacy SKU'"); ensureColumn("product_skus", "label", "VARCHAR(100) COMMENT '自定义显示标签,null=使用linked_product.name'"); // 迁移248: families 添加 butler_id(B-5 管家行级隔离) ensureColumn("families", "butler_id", "BIGINT COMMENT '绑定的管家userId'"); try { jdbcTemplate.execute("CREATE INDEX idx_families_butler_id ON families(butler_id)"); log.info("已创建families.butler_id索引"); } catch (Exception e) { String msg = e.getMessage(); if (msg == null || !(msg.contains("Duplicate") || msg.contains("exists"))) { log.warn("创建families.butler_id索引失败: {}", msg); } } // 迁移250: 清理测试脏数据(B-7:TEST-Energy-Grant 测试任务、乱码昵称) try { int deletedTasks = jdbcTemplate.update("DELETE FROM tasks WHERE title LIKE 'TEST-Energy-Grant%'"); int fixedNicknames = jdbcTemplate.update("UPDATE users SET nickname = '测试家庭' WHERE nickname LIKE '娴嬭瘯%' AND nickname <> '测试家庭'"); if (deletedTasks > 0 || fixedNicknames > 0) { log.info("迁移250: 已清理测试任务 {} 条,修复乱码昵称 {} 个", deletedTasks, fixedNicknames); } } catch (Exception e) { log.warn("迁移250失败: {}", e.getMessage()); } // 迁移252: 创建用户画像表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS profile_snapshot (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "member_id BIGINT NOT NULL UNIQUE, " + "dimension_scores JSON DEFAULT NULL, " + "body_metrics JSON DEFAULT NULL, " + "mind_metrics JSON DEFAULT NULL, " + "wisdom_metrics JSON DEFAULT NULL, " + "action_metrics JSON DEFAULT NULL, " + "wealth_metrics JSON DEFAULT NULL, " + "problem_domains VARCHAR(500) DEFAULT NULL, " + "computed_at DATETIME DEFAULT NULL, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_member_id (member_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); log.info("已创建profile_snapshot表"); } catch (Exception e) { log.warn("创建profile_snapshot表可能已存在: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS profile_history (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "member_id BIGINT NOT NULL, " + "snapshot_date DATE NOT NULL, " + "all_metrics JSON NOT NULL, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_member_date (member_id, snapshot_date)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); log.info("已创建profile_history表"); } catch (Exception e) { log.warn("创建profile_history表可能已存在: {}", e.getMessage()); } // 迁移254: 创建 report_template 表(报告展示模板配置) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_template (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "name VARCHAR(100) NOT NULL COMMENT '模板名称', " + "report_type VARCHAR(50) NOT NULL COMMENT '报告类型: gut_flora/dan/physical_exam/tongue', " + "config JSON NOT NULL COMMENT '模板配置JSON: fixedBlocks+freeDisplay', " + "is_active TINYINT DEFAULT 1 COMMENT '0=停用 1=启用', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_report_type (report_type)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告展示模板'"); log.info("已创建report_template表"); } catch (Exception e) { log.warn("report_template表可能已存在: {}", e.getMessage()); } // 迁移255: report_unknown_upload 添加 annotation 列(管理员标注训练数据) ensureColumn("report_unknown_upload", "annotation", "TEXT COMMENT '管理员标注: 手动指定类型+确认提取结果JSON'"); // 迁移256: social_circle 添加圈子管理字段 ensureColumn("social_circle", "description", "VARCHAR(500) NULL COMMENT '圈子描述'"); ensureColumn("social_circle", "cover_image", "VARCHAR(500) NULL COMMENT '封面图URL'"); ensureColumn("social_circle", "join_condition", "TINYINT(1) DEFAULT 0 COMMENT '加入条件: 0=公开/1=审核/2=邀请'"); ensureColumn("social_circle", "review_required", "TINYINT(1) DEFAULT 0 COMMENT '是否需要审核: 0=否/1=是'"); ensureColumn("social_circle", "is_public", "TINYINT(1) DEFAULT 1 COMMENT '是否公开可见: 0=否/1=是'"); // 迁移257: social_circle_member 添加审核状态和角色字段 ensureColumn("social_circle_member", "status", "VARCHAR(20) DEFAULT 'approved' COMMENT '申请状态: pending/approved/rejected'"); ensureColumn("social_circle_member", "role", "VARCHAR(20) DEFAULT 'member' COMMENT '角色: member/admin'"); // 迁移258: 创建圈子动态帖子表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS social_circle_post (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "circle_id BIGINT NOT NULL COMMENT '圈子ID', " + "member_id BIGINT NOT NULL COMMENT '发布者成员ID', " + "member_type VARCHAR(20) NOT NULL COMMENT 'parent/child', " + "type VARCHAR(50) NOT NULL COMMENT '帖子类型: checkin/diet/exercise/sleep/water/mood/text', " + "content TEXT COMMENT '文字内容', " + "checkin_id BIGINT COMMENT '关联打卡记录ID', " + "image_urls TEXT COMMENT '图片URL列表JSON', " + "like_count INT DEFAULT 0 COMMENT '点赞数', " + "comment_count INT DEFAULT 0 COMMENT '评论数', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "deleted TINYINT(1) DEFAULT 0, " + "INDEX idx_circle_time (circle_id, created_at), " + "INDEX idx_member_time (member_id, created_at)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈子动态帖子'"); log.info("已创建social_circle_post表"); } catch (Exception e) { log.warn("social_circle_post表可能已存在"); } // 迁移259: 创建圈子点赞表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS social_circle_like (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "circle_id BIGINT NOT NULL, " + "post_id BIGINT NOT NULL COMMENT '被点赞的帖子ID', " + "member_id BIGINT NOT NULL COMMENT '点赞成员ID', " + "member_type VARCHAR(20) NOT NULL, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_like (circle_id, post_id, member_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈子点赞记录'"); log.info("已创建social_circle_like表"); } catch (Exception e) { log.warn("social_circle_like表可能已存在"); } // 迁移260: 创建圈子评论表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS social_circle_comment (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "circle_id BIGINT NOT NULL, " + "post_id BIGINT NOT NULL COMMENT '所属帖子ID', " + "parent_id BIGINT DEFAULT NULL COMMENT '回复的评论ID(NULL=一级评论)', " + "member_id BIGINT NOT NULL COMMENT '评论者成员ID', " + "member_type VARCHAR(20) NOT NULL, " + "content TEXT NOT NULL COMMENT '评论内容', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_post_time (post_id, created_at)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈子评论记录'"); log.info("已创建social_circle_comment表"); } catch (Exception e) { log.warn("social_circle_comment表可能已存在"); } // 迁移261: butler_profiles 添加停接新单开关(管家自助选择功能) ensureColumn("butler_profiles", "accepting", "TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否接受新家庭绑定: 0=停接/1=接单'"); // 迁移262: family_join_requests 添加 inviter_user_id 列(审批人=邀请人,谁分享的卡谁确认) try { jdbcTemplate.execute("ALTER TABLE family_join_requests ADD COLUMN inviter_user_id BIGINT NULL COMMENT '邀请人用户ID(分享邀请令牌的人)' AFTER invite_ref"); jdbcTemplate.execute("ALTER TABLE family_join_requests ADD INDEX idx_inviter (inviter_user_id)"); log.info("已添加inviter_user_id列到family_join_requests表"); } catch (Exception e) { // 列/索引已存在,忽略错误 log.warn("family_join_requests.inviter_user_id可能已存在: {}", e.getMessage()); } // 迁移263: users 表添加 portrait_prompt 列(用户画像 prompt 拼装需求) try { jdbcTemplate.execute("ALTER TABLE users ADD COLUMN portrait_prompt TEXT COMMENT '用户自定义画像 prompt' AFTER mascot"); log.info("已添加 portrait_prompt 列到 users 表"); } catch (Exception e) { log.warn("users.portrait_prompt可能已存在: {}", e.getMessage()); } // 迁移264: 方案分解任务补全 family_member_id,修正 deadline 落在次日 00:00 的边界问题 try { jdbcTemplate.update( "UPDATE tasks SET family_member_id = child_id " + "WHERE source_type = 'plan' AND family_member_id IS NULL AND child_id IS NOT NULL"); jdbcTemplate.update( "UPDATE tasks SET deadline = TIMESTAMP(DATE(deadline)) - INTERVAL 1 SECOND " + "WHERE source_type = 'plan' AND TIME(deadline) = '00:00:00'"); log.info("已修复方案分解任务的 family_member_id 与 deadline 边界"); } catch (Exception e) { log.warn("修复方案分解任务字段失败: {}", e.getMessage()); } // 迁移265: 方案分解任务开启审核,打卡后需规划师/管理员审核通过才发放积分 try { jdbcTemplate.update( "UPDATE tasks SET need_review = 1 " + "WHERE source_type = 'plan' AND (need_review IS NULL OR need_review = 0) AND status IN ('pending', 'pending_review')"); log.info("已开启方案分解任务的审核模式"); } catch (Exception e) { log.warn("开启方案分解任务审核失败: {}", e.getMessage()); } // 迁移266: tasks.status 扩展 pending_review/locked(打卡提交待审核、前置任务锁定) try { jdbcTemplate.execute( "ALTER TABLE tasks MODIFY COLUMN `status` " + "ENUM('pending','assigned','in_progress','completed','rejected','overdue','cancelled','locked','pending_review') " + "DEFAULT 'pending' COMMENT '状态: pending=待完成 assigned=待接受 in_progress=进行中 completed=已完成 rejected=已拒绝 overdue=已过期 cancelled=已取消 locked=前置锁定 pending_review=待审核'"); log.info("已扩展 tasks.status 枚举,支持 pending_review/locked"); } catch (Exception e) { log.warn("扩展 tasks.status 枚举失败(可能已修改): {}", e.getMessage()); } // 迁移268: coupon 表添加 status 列(优惠券停用/启用) migrateCouponStatus(); // 迁移276: five_dimension_self_checks 表添加 question_ids_json 列(自检轮换用) ensureColumn("five_dimension_self_checks", "question_ids_json", "VARCHAR(255) COMMENT '本次自检使用的题号JSON: [1,5,9,...]'"); // 迁移277: 创建 self_check_ignores 表(记录用户忽略自检提醒的动作,作为 15 天周期重置依据) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS self_check_ignores (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "check_id BIGINT COMMENT '忽略的自检记录ID(可空)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '忽略时间', " + "INDEX idx_user_id (user_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五维自检忽略提醒记录'"); log.info("已创建 self_check_ignores 表"); } catch (Exception e) { log.warn("创建 self_check_ignores 表失败: {}", e.getMessage()); } // 迁移275: users 表添加 family_inviter_id(家庭邀请追踪) ensureColumn("users", "family_inviter_id", "BIGINT DEFAULT NULL COMMENT '家庭邀请人ID(首次注册时绑定,用于追踪邀请来源)'"); } /** * 迁移268: coupon 表添加 status 列(优惠券停用/启用,幂等) * 存量数据 status 为 NULL,查询层按 ACTIVE 处理,不影响现有券。 */ private void migrateCouponStatus() { try { jdbcTemplate.execute("ALTER TABLE coupon ADD COLUMN status VARCHAR(16) DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE=启用/DISABLED=停用'"); log.info("迁移268: 已为 coupon 表添加 status 列"); } catch (Exception e) { // 列已存在,忽略错误 } } /** * 角色-菜单授权(幂等,唯一键 uk_role_menu 冲突忽略) */ private void grantMenuToRole(String role, Long menuId) { if (role == null || menuId == null) { return; } try { jdbcTemplate.update("INSERT IGNORE INTO sys_role_menu (role, menu_id) VALUES (?, ?)", role, menuId); } catch (Exception ignored) { } } /** * 查询单个菜单 id,不存在返回 null */ private Long queryMenuId(String sql) { try { List ids = jdbcTemplate.queryForList(sql, Long.class); return ids.isEmpty() ? null : ids.get(0); } catch (Exception e) { return null; } } /** * 迁移244辅助方法: 插入 sys_menu 种子数据 * 结构与 cfc-web/src/views/Layout.vue 的 menuItems 硬编码一致 * 数组格式: {parentKey(空=顶级), key, title(目录), label(叶子), path, icon, perm, sort} */ private void seedSysMenus() { Map keyToId = new HashMap<>(); String[][] menus = { // 顶级叶子 {"", "home", "", "首页", "/dashboard", "el-icon-s-home", "dashboard", "0"}, // 家庭运营(family:*) {"", "operation", "家庭运营", "", "", "el-icon-s-custom", "family:*", "1"}, {"operation", "families", "", "家庭列表", "/families", "el-icon-s-custom", "family:list,family:read,family:bound", "0"}, {"operation", "children", "", "孩子管理", "/children", "el-icon-user-solid", "family:children,family:read,family:bound", "1"}, {"operation", "points", "", "积分管理", "/points", "el-icon-s-finance", "family:points", "2"}, {"operation", "pointsLog", "", "积分记录", "/points-log", "el-icon-document", "family:points,family:bound", "3"}, {"operation", "wishes", "", "心愿管理", "/wishes", "el-icon-star-off", "family:wishes", "4"}, {"operation", "rewards", "", "奖励管理", "/rewards", "el-icon-s-goods", "reward:list", "5"}, // 方案中心(plan:*,插在家庭运营与任务中心之间) {"", "planCenter", "方案中心", "", "", "el-icon-notebook-2", "plan:*", "2"}, {"planCenter", "healthPlanManage", "", "健康方案管理", "/health-plan-manage", "el-icon-first-aid-kit", "plan:health", "0"}, {"planCenter", "assessmentPlanManage", "", "测评方案管理", "/assessment-plan-manage", "el-icon-edit", "plan:assessment", "1"}, // 任务中心(task:*) {"", "taskCenter", "任务中心", "", "", "el-icon-s-order", "task:*", "3"}, {"taskCenter", "tasks", "", "任务列表", "/tasks", "el-icon-s-order", "task:list", "0"}, {"taskCenter", "taskTemplates", "", "任务模板", "/task-templates", "el-icon-document", "task:templates", "1"}, {"taskCenter", "growthTask", "", "成长任务管理", "/growth-task", "el-icon-s-management", "task:growth", "2"}, // 审核中心(audit,归集各审核页:服务角色申请/活动审核/退款/提现) {"", "reviewCenterGroup", "审核中心", "", "", "el-icon-s-check", "audit", "4"}, {"reviewCenterGroup", "reviewCenter", "", "审核中心总览", "/review-center", "el-icon-s-check", "audit", "0"}, {"reviewCenterGroup", "activityReview", "", "活动审核", "/activity-review", "el-icon-document-checked", "audit:activity", "2"}, {"reviewCenterGroup", "pendingRefund", "", "待退款管理", "/pending-refund", "el-icon-warning", "audit:refund", "3"}, {"reviewCenterGroup", "earningWithdraw", "", "收益提现审核", "/family-earnings-withdraw", "el-icon-document-checked", "audit:withdraw", "4"}, // 商城营销(commerce:*) {"", "commerce", "商城营销", "", "", "el-icon-s-goods", "commerce:*", "5"}, {"commerce", "productManage", "", "商品管理", "/product-manage", "el-icon-s-goods", "commerce:products,commerce:self", "0"}, {"commerce", "inventory", "", "库存管理", "/inventory", "el-icon-document", "inventory:self", "1"}, {"commerce", "orderManage", "", "订单管理", "/order-manage", "el-icon-s-order", "commerce:orders,order:self", "2"}, {"commerce", "ecomSupplier", "", "供应商管理", "/supply-system", "el-icon-s-shop", "system:supply", "3"}, {"commerce", "productProfitRate", "", "产品利润率", "/product-profit-rate", "el-icon-data-line", "finance:profit-rate", "4"}, {"commerce", "coupon", "", "优惠券管理", "/coupon", "el-icon-ticket", "commerce:coupon", "5"}, {"commerce", "couponGrantLog", "", "发券记录", "/coupon-grant-log", "el-icon-document", "commerce:coupon", "6"}, {"commerce", "discountRules", "", "限时折扣管理", "/discount-rules", "el-icon-timer", "commerce:discount", "7"}, {"commerce", "promotion", "", "推广管理", "/promotion", "el-icon-s-marketing", "commerce:promotion", "7"}, {"commerce", "familyEarnings", "", "家庭收益", "/family-earnings", "el-icon-s-money", "finance:family-earnings", "8"}, // 知识中心(articles:*) {"", "knowledge", "知识中心", "", "", "el-icon-document", "articles:*", "6"}, {"knowledge", "articleCategories", "", "知识分类", "/article-categories", "el-icon-folder", "articles:categories", "0"}, {"knowledge", "articleManage", "", "知识管理", "/article-manage", "el-icon-document", "articles:manage", "1"}, {"knowledge", "commentReview", "", "评论审核", "/comment-review", "el-icon-chat-dot-round", "articles:manage", "2"}, {"knowledge", "knowledgeTags", "", "知识标签", "/knowledge-tags", "el-icon-price-tag", "articles:categories", "3"}, {"knowledge", "knowledgeBase", "", "知识库", "/knowledge-base", "el-icon-reading", "knowledge:base", "4"}, {"knowledge", "healthKnowledge", "", "健康知识库", "/health-knowledge", "el-icon-first-aid-kit", "knowledge:base", "5"}, // 活动管理(activity:*,活动审核已移入审核中心) {"", "activity", "活动管理", "", "", "el-icon-date", "activity:*", "7"}, {"activity", "activities", "", "活动列表", "/activities", "el-icon-date", "activity:list,activity:self", "0"}, {"activity", "activityRegReview", "", "活动报名审核", "/activity-registration-review", "el-icon-document-checked", "activity:registration", "1"}, // 健康饮食(health:*,能量规则/行为/周期性服务已移入系统配置·健康配置,LangGraph已移入报告解析系统;报告管理/报告审核/应季食材按方案挂载) {"", "health", "健康饮食", "", "", "el-icon-first-aid-kit", "health:*", "8"}, {"health", "reportManagement", "", "报告管理", "/report-management", "el-icon-document", "health:reports", "0"}, {"health", "proxyReportUpload", "", "代传报告", "/report-proxy-upload", "el-icon-upload", "health:reports", "1"}, {"health", "healthIndicators", "", "健康指标", "/health-indicators", "el-icon-data-line", "health:indicators", "2"}, {"health", "healthCheckins", "", "健康打卡", "/health-checkins", "el-icon-s-order", "health:checkins", "3"}, {"health", "emotionAlert", "", "情绪告警", "/emotion-alert", "el-icon-warning", "health:checkins", "4"}, {"health", "nutritionMappings", "", "营养素映射", "/nutrition-mappings", "el-icon-connection", "health:mappings", "5"}, {"health", "foods", "", "食材管理", "/foods", "el-icon-apple", "diet:foods", "6"}, {"health", "recipes", "", "食谱管理", "/recipes", "el-icon-dish", "diet:recipes", "7"}, {"health", "seasonalFoods", "", "应季食材", "/seasonal-foods", "el-icon-apple", "diet:foods", "8"}, {"health", "nutritionProducts", "", "营养产品", "/nutrition-products", "el-icon-first-aid-kit", "health:products", "9"}, {"health", "energySandbox", "", "五维能量", "/energy-sandbox", "el-icon-data-line", "health:energy", "10"}, // 报告解析系统(config:report-parser,LangGraph 管理归入) {"", "reportParser", "报告解析系统", "", "", "el-icon-cpu", "config:report-parser", "9"}, {"reportParser", "reportTypes", "", "报告类型管理", "/report-types", "el-icon-collection", "config:report-parser", "0"}, {"reportParser", "reportParserImport", "", "解析器导入", "/report-parser-import", "el-icon-upload2", "config:report-parser", "1"}, {"reportParser", "reportUnknownClusters", "", "未知报告审核", "/report-unknown-clusters", "el-icon-question", "config:report-parser", "2"}, {"reportParser", "reportAutoLearn", "", "报告自学习", "/report-auto-learn", "el-icon-magic-stick", "config:report-parser", "3"}, {"reportParser", "langgraphAdmin", "", "LangGraph 管理", "/langgraph-admin", "el-icon-cpu", "config:report-parser", "4"}, // 家庭服务(service:*) {"", "familyService", "家庭服务", "", "", "el-icon-s-custom", "service:*", "10"}, {"familyService", "myFamilies", "", "我的家庭", "/my-families", "el-icon-s-custom", "service:family", "0"}, {"familyService", "teacherTeam", "", "我的团队", "/teacher-team", "el-icon-s-custom", "service:team", "1"}, // 业务管理(biz:*) {"", "biz", "业务管理", "", "", "el-icon-s-marketing", "biz:*", "11"}, {"biz", "teacherPackages", "", "任务模板管理", "/teacher-packages", "el-icon-s-goods", "biz:packages", "0"}, {"biz", "teacherOrders", "", "订单佣金", "/teacher-orders", "el-icon-s-order", "biz:orders", "1"}, // 测评咨询(测评管理/测评订单/待分配规划师已从系统配置移入) {"", "assessment", "测评咨询", "", "", "el-icon-edit", "assessment:*", "12"}, {"assessment", "assessmentAdmin", "", "测评管理", "/assessment-admin", "el-icon-edit", "assessment:manage", "0"}, {"assessment", "assessmentOrders", "", "测评订单", "/assessment-orders", "el-icon-s-order", "assessment:orders", "1"}, {"assessment", "assessmentAssign", "", "待分配规划师", "/assessment-assign", "el-icon-user", "assessment:assign", "2"}, {"assessment", "teacherAssessment", "", "DAN测评", "/teacher-assessment", "el-icon-edit", "assessment:dan", "3"}, {"assessment", "teacherConsult", "", "家长咨询", "/teacher-consult", "el-icon-chat-dot-round", "assessment:consult", "4"}, {"assessment", "growthRecords", "", "成长记录", "/growth-records", "el-icon-document", "growth:records", "5"}, {"assessment", "growthPlans", "", "成长计划", "/growth-plans", "el-icon-document", "growth:plans", "6"}, // 系统配置(system:*,权限拆分 system:base / config:member / config:dimension / config:health) {"", "system", "系统配置", "", "", "el-icon-s-tools", "system:*", "13"}, {"system", "baseManage", "基础管理", "", "", "el-icon-s-tools", "system:base", "0"}, {"baseManage", "sysConfig", "", "系统配置", "/sys-config", "el-icon-s-tools", "system:base", "0"}, {"baseManage", "users", "", "用户管理", "/users", "el-icon-user", "system:users", "1"}, {"baseManage", "operationLogs", "", "操作日志", "/operation-logs", "el-icon-s-order", "system:logs", "2"}, {"baseManage", "membershipCenter", "", "会员中心", "/membership-center", "el-icon-s-custom", "config:member", "3"}, {"baseManage", "badgeManage", "", "勋章管理", "/badge-manage", "el-icon-medal", "config:member", "4"}, {"baseManage", "virtualGoodsConfig", "", "虚拟支付道具", "/virtual-goods-config", "el-icon-goods", "config:member", "5"}, {"baseManage", "gates", "", "关卡配置", "/gates", "el-icon-key", "system:base", "6"}, {"baseManage", "menuManage", "", "菜单管理", "/menu-manage", "el-icon-menu", "system:menu", "7"}, {"system", "configCenter", "配置中心", "", "", "el-icon-setting", "config:dimension", "1"}, {"configCenter", "dimensionConfig", "", "维度配置", "/dimension-config", "el-icon-data-line", "config:dimension", "0"}, {"system", "supplyHierarchy", "供应商体系", "", "", "el-icon-s-management", "system:supply", "2"}, {"supplyHierarchy", "supplySystem", "", "供应商体系", "/supply-system", "el-icon-s-management", "system:supply", "0"}, {"system", "supplierManage", "供应商管理", "", "", "el-icon-s-shop", "supply:manage", "3"}, {"supplierManage", "supplyManage", "", "供应商管理", "/supply-manage", "el-icon-s-shop", "supply:manage", "0"}, {"supplierManage", "supplierProducts", "", "商品管理", "/supplier-products", "el-icon-s-goods", "supply:manage", "1"}, {"system", "healthConfig", "健康配置", "", "", "el-icon-first-aid-kit", "config:health", "4"}, {"healthConfig", "healthNormConfig", "", "健康常模", "/health-norm-config", "el-icon-data-line", "config:health", "0"}, {"healthConfig", "healthDataSource", "", "健康数据源", "/health-data-source", "el-icon-data-line", "config:health", "1"}, {"healthConfig", "healthEnergyConfig", "", "五维能量配置", "/health-energy-config", "el-icon-data-line", "config:health", "2"}, {"healthConfig", "energyRule", "", "能量规则管理", "/energy-rule", "el-icon-setting", "config:health", "3"}, {"healthConfig", "energyBehaviorConfig", "", "能量行为配置", "/energy-behavior-config", "el-icon-edit-outline", "config:health", "4"}, {"healthConfig", "periodicServiceConfig", "", "周期性服务配置", "/periodic-service-config", "el-icon-refresh", "config:health", "5"}, {"system", "lifeDimension", "人生维度", "", "", "el-icon-s-custom", "config:dimension", "5"}, {"lifeDimension", "zodiacConfigs", "", "星座配置", "/zodiac-configs", "el-icon-s-management", "config:dimension", "0"}, {"lifeDimension", "baziConfigs", "", "八字配置", "/bazi-configs", "el-icon-s-management", "config:dimension", "1"}, {"lifeDimension", "bloodTypeConfigs", "", "血型配置", "/blood-type-configs", "el-icon-s-management", "config:dimension", "2"}, {"system", "virtualTeam", "虚拟团队", "", "", "el-icon-s-custom", "system:base", "6"}, {"virtualTeam", "virtualTeams", "", "虚拟团队", "/virtual-teams", "el-icon-s-custom", "system:base", "0"}, }; for (String[] m : menus) { Long parentId = 0L; if (m[0] != null && !m[0].isEmpty()) { parentId = keyToId.get(m[0]); if (parentId == null) { parentId = 0L; } } String title = m[2] != null && !m[2].isEmpty() ? m[2] : null; String label = m[3] != null && !m[3].isEmpty() ? m[3] : null; String path = m[4] != null && !m[4].isEmpty() ? m[4] : null; String icon = m[5] != null && !m[5].isEmpty() ? m[5] : null; String perm = m[6] != null && !m[6].isEmpty() ? m[6] : null; int sort = Integer.parseInt(m[7]); jdbcTemplate.update( "INSERT INTO sys_menu (parent_id, title, label, path, icon, perm, sort, visible) VALUES (?, ?, ?, ?, ?, ?, ?, 1)", parentId, title, label, path, icon, perm, sort ); Long id = jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class); keyToId.put(m[1], id); } // ===== 迁移:初始化方案规则数据 (P1) ===== try { // 使用 jdbcTemplate 的 DataSource var ds = jdbcTemplate.getDataSource(); if (ds instanceof javax.sql.DataSource) { try (Connection conn = ((javax.sql.DataSource) ds).getConnection()) { PreparedStatement ps = null; try { // 插入注意力提升方案规则 1:0-60 分 → 模板 ID=38 ps = conn.prepareStatement("INSERT INTO assessment_plan_rules (id, dimension, score_min, score_max, template_id, priority, is_active, created_at, updated_at) VALUES (NULL, ?, ?, ?, ?, ?, ?, NOW(), NOW()) ON DUPLICATE KEY UPDATE updated_at=NOW()"); ps.setString(1, "attention"); ps.setInt(2, 0); ps.setInt(3, 60); ps.setLong(4, 38L); // 使用现有的模板 ID ps.setInt(5, 1); ps.setBoolean(6, true); ps.executeUpdate(); log.info("已插入注意力方案规则 1(0-60 分)"); ps.close(); // 插入注意力提升方案规则 2:61-100 分 → 模板 ID=39 ps = conn.prepareStatement("INSERT INTO assessment_plan_rules (id, dimension, score_min, score_max, template_id, priority, is_active, created_at, updated_at) VALUES (NULL, ?, ?, ?, ?, ?, ?, NOW(), NOW()) ON DUPLICATE KEY UPDATE updated_at=NOW()"); ps.setString(1, "attention"); ps.setInt(2, 61); ps.setInt(3, 100); ps.setLong(4, 39L); ps.setInt(5, 2); ps.setBoolean(6, true); ps.executeUpdate(); log.info("已插入注意力方案规则 2(61-100 分)"); ps.close(); } finally { if (ps != null) ps.close(); } } } } catch (SQLException e) { log.warn("初始化方案规则失败:{}", e.getMessage()); } // ===== 迁移:初始化报告类型注册 (R1) ===== try { // 使用 jdbcTemplate 的 DataSource var ds = jdbcTemplate.getDataSource(); if (ds instanceof javax.sql.DataSource) { try (Connection conn = ((javax.sql.DataSource) ds).getConnection()) { PreparedStatement ps = null; try { // 注册肠道菌群检测报告类型 ps = conn.prepareStatement("INSERT INTO report_type_registry (id, type_id, display_name, parser_path, min_confidence, is_active, source, review_status, version, created_at, updated_at) VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) ON DUPLICATE KEY UPDATE updated_at=NOW()"); ps.setString(1, "gut_flora"); ps.setString(2, "肠道菌群检测"); ps.setString(3, "dan_reports/gut_flora.py"); ps.setInt(4, 10); ps.setBoolean(5, true); ps.setString(6, "prebuilt"); ps.setString(7, "approved"); ps.setString(8, "1.0"); ps.executeUpdate(); log.info("已注册报告类型:gut_flora"); ps.close(); } finally { if (ps != null) ps.close(); } } } } catch (SQLException e) { log.warn("注册报告类型 gut_flora 失败:{}", e.getMessage()); } // 迁移278-313: 已移至独立方法 runSystemMigrations()(原误置于本方法仅空表触发) } // 迁移278-313: 系统级 DDL/数据迁移(原误置于 seedSysMenus 内,空表才执行,生产永远跳过) // 抽出为独立方法,由 runMigrations() 无条件调用,确保生产库每次启动补齐。 private void runSystemMigrations() { // 迁移278: 创建 family_coupon 表 + family_coupon_grant_log 表 // (优惠券全链路改为家庭维度:2026-08-31) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_coupon (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '所属家庭ID', " + "coupon_id BIGINT NOT NULL COMMENT '券模板ID', " + "status VARCHAR(16) DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE/USED', " + "received_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "used_at DATETIME, " + "order_id BIGINT COMMENT '核销时写入的订单号', " + "INDEX idx_family_coupon (family_id, coupon_id), " + "INDEX idx_status (status), " + "INDEX idx_order_id (order_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭优惠券表'"); log.info("已创建 family_coupon 表"); } catch (Exception e) { log.warn("family_coupon 表已存在,跳过创建: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_coupon_grant_log (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '受赠家庭ID', " + "coupon_id BIGINT NOT NULL COMMENT '券模板ID', " + "grant_type VARCHAR(16) NOT NULL COMMENT 'JOIN/PERIODIC/POPULATION/EXCHANGE/CF_EXCHANGE', " + "period VARCHAR(16) COMMENT '周期标识(YYYY-MM或YYYY-Qn),PERIODIC防重用', " + "quantity INT DEFAULT 1 COMMENT '发放数量', " + "source VARCHAR(64) COMMENT '触发来源(订单号/成员ID等)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_family_grant (family_id, coupon_id, grant_type, period), " + "INDEX idx_coupon (coupon_id), " + "INDEX idx_family (family_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭优惠券发放流水表'"); log.info("已创建 family_coupon_grant_log 表"); } catch (Exception e) { log.warn("family_coupon_grant_log 表已存在,跳过创建: {}", e.getMessage()); } // 迁移279: 创建 cf_rate_tier 表(CF值返佣阶梯配置,替代 promotion_tier_config) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS cf_rate_tier (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "tier_name VARCHAR(50) NOT NULL COMMENT '档位名称', " + "min_team_size INT NOT NULL DEFAULT 0 COMMENT '团队规模下限(含)', " + "rate_percent INT NOT NULL DEFAULT 0 COMMENT '返佣比例(%)', " + "sort_order INT NOT NULL DEFAULT 0 COMMENT '排序,越大越高', " + "enabled TINYINT DEFAULT 1 COMMENT '1启用/0停用', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_min_size (min_team_size)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值返佣阶梯配置'"); log.info("已创建cf_rate_tier表"); Integer cnt = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM cf_rate_tier", Integer.class); if (cnt == null || cnt == 0) { jdbcTemplate.execute("INSERT INTO cf_rate_tier (tier_name, min_team_size, rate_percent, sort_order) VALUES " + "('铜牌', 0, 5, 1), ('银牌', 3, 10, 2), ('金牌', 10, 15, 3), ('铂金', 30, 20, 4), ('钻石', 100, 25, 5)"); log.info("cf_rate_tier 种子数据已初始化"); } } catch (Exception e) { log.warn("创建cf_rate_tier表失败: {}", e.getMessage()); } // 迁移280: 创建 cf_transfer_record 表(CF值流转记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS cf_transfer_record (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "from_user_id BIGINT COMMENT '转出用户ID(null=平台)', " + "to_user_id BIGINT COMMENT '转入用户ID', " + "family_id BIGINT COMMENT '所属家庭ID', " + "amount INT NOT NULL COMMENT 'CF值数量', " + "type VARCHAR(16) NOT NULL COMMENT 'transfer/allocate/refund', " + "ref_type VARCHAR(50) COMMENT '关联业务类型', " + "ref_id BIGINT COMMENT '关联业务ID', " + "remark VARCHAR(255), " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_from (from_user_id), " + "INDEX idx_to (to_user_id), " + "INDEX idx_family (family_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值流转记录'"); log.info("已创建cf_transfer_record表"); } catch (Exception e) { log.warn("创建cf_transfer_record表失败: {}", e.getMessage()); } // 迁移281: promotion_tier 精简列(删三层,加 rate_percent) try { jdbcTemplate.execute("ALTER TABLE promotion_tier DROP COLUMN team_size_1st, DROP COLUMN team_size_2nd, DROP COLUMN team_size_3rd"); log.info("promotion_tier 已删除三层团队列"); } catch (Exception e) { log.warn("promotion_tier 三层列删除失败(可能不存在): {}", e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE promotion_tier ADD COLUMN rate_percent INT DEFAULT 0 COMMENT '当前返佣比例(%)'"); log.info("promotion_tier 已添加 rate_percent"); } catch (Exception e) { log.warn("promotion_tier.rate_percent 已存在: {}", e.getMessage()); } // 迁移282: coupon 表添加 family_id try { jdbcTemplate.execute("ALTER TABLE coupon ADD COLUMN family_id BIGINT DEFAULT NULL COMMENT '绑定家庭ID(NULL=不绑定)'"); log.info("coupon 已添加 family_id"); } catch (Exception e) { log.warn("coupon.family_id 已存在: {}", e.getMessage()); } // 迁移283: referral_tree 唯一键改为 (parent_id, child_id) 联合唯一(支持每祖先一行物化) try { jdbcTemplate.execute("ALTER TABLE referral_tree DROP INDEX uk_child_id"); log.info("referral_tree 已删除 uk_child_id 唯一键"); } catch (Exception e) { log.warn("referral_tree.uk_child_id 删除失败(可能不存在): {}", e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE referral_tree ADD UNIQUE KEY uk_parent_child (parent_id, child_id)"); log.info("referral_tree 已添加 uk_parent_child 联合唯一键"); } catch (Exception e) { log.warn("referral_tree.uk_parent_child 已存在: {}", e.getMessage()); } // 迁移284: cf_transfer_record 加 (ref_type, ref_id) 唯一索引(幂等兜底,防止并发 earn 重复发放) try { jdbcTemplate.execute("ALTER TABLE cf_transfer_record ADD UNIQUE KEY uk_ref_type_id (ref_type, ref_id)"); log.info("cf_transfer_record 已添加 uk_ref_type_id 唯一索引"); } catch (Exception e) { log.warn("cf_transfer_record.uk_ref_type_id 已存在: {}", e.getMessage()); } // 迁移285: 创建 product_gift_configs 表(商品赠品配置,商品×等级) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_gift_configs (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "product_id BIGINT NOT NULL COMMENT '关联products表', " + "level_code VARCHAR(50) NOT NULL COMMENT '会员等级编码(FAMILY/PREMIUM等)', " + "status TINYINT(1) DEFAULT 1 COMMENT '1=启用 0=停用', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_product_level (product_id, level_code), " + "INDEX idx_product_id (product_id), " + "INDEX idx_level_code (level_code)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品赠品配置(商品×等级)'"); log.info("已创建product_gift_configs表"); } catch (Exception e) { // 表已存在,忽略 } // 迁移286: 创建 product_gift_items 表(商品赠品项) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_gift_items (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "config_id BIGINT NOT NULL COMMENT '关联product_gift_configs.id', " + "gift_type VARCHAR(20) NOT NULL COMMENT 'product=赠品商品 cf=CF值', " + "gift_product_id BIGINT COMMENT '赠品商品ID(gift_type=product时必填)', " + "gift_product_name VARCHAR(200) COMMENT '赠品商品名快照', " + "gift_product_image VARCHAR(500) COMMENT '赠品商品图快照', " + "cf_value INT DEFAULT 0 COMMENT 'CF值数量(gift_type=cf时必填,单位分)', " + "sort_order INT DEFAULT 0 COMMENT '排序(值越小越靠前)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_config_id (config_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品赠品项'"); log.info("已创建product_gift_items表"); } catch (Exception e) { // 表已存在,忽略 } // 迁移287: 创建 product_order_gifts 表(订单赠品记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_order_gifts (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "order_id BIGINT NOT NULL COMMENT '关联product_orders.id', " + "order_no VARCHAR(64) NOT NULL COMMENT '订单号快照', " + "gift_type VARCHAR(20) NOT NULL COMMENT 'product=赠品商品 cf=CF值', " + "gift_product_id BIGINT COMMENT '赠品商品ID', " + "gift_product_name VARCHAR(200) COMMENT '赠品商品名快照', " + "gift_product_image VARCHAR(500) COMMENT '赠品商品图快照', " + "cf_value INT DEFAULT 0 COMMENT 'CF值数量(gift_type=cf时)', " + "quantity INT DEFAULT 1 COMMENT '数量(默认1)', " + "fulfilled TINYINT(1) DEFAULT 0 COMMENT '0=已选未发放 1=已发放', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_order_id (order_id), " + "INDEX idx_order_no (order_no)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单赠品记录'"); log.info("已创建product_order_gifts表"); } catch (Exception e) { // 表已存在,忽略 } // 迁移288: health_report_drafts 加异步采集状态列(报告采集流程异步化) // 用途:上传后进入后台异步采集(指纹判定→专用脚本/opencode),前端轮询 parse_status ensureColumn("health_report_drafts", "parse_status", "VARCHAR(20) DEFAULT NULL COMMENT '异步解析状态: collecting/completed/failed(NULL=未触发异步采集)'"); ensureColumn("health_report_drafts", "parse_method", "VARCHAR(32) DEFAULT NULL COMMENT '解析方式: fingerprint_script/opencode/langgraph'"); ensureColumn("health_report_drafts", "parse_error", "VARCHAR(2000) DEFAULT NULL COMMENT '异步解析失败原因'"); log.info("已添加 health_report_drafts 异步采集状态列 (parse_status/parse_method/parse_error)"); // 迁移289: health_reports 补 report_format 列(HealthReport 实体已含字段,但从未迁移至 DB,全字段 SELECT 报 Unknown column) ensureColumn("health_reports", "report_format", "VARCHAR(32) DEFAULT NULL COMMENT '报告格式: pdf/image'"); // 迁移290: 补齐实体已含但 DB 从未迁移的真实缺列(全字段 SELECT 防 Unknown column) // 逐表核实(对照 schema.sql + 历史 ADD COLUMN/ensureColumn),仅含确认不存在的列,共 6 表 21 列 // 风险预留:promotion_tier_config 实体与 DB 为两套不同晋级模型(已废弃,由 cf_rate_tier 迁移279 替代), // 按用户决定保持现状不迁移,避免与旧种子数据冲突;后续清理旧实体依赖(CommissionService等)时再处理 ensureColumn("activities", "visibility", "VARCHAR(20) DEFAULT 'public' COMMENT '浏览权限:public/login/private'"); ensureColumn("activities", "visible_to", "TEXT DEFAULT NULL COMMENT '私密指定人群JSON'"); ensureColumn("dan_knowledge_base", "file_url", "VARCHAR(500) DEFAULT NULL COMMENT 'fileUrl'"); ensureColumn("dan_knowledge_base", "file_name", "VARCHAR(255) DEFAULT NULL COMMENT 'fileName'"); ensureColumn("dan_knowledge_base", "source_url", "VARCHAR(500) DEFAULT NULL COMMENT 'sourceUrl'"); ensureColumn("dan_knowledge_base", "source_type", "VARCHAR(20) DEFAULT NULL COMMENT 'sourceType'"); ensureColumn("distribution_relations", "invite_code", "VARCHAR(64) DEFAULT NULL COMMENT 'inviteCode'"); ensureColumn("family_members", "birth_hour", "VARCHAR(10) DEFAULT NULL COMMENT '出生时辰'"); ensureColumn("family_members", "birth_weight", "INT DEFAULT NULL COMMENT '出生体重(g)'"); ensureColumn("family_members", "birth_place", "VARCHAR(255) DEFAULT NULL COMMENT '出生地'"); ensureColumn("family_members", "is_cesarean", "TINYINT(1) DEFAULT 0 COMMENT '是否剖腹产: 0否 1是'"); ensureColumn("health_indicators", "symptoms", "TEXT DEFAULT NULL COMMENT '症状描述'"); ensureColumn("product_orders", "tracking_number", "VARCHAR(100) DEFAULT NULL COMMENT '快递单号'"); ensureColumn("product_orders", "express_company", "VARCHAR(100) DEFAULT NULL COMMENT '快递公司'"); ensureColumn("product_orders", "delivery_method", "INT DEFAULT NULL COMMENT '配送方式: 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人'"); ensureColumn("product_orders", "pickup_code", "VARCHAR(64) DEFAULT NULL COMMENT '自提码'"); ensureColumn("product_orders", "confirm_time", "DATETIME DEFAULT NULL COMMENT '确认收货时间'"); ensureColumn("product_orders", "refund_status", "INT DEFAULT NULL COMMENT '退款状态'"); ensureColumn("product_orders", "refund_reason", "VARCHAR(255) DEFAULT NULL COMMENT '退款原因'"); ensureColumn("product_orders", "refund_amount", "INT DEFAULT NULL COMMENT '退款金额(分)'"); ensureColumn("product_orders", "refund_time", "DATETIME DEFAULT NULL COMMENT '退款时间'"); log.info("已补齐真实缺列 (迁移290, 6表21列)"); // 迁移291: food_nutrients 列名对齐(amount_per_100g -> amount_per100g) // FoodNutrient 实体字段 amountPer100g 经 MyBatis-Plus camel->snake 映射为 amount_per100g, // 但建表 SQL 误用 amount_per_100g,导致实体全字段 SELECT 报 Unknown column 'amount_per100g'。 // 当前 FoodNutrientMapper 无 Service 调用,属潜在隐患;此处统一列名为 amount_per100g。 try { Integer cnt = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = 'food_nutrients' AND COLUMN_NAME = 'amount_per_100g'", Integer.class); if (cnt != null && cnt > 0) { jdbcTemplate.execute("ALTER TABLE food_nutrients CHANGE COLUMN amount_per_100g amount_per100g DECIMAL(10,4) NOT NULL COMMENT '每100g含量'"); log.info("已重命名 food_nutrients.amount_per_100g -> amount_per100g"); } } catch (Exception e) { // 表不存在或列不存在,忽略 } // 迁移292: health_status 补 member_id 并改为成员级归档(Unknown column 'member_id') // 迁移239 建表只有 user_id,schema.sql 与实体已含 member_id(成员级档案),生产缺列导致 selectOne 报错。 // 原 UNIQUE KEY uk_user(user_id) 限制每账号仅一行,与成员级多行归档冲突,改为复合索引 idx_user_member。 ensureColumn("health_status", "member_id", "BIGINT DEFAULT NULL COMMENT '家庭成员ID(NULL=账号级,非空=成员级归档)'"); try { jdbcTemplate.execute("ALTER TABLE health_status DROP INDEX uk_user"); log.info("已移除 health_status.uk_user 唯一键(允许成员级多行归档)"); } catch (Exception e) { // 索引不存在,忽略 } try { jdbcTemplate.execute("ALTER TABLE health_status ADD INDEX idx_user_member (user_id, member_id)"); log.info("已添加 health_status.idx_user_member 复合索引"); } catch (Exception e) { // 索引已存在,忽略 } // 迁移293: family_members 添加 is_admin 列(后台家庭成员仅区分管理员/成员,不再区分家长/孩子) ensureColumn("family_members", "is_admin", "TINYINT(1) DEFAULT 0 COMMENT '是否家庭管理员: 1=管理员, 0=普通成员'"); // 回填:将每个家庭的创建者(families.creator_id 匹配 family_members.user_id)设为管理员 try { jdbcTemplate.execute("UPDATE family_members fm JOIN families f ON fm.family_id = f.id " + "SET fm.is_admin = 1 WHERE fm.user_id = f.creator_id AND (fm.is_admin IS NULL OR fm.is_admin = 0)"); log.info("迁移293: 已回填家庭创建者为管理员"); } catch (Exception e) { log.warn("迁移293: 回填家庭创建者为管理员失败: {}", e.getMessage()); } // 迁移294: articles 表加 sort_order 列(精选文章推荐排序,越小越靠前) ensureColumn("articles", "sort_order", "INT NOT NULL DEFAULT 0 COMMENT '推荐排序值(越小越靠前)'"); log.info("已添加 articles.sort_order 列(迁移294)"); // 迁移294b: articles 表添加 audio_url/audio_text 列(文章AI朗读音频) ensureColumn("articles", "audio_url", "VARCHAR(500) DEFAULT NULL COMMENT 'AI朗读音频URL'"); ensureColumn("articles", "audio_text", "TEXT COMMENT '朗读文本(纯文本,缺省从content剥离)'"); log.info("已添加 articles.audio_url/audio_text 列(迁移294b)"); // 迁移295: task_plan_instances 补齐实体已含但 DB 缺失的审核/确认/激活列(review/activate 写字段报 Unknown column) ensureColumn("task_plan_instances", "source", "VARCHAR(20) DEFAULT NULL COMMENT '来源: manual/assessment/template'"); ensureColumn("task_plan_instances", "source_result_id", "BIGINT DEFAULT NULL COMMENT '关联测评结果ID(source=assessment时)'"); ensureColumn("task_plan_instances", "remark", "TEXT COMMENT '方案备注(含指导意见摘要)'"); ensureColumn("task_plan_instances", "reviewed_by", "BIGINT DEFAULT NULL COMMENT '审核人(规划师ID)'"); ensureColumn("task_plan_instances", "reviewed_at", "DATETIME DEFAULT NULL COMMENT '审核时间'"); ensureColumn("task_plan_instances", "review_comment", "TEXT COMMENT '审核意见'"); ensureColumn("task_plan_instances", "confirmed_by", "BIGINT DEFAULT NULL COMMENT '确认人(家长ID)'"); ensureColumn("task_plan_instances", "confirmed_at", "DATETIME DEFAULT NULL COMMENT '确认时间'"); ensureColumn("task_plan_instances", "activated_at", "DATETIME DEFAULT NULL COMMENT '激活时间'"); log.info("迁移295: 已补齐 task_plan_instances 审核/确认/激活列"); // 迁移296: 健康方案任务 source_type 'plan' -> 'health_plan'(消除与 task_plan_instances 的 ID 域冲突) try { // 冲突检测:source_id 同时存在于 health_plans 和 task_plan_instances 的任务(需人工确认,不阻断迁移) Integer conflict = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM tasks t WHERE t.source_type = 'plan' " + "AND EXISTS (SELECT 1 FROM health_plans h WHERE h.id = t.source_id) " + "AND EXISTS (SELECT 1 FROM task_plan_instances i WHERE i.id = t.source_id)", Integer.class); if (conflict != null && conflict > 0) { log.warn("迁移296: 检测到{}条冲突任务(source_id同时存在于health_plans和task_plan_instances),需人工确认;无冲突部分仍继续迁移", conflict); } // 始终执行 UPDATE:仅迁移"存在于 health_plans 且不存在于 task_plan_instances"的任务(冲突部分不迁移,避免误改套餐任务) int updated = jdbcTemplate.update( "UPDATE tasks SET source_type = 'health_plan' " + "WHERE source_type = 'plan' " + "AND EXISTS (SELECT 1 FROM health_plans h WHERE h.id = tasks.source_id) " + "AND NOT EXISTS (SELECT 1 FROM task_plan_instances i WHERE i.id = tasks.source_id)"); log.info("迁移296: 已修正{}条健康方案任务 source_type -> health_plan", updated); } catch (Exception e) { log.warn("迁移296执行失败(health_plans 表可能不存在): {}", e.getMessage()); } // 迁移297: 认知训练方案任务补设 source_type='training_plan'(training_plan_tasks 关联溯源) try { Integer updated = jdbcTemplate.update( "UPDATE tasks t SET t.source_type = 'training_plan' " + "WHERE (t.source_type IS NULL OR t.source_type = '') " + "AND EXISTS (SELECT 1 FROM training_plan_tasks pt WHERE pt.task_id = t.id)"); log.info("迁移297: 已补设{}条认知训练方案任务 source_type -> training_plan", updated); } catch (Exception e) { log.warn("迁移297执行失败(training_plan_tasks 表可能不存在): {}", e.getMessage()); } // 迁移298: 存量 role='child' 账号强制迁移为成员模型 // 1) 有 openid 的 child → role 改 parent(可登录成员,登录后进成员端界面,不能被切换) // 2) 无 openid 的 child → 保持不可登录,确保有 family_members 记录(仅可被切换) try { Integer updated = jdbcTemplate.update( "UPDATE users SET role='parent' WHERE role='child' AND openid IS NOT NULL AND openid <> ''"); log.info("迁移298: 已迁移{}条有 openid 的 child 账号 role → parent", updated); } catch (Exception e) { log.warn("迁移298第一步失败", e); } try { jdbcTemplate.execute( "INSERT INTO family_members (family_id, user_id, nickname, gender, birthday, generation, show_to_family, total_points, created_at, updated_at) " + "SELECT u.family_id, u.id, u.nickname, u.gender, u.birthday, -1, 1, 0, NOW(), NOW() " + "FROM users u " + "WHERE u.role='child' AND (u.openid IS NULL OR u.openid='') " + "AND u.family_id IS NOT NULL " + "AND NOT EXISTS (SELECT 1 FROM family_members fm WHERE fm.user_id = u.id)"); log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记录"); } catch (Exception e) { log.warn("迁移298第二步失败", e); } // 迁移322: 废弃 children 表 —— 数据迁移到 family_members 后删除表(幂等,表不存在时跳过) try { Integer childrenTableCnt = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'children'", Integer.class); if (childrenTableCnt == null || childrenTableCnt == 0) { log.info("迁移322: children 表不存在,跳过(已废弃)"); } else { // 1. 匹配更新:family_members 已存在该成员记录时补全字段 jdbcTemplate.execute( "UPDATE family_members fm INNER JOIN children c " + "ON fm.family_id = c.family_id AND fm.nickname = c.nickname AND fm.user_id = c.user_id " + "SET fm.id_card = c.id_card, fm.penalty_enabled = c.penalty_enabled, fm.theme = c.theme, " + "fm.system_points = c.system_points, fm.streak_days = c.streak_days, fm.last_task_date = c.last_task_date, " + "fm.focus_max_daily = c.focus_max_daily, fm.focus_remaining = c.focus_remaining, " + "fm.focus_reset_date = c.focus_reset_date, fm.total_points = c.total_points, " + "fm.show_to_family = c.show_to_family, fm.birthday = c.birthday, fm.gender = c.gender, " + "fm.phone = c.phone, fm.role_override = 'child'"); log.info("迁移322: 已更新 family_members 中已存在的 children 匹配记录字段"); // 2. 不匹配则插入新记录 jdbcTemplate.execute( "INSERT INTO family_members (family_id, user_id, nickname, birthday, gender, phone, " + "id_card, penalty_enabled, theme, system_points, streak_days, last_task_date, " + "focus_max_daily, focus_remaining, focus_reset_date, show_to_family, total_points, " + "role_override, created_at, updated_at) " + "SELECT family_id, user_id, nickname, birthday, gender, phone, " + "id_card, penalty_enabled, theme, system_points, streak_days, last_task_date, " + "focus_max_daily, focus_remaining, focus_reset_date, show_to_family, total_points, " + "'child', created_at, updated_at " + "FROM children " + "WHERE NOT EXISTS (SELECT 1 FROM family_members fm2 WHERE fm2.family_id = children.family_id " + "AND fm2.nickname = children.nickname AND fm2.user_id = children.user_id)"); log.info("迁移322: 已从 children 插入不匹配记录到 family_members"); // 3. 删除 children 表 jdbcTemplate.execute("DROP TABLE IF EXISTS children"); log.info("迁移322: 已删除 children 表(数据已迁移到 family_members)"); } } catch (Exception e) { log.warn("迁移322执行失败(children 表可能不存在或迁移范围内字段缺失): {}", e.getMessage()); } ensureColumn("promotion_tier", "rate_percent", "INT DEFAULT 0 COMMENT '当前返佣比例(%)'"); // 迁移300: family_members 添加 status 列(成员状态: active/recycled,非微信用户被移出进回收箱) ensureColumn("family_members", "status", "VARCHAR(16) DEFAULT 'active' COMMENT '成员状态: active=正常, recycled=回收箱'"); // ===== 生产库对账候选风险记录(已核对生产库,2026-09,按用户决定不迁移/暂不动,仅记录)===== // 注:以下为 entity 期望但生产库未对齐.项目,用户已决定保持现状或记为候选,勿重复迁移。 // exportUserData 全字段 SELECT 会报 Unknown column 'phone')。schema.sql 已将 children 标为 // 已废除/并入 family_members,用户决定不迁移。 // 2. user_trigger_rules:生产表仅剩 user_id(PK,auto_increment)/action_config/last_triggered_at, // 实体期望 id/trigger_type/trigger_action/status/created_at 共 8 列,表 0 行且不在 schema.sql。 // UserSegmentationService.getTriggerRules 会查 status,命中时会报 Unknown column 'status'。候选。 // 3. promotion_tier.rate_percent:生产用旧模型(tier/team_size_1st/2nd/3rd/total_*),实体期望新模型, // 4. promotion_tier_config:实体已废弃,改用 cf_rate_tier(迁移279),保持现状。 // 5. family_members.member_type:实体为 VO 计算值已加 @TableField(exist=false)(不落库),schema.sql // 无此列;2026-09-06 生产已手动 ALTER 补列(JwtInterceptor/SurveyServiceImpl 查询报 Unknown column // 'member_type' 修复,生产旧代码兼容用),部署含 exist=false 的新代码后该列不再被引用,可留可删。 // 迁移302: tasks表新增is_daily_progress/target_value字段(统一任务系统;原迁移298用 AFTER source_id,列缺失时会被静默吞掉) ensureColumn("tasks", "is_daily_progress", "TINYINT DEFAULT 0 COMMENT '1=每日进度型任务(无deadline,每日重置)'"); ensureColumn("tasks", "target_value", "INT DEFAULT NULL COMMENT '进度目标值(is_daily_progress=1时有效)'"); try { jdbcTemplate.execute("ALTER TABLE tasks MODIFY COLUMN source_type VARCHAR(32) DEFAULT NULL COMMENT '来源类型: self/assigned/plan/health_plan/training_plan/onboarding/ai/template/article/activity/purchase'"); log.info("已更新tasks.source_type注释"); } catch (Exception e) { // 修改失败,忽略 } // 迁移296: admin_task_templates表新增onboarding_sequence字段(统一任务系统:新人引导复用任务模板表) try { jdbcTemplate.execute("ALTER TABLE admin_task_templates ADD COLUMN onboarding_sequence INT DEFAULT NULL COMMENT '新人引导顺序(source_type=onboarding的模板用)' AFTER teacher_id"); log.info("已添加onboarding_sequence列到admin_task_templates表"); } catch (Exception e) { // 列已存在,忽略 } // 迁移297: 统一任务系统数据迁移——growth_task DAILY定义迁入admin_task_templates(仅执行一次,幂等) try { Integer migrated = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM sys_config WHERE config_key = 'TASK_UNIFY_GT_MIGRATED'", Integer.class); if (migrated == null || migrated == 0) { // DAILY 类型 → admin_task_templates(title 拼入 [task_key] 前缀,供 updateProgress 匹配) jdbcTemplate.execute("INSERT INTO admin_task_templates (title, description, points, category, need_review, is_active, task_type, frequency, created_at, updated_at) " + "SELECT CONCAT('[', task_key, ']', title), description, reward_points, '成长', 0, enabled, 'recurring', 'daily', created_at, created_at " + "FROM growth_task WHERE type = 'DAILY' AND enabled = 1"); jdbcTemplate.update("INSERT INTO sys_config (config_key, config_value, description) VALUES ('TASK_UNIFY_GT_MIGRATED', '1', '统一任务系统: growth_task DAILY定义已迁入admin_task_templates')"); log.info("迁移297: growth_task DAILY定义已迁入admin_task_templates"); } } catch (Exception e) { log.warn("迁移297: growth_task迁移失败或已执行: {}", e.getMessage()); } // 说明:growth_task_log 历史进度、onboarding_task 存量数据仅保留在旧表(只读备份),不回填 tasks(历史数据无法1:1映射到新模型) // 迁移299: 插入新人引导任务模板种子数据(统一任务系统:onboarding 复用 admin_task_templates) // title = 原 taskType(REGISTER/COMPLETE_PROFILE/...),onboarding_sequence 定义顺序链 try { Integer onboardCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM admin_task_templates WHERE onboarding_sequence IS NOT NULL", Integer.class); if (onboardCount == null || onboardCount == 0) { jdbcTemplate.execute("INSERT INTO admin_task_templates (title, description, points, category, need_review, is_active, task_type, frequency, onboarding_sequence, created_at, updated_at) VALUES " + "('REGISTER', '完成注册', 100, '成长', 0, 1, 'onetime', 'none', 1, NOW(), NOW()), " + "('COMPLETE_PROFILE', '完善个人资料', 50, '成长', 0, 1, 'onetime', 'none', 2, NOW(), NOW()), " + "('ADD_CHILD', '添加家庭成员', 100, '成长', 0, 1, 'onetime', 'none', 3, NOW(), NOW()), " + "('FIRST_CHECKIN', '完成首次健康打卡', 30, '成长', 0, 1, 'onetime', 'none', 4, NOW(), NOW()), " + "('FIRST_TASK', '完成首次任务', 50, '成长', 0, 1, 'onetime', 'none', 5, NOW(), NOW()), " + "('BIND_REFERRAL', '绑定推荐人', 50, '成长', 0, 1, 'onetime', 'none', 6, NOW(), NOW()), " + "('COMPLETE_SELF_CHECK', '完成一次五维自检', 60, '成长', 0, 1, 'onetime', 'none', 7, NOW(), NOW())"); log.info("迁移299: 已插入7条新人引导任务模板种子数据"); } } catch (Exception e) { log.warn("迁移299: 插入新人引导任务模板失败或已存在: {}", e.getMessage()); } // 迁移301: platform_balance_log 加 (user_id, ref_type, ref_id) 唯一索引(earn 幂等 DB 兜底,防止并发重复发放) try { jdbcTemplate.execute("DELETE l1 FROM platform_balance_log l1 " + "INNER JOIN platform_balance_log l2 ON l1.user_id = l2.user_id " + "AND l1.ref_type = l2.ref_type AND l1.ref_id = l2.ref_id AND l1.id > l2.id " + "WHERE l1.ref_id IS NOT NULL AND l1.ref_type IS NOT NULL"); log.info("迁移301: platform_balance_log 历史重复流水已清理"); jdbcTemplate.execute("ALTER TABLE platform_balance_log ADD UNIQUE KEY uk_user_ref (user_id, ref_type, ref_id)"); log.info("迁移301: platform_balance_log 已添加 uk_user_ref 唯一索引"); } catch (Exception e) { log.warn("迁移301: platform_balance_log 唯一索引处理失败: {}", e.getMessage()); } // 迁移302: 创建 contact_help_logs 表(章鱼图帮助记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS contact_help_logs (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '被帮助者(当前用户)', " + "contact_id BIGINT NOT NULL COMMENT '帮助者(联系人)', " + "help_type VARCHAR(32) COMMENT '帮助类型: MONEY/ITEM/EMOTION/RESOURCE/SKILL/TIME', " + "amount DECIMAL(10,2) DEFAULT 0 COMMENT '帮助金额(非金钱类为0)', " + "description VARCHAR(255) COMMENT '帮助内容简述', " + "happened_at DATETIME COMMENT '帮助发生时间', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_chl_user (user_id), " + "INDEX idx_chl_contact (contact_id) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='联系人帮助记录(章鱼图数据源)'"); log.info("已创建contact_help_logs表"); } catch (Exception e) { log.warn("创建contact_help_logs表失败: {}", e.getMessage()); } // 迁移303: 创建 resource_items 表(珍珠图资源登记:信息/场所/技能类资源) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS resource_items (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '资源拥有者(当前用户)', " + "type VARCHAR(16) NOT NULL COMMENT '资源类型: INFO(信息)/PLACE(场所)/SKILL(技能)', " + "name VARCHAR(100) NOT NULL COMMENT '资源名称', " + "description VARCHAR(255) COMMENT '资源描述(可调用方式/说明)', " + "contact_id BIGINT DEFAULT NULL COMMENT '关联联系人ID(可选)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_ri_user (user_id) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='珍珠图资源登记表(信息/场所/技能类资源)'"); log.info("已创建resource_items表"); } catch (Exception e) { log.warn("创建resource_items表失败: {}", e.getMessage()); } // 迁移304: health_gut_flora.bacteria_name 改为可空(LLM解析偶发缺菌种名,NOT NULL 导致采集入库失败) try { jdbcTemplate.execute("ALTER TABLE health_gut_flora MODIFY COLUMN bacteria_name VARCHAR(100) NULL COMMENT '菌种名称'"); log.info("迁移304: health_gut_flora.bacteria_name 已改为可空"); } catch (Exception e) { log.warn("迁移304: health_gut_flora.bacteria_name 修改失败: {}", e.getMessage()); } // 迁移305: health_indicators.status 放宽到 VARCHAR(100)(opencode 回退解析产出自由文本 status, // 超长写入报 Data too long for column 'status',draft 入库失败) try { jdbcTemplate.execute("ALTER TABLE health_indicators MODIFY COLUMN status VARCHAR(100) NULL COMMENT '评估状态: 正常/偏低/偏高/过多/注意/低风险/未检出'"); log.info("迁移305: health_indicators.status 已放宽到 VARCHAR(100)"); } catch (Exception e) { log.warn("迁移305: health_indicators.status 修改失败: {}", e.getMessage()); } // 迁移306: products表添加visible_roles列(商品类型权限过滤,NULL=全部角色可见) try { jdbcTemplate.execute("ALTER TABLE products ADD COLUMN visible_roles JSON DEFAULT NULL COMMENT '可见角色: [\"parent\",\"child\",\"teacher\",\"admin\"],NULL=全部可见'"); log.info("迁移306: products.visible_roles 列已添加"); } catch (Exception e) { // ignore } // 迁移307: resource_items 表添加社会连接分类字段(珍珠图重构:25 类连接类型 + 价值分级) try { jdbcTemplate.execute("ALTER TABLE resource_items ADD COLUMN connection_type VARCHAR(50) NOT NULL DEFAULT '' COMMENT '连接类型枚举名(ConnectionType)'"); } catch (Exception e) { log.warn("迁移307: resource_items.connection_type 已存在或添加失败: {}", e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE resource_items ADD COLUMN priority TINYINT DEFAULT 2 COMMENT '优先级: 1=高 2=中 3=低(手动覆盖)'"); } catch (Exception e) { log.warn("迁移307: resource_items.priority 已存在或添加失败: {}", e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE resource_items ADD COLUMN value_score INT DEFAULT 0 COMMENT '价值分 0-100(自动计算,可手动覆盖)'"); } catch (Exception e) { log.warn("迁移307: resource_items.value_score 已存在或添加失败: {}", e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE resource_items ADD COLUMN last_interaction_at DATETIME DEFAULT NULL COMMENT '最近互动时间'"); } catch (Exception e) { log.warn("迁移307: resource_items.last_interaction_at 已存在或添加失败: {}", e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE resource_items ADD COLUMN interaction_count INT DEFAULT 0 COMMENT '互动总次数'"); } catch (Exception e) { log.warn("迁移307: resource_items.interaction_count 已存在或添加失败: {}", e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE resource_items ADD INDEX idx_ri_connection_type (connection_type)"); } catch (Exception e) { log.warn("迁移307: resource_items.connection_type 索引已存在或添加失败: {}", e.getMessage()); } log.info("迁移307: resource_items 社会连接字段添加完成"); // 迁移308: 创建珍珠图互动日志表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS pearl_interaction_log (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '操作人', " + "resource_item_id BIGINT NOT NULL COMMENT '关联资源', " + "interaction_type VARCHAR(20) NOT NULL COMMENT 'PHONE/WECHAT/MEETING/OTHER', " + "content VARCHAR(500) DEFAULT NULL COMMENT '互动内容简记', " + "happened_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '互动发生时间', " + "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_pil_user (user_id), " + "INDEX idx_pil_resource (resource_item_id), " + "INDEX idx_pil_happened (happened_at) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='珍珠图互动日志'"); log.info("迁移308: 已创建 pearl_interaction_log 表"); } catch (Exception e) { log.warn("迁移308: 创建 pearl_interaction_log 表失败: {}", e.getMessage()); } // 迁移309: 创建珍珠图互动提醒表 try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS pearl_interaction_reminder (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL, " + "resource_item_id BIGINT NOT NULL, " + "connection_type VARCHAR(50) NOT NULL, " + "suggest_interval_month INT NOT NULL COMMENT '建议互动周期(月)', " + "last_interaction_at DATETIME DEFAULT NULL, " + "days_since_last INT NOT NULL COMMENT '距上次互动天数', " + "status VARCHAR(20) DEFAULT 'PENDING' COMMENT 'PENDING/DONE/DISMISSED', " + "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_pir_user_status (user_id, status), " + "INDEX idx_pir_resource (resource_item_id) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='珍珠图互动提醒'"); log.info("迁移309: 已创建 pearl_interaction_reminder 表"); } catch (Exception e) { log.warn("迁移309: 创建 pearl_interaction_reminder 表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS octopus_effect_record (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '当前用户(租户键)', " + "member_order_id BIGINT COMMENT '关联 MemberSubscriptionOrder.id(会员订单)', " + "effect_type TINYINT UNSIGNED NOT NULL COMMENT '成效类型 bitmask:1=金额大,2=频次高,4=新成交(支持多选)', " + "effect_amount DECIMAL(10,2) DEFAULT 0 COMMENT '成效金额(元)', " + "effect_desc VARCHAR(500) COMMENT '成效描述', " + "status TINYINT DEFAULT 1 COMMENT '1=有效,0=无效', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_oer_user (user_id), " + "INDEX idx_oer_order (member_order_id) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关键人拓展-成效记录'"); log.info("已创建octopus_effect_record表"); } catch (Exception e) { log.warn("创建octopus_effect_record表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS octopus_key_person (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '当前用户(租户键)', " + "name VARCHAR(64) NOT NULL COMMENT '关键人姓名', " + "organization VARCHAR(128) COMMENT '单位/公司', " + "department VARCHAR(64) COMMENT '部门', " + "title VARCHAR(64) COMMENT '职务/职位', " + "first_meet_scene VARCHAR(128) COMMENT '初识场合', " + "know_way VARCHAR(255) COMMENT '如何认识(认识路径)', " + "contact_info VARCHAR(255) COMMENT '联系方式(可选)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_okp_user (user_id), " + "INDEX idx_okp_org (organization) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关键人画像'"); log.info("已创建octopus_key_person表"); } catch (Exception e) { log.warn("创建octopus_key_person表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS octopus_effect_key_person (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "effect_id BIGINT NOT NULL COMMENT '关联 octopus_effect_record.id', " + "key_person_id BIGINT NOT NULL COMMENT '关联 octopus_key_person.id', " + "role TINYINT NOT NULL COMMENT '1=引荐,2=决策,3=其他', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_oekp_effect (effect_id), " + "INDEX idx_oekp_person (key_person_id), " + "UNIQUE KEY uk_oekp (effect_id,key_person_id) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成效-关键人关联'"); log.info("已创建octopus_effect_key_person表"); } catch (Exception e) { log.warn("创建octopus_effect_key_person表失败: {}", e.getMessage()); } // 迁移310: health_meal_record 表添加 record_date(补打日期)与 is_backfill(是否补打)字段(饮食记录重设计) try { jdbcTemplate.execute("ALTER TABLE health_meal_record ADD COLUMN record_date DATE NULL COMMENT '用餐日期(补打时为实际用餐日,默认等于创建日)'"); log.info("迁移310: health_meal_record.record_date 列已添加"); } catch (Exception e) { log.warn("迁移310: health_meal_record.record_date 已存在或添加失败: {}", e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE health_meal_record ADD COLUMN is_backfill TINYINT DEFAULT 0 COMMENT '是否补打: 1=补打(无能量值), 0=当日打卡'"); log.info("迁移310: health_meal_record.is_backfill 列已添加"); } catch (Exception e) { log.warn("迁移310: health_meal_record.is_backfill 已存在或添加失败: {}", e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE health_meal_record ADD INDEX idx_meal_record_date (member_id, record_date)"); log.info("迁移310: health_meal_record (member_id, record_date) 索引已添加"); } catch (Exception e) { log.warn("迁移310: health_meal_record 索引已存在或添加失败: {}", e.getMessage()); } // 迁移310: health_reports / health_report_drafts 补 file_hash 列 // 实体与 schema.sql 已含该列,但建表迁移从未 ADD;upload-only 按 MD5 去重时 SELECT 报 Unknown column 'file_hash' ensureColumn("health_reports", "file_hash", "VARCHAR(64) DEFAULT NULL COMMENT '文件MD5哈希,用于重复上传检测'"); ensureColumn("health_report_drafts", "file_hash", "VARCHAR(64) DEFAULT NULL COMMENT '文件MD5哈希,用于重复上传检测'"); log.info("迁移310: 已补齐 health_reports/health_report_drafts.file_hash"); // 迁移311: report_type_registry 补 script_status 列 // 实体/schema 已含;异步采集指纹识别 SELECT 全字段时报 Unknown column 'script_status' ensureColumn("report_type_registry", "script_status", "VARCHAR(20) DEFAULT NULL COMMENT '采集脚本生成状态: generating/ready/failed'"); log.info("迁移311: 已补齐 report_type_registry.script_status"); // 迁移312: 为 gut_flora 补默认指纹规则(空表会导致指纹引擎永远 unknown) seedGutFloraFingerprints(); // 迁移316-329: 原误置于 seedGutFloraFingerprints 早返回(return)之后,gut_flora 指纹存在时永不执行; // 现移至 runSystemMigrations 无条件执行(recommendation_rules / task_orchestration / onboarding_guide_config / 打卡表照片语音字段) // 迁移316: 创建 recommendation_rules 表(统一推荐规则配置) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS recommendation_rules (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "rule_type TINYINT NOT NULL COMMENT '1-5', " + "rule_name VARCHAR(100) COMMENT '运营名称', " + "trigger_product_id BIGINT COMMENT '触发商品ID', " + "trigger_product_ids VARCHAR(1000) COMMENT '触发商品ID JSON 数组', " + "target_product_id BIGINT COMMENT '目标商品ID', " + "priority INT DEFAULT 10 COMMENT '优先级,越大越优', " + "enabled TINYINT DEFAULT 1 COMMENT '启用 1/0', " + "time_window_days INT COMMENT '回溯窗口(天)', " + "delay_days INT COMMENT '复购延迟天数', " + "scope VARCHAR(20) COMMENT 'family/individual', " + "scene_codes VARCHAR(500) COMMENT '生效场景 JSON 数组', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品推荐规则配置'"); log.info("迁移316: 已创建recommendation_rules表"); } catch (Exception e) { // 表已存在,忽略错误 } // 迁移317: recommendation_rules 表索引 try { jdbcTemplate.execute("CREATE INDEX idx_rules_enabled ON recommendation_rules(enabled)"); log.info("迁移317: 已创建idx_rules_enabled索引"); } catch (Exception e) { // 索引已存在,忽略错误 } try { jdbcTemplate.execute("CREATE INDEX idx_rules_scene ON recommendation_rules(scene_codes)"); log.info("迁移317: 已创建idx_rules_scene索引"); } catch (Exception e) { // 索引已存在,忽略错误 } try { jdbcTemplate.execute("CREATE INDEX idx_rules_priority ON recommendation_rules(priority)"); log.info("迁移317: 已创建idx_rules_priority索引"); } catch (Exception e) { // 索引已存在,忽略错误 } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `task_orchestration_flows` (" + "`id` bigint NOT NULL AUTO_INCREMENT," + "`name` varchar(100) NOT NULL," + "`description` text," + "`creator_id` bigint NOT NULL," + "`family_id` bigint DEFAULT NULL," + "`version` int NOT NULL DEFAULT 1," + "`status` varchar(20) NOT NULL DEFAULT 'draft'," + "`schedule_cron` varchar(64) DEFAULT NULL," + "`config_json` json DEFAULT NULL," + "`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP," + "`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," + "PRIMARY KEY (`id`)," + "KEY `idx_family_id` (`family_id`)," + "KEY `idx_status` (`status`)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务编排流定义'"); log.info("迁移329: 已创建task_orchestration_flows表"); } catch (Exception e) { log.warn("迁移329: task_orchestration_flows 已存在或创建失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `task_orchestration_edges` (" + "`id` bigint NOT NULL AUTO_INCREMENT," + "`flow_id` bigint NOT NULL," + "`from_node_id` varchar(64) NOT NULL," + "`to_node_id` varchar(64) NOT NULL," + "`edge_type` varchar(20) NOT NULL," + "`operator` varchar(10) NOT NULL DEFAULT 'AND'," + "`sort_order` int NOT NULL DEFAULT 0," + "`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP," + "PRIMARY KEY (`id`)," + "KEY `idx_flow_id` (`flow_id`)," + "UNIQUE KEY `uk_flow_edge` (`flow_id`, `from_node_id`, `to_node_id`)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务编排流边定义'"); log.info("迁移329: 已创建task_orchestration_edges表"); } catch (Exception e) { log.warn("迁移329: task_orchestration_edges 已存在或创建失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `task_orchestration_executions` (" + "`id` bigint NOT NULL AUTO_INCREMENT," + "`flow_id` bigint NOT NULL," + "`flow_version` int NOT NULL DEFAULT 1," + "`family_id` bigint NOT NULL," + "`family_member_id` bigint NOT NULL," + "`trigger_source` varchar(20) NOT NULL DEFAULT 'manual'," + "`status` varchar(20) NOT NULL DEFAULT 'running'," + "`started_at` datetime NOT NULL," + "`finished_at` datetime DEFAULT NULL," + "`error_reason` varchar(255) DEFAULT NULL," + "`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP," + "`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," + "PRIMARY KEY (`id`)," + "KEY `idx_execution_flow` (`flow_id`)," + "KEY `idx_execution_status` (`status`)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务编排流执行记录'"); log.info("迁移329: 已创建task_orchestration_executions表"); } catch (Exception e) { log.warn("迁移329: task_orchestration_executions 已存在或创建失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `task_orchestration_node_instances` (" + "`id` bigint NOT NULL AUTO_INCREMENT," + "`execution_id` bigint NOT NULL," + "`node_id` varchar(64) NOT NULL," + "`task_id` bigint DEFAULT NULL," + "`status` varchar(20) NOT NULL DEFAULT 'pending'," + "`generation` int NOT NULL DEFAULT 0," + "`condition_met_at` datetime DEFAULT NULL COMMENT '条件终止:外部API通知条件达成时间'," + "`loop_termination_reason` varchar(50) DEFAULT NULL," + "`started_at` datetime DEFAULT NULL," + "`completed_at` datetime DEFAULT NULL," + "`error_reason` varchar(255) DEFAULT NULL," + "`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP," + "`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," + "PRIMARY KEY (`id`)," + "UNIQUE KEY `uk_node_instance` (`execution_id`, `node_id`, `generation`)," + "KEY `idx_execution_id` (`execution_id`)," + "KEY `idx_task_id` (`task_id`)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务编排流节点实例'"); log.info("迁移329: 已创建task_orchestration_node_instances表"); } catch (Exception e) { log.warn("迁移329: task_orchestration_node_instances 已存在或创建失败: {}", e.getMessage()); } // 迁移318: sys_config 预置首页引导浮层默认配置(onboarding_guide_config key) try { String defaultConfig = "{" + "\"enabled\":true," + "\"version\":\"1.0\"," + "\"positions\":[\"home\",\"child\"]," + "\"parent\":[" + "{\"icon\":\"🔍\",\"title\":\"你真的了解孩子的状态吗?\",\"desc\":\"情绪低落没察觉、习惯变化没注意、能力短板没发现——很多问题等爆发了才看见。浠艾福帮你在问题发生前,看清孩子真实的身心状态。\",\"isIntro\":true}," + "{\"icon\":\"🌏\",\"title\":\"五维沙盘,看见全家\",\"desc\":\"身·心·智·行·富,一张全景图看清每个维度。孩子身体好不好、情绪怎么样、能力到哪了——不用猜,看得见.\"}," + "{\"icon\":\"📋\",\"title\":\"小任务,大改变\",\"desc\":\"布置每日小任务,完成就能攒星星。每天一点点积累,好习惯自然养成.\"}," + "{\"icon\":\"🎁\",\"title\":\"心愿墙,看得见努力\",\"desc\":\"孩子攒够星星就能兑换心愿。努力有回报,目标感和坚持力一起培养.\"}" + "]," + "\"child\":[" + "{\"icon\":\"🔍\",\"title\":\"你的每一点成长,都被看见\",\"desc\":\"今天完成了几个任务?比昨天进步了多少?浠艾福帮你记录每一点努力,让成长看得见。\",\"isIntro\":true}," + "{\"icon\":\"🌟\",\"title\":\"赚星星\",\"desc\":\"完成爸爸妈妈布置的任务,就能获得星星积分,越努力越多!\"}," + "{\"icon\":\"🎮\",\"title\":\"玩中学\",\"desc\":\"完成任务后可以玩小游戏、兑换心愿,学习和快乐两不误!\"}," + "{\"icon\":\"🏆\",\"title\":\"升级挑战\",\"desc\":\"从星星宝宝到宇宙小英雄,一步步升级,看看你能走多远!\"}" + "]" + "}"; jdbcTemplate.update( "INSERT IGNORE INTO sys_config (config_key, config_value, description) VALUES ('onboarding_guide_config', ?, '首页引导浮层配置')", defaultConfig ); log.info("迁移318: 已预置onboarding_guide_config默认配置"); } catch (Exception e) { log.warn("预置onboarding_guide_config可能已存在: {}", e.getMessage()); } // 迁移328: 饮食/运动打卡表补充照片与语音字段(前端已上传 media URL,补上持久化列) try { ensureColumn("health_meal_record", "photo_urls", "TEXT COMMENT '照片URL列表(逗号分隔)'"); ensureColumn("health_meal_record", "voice_url", "VARCHAR(500) COMMENT '语音URL'"); ensureColumn("health_meal_record", "voice_text", "TEXT COMMENT '语音转写文字'"); ensureColumn("exercise_record", "photo_urls", "TEXT COMMENT '照片URL列表(逗号分隔)'"); ensureColumn("exercise_record", "voice_url", "VARCHAR(500) COMMENT '语音URL'"); ensureColumn("exercise_record", "voice_text", "TEXT COMMENT '语音转写文字'"); log.info("迁移328: 已为health_meal_record/exercise_record补充照片语音字段"); } catch (Exception e) { log.warn("迁移328: 补充照片语音字段失败: {}", e.getMessage()); } // 迁移333-336: 引导弹窗配置表(guide_popup 系列,原远程版本误置于 seedGutFloraFingerprints 早返回之后, // 现并入 runSystemMigrations 无条件执行;编号 333-336 为本地迁移329(task_orchestration)以后的续号) // 迁移333: 创建 guide_popup 表(弹窗引导页配置化管理) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_popup (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', " + "slug VARCHAR(64) NOT NULL UNIQUE COMMENT '唯一标识', " + "title VARCHAR(128) NOT NULL DEFAULT '' COMMENT '弹窗标题', " + "description TEXT COMMENT '主描述文案', " + "icon VARCHAR(256) NOT NULL DEFAULT '' COMMENT '图标:emoji或图片URL', " + "icon_type TINYINT NOT NULL DEFAULT 0 COMMENT '图标类型:0=emoji 1=图片', " + "theme_color VARCHAR(20) NOT NULL DEFAULT '#F97316' COMMENT '主题色', " + "btn_text VARCHAR(64) NOT NULL DEFAULT '知道了' COMMENT '按钮文案', " + "btn_action VARCHAR(256) NOT NULL DEFAULT '' COMMENT '点击跳转路径,空=关闭', " + "status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0=禁用 1=启用', " + "sort_order INT NOT NULL DEFAULT 0 COMMENT '排序优先级', " + "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " + "updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', " + "INDEX idx_status_sort (status, sort_order)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='引导弹窗配置表'"); log.info("迁移333: 已创建 guide_popup 表"); } catch (Exception e) { log.warn("guide_popup 表可能已存在: {}", e.getMessage()); } // 迁移334: 创建 guide_popup_step 表(引导弹窗步骤) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_popup_step (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', " + "popup_id BIGINT NOT NULL COMMENT '关联 guide_popup.id', " + "step_order INT NOT NULL DEFAULT 0 COMMENT '步骤顺序', " + "title VARCHAR(128) NOT NULL DEFAULT '' COMMENT '步骤标题', " + "`desc` TEXT COMMENT '步骤描述', " + "icon VARCHAR(256) NOT NULL DEFAULT '' COMMENT '步骤图标', " + "icon_type TINYINT NOT NULL DEFAULT 0 COMMENT '图标类型:0=emoji 1=图片', " + "is_intro TINYINT NOT NULL DEFAULT 0 COMMENT '是否为开场身份定义页', " + "btn_text VARCHAR(64) NOT NULL DEFAULT '下一步' COMMENT '本步骤按钮文案', " + "btn_action VARCHAR(256) NOT NULL DEFAULT '' COMMENT '最后一步跳转链接', " + "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " + "INDEX idx_popup_order (popup_id, step_order)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='引导弹窗步骤表'"); log.info("迁移334: 已创建 guide_popup_step 表"); } catch (Exception e) { log.warn("guide_popup_step 表可能已存在: {}", e.getMessage()); } // 迁移335: 创建 guide_popup_trigger 表(引导弹窗触发条件) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_popup_trigger (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', " + "popup_id BIGINT NOT NULL COMMENT '关联 guide_popup.id', " + "trigger_type VARCHAR(32) NOT NULL COMMENT '触发类型:page_enter/task_complete/assessment_done/first_login/daily_active/role_change', " + "target_page VARCHAR(128) DEFAULT '' COMMENT '目标页面路径', " + "target_action VARCHAR(128) DEFAULT '' COMMENT '触发行为标识', " + "min_role VARCHAR(32) DEFAULT 'parent' COMMENT '最低角色要求', " + "extra_config JSON DEFAULT NULL COMMENT '扩展条件', " + "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " + "INDEX idx_popup_trigger (popup_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='引导弹窗触发条件表'"); log.info("迁移335: 已创建 guide_popup_trigger 表"); } catch (Exception e) { log.warn("guide_popup_trigger 表可能已存在: {}", e.getMessage()); } // 迁移336: 创建 guide_popup_log 表(引导弹窗用户消费记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_popup_log (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', " + "popup_id BIGINT NOT NULL COMMENT '关联 guide_popup.id', " + "user_id BIGINT NOT NULL COMMENT '用户ID users.id', " + "member_id BIGINT DEFAULT NULL COMMENT '家庭成员ID family_members.id', " + "role VARCHAR(32) NOT NULL COMMENT '弹出时所在角色', " + "consumed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '消费时间', " + "button_clicked TINYINT NOT NULL DEFAULT 0 COMMENT '是否点击按钮', " + "INDEX idx_user_popup (user_id, popup_id), " + "INDEX idx_consume_time (consumed_at)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='引导弹窗用户消费记录表'"); log.info("迁移336: 已创建 guide_popup_log 表"); } catch (Exception e) { log.warn("guide_popup_log 表可能已存在: {}", e.getMessage()); } // 迁移323: 注册素质测评A2报告类型(dan家族)+ 默认指纹规则 // 统一上传后指纹识别为 dan_a2 → ReportCollectService 按 report_type_registry.family=dan 分流走 DAN 采集管道 try { Integer danA2Count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM report_type_registry WHERE type_id = ?", Integer.class, "dan_a2"); if (danA2Count == null || danA2Count == 0) { jdbcTemplate.update( "INSERT INTO report_type_registry (type_id, family, display_name, description, parser_path, " + "min_confidence, is_active, source, review_status, version, created_at, updated_at) " + "VALUES ('dan_a2', 'dan', '素质测评A2', '核心素养评估报告:大五人格/认知能力/社会关系等', " + "NULL, 30, 1, 'prebuilt', 'approved', '1.0', NOW(), NOW())"); log.info("迁移323: 已注册报告类型 dan_a2(dan家族)"); } else { log.info("迁移323: 报告类型 dan_a2 已存在,跳过"); } } catch (Exception e) { log.warn("迁移323: 注册 dan_a2 类型失败: {}", e.getMessage()); } seedDanA2Fingerprints(); seedDiscountRulesMenu(); createUserShortcutConfigTable(); createUserAppUsageTable(); seedGuidePopupData(); addGiftAmountColumn(); createGiftMembershipProgressTable(); addFlowTaskColumnsToTasksTable(); addPolicyTermsColumn(); createHomeBannersTable(); ensureHealthReportDraftsMemberIdColumn(); ensureHealthPlansTotalDaysColumn(); seedTaskReminderTemplate(); backfillHealthPlanTaskPrerequisite(); backfillTemplateInstanceFamilyMemberId(); createReportTransferRequestsTable(); addProductMinDiscountLevel(); restoreBasicMembershipLevel(); backfillMembershipLevelOrder(); fixMembershipLevelOrder(); removeReportUploadQuota(); applyBasicMembership20261023Spec(); createProductDimensionMappingTable(); createProductRecommendationLogTable(); createRepurchaseReminderTables(); createRecommendationProductAssociationsTable(); addAbTestColumnsToRecommendationLog(); createTaskReminderConfigTable(); addMissingColumnsToRecommendationLog(); createDietFamilySelectedIngredientsTable(); createSsoTicketTable(); // 迁移327: 创建 report_feedback 表(报告反馈+AI解答) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_feedback (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '提交人 users.id', " + "member_id BIGINT DEFAULT NULL COMMENT '关联家庭成员 family_members.id', " + "report_id BIGINT DEFAULT NULL COMMENT '健康报告ID', " + "draft_id BIGINT DEFAULT NULL COMMENT '草稿ID', " + "feedback_type VARCHAR(20) DEFAULT 'question' COMMENT 'question=疑问 / suggestion=建议', " + "content VARCHAR(500) NOT NULL COMMENT '反馈内容', " + "ai_answer TEXT COMMENT 'AI 解答', " + "ai_status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/answered/failed', " + "conversation_id VARCHAR(64) DEFAULT NULL COMMENT 'AI 会话ID(付费多轮)', " + "created_at DATETIME NOT NULL COMMENT '创建时间', " + "INDEX idx_rf_user (user_id, created_at), " + "INDEX idx_rf_report (report_id) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告反馈与AI解答'"); log.info("迁移327: 已创建 report_feedback 表"); } catch (Exception e) { // 表已存在,忽略错误 } // 迁移313: 修复 meal_configs 历史双编码 participant_member_ids(共餐配置前端曾 JSON.stringify 二次编码,存成了 "\"[1,2]\"", // 导致再次进入页面时无法识别选中成员)。仅处理以双引号包裹的值,不触碰正常 "[]..." 格式 try { jdbcTemplate.execute("UPDATE meal_configs SET participant_member_ids = JSON_UNQUOTE(participant_member_ids) " + "WHERE participant_member_ids LIKE '\"%\"' "); log.info("迁移313: 已修复 meal_configs 双编码 participant_member_ids"); } catch (Exception e) { log.warn("迁移313: 修复 meal_configs participant_member_ids 失败: {}", e.getMessage()); } // 迁移314: meal_configs 增加 config_date 字段(当天临时共餐配置)+ 修改 UNIQUE KEY // 支持「今天」模式:config_date 有值=当天临时配置(优先级最高),config_date=NULL+config_date_type=工作日/周末默认配置 try { jdbcTemplate.execute("ALTER TABLE meal_configs ADD COLUMN config_date DATE DEFAULT NULL COMMENT '当天临时配置日期(NULL=默认工作日/周末配置)'"); log.info("迁移314: meal_configs 已添加 config_date 列"); } catch (Exception e) { log.warn("迁移314: meal_configs 添加 config_date 列失败(可能已存在): {}", e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE meal_configs ADD COLUMN config_date_eff DATE GENERATED ALWAYS AS (COALESCE(config_date, '9999-12-31')) STORED COMMENT '生效日期(NULL映射为9999-12-31,用于唯一约束)'"); log.info("迁移314: meal_configs 已添加生成列 config_date_eff"); } catch (Exception e) { log.warn("迁移314: meal_configs 添加 config_date_eff 生成列失败(可能已存在): {}", e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE meal_configs DROP INDEX uk_config"); log.info("迁移314: meal_configs 已删除旧 UNIQUE KEY uk_config"); } catch (Exception e) { log.warn("迁移314: 删除旧 UNIQUE KEY 失败(可能不存在): {}", e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE meal_configs ADD UNIQUE KEY uk_config (family_id, config_date_eff, meal_type)"); log.info("迁移314: meal_configs 已添加新 UNIQUE KEY uk_config(支持当天临时配置)"); } catch (Exception e) { log.warn("迁移314: 添加新 UNIQUE KEY 失败: {}", e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE meal_configs ADD INDEX idx_config_date (config_date)"); log.info("迁移314: meal_configs 已添加索引 idx_config_date"); } catch (Exception e) { log.warn("迁移314: meal_configs 添加索引 idx_config_date 失败(可能已存在): {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS doa_stage (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "member_id BIGINT NOT NULL COMMENT '目标归属成员ID(family_members.id)', " + "title VARCHAR(200) COMMENT '阶段主题(可选)', " + "start_date DATE NOT NULL COMMENT '阶段开始日期', " + "end_date DATE NOT NULL COMMENT '阶段结束日期', " + "duration_weeks TINYINT NOT NULL COMMENT '周期周数:2/4/8/12', " + "status VARCHAR(20) DEFAULT 'active' COMMENT 'active/completed/cancelled', " + "created_by BIGINT NOT NULL COMMENT '创建人userId(代管时为代管人)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_ds_family_member (family_id, member_id), " + "INDEX idx_ds_status (status) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DOA 阶段'"); log.info("迁移318: 已创建 doa_stage 表"); } catch (Exception e) { log.warn("迁移318: 创建 doa_stage 表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS doa_goal (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "stage_id BIGINT NOT NULL COMMENT '所属阶段ID', " + "domain VARCHAR(20) NOT NULL COMMENT '领域:health/wealth/family/social/help', " + "content VARCHAR(500) NOT NULL COMMENT '量化目标内容', " + "status VARCHAR(20) DEFAULT 'active' COMMENT 'active/achieved/abandoned', " + "sort_order TINYINT DEFAULT 0 COMMENT '排序', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_dg_stage (stage_id) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DOA 阶段目标'"); log.info("迁移318: 已创建 doa_goal 表"); } catch (Exception e) { log.warn("迁移318: 创建 doa_goal 表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS doa_week (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "stage_id BIGINT NOT NULL COMMENT '所属阶段ID', " + "week_no TINYINT NOT NULL COMMENT '第几周(1起)', " + "week_start DATE NOT NULL COMMENT '本周开始(周一)', " + "week_end DATE NOT NULL COMMENT '本周结束(周日)', " + "plan TEXT COMMENT '周行动宣告计划(A表)', " + "achieved TEXT COMMENT '复盘:本周达成了什么', " + "experience TEXT COMMENT '复盘:有什么体验', " + "worked TEXT COMMENT '复盘:行得通的', " + "improve TEXT COMMENT '复盘:要提升/行不通的', " + "next_challenge TEXT COMMENT '复盘:下周挑战自己什么', " + "status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/planned/reviewed', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_dw_stage_week (stage_id, week_no), " + "INDEX idx_dw_stage (stage_id) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DOA 周记录'"); log.info("迁移318: 已创建 doa_week 表"); } catch (Exception e) { log.warn("迁移318: 创建 doa_week 表失败: {}", e.getMessage()); } // 迁移318: 基础管理分组挂载「系统提示词」菜单(cfc-web菜单树同步) migrateSystemPromptMenu(); // 迁移319: system_prompts 插入 health_plan 提示词种子(仅当不存在时) try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM system_prompts WHERE prompt_key = 'health_plan'", Integer.class); if (count == null || count == 0) { jdbcTemplate.update( "INSERT INTO system_prompts (prompt_key, title, content, default_content, version, status, remark) VALUES (?, ?, ?, ?, 1, 1, ?)", "health_plan", "健康方案生成", "你是一个专业的家庭健康方案规划师。根据用户的健康数据和目标,生成结构化的改善方案。\n\n" + "## 输出格式(必须严格遵守 JSON)\n\n" + "```\n" + "{\n" + " \"overview\": \"总体概述(100字以内,说明方案目标和核心策略)\",\n" + " \"sections\": [\n" + " {\n" + " \"key\": \"nutrition\",\n" + " \"title\": \"营养补充建议\",\n" + " \"content\": \"Markdown 格式的详细内容,包含具体产品推荐、用量、服用时间\",\n" + " \"items\": [{\"name\": \"产品名\", \"dosage\": \"用量\", \"timing\": \"服用时间\", \"reason\": \"推荐理由\"}]\n" + " },\n" + " {\n" + " \"key\": \"diet\",\n" + " \"title\": \"饮食建议\",\n" + " \"content\": \"Markdown 格式的餐饮建议,包含早餐/午餐/晚餐建议\",\n" + " \"items\": [{\"meal\": \"餐型\", \"food\": \"食物建议\", \"notes\": \"注意事项\"}]\n" + " },\n" + " {\n" + " \"key\": \"exercise\",\n" + " \"title\": \"运动计划\",\n" + " \"content\": \"Markdown 格式的运动建议,包含频率、时长、类型\",\n" + " \"items\": [{\"type\": \"运动类型\", \"duration\": \"时长\", \"frequency\": \"频率\", \"notes\": \"注意事项\"}]\n" + " }\n" + " ]\n" + "}\n" + "```\n\n" + "## 原则\n" + "1. 基于实际数据给出建议,不编造\n" + "2. 引用知识库时标注来源\n" + "3. 建议要具体可执行,避免空泛\n" + "4. 营养补充部分要具体到品牌/产品类型和用量\n" + "5. 严重健康问题建议咨询医生", "你是一个专业的家庭健康方案规划师。根据用户的健康数据和目标,生成结构化的改善方案。", "健康方案生成 prompt(营养+饮食+运动三段结构化输出)"); log.info("迁移319: 已插入 health_plan 提示词种子数据"); } else { log.info("迁移319: health_plan 提示词已存在,跳过"); } } catch (Exception e) { log.warn("迁移319: 插入 health_plan 提示词种子数据失败: {}", e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE exercise_record ADD COLUMN step_count INT COMMENT '微信运动步数'"); log.info("迁移320: exercise_record.step_count 列已添加"); } catch (Exception e) { log.warn("迁移320: exercise_record.step_count 列已存在或添加失败: {}", e.getMessage()); } // 迁移321: 商品/分类可见性由「角色」改为「按会员级别」控制 // - membership_levels.level_order: 级别数字(数字越大级别越高): FREE=1 BASIC=2 FAMILY=3 PREMIUM=4 LIFETIME=5; PROVIDER=1(非会员) // - products.min_visible_level: 最低可见会员级别编码(NULL=所有用户含未登录可见) // - shop_categories.min_visible_level: 分类最低可见会员级别编码(NULL=所有用户含未登录可见) ensureColumn("membership_levels", "level_order", "INT DEFAULT 1 COMMENT '级别数字(越大级别越高): FREE=1 BASIC=2 FAMILY=3 PREMIUM=4 LIFETIME=5; PROVIDER=1(非会员)'"); try { jdbcTemplate.update("UPDATE membership_levels SET level_order = CASE level_code " + "WHEN 'FREE' THEN 1 WHEN 'BASIC' THEN 2 WHEN 'FAMILY' THEN 3 " + "WHEN 'PREMIUM' THEN 4 WHEN 'LIFETIME' THEN 5 WHEN 'PROVIDER' THEN 1 ELSE level_order END"); log.info("迁移321: 已回填 membership_levels.level_order"); } catch (Exception e) { log.warn("迁移321: 回填 membership_levels.level_order 失败: {}", e.getMessage()); } ensureColumn("products", "min_visible_level", "VARCHAR(50) DEFAULT NULL COMMENT '最低可见会员级别编码(FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME, NULL=全部可见)'"); ensureColumn("shop_categories", "min_visible_level", "VARCHAR(50) DEFAULT NULL COMMENT '最低可见会员级别编码(FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME, NULL=全部可见)'"); log.info("迁移321: 已完成商品/分类按会员级别可见性改造"); // 迁移299: 创建 article_quiz_records 表(文章阅读答题记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS article_quiz_records (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "article_id BIGINT DEFAULT NULL, " + "user_id BIGINT DEFAULT NULL, " + "member_id BIGINT DEFAULT NULL, " + "questions TEXT, " + "answers TEXT, " + "score INT DEFAULT 0, " + "total_questions INT DEFAULT 0, " + "cf_earned INT DEFAULT 0, " + "energy_earned INT DEFAULT 0, " + "created_at DATETIME, " + "updated_at DATETIME, " + "INDEX idx_member (member_id), " + "INDEX idx_article (article_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章答题记录表'"); log.info("已创建article_quiz_records表"); } catch (Exception e) { log.warn("创建article_quiz_records表可能已存在: {}", e.getMessage()); } // 迁移300: profile_snapshot 增加 quiz_summary 列(答题认知雷达汇总) ensureColumn("profile_snapshot", "quiz_summary", "JSON DEFAULT NULL COMMENT '答题认知雷达汇总'"); // 迁移323: dan_assessment_results 添加 language_score 列(游戏化认知测评:语言表达维度) ensureColumn("dan_assessment_results", "language_score", "INT COMMENT '语言表达得分'"); log.info("迁移323: 已确保 language_score 列存在于 dan_assessment_results"); // 迁移324-325: octopus_effect_record 添加 happened_at 列 + article_reading_records 回填与补列 try { jdbcTemplate.execute("ALTER TABLE octopus_effect_record ADD COLUMN happened_at DATETIME COMMENT '成效发生时间'"); log.info("迁移324: octopus_effect_record.happened_at 列已添加"); } catch (Exception e) { log.warn("迁移324: octopus_effect_record.happened_at 列已存在或添加失败: {}", e.getMessage()); } try { int updated = jdbcTemplate.update( "UPDATE article_reading_records r " + "LEFT JOIN tasks t ON r.task_id = t.id AND t.source_type = 'article' " + "SET r.article_id = t.source_id " + "WHERE r.article_id IS NULL AND t.id IS NOT NULL AND t.source_id IS NOT NULL" ); log.info("迁移325: 已回填 article_reading_records.article_id {} 条记录", updated); } catch (Exception e) { log.warn("迁移325: 回填 article_reading_records.article_id 失败: {}", e.getMessage()); } ensureColumn("article_reading_records", "reading_completed", "TINYINT DEFAULT 0 COMMENT '是否已阅读达标(达到最低时长阈值)'"); // 迁移342: octopus_key_person 添加 first_meet_time / referrer_id / system_user_id 列(感恩记录新增字段) ensureColumn("octopus_key_person", "first_meet_time", "DATETIME COMMENT '相识具体时间'"); log.info("迁移342: octopus_key_person.first_meet_time 列已添加"); ensureColumn("octopus_key_person", "referrer_id", "BIGINT DEFAULT NULL COMMENT '介绍人关键人ID(指向同用户另一条关键人)'"); log.info("迁移342: octopus_key_person.referrer_id 列已添加"); ensureColumn("octopus_key_person", "system_user_id", "BIGINT DEFAULT NULL COMMENT '匹配到的系统用户ID(如有)'"); log.info("迁移342: octopus_key_person.system_user_id 列已添加"); try { Integer reportCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM admin_task_templates WHERE title = 'REPORT_UPLOAD' AND onboarding_sequence IS NOT NULL", Integer.class); if (reportCount == null || reportCount == 0) { jdbcTemplate.execute("INSERT INTO admin_task_templates (title, description, points, category, need_review, is_active, task_type, frequency, onboarding_sequence, created_at, updated_at) VALUES " + "('REPORT_UPLOAD', '完成首次报告上传', 300, '成长', 0, 1, 'onetime', 'none', 8, NOW(), NOW())"); log.info("迁移343: 已插入完成首次报告上传新人引导任务模板"); } } catch (Exception e) { log.warn("迁移343: 插入完成首次报告上传任务模板失败或已存在: {}", e.getMessage()); } } /** * 迁移318: 系统配置·基础管理 挂载 系统提示词 菜单(幂等) * 仅当 sys_menu 表已有数据(即角色授权已存在)时补授权,避免破坏兼容逻辑 */ private void migrateSystemPromptMenu() { try { Long baseManageId = queryMenuId("SELECT id FROM sys_menu WHERE title='基础管理'"); if (baseManageId == null) { log.warn("迁移318: 未找到基础管理分组,跳过"); return; } Long promptId = insertSystemPromptMenu(baseManageId); if (promptId != null) { Integer grantCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM sys_role_menu", Integer.class); if (grantCount != null && grantCount > 0) { grantMenuToRole("admin", promptId); log.info("迁移318: 已授权 admin 角色"); } log.info("迁移318: 系统提示词菜单挂载完成 id={}", promptId); } } catch (Exception e) { log.warn("迁移318失败: {}", e.getMessage()); } } /** * 按 path 幂等插入系统提示词叶子菜单,返回菜单 id(已存在返回原 id) */ private Long insertSystemPromptMenu(Long parentId) { Long existed = queryMenuId("SELECT id FROM sys_menu WHERE path='/system-prompt'"); if (existed != null) { return existed; } try { jdbcTemplate.update( "INSERT INTO sys_menu (parent_id, title, label, path, icon, perm, sort, visible) VALUES (?, NULL, '系统提示词', '/system-prompt', 'el-icon-chat-line-square', 'system:base', 8, 1)", parentId); Long id = jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class); log.info("迁移318: 已挂载系统提示词菜单"); return id; } catch (Exception e) { log.warn("迁移318: 挂载菜单失败: {}", e.getMessage()); return null; } } private void seedGutFloraFingerprints() { try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM report_fingerprint WHERE type_id = ?", Integer.class, "gut_flora"); if (count != null && count > 0) { log.info("迁移312: gut_flora 指纹规则已存在,跳过"); return; } String[][] rows = { {"肠道菌群检测", "20", "0", "1"}, {"核心菌属", "15", "0", "2"}, {"高通量测序", "15", "0", "3"}, {"菌群健康", "10", "0", "4"}, {"益生菌", "8", "0", "5"}, {"Shannon", "8", "0", "6"}, {"肠道菌群", "10", "0", "7"}, }; for (String[] row : rows) { jdbcTemplate.update( "INSERT INTO report_fingerprint (type_id, pattern, weight, is_exclusion, sort_order, created_at) " + "VALUES ('gut_flora', ?, ?, ?, ?, NOW())", row[0], Integer.valueOf(row[1]), Integer.valueOf(row[2]), Integer.valueOf(row[3])); } log.info("迁移312: 已写入 gut_flora 默认指纹规则 {} 条", rows.length); } catch (Exception e) { log.warn("迁移312: 写入 gut_flora 指纹规则失败: {}", e.getMessage()); } } /** * 迁移323: 为 dan_a2(素质测评A2)补默认指纹规则。 * 空表会导致指纹引擎永远 unknown;A2 报告特征词取自 DanReportParseService.parseA2Report 的章节识别词。 * dan_a2 注册表 min_confidence=30,两高权重词同时命中即判定成功,避免单词误判。 */ private void seedDanA2Fingerprints() { try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM report_fingerprint WHERE type_id = ?", Integer.class, "dan_a2"); if (count != null && count > 0) { log.info("迁移323: dan_a2 指纹规则已存在,跳过"); return; } String[][] rows = { {"大五人格", "20", "0", "1"}, {"人格特质", "15", "0", "2"}, {"核心素养", "15", "0", "3"}, {"认知能力", "10", "0", "4"}, {"感知觉", "10", "0", "5"}, {"评估报告", "8", "0", "6"}, {"综合评估", "8", "0", "7"}, }; for (String[] row : rows) { jdbcTemplate.update( "INSERT INTO report_fingerprint (type_id, pattern, weight, is_exclusion, sort_order, created_at) " + "VALUES ('dan_a2', ?, ?, ?, ?, NOW())", row[0], Integer.valueOf(row[1]), Integer.valueOf(row[2]), Integer.valueOf(row[3])); } log.info("迁移323: 已写入 dan_a2 默认指纹规则 {} 条", rows.length); } catch (Exception e) { log.warn("迁移323: 写入 dan_a2 指纹规则失败: {}", e.getMessage()); } } // 迁移326: 商城营销菜单新增「限时折扣管理」(幂等) private void seedDiscountRulesMenu() { try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM sys_menu WHERE path = '/discount-rules'", Integer.class); if (count != null && count > 0) { log.info("迁移326: 限时折扣管理菜单已存在,跳过"); return; } jdbcTemplate.update( "INSERT INTO sys_menu (parent_id, title, label, path, icon, perm, sort, visible, created_at) " + "VALUES (15, NULL, '限时折扣管理', '/discount-rules', 'el-icon-timer', 'commerce:discount', 7, 1, NOW())"); log.info("迁移326: 已添加限时折扣管理菜单"); } catch (Exception e) { log.warn("迁移326: 添加限时折扣管理菜单失败: {}", e.getMessage()); } } // 迁移327: 创建 user_shortcut_config 表(首页快捷方式自定义配置,每应用一条记录,locked=置顶) private void createUserShortcutConfigTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_shortcut_config (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "app_key VARCHAR(64) NOT NULL COMMENT '应用key', " + "locked TINYINT DEFAULT 0 COMMENT '是否锁定置顶(1=锁定,0=普通)', " + "sort_order INT DEFAULT 0 COMMENT '手动排序序号', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_user_app (user_id, app_key)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='首页快捷方式自定义配置'"); log.info("迁移327: 已创建 user_shortcut_config 表"); } catch (Exception e) { log.warn("迁移327: 创建 user_shortcut_config 表失败: {}", e.getMessage()); } } // 迁移327: 创建 user_app_usage 表(应用使用频度记录) private void createUserAppUsageTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_app_usage (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '用户ID', " + "app_key VARCHAR(64) NOT NULL COMMENT '应用key', " + "enter_count INT DEFAULT 0 COMMENT '累计进入次数', " + "last_enter_at DATETIME COMMENT '最近进入时间', " + "UNIQUE KEY uk_user_app (user_id, app_key)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='应用使用频度记录'"); log.info("迁移327: 已创建 user_app_usage 表"); } catch (Exception e) { log.warn("迁移327: 创建 user_app_usage 表失败: {}", e.getMessage()); } } // 迁移333: 预置引导弹窗种子数据(parent_first_login,首次登录家长端) private void seedGuidePopupData() { try { Long count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM guide_popup WHERE slug = 'parent_first_login'", Long.class); if (count != null && count > 0L) { log.info("迁移333: parent_first_login 种子弹窗已存在,跳过"); return; } jdbcTemplate.update( "INSERT INTO guide_popup (slug, title, description, icon, icon_type, theme_color, btn_text, btn_action, status, sort_order, created_at, updated_at) " + "VALUES ('parent_first_login', '欢迎使用浠艾福', '身·智·富·行·心,全家健康一站式管理', '🌏', 0, '#F97316', '开始体验', '', 1, 0, NOW(), NOW())"); Long popupId = jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class); // 步骤 jdbcTemplate.update("INSERT INTO guide_popup_step (popup_id, step_order, title, desc, icon, icon_type, is_intro, btn_text, btn_action) VALUES (?, 0, '你真的了解家人的状态吗?', '情绪低落没察觉、习惯变化没注意、能力短板没发现——什么问题等爆发了才看见。浠艾福帮你在问题发生前,看清每位家人的真实状态。', '🔍', 0, 1, '下一步', '')", popupId); jdbcTemplate.update("INSERT INTO guide_popup_step (popup_id, step_order, title, desc, icon, icon_type, is_intro, btn_text, btn_action) VALUES (?, 1, '五维能量,看见全家', '身·心·智·行·富,一张全景图看清每位家人。谁的身体需要关注、谁的情绪需要疏导、谁的状态正在下滑——不用猜,看得见。', '🌏', 0, 0, '下一步', '')", popupId); jdbcTemplate.update("INSERT INTO guide_popup_step (popup_id, step_order, title, desc, icon, icon_type, is_intro, btn_text, btn_action) VALUES (?, 2, '小行动,大改变', '全家一起完成每日小任务,各自攒星星。每天一点点积累,好习惯自然养成。', '📋', 0, 0, '下一步', '')", popupId); jdbcTemplate.update("INSERT INTO guide_popup_step (popup_id, step_order, title, desc, icon, icon_type, is_intro, btn_text, btn_action) VALUES (?, 3, '心愿墙,看得见努力', '每位家人用自己的星星兑换心愿。努力有回报,全家人都能看到彼此的坚持。', '🎁', 0, 0, '开始体验', '')", popupId); // 触发条件:首次登录,parent 角色 jdbcTemplate.update("INSERT INTO guide_popup_trigger (popup_id, trigger_type, target_page, target_action, min_role, extra_config, created_at) VALUES (?, 'first_login', '', '', 'parent', NULL, NOW())", popupId); log.info("迁移333: 已预置 parent_first_login 种子弹窗 (popup_id={})", popupId); } catch (Exception e) { log.warn("预置引导弹窗种子数据失败: {}", e.getMessage()); } } // 迁移337: product_orders 表添加 gift_amount 列(送会员商品原价金额,分) private void addGiftAmountColumn() { ensureColumn("product_orders", "gift_amount", "INT DEFAULT 0 COMMENT '送会员商品原价金额(分)'"); } // 迁移338: 创建 gift_membership_progress 表(家庭维度送会员累计进度) private void createGiftMembershipProgressTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS gift_membership_progress (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "accumulated_amount INT DEFAULT 0 COMMENT '累计送会员商品原价金额(分)', " + "granted INT DEFAULT 0 COMMENT '是否已赠送 1=已赠送 0=未赠送', " + "granted_level VARCHAR(50) COMMENT '已赠送会员等级', " + "granted_order_no VARCHAR(64) COMMENT '触发赠送的订单号', " + "granted_at DATETIME COMMENT '赠送时间', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_family (family_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='送会员累计进度'"); log.info("迁移338: 已创建 gift_membership_progress 表"); } catch (Exception e) { log.warn("迁移338: 创建 gift_membership_progress 表失败: {}", e.getMessage()); } } // 迁移339: tasks 表加 is_flow_task / successor_task_ids 列(TASK-SYS-248 流程任务前置/后置逻辑) private void addFlowTaskColumnsToTasksTable() { ensureColumn("tasks", "is_flow_task", "TINYINT DEFAULT 0 COMMENT '是否为流程任务:1=是(有前置/后置任务),0=否'"); ensureColumn("tasks", "successor_task_ids", "TEXT NULL COMMENT '后置任务IDs JSON: [1,2,3],任意一个完成则解锁(OR 逻辑)'"); } // 迁移339: insurance_policies 表加 policy_terms 列(保单原始条款,AI 解析后原样保存) private void addPolicyTermsColumn() { ensureColumn("insurance_policies", "policy_terms", "TEXT COMMENT '保单原始条款(AI 解析后原样保存)'"); } // 迁移340: 创建 home_banners 表(首页广告栏配置) private void createHomeBannersTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS home_banners (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," + "title VARCHAR(100) NOT NULL COMMENT '展示标题'," + "banner_type VARCHAR(20) NOT NULL COMMENT '类型: activity/product/link'," + "ref_id BIGINT DEFAULT NULL COMMENT '关联活动/商品ID(banner_type=activity/product时有效)'," + "cover_image VARCHAR(500) DEFAULT NULL COMMENT '封面图URL(可覆盖默认封面)'," + "link_url VARCHAR(500) DEFAULT NULL COMMENT '跳转链接(banner_type=link时有效)'," + "sort_order INT NOT NULL DEFAULT 0 COMMENT '排序,值越小越靠前'," + "enabled TINYINT NOT NULL DEFAULT 1 COMMENT '启用状态:1=启用 0=禁用'," + "start_time DATETIME DEFAULT NULL COMMENT '投放开始时间(NULL=不限)'," + "end_time DATETIME DEFAULT NULL COMMENT '投放结束时间(NULL=不限)'," + "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," + "updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='首页广告栏配置表'"); log.info("迁移340: 已创建 home_banners 表"); } catch (Exception e) { log.warn("迁移340: 创建 home_banners 表失败: {}", e.getMessage()); } } /** 迁移343: health_report_drafts 补 member_id 列(commit 21f8f6740 遗漏,schema.sql 已含但无迁移脚本) */ /** 迁移345: health_plans 补 total_days 列(方案周期天数,驱动服务任务周期边界与截止日期) */ private void ensureHealthPlansTotalDaysColumn() { ensureColumn("health_plans", "total_days", "INT DEFAULT NULL COMMENT '方案周期天数,驱动服务任务周期边界与截止日期'"); log.info("迁移345: health_plans.total_days 已确保存在"); } /** 迁移346: 未打卡任务提醒通知模板(原有种子仅在空表时插入,此处单独幂等补种) */ /** 迁移346: 未打卡任务提醒通知模板(原有种子仅在空表时插入,此处单独幂等补种) */ private void seedTaskReminderTemplate() { try { Integer cnt = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM notification_templates WHERE type = ?", Integer.class, "task_reminder"); if (cnt != null && cnt == 0) { jdbcTemplate.update("INSERT INTO notification_templates (type, title, content, channels) VALUES (?,?,?,?)", "task_reminder", "任务待完成提醒", "任务「{title}」{status},完成后记得打卡,可获得积分与 CF 值奖励。", "in_app,sms"); log.info("迁移346: task_reminder 通知模板已插入"); } } catch (Exception e) { log.warn("迁移346: 插入 task_reminder 通知模板失败: " + e.getMessage()); } try { Integer c2 = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM notification_templates WHERE type = ?", Integer.class, "task_completion_praise"); if (c2 != null && c2 == 0) { jdbcTemplate.update("INSERT INTO notification_templates (type, title, content, channels) VALUES (?,?,?,?)", "task_completion_praise", "任务完成,太棒了!", "太棒了!任务「{title}」已完成,继续保持,加油~", "in_app,wechat"); log.info("迁移346+: task_completion_praise 通知模板已插入"); } } catch (Exception e) { log.warn("迁移346+: 插入 task_completion_praise 通知模板失败: " + e.getMessage()); } } /** * 迁移347: 回填健康方案任务的依赖链。 * 历史上使用任务只写了 parent_task_id,从未写 prerequisite_task_id,导致"采购完成才激活服务任务"的闸门失效。 * 这里补写依赖并把前置未完成的使用任务置为 locked。 * 幂等:每次启动只处理 prerequisite IS NULL / status=pending 的行,可重复执行。 */ /** * 迁移348: 回填周期实例的 family_member_id。 * RepeatTaskGenerator 过去未透传该字段,导致周期实例在"今日任务"列表查不到、 * 且 completeTask 抛 NPE。存量实例按其模板回填,模板缺失时按 child_id 兜底。 */ private void backfillTemplateInstanceFamilyMemberId() { try { int fromTpl = jdbcTemplate.update( "UPDATE tasks i JOIN tasks tpl ON tpl.id = i.template_id " + "SET i.family_member_id = tpl.family_member_id " + "WHERE i.family_member_id IS NULL AND tpl.family_member_id IS NOT NULL"); int fromChild = jdbcTemplate.update( "UPDATE tasks SET family_member_id = child_id " + "WHERE family_member_id IS NULL AND child_id IS NOT NULL AND is_template = 0"); if (fromTpl > 0 || fromChild > 0) { log.info("迁移348: 周期实例 family_member_id 回填 模板来源={}条 child_id兜底={}条", fromTpl, fromChild); } } catch (Exception e) { log.warn("迁移348: 周期实例 family_member_id 回填失败: " + e.getMessage()); } } private void backfillHealthPlanTaskPrerequisite() { try { int linked = jdbcTemplate.update( "UPDATE tasks SET prerequisite_task_id = parent_task_id " + "WHERE source_type = 'health_plan' AND category = '使用任务' " + "AND parent_task_id IS NOT NULL AND prerequisite_task_id IS NULL"); // 注意:单表 UPDATE 的 WHERE 子查询不能引用目标表本身(MySQL 1093),必须用 LEFT JOIN 自连接 int locked = jdbcTemplate.update( "UPDATE tasks t LEFT JOIN tasks p " + "ON p.id = t.prerequisite_task_id AND p.status = 'completed' " + "SET t.status = 'locked' " + "WHERE t.source_type = 'health_plan' AND t.category = '使用任务' " + "AND t.prerequisite_task_id IS NOT NULL AND t.status = 'pending' AND p.id IS NULL"); if (linked > 0 || locked > 0) { log.info("迁移347: 健康方案任务依赖回填 prerequisite={}条, 置locked={}条", linked, locked); } } catch (Exception e) { log.warn("迁移347: 健康方案任务依赖回填失败: " + e.getMessage()); } } private void ensureHealthReportDraftsMemberIdColumn() { ensureColumn("health_report_drafts", "member_id", "BIGINT DEFAULT NULL COMMENT '报告归属成员ID(family_members.id),A2/DAN自动采集必需'"); log.info("迁移343: health_report_drafts.member_id 已确保存在"); } // 迁移344: 创建 supply_customer_relation 表(供应商团队客户归属记录) private void createSupplyCustomerRelationTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_customer_relation (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," + "system_id BIGINT NOT NULL COMMENT '供应商体系ID(supply_system.id)'," + "parent_user_id BIGINT NOT NULL COMMENT '上级供应商用户ID(supply_hierarchy.parent_id)'," + "family_id BIGINT NOT NULL COMMENT '客户家庭ID(family_members.family_id)'," + "child_user_id BIGINT DEFAULT NULL COMMENT '下单用户ID(users.id),可为空'," + "relation_type VARCHAR(32) NOT NULL DEFAULT 'family' COMMENT '归属类型: family=家庭归属 order=下单自动 referral=推荐码'," + "source VARCHAR(32) NOT NULL DEFAULT 'manual' COMMENT '来源: manual=人工绑定 order=下单自动 referral=推荐码'," + "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," + "INDEX idx_system_family (system_id, family_id)," + "INDEX idx_parent_user (parent_user_id)," + "INDEX idx_family_id (family_id)," + "UNIQUE KEY uk_system_family (system_id, family_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商团队客户归属关系表'"); log.info("迁移344: 已创建 supply_customer_relation 表"); } catch (Exception e) { log.warn("迁移344: 创建 supply_customer_relation 表失败: {}", e.getMessage()); } } // 迁移345: 创建 product_dimension_mapping 表(商品-维度关联) private void createProductDimensionMappingTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_dimension_mapping (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," + "product_id BIGINT NOT NULL COMMENT '商品ID(products.id)'," + "dimension_code VARCHAR(32) NOT NULL COMMENT '维度编码: body/wisdom/mind/action/wealth'," + "match_score INT DEFAULT 100 COMMENT '匹配度 0-100'," + "match_reason VARCHAR(200) COMMENT '匹配原因说明'," + "tags VARCHAR(500) COMMENT '推荐标签JSON'," + "enabled TINYINT DEFAULT 1 COMMENT '启用状态 1=启用 0=禁用'," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," + "INDEX idx_product_id (product_id)," + "INDEX idx_dimension (dimension_code)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品维度关联表'"); log.info("迁移345: 已创建 product_dimension_mapping 表"); } catch (Exception e) { log.warn("迁移345: 创建 product_dimension_mapping 表失败: {}", e.getMessage()); } } // 迁移346: 创建 product_recommendation_log 表(推荐曝光日志) private void createProductRecommendationLogTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_recommendation_log (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," + "user_id BIGINT DEFAULT NULL COMMENT '用户ID(users.id)'," + "family_id BIGINT DEFAULT NULL COMMENT '家庭ID(family_members.family_id)'," + "product_id BIGINT NOT NULL COMMENT '商品ID'," + "scene VARCHAR(32) NOT NULL COMMENT '场景: dimension_page/ai_chat/report_upload/repurchase'," + "reason VARCHAR(200) COMMENT '推荐理由'," + "match_score INT DEFAULT NULL COMMENT '匹配分'," + "was_clicked TINYINT DEFAULT 0 COMMENT '是否点击 1=是 0=否'," + "was_purchased TINYINT DEFAULT 0 COMMENT '是否购买 1=是 0=否'," + "clicked_at DATETIME DEFAULT NULL COMMENT '点击时间'," + "purchased_at DATETIME DEFAULT NULL COMMENT '购买时间'," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," + "INDEX idx_user_scene (user_id, scene)," + "INDEX idx_product_purchased (product_id, was_purchased)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推荐曝光日志表'"); log.info("迁移346: 已创建 product_recommendation_log 表"); } catch (Exception e) { log.warn("迁移346: 创建 product_recommendation_log 表失败: {}", e.getMessage()); } } // 迁移347: 创建 repurchase_reminder_record 和 repurchase_reminder_config 表 private void createRepurchaseReminderTables() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS repurchase_reminder_record (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," + "user_id BIGINT NOT NULL COMMENT '用户ID(users.id)'," + "product_id BIGINT NOT NULL COMMENT '商品ID'," + "order_id BIGINT DEFAULT NULL COMMENT '关联订单ID'," + "reminder_days INT DEFAULT 30 COMMENT '提醒天数'," + "sent_at DATETIME DEFAULT NULL COMMENT '发送时间'," + "clicked TINYINT DEFAULT 0 COMMENT '是否点击 1=是 0=否'," + "purchased TINYINT DEFAULT 0 COMMENT '是否购买 1=是 0=否'," + "INDEX idx_user_pending (user_id, purchased)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='复购提醒记录表'"); log.info("迁移347a: 已创建 repurchase_reminder_record 表"); } catch (Exception e) { log.warn("迁移347a: 创建 repurchase_reminder_record 表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS repurchase_reminder_config (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," + "product_category VARCHAR(100) DEFAULT NULL COMMENT '商品类目'," + "product_id BIGINT DEFAULT NULL COMMENT '特定商品ID(优先于category)'," + "reminder_days INT DEFAULT 30 COMMENT '提醒天数'," + "reminder_template VARCHAR(500) DEFAULT NULL COMMENT '提醒话术模板'," + "max_reminders INT DEFAULT 3 COMMENT '最大提醒次数'," + "enabled TINYINT DEFAULT 1 COMMENT '启用状态 1=启用 0=禁用'," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='复购提醒配置表'"); log.info("迁移347b: 已创建 repurchase_reminder_config 表"); } catch (Exception e) { log.warn("迁移347b: 创建 repurchase_reminder_config 表失败: {}", e.getMessage()); } } // 迁移348: 创建 recommendation_product_associations 表(商品推荐关联关系) private void createRecommendationProductAssociationsTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS recommendation_product_associations (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," + "source_product_id BIGINT NOT NULL COMMENT '源商品ID(已购/关注商品)'," + "target_product_id BIGINT NOT NULL COMMENT '目标商品ID(被推荐商品)'," + "association_type VARCHAR(32) NOT NULL COMMENT '类型: co_purchase/repurchase/profile_gap/manual'," + "strength DECIMAL(5,2) DEFAULT 0.50 COMMENT '关联强度 0.00-1.00'," + "manual_override TINYINT DEFAULT 0 COMMENT '是否运营手动覆盖 1=是 0=否'," + "reason VARCHAR(500) DEFAULT NULL COMMENT '关联原因说明'," + "dimension_code VARCHAR(32) DEFAULT NULL COMMENT '关联维度(body/mind/wisdom/action/wealth)'," + "repurchase_days INT DEFAULT NULL COMMENT '复购周期天数(repurchase场景)'," + "co_purchase_window_days INT DEFAULT 30 COMMENT '共购时间窗口天数(co_purchase场景)'," + "co_purchase_count INT DEFAULT 0 COMMENT '共购次数(co_purchase场景自动累计)'," + "enabled TINYINT DEFAULT 1 COMMENT '启用状态 1=启用 0=禁用'," + "created_by BIGINT DEFAULT NULL COMMENT '创建人用户ID(manual场景记录运营)'," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," + "INDEX idx_source_target (source_product_id, target_product_id)," + "INDEX idx_type_enabled (association_type, enabled)," + "INDEX idx_dimension (dimension_code)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品推荐关联关系表'"); log.info("迁移348: 已创建 recommendation_product_associations 表"); } catch (Exception e) { log.warn("迁移348: 创建 recommendation_product_associations 表失败: {}", e.getMessage()); } } // 迁移349: product_recommendation_log 表添加 AB测试分组与推荐来源类型列 private void addAbTestColumnsToRecommendationLog() { ensureColumn("product_recommendation_log", "ab_test_group", "VARCHAR(32) DEFAULT NULL COMMENT 'AB测试分组(A/B/C/控制组)'"); ensureColumn("product_recommendation_log", "recommendation_type", "VARCHAR(32) DEFAULT NULL COMMENT '推荐来源类型:dimension/co_purchase/repurchase/profile_gap/manual'"); log.info("迁移349: product_recommendation_log 已添加 ab_test_group / recommendation_type 列"); } // 迁移349: 创建 report_transfer_requests 表(健康报告跨家庭转移申请) // 同一份报告(file_hash 相同)被其他家庭上传时,申请方家庭发起转移申请, // 原归属家庭同意后 health_reports.family_id 转移至申请方,原家庭不再可见。 private void createReportTransferRequestsTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_transfer_requests (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," + "report_id BIGINT NOT NULL COMMENT '报告ID(health_reports.id)'," + "from_family_id BIGINT NOT NULL COMMENT '原归属家庭ID(需同意方)'," + "to_family_id BIGINT NOT NULL COMMENT '申请转入家庭ID(申请方)'," + "requester_user_id BIGINT NOT NULL COMMENT '申请人用户ID(users.id)'," + "status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '状态: pending=待同意 approved=已同意 denied=已拒绝'," + "handled_by BIGINT DEFAULT NULL COMMENT '处理人用户ID'," + "handled_at DATETIME DEFAULT NULL COMMENT '处理时间'," + "remark VARCHAR(200) DEFAULT NULL COMMENT '备注(拒绝原因)'," + "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," + "updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," + "INDEX idx_report_id (report_id)," + "INDEX idx_from_family_status (from_family_id, status)," + "INDEX idx_to_family_status (to_family_id, status)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康报告跨家庭转移申请'"); log.info("迁移349: 已创建 report_transfer_requests 表"); } catch (Exception e) { log.warn("迁移349: 创建 report_transfer_requests 表失败: {}", e.getMessage()); } } // 迁移350: products 表添加 min_discount_level 列(商品粒度控制会员价门槛,NULL=所有会员均可享会员价) private void addProductMinDiscountLevel() { try { jdbcTemplate.execute("ALTER TABLE products ADD COLUMN min_discount_level VARCHAR(50) DEFAULT NULL " + "COMMENT '享受会员价所需的最低会员级别编码(FREE/FAMILY/PREMIUM/LIFETIME,NULL=所有会员均可)' AFTER min_visible_level"); log.info("迁移350: 已添加 products.min_discount_level 列"); } catch (Exception e) { // 列已存在,忽略 log.debug("迁移350: products.min_discount_level 列已存在,跳过"); } } // 迁移351: membership_levels.level_order 顺延,为 BASIC 让出独立档位 // 会员级别包含关系(数字越大级别越高): FREE=1 BASIC=2 FAMILY=3 PREMIUM=4 LIFETIME=5 // PROVIDER 为服务商独立角色(不走会员权益),与 FREE 同级=1;最终值由迁移381 fixMembershipLevelOrder 兜底 private void backfillMembershipLevelOrder() { try { int updated = jdbcTemplate.update("UPDATE membership_levels SET level_order = CASE level_code " + "WHEN 'FREE' THEN 1 WHEN 'BASIC' THEN 2 WHEN 'FAMILY' THEN 3 WHEN 'PROVIDER' THEN 1 " + "WHEN 'PREMIUM' THEN 4 WHEN 'LIFETIME' THEN 5 ELSE level_order END"); log.info("迁移351: 已回填 membership_levels.level_order({} 行):FREE=1 BASIC=2 FAMILY=3 PREMIUM=4 LIFETIME=5; PROVIDER=1(非会员)", updated); } catch (Exception e) { log.warn("迁移351: 回填 membership_levels.level_order 失败: {}", e.getMessage()); } } // 迁移381: 修正 membership_levels.level_order(迁移351/360 执行顺序导致的 FAMILY 被重置为 2 的问题; // 同时 PROVIDER 为服务商独立角色,不走会员权益,降至与 FREE 同级,避免其参与 getMemberLevel / resolveEffectiveLevel 的等级比较) // level_order 语义:数字越大级别越高; FREE=1 BASIC=2 FAMILY=3 PREMIUM=4 LIFETIME=5; PROVIDER=1(非会员) private void fixMembershipLevelOrder() { try { int updated = jdbcTemplate.update("UPDATE membership_levels SET level_order = CASE level_code " + "WHEN 'FREE' THEN 1 WHEN 'BASIC' THEN 2 WHEN 'FAMILY' THEN 3 " + "WHEN 'PREMIUM' THEN 4 WHEN 'LIFETIME' THEN 5 WHEN 'PROVIDER' THEN 1 ELSE level_order END " + "WHERE level_code IN ('FREE','BASIC','FAMILY','PREMIUM','LIFETIME','PROVIDER')"); log.info("迁移381: 已修正 membership_levels.level_order({} 行):" + "FREE=1 BASIC=2 FAMILY=3 PREMIUM=4 LIFETIME=5 PROVIDER=1(非会员)", updated); } catch (Exception e) { log.warn("迁移381: 修正 membership_levels.level_order 失败: {}", e.getMessage()); } } /** * 迁移360: 恢复 9.9 元/月 BASIC 体验会员等级与权益(幂等)。 * 反向还原提交 f86dd164b 删除的内容;level_order 由 fixMembershipLevelOrder(迁移381)统一回填。 */ private void restoreBasicMembershipLevel() { try { jdbcTemplate.execute( "INSERT IGNORE INTO membership_levels " + "(level_code, level_name, level_desc, price_monthly, features, max_children, max_tasks_per_day) " + "VALUES ('BASIC', '体验会员', " + "'9.9元/月体验版,解锁核心功能,限次使用AI与报告解读', " + "990, " + "'[\"free_activities\",\"free_courses\",\"discount_purchase\",\"invite_family\",\"mascot_ai_lite\",\"ai_report_reading_lite\",\"assessment_quota_lite\",\"health_report_upload_lite\",\"growth_plan_lite\",\"task_template_lite\",\"referral_commission\"]', " + "3, 3)"); log.info("迁移360: 已恢复 BASIC 体验会员等级"); } catch (Exception e) { log.warn("迁移360: 恢复 BASIC 会员等级失败: {}", e.getMessage()); } // 兼容历史数据:早期 BASIC features 缺少 referral_commission,补齐 try { jdbcTemplate.update("UPDATE membership_levels SET features = ? WHERE level_code = 'BASIC' AND features NOT LIKE '%referral_commission%'", "[\"free_activities\",\"free_courses\",\"discount_purchase\",\"invite_family\",\"mascot_ai_lite\",\"ai_report_reading_lite\",\"assessment_quota_lite\",\"health_report_upload_lite\",\"growth_plan_lite\",\"task_template_lite\",\"referral_commission\"]"); } catch (Exception e) { log.warn("迁移360: 补齐 BASIC features 失败: {}", e.getMessage()); } try { jdbcTemplate.execute( "INSERT IGNORE INTO membership_benefits " + "(level_code, benefit_code, benefit_name, benefit_desc, icon, bg_color, sort_order) VALUES " + "('BASIC', 'AI_CHAT_LITE', 'AI健康顾问(限3次/月)', '每月3次AI健康顾问问答', '🤖', '#DBEAFE', 1), " + "('BASIC', 'REPORT_READING_LITE', '报告解读(限1次/月)', '每月1次体检/测评报告AI解读', '📊', '#FCE7F3', 4), " + "('BASIC', 'GROWTH_PLAN_LITE', '基础成长计划', '创建成长计划但模板有限', '📋', '#FEF3C7', 3), " + "('BASIC', 'ASSESSMENT_QUOTA_LITE', '测评预约(限1次)', '每月可预约1次测试', '📅', '#E0E7FF', 8)"); log.info("迁移360: 已恢复 BASIC 会员权益"); } catch (Exception e) { log.warn("迁移360: 恢复 BASIC 会员权益失败: {}", e.getMessage()); } } private void removeReportUploadQuota() { try { int deletedBenefits = jdbcTemplate.update( "DELETE FROM membership_benefits WHERE level_code = 'BASIC' AND benefit_code = 'REPORT_UPLOAD_LITE'"); log.info("迁移352: 已清理遗留权益记录({} 行)", deletedBenefits); } catch (Exception e) { log.warn("迁移352: 移除 REPORT_UPLOAD_LITE 权益失败: {}", e.getMessage()); } try { int deletedUsage = jdbcTemplate.update( "DELETE FROM basic_quota_usage WHERE quota_type = 'REPORT_UPLOAD'"); log.info("迁移352: 已清理 REPORT_UPLOAD 配额记录({} 行)", deletedUsage); } catch (Exception e) { log.warn("迁移352: 清理 REPORT_UPLOAD 配额记录失败: {}", e.getMessage()); } } /** * 迁移405: 9.9 元体验会员(BASIC)权益口径修正(2026-10-23 用户定稿,单一事实源)。 *
    *
  • AI 对话:每天 3 次(原"AI健康顾问"月配额口径,展示名同步改为"AI 对话");
  • *
  • 饮食拍照识别:每天 3 次;
  • *
  • 报告解读:保留每月 1 次;
  • *
  • 测评预约:不是特殊功能 → 移除 assessment_quota_lite 与 ASSESSMENT_QUOTA 配额(预约凭已购测评额度);
  • *
  • 报告上传:全员开放 → 移除 health_report_upload_lite;
  • *
  • 每日任务:不限 → max_tasks_per_day=0;
  • *
  • 指定商品会员价:discount_purchase 生效(products.member_price + min_discount_level 机制)。
  • *
* 配额周期粒度(日/月)由 BasicQuotaService.QUOTA_PERIODS 按类型判定,本迁移同步 features/权益/描述。 */ private void applyBasicMembership20261023Spec() { try { jdbcTemplate.update( "UPDATE membership_levels SET level_desc = ?, features = ?, max_tasks_per_day = 0 " + "WHERE level_code = 'BASIC'", "9.9元/月体验版:AI对话每天3次、饮食识别每天3次、报告解读每月1次,享指定商品会员价", "[\"free_activities\",\"free_courses\",\"discount_purchase\",\"invite_family\",\"mascot_ai_lite\",\"ai_report_reading_lite\",\"growth_plan_lite\",\"task_template_lite\",\"referral_commission\"]"); log.info("迁移405: 已更新 BASIC 等级口径(features/max_tasks_per_day/level_desc)"); } catch (Exception e) { log.warn("迁移405: 更新 BASIC 等级失败: {}", e.getMessage()); } try { jdbcTemplate.update( "UPDATE membership_benefits SET benefit_name = ?, benefit_desc = ? " + "WHERE level_code = 'BASIC' AND benefit_code = 'AI_CHAT_LITE'", "AI对话(限3次/天)", "每天3次AI对话问答"); jdbcTemplate.update( "DELETE FROM membership_benefits WHERE level_code = 'BASIC' AND benefit_code = 'ASSESSMENT_QUOTA_LITE'"); jdbcTemplate.execute( "INSERT IGNORE INTO membership_benefits (level_code, benefit_code, benefit_name, benefit_desc, icon, bg_color, sort_order) VALUES " + "('BASIC', 'FOOD_RECOGNIZE_LITE', '饮食拍照识别(限3次/天)', '每天3次食材图片识别', '📷', '#FEF9C3', 2), " + "('BASIC', 'MEMBER_PRICE', '指定商品会员价', '购买指定商品享受会员价', '🏷️', '#FEF3C7', 5)"); log.info("迁移405: 已更新 BASIC 权益列表(AI对话更名/去测评预约/增识别与会员价)"); } catch (Exception e) { log.warn("迁移405: 更新 BASIC 权益失败: {}", e.getMessage()); } // 配额记录切新制度:ASSESSMENT_QUOTA 整体移除;AI_CHAT/FOOD_RECOGNIZE 月记录改按日懒重建(删除旧记录避免月初日/月周期重合碰撞) try { int deleted = jdbcTemplate.update( "DELETE FROM basic_quota_usage WHERE quota_type IN ('ASSESSMENT_QUOTA','AI_CHAT','FOOD_RECOGNIZE')"); log.info("迁移405: 已清理旧口径配额记录({} 行)", deleted); } catch (Exception e) { log.warn("迁移405: 清理配额记录失败: {}", e.getMessage()); } } // 迁移353: 任务提醒配置表(用户级,一行/用户;提醒渠道/时机/偏移分钟可配置) private void createTaskReminderConfigTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS task_reminder_config (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," + "user_id BIGINT NOT NULL COMMENT '用户ID(users.id),家长账号'," + "channels VARCHAR(100) DEFAULT 'wechat' COMMENT '提醒渠道(逗号分隔): in_app/wechat/sms/phone/email'," + "timing_mode VARCHAR(20) DEFAULT 'after_timeout' COMMENT '提醒时机: before_deadline=截止前提醒 after_timeout=超时后提醒'," + "offset_minutes INT DEFAULT 60 COMMENT '偏移分钟: before_deadline=截止前N分钟; after_timeout=超时后N分钟'," + "enabled TINYINT DEFAULT 1 COMMENT '是否启用 1=启用 0=停用'," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," + "UNIQUE KEY uk_user (user_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务提醒配置表'"); log.info("迁移353: 已创建 task_reminder_config 表"); try { ensureColumn("task_reminder_config", "completion_praise_enabled", "TINYINT DEFAULT 0 COMMENT '任务正常完成时是否发送表扬鼓励消息 1=启用 0=停用'"); ensureColumn("task_reminder_config", "completion_praise_channels", "VARCHAR(100) DEFAULT 'wechat,in_app' COMMENT '完成表扬渠道(逗号分隔)'"); } catch (Exception ex) { log.warn("迁移353补充列失败: {}", ex.getMessage()); } } catch (Exception e) { log.warn("迁移353: 创建 task_reminder_config 表失败: {}", e.getMessage()); } } // 迁移359: 跨小程序单点登录一次性票据表(cfc ↔ train 双向免登) // 详见 schema.sql。此处自愈建表,避免 schema.sql 建表语句因故静默失败导致登录报错。 private void createSsoTicketTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS sso_ticket (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," + "ticket VARCHAR(64) NOT NULL COMMENT '一次性票据(SecureRandom 生成)'," + "source_system VARCHAR(16) NOT NULL COMMENT '签发方系统: cfc / train'," + "target_system VARCHAR(16) NOT NULL COMMENT '目标系统: cfc / train'," + "source_user_id BIGINT NOT NULL COMMENT '签发方用户ID(cfc=users.id / train=train_user.id)'," + "cfc_user_id BIGINT NOT NULL COMMENT 'cfc 统一会员ID(users.id),跨系统换票依据'," + "used TINYINT NOT NULL DEFAULT 0 COMMENT '是否已使用: 0=未用 1=已用'," + "expire_at DATETIME NOT NULL COMMENT '过期时间'," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," + "UNIQUE KEY uk_ticket (ticket)," + "INDEX idx_expire (expire_at)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跨小程序单点登录一次性票据'"); log.info("迁移359: 已创建 sso_ticket 表"); } catch (Exception e) { log.warn("迁移359: 创建 sso_ticket 表失败: {}", e.getMessage()); } } // 迁移355: product_recommendation_log 补齐缺失列 // 旧库该表由更早版本的建表语句创建(CREATE TABLE IF NOT EXISTS 不会补列), // 缺 family_id/was_clicked/was_purchased/clicked_at/purchased_at/updated_at, // 导致 stats/ab-summary、stats/by-type 查询报 Unknown column 返回 500 private void addMissingColumnsToRecommendationLog() { ensureColumn("product_recommendation_log", "family_id", "BIGINT DEFAULT NULL COMMENT '家庭ID(family_members.family_id)'"); ensureColumn("product_recommendation_log", "was_clicked", "TINYINT DEFAULT 0 COMMENT '是否点击 1=是 0=否'"); ensureColumn("product_recommendation_log", "was_purchased", "TINYINT DEFAULT 0 COMMENT '是否购买 1=是 0=否'"); ensureColumn("product_recommendation_log", "clicked_at", "DATETIME DEFAULT NULL COMMENT '点击时间'"); ensureColumn("product_recommendation_log", "purchased_at", "DATETIME DEFAULT NULL COMMENT '购买时间'"); ensureColumn("product_recommendation_log", "updated_at", "DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'"); log.info("迁移355: product_recommendation_log 已补齐缺失列"); } // 迁移354: 报告采集指标项定义(report_type_registry.expected_indicators)+ 草稿采集校验结果列(health_report_drafts.collect_validation) // 每个报告类型定义「应采集指标项」清单(JSON 数组),ReportCollectService 采集完成后按此校验完整性,结果写入草稿 collect_validation。 // DAN 指标项按 测评数据_优化提取.csv 各报告类型列(填充率>=30%)总结;菌群指标项按 docs/参考资料 已解析报告的指标/区块总结。 private void registerCollectExpectedIndicators() { ensureColumn("report_type_registry", "expected_indicators", "TEXT NULL COMMENT '该报告类型应采集的指标项定义(JSON数组),用于数据采集完整性校验'"); ensureColumn("health_report_drafts", "collect_validation", "TEXT NULL COMMENT '采集指标校验结果(JSON: typeId/expected/matched/pass/missing)'"); // 确保注册表存在各 DAN 类型 + 北京菌群类型的行(parser_path 空 = 保持走 LangGraph 检测,不影响既有路由) ensureReportTypeRow("dan_a1", "dan", "素质测评A1", "核心认知发展评估报告:感知觉/注意力/记忆力/推理能力/空间能力/加工速度"); ensureReportTypeRow("dan_b2", "dan", "素质测评B2", "儿童行为评估报告:自我概念/行为问题"); ensureReportTypeRow("dan_b3", "dan", "素质测评B3", "学习能力评估报告:执行功能/学习动机/学习策略/自我效能感"); ensureReportTypeRow("dan_b4", "dan", "素质测评B4", "核心素养评估报告:认知能力/成长型思维/自我概念"); ensureReportTypeRow("dan_b5", "dan", "素质测评B5", "青春期发展评估报告:情绪调节/人际关系/睡眠/运动/网络依赖/学业压力"); ensureReportTypeRow("dan_b6", "dan", "素质测评B6", "职业发展评估报告:霍兰德兴趣/多元智能/职业价值观"); ensureReportTypeRow("dan_c1", "dan", "素质测评C1", "校园版核心素养评估报告:认知能力/大五人格/自我概念/学习动机"); ensureReportTypeRow("gut_flora_beijing", "gut_flora", "北京菌群检测", "北京肠道菌群检测报告(FMT移植前/后评估)"); // 种子各类型「应采集指标项」定义(每次启动覆盖,保证与代码一致) seedExpectedIndicator("dan_a1", jsonArray( "感知觉_pct", "感知觉_score", "注意力_pct", "注意力_score", "记忆力_pct", "记忆力_score", "推理能力_pct", "推理能力_score", "空间能力_pct", "空间能力_score", "加工速度_pct", "加工速度_score", "总分", "百分位")); seedExpectedIndicator("dan_a2", jsonArray( "BMI", "感知觉_pct", "记忆力_pct", "注意力_pct", "外倾性", "宜人性", "开放性", "责任心", "神经质", "情绪总分", "焦虑-安详", "抑郁-愉快", "无力感-掌控感", "自卑-自尊", "与同伴亲近", "与同伴信任", "与同伴沟通", "与母亲亲近", "与母亲信任", "与母亲沟通", "与父亲亲近", "与父亲信任", "与父亲沟通", "身高", "体重", "睡眠_小时", "运动_小时", "饮食_小时", "总分", "百分位")); seedExpectedIndicator("dan_b2", jsonArray( "自我概念_合群", "自我概念_幸福与满足", "自我概念_情绪状态", "自我概念_能力与学校", "自我概念_行为表现", "自我概念_躯体外貌", "行为_品行问题", "行为_学习问题", "行为_情绪问题", "行为_生活习惯", "行为_社交问题")); seedExpectedIndicator("dan_b3", jsonArray( "工作记忆_pct", "认知灵活性_pct", "抑制控制_pct", "表面动机", "深层动机", "学习自我调节", "表面方法与策略", "深层方法与策略", "自我效能感", "总分")); seedExpectedIndicator("dan_b4", jsonArray( "感知觉_pct", "感知觉_score", "注意力_pct", "注意力_score", "记忆力_pct", "记忆力_score", "推理能力_pct", "推理能力_score", "空间能力_pct", "空间能力_score", "加工速度_pct", "加工速度_score", "成长型思维", "自我概念_合群", "自我概念_幸福与满足", "自我概念_情绪状态", "自我概念_能力与学校", "自我概念_行为表现", "自我概念_躯体外貌", "自主性", "胜任感", "归属感", "总分", "百分位")); seedExpectedIndicator("dan_b5", jsonArray( "表达抑制", "认知重评", "人际_父亲关系", "人际_父亲关系_过度期待", "人际_父亲关系_价值冲突", "人际_父亲关系_控制压迫", "人际_父亲关系_情感疏离", "人际_父亲关系_双重标准", "人际_母亲关系", "人际_母亲关系_过度期待", "人际_母亲关系_价值冲突", "人际_母亲关系_控制压迫", "人际_母亲关系_情感疏离", "人际_母亲关系_双重标准", "人际_师生关系", "人际_师生关系_敌对型", "人际_师生关系_高控制型", "人际_师生关系_关系疏离型", "人际_师生关系_回避型", "人际_同伴关系", "人际_同伴关系_被排斥型", "人际_同伴关系_边缘型", "人际_同伴关系_攻击型", "人际_同伴关系_回避型", "人际_同伴关系_特殊因素型", "社交_表达影响", "社交_冲突解决", "社交_情感表达", "社交_情感支持", "社交_主动交往", "入睡时间_min", "睡眠_催眠药物", "睡眠_日间功能", "睡眠_时长", "睡眠_效率", "睡眠_障碍", "睡眠_主观质量", "网络依赖_戒断反应", "网络依赖_强迫使用", "网络依赖_时间管理", "网络依赖_总分", "学业压力_家庭期望", "学业压力_师生关系", "学业压力_同伴竞争", "学业压力_学业负担", "学业压力_自我期望", "运动_步行_频率", "运动_步行_时长", "运动_高强度_频率", "运动_高强度_时长", "运动_久坐_频率", "运动_久坐_时长", "运动_中等强度_频率", "运动_中等强度_时长", "总分", "百分位")); seedExpectedIndicator("dan_b6", jsonArray( "能力_内省", "能力_人际关系", "能力_音乐", "能力_自然", "兴趣_常规型", "兴趣_社会型", "兴趣_事业型", "兴趣_现实型", "兴趣_研究型", "兴趣_艺术型", "职业价值观")); seedExpectedIndicator("dan_c1", jsonArray( "BMI", "感知觉_pct", "感知觉_score", "注意力_pct", "注意力_score", "记忆力_pct", "记忆力_score", "推理能力_pct", "推理能力_score", "空间能力_pct", "空间能力_score", "加工速度_pct", "加工速度_score", "外倾性", "宜人性", "开放性", "责任心", "神经质", "情绪总分", "自我概念_合群", "自我概念_幸福与满足", "自我概念_情绪状态", "自我概念_能力与学校", "自我概念_行为表现", "自我概念_躯体外貌", "表面动机", "深层动机", "学习自我调节", "表面方法与策略", "深层方法与策略", "自我效能感", "自主性", "胜任感", "归属感", "与同伴亲近", "与同伴信任", "与同伴沟通", "与母亲亲近", "与母亲信任", "与母亲沟通", "与父亲亲近", "与父亲信任", "与父亲沟通", "身高", "体重", "睡眠时长", "运动时间", "总分", "百分位")); seedExpectedIndicator("gut_flora", jsonArray( "肠道预测年龄", "肠型", "肠道菌群平衡", "菌群多样性", "有益菌", "有害菌", "核心菌属", "健康总分", "菌群健康", "慢病控制", "营养均衡", "疾病风险评估", "主要营养评估", "氨基酸评估", "维生素评估", "微量元素评估", "主要消化道致病菌", "抗生素风险评估", "肠道屏障及代谢物", "短链脂肪酸", "神经递质及激素", "菌群检出详细列表", "个体化食物推荐表")); seedExpectedIndicator("gut_flora_beijing", jsonArray( "肠型", "肠道微生物健康指数", "肠道菌群平衡评估", "肠道黏膜屏障", "肠道菌群多样性", "肠道菌群表型评估", "肠道抗炎能力", "肠道免疫力", "肠道膳食纤维需求", "短链脂肪酸合成能力", "核心菌属综合评分", "有益菌综合评分", "有害菌综合评分", "疾病风险评估", "营养物质及营养素代谢评估", "抗生素风险评估", "毒性物质清除能力评估", "胃肠道感染病原体评估", "趣味肠菌评估", "菌群检出详细列表", "个体化食物推荐表")); } /** 注册报告类型行(INSERT IF 不存在,幂等;parser_path=null 保持走 LangGraph 检测) */ private void ensureReportTypeRow(String typeId, String family, String displayName, String description) { try { Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM report_type_registry WHERE type_id = ?", Integer.class, typeId); if (count == null || count == 0) { jdbcTemplate.update( "INSERT INTO report_type_registry (type_id, family, display_name, description, parser_path, " + "min_confidence, is_active, source, review_status, version, created_at, updated_at) " + "VALUES (?, ?, ?, ?, NULL, 30, 1, 'prebuilt', 'approved', '1.0', NOW(), NOW())", typeId, family, displayName, description); log.info("迁移354: 已注册报告类型 {}", typeId); } } catch (Exception e) { log.warn("迁移354: 注册报告类型 {} 失败: {}", typeId, e.getMessage()); } } /** 写入类型「应采集指标项」定义(仅当为空时写入,保留管理员编辑) */ void seedExpectedIndicator(String typeId, String expectedJson) { try { int updated = jdbcTemplate.update( "UPDATE report_type_registry SET expected_indicators = ?, updated_at = NOW() " + "WHERE type_id = ? AND (expected_indicators IS NULL OR expected_indicators = '')", expectedJson, typeId); if (updated == 0) { log.info("迁移354: 类型 {} 已有指标项定义,跳过种子", typeId); } else { log.info("迁移354: 已为类型 {} 写入指标项定义", typeId); } } catch (Exception e) { log.warn("迁移354: 为类型 {} 写入指标项定义失败: {}", typeId, e.getMessage()); } } /** 简易 JSON 字符串数组序列化(指标项均为中文/ASCII,无转义风险) */ private String jsonArray(String... items) { StringBuilder sb = new StringBuilder("["); for (int i = 0; i < items.length; i++) { if (i > 0) { sb.append(','); } sb.append('"').append(items[i]).append('"'); } return sb.append(']').toString(); } // 迁移359: 家庭共餐选中食材表(持久化共餐场景食材选择) private void createDietFamilySelectedIngredientsTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS diet_family_selected_ingredients (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," + "family_id BIGINT NOT NULL COMMENT '家庭ID'," + "food_id BIGINT NOT NULL COMMENT '食材ID'," + "food_name VARCHAR(100) COMMENT '食材名称'," + "score INT COMMENT '推荐分'," + "reason VARCHAR(255) COMMENT '推荐理由'," + "category VARCHAR(50) COMMENT '分类'," + "calories_per_100g INT COMMENT '每100克卡路里'," + "cooking_level TINYINT COMMENT '烹饪难度(1=快手,2=中等,3=耗时)'," + "nutrition_score INT COMMENT '营养密度分(0-100)'," + "balance_reason VARCHAR(255) COMMENT '均衡化推荐理由'," + "sort_order INT DEFAULT 0 COMMENT '排序'," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," + "UNIQUE KEY uk_family_food (family_id, food_id)," + "INDEX idx_family (family_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭共餐选中食材'"); log.info("迁移359: 已创建 diet_family_selected_ingredients 表"); } catch (Exception e) { log.warn("迁移359: 创建 diet_family_selected_ingredients 表失败: {}", e.getMessage()); } } // 迁移356: indicator_definitions 升级为统一指标层(指标变化曲线/速查/关联 需求) private void migrateIndicatorDefinitionsColumns() { ensureColumn("indicator_definitions", "source_kind", "VARCHAR(32) DEFAULT NULL COMMENT '指标来源: report_indicator/bacteria/food_index/dimension_score'"); ensureColumn("indicator_definitions", "report_type_scope", "VARCHAR(32) DEFAULT NULL COMMENT '适用报告类型(type_id,空=通用)'"); ensureColumn("indicator_definitions", "value_type", "VARCHAR(16) DEFAULT NULL COMMENT '语义类型: numeric/categorical/level'"); ensureColumn("indicator_definitions", "good_direction", "VARCHAR(16) DEFAULT 'neutral' COMMENT '变化优劣方向: higher_is_better/lower_is_better/in_range_is_better/neutral'"); ensureColumn("indicator_definitions", "dimension_code", "VARCHAR(16) DEFAULT NULL COMMENT '关联五维(body/mind/wisdom/action/wealth,可空)'"); log.info("迁移356: indicator_definitions 已补齐统一指标层 5 列"); } // 迁移357: indicator_values 升级为统一观测时序(subject_id/family_id/report_id/report_date/unit/status) private void migrateIndicatorValuesColumns() { ensureColumn("indicator_values", "subject_id", "BIGINT DEFAULT NULL COMMENT '观测对象(family_members.id)'"); ensureColumn("indicator_values", "family_id", "BIGINT DEFAULT NULL COMMENT '所属家庭(family_members.family_id)'"); ensureColumn("indicator_values", "report_id", "BIGINT DEFAULT NULL COMMENT '来源报告(health_reports.id,食物指数可空)'"); ensureColumn("indicator_values", "report_date", "DATE DEFAULT NULL COMMENT '冗余真实报告日期(时序查询键)'"); ensureColumn("indicator_values", "unit", "VARCHAR(32) DEFAULT NULL COMMENT '观测单位'"); ensureColumn("indicator_values", "status", "VARCHAR(32) DEFAULT NULL COMMENT '观测状态快照(正常/偏高/偏低等)'"); log.info("迁移357: indicator_values 已补齐统一观测时序 6 列"); } // 迁移358: indicator_values 幂等唯一键(source_type+source_id+definition_id)+ 时序查询索引 private void addIndicatorValuesKeys() { try { // 先清理极端重复(保留最小 id),避免建唯一键失败;正常数据本无重复 jdbcTemplate.execute( "DELETE v FROM indicator_values v " + "JOIN (SELECT MIN(id) keep_id, source_type, source_id, definition_id " + " FROM indicator_values " + " GROUP BY source_type, source_id, definition_id " + " HAVING COUNT(*) > 1) d " + " ON v.source_type = d.source_type AND v.source_id = d.source_id " + " AND v.definition_id = d.definition_id AND v.id <> d.keep_id"); log.info("迁移358: 已清理 indicator_values 重复行(如有)"); } catch (Exception e) { log.warn("迁移358: 清理 indicator_values 重复行失败(表可能为空): {}", e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE indicator_values ADD UNIQUE KEY uk_source_definition (source_type, source_id, definition_id)"); log.info("迁移358: 已添加 uk_source_definition 唯一键"); } catch (Exception e) { log.info("迁移358: uk_source_definition 已存在,跳过"); } try { jdbcTemplate.execute("ALTER TABLE indicator_values ADD INDEX idx_subject_def_date (subject_id, definition_id, report_date)"); log.info("迁移358: 已添加 idx_subject_def_date 索引"); } catch (Exception e) { log.info("迁移358: idx_subject_def_date 已存在,跳过"); } } // 迁移359: 新建 indicator_mapping_rules(指标归一化映射规则) private void createIndicatorMappingRulesTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS indicator_mapping_rules (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY," + "source_type VARCHAR(32) NOT NULL COMMENT '来源: gut_flora|physical_exam|dan_a2|food_recommend|five_dimension|health_dimension'," + "raw_name VARCHAR(200) NOT NULL COMMENT '报告中的原始名称(指标名/菌名/食材名)'," + "alias_text TEXT COMMENT '别名 JSON 数组,用于模糊匹配'," + "definition_id BIGINT NOT NULL COMMENT 'FK → indicator_definitions.id'," + "match_type VARCHAR(20) DEFAULT 'exact' COMMENT 'exact|fuzzy|ai_fallback|auto_created'," + "priority INT DEFAULT 0 COMMENT '多规则冲突时取最高'," + "status TINYINT DEFAULT 1," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," + "UNIQUE KEY uk_source_raw (source_type, raw_name)," + "INDEX idx_def (definition_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标归一化映射规则'"); log.info("迁移359: 已创建 indicator_mapping_rules 表"); } catch (Exception e) { log.warn("迁移359: 创建 indicator_mapping_rules 表失败: {}", e.getMessage()); } } // 迁移360: 新建 indicator_correlations(指标关联关系:人工定义 + 数据校准) private void createIndicatorCorrelationsTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS indicator_correlations (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY," + "definition_id_a BIGINT NOT NULL COMMENT 'code 字典序较小的指标'," + "definition_id_b BIGINT NOT NULL COMMENT 'code 字典序较大的指标'," + "relation_type VARCHAR(20) DEFAULT 'correlation' COMMENT 'correlation|influence|same_source'," + "direction VARCHAR(10) NOT NULL COMMENT 'positive|negative'," + "manual_strength DECIMAL(3,2) NOT NULL COMMENT '人工标定强度 0~1'," + "computed_strength DECIMAL(4,3) NULL COMMENT '统计计算相关系数(Spearman rho)'," + "method VARCHAR(20) DEFAULT 'spearman' COMMENT 'pearson|spearman'," + "sample_size INT DEFAULT 0 COMMENT '参与计算的聚合有效样本数'," + "confidence VARCHAR(10) COMMENT 'high(>=30)|medium(10~29)|low(<10)'," + "effective_strength DECIMAL(4,3) NOT NULL COMMENT '展示值: 样本>=30 时取 computed 否则取 manual'," + "manual_locked TINYINT DEFAULT 0 COMMENT '1=人工锁定,校准不覆盖'," + "cohort_scope VARCHAR(20) DEFAULT 'subject' COMMENT 'subject|family|population'," + "status VARCHAR(20) DEFAULT 'active' COMMENT 'active|inactive|pending'," + "computed_at DATETIME NULL COMMENT '最后校准时间'," + "created_by BIGINT NULL COMMENT '创建者'," + "description VARCHAR(500) NULL COMMENT '给用户看的解释'," + "source_note VARCHAR(255) NULL COMMENT '依据/出处'," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," + "UNIQUE KEY uk_pair (definition_id_a, definition_id_b)," + "INDEX idx_def_a (definition_id_a)," + "INDEX idx_def_b (definition_id_b)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标关联关系(人工定义+数据校准)'"); log.info("迁移360: 已创建 indicator_correlations 表"); } catch (Exception e) { log.warn("迁移360: 创建 indicator_correlations 表失败: {}", e.getMessage()); } } // 迁移361: health_indicators.collection_date 生产库兜底(schema.sql 已有该列,仅确保老库补齐) private void ensureIndicatorCollectionDate() { ensureColumn("health_indicators", "collection_date", "DATETIME COMMENT '指标采集日期(报告检测日期)'"); log.info("迁移361: health_indicators.collection_date 已确认存在"); } // 迁移362: 预置五维展示定义(dim.body 等,仅作展示标签,不写 indicator_values) private void seedDimensionDefinitions() { Object[][] dims = { {"dim.body", "身", "身体", "higher_is_better", "higher_is_better"}, {"dim.mind", "心", "心理", "higher_is_better", "higher_is_better"}, {"dim.wisdom", "智", "智慧", "higher_is_better", "higher_is_better"}, {"dim.action", "行", "行动", "higher_is_better", "higher_is_better"}, {"dim.wealth", "富", "财富", "higher_is_better", "higher_is_better"}, }; for (Object[] d : dims) { try { Integer exists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM indicator_definitions WHERE code = ?", Integer.class, (String) d[0]); if (exists == null || exists == 0) { jdbcTemplate.update( "INSERT INTO indicator_definitions (domain, category, code, name, data_type, unit, " + "source_kind, value_type, good_direction, dimension_code, sort_order, status) " + "VALUES ('physical', ?, ?, ?, 'number', '分', 'dimension_score', 'numeric', ?, ?, 0, 1)", d[1], d[0], d[2], d[4], d[3]); log.info("迁移362: 已预置维度定义 {}", d[0]); } } catch (Exception e) { log.warn("迁移362: 预置维度定义 {} 失败: {}", d[0], e.getMessage()); } } } // 迁移355: 创建减脂专题体重记录表 private void createFatWeightRecordTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_weight_record (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "member_id BIGINT NOT NULL, " + "weight DECIMAL(5,2) NOT NULL, " + "body_fat_percent DECIMAL(4,2) DEFAULT NULL, " + "waist_circumference DECIMAL(4,2) DEFAULT NULL, " + "measure_date DATE NOT NULL, " + "remark VARCHAR(255) DEFAULT NULL, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_fat_weight_member (member_id, measure_date)) " + "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-体重体脂记录'"); log.info("迁移355: 已创建fat_weight_record表"); } catch (Exception e) { log.warn("迁移355: 创建fat_weight_record表失败: {}", e.getMessage()); } } // 迁移356: 创建减脂专题饮食记录表 private void createFatDietRecordTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_diet_record (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "member_id BIGINT NOT NULL, " + "meal_type VARCHAR(20) NOT NULL, " + "food_name VARCHAR(255) NOT NULL, " + "food_weight DECIMAL(6,2) DEFAULT NULL, " + "calories INT DEFAULT 0, " + "protein DECIMAL(6,2) DEFAULT 0, " + "fat DECIMAL(6,2) DEFAULT 0, " + "carbs DECIMAL(6,2) DEFAULT 0, " + "eat_date DATE NOT NULL, " + "remark VARCHAR(255) DEFAULT NULL, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_fat_diet_member (member_id, eat_date)) " + "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-饮食记录'"); log.info("迁移356: 已创建fat_diet_record表"); } catch (Exception e) { log.warn("迁移356: 创建fat_diet_record表失败: {}", e.getMessage()); } } // 迁移357: 创建减脂专题运动记录表 private void createFatExerciseRecordTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_exercise_record (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "member_id BIGINT NOT NULL, " + "exercise_type VARCHAR(50) NOT NULL, " + "duration_min INT DEFAULT 0, " + "distance_km DECIMAL(5,2) DEFAULT NULL, " + "calories_burned INT DEFAULT 0, " + "exercise_date DATE NOT NULL, " + "remark VARCHAR(255) DEFAULT NULL, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_fat_exercise_member (member_id, exercise_date)) " + "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-运动记录'"); log.info("迁移357: 已创建fat_exercise_record表"); } catch (Exception e) { log.warn("迁移357: 创建fat_exercise_record表失败: {}", e.getMessage()); } } // 迁移358: 创建减脂专题目标表 private void createFatGoalTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_goal (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "member_id BIGINT NOT NULL, " + "target_weight DECIMAL(5,2) NOT NULL, " + "start_weight DECIMAL(5,2) NOT NULL, " + "target_body_fat DECIMAL(4,2) DEFAULT NULL, " + "target_date DATE DEFAULT NULL, " + "status VARCHAR(20) DEFAULT 'active', " + "start_date DATE NOT NULL, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_fat_goal_member (member_id)) " + "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-减脂目标'"); log.info("迁移358: 已创建fat_goal表"); } catch (Exception e) { log.warn("迁移358: 创建fat_goal表失败: {}", e.getMessage()); } } // 迁移384: products 表添加 vendor_visible 列(仅供应商体系内可见开关) private void addProductVendorVisibleColumn() { ensureColumn("products", "vendor_visible", "TINYINT(1) DEFAULT NULL COMMENT '是否仅供应商体系内用户可见(列表接口生效,直接分享详情页可访问)'"); } // 迁移367: products 表添加健康约束字段(基础疾病禁忌/过敏原/药物相互作用标记) private void addProductHealthConstraintColumns() { ensureColumn("products", "contraindicated_diseases", "VARCHAR(255) DEFAULT NULL COMMENT '禁忌基础疾病,逗号分隔(如\"慢性肾病,肝病\")'"); ensureColumn("products", "allergen_tags", "VARCHAR(255) DEFAULT NULL COMMENT '过敏原标签,逗号分隔(如\"花生,小麦\")'"); ensureColumn("products", "drug_interaction_flags", "TINYINT DEFAULT 0 COMMENT '是否命中药物-食物相互作用(1=是,需对照drug_interactions表)'"); } // 迁移368: 创建 drug_interactions 表(药物-食物禁忌对照表) private void createDrugInteractionsTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS drug_interactions (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "drug_class VARCHAR(50) NOT NULL COMMENT '药物类别(如CCB/他汀/抗凝药/抗生素/铁剂/甲状腺药/利尿剂/MAOI/头孢/降糖药)', " + "drug_specifics VARCHAR(100) NOT NULL COMMENT '具体药名(如硝苯地平/华法林)', " + "interacting_subst VARCHAR(100) NOT NULL COMMENT '禁忌/需间隔的食材或成分(如西柚/维生素K)', " + "risk_level TINYINT NOT NULL COMMENT '1=拦截 2=警告 3=需间隔', " + "action_type VARCHAR(20) NOT NULL COMMENT 'block=硬拦截 warn=软警告 space=间隔', " + "interval_minutes INT DEFAULT NULL COMMENT '需间隔时长(分钟)', " + "note VARCHAR(255) COMMENT '风险说明', " + "UNIQUE KEY uk_drug_food (drug_class, drug_specifics, interacting_subst), " + "INDEX idx_interacting (interacting_subst)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='药物-食物禁忌对照表'"); log.info("迁移368: 已创建drug_interactions表"); } catch (Exception e) { log.warn("迁移368: 创建drug_interactions表失败: {}", e.getMessage()); } } // 迁移369: 创建 disease_intake_limits 表(疾病-饮食摄入阈值) private void createDiseaseIntakeLimitsTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS disease_intake_limits (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "disease_type VARCHAR(50) NOT NULL COMMENT '疾病类型(慢性肾病/糖尿病/高血压/痛风/肝病/甲状腺疾病)', " + "nutrient VARCHAR(20) NOT NULL COMMENT '校验营养素(protein/potassium/phosphorus/sodium/carb/glycemic/fructose/purine/alcohol/iodine)', " + "unit VARCHAR(20) NOT NULL COMMENT '单位', " + "threshold_max DECIMAL(8,2) DEFAULT NULL COMMENT '每日上限', " + "threshold_min DECIMAL(8,2) DEFAULT NULL COMMENT '每日下限', " + "block_foods VARCHAR(500) DEFAULT NULL COMMENT '硬拦截食材列表,逗号分隔(如杨桃)', " + "stage_dependent TINYINT(1) DEFAULT 0 COMMENT '是否按病情分期(1=是 0=否)', " + "INDEX idx_disease_nutrient (disease_type, nutrient)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='疾病-饮食摄入校验阈值表'"); log.info("迁移369: 已创建disease_intake_limits表"); } catch (Exception e) { log.warn("迁移369: 创建disease_intake_limits表失败: {}", e.getMessage()); } } // 迁移370: 初始化 drug_interactions 种子数据(高危组合,幂等) private void seedDrugInteractionsData() { String[][] seed = { {"CCB", "硝苯地平", "西柚", "1", "block", "0", "西柚抑制药物代谢酶,血药浓度升高数倍,低血压风险,服药期间完全避免"}, {"CCB", "氨氯地平", "西柚", "1", "block", "0", "西柚抑制药物代谢酶,血药浓度升高数倍,低血压风险,服药期间完全避免"}, {"他汀类", "辛伐他汀", "西柚", "1", "block", "0", "增加肌肉损伤、肝损伤风险"}, {"他汀类", "阿托伐他汀", "西柚", "1", "block", "0", "增加肌肉损伤、肝损伤风险"}, {"抗凝药", "华法林", "维生素K", "2", "warn", "0", "维K拮抗药效,摄入量保持稳定,调整饮食结构前先与医生沟通"}, {"抗凝药", "华法林", "大蒜", "2", "warn", "0", "大蒜可能增强抗凝作用,增加出血风险"}, {"抗生素", "四环素", "牛奶", "3", "space", "120", "钙与药物结合影响吸收,需间隔2小时以上"}, {"抗生素", "喹诺酮类", "牛奶", "3", "space", "120", "钙与药物结合影响吸收,需间隔2小时以上"}, {"抗生素", "喹诺酮类", "钙剂", "3", "space", "120", "钙与药物结合影响吸收,需间隔2小时以上"}, {"铁剂", "硫酸亚铁", "茶", "3", "space", "60", "鞣酸抑制铁吸收,需间隔1-2小时"}, {"铁剂", "硫酸亚铁", "咖啡", "3", "space", "60", "鞣酸抑制铁吸收,需间隔1-2小时"}, {"铁剂", "硫酸亚铁", "钙剂", "3", "space", "60", "钙抑制铁吸收,需间隔1-2小时"}, {"铁剂", "硫酸亚铁", "牛奶", "3", "space", "60", "钙抑制铁吸收,需间隔1-2小时"}, {"甲状腺药", "左甲状腺素", "豆浆", "3", "space", "240", "影响吸收,需间隔4小时"}, {"甲状腺药", "左甲状腺素", "高纤维食物", "3", "space", "240", "影响吸收,需间隔4小时"}, {"甲状腺药", "左甲状腺素", "钙剂", "3", "space", "240", "影响吸收,需间隔4小时"}, {"保钾利尿剂", "螺内酯", "高钾食物", "2", "warn", "0", "高钾血症风险,避免香蕉、橙汁、低钠盐大量集中摄入"}, {"MAOI", "苯乙肼", "发酵食品", "2", "warn", "0", "酪胺反应可致血压骤升,避免奶酪/豆豉/腌制品/陈年食品"}, {"头孢类", "头孢类", "酒精", "1", "block", "0", "双硫仑样反应,严重可危及生命"}, {"甲硝唑", "甲硝唑", "酒精", "1", "block", "0", "双硫仑样反应,严重可危及生命"}, {"降糖药", "磺脲类", "酒精", "1", "block", "0", "低血糖风险,避免空腹饮酒"} }; for (String[] row : seed) { try { Integer cnt = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM drug_interactions WHERE drug_class=? AND drug_specifics=? AND interacting_subst=?", Integer.class, row[0], row[1], row[2]); if (cnt != null && cnt > 0) continue; jdbcTemplate.update( "INSERT INTO drug_interactions (drug_class, drug_specifics, interacting_subst, risk_level, action_type, interval_minutes, note) VALUES (?,?,?,?,?,?,?)", row[0], row[1], row[2], Integer.valueOf(row[3]), row[4], Integer.valueOf(row[5]), row[6]); } catch (Exception e) { log.warn("迁移370: 插入drug_interactions种子失败: {}", e.getMessage()); } } log.info("迁移370: drug_interactions种子数据初始化完成"); } // 迁移371: 初始化 disease_intake_limits 种子数据(幂等) private void seedDiseaseIntakeLimitsData() { String[][] seed = { {"慢性肾病", "protein", "g/kg/day", "0.8", "0.6", "杨桃", "1"}, {"慢性肾病", "potassium", "mmol/L/day", "5.0", "3.5", "杨桃,香蕉,橙子,土豆,菠菜,菌菇", "1"}, {"慢性肾病", "phosphorus", "mg/day", "800", "600", "杨桃,奶制品,坚果,加工食品", "1"}, {"糖尿病", "carb", "g/meal", "75", "30", "", "0"}, {"糖尿病", "fructose", "g/day", "50", "0", "含糖饮料,果汁,蜂蜜,甜点", "0"}, {"高血压", "sodium", "g/day", "5", "2", "腌菜,酱菜,酱油,加工肉制品,挂面,咸味零食", "0"}, {"痛风", "purine", "mg/day", "300", "150", "动物内脏,部分海鲜,浓肉汤", "0"}, {"痛风", "fructose", "g/day", "50", "0", "含糖饮料,蜂蜜", "0"}, {"肝病", "protein", "g/kg/day", "1.0", "0.5", "", "1"}, {"甲状腺疾病", "iodine", "ug/day", "300", "150", "海带,紫菜", "0"} }; for (String[] row : seed) { try { Integer cnt = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM disease_intake_limits WHERE disease_type=? AND nutrient=?", Integer.class, row[0], row[1]); if (cnt != null && cnt > 0) continue; jdbcTemplate.update( "INSERT INTO disease_intake_limits (disease_type, nutrient, unit, threshold_max, threshold_min, block_foods, stage_dependent) VALUES (?,?,?,?,?,?,?)", row[0], row[1], row[2], row[3], row[4], row[5], Integer.valueOf(row[6])); } catch (Exception e) { log.warn("迁移371: 插入disease_intake_limits种子失败: {}", e.getMessage()); } } log.info("迁移371: disease_intake_limits种子数据初始化完成"); } // 迁移363: 创建采购任务表(商品组合推荐与采购任务生命周期管理) private void createProcurementTaskTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS procurement_tasks (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "task_id VARCHAR(64) NOT NULL COMMENT '任务唯一标识', " + "user_id BIGINT NOT NULL COMMENT '所属用户ID', " + "family_member_id BIGINT NOT NULL COMMENT '所属家庭成员ID', " + "combo_tier VARCHAR(20) NOT NULL COMMENT '选择的套餐等级: high|medium|low', " + "original_combo_tier VARCHAR(20) DEFAULT NULL COMMENT '原始套餐等级(用于自定义追踪)', " + "selected_product_sku_ids TEXT COMMENT '最终选中的产品SKU列表(JSON数组)', " + "custom_addition_sku_ids TEXT COMMENT '用户自新增的产品SKU列表(JSON数组)', " + "custom_removal_sku_ids TEXT COMMENT '用户移除的产品SKU列表(JSON数组)', " + "status VARCHAR(32) NOT NULL COMMENT '任务状态: SELECTED|PROCURING|PROCURED|EXECUTING|COMPLETED|CANCELLED', " + "create_time DATETIME NOT NULL COMMENT '创建时间', " + "complete_time DATETIME DEFAULT NULL COMMENT '完成时间', " + "message_reminder_sent TINYINT DEFAULT 0 COMMENT '是否已发送消息提醒', " + "execution_task_id VARCHAR(64) DEFAULT NULL COMMENT '关联的执行任务ID', " + "update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_task_id (task_id), " + "INDEX idx_user_id (user_id), " + "INDEX idx_family_member_id (family_member_id), " + "INDEX idx_status (status), " + "INDEX idx_create_time (create_time)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购任务表(商品组合推荐与采购任务生命周期管理)'"); log.info("已创建procurement_tasks表"); } catch (Exception e) { log.warn("创建procurement_tasks表失败: {}", e.getMessage()); } } // 迁移364: health_report_drafts 添加解析进度/重试字段(INFORMATION_SCHEMA 检查后再 ALTER,幂等) private void addDraftParseProgressColumns() { if (!columnExists("health_report_drafts", "parse_attempt")) { jdbcTemplate.execute("ALTER TABLE health_report_drafts ADD COLUMN parse_attempt INT DEFAULT 0 COMMENT '已知类型解析重试次数'"); log.info("已添加 parse_attempt 列到 health_report_drafts 表"); } if (!columnExists("health_report_drafts", "next_retry_at")) { jdbcTemplate.execute("ALTER TABLE health_report_drafts ADD COLUMN next_retry_at DATETIME NULL COMMENT '下次自动重试时间(指数退避,上限5分钟)'"); log.info("已添加 next_retry_at 列到 health_report_drafts 表"); } if (!columnExists("health_report_drafts", "parse_started_at")) { jdbcTemplate.execute("ALTER TABLE health_report_drafts ADD COLUMN parse_started_at DATETIME NULL COMMENT '最近一次解析开始时间'"); log.info("已添加 parse_started_at 列到 health_report_drafts 表"); } if (!columnExists("health_report_drafts", "parse_finished_at")) { jdbcTemplate.execute("ALTER TABLE health_report_drafts ADD COLUMN parse_finished_at DATETIME NULL COMMENT '最近一次解析结束时间(成功/失败)'"); log.info("已添加 parse_finished_at 列到 health_report_drafts 表"); } } // 迁移374: 创建 user_content_weights 表(用户内容偏好权重表) /** * 迁移378: 创建 member_indicator_current 表 * 成员维度「当前指标 + 相对上次变化」: * - 报告确认入库后,取该成员最新一份报告 vs 次新报告的同名指标,计算 delta * - 方案生成(LangGraph)与前端指标卡片可直接读取,无需每次跨表 diff * 幂等:CREATE TABLE IF NOT EXISTS */ private void addMemberIndicatorCurrentTable() { try { jdbcTemplate.execute( "CREATE TABLE IF NOT EXISTS `member_indicator_current` (" + "`id` bigint NOT NULL AUTO_INCREMENT," + "`member_id` bigint NOT NULL COMMENT '家庭成员ID(family_members.id)'," + "`definition_id` bigint NOT NULL COMMENT '指标定义ID(indicator_definitions.id)'," + "`domain` varchar(50) NOT NULL DEFAULT 'physical' COMMENT '指标域(body/mind/physical/wisdom)'," + "`category` varchar(50) DEFAULT NULL COMMENT '指标分类'," + "`name` varchar(100) NOT NULL COMMENT '指标名称'," + "`value` varchar(200) DEFAULT NULL COMMENT '最新值'," + "`numeric_value` decimal(12,4) DEFAULT NULL COMMENT '最新数值(可计算)'," + "`previous_value` varchar(200) DEFAULT NULL COMMENT '上一份报告的同类指标值'," + "`previous_numeric` decimal(12,4) DEFAULT NULL COMMENT '上一份报告数值'," + "`delta` decimal(12,4) DEFAULT NULL COMMENT 'numeric_value - previous_numeric'," + "`trend` varchar(16) DEFAULT NULL COMMENT '变化方向: up/down/flat/new/na'," + "`latest_report_id` bigint DEFAULT NULL COMMENT '最新来源报告ID'," + "`previous_report_id` bigint DEFAULT NULL COMMENT '上次来源报告ID'," + "`report_date` date DEFAULT NULL COMMENT '最新报告日期'," + "`recorded_at` datetime DEFAULT CURRENT_TIMESTAMP," + "`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," + "PRIMARY KEY (`id`)," + "UNIQUE KEY `uk_member_definition` (`member_id`, `definition_id`)," + "KEY `idx_member_domain` (`member_id`, `domain`)," + "KEY `idx_report` (`latest_report_id`)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成员指标当前值+相对上次变化'"); log.info("迁移378: member_indicator_current 表就绪"); } catch (Exception e) { log.warn("迁移378: 创建 member_indicator_current 表失败或已存在: {}", e.getMessage()); } } private void addUserContentWeightsTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_content_weights (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," + "user_id BIGINT NOT NULL COMMENT '用户ID(users.id)'," + "family_id BIGINT NOT NULL COMMENT '家庭ID'," + "content_type VARCHAR(16) NOT NULL COMMENT '内容类型: product/activity/article'," + "item_id BIGINT NOT NULL COMMENT '内容ID(商品ID/活动ID/文章ID)'," + "dimension_code VARCHAR(32) DEFAULT NULL COMMENT '维度编码: body/mind/wisdom/action/wealth'," + "weight DECIMAL(5,2) NOT NULL DEFAULT 1.00 COMMENT '权重值 0.00-10.00,越大越优先'," + "reason VARCHAR(200) DEFAULT NULL COMMENT '权重来源: user_set/auto_click/auto_purchase/manual/auto_profile'," + "created_by BIGINT DEFAULT NULL COMMENT '创建人ID(manual场景记录运营人员)'," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," + "INDEX idx_user_content (user_id, content_type, item_id)," + "INDEX idx_family_dimension (family_id, dimension_code)," + "UNIQUE KEY uk_user_item (user_id, content_type, item_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户内容偏好权重表'"); log.info("迁移374: 已创建/确认 user_content_weights 表"); } catch (Exception e) { log.warn("迁移374: 创建 user_content_weights 表失败: {}", e.getMessage()); } } // 迁移375: 修复 article_quiz_records 表结构(旧表由迁移299创建时已存在导致缺列) private void fixArticleQuizRecordsTable() { try { if (!columnExists("article_quiz_records", "member_id")) { jdbcTemplate.execute("ALTER TABLE article_quiz_records ADD COLUMN member_id BIGINT DEFAULT NULL COMMENT '家庭成员ID(作答人)'"); log.info("迁移375: 已添加 member_id 列到 article_quiz_records"); } if (!columnExists("article_quiz_records", "total_questions")) { jdbcTemplate.execute("ALTER TABLE article_quiz_records ADD COLUMN total_questions INT DEFAULT 0 COMMENT '题目总数'"); log.info("迁移375: 已添加 total_questions 列到 article_quiz_records"); } if (!columnExists("article_quiz_records", "cf_earned")) { jdbcTemplate.execute("ALTER TABLE article_quiz_records ADD COLUMN cf_earned INT DEFAULT 0 COMMENT '发放的CF值'"); log.info("迁移375: 已添加 cf_earned 列到 article_quiz_records"); } if (!columnExists("article_quiz_records", "energy_earned")) { jdbcTemplate.execute("ALTER TABLE article_quiz_records ADD COLUMN energy_earned INT DEFAULT 0 COMMENT '发放的能量值'"); log.info("迁移375: 已添加 energy_earned 列到 article_quiz_records"); } // questions/answers 旧表为 VARCHAR(255),实体存 JSON 快照会超长,与 schema.sql 对齐为 TEXT if (!columnTypeStartsWith("article_quiz_records", "questions", "text")) { jdbcTemplate.execute("ALTER TABLE article_quiz_records MODIFY COLUMN questions TEXT COMMENT '题目快照JSON(含正确答案与维度)'"); log.info("迁移375: questions 列已改为 TEXT"); } if (!columnTypeStartsWith("article_quiz_records", "answers", "text")) { jdbcTemplate.execute("ALTER TABLE article_quiz_records MODIFY COLUMN answers TEXT COMMENT '用户作答JSON(含每题所选选项),作答前为NULL'"); log.info("迁移375: answers 列已改为 TEXT"); } // 补充索引(幂等;重复创建会报 Duplicate key name,捕获忽略) try { jdbcTemplate.execute("ALTER TABLE article_quiz_records ADD INDEX idx_member (member_id)"); } catch (Exception ignored) { } log.info("迁移375: article_quiz_records 表结构修复完成"); } catch (Exception e) { log.warn("迁移375: 修复 article_quiz_records 表结构失败: {}", e.getMessage()); } } private boolean columnTypeStartsWith(String table, String column, String typePrefix) { try { String t = jdbcTemplate.queryForObject( "SELECT DATA_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?", String.class, table, column); return t != null && t.toLowerCase().startsWith(typePrefix); } catch (Exception e) { return false; } } // 迁移372: nutrition_product 添加 efficacy_tags 列(成分功效标签) private void addNutritionProductEfficacyTags() { if (!columnExists("nutrition_product", "efficacy_tags")) { jdbcTemplate.execute("ALTER TABLE nutrition_product ADD COLUMN efficacy_tags TEXT COMMENT '成分功效标签JSON数组(如 [\"补钙\",\"补锌\",\"益生菌\",\"维生素D\"])'"); log.info("已添加 efficacy_tags 列到 nutrition_product 表"); } } // 迁移373: products 添加 tags 列(文章-商品标签关联推荐) private void addProductTagsColumn() { ensureColumn("products", "tags", "VARCHAR(500) DEFAULT '' COMMENT '商品标签 JSON 数组,用于文章-商品标签匹配推荐'"); log.info("迁移373: products.tags 已确认存在"); } // 迁移379: 创建 report_member_permissions 表(报告成员级查看权限) private void addReportMemberPermissionsTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_member_permissions (" + "id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID'," + "family_id BIGINT NOT NULL COMMENT '家庭ID(隔离作用域)'," + "authorizer_user_id BIGINT NOT NULL COMMENT '授权人(家庭管理员)的用户ID'," + "target_member_id BIGINT NOT NULL COMMENT '被授权查看的成员ID(family_members.id)'," + "grantee_user_id BIGINT NOT NULL COMMENT '被授权用户的ID(能看报告的人)'," + "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '授权时间'," + "PRIMARY KEY (id)," + "UNIQUE KEY uk_perm (family_id, target_member_id, grantee_user_id)," + "KEY idx_target_member (target_member_id)," + "KEY idx_grantee_user (grantee_user_id)," + "KEY idx_family_id (family_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='报告成员级查看权限(管理员授权某用户查看某成员的全部报告)'"); log.info("迁移379: report_member_permissions 表就绪"); } catch (Exception e) { log.warn("迁移379: 创建 report_member_permissions 表失败或已存在: {}", e.getMessage()); } } // 迁移380: 修正 FAMILY 会员虚拟道具映射为 member1980(幂等;仅纠正历史错误值 member1314,不覆盖后台后续配置) private void fixFamilyMembershipVirtualGoodsMapping() { try { int updated = jdbcTemplate.update( "UPDATE virtual_goods_config SET product_id = 'member1980' " + "WHERE goods_type = 'MEMBERSHIP' AND biz_key = 'FAMILY' AND product_id = 'member1314'"); if (updated > 0) { log.info("迁移380: FAMILY 会员虚拟道具映射已修正为 member1980,受影响 {} 行", updated); } } catch (Exception e) { log.warn("迁移380: 修正 FAMILY 会员道具映射失败: {}", e.getMessage()); } } // 迁移381: 创建活动海报模板表 + 初始化默认模板(Web 管理端定义,小程序端海报模板选择) private void createActivityPosterTemplatesTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS activity_poster_templates (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," + "name VARCHAR(50) NOT NULL COMMENT '模板名称(管理端展示,如 暖橙渐变)'," + "template_key VARCHAR(30) NOT NULL COMMENT '模板类型: gradient/minimal/card 小程序绘制引擎'," + "main_color VARCHAR(20) DEFAULT '#F97316' COMMENT '主色(渐变起点/卡片顶栏)'," + "dark_color VARCHAR(20) DEFAULT '#EA580C' COMMENT '深色(渐变终点/深色模板背景)'," + "light_color VARCHAR(20) DEFAULT '#FFF7ED' COMMENT '浅色(内容卡背景)'," + "bg_colors VARCHAR(500) DEFAULT '[\"#F97316\"]' COMMENT '可选背景色板 JSON 数组'," + "qr_size INT DEFAULT 100 COMMENT '二维码尺寸(rpx)'," + "sort_order 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 '更新时间'," + "UNIQUE KEY uk_name (name)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动海报模板(Web管理端定义)'"); log.info("迁移381: 已创建/确认 activity_poster_templates 表"); } catch (Exception e) { log.warn("迁移381: 创建 activity_poster_templates 表失败: {}", e.getMessage()); } // 初始化默认模板(幂等:按名称 INSERT IGNORE,重复执行不会产生重复行) try { jdbcTemplate.execute("INSERT IGNORE INTO activity_poster_templates (name, template_key, main_color, dark_color, light_color, bg_colors, qr_size, sort_order, status) VALUES " + "('暖橙渐变', 'gradient', '#F97316', '#EA580C', '#FFF7ED', '[\"#F97316\",\"#EA580C\",\"#F59E0B\",\"#10B981\",\"#FF6B9D\",\"#6366F1\",\"#1E293B\",\"#FFFFFF\"]', 100, 0, 1), " + "('深色极简', 'minimal', '#F97316', '#1E293B', '#F3F4F6', '[\"#1E293B\",\"#0F172A\",\"#111827\"]', 100, 1, 1), " + "('白色卡片', 'card', '#F97316', '#EA580C', '#FFF7ED', '[\"#FFFFFF\",\"#FFF7ED\",\"#F8FAFC\"]', 100, 2, 1)"); log.info("迁移381: 默认海报模板种子数据已确认"); } catch (Exception e) { log.warn("迁移381: 初始化默认海报模板失败: {}", e.getMessage()); } } // 迁移385: 活动报名添加驳回原因列(审核中心详情页驳回时写入) private void addActivityRegistrationRejectReasonColumn() { try { if (!columnExists("activity_registrations", "reject_reason")) { jdbcTemplate.execute("ALTER TABLE activity_registrations ADD COLUMN reject_reason VARCHAR(500) NULL COMMENT '驳回原因'"); log.info("迁移385: 已添加 activity_registrations.reject_reason 列"); } } catch (Exception e) { log.warn("迁移385: 添加活动报名驳回原因列失败: {}", e.getMessage()); } } // 迁移386: 历史 teacher_status='pending' 占位假数据清理,并把默认值改为 NULL private void cleanPlannerPlaceholderData() { try { int cleaned = jdbcTemplate.update( "UPDATE users SET teacher_status = NULL WHERE teacher_status = 'pending' AND (role IS NULL OR role <> 'teacher')"); jdbcTemplate.execute("ALTER TABLE users MODIFY COLUMN teacher_status VARCHAR(20) DEFAULT NULL COMMENT '成长规划师审核状态'"); log.info("迁移386: 已清理规划师占位数据 {} 行,teacher_status 默认值改为 NULL", cleaned); } catch (Exception e) { log.warn("迁移386: 清理规划师占位数据失败: {}", e.getMessage()); } } // 迁移387: 下线独立“服务角色申请审核”菜单,服务角色申请按角色分流合并进审核中心 private void hideServiceRoleApplicationMenu() { try { int updated = jdbcTemplate.update( "UPDATE sys_menu SET visible = 0 WHERE path = '/service-role-applications'"); int removed = jdbcTemplate.update( "DELETE FROM sys_role_menu WHERE menu_id IN (SELECT id FROM sys_menu WHERE path = '/service-role-applications')"); log.info("迁移387: 服务角色申请菜单已下线(菜单 {} 行,授权 {} 行)", updated, removed); } catch (Exception e) { log.warn("迁移387: 下线服务角色申请菜单失败: {}", e.getMessage()); } } // 迁移394: 创建 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()); } } /** * 迁移402: delivery_method COMMENT 增加 6=同城配送(履约多渠道,规格 2026-10-22-fulfillment-multi-mode-design)。 * 仅 COMMENT 变更,无数据变更,幂等可重复执行(MODIFY COLUMN 天然幂等 + 双层 try-catch)。 */ private void addDeliveryMethod6Comments() { try { jdbcTemplate.execute("ALTER TABLE products MODIFY COLUMN delivery_method TINYINT DEFAULT 1 " + "COMMENT '配送方式: 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人 6=同城配送'"); log.info("迁移402: products.delivery_method COMMENT 已增加 6=同城配送"); } catch (Exception e) { // COMMENT 变更失败(如列不存在)不影响启动 log.warn("迁移402: products.delivery_method COMMENT 更新失败: {}", e.getMessage()); } try { jdbcTemplate.execute("ALTER TABLE product_orders MODIFY COLUMN delivery_method INT DEFAULT NULL " + "COMMENT '配送方式: 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人 6=同城配送'"); log.info("迁移402: product_orders.delivery_method COMMENT 已增加 6=同城配送"); } catch (Exception e) { // COMMENT 变更失败(如列不存在)不影响启动 log.warn("迁移402: product_orders.delivery_method COMMENT 更新失败: {}", e.getMessage()); } } // 迁移403: withdrawal_requests 增加 reject_reason 列(审核中心统一详情:提现驳回原因口径统一) private void addWithdrawalRejectReasonColumn() { try { if (!columnExists("withdrawal_requests", "reject_reason")) { jdbcTemplate.execute("ALTER TABLE withdrawal_requests ADD COLUMN reject_reason VARCHAR(500) NULL COMMENT '驳回原因'"); log.info("迁移403: 已添加 withdrawal_requests.reject_reason 列"); } } catch (Exception e) { log.warn("迁移403: 添加 withdrawal_requests.reject_reason 列失败: {}", e.getMessage()); } } // 迁移404: articles.audio_url 由 varchar(500) 改为 MEDIUMTEXT // 多段音频 JSON 数组(每段 OSS URL ~94 字符)在长文中可超 2900 字符, // 超出 varchar(500) 导致 DataIntegrityViolationException("数据不完整或格式错误") private void alterArticleAudioUrlToMediumtext() { try { jdbcTemplate.execute("ALTER TABLE articles MODIFY COLUMN audio_url MEDIUMTEXT NULL COMMENT '音频URL(单URL或JSON数组,多段)'"); log.info("迁移404: articles.audio_url 已改为 MEDIUMTEXT"); } catch (Exception e) { log.warn("迁移404: articles.audio_url 修改失败: {}", e.getMessage()); } } // 迁移406: activity_poster_templates 增加 layout_config 列(活动海报模板元素位置/样式可配置;空值=小程序端内置默认布局) private void addActivityPosterTemplateLayoutConfigColumn() { try { if (!columnExists("activity_poster_templates", "layout_config")) { jdbcTemplate.execute("ALTER TABLE activity_poster_templates " + "ADD COLUMN layout_config TEXT NULL COMMENT '布局配置JSON(元素位置/样式可配置,空=默认布局)'"); log.info("迁移406: 已添加 activity_poster_templates.layout_config 列"); } } catch (Exception e) { log.warn("迁移406: 添加 activity_poster_templates.layout_config 列失败: {}", e.getMessage()); } } // ================================================================================== // F-1 数据层(CF 值体系统一):cf_balance / cf_balance_log / cf_earn_rule / // cf_earn_rule_history / cf_direct_sales_tier / cf_indirect_sales_tier / cf_earn_daily_limit // 口径:docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §五 + §3.6 // 仅建表 + 种子,不含任何发放/扣减业务逻辑(F-2 批) // ================================================================================== // 迁移407: 创建 cf_balance / cf_balance_log(个人 CF 值账本,成员维度) private void createCfBalanceTables() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `cf_balance` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`member_id` BIGINT NOT NULL COMMENT '家庭成员ID(family_members.id)', " + "`user_id` BIGINT NULL COMMENT '关联登录用户ID(可为空,不能登录成员无账号)', " + "`family_id` BIGINT NULL COMMENT '所属家庭ID(冗余,便于聚合)', " + "`total_earned` INT NOT NULL DEFAULT 0 COMMENT '累计获得', " + "`available` INT NOT NULL DEFAULT 0 COMMENT '可用', " + "`frozen` INT NOT NULL DEFAULT 0 COMMENT '提现冻结', " + "`withdrawn` INT NOT NULL DEFAULT 0 COMMENT '已提现', " + "`exchanged` INT NOT NULL DEFAULT 0 COMMENT '已兑换消费', " + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY `uk_member` (`member_id`), " + "KEY `idx_family` (`family_id`) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人CF值余额(家庭成员维度)'"); log.info("迁移407: 已创建 cf_balance 表"); } catch (Exception e) { log.warn("迁移407: 创建 cf_balance 表失败(可能已存在): {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `cf_balance_log` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`member_id` BIGINT NOT NULL, " + "`user_id` BIGINT NULL, " + "`family_id` BIGINT NULL, " + "`type` VARCHAR(20) NOT NULL COMMENT 'earn/spend/freeze/unfreeze/withdraw/adjust/skip', " + "`ref_type` VARCHAR(50) NOT NULL COMMENT '规则编码或迁移标识(F02-F24 / migration_*)', " + "`ref_id` BIGINT NULL, " + "`amount` INT NOT NULL COMMENT '变动数量(正负)', " + "`balance_after` INT NOT NULL COMMENT '变动后可用余额', " + "`operator_user_id` BIGINT NULL COMMENT '操作人用户ID(管理员调整等人工操作留痕)', " + "`remark` VARCHAR(255) NULL, " + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "KEY `idx_member` (`member_id`), " + "KEY `idx_family` (`family_id`), " + "UNIQUE KEY `uk_ref_type` (`member_id`, `type`, `ref_type`, `ref_id`) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人CF值流水'"); log.info("迁移407: 已创建 cf_balance_log 表"); } catch (Exception e) { log.warn("迁移407: 创建 cf_balance_log 表失败(可能已存在): {}", e.getMessage()); } } // 迁移408: 创建 cf_earn_rule / cf_earn_rule_history(CF 值获取规则 + 变更审计) private void createCfEarnRuleTables() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `cf_earn_rule` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`code` VARCHAR(20) NOT NULL COMMENT '规则编码 F02~F24(F03 拆 F03a/F03b)', " + "`name` VARCHAR(100) NOT NULL COMMENT '途径名称', " + "`category` VARCHAR(20) NOT NULL COMMENT 'consume消费返佣/behavior成长行为/admin管理员', " + "`enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用(随时开关)', " + "`rule_type` VARCHAR(20) NOT NULL COMMENT 'fixed固定/formula公式/per_item逐人发放', " + "`amount` INT NULL COMMENT '固定数量(rule_type=fixed)', " + "`formula` VARCHAR(500) NULL COMMENT '计算公式(rule_type=formula)', " + "`params_json` TEXT NULL COMMENT '公式参数(bps/倍数/档位表等,JSON)', " + "`daily_limit_per_member` INT NULL COMMENT '单成员每日上限,空=不限', " + "`total_limit_per_member` INT NULL COMMENT '单成员累计上限,空=不限', " + "`cooldown_seconds` INT NULL COMMENT '同一成员冷却秒数,空=不限', " + "`dedup_ref_type` VARCHAR(50) NOT NULL COMMENT '幂等refType,写入cf_balance_log', " + "`effective_from` DATETIME NULL COMMENT '生效起,空=立即', " + "`effective_to` DATETIME NULL COMMENT '生效止,空=长期', " + "`sort_order` INT NOT NULL DEFAULT 0, " + "`remark` VARCHAR(255) NULL, " + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "`updated_by` BIGINT NULL COMMENT '最后修改人', " + "UNIQUE KEY `uk_code` (`code`) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值获取规则(运营可配置)'"); log.info("迁移408: 已创建 cf_earn_rule 表"); } catch (Exception e) { log.warn("迁移408: 创建 cf_earn_rule 表失败(可能已存在): {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `cf_earn_rule_history` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`rule_code` VARCHAR(20) NOT NULL, " + "`old_json` TEXT NULL COMMENT '变更前整行快照', " + "`new_json` TEXT NULL COMMENT '变更后整行快照', " + "`changed_by` BIGINT NOT NULL, " + "`change_reason` VARCHAR(255) NULL, " + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "KEY `idx_rule` (`rule_code`, `created_at`) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值规则变更历史'"); log.info("迁移408: 已创建 cf_earn_rule_history 表"); } catch (Exception e) { log.warn("迁移408: 创建 cf_earn_rule_history 表失败(可能已存在): {}", e.getMessage()); } } /** * 24 行规则种子(F02–F24,F03 拆 F03a/F03b),列序: * code, name, category, ruleType, amount, formula, paramsJson, dailyLimit, totalLimit, * cooldownSeconds, dedupRefType, sortOrder, remark * * 数值口径:§三 各条「参数来源」+ §九 修正后数值(F06=1990、F18 与 F02 同口径)。 * 注意:§三 公式写作 floor(实付(分)/100)×100,与 §九「19.9 元=1990 CF」不自洽; * 按 §九 修正口径执行 —— 1 元=100 CF 即 实付(分)→CF 1:1,公式记作 floor(amt)。 */ private static final String[][] CF_EARN_RULE_SEEDS = { {"F02", "消费返CF(所有订单)", "consume", "formula", "", "floor(amt)", "{\"cf_per_yuan\":100,\"amt_unit\":\"cent\",\"note\":\"1元=100CF,实付分→CF 1:1\"}", "", "", "", "cf_consume_order", "10", "所有消费订单(商品/套餐/测评/会员/订阅/调研)均返"}, {"F03a", "直接销售奖", "consume", "formula", "", "floor(base_ppoints * rate_percent / 100)", "{\"base\":\"team_annual_ppoints\",\"tier_table\":\"cf_direct_sales_tier\",\"window\":\"1y_rolling\",\"team_depth\":8,\"max_rate_percent\":70,\"downgrade\":true}", "", "", "", "F03a", "20", "基数=直接推荐人消费P点;档位按团队8层内年度累计P点(cf_direct_sales_tier);幂等ref_type与规则编码一致(F-4)"}, {"F03b", "间接销售奖", "consume", "formula", "", "floor(base_ppoints * rate_percent / 100)", "{\"base\":\"self_annual_ppoints\",\"tier_table\":\"cf_indirect_sales_tier\",\"window\":\"1y_rolling\",\"max_rate_percent\":20,\"downgrade\":true}", "", "", "", "F03b", "30", "基数=间接推荐人消费P点;档位按自身年度累计消费P点(cf_indirect_sales_tier);幂等ref_type与规则编码一致(F-4)"}, {"F04", "商品赠品CF", "consume", "per_item", "", "", "{\"source\":\"product_order_gift.cf_value\"}", "", "", "", "product_order_gift", "40", "赠品规则管理端配置"}, {"F05", "活动签到CF", "consume", "per_item", "", "", "{\"source\":\"activities.platform_points\"}", "", "", "", "activity_checkin", "50", "活动配置数量"}, {"F06", "菌群健康调研打卡返还", "consume", "formula", "", "floor(amt)", "{\"cf_per_yuan\":100,\"amt_unit\":\"cent\",\"fallback\":\"gut_health_project.cf_refund_amount\",\"example\":\"19.9元=1990CF\"}", "7", "1", "", "gut_health_refund", "60", "cf_refund_amount 为空时按项目价×100(19.9元=1990CF,§九修正值);一周项目只发一次"}, {"F07", "文章答题奖励", "behavior", "formula", "", "score + (all_correct ? 3 : 0)", "{\"bonus_all_correct\":3,\"dedup\":\"article\"}", "5", "", "", "article_answer", "70", "答对题数+全对3;日限5次(N9)"}, {"F08", "AI阅读答题奖励", "behavior", "fixed", "5", "", "{\"require_all_correct\":true}", "3", "", "", "ai_reading_answer", "80", "仅全部答对才发(原硬编码已外置)"}, {"F09", "完成测评建档", "behavior", "formula", "", "first_report ? 50 : 10", "{\"first_amount\":50,\"repeat_amount\":10,\"dedup_key\":\"report_hash\"}", "3", "", "", "assessment_report", "90", "第1份50/后续每份10,重复报告不计(N10)"}, {"F10", "家庭挑战完成", "behavior", "per_item", "", "", "{\"source\":\"family_challenge.reward_points\",\"distribution\":\"per_completed_member\"}", "1", "1", "", "family_challenge", "100", "按挑战配置数量(Q1 已定:每个完成成员各发一次)"}, {"F11", "邀请里程碑", "behavior", "per_item", "", "", "{\"source\":\"invite_milestone.reward_points\"}", "", "", "", "invite_milestone", "110", "按里程碑配置"}, {"F12", "成长任务", "behavior", "per_item", "", "", "{\"source\":\"growth_task.reward_points\"}", "", "", "", "growth_task", "120", "按任务模板配置"}, {"F13", "分享阅读奖励", "behavior", "fixed", "5", "", "{\"source\":\"cf.rate.share_read_reward_points\"}", "5", "", "", "share_read", "130", "默认5CF;日限5次"}, {"F14", "喝水打卡", "behavior", "fixed", "5", "", "{\"daily_limit_by_tier\":\"cf_earn_daily_limit\"}", "", "", "", "health_water", "140", "5CF/次;日限按会员等级(N11)"}, {"F15", "饮食打卡", "behavior", "fixed", "5", "", "{\"daily_limit_by_tier\":\"cf_earn_daily_limit\"}", "", "", "", "health_meal", "150", "5CF/次;日限按会员等级(N11)"}, {"F16", "每日健康打卡", "behavior", "fixed", "10", "", "{\"daily_limit_by_tier\":\"cf_earn_daily_limit\"}", "", "", "", "daily_health_checkin", "160", "10CF/次;日限按会员等级(N11)"}, {"F17", "挑战赛子任务完成", "behavior", "per_item", "", "", "{\"source\":\"challenge_task.reward_cf\"}", "", "", "", "challenge_sub_task", "170", "按挑战赛任务配置"}, {"F18", "健康启航计划付费", "consume", "formula", "", "floor(amt)", "{\"cf_per_yuan\":100,\"amt_unit\":\"cent\",\"note\":\"与F02同口径\"}", "1", "1", "", "health_start_plan", "180", "实付分→CF 1:1(§九修正口径,原硬编码已外置)"}, {"F19", "管理员手动调整", "admin", "per_item", "", "", "{\"require_parent_password\":true,\"allow_negative\":true}", "", "", "", "admin_adjust", "190", "正负任意,需家长密码校验,必须留痕(Q9)"}, {"F20", "问题域全部任务完成", "behavior", "per_item", "", "", "{\"source\":\"problem_domain.reward_cf_value\"}", "", "", "", "problem_domain", "200", "按管理端配置"}, {"F21", "关卡通关奖励", "behavior", "per_item", "", "", "{\"source\":\"unlock_gate.reward_points\"}", "", "", "", "unlock_gate", "210", "按管理端配置"}, {"F22", "任务完成CF", "behavior", "per_item", "", "", "{\"source\":\"task.cf_amount\",\"daily_limit_by_tier\":\"cf_earn_daily_limit\"}", "", "", "", "task_complete", "220", "保留个人发放,取消家庭池那条;日限按会员等级(N11)"}, {"F23", "成员间转让(收款方)", "behavior", "per_item", "", "", "{\"direction\":\"transfer_in\",\"pair_type\":\"transfer_out\"}", "", "", "", "cf_transfer", "230", "转入+X / 转出−X,同一事务"}, {"F24", "分享奖励(推荐注册)", "behavior", "fixed", "20", "", "{\"trigger\":\"referral_register\"}", "10", "", "", "referral_register", "240", "N8 新增:推荐他人注册即得,建议20CF/人,日限10次"} }; /** 幂等写入 24 行规则种子(已存在则跳过,不覆盖运营改过的配置) */ private void seedCfEarnRules() { int inserted = 0; for (String[] r : CF_EARN_RULE_SEEDS) { try { Integer cnt = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM cf_earn_rule WHERE code = ?", Integer.class, r[0]); if (cnt != null && cnt > 0) { continue; } jdbcTemplate.update("INSERT INTO cf_earn_rule (code, name, category, enabled, rule_type, amount, " + "formula, params_json, daily_limit_per_member, total_limit_per_member, cooldown_seconds, " + "dedup_ref_type, sort_order, remark, created_at, updated_at) " + "VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())", r[0], r[1], r[2], r[3], cfIntOrNull(r[4]), cfTextOrNull(r[5]), cfTextOrNull(r[6]), cfIntOrNull(r[7]), cfIntOrNull(r[8]), cfIntOrNull(r[9]), r[10], cfIntOrNull(r[11]) != null ? cfIntOrNull(r[11]) : 0, cfTextOrNull(r[12])); inserted++; } catch (Exception e) { log.warn("迁移408: 写入规则种子 {} 失败: {}", r[0], e.getMessage()); } } if (inserted > 0) { log.info("迁移408: cf_earn_rule 规则种子新增 {} 行(共 {} 条定义)", inserted, CF_EARN_RULE_SEEDS.length); } } // 迁移409: 创建 cf_direct_sales_tier / cf_indirect_sales_tier(两级销售奖档位,数值由运营配置) private void createCfSalesTierTables() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `cf_direct_sales_tier` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`tier_name` VARCHAR(50) NOT NULL COMMENT '档位名称', " + "`min_annual_ppoints` INT NOT NULL COMMENT '团队8层内年度累计P点下限', " + "`rate_percent` INT NOT NULL COMMENT '分润比例(%)', " + "`sort_order` INT NOT NULL DEFAULT 0, " + "`enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', " + "`remark` VARCHAR(255) NULL, " + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY `uk_min` (`min_annual_ppoints`) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='直接销售奖档位(按团队8层内年度P点累计)'"); log.info("迁移409: 已创建 cf_direct_sales_tier 表"); } catch (Exception e) { log.warn("迁移409: 创建 cf_direct_sales_tier 表失败(可能已存在): {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `cf_indirect_sales_tier` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`tier_name` VARCHAR(50) NOT NULL, " + "`min_self_ppoints` INT NOT NULL COMMENT '自身年度累计消费P点下限', " + "`rate_percent` INT NOT NULL COMMENT '分润比例(%)', " + "`sort_order` INT NOT NULL DEFAULT 0, " + "`enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', " + "`remark` VARCHAR(255) NULL, " + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY `uk_min` (`min_self_ppoints`) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='间接销售奖档位(按自身年度消费P点累计)'"); log.info("迁移409: 已创建 cf_indirect_sales_tier 表"); } catch (Exception e) { log.warn("迁移409: 创建 cf_indirect_sales_tier 表失败(可能已存在): {}", e.getMessage()); } } // 迁移410: 创建 cf_earn_daily_limit(规则 × 会员等级 每日次数上限,N11) private void createCfEarnDailyLimitTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `cf_earn_daily_limit` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`rule_code` VARCHAR(20) NOT NULL COMMENT '规则编码(cf_earn_rule.code)', " + "`level_code` VARCHAR(32) NOT NULL COMMENT '会员等级编码 FREE/BASIC/FAMILY/LIFETIME/PREMIUM', " + "`daily_limit` INT NOT NULL COMMENT '每日次数上限', " + "`remark` VARCHAR(255) NULL, " + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY `uk_rule_level` (`rule_code`, `level_code`) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值规则×会员等级每日次数上限(N11)'"); log.info("迁移410: 已创建 cf_earn_daily_limit 表"); } catch (Exception e) { log.warn("迁移410: 创建 cf_earn_daily_limit 表失败(可能已存在): {}", e.getMessage()); } } /** N11 会员等级日限:FREE=1(建议) / BASIC=3 / FAMILY=10 / LIFETIME=10 / PREMIUM=20,PROVIDER 待定不落行 */ private static final String[][] CF_DAILY_LIMIT_LEVELS = { {"FREE", "1", "免费会员(建议值,Q11)"}, {"BASIC", "3", "9.9体验会员"}, {"FAMILY", "10", "家庭会员"}, {"LIFETIME", "10", "终身会员"}, {"PREMIUM", "20", "高级会员"} }; /** 按会员等级限次的规则(任务/打卡类):F14 喝水 / F15 饮食 / F16 每日健康 / F22 任务完成 */ private static final String[] CF_DAILY_LIMIT_RULES = {"F14", "F15", "F16", "F22"}; private void seedCfEarnDailyLimits() { int inserted = 0; for (String ruleCode : CF_DAILY_LIMIT_RULES) { for (String[] lv : CF_DAILY_LIMIT_LEVELS) { try { Integer cnt = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM cf_earn_daily_limit WHERE rule_code = ? AND level_code = ?", Integer.class, ruleCode, lv[0]); if (cnt != null && cnt > 0) { continue; } jdbcTemplate.update("INSERT INTO cf_earn_daily_limit (rule_code, level_code, daily_limit, remark, created_at, updated_at) " + "VALUES (?, ?, ?, ?, NOW(), NOW())", ruleCode, lv[0], Integer.valueOf(lv[1]), lv[2]); inserted++; } catch (Exception e) { log.warn("迁移410: 写入日限种子 {}/{} 失败: {}", ruleCode, lv[0], e.getMessage()); } } } if (inserted > 0) { log.info("迁移410: cf_earn_daily_limit 日限种子新增 {} 行", inserted); } } // 迁移411: coupon / user_coupon 补 family_id(优惠券家庭化 N3) private void addCouponFamilyIdColumns() { if (!tableExists("coupon") || !tableExists("user_coupon")) { log.warn("迁移411: coupon / user_coupon 表不存在,跳过 family_id 迁移"); return; } ensureColumn("coupon", "family_id", "BIGINT DEFAULT NULL COMMENT '模板块绑定家庭ID(NULL=全平台模板)'"); ensureColumn("user_coupon", "family_id", "BIGINT DEFAULT NULL COMMENT '券实例归属家庭ID(家庭内任意成员可用)'"); try { if (!indexExists("user_coupon", "idx_family")) { jdbcTemplate.execute("ALTER TABLE user_coupon ADD INDEX idx_family (family_id)"); log.info("迁移411: 已添加 user_coupon.idx_family 索引"); } } catch (Exception e) { log.warn("迁移411: 添加 user_coupon.idx_family 失败(可能已存在): {}", e.getMessage()); } try { if (!indexExists("coupon", "idx_family")) { jdbcTemplate.execute("ALTER TABLE coupon ADD INDEX idx_family (family_id)"); log.info("迁移411: 已添加 coupon.idx_family 索引"); } } catch (Exception e) { log.warn("迁移411: 添加 coupon.idx_family 失败(可能已存在): {}", e.getMessage()); } } // 迁移412: 创建 service_config(L2 配置中心;W0.1) private void createServiceConfigTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `service_config` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`group_key` VARCHAR(64) NOT NULL DEFAULT 'general' COMMENT '配置分组(如 cf.rate / cf.commission)', " + "`config_key` VARCHAR(128) NOT NULL COMMENT '配置键(如 cf.rate.points_multiplier)', " + "`config_value` TEXT NULL COMMENT '配置值(统一字符串存储,按 value_type 解析)', " + "`value_type` VARCHAR(16) NOT NULL DEFAULT 'string' COMMENT 'string/int/double/bool/json', " + "`description` VARCHAR(255) NULL, " + "`editable` TINYINT NOT NULL DEFAULT 1 COMMENT '1允许后台编辑 0系统托管', " + "`updated_by` BIGINT NULL, " + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY `uk_config_key` (`config_key`), " + "KEY `idx_group_key` (`group_key`) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务配置中心(L2,CF通用运营参数归口)'"); log.info("迁移412: 已创建 service_config 表"); } catch (Exception e) { log.warn("迁移412: 创建 service_config 表失败(可能已存在): {}", e.getMessage()); } } /** W0.1 §3.6.6(2) 10 项 CF 通用运营参数 + cf.version 版本号 */ private void seedCfServiceConfig() { ensureServiceConfig("cf.rate.points_multiplier", "1.0", "double", "积分倍数(消费→CF 换算);原散落 products.points_multiplier / sys_config", 1); ensureServiceConfig("cf.rate.repeat_rate_default", "1.0", "double", "复购率默认系数(未匹配规则时不加权)", 1); ensureServiceConfig("cf.commission.settle_days", "7", "int", "佣金结算周期(天),财务口径常用", 1); ensureServiceConfig("cf.invite.valid_days", "7", "int", "邀请码有效期(天)", 1); ensureServiceConfig("cf.price.default_factor", "1.0", "double", "默认价格系数(未定价时原价)", 1); ensureServiceConfig("cf.stock.deduction_mode", "immediate", "string", "库存扣减策略 immediate/on_pay(Phase 4 定稿)", 1); ensureServiceConfig("cf.rate.share_read_reward_points", "5", "int", "分享阅读奖励 CF;原散落 sys_config.share_read_reward_points", 1); ensureServiceConfig("cf.commission.service_rate", "1000", "int", "佣金服务费率(bps);原散落 sys_config.commission_service_rate", 1); ensureServiceConfig("cf.product.gut_health_price", "1990", "int", "肠道健康项目定价(分);原散落 GutHealthProjectService.DEFAULT_PRICE / sys_config", 1); ensureServiceConfig("cf.product.platform_points_share", "1000", "int", "产品平台积分分成比例(bps);原散落 sys_config.product_platform_points_share", 1); ensureServiceConfig("cf.version", "1", "int", "CF 配置版本号(单调递增,前端据此局部刷新提示)", 0); } /** * 迁移416:CF 账本流水表结构调整(F-2) *
    *
  • 补 operator_user_id:管理员调整等人工操作留痕操作人;
  • *
  • 幂等键升级:旧 uk_ref(member_id,ref_type,ref_id) 会让同一业务对象的 * freeze→unfreeze/withdraw 互相冲突,升级为 uk_ref_type(member_id,type,ref_type,ref_id), * earn 幂等口径不变(type='earn');
  • *
  • 种子:提现开关(默认关闭)+ 人民币→CF 汇率(1 元 = 100 CF,F02 口径)。
  • *
* 幂等:列/索引/配置均先探测后变更,可重复执行。 */ private void migrateCfBalanceLogForLedger() { if (!tableExists("cf_balance_log")) { log.warn("迁移416: cf_balance_log 表不存在,跳过流水表结构调整"); return; } // 1) 操作人留痕列 ensureColumn("cf_balance_log", "operator_user_id", "BIGINT NULL COMMENT '操作人用户ID(管理员调整等人工操作留痕)'"); // 2) 幂等键升级:先删旧唯一索引,再加新唯一索引 try { if (indexExists("cf_balance_log", "uk_ref")) { jdbcTemplate.execute("ALTER TABLE cf_balance_log DROP INDEX uk_ref"); log.info("迁移416: 已删除 cf_balance_log.uk_ref 旧唯一索引"); } } catch (Exception e) { log.warn("迁移416: 删除 cf_balance_log.uk_ref 失败: {}", e.getMessage()); } try { if (!indexExists("cf_balance_log", "uk_ref_type")) { jdbcTemplate.execute("ALTER TABLE cf_balance_log " + "ADD UNIQUE KEY uk_ref_type (member_id, type, ref_type, ref_id)"); log.info("迁移416: 已添加 cf_balance_log.uk_ref_type 唯一索引"); } } catch (Exception e) { log.warn("迁移416: 添加 cf_balance_log.uk_ref_type 失败: {}", e.getMessage()); } // 3) CF 运营参数种子 ensureServiceConfig("cf.withdraw_enabled", "false", "bool", "CF值提现开关(false=禁止提现;Q4 合规审查通过后再开启)", 1); ensureServiceConfig("cf.rate.cf_per_yuan", "100", "int", "人民币→CF值换算比例(1元=100CF,F02 消费返 CF 口径)", 1); } /** * 迁移散落在 sys_config / products 的 CF 参数到 service_config: * 优先取旧位置的实际值(存在则沿用),旧位置仅做标记清理,不删值(F-2/F-3 收口前保持兼容)。 * 幂等:新 key 已存在则跳过;旧位置标记带唯一前缀,重复执行不重复追加。 */ private void migrateScatteredCfConfigs() { migrateSysConfigToServiceConfig("commission_service_rate", "cf.commission.service_rate", "1000"); migrateSysConfigToServiceConfig("product_platform_points_share", "cf.product.platform_points_share", "1000"); migrateSysConfigToServiceConfig("share_read_reward_points", "cf.rate.share_read_reward_points", "5"); migrateSysConfigToServiceConfig("gut_health_project_price", "cf.product.gut_health_price", "1990"); // products.points_multiplier(列)→ cf.rate.points_multiplier,并标记列注释 try { if (!serviceConfigExists("cf.rate.points_multiplier")) { String val = null; try { // 仅当所有商品倍数一致时才沿用该值,否则回落到 1.0(避免把某个商品的特殊倍数当成全局口径) Integer distinct = jdbcTemplate.queryForObject( "SELECT COUNT(DISTINCT points_multiplier) FROM products WHERE points_multiplier IS NOT NULL", Integer.class); if (distinct != null && distinct == 1) { val = jdbcTemplate.queryForObject( "SELECT CAST(points_multiplier AS CHAR) FROM products WHERE points_multiplier IS NOT NULL LIMIT 1", String.class); } } catch (Exception ignored) { // 列不存在或表为空 } ensureServiceConfig("cf.rate.points_multiplier", val != null ? val : "1.0", "double", "积分倍数(消费→CF 换算);原散落 products.points_multiplier", 1); } markProductsPointsMultiplierMigrated(); } catch (Exception e) { log.warn("迁移412: products.points_multiplier 迁移失败: {}", e.getMessage()); } } private void migrateSysConfigToServiceConfig(String oldKey, String newKey, String defaultVal) { try { if (serviceConfigExists(newKey)) { markSysConfigMigrated(oldKey, newKey); return; } String val = null; try { val = jdbcTemplate.queryForObject( "SELECT config_value FROM sys_config WHERE config_key = ? LIMIT 1", String.class, oldKey); } catch (Exception ignored) { // 旧配置不存在,使用默认值 } ensureServiceConfig(newKey, val != null && !val.trim().isEmpty() ? val : defaultVal, cfValueTypeOf(defaultVal), "由 sys_config." + oldKey + " 迁入(W0.1)", 1); markSysConfigMigrated(oldKey, newKey); } catch (Exception e) { log.warn("迁移412: {} → {} 迁移失败: {}", oldKey, newKey, e.getMessage()); } } /** 旧 sys_config 行标记为已迁移(保留值做过渡兼容,不物理删除) */ private void markSysConfigMigrated(String oldKey, String newKey) { try { int updated = jdbcTemplate.update( "UPDATE sys_config SET description = CONCAT(COALESCE(description, ''), ?) " + "WHERE config_key = ? AND (description IS NULL OR description NOT LIKE '%【已迁移→service_config%')", " 【已迁移→service_config " + newKey + ",本行仅过渡期兼容】", oldKey); if (updated > 0) { log.info("迁移412: 已标记 sys_config.{} 迁往 service_config.{}", oldKey, newKey); } } catch (Exception e) { log.warn("迁移412: 标记 sys_config.{} 失败: {}", oldKey, e.getMessage()); } } /** products.points_multiplier 列注释标记(列保留兼容,不再作为权威来源) */ private void markProductsPointsMultiplierMigrated() { try { String comment = jdbcTemplate.queryForObject( "SELECT COLUMN_COMMENT FROM information_schema.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'products' AND COLUMN_NAME = 'points_multiplier'", String.class); if (comment != null && comment.contains("已迁移")) { return; } jdbcTemplate.execute("ALTER TABLE products MODIFY COLUMN points_multiplier DECIMAL(10,2) NOT NULL DEFAULT 1.00 " + "COMMENT '积分倍数【已迁移→service_config cf.rate.points_multiplier,本列仅过渡期兼容】'"); log.info("迁移412: 已标记 products.points_multiplier 迁往 service_config.cf.rate.points_multiplier"); } catch (Exception e) { log.warn("迁移412: 标记 products.points_multiplier 失败: {}", e.getMessage()); } } private boolean serviceConfigExists(String key) { try { Integer cnt = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM service_config WHERE config_key = ?", Integer.class, key); return cnt != null && cnt > 0; } catch (Exception e) { return false; } } /** 幂等写入 service_config(已存在则跳过,不覆盖运营改过的值) */ private void ensureServiceConfig(String key, String value, String valueType, String description, int editable) { try { if (serviceConfigExists(key)) { return; } jdbcTemplate.update("INSERT INTO service_config (group_key, config_key, config_value, value_type, description, editable, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())", groupKeyOfConfig(key), key, value, valueType, description, editable); } catch (Exception e) { log.warn("写入 service_config.{} 失败: {}", key, e.getMessage()); } } /** cf.rate.points_multiplier → cf.rate */ private String groupKeyOfConfig(String key) { if (key == null) { return "general"; } int idx = key.lastIndexOf('.'); return idx > 0 ? key.substring(0, idx) : "general"; } // ==================== 迁移413:W0.2 双配置中心合并(service_config 收编 product_config) ==================== /** * 迁移413-1:service_config 增加 product_key 列(product 侧配置关联键;NULL = 全局/CF 通用参数)。 * * 幂等:service_config 不存在 / 列已存在 / 索引已存在均跳过,可重复执行。 */ private void addServiceConfigProductKeyColumn() { try { if (!tableExists("service_config")) { log.warn("迁移413: service_config 表不存在(迁移412 未执行?),跳过 product_key 列/索引"); return; } if (columnExists("service_config", "product_key")) { log.info("迁移413: service_config.product_key 已存在,跳过加列"); } else { ensureColumn("service_config", "product_key", "VARCHAR(64) DEFAULT NULL COMMENT '关联产品标识(product 侧配置;NULL=全局/CF 通用参数)'"); log.info("迁移413: 已为 service_config 添加 product_key 列"); } if (!indexExists("service_config", "idx_product_key")) { jdbcTemplate.execute("ALTER TABLE service_config ADD KEY idx_product_key (product_key)"); log.info("迁移413: 已创建 service_config.idx_product_key 索引"); } } catch (Exception e) { log.warn("迁移413: 添加 product_key 列/索引失败: {}", e.getMessage()); } } /** * 迁移413-2:把 product_config 表数据并入 service_config(product_key 填对应产品标识)。 * *

product_key 标识规则(按 product_config 实际列优先级取第一个存在的列):

*
    *
  1. {@code product_key}(字符串产品标识)
  2. *
  3. {@code product_code}(产品编码)
  4. *
  5. {@code product_id}(产品主键,CAST 为字符串;唯一索引冲突风险由 idempotentKey 拼接规避)
  6. *
  7. group_key(计划文档 §3.6.6 中唯一保证存在的关联字段,作为兜底产品标识)
  8. *
  9. 以上皆缺 → {@code default}
  10. *
* *

config_key 写入规则:product 侧键统一加 {@code product.{productKey}.} 前缀 * (见 {@link com.etotem.cfc.constant.ProductConfigKeys}),保证 config_key 全局唯一 * (service_config 唯一键 uk_config_key 保留不动,W0.2 不改 W0.1 种子与索引)。

* *

幂等:本仓库基线(origin/cfclub / 测试库)不存在 product_config 表,迁移为空跑; * 若目标环境存在该表,按「service_config 中不存在同 key 才插入」幂等,INSERT IGNORE 兜底唯一键冲突。

*/ private void migrateProductConfigToServiceConfig() { if (!tableExists("product_config")) { log.info("迁移413: product_config 表不存在(本基线无该表),跳过数据并入(幂等空跑)"); return; } try { String groupCol = firstExistingColumn("product_config", "group_key", "groupKey"); String keyCol = firstExistingColumn("product_config", "config_key", "configKey"); String valueCol = firstExistingColumn("product_config", "config_value", "configValue"); String typeCol = firstExistingColumn("product_config", "value_type", "valueType"); String descCol = firstExistingColumn("product_config", "description"); String editableCol = firstExistingColumn("product_config", "editable"); String upByCol = firstExistingColumn("product_config", "updated_by", "updatedBy"); String createdAtCol = firstExistingColumn("product_config", "created_at", "createdAt"); String updatedAtCol = firstExistingColumn("product_config", "updated_at", "updatedAt"); if (keyCol == null) { log.warn("迁移413: product_config 缺少配置键列(config_key/configKey),无法并入,跳过"); return; } // 产品标识表达式(优先级:product_key → product_code → product_id → group_key → 'default') // 统一加 p. 前缀限定,避免与 service_config 同名列歧义 String productKeyExpr = null; for (String c : new String[]{"product_key", "product_code"}) { if (columnExists("product_config", c)) { productKeyExpr = "NULLIF(TRIM(p.`" + c + "`), '')"; break; } } if (productKeyExpr == null && columnExists("product_config", "product_id")) { productKeyExpr = "CAST(p.`product_id` AS CHAR)"; } if (productKeyExpr == null && groupCol != null) { productKeyExpr = "NULLIF(TRIM(p.`" + groupCol + "`), '')"; } if (productKeyExpr == null) { productKeyExpr = "'" + com.etotem.cfc.constant.ProductConfigKeys.DEFAULT_PRODUCT_KEY + "'"; } StringBuilder sql = new StringBuilder("INSERT IGNORE INTO service_config ") .append("(group_key, config_key, config_value, value_type, description, editable, product_key, updated_by, created_at, updated_at) ") .append("SELECT "); sql.append("'").append(com.etotem.cfc.constant.ProductConfigKeys.PRODUCT_GROUP_KEY).append("', "); // config_key = product.{productKey}.{原key}(保证全局唯一;截断到 128 防超长) sql.append("LEFT(CONCAT('").append(com.etotem.cfc.constant.ProductConfigKeys.PRODUCT_KEY_PREFIX) .append("', COALESCE(").append(productKeyExpr).append(", '") .append(com.etotem.cfc.constant.ProductConfigKeys.DEFAULT_PRODUCT_KEY).append("'), '.', p.`") .append(keyCol).append("`), 128), "); sql.append(valueCol != null ? "p.`" + valueCol + "`, " : "NULL, "); sql.append(typeCol != null ? "COALESCE(NULLIF(TRIM(p.`" + typeCol + "`), ''), 'string'), " : "'string', "); sql.append(descCol != null ? "LEFT(p.`" + descCol + "`, 255), " : "NULL, "); sql.append(editableCol != null ? "COALESCE(p.`" + editableCol + "`, 1), " : "1, "); sql.append("LEFT(COALESCE(").append(productKeyExpr).append(", '") .append(com.etotem.cfc.constant.ProductConfigKeys.DEFAULT_PRODUCT_KEY).append("'), 64), "); sql.append(upByCol != null ? "p.`" + upByCol + "`, " : "NULL, "); sql.append(createdAtCol != null ? "COALESCE(p.`" + createdAtCol + "`, NOW()), " : "NOW(), "); sql.append(updatedAtCol != null ? "COALESCE(p.`" + updatedAtCol + "`, NOW()) " : "NOW() "); sql.append("FROM product_config p WHERE NOT EXISTS " + "(SELECT 1 FROM service_config s WHERE s.config_key = CONCAT('" + com.etotem.cfc.constant.ProductConfigKeys.PRODUCT_KEY_PREFIX + "', COALESCE(").append(productKeyExpr).append(", '") .append(com.etotem.cfc.constant.ProductConfigKeys.DEFAULT_PRODUCT_KEY).append("'), '.', p.`") .append(keyCol).append("`))"); int rows = jdbcTemplate.update(sql.toString()); log.info("迁移413: product_config → service_config 并入完成,新增 {} 行", rows); } catch (Exception e) { log.warn("迁移413: product_config 数据并入失败(跳过,可重试):{}", e.getMessage()); } } /** 返回 product_config 中第一个实际存在的列名(蛇形优先,兼容驼峰),全部缺失返回 null */ private String firstExistingColumn(String table, String... candidates) { for (String c : candidates) { if (columnExists(table, c)) { return c; } } return null; } private String cfValueTypeOf(String value) { if (value == null) { return "string"; } try { Integer.parseInt(value.trim()); return "int"; } catch (NumberFormatException ignored) { // 非整数 } try { Double.parseDouble(value.trim()); return "double"; } catch (NumberFormatException ignored) { // 非数值 } return "string"; } private Integer cfIntOrNull(String v) { if (v == null || v.trim().isEmpty()) { return null; } try { return Integer.valueOf(v.trim()); } catch (NumberFormatException e) { return null; } } private String cfTextOrNull(String v) { return (v == null || v.trim().isEmpty()) ? null : v; } private boolean indexExists(String table, String indexName) { try { Integer cnt = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = ? AND INDEX_NAME = ?", Integer.class, table, indexName); return cnt != null && cnt > 0; } catch (Exception e) { return false; } } /** * 迁移414: 创建 audit_log 表(管理端写操作统一审计,W0.3;表只增不删改)。 * 幂等:CREATE TABLE IF NOT EXISTS + 索引存在性检查,可重复执行。 */ private void createAuditLogTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS audit_log (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY," + "operator_id BIGINT COMMENT '操作人ID(关联 users.id)'," + "operator_name VARCHAR(100) COMMENT '操作人姓名'," + "module VARCHAR(64) NOT NULL COMMENT '审计模块(config/earn_rule/tier/balance)'," + "action VARCHAR(32) NOT NULL COMMENT '动作类型(save/update/toggle/delete/adjust)'," + "target_type VARCHAR(32) NOT NULL COMMENT '目标类型(service_config/cf_earn_rule/cf_rate_tier/cf_balance)'," + "target_id VARCHAR(128) COMMENT '目标标识(配置key / 规则code / 主键ID)'," + "before_value TEXT COMMENT '变更前值(JSON)'," + "after_value TEXT COMMENT '变更后值(JSON)'," + "ip VARCHAR(45) COMMENT '操作来源IP'," + "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发生时间'," + "KEY idx_module_target_created (module, target_id, created_at)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审计日志(管理端写操作前后值,W0.3)'"); log.info("迁移414: audit_log 表已就绪"); } catch (Exception e) { log.warn("迁移414: 创建 audit_log 表失败(表可能已存在): {}", e.getMessage()); } // 索引兜底:表已存在但索引缺失时补建(幂等) try { if (!indexExists("audit_log", "idx_module_target_created")) { jdbcTemplate.execute("ALTER TABLE audit_log ADD INDEX idx_module_target_created (module, target_id, created_at)"); log.info("迁移414: 已为 audit_log 补建索引 idx_module_target_created"); } } catch (Exception e) { log.warn("迁移414: audit_log 索引检查/补建失败(可能已存在): {}", e.getMessage()); } } /** * 迁移415: 创建 family_food_overview 表(家庭×食材 的持久化最小值总览,T28)。 * 家庭总览口径:家庭成员对该项食材最新推荐指数的最小值(最保守值),落库持久化。 * 幂等:CREATE TABLE IF NOT EXISTS + 索引存在性检查,可重复执行。 */ private void createFamilyFoodOverviewTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_food_overview (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY," + "family_id BIGINT NOT NULL COMMENT '家庭ID'," + "food_id BIGINT NOT NULL COMMENT '食材ID'," + "food_name VARCHAR(100) COMMENT '食材名称快照'," + "category VARCHAR(50) COMMENT '分类快照'," + "min_index_score INT NOT NULL COMMENT '家庭成员该项食材最新推荐指数的最小值'," + "member_count INT NOT NULL DEFAULT 0 COMMENT '参与计算的成员数(有该食材记录的)'," + "member_scores JSON COMMENT '成员明细 [{userId,nickname,score}]'," + "source_fingerprint VARCHAR(64) COMMENT '源数据指纹(成员指数快照哈希),用于幂等与变更检测'," + "calculated_at DATETIME NOT NULL COMMENT '本次计算时间'," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," + "UNIQUE KEY uk_family_food (family_id, food_id)," + "INDEX idx_family (family_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭×食材持久化最小值总览(T28)'"); log.info("迁移415: family_food_overview 表已就绪"); } catch (Exception e) { log.warn("迁移415: 创建 family_food_overview 表失败(表可能已存在): {}", e.getMessage()); } // 索引兜底:表已存在但索引缺失时补建(幂等) try { if (!indexExists("family_food_overview", "idx_family")) { jdbcTemplate.execute("ALTER TABLE family_food_overview ADD INDEX idx_family (family_id)"); log.info("迁移415: 已为 family_food_overview 补建索引 idx_family"); } } catch (Exception e) { log.warn("迁移415: family_food_overview 索引检查/补建失败(可能已存在): {}", e.getMessage()); } } // ==================== 迁移418:F-4 分佣收口 ==================== /** * 直接销售奖档位种子(F03a;初始运营值,管理端可调)。 * 列序:tier_name, min_annual_ppoints, rate_percent, sort_order, remark */ private static final String[][] CF_DIRECT_SALES_TIER_SEEDS = { {"新秀", "10000", "10", "1", "团队8层内年度P点≥10000,直接销售奖10%"}, {"小成", "50000", "25", "2", "团队8层内年度P点≥50000,直接销售奖25%"}, {"中富", "200000", "40", "3", "团队8层内年度P点≥200000,直接销售奖40%"}, {"大成", "500000", "55", "4", "团队8层内年度P点≥500000,直接销售奖55%"}, {"至富", "1000000", "70", "5", "团队8层内年度P点≥1000000,直接销售奖70%"} }; /** * 间接销售奖档位种子(F03b;初始运营值,管理端可调)。 * 列序:tier_name, min_self_ppoints, rate_percent, sort_order, remark */ private static final String[][] CF_INDIRECT_SALES_TIER_SEEDS = { {"新秀", "10000", "2", "1", "自身年度消费P点≥10000,间接销售奖2%"}, {"小成", "50000", "5", "2", "自身年度消费P点≥50000,间接销售奖5%"}, {"中富", "200000", "10", "3", "自身年度消费P点≥200000,间接销售奖10%"}, {"大成", "500000", "15", "4", "自身年度消费P点≥500000,间接销售奖15%"}, {"至富", "1000000", "20", "5", "自身年度消费P点≥1000000,间接销售奖20%"} }; // 迁移418: 两级销售奖档位种子(幂等:min 下限唯一键已存在则跳过,不覆盖运营改过的档位) // 迁移418(F-4):缺行才补的档位种子,包内可见以便单测直接调用 void seedCfSalesTiers() { // 迁移431(×100) 后清理旧档残留:旧种子青铜(100)/白银(500)/黄金(2000)/铂金(5000)/钻石(10000) // 的 min 阈值与迁移431 新档位(10000/50000/...)不同,缺行才补逻辑会在每次重启重新插入旧档, // 导致新旧档并存、按 min DESC 匹配时旧档(如5000,55%)抢在新秀(10000,10%)前。 // 此处删除不在新档阈值集合内的旧档行(幂等,不影响已改名为新秀/小成/...的 1-5 行)。 try { int cleaned = jdbcTemplate.update( "DELETE FROM cf_direct_sales_tier " + "WHERE min_annual_ppoints NOT IN (10000, 50000, 200000, 500000, 1000000)"); int cleaned2 = jdbcTemplate.update( "DELETE FROM cf_indirect_sales_tier " + "WHERE min_self_ppoints NOT IN (10000, 50000, 200000, 500000, 1000000)"); if (cleaned > 0 || cleaned2 > 0) { log.info("迁移418: 清理旧档位残留 {} 行(直接)/{} 行(间接)", cleaned, cleaned2); } } catch (Exception e) { log.warn("迁移418: 清理旧档位残留失败(幂等容忍): {}", e.getMessage()); } int inserted = 0; for (String[] t : CF_DIRECT_SALES_TIER_SEEDS) { try { Integer cnt = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM cf_direct_sales_tier WHERE min_annual_ppoints = ?", Integer.class, Integer.valueOf(t[1])); if (cnt != null && cnt > 0) { continue; } jdbcTemplate.update("INSERT INTO cf_direct_sales_tier " + "(tier_name, min_annual_ppoints, rate_percent, sort_order, enabled, remark, created_at, updated_at) " + "VALUES (?, ?, ?, ?, 1, ?, NOW(), NOW())", t[0], Integer.valueOf(t[1]), Integer.valueOf(t[2]), Integer.valueOf(t[3]), t[4]); inserted++; } catch (Exception e) { log.warn("迁移418: 写入直接销售奖档位种子 {} 失败: {}", t[0], e.getMessage()); } } for (String[] t : CF_INDIRECT_SALES_TIER_SEEDS) { try { Integer cnt = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM cf_indirect_sales_tier WHERE min_self_ppoints = ?", Integer.class, Integer.valueOf(t[1])); if (cnt != null && cnt > 0) { continue; } jdbcTemplate.update("INSERT INTO cf_indirect_sales_tier " + "(tier_name, min_self_ppoints, rate_percent, sort_order, enabled, remark, created_at, updated_at) " + "VALUES (?, ?, ?, ?, 1, ?, NOW(), NOW())", t[0], Integer.valueOf(t[1]), Integer.valueOf(t[2]), Integer.valueOf(t[3]), t[4]); inserted++; } catch (Exception e) { log.warn("迁移418: 写入间接销售奖档位种子 {} 失败: {}", t[0], e.getMessage()); } } if (inserted > 0) { log.info("迁移418: 两级销售奖档位种子新增 {} 行(缺行才补,已存在不覆盖)", inserted); } } // 迁移418: F03a/F03b 幂等 ref_type 对齐为规则编码(计划 §5.2 ref_type 见 F02-F24 编码;F-4) // 迁移418(F-4):ref_type 对齐,包内可见以便单测直接调用 void alignSalesRuleRefTypes() { try { int updated = jdbcTemplate.update( "UPDATE cf_earn_rule SET dedup_ref_type = 'F03a', updated_at = NOW() " + "WHERE code = 'F03a' AND dedup_ref_type <> 'F03a'"); if (updated > 0) { log.info("迁移418: F03a dedup_ref_type 已对齐为 F03a({} 行)", updated); } } catch (Exception e) { log.warn("迁移418: F03a dedup_ref_type 对齐失败: {}", e.getMessage()); } try { int updated = jdbcTemplate.update( "UPDATE cf_earn_rule SET dedup_ref_type = 'F03b', updated_at = NOW() " + "WHERE code = 'F03b' AND dedup_ref_type <> 'F03b'"); if (updated > 0) { log.info("迁移418: F03b dedup_ref_type 已对齐为 F03b({} 行)", updated); } } catch (Exception e) { log.warn("迁移418: F03b dedup_ref_type 对齐失败: {}", e.getMessage()); } } // ==================== 迁移420:P 点消费日志(F-4 滚动一年档位数据源) ==================== // 迁移420: 创建 ppoint_consumption_log(消费支付成功时落一行,按消费时间滚动一年聚合) // 迁移420(F-4):建表幂等,包内可见以便单测直接调用 /** * 迁移422 前置:确保 health_indicators.collection_date 物理列存在。 * 放开 HealthIndicator.collectionDate 的 @TableField(exist=false) 之前必须先跑这一步; * 任一环境缺列都会让所有 health_indicators 查询报 Unknown column(报告详情页整体崩溃)。 */ void ensureHealthIndicatorsCollectionDateColumn() { if (!columnExists("health_indicators", "collection_date")) { ensureColumn("health_indicators", "collection_date", "DATE DEFAULT NULL COMMENT '指标采集日期(从报告提取的检测日期,非入库时间)'"); log.info("迁移422: 已补建 health_indicators.collection_date 列"); } else { log.info("迁移422: health_indicators.collection_date 已存在,放开实体映射的前置条件满足"); } } /** * 迁移422: indicator_values 增加日期来源标记 + 参考范围快照。 * date_source 区分采样日/上传日;ref_range_snapshot 保证历史判定可复现(字典改了不影响历史)。 */ void migrateIndicatorValuesTimelineColumns() { ensureColumn("indicator_values", "date_source", "VARCHAR(16) DEFAULT 'unknown' COMMENT 'report_date 来源: real=采样日/upload=上传日/confirm=确认日/unknown=未知(存量)'"); ensureColumn("indicator_values", "ref_range_snapshot", "VARCHAR(100) DEFAULT NULL COMMENT '该期参考范围快照(为空时回落 indicator_definitions.ref_range)'"); log.info("迁移422: indicator_values 已补齐日期来源与参考范围快照 2 列"); } // ==================== 迁移423:体脂秤测量记录表 ==================== /** * 迁移423: 体脂秤测量记录表(一次测量一条记录)。 * 现状 sourceId=memberId + insert 会在第二次测量时撞 uk_source_definition 导致整批保存失败; * 本表提供 recordId 作为 indicator_values.source_id 的新取值语义(服务层改造见 T05)。 */ void createBodyFatScaleRecordsTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `body_fat_scale_records` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`member_id` BIGINT NOT NULL COMMENT '家庭成员ID(family_members.id)', " + "`family_id` BIGINT NULL COMMENT '所属家庭', " + "`image_url` VARCHAR(500) NULL COMMENT '体脂秤照片', " + "`measured_at` DATE NULL COMMENT '测量日期(取不到为NULL,观测 report_date 记为上传日)', " + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX `idx_member_date` (`member_id`, `measured_at`) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='体脂秤测量记录'"); log.info("迁移423: 已创建 body_fat_scale_records 表"); } catch (Exception e) { log.warn("迁移423: 创建 body_fat_scale_records 表失败(可能已存在): {}", e.getMessage()); } } /** * 迁移427: 内容维度阈值显隐配置表(A-后端) * - content_type: 'PRODUCT'|'ARTICLE'|'ACTIVITY'|'MINIGAME' * - dimension_code: 五维码(body/mind/wisdom/action/wealth)/子项码/自定义指标码 * - threshold: 0-100,与 FiveDimensionScore.score 同量纲 * - 显隐规则(OR 逻辑,在前端/列表服务层计算):任一维度得分 < 阈值 → 显示;全部维度得分 >= 阈值 → 隐藏 * 幂等:CREATE TABLE IF NOT EXISTS + 异常兜底。 */ void createContentDimensionThresholdTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `content_dimension_threshold` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`content_type` VARCHAR(20) NOT NULL COMMENT '内容类型: PRODUCT/ARTICLE/ACTIVITY/MINIGAME', " + "`content_id` BIGINT NOT NULL COMMENT '内容ID(products/articles/activities/mini_games 主键)', " + "`dimension_code` VARCHAR(50) NOT NULL COMMENT '维度码: 五维码/子项码/自定义指标码', " + "`dimension_name` VARCHAR(100) NULL COMMENT '展示名(自定义指标即自定义名)', " + "`threshold` INT NOT NULL COMMENT '阈值 0-100', " + "`is_custom` TINYINT DEFAULT 0 COMMENT '1=管理员自定义指标', " + "`enabled` TINYINT DEFAULT 1 COMMENT '1=启用', " + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, " + "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY `uk_content_dim` (`content_type`, `content_id`, `dimension_code`) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='内容维度阈值显隐配置'"); log.info("迁移427: 已创建 content_dimension_threshold 表"); } catch (Exception e) { log.warn("迁移427: 创建 content_dimension_threshold 表失败(可能已存在): {}", e.getMessage()); } } // ==================== 迁移428:会员优惠券转赠 ==================== /** * 迁移428: 会员优惠券转赠。 *

- 新建 coupon_gift_transfer 表(记录券实例从赠送方家庭转移到接收方家庭的过程,支持撤回)。 * - family_coupon 增加 is_gift 列(1=来自转赠,接收方可被非会员用于购买会员)。

* 幂等:建表用 IF NOT EXISTS,加列前检查 information_schema。 */ void createCouponGiftTransferTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `coupon_gift_transfer` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`giver_user_id` BIGINT NOT NULL COMMENT '赠送人用户ID(users.id)', " + "`giver_family_id` BIGINT NOT NULL COMMENT '赠送人家庭ID', " + "`receiver_user_id` BIGINT NOT NULL COMMENT '接收人用户ID(users.id)', " + "`receiver_family_id` BIGINT NOT NULL COMMENT '接收人家庭ID', " + "`coupon_id` BIGINT NOT NULL COMMENT '券模板ID(coupon.id)', " + "`family_coupon_id` BIGINT NOT NULL COMMENT '被转移的 family_coupon 实例ID', " + "`gift_level` INT NOT NULL COMMENT '推荐层级 1=直推 2=隔级', " + "`status` VARCHAR(16) NOT NULL DEFAULT 'GIFTED' COMMENT 'GIFTED/USED/REVOKED', " + "`remark` VARCHAR(255) NULL COMMENT '赠送留言', " + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, " + "`used_at` DATETIME NULL, " + "`revoked_at` DATETIME NULL, " + "INDEX `idx_receiver` (`receiver_family_id`, `status`), " + "INDEX `idx_giver` (`giver_user_id`), " + "INDEX `idx_family_coupon` (`family_coupon_id`) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员优惠券转赠记录'"); log.info("迁移428: 已创建 coupon_gift_transfer 表"); } catch (Exception e) { log.warn("迁移428: 创建 coupon_gift_transfer 表失败(可能已存在): {}", e.getMessage()); } // family_coupon.is_gift 列(1=来自转赠) try { Integer colExists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'family_coupon' AND COLUMN_NAME = 'is_gift'", Integer.class); if (colExists == null || colExists == 0) { jdbcTemplate.execute("ALTER TABLE family_coupon ADD COLUMN is_gift INT DEFAULT 0 COMMENT '1=该券实例来自他人转赠'"); log.info("迁移428: 已为 family_coupon 增加 is_gift 列"); } else { log.info("迁移428: family_coupon.is_gift 列已存在,跳过"); } } catch (Exception e) { log.warn("迁移428: 检查/添加 family_coupon.is_gift 列失败: {}", e.getMessage()); } } // ==================== 迁移434:SKU 商品组合 ==================== void migrateSkuCombo() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `product_sku_combo_items` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`sku_id` BIGINT NOT NULL COMMENT '组合SKU ID(product_skus.id)', " + "`child_product_id` BIGINT NOT NULL COMMENT '组成商品ID', " + "`child_sku_id` BIGINT NULL COMMENT '组成商品SKU ID(组成商品有SKU时必填)', " + "`quantity` INT NOT NULL DEFAULT 1 COMMENT '该组成商品数量', " + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX `idx_sku` (`sku_id`), " + "INDEX `idx_child_product` (`child_product_id`) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品SKU组合明细'"); log.info("迁移434: 已创建 product_sku_combo_items 表"); } catch (Exception e) { log.warn("迁移434: 创建 product_sku_combo_items 表失败(可能已存在): {}", e.getMessage()); } try { Integer colExists = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'product_orders' AND COLUMN_NAME = 'sku_id'", Integer.class); if (colExists == null || colExists == 0) { jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN sku_id BIGINT NULL COMMENT '下单SKU ID(组合SKU识别用)' AFTER product_type"); log.info("迁移434: 已为 product_orders 增加 sku_id 列"); } else { log.info("迁移434: product_orders.sku_id 列已存在,跳过"); } } catch (Exception e) { log.warn("迁移434: 检查/添加 product_orders.sku_id 列失败: {}", e.getMessage()); } } // ==================== 迁移429:用户关注指标收藏(T04 方案A) ==================== /** * 迁移429: 用户关注指标收藏表(跨端同步;5维+指标唯一)。 * 幂等:CREATE TABLE IF NOT EXISTS + 异常兜底。 */ void createIndicatorFavoriteTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `indicator_favorite` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`user_id` BIGINT NOT NULL COMMENT '收藏用户ID(users.id)', " + "`family_id` BIGINT NOT NULL COMMENT '所属家庭ID', " + "`member_id` BIGINT NOT NULL COMMENT '观测对象ID(family_members.id)', " + "`source_type` VARCHAR(50) NOT NULL COMMENT '报告类型', " + "`definition_id` BIGINT NOT NULL COMMENT '指标定义ID(indicator_definitions.id)', " + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, " + "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY `uk_user_family_member_source_def` (`user_id`, `family_id`, `member_id`, `source_type`, `definition_id`), " + "KEY `idx_member_source` (`member_id`, `source_type`), " + "KEY `idx_user_created` (`user_id`, `created_at`) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户关注指标收藏'"); log.info("迁移429: 已创建 indicator_favorite 表"); } catch (Exception e) { log.warn("迁移429: 创建 indicator_favorite 表失败(可能已存在): {}", e.getMessage()); } } // ==================== 迁移433:家庭指标聚合方式配置(五维能量汇总/珍珠图/章鱼图汇集配置) ==================== /** * 迁移433: 家庭指标聚合方式配置表 family_metric_agg_config。 * 平台级全局一份,按数据项一行一条。 * 预置 8 行数据:五维各维度 AVG + 综合 AVG + pearl.resources COLLECT + octopus.effects COLLECT */ void createFamilyMetricAggConfigTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `family_metric_agg_config` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`metric_key` VARCHAR(64) NOT NULL COMMENT '数据项唯一标识,如 energy.body、pearl.resources', " + "`metric_name` VARCHAR(64) NOT NULL COMMENT '中文名,如「身体维度能量分」', " + "`dimension_code` VARCHAR(32) COMMENT '所属维度:body/mind/wisdom/action/wealth/common', " + "`agg_type` VARCHAR(16) NOT NULL COMMENT '汇总方式:SUM=合计 / AVG=平均 / COLLECT=汇集', " + "`value_type` VARCHAR(16) NOT NULL COMMENT '值类型:NUMBER(数值)/ COLLECTION(集合)', " + "`display_mode` VARCHAR(32) COMMENT '展示形态:number/bar/radar/merge_graph', " + "`sort_order` INT DEFAULT 0 COMMENT '排序', " + "`status` VARCHAR(16) DEFAULT 'active' COMMENT '状态:active/disabled', " + "`remark` VARCHAR(255) COMMENT '备注', " + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, " + "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY `uk_metric_key` (`metric_key`) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭指标聚合方式配置'"); log.info("迁移433: 已创建 family_metric_agg_config 表"); } catch (Exception e) { log.warn("迁移433: 创建 family_metric_agg_config 表失败(可能已存在): {}", e.getMessage()); } // 预置数据(幂等 upsert) upsertFamilyMetricAggConfig("energy.body", "身体维度能量分", "body", "AVG", "NUMBER", "number", 1, "active", "五维身体维度家庭汇总,默认平均值"); upsertFamilyMetricAggConfig("energy.mind", "心理维度能量分", "mind", "AVG", "NUMBER", "number", 2, "active", "五维心理维度家庭汇总,默认平均值"); upsertFamilyMetricAggConfig("energy.wisdom", "认知维度能量分", "wisdom", "AVG", "NUMBER", "number", 3, "active", "五维认知维度家庭汇总,默认平均值"); upsertFamilyMetricAggConfig("energy.action", "社交维度能量分", "action", "AVG", "NUMBER", "number", 4, "active", "五维社交维度家庭汇总,默认平均值"); upsertFamilyMetricAggConfig("energy.wealth", "成就维度能量分", "wealth", "AVG", "NUMBER", "number", 5, "active", "五维成就维度家庭汇总,默认平均值"); upsertFamilyMetricAggConfig("energy.overall", "综合能量分", "common", "AVG", "NUMBER", "number", 6, "active", "五维综合能量分家庭汇总,默认平均值"); upsertFamilyMetricAggConfig("pearl.resources", "珍珠图资源(我有什么)", "action", "COLLECT", "COLLECTION", "merge_graph", 7, "active", "珍珠图家庭汇集,合并成一张图按成员着色"); upsertFamilyMetricAggConfig("octopus.effects", "章鱼图关键人/成效", "action", "COLLECT", "COLLECTION", "merge_graph", 8, "active", "章鱼图家庭汇集,合并成一张图按成员着色"); } private void upsertFamilyMetricAggConfig(String metricKey, String metricName, String dimensionCode, String aggType, String valueType, String displayMode, Integer sortOrder, String status, String remark) { try { // 先尝试更新 int updated = jdbcTemplate.update( "UPDATE `family_metric_agg_config` SET metric_name=?, dimension_code=?, agg_type=?, value_type=?, display_mode=?, sort_order=?, status=?, remark=?, updated_at=CURRENT_TIMESTAMP WHERE metric_key=?", metricName, dimensionCode, aggType, valueType, displayMode, sortOrder, status, remark, metricKey); if (updated == 0) { // 不存在则插入 jdbcTemplate.update( "INSERT INTO `family_metric_agg_config` (metric_key, metric_name, dimension_code, agg_type, value_type, display_mode, sort_order, status, remark) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", metricKey, metricName, dimensionCode, aggType, valueType, displayMode, sortOrder, status, remark); log.info("迁移433: 已插入预置配置 {}", metricKey); } else { log.info("迁移433: 已更新预置配置 {}", metricKey); } } catch (Exception e) { log.warn("迁移433: 预置配置 {} 处理失败(忽略): {}", metricKey, e.getMessage()); } } void createPpointConsumptionLogTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `ppoint_consumption_log` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`user_id` BIGINT NOT NULL COMMENT '消费人用户ID(users.id)', " + "`member_id` BIGINT NULL COMMENT '消费人家庭成员ID(family_members.id)', " + "`family_id` BIGINT NULL COMMENT '消费家庭ID', " + "`order_id` BIGINT NOT NULL COMMENT '订单ID', " + "`order_type` VARCHAR(32) NOT NULL COMMENT '订单类型(product/package/assessment/membership/subscription)', " + "`product_id` BIGINT NULL COMMENT '商品ID(商品订单)', " + "`amount_cent` INT NULL COMMENT '实付金额(分)', " + "`ppoints` INT NOT NULL DEFAULT 0 COMMENT '本单产生的P点', " + "`remark` VARCHAR(255) NULL, " + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '消费时间(订单支付成功时刻)', " + "UNIQUE KEY `uk_order` (`order_type`, `order_id`), " + "KEY `idx_user_time` (`user_id`, `created_at`) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='P点消费日志(滚动一年销售奖档位聚合数据源,F-4)'"); log.info("迁移420: 已创建 ppoint_consumption_log 表"); } catch (Exception e) { log.warn("迁移420: 创建 ppoint_consumption_log 表失败(可能已存在): {}", e.getMessage()); } } // ==================== 迁移424:家庭归因阶段一·数据地基 ==================== /** * 迁移424: health_plans 加 plan_type,区分健康方案与家庭归因干预方案(G5)。 * 默认 'health_plan',存量行自动命中默认值;幂等(列已存在则跳过)。 */ void migrateHealthPlansPlanType() { ensureColumn("health_plans", "plan_type", "VARCHAR(32) DEFAULT 'health_plan' COMMENT '方案类型: health_plan=健康方案, family_attribution_plan=家庭归因方案'"); } // ==================== 迁移425:家庭归因常模值表(G10) ==================== /** 菌群常模 JSON 种子路径(classpath) */ static final String NORM_GUT_FLORA_CLASSPATH = "norm/attribution_norm_gut_flora.json"; /** 家庭归因确认问卷模板 JSON 种子路径(classpath) */ static final String SURVEY_FAMILY_ATTRIBUTION_CLASSPATH = "survey/family_attribution_questions.json"; /** 家庭归因确认问卷模板标题(幂等判重键) */ static final String SURVEY_FAMILY_ATTRIBUTION_TITLE = "家庭归因分析问卷(第八章16条)"; /** * 迁移425: 家庭归因常模值表 attribution_norm_values。 * 菌群 710 常模落点;DAN 常模优先复用 cognitive_norms / health_norm_reference,不重复建。 */ void createAttributionNormValuesTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `attribution_norm_values` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`norm_type` VARCHAR(20) NOT NULL COMMENT '常模类型: gut_flora(菌群)/dan(DAN测评)', " + "`definition_id` BIGINT NULL COMMENT 'FK→indicator_definitions.id(归一化后关联,可空)', " + "`code` VARCHAR(64) NOT NULL COMMENT '指标编码(菌种学名如Bifidobacterium 或 DAN维度key如emotionScore)', " + "`display_name` VARCHAR(100) COMMENT '显示名', " + "`member_age_band` VARCHAR(32) NOT NULL COMMENT '年龄段带: 0-3/4-6/7-12/13-18/19-45/46-65/66+/all', " + "`gender` VARCHAR(10) DEFAULT 'all' COMMENT '性别: male/female/all', " + "`p5` DECIMAL(10,4) NULL COMMENT 'P5百分位值', " + "`p25` DECIMAL(10,4) NULL COMMENT 'P25百分位值', " + "`p50` DECIMAL(10,4) NULL COMMENT 'P50百分位值(中位数)', " + "`p75` DECIMAL(10,4) NULL COMMENT 'P75百分位值', " + "`p95` DECIMAL(10,4) NULL COMMENT 'P95百分位值', " + "`unit` VARCHAR(20) COMMENT '单位(如 %)', " + "`norm_version` VARCHAR(20) NOT NULL DEFAULT 'v1' COMMENT '常模版本', " + "`source` VARCHAR(200) COMMENT '数据来源(谷禾710常模/DAN常模/导入文件名)', " + "`status` TINYINT DEFAULT 1 COMMENT '0=禁用 1=启用', " + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, " + "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY `uk_type_code_band_gender_ver` (`norm_type`, `code`, `member_age_band`, `gender`, `norm_version`), " + "INDEX `idx_type_code` (`norm_type`, `code`), " + "INDEX `idx_def` (`definition_id`) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭归因常模值'"); log.info("迁移425: 已创建 attribution_norm_values 表"); } catch (Exception e) { log.warn("迁移425: 创建 attribution_norm_values 表失败(可能已存在): {}", e.getMessage()); } } /** * 迁移425-导入: 菌群常模可配置 JSON 导入(norm_type=gut_flora)。 * 空壳模板(placeholder=true / data 为空)→ 0 行导入且不报错; * 真实数据到位后填充 classpath:norm/attribution_norm_gut_flora.json 即可增量导入。 */ void importAttributionNormGutFlora() { try { ClassPathResource resource = new ClassPathResource(NORM_GUT_FLORA_CLASSPATH); if (!resource.exists()) { log.warn("迁移425: 未找到常模种子文件 {},跳过导入", NORM_GUT_FLORA_CLASSPATH); return; } String content = StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8); if (content.trim().isEmpty()) { log.info("迁移425: 常模种子文件为空,0 行导入"); return; } ObjectMapper mapper = new ObjectMapper(); JsonNode root = mapper.readTree(content); if (root == null || !root.isObject()) { log.warn("迁移425: 常模种子文件格式非法(期望 JSON 对象),0 行导入"); return; } if (root.path("placeholder").asBoolean(false)) { log.info("迁移425: 常模种子为占位模板(placeholder=true),0 行导入,等待业务侧填充真实常模"); return; } JsonNode data = root.get("data"); if (data == null || !data.isArray() || data.size() == 0) { log.info("迁移425: 常模种子 data 为空,0 行导入"); return; } int inserted = 0; for (JsonNode row : data) { String code = text(row, "code"); if (code == null || code.trim().isEmpty()) { continue; } String ageBand = defaultIfBlank(text(row, "memberAgeBand"), "all"); String gender = defaultIfBlank(text(row, "gender"), "all"); String normVersion = defaultIfBlank(text(row, "normVersion"), "v1"); jdbcTemplate.update( "INSERT INTO attribution_norm_values " + "(norm_type, definition_id, code, display_name, member_age_band, gender, " + "p5, p25, p50, p75, p95, unit, norm_version, source, status, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, NOW(), NOW()) " + "ON DUPLICATE KEY UPDATE definition_id=VALUES(definition_id), display_name=VALUES(display_name), " + "p5=VALUES(p5), p25=VALUES(p25), p50=VALUES(p50), p75=VALUES(p75), p95=VALUES(p95), " + "unit=VALUES(unit), source=VALUES(source), status=VALUES(status), updated_at=NOW()", "gut_flora", longOrNull(row.get("definitionId")), code.trim(), text(row, "displayName"), ageBand, gender, decimalOrNull(row.get("p5")), decimalOrNull(row.get("p25")), decimalOrNull(row.get("p50")), decimalOrNull(row.get("p75")), decimalOrNull(row.get("p95")), text(row, "unit"), normVersion, text(row, "source")); inserted++; } log.info("迁移425: 菌群常模导入完成,共 {} 行(norm_type=gut_flora,幂等 upsert)", inserted); } catch (Exception e) { log.warn("迁移425: 菌群常模导入失败(忽略): {}", e.getMessage()); } } /** * 迁移425-种子: 家庭归因确认问卷模板种子(A2:第八章 16 条 / 45 采集项)。 * 幂等:按 title 判重;已存在则只确保 enabled=1(不覆盖管理员改过的 questions_json); * questionsJson 存 §3.2 扩展结构(schemaVersion/target/questions)。 */ void seedFamilyAttributionSurveyTemplate() { try { ClassPathResource resource = new ClassPathResource(SURVEY_FAMILY_ATTRIBUTION_CLASSPATH); if (!resource.exists()) { log.warn("迁移425-种子: 未找到问卷模板种子 {},跳过", SURVEY_FAMILY_ATTRIBUTION_CLASSPATH); return; } Integer cnt = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM survey_templates WHERE title = ?", Integer.class, SURVEY_FAMILY_ATTRIBUTION_TITLE); if (cnt != null && cnt > 0) { // 已存在:幂等启用(不覆盖管理员可能改过的 questions_json) jdbcTemplate.update( "UPDATE survey_templates SET enabled = 1, updated_at = NOW() WHERE title = ?", SURVEY_FAMILY_ATTRIBUTION_TITLE); log.info("迁移425-种子: 家庭归因问卷模板已存在(title={}),幂等启用(enabled=1)", SURVEY_FAMILY_ATTRIBUTION_TITLE); return; } String content = StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8); // 校验 JSON 合法后再落库,避免脏种子进库 ObjectMapper mapper = new ObjectMapper(); JsonNode root = mapper.readTree(content); if (root == null || !root.isObject() || root.get("questions") == null) { log.warn("迁移425-种子: 问卷模板种子格式非法,跳过"); return; } jdbcTemplate.update( "INSERT INTO survey_templates (title, dimension, questions_json, cycle_type, ai_generated, enabled, created_at, updated_at) " + "VALUES (?, ?, ?, ?, 0, 1, NOW(), NOW())", SURVEY_FAMILY_ATTRIBUTION_TITLE, "family_attribution", content, "once"); log.info("迁移425-种子: 已写入家庭归因问卷模板种子 title={} questions={}(enabled=1,已启用)", SURVEY_FAMILY_ATTRIBUTION_TITLE, root.get("questions").size()); } catch (Exception e) { log.warn("迁移425-种子: 家庭归因问卷模板种子失败(忽略): {}", e.getMessage()); } } // ==================== 迁移426:家庭归因报告表(B1 核心闭环③) ==================== /** * 迁移426: 家庭归因报告表 attribution_reports。 * 存十节结构 report_json + 归因中间态 intermediate_state(供 B2 条件路由 / B3 回填) * + 红线命中 redline_hits + 状态机 status(ANALYZING/READY/ADJUSTING/ADJUSTED/CONFIRMED/GENERATED/INVALIDATED)。 * 幂等:CREATE TABLE IF NOT EXISTS。 */ void createAttributionReportsTable() { try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `attribution_reports` (" + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " + "`family_id` BIGINT NOT NULL COMMENT '家庭ID', " + "`member_ids` VARCHAR(200) NULL COMMENT '目标成员ID列表(逗号分隔;E6: 妈妈/女儿/儿子)', " + "`plan_id` BIGINT NULL COMMENT '关联健康方案ID(health_plans.id)', " + "`status` VARCHAR(20) NOT NULL DEFAULT 'ANALYZING' " + "COMMENT '状态机: ANALYZING/READY/ADJUSTING/ADJUSTED/CONFIRMED/GENERATED/INVALIDATED', " + "`report_json` MEDIUMTEXT NULL COMMENT '十节结构化归因报告(JSON)', " + "`redline_hits` VARCHAR(255) NULL COMMENT '红线命中编号(JSON数组, 如 [\"RL02\"])', " + "`intermediate_state` MEDIUMTEXT NULL COMMENT '归因中间态(JSON, 供B2条件路由/B3回填)', " + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, " + "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX `idx_family` (`family_id`), " + "INDEX `idx_status` (`status`), " + "INDEX `idx_plan` (`plan_id`) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭归因分析报告(阶段二核心闭环)'"); log.info("迁移426: 已创建 attribution_reports 表"); } catch (Exception e) { log.warn("迁移426: 创建 attribution_reports 表失败(可能已存在): {}", e.getMessage()); } // 迁移435: product_bundle_items 增加套餐SKU锚点列(订单套餐自动定价) // bundle_sku_id 非空 = 定价套餐行(套餐价取该SKU.price,CartService/ProductOrderService 匹配用); // 为空 = 仅出库联动行(InventoryService 语义,不参与定价)。 ensureColumn("product_bundle_items", "bundle_sku_id", "BIGINT NULL COMMENT '套餐SKU锚点:非空=订单套餐自动定价(套餐价取该SKU.price);空=仅出库联动'"); // 同时放开 bundle_product_id 可空:定价套餐行置 NULL,避免 InventoryService // (按 bundle_product_id 关联、仅 productType=bundle 商品)误触发出库联动。 try { jdbcTemplate.execute("ALTER TABLE product_bundle_items MODIFY COLUMN bundle_product_id BIGINT NULL COMMENT '套餐商品ID(出库联动行=套餐商品ID;定价套餐行置NULL)'"); log.info("迁移435: product_bundle_items.bundle_product_id 已放开可空"); } catch (Exception e) { log.warn("迁移427: 放开 bundle_product_id 可空失败(可能已放开): {}", e.getMessage()); } } private static String text(JsonNode row, String field) { if (row == null) return null; JsonNode n = row.get(field); if (n == null || n.isNull()) return null; String s = n.asText(); return s == null || s.trim().isEmpty() ? null : s; } private static String defaultIfBlank(String v, String def) { return (v == null || v.trim().isEmpty()) ? def : v; } private static Long longOrNull(JsonNode n) { if (n == null || n.isNull()) return null; return n.isNumber() ? n.longValue() : null; } private static java.math.BigDecimal decimalOrNull(JsonNode n) { if (n == null || n.isNull() || n.asText().trim().isEmpty()) return null; try { return new java.math.BigDecimal(n.asText().trim()); } catch (NumberFormatException e) { return null; } } }