diff --git a/license-service/src/main/java/com/xuqm/license/entity/DeviceEntity.java b/license-service/src/main/java/com/xuqm/license/entity/DeviceEntity.java index 8326bb8..7642868 100644 --- a/license-service/src/main/java/com/xuqm/license/entity/DeviceEntity.java +++ b/license-service/src/main/java/com/xuqm/license/entity/DeviceEntity.java @@ -19,7 +19,7 @@ public class DeviceEntity { @Column(nullable = false, name = "app_key", length = 64) private String appKey; - @Column(nullable = false, name = "device_id", length = 255, unique = true) + @Column(nullable = false, name = "device_id", length = 255) private String deviceId; @Column(name = "device_name", length = 255) diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/SystemUpdateController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/SystemUpdateController.java index 8ac2648..26df32a 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/SystemUpdateController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/SystemUpdateController.java @@ -4,11 +4,14 @@ import com.xuqm.tenant.config.PrivateDeploymentProperties; import com.xuqm.tenant.service.SystemUpdateService; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import java.util.Map; + @RestController @RequestMapping("/api/system") public class SystemUpdateController { @@ -22,6 +25,16 @@ public class SystemUpdateController { this.updateService = updateService; } + /** 返回当前部署版本号。仅 PRIVATE 模式可用。 */ + @GetMapping("/version") + public ResponseEntity version() { + if (!deployProps.isPrivate()) { + return ResponseEntity.status(403).body(Map.of("message", "此接口仅在私有化部署可用")); + } + String currentVersion = updateService.readCurrentVersion(); + return ResponseEntity.ok(Map.of("data", Map.of("currentVersion", currentVersion))); + } + /** * 拉取最新镜像并重建所有容器。耗时较长(需 docker pull)。 * 仅 PRIVATE 模式可用。 diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java index 1590c81..9b5ca4f 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java @@ -3,8 +3,11 @@ 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; @@ -12,6 +15,10 @@ 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.List; import java.util.function.Consumer; @@ -20,7 +27,7 @@ public class SystemUpdateService { private static final Logger log = LoggerFactory.getLogger(SystemUpdateService.class); - // nginx is restarted last so it picks up any patched config files. + // nginx 最后重启,确保它能获取到其他服务修复后的配置 private static final List OTHER_SERVICES = List.of( "file-service", "tenant-web", "im-service", "push-service", "update-service", "license-service", "nginx" ); @@ -28,12 +35,39 @@ public class SystemUpdateService { @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)); + } + + // ── 公开接口 ──────────────────────────────────────────────────────────────── + + /** 读取部署目录的 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) { @@ -52,10 +86,118 @@ public class SystemUpdateService { String composeFile = deployRoot + "/docker-compose.yml"; patchConfigs(emit); + runSchemaMigrations(emit); restartAndSelfUpdate(emit, composeFile); } - // ── Shared core ─────────────────────────────────────────────────────────── + // ── 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(">>> 重建各服务容器..."); @@ -75,7 +217,6 @@ public class SystemUpdateService { return; } boolean helperStarted = spawnSelfUpdater(composeFile, selfImage); - if (helperStarted) { emit.accept(">>> 助手容器已就绪,tenant-service 即将重建(连接将短暂中断)..."); emit.accept("RESTART_SELF"); @@ -86,7 +227,7 @@ public class SystemUpdateService { } } - // ── Config patchers ─────────────────────────────────────────────────────── + // ── 配置文件热修复 ────────────────────────────────────────────────────────── private void patchConfigs(Consumer emit) { emit.accept(">>> 检查并修复配置文件..."); @@ -101,7 +242,6 @@ public class SystemUpdateService { if (!Files.exists(conf)) return; try { String content = Files.readString(conf); - // Already patched with regex location (new format) or exact-match (old format) 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)) { @@ -119,8 +259,7 @@ public class SystemUpdateService { + " proxy_send_timeout 600s;\n" + " }\n\n" + anchor; - String patched = content.replace(anchor, injection); - Files.writeString(conf, patched, StandardOpenOption.TRUNCATE_EXISTING); + 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()); @@ -133,8 +272,8 @@ public class SystemUpdateService { try { String content = Files.readString(conf); if (!content.contains("location /file/")) return; - String patched = content.replace("location /file/", "location /api/file/"); - Files.writeString(conf, patched, StandardOpenOption.TRUNCATE_EXISTING); + 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()); @@ -153,14 +292,13 @@ public class SystemUpdateService { String anchor = " SPRING_DATA_REDIS_DATABASE: \"${REDIS_DATABASE:-0}\"\n"; if (!content.contains(anchor)) { - emit.accept(" [跳过] docker-compose 文件-服务补丁锚点未找到,请手动检查"); + emit.accept(" [跳过] docker-compose 文件服务补丁锚点未找到,请手动检查"); return; } String injection = anchor + " FILE_UPLOAD_DIR: \"/data/uploads\"\n" + " FILE_BASE_URL: \"" + consoleDomain + "\"\n"; - String patched = content.replace(anchor, injection); - Files.writeString(composeFile, patched, StandardOpenOption.TRUNCATE_EXISTING); + 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()); @@ -177,22 +315,16 @@ public class SystemUpdateService { String consoleDomain = readEnvValue(Paths.get(deployRoot, "config", "xuqm.env"), "CONSOLE_DOMAIN"); if (consoleDomain == null) consoleDomain = ""; - String anchor = " SDK_TENANT_SERVICE_URL: \"http://tenant-service:9001\"\n"; - // Fallback anchor for older docker-compose that may not have SDK_TENANT_SERVICE_URL - String fallbackAnchor = " update-service:\n"; 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)) { - // Inject env block into update-service's environment section by finding its image line - String imageAnchor = " image: ${REGISTRY}/update-service:${IMAGE_TAG}\n"; - if (!content.contains(imageAnchor)) { - emit.accept(" [跳过] docker-compose update-service 补丁锚点未找到,请手动检查"); - return; - } - String envAnchor = imageAnchor + " environment:\n"; + String envAnchor = fallbackAnchor + " environment:\n"; if (!content.contains(envAnchor)) { emit.accept(" [跳过] docker-compose update-service environment 段未找到,请手动检查"); return; @@ -209,14 +341,14 @@ public class SystemUpdateService { } } - // ── Docker helpers ──────────────────────────────────────────────────────── + // ── 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(); + 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;