feat(system): 添加系统版本查询和数据库迁移功能
- 移除 license-service 中 DeviceEntity 的 device_id 唯一约束注解 - 添加 /api/system/version 接口用于查询当前部署版本 - 实现数据库 schema 版本化迁移机制 - 添加自动执行数据库迁移的功能 - 在前端安全中心界面显示当前版本和迁移状态 - 优化配置文件修复逻辑和代码结构
这个提交包含在:
父节点
c6ab1b9244
当前提交
0e5558116c
@ -19,7 +19,7 @@ public class DeviceEntity {
|
|||||||
@Column(nullable = false, name = "app_key", length = 64)
|
@Column(nullable = false, name = "app_key", length = 64)
|
||||||
private String appKey;
|
private String appKey;
|
||||||
|
|
||||||
@Column(nullable = false, name = "device_id", length = 255, unique = true)
|
@Column(nullable = false, name = "device_id", length = 255)
|
||||||
private String deviceId;
|
private String deviceId;
|
||||||
|
|
||||||
@Column(name = "device_name", length = 255)
|
@Column(name = "device_name", length = 255)
|
||||||
|
|||||||
@ -4,11 +4,14 @@ import com.xuqm.tenant.config.PrivateDeploymentProperties;
|
|||||||
import com.xuqm.tenant.service.SystemUpdateService;
|
import com.xuqm.tenant.service.SystemUpdateService;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/system")
|
@RequestMapping("/api/system")
|
||||||
public class SystemUpdateController {
|
public class SystemUpdateController {
|
||||||
@ -22,6 +25,16 @@ public class SystemUpdateController {
|
|||||||
this.updateService = updateService;
|
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)。
|
* 拉取最新镜像并重建所有容器。耗时较长(需 docker pull)。
|
||||||
* 仅 PRIVATE 模式可用。
|
* 仅 PRIVATE 模式可用。
|
||||||
|
|||||||
@ -3,8 +3,11 @@ package com.xuqm.tenant.service;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
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 org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
@ -12,6 +15,10 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.nio.file.StandardOpenOption;
|
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.List;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
@ -20,7 +27,7 @@ public class SystemUpdateService {
|
|||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(SystemUpdateService.class);
|
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<String> OTHER_SERVICES = List.of(
|
private static final List<String> OTHER_SERVICES = List.of(
|
||||||
"file-service", "tenant-web", "im-service", "push-service", "update-service", "license-service", "nginx"
|
"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}")
|
@Value("${PRIVATE_DEPLOY_ROOT:/opt/xuqm-private}")
|
||||||
private String deployRoot;
|
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<String> emit) {
|
public void runUpdate(Consumer<String> emit) {
|
||||||
String composeFile = deployRoot + "/docker-compose.yml";
|
String composeFile = deployRoot + "/docker-compose.yml";
|
||||||
|
|
||||||
dockerLogin(emit);
|
dockerLogin(emit);
|
||||||
patchConfigs(emit);
|
patchConfigs(emit);
|
||||||
|
runSchemaMigrations(emit);
|
||||||
|
|
||||||
emit.accept(">>> 拉取最新镜像...");
|
emit.accept(">>> 拉取最新镜像...");
|
||||||
for (String svc : OTHER_SERVICES) {
|
for (String svc : OTHER_SERVICES) {
|
||||||
@ -52,10 +86,118 @@ public class SystemUpdateService {
|
|||||||
String composeFile = deployRoot + "/docker-compose.yml";
|
String composeFile = deployRoot + "/docker-compose.yml";
|
||||||
|
|
||||||
patchConfigs(emit);
|
patchConfigs(emit);
|
||||||
|
runSchemaMigrations(emit);
|
||||||
restartAndSelfUpdate(emit, composeFile);
|
restartAndSelfUpdate(emit, composeFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Shared core ───────────────────────────────────────────────────────────
|
// ── Schema 版本化迁移 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行所有待处理的 schema 迁移。
|
||||||
|
*
|
||||||
|
* 迁移原则:
|
||||||
|
* - ddl-auto:update 自动处理新增列/表,此处仅处理 Hibernate 无法完成的变更
|
||||||
|
* (删列、改列名、类型转换、数据填充等)
|
||||||
|
* - 每个迁移有唯一 ID,执行后记录到 _schema_migrations,保证幂等
|
||||||
|
* - 新版本新增迁移时,在末尾追加新的 migrate_xxx() 调用即可
|
||||||
|
*/
|
||||||
|
public void runSchemaMigrations(Consumer<String> 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<String> 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<String> emit, String composeFile) {
|
private void restartAndSelfUpdate(Consumer<String> emit, String composeFile) {
|
||||||
emit.accept(">>> 重建各服务容器...");
|
emit.accept(">>> 重建各服务容器...");
|
||||||
@ -75,7 +217,6 @@ public class SystemUpdateService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
boolean helperStarted = spawnSelfUpdater(composeFile, selfImage);
|
boolean helperStarted = spawnSelfUpdater(composeFile, selfImage);
|
||||||
|
|
||||||
if (helperStarted) {
|
if (helperStarted) {
|
||||||
emit.accept(">>> 助手容器已就绪,tenant-service 即将重建(连接将短暂中断)...");
|
emit.accept(">>> 助手容器已就绪,tenant-service 即将重建(连接将短暂中断)...");
|
||||||
emit.accept("RESTART_SELF");
|
emit.accept("RESTART_SELF");
|
||||||
@ -86,7 +227,7 @@ public class SystemUpdateService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Config patchers ───────────────────────────────────────────────────────
|
// ── 配置文件热修复 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void patchConfigs(Consumer<String> emit) {
|
private void patchConfigs(Consumer<String> emit) {
|
||||||
emit.accept(">>> 检查并修复配置文件...");
|
emit.accept(">>> 检查并修复配置文件...");
|
||||||
@ -101,7 +242,6 @@ public class SystemUpdateService {
|
|||||||
if (!Files.exists(conf)) return;
|
if (!Files.exists(conf)) return;
|
||||||
try {
|
try {
|
||||||
String content = Files.readString(conf);
|
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;
|
if (content.contains("location ~ ^/api/system/") || content.contains("location = /api/system/update")) return;
|
||||||
String anchor = " # 核心 API(兜底,在所有具体 /api/xxx/ 之后)\n location /api/ {";
|
String anchor = " # 核心 API(兜底,在所有具体 /api/xxx/ 之后)\n location /api/ {";
|
||||||
if (!content.contains(anchor)) {
|
if (!content.contains(anchor)) {
|
||||||
@ -119,8 +259,7 @@ public class SystemUpdateService {
|
|||||||
+ " proxy_send_timeout 600s;\n"
|
+ " proxy_send_timeout 600s;\n"
|
||||||
+ " }\n\n"
|
+ " }\n\n"
|
||||||
+ anchor;
|
+ anchor;
|
||||||
String patched = content.replace(anchor, injection);
|
Files.writeString(conf, content.replace(anchor, injection), StandardOpenOption.TRUNCATE_EXISTING);
|
||||||
Files.writeString(conf, patched, StandardOpenOption.TRUNCATE_EXISTING);
|
|
||||||
emit.accept(" [已修复] nginx: 补齐 /api/system/(update|reset) 600s 超时");
|
emit.accept(" [已修复] nginx: 补齐 /api/system/(update|reset) 600s 超时");
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
emit.accept(" [警告] nginx 更新超时修复失败: " + e.getMessage());
|
emit.accept(" [警告] nginx 更新超时修复失败: " + e.getMessage());
|
||||||
@ -133,8 +272,8 @@ public class SystemUpdateService {
|
|||||||
try {
|
try {
|
||||||
String content = Files.readString(conf);
|
String content = Files.readString(conf);
|
||||||
if (!content.contains("location /file/")) return;
|
if (!content.contains("location /file/")) return;
|
||||||
String patched = content.replace("location /file/", "location /api/file/");
|
Files.writeString(conf, content.replace("location /file/", "location /api/file/"),
|
||||||
Files.writeString(conf, patched, StandardOpenOption.TRUNCATE_EXISTING);
|
StandardOpenOption.TRUNCATE_EXISTING);
|
||||||
emit.accept(" [已修复] nginx: location /file/ → /api/file/");
|
emit.accept(" [已修复] nginx: location /file/ → /api/file/");
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
emit.accept(" [警告] nginx 配置修复失败: " + e.getMessage());
|
emit.accept(" [警告] nginx 配置修复失败: " + e.getMessage());
|
||||||
@ -153,14 +292,13 @@ public class SystemUpdateService {
|
|||||||
|
|
||||||
String anchor = " SPRING_DATA_REDIS_DATABASE: \"${REDIS_DATABASE:-0}\"\n";
|
String anchor = " SPRING_DATA_REDIS_DATABASE: \"${REDIS_DATABASE:-0}\"\n";
|
||||||
if (!content.contains(anchor)) {
|
if (!content.contains(anchor)) {
|
||||||
emit.accept(" [跳过] docker-compose 文件-服务补丁锚点未找到,请手动检查");
|
emit.accept(" [跳过] docker-compose 文件服务补丁锚点未找到,请手动检查");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String injection = anchor
|
String injection = anchor
|
||||||
+ " FILE_UPLOAD_DIR: \"/data/uploads\"\n"
|
+ " FILE_UPLOAD_DIR: \"/data/uploads\"\n"
|
||||||
+ " FILE_BASE_URL: \"" + consoleDomain + "\"\n";
|
+ " FILE_BASE_URL: \"" + consoleDomain + "\"\n";
|
||||||
String patched = content.replace(anchor, injection);
|
Files.writeString(composeFile, content.replace(anchor, injection), StandardOpenOption.TRUNCATE_EXISTING);
|
||||||
Files.writeString(composeFile, patched, StandardOpenOption.TRUNCATE_EXISTING);
|
|
||||||
emit.accept(" [已修复] docker-compose: 补齐 FILE_UPLOAD_DIR 和 FILE_BASE_URL");
|
emit.accept(" [已修复] docker-compose: 补齐 FILE_UPLOAD_DIR 和 FILE_BASE_URL");
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
emit.accept(" [警告] docker-compose 修复失败: " + e.getMessage());
|
emit.accept(" [警告] docker-compose 修复失败: " + e.getMessage());
|
||||||
@ -177,22 +315,16 @@ public class SystemUpdateService {
|
|||||||
String consoleDomain = readEnvValue(Paths.get(deployRoot, "config", "xuqm.env"), "CONSOLE_DOMAIN");
|
String consoleDomain = readEnvValue(Paths.get(deployRoot, "config", "xuqm.env"), "CONSOLE_DOMAIN");
|
||||||
if (consoleDomain == null) consoleDomain = "";
|
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"
|
String envBlock = " FILE_BASE_URL: \"" + consoleDomain + "\"\n"
|
||||||
+ " FILE_SERVICE_INTERNAL_URL: \"http://file-service:8086\"\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;
|
String patched;
|
||||||
if (content.contains(anchor)) {
|
if (content.contains(anchor)) {
|
||||||
patched = content.replace(anchor, anchor + envBlock);
|
patched = content.replace(anchor, anchor + envBlock);
|
||||||
} else if (content.contains(fallbackAnchor)) {
|
} else if (content.contains(fallbackAnchor)) {
|
||||||
// Inject env block into update-service's environment section by finding its image line
|
String envAnchor = fallbackAnchor + " environment:\n";
|
||||||
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";
|
|
||||||
if (!content.contains(envAnchor)) {
|
if (!content.contains(envAnchor)) {
|
||||||
emit.accept(" [跳过] docker-compose update-service environment 段未找到,请手动检查");
|
emit.accept(" [跳过] docker-compose update-service environment 段未找到,请手动检查");
|
||||||
return;
|
return;
|
||||||
@ -209,7 +341,7 @@ public class SystemUpdateService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Docker helpers ────────────────────────────────────────────────────────
|
// ── Docker 工具方法 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void dockerLogin(Consumer<String> emit) {
|
private void dockerLogin(Consumer<String> emit) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户