Pārlūkot izejas kodu

liquibase配置文件

yuhongqi 5 dienas atpakaļ
vecāks
revīzija
eb4fe401f2
22 mainītis faili ar 964 papildinājumiem un 1 dzēšanām
  1. 5 1
      fs-admin/pom.xml
  2. 5 0
      fs-company/pom.xml
  3. 5 0
      fs-service/pom.xml
  4. 3 0
      fs-service/src/main/java/com/fs/core/config/WxMaConfiguration.java
  5. 137 0
      fs-service/src/main/java/com/fs/framework/liquibase/ChangelogMd5Service.java
  6. 34 0
      fs-service/src/main/java/com/fs/framework/liquibase/LiquibaseAutoConfiguration.java
  7. 102 0
      fs-service/src/main/java/com/fs/framework/liquibase/LiquibaseDependsOnConfigurer.java
  8. 128 0
      fs-service/src/main/java/com/fs/framework/liquibase/LiquibaseMetaRepository.java
  9. 192 0
      fs-service/src/main/java/com/fs/framework/liquibase/LiquibaseMigrationRunner.java
  10. 87 0
      fs-service/src/main/java/com/fs/framework/liquibase/LiquibaseProperties.java
  11. 139 0
      fs-service/src/main/java/com/fs/framework/liquibase/LiquibaseStartupGate.java
  12. 36 0
      fs-service/src/main/java/com/fs/framework/liquibase/MasterDataSourceValidator.java
  13. 2 0
      fs-service/src/main/resources/META-INF/spring-devtools.properties
  14. 5 0
      fs-service/src/main/resources/META-INF/spring.factories
  15. 10 0
      fs-service/src/main/resources/application-common.yml
  16. 6 0
      fs-service/src/main/resources/db/changelog/baseline/baseline.sql
  17. 15 0
      fs-service/src/main/resources/db/changelog/baseline/init-meta.sql
  18. 17 0
      fs-service/src/main/resources/db/changelog/changes/20260613-live-user-add-is-del.sql
  19. 14 0
      fs-service/src/main/resources/db/changelog/db.changelog-master.xml
  20. 9 0
      fs-service/src/main/resources/db/changelog/draft/20260615-live-user-drop-is-del.sql
  21. 5 0
      fs-user-app/pom.xml
  22. 8 0
      pom.xml

+ 5 - 1
fs-admin/pom.xml

@@ -44,7 +44,11 @@
             <artifactId>fs-framework</artifactId>
         </dependency>
 
-
+        <!-- Liquibase(启动模块显式依赖) -->
+        <dependency>
+            <groupId>org.liquibase</groupId>
+            <artifactId>liquibase-core</artifactId>
+        </dependency>
 
         <!-- 定时任务-->
         <dependency>

+ 5 - 0
fs-company/pom.xml

@@ -38,6 +38,11 @@
             <artifactId>swagger-bootstrap-ui</artifactId>
             <version>1.9.3</version>
         </dependency>
+        <!-- Liquibase(启动模块显式依赖) -->
+        <dependency>
+            <groupId>org.liquibase</groupId>
+            <artifactId>liquibase-core</artifactId>
+        </dependency>
 
         <dependency>
             <groupId>com.github.javen205</groupId>

+ 5 - 0
fs-service/pom.xml

@@ -42,6 +42,11 @@
             <groupId>com.fs</groupId>
             <artifactId>fs-common</artifactId>
         </dependency>
+        <!-- Liquibase(启动模块显式依赖,避免 IDE/devtools classpath 缺失) -->
+        <dependency>
+            <groupId>org.liquibase</groupId>
+            <artifactId>liquibase-core</artifactId>
+        </dependency>
         <dependency>
             <groupId>org.projectlombok</groupId>
             <artifactId>lombok</artifactId>

+ 3 - 0
fs-service/src/main/java/com/fs/core/config/WxMaConfiguration.java

@@ -16,6 +16,7 @@ import com.fs.course.config.CourseMaConfig;
 import com.fs.course.domain.FsCoursePlaySourceConfig;
 import com.fs.course.mapper.FsCoursePlaySourceConfigMapper;
 import com.fs.course.service.IFsCoursePlaySourceConfigService;
+import com.fs.framework.liquibase.LiquibaseStartupGate;
 import com.fs.system.domain.SysConfig;
 import com.fs.system.mapper.SysConfigMapper;
 import com.google.common.collect.Lists;
@@ -30,6 +31,7 @@ import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.annotation.ComponentScan;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.DependsOn;
 import org.yaml.snakeyaml.events.Event;
 
 import javax.annotation.PostConstruct;
@@ -44,6 +46,7 @@ import java.util.stream.Collectors;
 @Slf4j
 @Configuration
 @ComponentScan("com.fs.system.mapper")
