package com.xuqm.tenant.service; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import javax.sql.DataSource; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.Statement; import java.util.Arrays; import java.util.List; import java.util.Set; import java.util.function.Consumer; import java.util.stream.Collectors; @Service public class SystemUpdateService { private static final Logger log = LoggerFactory.getLogger(SystemUpdateService.class); // nginx 最后重启,确保它能获取到其他服务修复后的配置 private static final List OTHER_SERVICES = List.of( "file-service", "tenant-web", "im-service", "push-service", "update-service", "license-service", "nginx" ); private static final Set ALLOWED_LOG_SERVICES = Set.of( "tenant-service", "file-service", "im-service", "push-service", "update-service", "license-service", "nginx", "tenant-web" ); @Value("${PRIVATE_DEPLOY_ROOT:/opt/xuqm-private}") private String deployRoot; private final DataSource dataSource; public SystemUpdateService(DataSource dataSource) { this.dataSource = dataSource; } // ── 启动时自动执行迁移 ────────────────────────────────────────────────────── @EventListener(ApplicationReadyEvent.class) public void onApplicationReady() { runSchemaMigrations(line -> log.info("[migration] {}", line)); } // ── 公开接口 ──────────────────────────────────────────────────────────────── /** * 返回当前正在运行的服务名列表。 * 使用 docker ps --format "{{.Label \"com.docker.compose.service\"}}" 枚举运行中容器的服务标签, * 不依赖 compose 文件路径,公有云/私有云均可用。 * 结果已过滤为 ALLOWED_LOG_SERVICES 白名单内的服务。 */ public List getRunningServices() { try { Process p = new ProcessBuilder( "docker", "ps", "--format", "{{.Label \"com.docker.compose.service\"}}" ).redirectErrorStream(true).start(); String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); p.waitFor(); if (out.isEmpty()) return List.of(); return Arrays.stream(out.split("\n")) .map(String::trim) .filter(s -> !s.isEmpty() && ALLOWED_LOG_SERVICES.contains(s)) .distinct() .collect(Collectors.toList()); } catch (Exception e) { log.error("failed to list running services", e); return List.of(); } } /** * 获取指定服务最近 N 行日志(上限 1000)。 * 通过 docker ps 标签找到容器 ID 后使用 docker logs,不依赖 compose 文件路径。 */ public String getServiceLogs(String service, int lines) { if (!ALLOWED_LOG_SERVICES.contains(service)) { throw new IllegalArgumentException("不允许查看此服务的日志: " + service); } int safeLines = Math.min(Math.max(lines, 10), 1000); try { // 找到对应服务的容器 ID(可能有多个,取第一个) Process psProc = new ProcessBuilder( "docker", "ps", "--filter", "label=com.docker.compose.service=" + service, "--format", "{{.ID}}" ).redirectErrorStream(true).start(); String containerId = new String(psProc.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); psProc.waitFor(); if (containerId.isEmpty()) { return "(服务 " + service + " 当前没有运行中的容器)"; } // 取第一行(如有多个容器) String firstId = containerId.split("\n")[0].trim(); Process logsProc = new ProcessBuilder( "docker", "logs", "--tail", String.valueOf(safeLines), "--timestamps", firstId ).redirectErrorStream(true).start(); String out = new String(logsProc.getInputStream().readAllBytes(), StandardCharsets.UTF_8); int exitCode = logsProc.waitFor(); if (exitCode != 0 && out.isBlank()) { throw new RuntimeException("docker logs 返回非零退出码: " + exitCode); } return out; } catch (IllegalArgumentException e) { throw e; } catch (Exception e) { log.error("failed to fetch logs for service {}", service, e); throw new RuntimeException("获取日志失败: " + e.getMessage()); } } /** 读取部署目录的 VERSION 文件,返回当前版本号,文件不存在时返回 "unknown"。 */ public String readCurrentVersion() { Path versionFile = Paths.get(deployRoot, "VERSION"); try { if (Files.exists(versionFile)) { return Files.readString(versionFile).trim(); } } catch (IOException ignored) {} return "unknown"; } /** 拉取最新镜像并重建所有容器。 */ public void runUpdate(Consumer emit) { String composeFile = deployRoot + "/docker-compose.yml"; dockerLogin(emit); patchConfigs(emit); runSchemaMigrations(emit); emit.accept(">>> 拉取最新镜像..."); for (String svc : OTHER_SERVICES) { emit.accept(" pulling " + svc + " ..."); exec(emit, "docker", "compose", "-f", composeFile, "pull", "--quiet", svc); } emit.accept(" pulling tenant-service ..."); exec(emit, "docker", "compose", "-f", composeFile, "pull", "--quiet", "tenant-service"); emit.accept(">>> 镜像拉取完成"); restartAndSelfUpdate(emit, composeFile); } /** 不拉取新镜像,直接用当前本地镜像重建所有容器。 */ public void runReset(Consumer emit) { String composeFile = deployRoot + "/docker-compose.yml"; patchConfigs(emit); runSchemaMigrations(emit); restartAndSelfUpdate(emit, composeFile); } // ── Schema 版本化迁移 ─────────────────────────────────────────────────────── /** * 执行所有待处理的 schema 迁移。 * * 迁移原则: * - ddl-auto:update 自动处理新增列/表,此处仅处理 Hibernate 无法完成的变更 * (删列、改列名、类型转换、数据填充等) * - 每个迁移有唯一 ID,执行后记录到 _schema_migrations,保证幂等 * - 新版本新增迁移时,在末尾追加新的 migrate_xxx() 调用即可 */ public void runSchemaMigrations(Consumer emit) { emit.accept(">>> 检查数据库迁移..."); try { ensureMigrationsTable(); } catch (Exception e) { emit.accept(" [警告] 无法初始化迁移记录表: " + e.getMessage()); return; } migrate_v20260101_drop_device_id_unique_index(emit); // 新版本迁移在此追加,例如: // migrate_v20260601_add_app_extra_column(emit); emit.accept(">>> 数据库迁移检查完成"); } private void ensureMigrationsTable() throws Exception { try (Connection conn = dataSource.getConnection(); Statement stmt = conn.createStatement()) { stmt.execute(""" CREATE TABLE IF NOT EXISTS _schema_migrations ( id VARCHAR(128) NOT NULL PRIMARY KEY, applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, description VARCHAR(255) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """); } } private boolean migrationApplied(String id) { try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement( "SELECT COUNT(*) FROM _schema_migrations WHERE id = ?")) { ps.setString(1, id); try (ResultSet rs = ps.executeQuery()) { return rs.next() && rs.getInt(1) > 0; } } catch (Exception e) { log.warn("check migration {} failed: {}", id, e.getMessage()); return false; } } private void recordMigration(String id, String description) { try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement( "INSERT IGNORE INTO _schema_migrations (id, description) VALUES (?, ?)")) { ps.setString(1, id); ps.setString(2, description); ps.executeUpdate(); } catch (Exception e) { log.warn("record migration {} failed: {}", id, e.getMessage()); } } // ── 各版本迁移 ────────────────────────────────────────────────────────────── /** * license-service DeviceEntity 上的 column-level unique=true 在多租户场景下产生了跨 appKey 的 * 全局唯一约束,与正确的复合唯一索引 uk_app_key_device_id(app_key, device_id) 冲突。 * Hibernate ddl-auto:update 不删除多余约束,必须手动 ALTER TABLE。 * 根治方案:已同步移除 DeviceEntity.deviceId 上的 unique=true 注解,新安装不再产生该约束。 */ private void migrate_v20260101_drop_device_id_unique_index(Consumer emit) { final String id = "v20260101_drop_device_id_unique_index"; if (migrationApplied(id)) { emit.accept(" [已应用] " + id); return; } try (Connection conn = dataSource.getConnection()) { boolean exists; try (PreparedStatement ps = conn.prepareStatement(""" SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'devices' AND INDEX_NAME = 'device_id' AND NON_UNIQUE = 0 """); ResultSet rs = ps.executeQuery()) { exists = rs.next() && rs.getInt(1) > 0; } if (exists) { try (Statement stmt = conn.createStatement()) { stmt.execute("ALTER TABLE devices DROP INDEX device_id"); } emit.accept(" [已迁移] " + id + ": 删除 devices.device_id 旧单列唯一约束"); } else { emit.accept(" [已迁移] " + id + ": devices.device_id 单列约束不存在,无需处理"); } recordMigration(id, "删除 devices 表 device_id 旧单列唯一约束"); } catch (Exception e) { emit.accept(" [错误] " + id + ": " + e.getMessage()); log.error("migration {} failed", id, e); } } // ── 重启核心 ──────────────────────────────────────────────────────────────── private void restartAndSelfUpdate(Consumer emit, String composeFile) { emit.accept(">>> 重建各服务容器..."); for (String svc : OTHER_SERVICES) { emit.accept(" restarting " + svc + " ..."); exec(emit, "docker", "compose", "-f", composeFile, "up", "-d", "--no-deps", "--force-recreate", svc); emit.accept(" " + svc + " ✓"); } emit.accept(">>> 启动自更新助手容器..."); String selfImage = getCurrentImage(); if (selfImage == null) { emit.accept(">>> [错误] 无法获取当前 tenant-service 镜像名,请手动执行:"); emit.accept(">>> docker compose -f " + composeFile + " up -d --no-deps --force-recreate tenant-service"); emit.accept("DONE"); return; } boolean helperStarted = spawnSelfUpdater(composeFile, selfImage); if (helperStarted) { emit.accept(">>> 助手容器已就绪,tenant-service 即将重建(连接将短暂中断)..."); emit.accept("RESTART_SELF"); } else { emit.accept(">>> [警告] 助手容器启动失败,请手动执行:"); emit.accept(">>> docker compose -f " + composeFile + " up -d --no-deps --force-recreate tenant-service"); emit.accept("DONE"); } } // ── 配置文件热修复 ────────────────────────────────────────────────────────── private void patchConfigs(Consumer emit) { emit.accept(">>> 检查并修复配置文件..."); patchNginxFileRoute(emit); patchNginxUpdateTimeout(emit); patchDockerComposeFileService(emit); patchDockerComposeUpdateService(emit); } private void patchNginxUpdateTimeout(Consumer emit) { Path conf = Paths.get(deployRoot, "config", "nginx", "conf.d", "xuqm.conf"); if (!Files.exists(conf)) return; try { String content = Files.readString(conf); if (content.contains("location ~ ^/api/system/") || content.contains("location = /api/system/update")) return; String anchor = " # 核心 API(兜底,在所有具体 /api/xxx/ 之后)\n location /api/ {"; if (!content.contains(anchor)) { emit.accept(" [跳过] nginx 更新超时补丁锚点未找到,请手动检查"); return; } String injection = " # 一键更新/重置:操作耗时较长,需要更长超时(精确匹配,优先于 /api/ 前缀)\n" + " location ~ ^/api/system/(update|reset)$ {\n" + " set $svc tenant-service;\n" + " proxy_pass http://$svc:9001;\n" + " proxy_set_header Host $host;\n" + " proxy_set_header X-Real-IP $remote_addr;\n" + " proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n" + " proxy_read_timeout 600s;\n" + " proxy_send_timeout 600s;\n" + " }\n\n" + anchor; Files.writeString(conf, content.replace(anchor, injection), StandardOpenOption.TRUNCATE_EXISTING); emit.accept(" [已修复] nginx: 补齐 /api/system/(update|reset) 600s 超时"); } catch (IOException e) { emit.accept(" [警告] nginx 更新超时修复失败: " + e.getMessage()); } } private void patchNginxFileRoute(Consumer emit) { Path conf = Paths.get(deployRoot, "config", "nginx", "conf.d", "xuqm.conf"); if (!Files.exists(conf)) return; try { String content = Files.readString(conf); if (!content.contains("location /file/")) return; Files.writeString(conf, content.replace("location /file/", "location /api/file/"), StandardOpenOption.TRUNCATE_EXISTING); emit.accept(" [已修复] nginx: location /file/ → /api/file/"); } catch (IOException e) { emit.accept(" [警告] nginx 配置修复失败: " + e.getMessage()); } } private void patchDockerComposeFileService(Consumer emit) { Path composeFile = Paths.get(deployRoot, "docker-compose.yml"); if (!Files.exists(composeFile)) return; try { String content = Files.readString(composeFile); if (content.contains("FILE_UPLOAD_DIR") && content.contains("FILE_BASE_URL")) return; String consoleDomain = readEnvValue(Paths.get(deployRoot, "config", "xuqm.env"), "CONSOLE_DOMAIN"); if (consoleDomain == null) consoleDomain = ""; String anchor = " SPRING_DATA_REDIS_DATABASE: \"${REDIS_DATABASE:-0}\"\n"; if (!content.contains(anchor)) { emit.accept(" [跳过] docker-compose 文件服务补丁锚点未找到,请手动检查"); return; } String injection = anchor + " FILE_UPLOAD_DIR: \"/data/uploads\"\n" + " FILE_BASE_URL: \"" + consoleDomain + "\"\n"; Files.writeString(composeFile, content.replace(anchor, injection), StandardOpenOption.TRUNCATE_EXISTING); emit.accept(" [已修复] docker-compose: 补齐 FILE_UPLOAD_DIR 和 FILE_BASE_URL"); } catch (IOException e) { emit.accept(" [警告] docker-compose 修复失败: " + e.getMessage()); } } private void patchDockerComposeUpdateService(Consumer emit) { Path composeFile = Paths.get(deployRoot, "docker-compose.yml"); if (!Files.exists(composeFile)) return; try { String content = Files.readString(composeFile); if (content.contains("FILE_SERVICE_INTERNAL_URL")) return; String consoleDomain = readEnvValue(Paths.get(deployRoot, "config", "xuqm.env"), "CONSOLE_DOMAIN"); if (consoleDomain == null) consoleDomain = ""; String envBlock = " FILE_BASE_URL: \"" + consoleDomain + "\"\n" + " FILE_SERVICE_INTERNAL_URL: \"http://file-service:8086\"\n"; String anchor = " SDK_TENANT_SERVICE_URL: \"http://tenant-service:9001\"\n"; String fallbackAnchor = " image: ${REGISTRY}/update-service:${IMAGE_TAG}\n"; String patched; if (content.contains(anchor)) { patched = content.replace(anchor, anchor + envBlock); } else if (content.contains(fallbackAnchor)) { String envAnchor = fallbackAnchor + " environment:\n"; if (!content.contains(envAnchor)) { emit.accept(" [跳过] docker-compose update-service environment 段未找到,请手动检查"); return; } patched = content.replace(envAnchor, envAnchor + envBlock); } else { emit.accept(" [跳过] docker-compose update-service 段未找到,请手动检查"); return; } Files.writeString(composeFile, patched, StandardOpenOption.TRUNCATE_EXISTING); emit.accept(" [已修复] docker-compose: 补齐 update-service 的 FILE_BASE_URL 和 FILE_SERVICE_INTERNAL_URL"); } catch (IOException e) { emit.accept(" [警告] docker-compose update-service 修复失败: " + e.getMessage()); } } // ── Docker 工具方法 ───────────────────────────────────────────────────────── private void dockerLogin(Consumer emit) { try { String registry = null, user = null, password = null; for (String line : Files.readAllLines(Paths.get(deployRoot + "/.env"))) { if (line.startsWith("REGISTRY=")) registry = line.substring("REGISTRY=".length()).trim(); else if (line.startsWith("REGISTRY_USER=")) user = line.substring("REGISTRY_USER=".length()).trim(); else if (line.startsWith("REGISTRY_PASSWORD=")) password = line.substring("REGISTRY_PASSWORD=".length()).trim(); } if (registry == null || user == null || password == null || password.isEmpty()) return; String host = registry.contains("/") ? registry.substring(0, registry.indexOf('/')) : registry; ProcessBuilder pb = new ProcessBuilder("docker", "login", host, "-u", user, "--password-stdin") .redirectErrorStream(true); Process p = pb.start(); p.getOutputStream().write((password + "\n").getBytes()); p.getOutputStream().flush(); p.getOutputStream().close(); String out = new String(p.getInputStream().readAllBytes()).trim(); int code = p.waitFor(); if (code == 0) { emit.accept(" 已完成镜像仓库登录"); } else { emit.accept(" [警告] 镜像仓库登录失败,将使用本地缓存(" + out + ")"); } } catch (Exception e) { emit.accept(" [警告] 读取仓库凭据失败: " + e.getMessage()); } } private boolean spawnSelfUpdater(String composeFile, String image) { try { new ProcessBuilder("docker", "rm", "-f", "xuqm-self-updater") .redirectErrorStream(true).start().waitFor(); String shellCmd = "sleep 8 && docker compose -f " + composeFile + " up -d --no-deps --force-recreate tenant-service"; Process p = new ProcessBuilder( "docker", "run", "-d", "--rm", "--name", "xuqm-self-updater", "-v", "/var/run/docker.sock:/var/run/docker.sock", "-v", deployRoot + ":" + deployRoot, "--entrypoint", "sh", image, "-c", shellCmd ).redirectErrorStream(true).start(); String out = new String(p.getInputStream().readAllBytes()).trim(); int code = p.waitFor(); log.info("self-updater spawn: code={} containerId={}", code, out); return code == 0; } catch (Exception e) { log.error("failed to spawn self-updater", e); return false; } } private String getCurrentImage() { try { Process p = new ProcessBuilder( "docker", "ps", "--filter", "label=com.docker.compose.service=tenant-service", "--format", "{{.Image}}" ).redirectErrorStream(true).start(); String out = new String(p.getInputStream().readAllBytes()).trim(); p.waitFor(); return out.isEmpty() ? null : out; } catch (Exception e) { return null; } } private String readEnvValue(Path envFile, String key) { if (!Files.exists(envFile)) return null; try { for (String line : Files.readAllLines(envFile)) { if (line.startsWith(key + "=")) { return line.substring(key.length() + 1).trim(); } } } catch (IOException ignored) {} return null; } private void exec(Consumer emit, String... cmd) { try { Process p = new ProcessBuilder(cmd) .redirectErrorStream(true) .start(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { if (!line.isBlank()) emit.accept(" " + line); } } int code = p.waitFor(); if (code != 0) emit.accept(" [warn] exit code " + code); } catch (Exception e) { emit.accept(" [error] " + e.getMessage()); log.error("exec failed: {}", String.join(" ", cmd), e); } } }