yuhongqi 1 неделя назад
Родитель
Сommit
725e414143

+ 76 - 8
fs-service/src/main/java/com/fs/framework/liquibase/ChangelogMd5Service.java

@@ -5,12 +5,14 @@ import org.slf4j.LoggerFactory;
 import org.springframework.core.io.Resource;
 import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
 import org.springframework.util.StreamUtils;
+import org.springframework.util.StringUtils;
 
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
 import java.util.Locale;
@@ -29,15 +31,24 @@ public class ChangelogMd5Service {
         this.properties = properties;
     }
 
+    /** 兼容旧调用:按全局 changelogRoot 计算。 */
     public BundleMd5Result computeBundleMd5() {
+        return computeBundleMd5(properties.getChangelogRoot(), Collections.<String>emptyList());
+    }
+
+    public BundleMd5Result computeBundleMd5(LiquibaseProperties.LiquibaseTarget target) {
+        return computeBundleMd5(target.getChangelogRoot(), target.getExcludeDirs());
+    }
+
+    public BundleMd5Result computeBundleMd5(String changelogRoot, List<String> excludeDirs) {
         try {
-            String pattern = buildScanPattern();
+            String pattern = buildScanPattern(changelogRoot);
             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)) {
+                        && isActiveChangelogResource(resource, changelogRoot, excludeDirs)) {
                     matched.add(resource);
                 }
             }
@@ -50,18 +61,22 @@ public class ChangelogMd5Service {
                 payload.append('\n');
             }
             String md5 = md5Hex(payload.toString());
-            log.info("Liquibase bundle MD5 计算完成: md5={}, fileCount={}", md5, matched.size());
+            log.info("Liquibase bundle MD5 计算完成: root={}, md5={}, fileCount={}",
+                    changelogRoot, md5, matched.size());
             return new BundleMd5Result(md5, matched.size());
         } catch (IOException ex) {
-            throw new IllegalStateException("计算 Liquibase changelog bundle MD5 失败", ex);
+            throw new IllegalStateException("计算 Liquibase changelog bundle MD5 失败: " + changelogRoot, ex);
         }
     }
 