+@DependsOn(LiquibaseStartupGate.BEAN_NAME)
 public class WxMaConfiguration {
     private final WxMaConfig properties;
 

+ 137 - 0
fs-service/src/main/java/com/fs/framework/liquibase/ChangelogMd5Service.java

@@ -0,0 +1,137 @@
+package com.fs.framework.liquibase;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
+import org.springframework.util.StreamUtils;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * 计算 changelog 目录 bundle MD5。
+ */
+public class ChangelogMd5Service {
+
+    private static final Logger log = LoggerFactory.getLogger(ChangelogMd5Service.class);
+
+    private final LiquibaseProperties properties;
+    private final PathMatchingResourcePatternResolver resourceResolver = new PathMatchingResourcePatternResolver();
+
+    public ChangelogMd5Service(LiquibaseProperties properties) {
+        this.properties = properties;
+    }
+
+    public BundleMd5Result computeBundleMd5() {
+        try {
+            String pattern = buildScanPattern();
+            Resource[] resources = resourceResolver.getResources(pattern);
+            List<Resource> matched = new ArrayList<>();
+            for (Resource resource : resources) {
+                if (resource.exists() && resource.isReadable() && !isDirectoryResource(resource)
+                        && matchExtension(resource.getFilename())
+                        && isActiveChangelogResource(resource)) {
+                    matched.add(resource);
+                }
+            }
+            matched.sort(Comparator.comparing(this::resourceKey));
+
+            StringBuilder payload = new StringBuilder();
+            for (Resource resource : matched) {
+                payload.append(resourceKey(resource)).append('\n');
+                payload.append(StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8));
+                payload.append('\n');
+            }
+            String md5 = md5Hex(payload.toString());
+            log.info("Liquibase bundle MD5 计算完成: md5={}, fileCount={}", md5, matched.size());
+            return new BundleMd5Result(md5, matched.size());
+        } catch (IOException ex) {
+            throw new IllegalStateException("计算 Liquibase changelog bundle MD5 失败", ex);
+        }
+    }
+
+    private String buildScanPattern() {
+        String root = properties.getChangelogRoot();
+        if (root.startsWith("classpath:")) {
+            root = root.substring("classpath:".length());
+        }
+        if (!root.endsWith("/")) {
+            root = root + "/";
+        }
+        return "classpath*:" + root + "**/*";
+    }
+
+    private boolean isDirectoryResource(Resource resource) throws IOException {
+        return resource.getURL().toString().endsWith("/");
+    }
+
+    private boolean isActiveChangelogResource(Resource resource) {
+        try {
+            String path = resource.getURI().toString().replace('\\', '/');
+            return !path.contains("/draft/");
+        } catch (IOException ex) {
+            return true;
+        }
+    }
+
+    private String resourceKey(Resource resource) {
+        try {
+            return resource.getURI().toString();
+        } catch (IOException ex) {
+            return resource.getDescription();
+        }
+    }
+
+    private boolean matchExtension(String filename) {
+        int index = filename.lastIndexOf('.');
+        if (index < 0) {
+            return false;
+        }
+        String ext = filename.substring(index + 1).toLowerCase(Locale.ROOT);
+        for (String allowed : properties.getIncludeExtensions()) {
+            if (allowed.equalsIgnoreCase(ext)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private String md5Hex(String content) {
+        try {
+            MessageDigest digest = MessageDigest.getInstance("MD5");
+            byte[] hash = digest.digest(content.getBytes(StandardCharsets.UTF_8));
+            StringBuilder builder = new StringBuilder(hash.length * 2);
+            for (byte value : hash) {
+                builder.append(String.format("%02x", value));
+            }
+            return builder.toString();
+        } catch (NoSuchAlgorithmException ex) {
+            throw new IllegalStateException("MD5 算法不可用", ex);
+        }
+    }
+
+    public static final class BundleMd5Result {
+        private final String md5;
+        private final int fileCount;
+
+        public BundleMd5Result(String md5, int fileCount) {
+            this.md5 = md5;
+            this.fileCount = fileCount;
+        }
+
+        public String getMd5() {
+            return md5;
+        }
+
+        public int getFileCount() {
+            return fileCount;
+        }
+    }
+}

+ 34 - 0
fs-service/src/main/java/com/fs/framework/liquibase/LiquibaseAutoConfiguration.java

@@ -0,0 +1,34 @@
+package com.fs.framework.liquibase;
+
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.autoconfigure.AutoConfigureOrder;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.DependsOn;
+import org.springframework.core.Ordered;
+import org.springframework.core.env.Environment;
+
+import javax.sql.DataSource;
+
+/**
+ * Liquibase 启动门禁:不依赖 @ConditionalOnClass,避免 devtools 重启类加载器导致 gate Bean 未注册。
+ */
+@Configuration
+@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
+@ConditionalOnBean(name = "masterDataSource")
+@ConditionalOnProperty(prefix = "fs.liquibase", name = "enabled", havingValue = "true", matchIfMissing = true)
+@EnableConfigurationProperties(LiquibaseProperties.class)
+public class LiquibaseAutoConfiguration {
+
+    @Bean(name = LiquibaseStartupGate.BEAN_NAME)
+    @DependsOn("masterDataSource")
+    public LiquibaseStartupGate liquibaseStartupGate(
+            @Qualifier("masterDataSource") DataSource masterDataSource,
+            LiquibaseProperties properties,
+            Environment environment) {
+        return new LiquibaseStartupGate(masterDataSource, properties, environment);
+    }
+}

+ 102 - 0
fs-service/src/main/java/com/fs/framework/liquibase/LiquibaseDependsOnConfigurer.java

@@ -0,0 +1,102 @@
+package com.fs.framework.liquibase;
+
+import org.springframework.beans.BeansException;
+import org.springframework.beans.factory.config.BeanDefinition;
+import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.context.EnvironmentAware;
+import org.springframework.core.PriorityOrdered;
+import org.springframework.core.env.Environment;
+import org.springframework.util.ClassUtils;
+import org.springframework.util.StringUtils;
+
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/**
+ * 为业务 {@code @Configuration} 统一追加对 Liquibase 门禁 Bean 的依赖,
+ * 确保数据库 migration 完成后再初始化 WxMaConfiguration 等配置类。
+ */
+public class LiquibaseDependsOnConfigurer implements BeanFactoryPostProcessor, PriorityOrdered, EnvironmentAware {
+
+    private Environment environment;
+
+    public LiquibaseDependsOnConfigurer() {
+    }
+
+    @Override
+    public void setEnvironment(Environment environment) {
+        this.environment = environment;
+    }
+
+    @Override
+    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
+        if (!environment.getProperty("fs.liquibase.enabled", Boolean.class, true)) {
+            return;
+        }
+        if (!ClassUtils.isPresent("liquibase.Liquibase", getClass().getClassLoader())) {
+            return;
+        }
+        if (!beanFactory.containsBeanDefinition("masterDataSource")) {
+            return;
+        }
+
+        int affected = 0;
+        for (String beanName : beanFactory.getBeanDefinitionNames()) {
+            if (LiquibaseStartupGate.BEAN_NAME.equals(beanName) || isLiquibaseBean(beanName)) {
+                continue;
+            }
+            BeanDefinition definition = beanFactory.getBeanDefinition(beanName);
+            String beanClassName = resolveBeanClassName(definition);
+            if (!shouldDependOnLiquibase(beanClassName)) {
+                continue;
+            }
+            appendDependsOn(definition, LiquibaseStartupGate.BEAN_NAME);
+            affected++;
+        }
+        if (affected > 0) {
+            // 日志在 Bean 创建前无法使用 Slf4j 注入,此处仅做 debug 级别输出到标准日志框架由后续 gate 打印
+        }
+    }
+
+    private boolean isLiquibaseBean(String beanName) {
+        return beanName.startsWith("liquibase") || beanName.contains("Liquibase");
+    }
+
+    private String resolveBeanClassName(BeanDefinition definition) {
+        if (StringUtils.hasText(definition.getBeanClassName())) {
+            return definition.getBeanClassName();
+        }
+        return null;
+    }
+
+    private boolean shouldDependOnLiquibase(String beanClassName) {
+        if (!StringUtils.hasText(beanClassName) || !beanClassName.startsWith("com.fs.")) {
+            return false;
+        }
+        if (beanClassName.startsWith("com.fs.framework.liquibase.")) {
+            return false;
+        }
+        if (beanClassName.endsWith("DataSourceConfig") || beanClassName.endsWith("DruidConfig")) {
+            return false;
+        }
+        return beanClassName.endsWith("Configuration");
+    }
+
+    private void appendDependsOn(BeanDefinition definition, String dependsOnBean) {
+        String[] existing = definition.getDependsOn();
+        Set<String> merged = new LinkedHashSet<>();
+        if (existing != null) {
+            merged.addAll(Arrays.asList(existing));
+        }
+        if (merged.add(dependsOnBean)) {
+            definition.setDependsOn(merged.toArray(new String[0]));
+        }
+    }
+
+    @Override
+    public int getOrder() {
+        return PriorityOrdered.LOWEST_PRECEDENCE;
+    }
+}

+ 128 - 0
fs-service/src/main/java/com/fs/framework/liquibase/LiquibaseMetaRepository.java

@@ -0,0 +1,128 @@
+package com.fs.framework.liquibase;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.sql.DataSource;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.sql.Timestamp;
+import java.util.regex.Pattern;
+
+/**
+ * 读写 database_init_meta 表中的 bundle MD5。
+ */
+public class LiquibaseMetaRepository {
+
+    private static final Logger log = LoggerFactory.getLogger(LiquibaseMetaRepository.class);
+    private static final Pattern TABLE_NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_]+$");
+    private static final long META_ROW_ID = 1L;
+
+    private final LiquibaseProperties properties;
+
+    public LiquibaseMetaRepository(LiquibaseProperties properties) {
+        this.properties = properties;
+    }
+
+    public String loadBundleMd5(DataSource dataSource) {
+        assertTableName();
+        String sql = "SELECT bundle_md5 FROM " + properties.getMetaTable() + " WHERE id = ?";
+        try (Connection connection = dataSource.getConnection();
+             PreparedStatement statement = connection.prepareStatement(sql)) {
+            statement.setLong(1, META_ROW_ID);
+            try (ResultSet rs = statement.executeQuery()) {
+                if (rs.next()) {
+                    return rs.getString("bundle_md5");
+                }
+            }
+        } catch (SQLException ex) {
+            if (isTableNotExists(ex)) {
+                log.info("Liquibase 元数据表 {} 尚未创建,视为首次接入", properties.getMetaTable());
+                return null;
+            }
+            throw new IllegalStateException("读取 Liquibase MD5 元数据失败", ex);
+        }
+        return null;
+    }
+
+    public void saveBundleMd5(DataSource dataSource, String bundleMd5, int fileCount, String moduleName) {
+        assertTableName();
+        String sql = "INSERT INTO " + properties.getMetaTable()
+                + " (id, bundle_md5, file_count, last_module, update_time, remark) "
+                + "VALUES (?, ?, ?, ?, ?, ?) "
+                + "ON DUPLICATE KEY UPDATE bundle_md5 = VALUES(bundle_md5), "
+                + "file_count = VALUES(file_count), "
+                + "last_module = VALUES(last_module), "
+                + "update_time = VALUES(update_time), "
+                + "remark = VALUES(remark)";
+        try (Connection connection = dataSource.getConnection();
+             PreparedStatement statement = connection.prepareStatement(sql)) {
+            statement.setLong(1, META_ROW_ID);
+            statement.setString(2, bundleMd5);
+            statement.setInt(3, fileCount);
+            statement.setString(4, moduleName);
+            statement.setTimestamp(5, new Timestamp(System.currentTimeMillis()));
+            statement.setString(6, "Liquibase startup gate");
+            statement.executeUpdate();
+            log.info("Liquibase bundle MD5 已写入 {}: md5={}", properties.getMetaTable(), bundleMd5);
+        } catch (SQLException ex) {
+            if (isTableNotExists(ex)) {
+                throw new IllegalStateException(
+                        "Liquibase 元数据表 " + properties.getMetaTable() + " 不存在,请先执行 baseline changeset", ex);
+            }
+            throw new IllegalStateException("写入 Liquibase MD5 元数据失败", ex);
+        }
+    }
+
+    public boolean metaTableExists(DataSource dataSource) {
+        assertTableName();
+        try (Connection connection = dataSource.getConnection();
+             Statement statement = connection.createStatement();
+             ResultSet rs = statement.executeQuery("SHOW TABLES LIKE '" + properties.getMetaTable() + "'")) {
+            return rs.next();
+        } catch (SQLException ex) {
+            throw new IllegalStateException("检测 Liquibase 元数据表失败", ex);
+        }
+    }
+
+    /**
+     * 兜底创建 MD5 元数据表(已有库首次接入或 changeset 被 MARK_RAN 跳过时使用)。
+     */
+    public void ensureMetaTable(DataSource dataSource) {
+        if (metaTableExists(dataSource)) {
+            return;
+        }
+        assertTableName();
+        String ddl = "CREATE TABLE IF NOT EXISTS " + properties.getMetaTable() + " ("
+                + "id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',"
+                + "bundle_md5 VARCHAR(32) NOT NULL COMMENT 'changelog bundle MD5',"
+                + "bundle_version VARCHAR(64) NULL COMMENT '可选版本号',"
+                + "file_count INT NOT NULL COMMENT '参与计算的文件数',"
+                + "last_module VARCHAR(64) NULL COMMENT '最后成功校验的模块',"
+                + "update_time DATETIME NOT NULL COMMENT 'MD5 更新时间',"
+                + "remark VARCHAR(255) NULL COMMENT '备注',"
+                + "PRIMARY KEY (id)"
+                + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Liquibase changelog bundle MD5 元数据'";
+        try (Connection connection = dataSource.getConnection();
+             Statement statement = connection.createStatement()) {
+            statement.execute(ddl);
+            log.info("已自动创建 Liquibase 元数据表 {}", properties.getMetaTable());
+        } catch (SQLException ex) {
+            throw new IllegalStateException("创建 Liquibase 元数据表失败", ex);
+        }
+    }
+
+    private void assertTableName() {
+        if (!TABLE_NAME_PATTERN.matcher(properties.getMetaTable()).matches()) {
+            throw new IllegalStateException("非法的 Liquibase 元数据表名: " + properties.getMetaTable());
+        }
+    }
+
+    private boolean isTableNotExists(SQLException ex) {
+        String message = ex.getMessage();
+        return message != null && (message.contains("doesn't exist") || message.contains("does not exist"));
+    }
+}

+ 192 - 0
fs-service/src/main/java/com/fs/framework/liquibase/LiquibaseMigrationRunner.java

@@ -0,0 +1,192 @@
+package com.fs.framework.liquibase;
+
+import liquibase.Liquibase;
+import liquibase.changelog.ChangeSet;
+import liquibase.database.Database;
+import liquibase.database.DatabaseFactory;
+import liquibase.database.jvm.JdbcConnection;
+import liquibase.resource.ClassLoaderResourceAccessor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.sql.DataSource;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * 执行 Liquibase 迁移并统计 pending changeset。
+ */
+public class LiquibaseMigrationRunner {
+
+    private static final Logger log = LoggerFactory.getLogger(LiquibaseMigrationRunner.class);
+
+    private final LiquibaseProperties properties;
+
+    public LiquibaseMigrationRunner(LiquibaseProperties properties) {
+        this.properties = properties;
+    }
+
+    public MigrationAudit audit(DataSource dataSource) {
+        configureLockWait();
+        try (Connection connection = dataSource.getConnection()) {
+            Database database = createDatabase(connection);
+            Liquibase liquibase = createLiquibase(database);
+            List<ChangeSet> all = liquibase.getDatabaseChangeLog().getChangeSets();
+            List<ChangeSet> pending = liquibase.listUnrunChangeSets(null);
+            Set<String> parsedKeys = toChangeSetKeys(all);
+            Set<String> pendingKeys = toChangeSetKeys(pending);
+            Set<String> executedKeys = loadExecutedChangeSetKeys(dataSource);
+            Set<String> missingInDb = new LinkedHashSet<>(parsedKeys);
+            missingInDb.removeAll(executedKeys);
+
+            log.info("Liquibase 审计: 已解析={}, 待执行={}, DATABASECHANGELOG 已记录={}, 缺失记录={}",
+                    parsedKeys.size(), pendingKeys.size(), executedKeys.size(), missingInDb.size());
+            for (ChangeSet changeSet : pending) {
+                log.info("待执行 changeset -> id={}, author={}, file={}",
+                        changeSet.getId(), changeSet.getAuthor(), changeSet.getFilePath());
+            }
+            for (String missing : missingInDb) {
+                log.warn("DATABASECHANGELOG 缺失 changeset -> {}", missing);
+            }
+            return new MigrationAudit(parsedKeys.size(), pendingKeys.size(), parsedKeys, pendingKeys, missingInDb);
+        } catch (Exception ex) {
+            throw new IllegalStateException("Liquibase migration 审计失败", ex);
+        }
+    }
+
+    public int countPendingChangeSets(DataSource dataSource) {
+        return audit(dataSource).getPendingTotal();
+    }
+
+    public void update(DataSource dataSource) {
+        configureLockWait();
+        try (Connection connection = dataSource.getConnection()) {
+            connection.setAutoCommit(false);
+            Database database = createDatabase(connection);
+            Liquibase liquibase = createLiquibase(database);
+            try {
+                int totalChangeSets = liquibase.getDatabaseChangeLog().getChangeSets().size();
+                log.info("开始执行 Liquibase update, changelog={}, 已解析 changeset 总数={}",
+                        properties.getChangelog(), totalChangeSets);
+                if (totalChangeSets == 0) {
+                    throw new IllegalStateException(
+                            "Liquibase 未解析到任何 changeset,请检查 master changelog 是否正确(SQL master 不支持 --include,需使用 XML)");
+                }
+                liquibase.update((String) null);
+                connection.commit();
+                log.info("Liquibase update 执行成功");
+            } catch (Exception ex) {
+                try {
+                    connection.rollback();
+                } catch (Exception rollbackEx) {
+                    log.warn("Liquibase update 回滚失败", rollbackEx);
+                }
+                throw new IllegalStateException("Liquibase 迁移失败,已回滚,禁止启动", ex);
+            }
+        } catch (IllegalStateException ex) {
+            throw ex;
+        } catch (Exception ex) {
+            throw new IllegalStateException("Liquibase 迁移失败,已回滚,禁止启动", ex);
+        }
+    }
+
+    public Set<String> loadExecutedChangeSetKeys(DataSource dataSource) {
+        Set<String> keys = new LinkedHashSet<>();
+        String sql = "SELECT ID, AUTHOR FROM DATABASECHANGELOG";
+        try (Connection connection = dataSource.getConnection();
+             Statement statement = connection.createStatement();
+             ResultSet rs = statement.executeQuery(sql)) {
+            while (rs.next()) {
+                keys.add(rs.getString("AUTHOR") + ":" + rs.getString("ID"));
+            }
+            return keys;
+        } catch (SQLException ex) {
+            if (isTableNotExists(ex)) {
+                return keys;
+            }
+            throw new IllegalStateException("读取 DATABASECHANGELOG 失败", ex);
+        }
+    }
+
+    private Set<String> toChangeSetKeys(List<ChangeSet> changeSets) {
+        Set<String> keys = new LinkedHashSet<>();
+        for (ChangeSet changeSet : changeSets) {
+            keys.add(changeSet.getAuthor() + ":" + changeSet.getId());
+        }
+        return keys;
+    }
+
+    private Database createDatabase(Connection connection) throws Exception {
+        return DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
+    }
+
+    private Liquibase createLiquibase(Database database) throws Exception {
+        ClassLoader classLoader = getClass().getClassLoader();
+        if (classLoader.getResource(properties.getChangelog()) == null) {
+            throw new IllegalStateException("未找到 master changelog: " + properties.getChangelog());
+        }
+        Liquibase liquibase = new Liquibase(properties.getChangelog(), new ClassLoaderResourceAccessor(), database);
+        int parsed = liquibase.getDatabaseChangeLog().getChangeSets().size();
+        if (parsed == 0) {
+            throw new IllegalStateException(
+                    "Liquibase 未解析到 changeset,请检查 SQL 文件是否包含 "
+                            + "'--liquibase formatted sql' 及 '--changeset author:id' 格式");
+        }
+        return liquibase;
+    }
+
+    private void configureLockWait() {
+        System.setProperty("liquibase.changelogLockWaitTimeInMinutes",
+                String.valueOf(properties.getLockWaitTimeoutMinutes()));
+    }
+
+    private boolean isTableNotExists(SQLException ex) {
+        String message = ex.getMessage();
+        return message != null && (message.contains("doesn't exist") || message.contains("does not exist"));
+    }
+
+    public static final class MigrationAudit {
+        private final int parsedTotal;
+        private final int pendingTotal;
+        private final Set<String> parsedKeys;
+        private final Set<String> pendingKeys;
+        private final Set<String> missingInDatabase;
+
+        public MigrationAudit(int parsedTotal,
+                              int pendingTotal,
+                              Set<String> parsedKeys,
+                              Set<String> pendingKeys,
+                              Set<String> missingInDatabase) {
+            this.parsedTotal = parsedTotal;
+            this.pendingTotal = pendingTotal;
+            this.parsedKeys = parsedKeys;
+            this.pendingKeys = pendingKeys;
+            this.missingInDatabase = missingInDatabase;
+        }
+
+        public int getParsedTotal() {
+            return parsedTotal;
+        }
+
+        public int getPendingTotal() {
+            return pendingTotal;
+        }
+
+        public Set<String> getParsedKeys() {
+            return parsedKeys;
+        }
+
+        public Set<String> getPendingKeys() {
+            return pendingKeys;
+        }
+
+        public Set<String> getMissingInDatabase() {
+            return missingInDatabase;
+        }
+    }
+}

+ 87 - 0
fs-service/src/main/java/com/fs/framework/liquibase/LiquibaseProperties.java

@@ -0,0 +1,87 @@
+package com.fs.framework.liquibase;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import java.util.Arrays;
+import java.util.List;
+
+@ConfigurationProperties(prefix = "fs.liquibase")
+public class LiquibaseProperties {
+
+    /** 是否启用启动门禁 */
+    private boolean enabled = true;
+
+    /** 校验失败是否禁止启动 */
+    private boolean failFast = true;
+
+    /** Liquibase 主 changelog(classpath 相对路径) */
+    private String changelog = "db/changelog/db.changelog-master.xml";
+
+    /** bundle MD5 扫描根路径 */
+    private String changelogRoot = "classpath:db/changelog/";
+
+    /** MD5 元数据表名 */
+    private String metaTable = "database_init_meta";
+
+    /** Liquibase 全局锁等待时间(分钟) */
+    private int lockWaitTimeoutMinutes = 2;
+
+    /** 参与 MD5 计算的文件扩展名 */
+    private List<String> includeExtensions = Arrays.asList("sql", "xml", "yaml", "yml");
+
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    public void setEnabled(boolean enabled) {
+        this.enabled = enabled;
+    }
+
+    public boolean isFailFast() {
+        return failFast;
+    }
+
+    public void setFailFast(boolean failFast) {
+        this.failFast = failFast;
+    }
+
+    public String getChangelog() {
+        return changelog;
+    }
+
+    public void setChangelog(String changelog) {
+        this.changelog = changelog;
+    }
+
+    public String getChangelogRoot() {
+        return changelogRoot;
+    }
+
+    public void setChangelogRoot(String changelogRoot) {
+        this.changelogRoot = changelogRoot;
+    }
+
+    public String getMetaTable() {
+        return metaTable;
+    }
+
+    public void setMetaTable(String metaTable) {
+        this.metaTable = metaTable;
+    }
+
+    public int getLockWaitTimeoutMinutes() {
+        return lockWaitTimeoutMinutes;
+    }
+
+    public void setLockWaitTimeoutMinutes(int lockWaitTimeoutMinutes) {
+        this.lockWaitTimeoutMinutes = lockWaitTimeoutMinutes;
+    }
+
+    public List<String> getIncludeExtensions() {
+        return includeExtensions;
+    }
+
+    public void setIncludeExtensions(List<String> includeExtensions) {
+        this.includeExtensions = includeExtensions;
+    }
+}

+ 139 - 0
fs-service/src/main/java/com/fs/framework/liquibase/LiquibaseStartupGate.java

@@ -0,0 +1,139 @@
+package com.fs.framework.liquibase;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.InitializingBean;
+import org.springframework.core.env.Environment;
+import org.springframework.util.ClassUtils;
+import org.springframework.util.StringUtils;
+
+import javax.sql.DataSource;
+
+/**
+ * 应用启动门禁:校验 master 数据源、执行 pending migration、校验 changeset 执行记录、同步 bundle MD5。
+ */
+public class LiquibaseStartupGate implements InitializingBean {
+
+    public static final String BEAN_NAME = "liquibaseStartupGate";
+
+    private static final Logger log = LoggerFactory.getLogger(LiquibaseStartupGate.class);
+
+    private final DataSource masterDataSource;
+    private final LiquibaseProperties properties;
+    private final Environment environment;
+
+    public LiquibaseStartupGate(DataSource masterDataSource,
+                                LiquibaseProperties properties,
+                                Environment environment) {
+        this.masterDataSource = masterDataSource;
+        this.properties = properties;
+        this.environment = environment;
+    }
+
+    @Override
+    public void afterPropertiesSet() {
+        runGate();
+    }
+
+    public void runGate() {
+        if (!properties.isEnabled()) {
+            log.info("Liquibase 启动门禁已关闭 (fs.liquibase.enabled=false)");
+            return;
+        }
+
+        assertLiquibaseOnClasspath();
+
+        MasterDataSourceValidator masterDataSourceValidator = new MasterDataSourceValidator();
+        ChangelogMd5Service changelogMd5Service = new ChangelogMd5Service(properties);
+        LiquibaseMetaRepository liquibaseMetaRepository = new LiquibaseMetaRepository(properties);
+        LiquibaseMigrationRunner liquibaseMigrationRunner = new LiquibaseMigrationRunner(properties);
+        String moduleName = environment.getProperty("spring.application.name", "unknown-module");
+        log.info("模块 {} 开始 Liquibase 启动门禁校验(优先于业务 Bean 初始化)", moduleName);
+
+        masterDataSourceValidator.assertMasterExists(masterDataSource);
+
+        ChangelogMd5Service.BundleMd5Result bundleMd5Result = changelogMd5Service.computeBundleMd5();
+        String fileMd5 = bundleMd5Result.getMd5();
+
+        boolean metaTableExists = liquibaseMetaRepository.metaTableExists(masterDataSource);
+        String dbMd5 = liquibaseMetaRepository.loadBundleMd5(masterDataSource);
+        LiquibaseMigrationRunner.MigrationAudit audit = liquibaseMigrationRunner.audit(masterDataSource);
+        boolean md5Mismatch = StringUtils.hasText(dbMd5) && !fileMd5.equals(dbMd5);
+
+        assertChangeSetsParsable(audit);
+
+        if (audit.getPendingTotal() > 0) {
+            log.info("检测到 {} 条未执行 Liquibase changeset", audit.getPendingTotal());
+        }
+        if (!audit.getMissingInDatabase().isEmpty()) {
+            log.warn("检测到 {} 条 changeset 未写入 DATABASECHANGELOG", audit.getMissingInDatabase().size());
+        }
+        if (md5Mismatch) {
+            log.info("changelog bundle MD5 已变化: dbMd5={}, fileMd5={}", dbMd5, fileMd5);
+        }
+
+        boolean needUpdate = audit.getPendingTotal() > 0
+                || !audit.getMissingInDatabase().isEmpty()
+                || !metaTableExists
+                || !StringUtils.hasText(dbMd5)
+                || md5Mismatch;
+
+        if (needUpdate) {
+            liquibaseMigrationRunner.update(masterDataSource);
+            bootstrapMetaIfNeeded(liquibaseMetaRepository);
+            audit = liquibaseMigrationRunner.audit(masterDataSource);
+            assertMigrationCompleted(audit);
+        } else {
+            assertMigrationCompleted(audit);
+        }
+
+        ChangelogMd5Service.BundleMd5Result latestMd5 = changelogMd5Service.computeBundleMd5();
+        liquibaseMetaRepository.saveBundleMd5(
+                masterDataSource, latestMd5.getMd5(), latestMd5.getFileCount(), moduleName);
+
+        if (md5Mismatch) {
+            log.info("changelog 文件变更且 migration 校验通过,bundle MD5 已同步: {} -> {}",
+                    dbMd5, latestMd5.getMd5());
+        } else {
+            log.info("模块 {} Liquibase 启动门禁通过,bundle MD5={}", moduleName, latestMd5.getMd5());
+        }
+    }
+
+    private void assertChangeSetsParsable(LiquibaseMigrationRunner.MigrationAudit audit) {
+        if (audit.getParsedTotal() == 0) {
+            abort("Liquibase 未解析到任何 changeset,请确认 master changelog 使用 XML include 且已正确引用 SQL 文件");
+        }
+    }
+
+    private void assertMigrationCompleted(LiquibaseMigrationRunner.MigrationAudit audit) {
+        if (audit.getPendingTotal() > 0) {
+            abort("Liquibase update 后仍有 " + audit.getPendingTotal() + " 条未执行 changeset: "
+                    + audit.getPendingKeys());
+        }
+        if (!audit.getMissingInDatabase().isEmpty()) {
+            abort("以下 changeset 未写入 DATABASECHANGELOG: " + audit.getMissingInDatabase());
+        }
+    }
+
+    private void abort(String message) {
+        if (properties.isFailFast()) {
+            throw new IllegalStateException(message);
+        }
+        log.error("Liquibase 启动门禁校验失败(fail-fast=false): {}", message);
+    }
+
+    private void bootstrapMetaIfNeeded(LiquibaseMetaRepository liquibaseMetaRepository) {
+        if (!liquibaseMetaRepository.metaTableExists(masterDataSource)) {
+            liquibaseMetaRepository.ensureMetaTable(masterDataSource);
+        }
+    }
+
+    private void assertLiquibaseOnClasspath() {
+        if (!ClassUtils.isPresent("liquibase.Liquibase", getClass().getClassLoader())) {
+            throw new IllegalStateException(
+                    "已启用 fs.liquibase,但 classpath 未找到 liquibase-core。"
+                            + "请在启动模块 pom.xml 添加 org.liquibase:liquibase-core 依赖,"
+                            + "并关闭 devtools 后完整重启(或配置 spring-devtools restart.include.liquibase)");
+        }
+    }
+}

+ 36 - 0
fs-service/src/main/java/com/fs/framework/liquibase/MasterDataSourceValidator.java

@@ -0,0 +1,36 @@
+package com.fs.framework.liquibase;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.sql.DataSource;
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.sql.Statement;
+
+/**
+ * 校验 MySQL MASTER 主数据源是否可用。
+ */
+public class MasterDataSourceValidator {
+
+    private static final Logger log = LoggerFactory.getLogger(MasterDataSourceValidator.class);
+
+    public static final String ERROR_MESSAGE = "未检测到主数据源mysql的master";
+
+    public void assertMasterExists(DataSource masterDataSource) {
+        if (masterDataSource == null) {
+            throw new IllegalStateException(ERROR_MESSAGE);
+        }
+        try (Connection connection = masterDataSource.getConnection()) {
+            if (connection.isClosed()) {
+                throw new IllegalStateException(ERROR_MESSAGE);
+            }
+            try (Statement statement = connection.createStatement()) {
+                statement.execute("SELECT 1");
+            }
+        } catch (SQLException ex) {
+            log.error("主数据源 mysql master 连接失败", ex);
+            throw new IllegalStateException(ERROR_MESSAGE, ex);
+        }
+    }
+}

+ 2 - 0
fs-service/src/main/resources/META-INF/spring-devtools.properties

@@ -0,0 +1,2 @@
+# devtools 热重启时默认不加载三方 jar,需显式包含 liquibase-core
+restart.include.liquibase=/liquibase-core[\\w\\d\\.\\-]*\\.jar

+ 5 - 0
fs-service/src/main/resources/META-INF/spring.factories

@@ -0,0 +1,5 @@
+org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
+com.fs.framework.liquibase.LiquibaseAutoConfiguration
+
+org.springframework.beans.factory.config.BeanFactoryPostProcessor=\
+com.fs.framework.liquibase.LiquibaseDependsOnConfigurer

+ 10 - 0
fs-service/src/main/resources/application-common.yml

@@ -1,5 +1,13 @@
 # 项目相关配置
 fs:
+  liquibase:
+    enabled: true
+    fail-fast: true
+    changelog: db/changelog/db.changelog-master.xml
+    changelog-root: classpath:db/changelog/
+    meta-table: database_init_meta
+    lock-wait-timeout-minutes: 2
+    include-extensions: sql,xml,yaml,yml
   # 名称
   name: fs
   # 版本
@@ -43,6 +51,8 @@ express:
   omsCode: "SF.0235402855"
 # Spring配置
 spring:
+  liquibase:
+    enabled: false
   cache:
     type: redis
   # 资源信息

+ 6 - 0
fs-service/src/main/resources/db/changelog/baseline/baseline.sql

@@ -0,0 +1,6 @@
+--liquibase formatted sql
+
+--changeset yhq:baseline-v1.1.0
+--comment 标记 Liquibase 接入前数据库已是当前结构,不执行 DDL
+--tagDatabase baseline-v1.1.0
+SELECT 1;

+ 15 - 0
fs-service/src/main/resources/db/changelog/baseline/init-meta.sql

@@ -0,0 +1,15 @@
+--liquibase formatted sql
+
+--changeset yhq:database-init-meta
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'database_init_meta'
+CREATE TABLE database_init_meta (
+    id              BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键',
+    bundle_md5      VARCHAR(32)  NOT NULL COMMENT 'changelog bundle MD5(32位小写)',
+    bundle_version  VARCHAR(64)  NULL     COMMENT '可选:Git tag / 发布版本号',
+    file_count      INT          NOT NULL COMMENT '参与计算的 changelog 文件数',
+    last_module     VARCHAR(64)  NULL     COMMENT '最后一次成功校验的启动模块',
+    update_time     DATETIME     NOT NULL COMMENT 'MD5 更新时间',
+    remark          VARCHAR(255) NULL     COMMENT '备注',
+    PRIMARY KEY (id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Liquibase changelog bundle MD5 元数据';

+ 17 - 0
fs-service/src/main/resources/db/changelog/changes/20260613-live-user-add-is-del.sql

@@ -0,0 +1,17 @@
+--liquibase formatted sql
+
+--changeset yhq:20260613-live-user-add-is-del
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'live_user'
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'live_user' AND column_name = 'is_del'
+ALTER TABLE live_user
+    ADD COLUMN is_del TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除 0-未删除 1-已删除';
+--rollback ALTER TABLE live_user DROP COLUMN is_del;
+
+--changeset yhq:20260613-fs-course-play-source-config-add-integral-goods
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'fs_course_play_source_config'
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fs_course_play_source_config' AND column_name = 'integral_goods'
+ALTER TABLE fs_course_play_source_config
+    ADD COLUMN integral_goods VARCHAR(2000) NULL DEFAULT NULL COMMENT '积分商品配置';
+--rollback ALTER TABLE fs_course_play_source_config DROP COLUMN integral_goods;

+ 14 - 0
fs-service/src/main/resources/db/changelog/db.changelog-master.xml

@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<databaseChangeLog
+        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
+        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+        xsi:schemaLocation="
+          http://www.liquibase.org/xml/ns/dbchangelog
+          http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">
+
+    <include file="baseline/init-meta.sql" relativeToChangelogFile="true"/>
+
+    <include file="baseline/baseline.sql" relativeToChangelogFile="true"/>
+    <include file="changes/20260613-live-user-add-is-del.sql" relativeToChangelogFile="true"/>
+
+</databaseChangeLog>

+ 9 - 0
fs-service/src/main/resources/db/changelog/draft/20260615-live-user-drop-is-del.sql

@@ -0,0 +1,9 @@
+--liquibase formatted sql
+-- 未纳入 db.changelog-master.xml,仅作草稿保留,启动时不会执行
+
+--changeset yhq:20260615-live-user-drop-is-del
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'live_user'
+--precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'live_user' AND column_name = 'is_del'
+ALTER TABLE live_user DROP COLUMN is_del;
+--rollback ALTER TABLE live_user ADD COLUMN is_del TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除 0-未删除 1-已删除';

+ 5 - 0
fs-user-app/pom.xml

@@ -103,6 +103,11 @@
             <groupId>com.fs</groupId>
             <artifactId>fs-service</artifactId>
         </dependency>
+        <!-- Liquibase(启动模块显式依赖) -->
+        <dependency>
+            <groupId>org.liquibase</groupId>
+            <artifactId>liquibase-core</artifactId>
+        </dependency>
         <dependency>
             <groupId>org.apache.rocketmq</groupId>
             <artifactId>rocketmq-spring-boot-starter</artifactId>

+ 8 - 0
pom.xml

@@ -33,6 +33,7 @@
         <org.mapstruct.version>1.5.5.Final</org.mapstruct.version>
         <gson-version>2.10</gson-version>
         <ijpay-version>2.7.8</ijpay-version>
+        <liquibase.version>4.9.1</liquibase.version>
     </properties>
 
     <!-- 依赖声明 -->
@@ -54,6 +55,13 @@
                 <scope>import</scope>
             </dependency>
 
+            <!-- Liquibase 数据库版本管理 -->
+            <dependency>
+                <groupId>org.liquibase</groupId>
+                <artifactId>liquibase-core</artifactId>
+                <version>${liquibase.version}</version>
+            </dependency>
+
             <!-- 阿里数据库连接池 -->
             <dependency>
                 <groupId>com.alibaba</groupId>