-    private String buildScanPattern() {
-        String root = properties.getChangelogRoot();
+    private String buildScanPattern(String changelogRoot) {
+        String root = changelogRoot;
         if (root.startsWith("classpath:")) {
             root = root.substring("classpath:".length());
         }
+        if (root.startsWith("classpath*:")) {
+            root = root.substring("classpath*:".length());
+        }
         if (!root.endsWith("/")) {
             root = root + "/";
         }
@@ -72,15 +87,65 @@ public class ChangelogMd5Service {
         return resource.getURL().toString().endsWith("/");
     }
 
-    private boolean isActiveChangelogResource(Resource resource) {
+    private boolean isActiveChangelogResource(Resource resource,
+                                              String changelogRoot,
+                                              List<String> excludeDirs) {
         try {
             String path = resource.getURI().toString().replace('\\', '/');
-            return !path.contains("/draft/");
+            if (path.contains("/draft/")) {
+                return false;
+            }
+            String normalizedRoot = normalizeRootPath(changelogRoot);
+            if (StringUtils.hasText(normalizedRoot)) {
+                int idx = path.indexOf(normalizedRoot);
+                if (idx >= 0) {
+                    String relative = path.substring(idx + normalizedRoot.length());
+                    if (isUnderExcludedDir(relative, excludeDirs)) {
+                        return false;
+                    }
+                }
+            }
+            return true;
         } catch (IOException ex) {
             return true;
         }
     }
 
+    private boolean isUnderExcludedDir(String relativePath, List<String> excludeDirs) {
+        if (excludeDirs == null || excludeDirs.isEmpty() || !StringUtils.hasText(relativePath)) {
+            return false;
+        }
+        for (String dir : excludeDirs) {
+            if (!StringUtils.hasText(dir)) {
+                continue;
+            }
+            String marker = dir.startsWith("/") ? dir.substring(1) : dir;
+            if (relativePath.equals(marker)
+                    || relativePath.startsWith(marker + "/")
+                    || relativePath.contains("/" + marker + "/")) {
+                // 仅排除 root 下一级目录:relative 以 dir/ 开头
+                if (relativePath.startsWith(marker + "/") || relativePath.equals(marker)) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private String normalizeRootPath(String changelogRoot) {
+        String root = changelogRoot == null ? "" : changelogRoot.replace('\\', '/');
+        if (root.startsWith("classpath:")) {
+            root = root.substring("classpath:".length());
+        }
+        if (root.startsWith("classpath*:")) {
+            root = root.substring("classpath*:".length());
+        }
+        if (!root.endsWith("/") && StringUtils.hasText(root)) {
+            root = root + "/";
+        }
+        return root;
+    }
+
     private String resourceKey(Resource resource) {
         try {
             return resource.getURI().toString();
@@ -90,6 +155,9 @@ public class ChangelogMd5Service {
     }
 
     private boolean matchExtension(String filename) {
+        if (!StringUtils.hasText(filename)) {
+            return false;
+        }
         int index = filename.lastIndexOf('.');
         if (index < 0) {
             return false;

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

@@ -1,20 +1,20 @@
 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.ApplicationContext;
 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 未注册。
+ * <p>
+ * 按 {@code fs.liquibase.targets} 依次检查数据源与 changelog,并在对应库执行。
  */
 @Configuration
 @AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
@@ -26,9 +26,9 @@ public class LiquibaseAutoConfiguration {
     @Bean(name = LiquibaseStartupGate.BEAN_NAME)
     @DependsOn("masterDataSource")
     public LiquibaseStartupGate liquibaseStartupGate(
-            @Qualifier("masterDataSource") DataSource masterDataSource,
+            ApplicationContext applicationContext,
             LiquibaseProperties properties,
             Environment environment) {
-        return new LiquibaseStartupGate(masterDataSource, properties, environment);
+        return new LiquibaseStartupGate(applicationContext, properties, environment);
     }
 }

+ 25 - 15
fs-service/src/main/java/com/fs/framework/liquibase/LiquibaseMigrationRunner.java

@@ -31,11 +31,16 @@ public class LiquibaseMigrationRunner {
         this.properties = properties;
     }
 
+    /** 兼容旧调用:使用全局 changelog。 */
     public MigrationAudit audit(DataSource dataSource) {
+        return audit(dataSource, properties.getChangelog());
+    }
+
+    public MigrationAudit audit(DataSource dataSource, String changelog) {
         configureLockWait();
         try (Connection connection = dataSource.getConnection()) {
             Database database = createDatabase(connection);
-            Liquibase liquibase = createLiquibase(database);
+            Liquibase liquibase = createLiquibase(database, changelog);
             List<ChangeSet> all = liquibase.getDatabaseChangeLog().getChangeSets();
             List<ChangeSet> pending = liquibase.listUnrunChangeSets(null);
             Set<String> parsedKeys = toChangeSetKeys(all);
@@ -44,8 +49,8 @@ public class LiquibaseMigrationRunner {
             Set<String> missingInDb = new LinkedHashSet<>(parsedKeys);
             missingInDb.removeAll(executedKeys);
 
-            log.info("Liquibase 审计: 已解析={}, 待执行={}, DATABASECHANGELOG 已记录={}, 缺失记录={}",
-                    parsedKeys.size(), pendingKeys.size(), executedKeys.size(), missingInDb.size());
+            log.info("Liquibase 审计[{}]: 已解析={}, 待执行={}, DATABASECHANGELOG 已记录={}, 缺失记录={}",
+                    changelog, parsedKeys.size(), pendingKeys.size(), executedKeys.size(), missingInDb.size());
             for (ChangeSet changeSet : pending) {
                 log.info("待执行 changeset -> id={}, author={}, file={}",
                         changeSet.getId(), changeSet.getAuthor(), changeSet.getFilePath());
@@ -55,7 +60,7 @@ public class LiquibaseMigrationRunner {
             }
             return new MigrationAudit(parsedKeys.size(), pendingKeys.size(), parsedKeys, pendingKeys, missingInDb);
         } catch (Exception ex) {
-            throw new IllegalStateException("Liquibase migration 审计失败", ex);
+            throw new IllegalStateException("Liquibase migration 审计失败: " + changelog, ex);
         }
     }
 
@@ -63,35 +68,40 @@ public class LiquibaseMigrationRunner {
         return audit(dataSource).getPendingTotal();
     }
 
+    /** 兼容旧调用:使用全局 changelog。 */
     public void update(DataSource dataSource) {
+        update(dataSource, properties.getChangelog());
+    }
+
+    public void update(DataSource dataSource, String changelog) {
         configureLockWait();
         try (Connection connection = dataSource.getConnection()) {
             connection.setAutoCommit(false);
             Database database = createDatabase(connection);
-            Liquibase liquibase = createLiquibase(database);
+            Liquibase liquibase = createLiquibase(database, changelog);
             try {
                 int totalChangeSets = liquibase.getDatabaseChangeLog().getChangeSets().size();
                 log.info("开始执行 Liquibase update, changelog={}, 已解析 changeset 总数={}",
-                        properties.getChangelog(), totalChangeSets);
+                        changelog, 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 执行成功");
+                log.info("Liquibase update 执行成功: {}", changelog);
             } catch (Exception ex) {
                 try {
                     connection.rollback();
                 } catch (Exception rollbackEx) {
-                    log.warn("Liquibase update 回滚失败", rollbackEx);
+                    log.warn("Liquibase update 回滚失败: {}", changelog, rollbackEx);
                 }
-                throw new IllegalStateException("Liquibase 迁移失败,已回滚,禁止启动", ex);
+                throw new IllegalStateException("Liquibase 迁移失败,已回滚,禁止启动: " + changelog, ex);
             }
         } catch (IllegalStateException ex) {
             throw ex;
         } catch (Exception ex) {
-            throw new IllegalStateException("Liquibase 迁移失败,已回滚,禁止启动", ex);
+            throw new IllegalStateException("Liquibase 迁移失败,已回滚,禁止启动: " + changelog, ex);
         }
     }
 
@@ -125,17 +135,17 @@ public class LiquibaseMigrationRunner {
         return DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
     }
 
-    private Liquibase createLiquibase(Database database) throws Exception {
+    private Liquibase createLiquibase(Database database, String changelog) throws Exception {
         ClassLoader classLoader = getClass().getClassLoader();
-        if (classLoader.getResource(properties.getChangelog()) == null) {
-            throw new IllegalStateException("未找到 master changelog: " + properties.getChangelog());
+        if (classLoader.getResource(changelog) == null) {
+            throw new IllegalStateException("未找到 master changelog: " + changelog);
         }
-        Liquibase liquibase = new Liquibase(properties.getChangelog(), new ClassLoaderResourceAccessor(), database);
+        Liquibase liquibase = new Liquibase(changelog, new ClassLoaderResourceAccessor(), database);
         int parsed = liquibase.getDatabaseChangeLog().getChangeSets().size();
         if (parsed == 0) {
             throw new IllegalStateException(
                     "Liquibase 未解析到 changeset,请检查 SQL 文件是否包含 "
-                            + "'--liquibase formatted sql' 及 '--changeset author:id' 格式");
+                            + "'--liquibase formatted sql' 及 '--changeset author:id' 格式: " + changelog);
         }
         return liquibase;
     }

+ 183 - 3
fs-service/src/main/java/com/fs/framework/liquibase/LiquibaseProperties.java

@@ -1,8 +1,11 @@
 package com.fs.framework.liquibase;
 
 import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.util.StringUtils;
 
+import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 
 @ConfigurationProperties(prefix = "fs.liquibase")
@@ -14,13 +17,17 @@ public class LiquibaseProperties {
     /** 校验失败是否禁止启动 */
     private boolean failFast = true;
 
-    /** Liquibase 主 changelog(classpath 相对路径) */
+    /**
+     * 兼容旧配置:未配置 targets 时,作为 MASTER 唯一入口。
+     */
     private String changelog = "db/changelog/db.changelog-master.xml";
 
-    /** bundle MD5 扫描根路径 */
+    /**
+     * 兼容旧配置:未配置 targets 时,作为 MASTER bundle MD5 扫描根。
+     */
     private String changelogRoot = "classpath:db/changelog/";
 
-    /** MD5 元数据表名 */
+    /** MD5 元数据表名(各目标库各自维护) */
     private String metaTable = "database_init_meta";
 
     /** Liquibase 全局锁等待时间(分钟) */
@@ -29,6 +36,11 @@ public class LiquibaseProperties {
     /** 参与 MD5 计算的文件扩展名 */
     private List<String> includeExtensions = Arrays.asList("sql", "xml", "yaml", "yml");
 
+    /**
+     * 多数据源目标。配置后优先生效;为空则回退到 changelog/changelogRoot(主库)。
+     */
+    private List<LiquibaseTarget> targets = new ArrayList<>();
+
     public boolean isEnabled() {
         return enabled;
     }
@@ -84,4 +96,172 @@ public class LiquibaseProperties {
     public void setIncludeExtensions(List<String> includeExtensions) {
         this.includeExtensions = includeExtensions;
     }
+
+    public List<LiquibaseTarget> getTargets() {
+        return targets;
+    }
+
+    public void setTargets(List<LiquibaseTarget> targets) {
+        this.targets = targets;
+    }
+
+    /**
+     * 解析实际执行目标:优先 targets;否则用旧字段构造 MASTER。
+     */
+    public List<LiquibaseTarget> resolveTargets() {
+        if (targets != null && !targets.isEmpty()) {
+            List<LiquibaseTarget> resolved = new ArrayList<>();
+            for (LiquibaseTarget target : targets) {
+                resolved.add(normalizeTarget(target));
+            }
+            applyCrossTargetExcludes(resolved);
+            return resolved;
+        }
+        LiquibaseTarget master = new LiquibaseTarget();
+        master.setName("master");
+        master.setDataSource("masterDataSource");
+        master.setChangelog(changelog);
+        master.setChangelogRoot(changelogRoot);
+        master.setRequired(true);
+        return Collections.singletonList(normalizeTarget(master));
+    }
+
+    private LiquibaseTarget normalizeTarget(LiquibaseTarget target) {
+        if (!StringUtils.hasText(target.getName())) {
+            throw new IllegalStateException("fs.liquibase.targets[].name 不能为空");
+        }
+        if (!StringUtils.hasText(target.getDataSource())) {
+            throw new IllegalStateException("fs.liquibase.targets[" + target.getName() + "].data-source 不能为空");
+        }
+        if (!StringUtils.hasText(target.getChangelog())) {
+            throw new IllegalStateException("fs.liquibase.targets[" + target.getName() + "].changelog 不能为空");
+        }
+        if (!StringUtils.hasText(target.getChangelogRoot())) {
+            String changelogPath = target.getChangelog().replace('\\', '/');
+            int slash = changelogPath.lastIndexOf('/');
+            String root = slash >= 0 ? changelogPath.substring(0, slash + 1) : "";
+            target.setChangelogRoot("classpath:" + root);
+        }
+        if (target.getExcludeDirs() == null) {
+            target.setExcludeDirs(new ArrayList<>());
+        }
+        return target;
+    }
+
+    /**
+     * 主库 root 若包含其他目标子目录,自动把对方目录名加入 excludeDirs,避免 MD5 互相污染。
+     */
+    private void applyCrossTargetExcludes(List<LiquibaseTarget> resolved) {
+        for (LiquibaseTarget current : resolved) {
+            String currentRoot = normalizeClasspathRoot(current.getChangelogRoot());
+            for (LiquibaseTarget other : resolved) {
+                if (current == other) {
+                    continue;
+                }
+                String otherRoot = normalizeClasspathRoot(other.getChangelogRoot());
+                if (otherRoot.startsWith(currentRoot) && otherRoot.length() > currentRoot.length()) {
+                    String relative = otherRoot.substring(currentRoot.length());
+                    if (relative.endsWith("/")) {
+                        relative = relative.substring(0, relative.length() - 1);
+                    }
+                    if (StringUtils.hasText(relative) && !relative.contains("/")
+                            && !current.getExcludeDirs().contains(relative)) {
+                        current.getExcludeDirs().add(relative);
+                    }
+                }
+            }
+        }
+    }
+
+    private String normalizeClasspathRoot(String root) {
+        String value = root == null ? "" : root.replace('\\', '/');
+        if (value.startsWith("classpath:")) {
+            value = value.substring("classpath:".length());
+        }
+        if (value.startsWith("classpath*:")) {
+            value = value.substring("classpath*:".length());
+        }
+        if (!value.endsWith("/")) {
+            value = value + "/";
+        }
+        return value;
+    }
+
+    /**
+     * 单个 Liquibase 执行目标(对应一个数据源 + 一套 changelog)。
+     */
+    public static class LiquibaseTarget {
+
+        /** 目标名称,用于日志(如 master / sop) */
+        private String name;
+
+        /** Spring DataSource Bean 名(如 masterDataSource / sopDataSource) */
+        private String dataSource;
+
+        /** Liquibase 主入口 changelog(classpath 相对路径) */
+        private String changelog;
+
+        /** bundle MD5 扫描根路径 */
+        private String changelogRoot;
+
+        /**
+         * 数据源 Bean 不存在或不可用时是否 fail-fast。
+         * master 建议 true;其他库建议 false(模块未装配该库时跳过)。
+         */
+        private boolean required = true;
+
+        /**
+         * MD5 扫描时排除的一级子目录名(相对 changelogRoot)。
+         * 例如主库 root 为 db/changelog/ 时排除 sop,避免把 SOP 文件算进主库 MD5。
+         */
+        private List<String> excludeDirs = new ArrayList<>();
+
+        public String getName() {
+            return name;
+        }
+
+        public void setName(String name) {
+            this.name = name;
+        }
+
+        public String getDataSource() {
+            return dataSource;
+        }
+
+        public void setDataSource(String dataSource) {
+            this.dataSource = dataSource;
+        }
+
+        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 boolean isRequired() {
+            return required;
+        }
+
+        public void setRequired(boolean required) {
+            this.required = required;
+        }
+
+        public List<String> getExcludeDirs() {
+            return excludeDirs;
+        }
+
+        public void setExcludeDirs(List<String> excludeDirs) {
+            this.excludeDirs = excludeDirs;
+        }
+    }
 }

+ 155 - 55
fs-service/src/main/java/com/fs/framework/liquibase/LiquibaseStartupGate.java

@@ -3,14 +3,17 @@ package com.fs.framework.liquibase;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.InitializingBean;
+import org.springframework.beans.factory.NoSuchBeanDefinitionException;
+import org.springframework.context.ApplicationContext;
 import org.springframework.core.env.Environment;
 import org.springframework.util.ClassUtils;
 import org.springframework.util.StringUtils;
 
 import javax.sql.DataSource;
+import java.util.List;
 
 /**
- * 应用启动门禁:校验 master 数据源、执行 pending migration、校验 changeset 执行记录、同步 bundle MD5。
+ * 应用启动门禁:按目标检查数据源 → 检查 changelog 文件 → 执行 pending migration → 同步 bundle MD5。
  */
 public class LiquibaseStartupGate implements InitializingBean {
 
@@ -18,14 +21,14 @@ public class LiquibaseStartupGate implements InitializingBean {
 
     private static final Logger log = LoggerFactory.getLogger(LiquibaseStartupGate.class);
 
-    private final DataSource masterDataSource;
+    private final ApplicationContext applicationContext;
     private final LiquibaseProperties properties;
     private final Environment environment;
 
-    public LiquibaseStartupGate(DataSource masterDataSource,
+    public LiquibaseStartupGate(ApplicationContext applicationContext,
                                 LiquibaseProperties properties,
                                 Environment environment) {
-        this.masterDataSource = masterDataSource;
+        this.applicationContext = applicationContext;
         this.properties = properties;
         this.environment = environment;
     }
@@ -43,89 +46,186 @@ public class LiquibaseStartupGate implements InitializingBean {
 
         assertLiquibaseOnClasspath();
 
-        MasterDataSourceValidator masterDataSourceValidator = new MasterDataSourceValidator();
+        DataSourceAvailabilityValidator dataSourceValidator = new DataSourceAvailabilityValidator();
         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);
+        List<LiquibaseProperties.LiquibaseTarget> targets = properties.resolveTargets();
+        log.info("模块 {} 开始 Liquibase 启动门禁,共 {} 个目标: {}",
+                moduleName, targets.size(), summarizeTargets(targets));
 
-        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);
+        for (LiquibaseProperties.LiquibaseTarget target : targets) {
+            runTarget(target, moduleName, dataSourceValidator, changelogMd5Service,
+                    liquibaseMetaRepository, liquibaseMigrationRunner);
+        }
 
-        assertChangeSetsParsable(audit);
+        log.info("模块 {} Liquibase 全部目标门禁完成", moduleName);
+    }
 
-        if (audit.getPendingTotal() > 0) {
-            log.info("检测到 {} 条未执行 Liquibase changeset", audit.getPendingTotal());
-        }
-        if (!audit.getMissingInDatabase().isEmpty()) {
-            log.warn("检测到 {} 条 changeset 未写入 DATABASECHANGELOG", audit.getMissingInDatabase().size());
+    private void runTarget(LiquibaseProperties.LiquibaseTarget target,
+                           String moduleName,
+                           DataSourceAvailabilityValidator dataSourceValidator,
+                           ChangelogMd5Service changelogMd5Service,
+                           LiquibaseMetaRepository liquibaseMetaRepository,
+                           LiquibaseMigrationRunner liquibaseMigrationRunner) {
+        log.info("Liquibase 目标 [{}] 开始: dataSource={}, changelog={}",
+                target.getName(), target.getDataSource(), target.getChangelog());
+
+        DataSource dataSource = resolveDataSource(target);
+        if (dataSource == null) {
+            return;
         }
-        if (md5Mismatch) {
-            log.info("changelog bundle MD5 已变化: dbMd5={}, fileMd5={}", dbMd5, fileMd5);
+
+        try {
+            dataSourceValidator.assertAvailable(dataSource, target.getName(), target.getDataSource());
+        } catch (IllegalStateException ex) {
+            handleTargetFailure(target, ex.getMessage(), ex);
+            return;
         }
 
-        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);
+        if (!changelogFileExists(target.getChangelog())) {
+            handleTargetFailure(target, "未找到 changelog 文件: " + target.getChangelog(), null);
+            return;
         }
 
-        ChangelogMd5Service.BundleMd5Result latestMd5 = changelogMd5Service.computeBundleMd5();
-        liquibaseMetaRepository.saveBundleMd5(
-                masterDataSource, latestMd5.getMd5(), latestMd5.getFileCount(), moduleName);
+        try {
+            ChangelogMd5Service.BundleMd5Result bundleMd5Result = changelogMd5Service.computeBundleMd5(target);
+            String fileMd5 = bundleMd5Result.getMd5();
+
+            boolean metaTableExists = liquibaseMetaRepository.metaTableExists(dataSource);
+            String dbMd5 = liquibaseMetaRepository.loadBundleMd5(dataSource);
+            LiquibaseMigrationRunner.MigrationAudit audit =
+                    liquibaseMigrationRunner.audit(dataSource, target.getChangelog());
+            boolean md5Mismatch = StringUtils.hasText(dbMd5) && !fileMd5.equals(dbMd5);
+
+            assertChangeSetsParsable(target, audit);
+
+            if (audit.getPendingTotal() > 0) {
+                log.info("目标 [{}] 检测到 {} 条未执行 Liquibase changeset",
+                        target.getName(), audit.getPendingTotal());
+            }
+            if (!audit.getMissingInDatabase().isEmpty()) {
+                log.warn("目标 [{}] 检测到 {} 条 changeset 未写入 DATABASECHANGELOG",
+                        target.getName(), audit.getMissingInDatabase().size());
+            }
+            if (md5Mismatch) {
+                log.info("目标 [{}] changelog bundle MD5 已变化: dbMd5={}, fileMd5={}",
+                        target.getName(), dbMd5, fileMd5);
+            }
+
+            boolean needUpdate = audit.getPendingTotal() > 0
+                    || !audit.getMissingInDatabase().isEmpty()
+                    || !metaTableExists
+                    || !StringUtils.hasText(dbMd5)
+                    || md5Mismatch;
+
+            if (needUpdate) {
+                liquibaseMigrationRunner.update(dataSource, target.getChangelog());
+                bootstrapMetaIfNeeded(liquibaseMetaRepository, dataSource);
+                audit = liquibaseMigrationRunner.audit(dataSource, target.getChangelog());
+                assertMigrationCompleted(target, audit);
+            } else {
+                assertMigrationCompleted(target, audit);
+            }
+
+            ChangelogMd5Service.BundleMd5Result latestMd5 = changelogMd5Service.computeBundleMd5(target);
+            liquibaseMetaRepository.saveBundleMd5(
+                    dataSource, latestMd5.getMd5(), latestMd5.getFileCount(),
+                    moduleName + "#" + target.getName());
+
+            if (md5Mismatch) {
+                log.info("目标 [{}] changelog 文件变更且 migration 校验通过,bundle MD5 已同步: {} -> {}",
+                        target.getName(), dbMd5, latestMd5.getMd5());
+            } else {
+                log.info("目标 [{}] Liquibase 启动门禁通过,bundle MD5={}",
+                        target.getName(), latestMd5.getMd5());
+            }
+        } catch (RuntimeException ex) {
+            handleTargetFailure(target, ex.getMessage(), ex);
+        }
+    }
 
-        if (md5Mismatch) {
-            log.info("changelog 文件变更且 migration 校验通过,bundle MD5 已同步: {} -> {}",
-                    dbMd5, latestMd5.getMd5());
-        } else {
-            log.info("模块 {} Liquibase 启动门禁通过,bundle MD5={}", moduleName, latestMd5.getMd5());
+    private DataSource resolveDataSource(LiquibaseProperties.LiquibaseTarget target) {
+        try {
+            return applicationContext.getBean(target.getDataSource(), DataSource.class);
+        } catch (NoSuchBeanDefinitionException ex) {
+            String message = "数据源 Bean 不存在: " + target.getDataSource();
+            if (!target.isRequired()) {
+                log.info("目标 [{}] {},required=false,跳过", target.getName(), message);
+                return null;
+            }
+            handleTargetFailure(target, message, ex);
+            return null;
         }
     }
 
-    private void assertChangeSetsParsable(LiquibaseMigrationRunner.MigrationAudit audit) {
+    private boolean changelogFileExists(String changelog) {
+        ClassLoader classLoader = getClass().getClassLoader();
+        return classLoader != null && classLoader.getResource(changelog) != null;
+    }
+
+    private void assertChangeSetsParsable(LiquibaseProperties.LiquibaseTarget target,
+                                          LiquibaseMigrationRunner.MigrationAudit audit) {
         if (audit.getParsedTotal() == 0) {
-            abort("Liquibase 未解析到任何 changeset,请确认 master changelog 使用 XML include 且已正确引用 SQL 文件");
+            abort(target, "Liquibase 未解析到任何 changeset,请确认 master changelog 使用 XML include 且已正确引用 SQL 文件");
         }
     }
 
-    private void assertMigrationCompleted(LiquibaseMigrationRunner.MigrationAudit audit) {
+    private void assertMigrationCompleted(LiquibaseProperties.LiquibaseTarget target,
+                                          LiquibaseMigrationRunner.MigrationAudit audit) {
         if (audit.getPendingTotal() > 0) {
-            abort("Liquibase update 后仍有 " + audit.getPendingTotal() + " 条未执行 changeset: "
+            abort(target, "Liquibase update 后仍有 " + audit.getPendingTotal() + " 条未执行 changeset: "
                     + audit.getPendingKeys());
         }
         if (!audit.getMissingInDatabase().isEmpty()) {
-            abort("以下 changeset 未写入 DATABASECHANGELOG: " + audit.getMissingInDatabase());
+            abort(target, "以下 changeset 未写入 DATABASECHANGELOG: " + audit.getMissingInDatabase());
+        }
+    }
+
+    private void handleTargetFailure(LiquibaseProperties.LiquibaseTarget target,
+                                     String message,
+                                     Throwable cause) {
+        String full = "Liquibase 目标 [" + target.getName() + "] 失败: " + message;
+        if (target.isRequired() && properties.isFailFast()) {
+            if (cause instanceof IllegalStateException) {
+                throw (IllegalStateException) cause;
+            }
+            throw new IllegalStateException(full, cause);
+        }
+        if (target.isRequired() && !properties.isFailFast()) {
+            log.error("Liquibase 启动门禁校验失败(fail-fast=false): {}", full, cause);
+            return;
         }
+        log.error("Liquibase 目标 [{}] 失败(required=false,跳过): {}", target.getName(), message, cause);
+    }
+
+    private void abort(LiquibaseProperties.LiquibaseTarget target, String message) {
+        throw new IllegalStateException("Liquibase 目标 [" + target.getName() + "] " + message);
     }
 
-    private void abort(String message) {
-        if (properties.isFailFast()) {
-            throw new IllegalStateException(message);
+    private void bootstrapMetaIfNeeded(LiquibaseMetaRepository liquibaseMetaRepository,
+                                       DataSource dataSource) {
+        if (!liquibaseMetaRepository.metaTableExists(dataSource)) {
+            liquibaseMetaRepository.ensureMetaTable(dataSource);
         }
-        log.error("Liquibase 启动门禁校验失败(fail-fast=false): {}", message);
     }
 
-    private void bootstrapMetaIfNeeded(LiquibaseMetaRepository liquibaseMetaRepository) {
-        if (!liquibaseMetaRepository.metaTableExists(masterDataSource)) {
-            liquibaseMetaRepository.ensureMetaTable(masterDataSource);
+    private String summarizeTargets(List<LiquibaseProperties.LiquibaseTarget> targets) {
+        StringBuilder builder = new StringBuilder();
+        for (int i = 0; i < targets.size(); i++) {
+            if (i > 0) {
+                builder.append(", ");
+            }
+            LiquibaseProperties.LiquibaseTarget target = targets.get(i);
+            builder.append(target.getName())
+                    .append('(')
+                    .append(target.getDataSource())
+                    .append(target.isRequired() ? ",required" : ",optional")
+                    .append(')');
         }
+        return builder.toString();
     }
 
     private void assertLiquibaseOnClasspath() {

+ 10 - 22
fs-service/src/main/java/com/fs/framework/liquibase/MasterDataSourceValidator.java

@@ -1,36 +1,24 @@
 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 主数据源是否可用。
+ * 兼容旧调用:校验 MASTER 主数据源是否可用。
+ *
+ * @deprecated 请使用 {@link DataSourceAvailabilityValidator}
  */
+@Deprecated
 public class MasterDataSourceValidator {
 
-    private static final Logger log = LoggerFactory.getLogger(MasterDataSourceValidator.class);
-
     public static final String ERROR_MESSAGE = "未检测到主数据源mysql的master";
 
+    private final DataSourceAvailabilityValidator delegate = new DataSourceAvailabilityValidator();
+
     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);
+        try {
+            delegate.assertAvailable(masterDataSource, "master", "masterDataSource");
+        } catch (IllegalStateException ex) {
+            throw new IllegalStateException(ERROR_MESSAGE, ex.getCause() != null ? ex.getCause() : ex);
         }
     }
 }

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

@@ -3,11 +3,24 @@ fs:
   liquibase:
     enabled: true
     fail-fast: true
+    # 兼容旧字段:未配置 targets 时仅跑主库
     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
+    # 多数据源:按目录标记执行(详见 db/changelog/DATASOURCE-MARK.md)
+    targets:
+      - name: master
+        data-source: masterDataSource
+        changelog: db/changelog/db.changelog-master.xml
+        changelog-root: classpath:db/changelog/
+        required: true
+      - name: sop
+        data-source: sopDataSource
+        changelog: db/changelog/sop/db.changelog-master.xml
+        changelog-root: classpath:db/changelog/sop/
+        required: true
   # 名称
   name: fs
   # 版本

+ 66 - 41
fs-service/src/main/resources/db/changelog/Liquibase-changelog使用说明.md

@@ -1,46 +1,66 @@
 # Liquibase Changelog 使用说明
 
 > 适用模块:`fs-service`(所有依赖它的启动模块启动时自动执行)  
-> 配置入口:`fs-service/src/main/resources/application-common.yml` → `fs.liquibase.*`
+> 配置入口:`fs-service/src/main/resources/application-common.yml` → `fs.liquibase.*`  
+> 数据源目录标记:见同目录 [`DATASOURCE-MARK.md`](./DATASOURCE-MARK.md)
 
 ---
 
-## 1. 目录结构
+## 1. 目录结构(按数据源标记)
 
 ```
 db/changelog/
-├── db.changelog-master.xml          ← 主入口(唯一入口,必须用 XML)
-├── baseline/
-│   ├── init-meta.sql                ← 创建 MD5 元数据表
-│   └── baseline.sql                 ← 基线标记(不改表结构)
-├── changes/
-│   └── 20260613-live-user-add-is-del.sql   ← 业务 DDL 变更
-└── draft/                           ← 草稿目录,不会被 master 引用,启动不执行
-    └── 20260615-live-user-drop-is-del.sql
+├── DATASOURCE-MARK.md               ← 数据源目录归属说明
+├── db.changelog-master.xml          ← 【主库】入口
+├── baseline/                        ← 【主库】
+├── changes/                         ← 【主库】业务 DDL
+├── table/                           ← 【主库】
+├── dictData/                        ← 【主库】
+├── draft/                           ← 【主库】草稿,不执行
+└── sop/                             ← 【SOP 库】整棵子树只在 sopDataSource 执行
+    ├── db.changelog-master.xml
+    ├── baseline/
+    ├── changes/
+    └── draft/
 ```
 
-| 目录/文件 | 作用 |
-|-----------|------|
-| `db.changelog-master.xml` | 按顺序 `include` 子 changelog,Liquibase 只认这一份入口 |
-| `baseline/` | 首次接入、元数据表、基线 tag,一般很少改 |
-| `changes/` | 日常 DDL/DML 变更,**新增 SQL 放这里** |
-| `draft/` | 未纳入 master 的草稿,不参与 MD5 门禁,不执行 |
+| 目录/文件 | 数据源 | 作用 |
+|-----------|--------|------|
+| 根下 `db.changelog-master.xml` 及 `baseline/changes/table/dictData` | `masterDataSource` | 主库变更(已上线路径勿搬迁) |
+| `sop/**` | `sopDataSource` | SOP 库变更 |
+| 各目标下的 `draft/` | — | 草稿,不参与 MD5,不执行 |
 
 ---
 
-## 2. `db.changelog-master.xml` 用法
-
-### 2.1 文件作用
-
-这是 **Liquibase 唯一主入口**。应用启动时,`LiquibaseStartupGate` 读取配置:
+## 1.1 多数据源配置
 
 ```yaml
-fs.liquibase.changelog: db/changelog/db.changelog-master.xml
+fs.liquibase:
+  targets:
+    - name: master
+      data-source: masterDataSource
+      changelog: db/changelog/db.changelog-master.xml
+      changelog-root: classpath:db/changelog/
+      required: true          # 主库必须成功
+    - name: sop
+      data-source: sopDataSource
+      changelog: db/changelog/sop/db.changelog-master.xml
+      changelog-root: classpath:db/changelog/sop/
+      required: false         # 模块未装配 sopDataSource 时跳过
 ```
 
-然后通过 XML 的 `<include>` 依次加载 SQL 子文件并执行其中的 changeset。
+启动门禁对每个 target:**检查数据源 → 检查 changelog 文件 → audit/update → 写该库 MD5**。  
+主库 MD5 扫描会自动排除 `sop` 等其他目标子目录。
+
+---
+
+## 2. `db.changelog-master.xml` 用法(主库)
 
-> **注意**:SQL 格式 **不支持** `--include`。主入口必须是 XML(或 YAML),不能写成 `db.changelog-master.sql`。
+### 2.1 文件作用
+
+这是 **主库 Liquibase 入口**。SOP 库另有 `sop/db.changelog-master.xml`。应用启动时按 `fs.liquibase.targets` 依次执行。
+
+> **注意**:SQL 格式 **不支持** `--include`。主入口必须是 XML(或 YAML)。
 
 ### 2.2 当前内容
 
@@ -55,21 +75,22 @@ fs.liquibase.changelog: db/changelog/db.changelog-master.xml
 | `file` | 相对 master 文件的路径 |
 | `relativeToChangelogFile="true"` | 路径相对于 `db.changelog-master.xml` 所在目录解析 |
 
-**执行顺序 = include 的书写顺序**,上面的文件会按 1 → 2 → 3 依次执行
+**执行顺序 = include 的书写顺序**。
 
 ### 2.3 新增一条 SQL 变更的标准步骤
 
-1. 在 `changes/` 下新建文件,建议命名:`YYYYMMDD-模块-简述.sql`  
-   例:`20260620-order-add-index.sql`
-2. 在文件内写 changeset(见第 3 节格式)
-3. 在 `db.changelog-master.xml` **末尾**追加一行 include:
+**主库:**
 
-```xml
-<include file="changes/20260620-order-add-index.sql" relativeToChangelogFile="true"/>
-```
+1. 在 `changes/` 下新建 `YYYYMMDD-模块-简述.sql`
+2. 写入 changeset(见第 3 节)
+3. 在根目录 `db.changelog-master.xml` 末尾 `include`
+4. 重启后在**主库**查 `DATABASECHANGELOG`
+
+**SOP 库:**
 
-4. 本地启动应用(或 `mvn compile` 后完整重启)
-5. 查库确认:
+1. 在 `sop/changes/` 下新建 SQL
+2. 在 `sop/db.changelog-master.xml` 末尾 `include`
+3. 重启后在 **SOP 库**查 `DATABASECHANGELOG`
 
 ```sql
 SELECT ID, AUTHOR, EXECTYPE, DATEEXECUTED, FILENAME
@@ -81,23 +102,27 @@ ORDER BY DATEEXECUTED DESC;
 
 | 错误做法 | 后果 |
 |----------|------|
+| 把主库 SQL 放进 `sop/`(或反过来) | 会在错误的数据源执行 |
 | 只改 SQL 文件名,不更新 master 里的 `file=` | Liquibase 找不到文件或仍读旧路径 |
 | 把 SQL 放在 `changes/` 但不写进 master | 文件存在但不会执行 |
 | 用 SQL 文件当 master 并写 `--include` | 解析 0 条 changeset,门禁报错 |
 | 修改 **已发布且已执行** 的 changeset 内容 | checksum 不一致,Liquibase 校验失败 |
-| 在 `draft/` 里放正式变更却不 include | 永远不会执行 |
+| 搬迁已上线主库文件路径 | DATABASECHANGELOG 路径对不上 |
 
 ### 2.5 启动门禁简要流程
 
 ```
-启动 → 读 master.xml → 解析 changeset
-     → 对比 DATABASECHANGELOG(有无未执行/缺失记录)
-     → 有 pending 则 liquibase.update()
-     → 全部写入 DATABASECHANGELOG 后同步 bundle MD5(database_init_meta 表)
-     → 失败则禁止启动(fail-fast: true)
+启动 → 遍历 fs.liquibase.targets
+     → 检查 DataSource Bean / 连通性(required=false 可跳过)
+     → 检查 changelog 文件存在
+     → 读对应 master.xml → 解析 changeset
+     → 对比该库 DATABASECHANGELOG
+     → 有 pending 则 liquibase.update()(写到该库)
+     → 同步该库 database_init_meta.bundle_md5
+     → required 目标失败且 fail-fast=true 则禁止启动
 ```
 
-MD5 参与计算的文件:`db/changelog/` 下 `sql/xml/yaml/yml`,**排除 `draft/` 目录**。
+MD5:按每个 target 的 `changelog-root` 计算;排除 `draft/` 及其他目标子目录
 
 ---
 

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

@@ -6,6 +6,7 @@
           http://www.liquibase.org/xml/ns/dbchangelog
           http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">
 
+    <!-- 【主库 masterDataSource】仅 include 本目录(根)下文件;SOP 库变更请放到 sop/ 目录 -->
     <include file="baseline/init-meta.sql" relativeToChangelogFile="true"/>
 
     <include file="baseline/baseline.sql" relativeToChangelogFile="true"/>