XuqmGroup-Server/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java
XuqmGroup 4a38147cb9 feat(tenant-service): 一键更新自动修复配置文件
- 更新前执行幂等配置修复:nginx location /file/ → /api/file/,
  docker-compose.yml 补齐 FILE_UPLOAD_DIR 和 FILE_BASE_URL
- nginx 移至 OTHER_SERVICES 末尾,最后重启以应用修复后的配置
- docker login 读取 .env 中的仓库凭据,解决私有镜像拉取 403

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 17:08:01 +08:00

263 行
11 KiB
Java

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

package com.xuqm.tenant.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.List;
import java.util.function.Consumer;
@Service
public class SystemUpdateService {
private static final Logger log = LoggerFactory.getLogger(SystemUpdateService.class);
// nginx is restarted last so it picks up any patched config files.
private static final List<String> OTHER_SERVICES = List.of(
"file-service", "tenant-web", "im-service", "push-service", "update-service", "license-service", "nginx"
);
@Value("${PRIVATE_DEPLOY_ROOT:/opt/xuqm-private}")
private String deployRoot;
public void runUpdate(Consumer<String> emit) {
String composeFile = deployRoot + "/docker-compose.yml";
// Step 1: authenticate to registry
dockerLogin(emit);
// Step 2: apply any pending config patches (idempotent)
patchConfigs(emit);
// Step 3: pull images
emit.accept(">>> 拉取最新镜像...");
for (String svc : OTHER_SERVICES) {
if (isRunning(svc)) {
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(">>> 镜像拉取完成");
// Step 4: restart other services (nginx last so patched conf is applied)
emit.accept(">>> 重启各服务...");
for (String svc : OTHER_SERVICES) {
if (isRunning(svc)) {
emit.accept(" restarting " + svc + " ...");
exec(emit, "docker", "compose", "-f", composeFile,
"up", "-d", "--no-deps", "--force-recreate", svc);
emit.accept(" " + svc + "");
}
}
// Step 5: self-update tenant-service via detached helper container
emit.accept(">>> 启动自更新助手容器...");
String selfImage = getCurrentImage();
if (selfImage == null) {
emit.accept(">>> [错误] 无法获取当前 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");
}
}
/**
* Idempotent config patches — fixes known misconfigurations introduced in earlier deploy versions.
* Each patch checks before writing so running this multiple times is safe.
*/
private void patchConfigs(Consumer<String> emit) {
emit.accept(">>> 检查并修复配置文件...");
patchNginxFileRoute(emit);
patchDockerComposeFileService(emit);
}
/** nginx: location /file/ must be location /api/file/ to match the file controller path. */
private void patchNginxFileRoute(Consumer<String> 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;
String patched = content.replace("location /file/", "location /api/file/");
Files.writeString(conf, patched, StandardOpenOption.TRUNCATE_EXISTING);
emit.accept(" [已修复] nginx: location /file/ → /api/file/");
} catch (IOException e) {
emit.accept(" [警告] nginx 配置修复失败: " + e.getMessage());
}
}
/**
* docker-compose: ensure FILE_UPLOAD_DIR and FILE_BASE_URL are set for file-service.
* Reads CONSOLE_DOMAIN from xuqm.env to determine the correct base URL.
*/
private void patchDockerComposeFileService(Consumer<String> 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 = "";
// Inject the missing env vars directly after SPRING_DATA_REDIS_DATABASE line in file-service block.
// This pattern is stable — the line is unique in the file-service environment section.
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";
String patched = content.replace(anchor, injection);
Files.writeString(composeFile, patched, StandardOpenOption.TRUNCATE_EXISTING);
emit.accept(" [已修复] docker-compose: 补齐 FILE_UPLOAD_DIR 和 FILE_BASE_URL");
} catch (IOException e) {
emit.accept(" [警告] docker-compose 修复失败: " + e.getMessage());
}
}
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;
}
/**
* Read REGISTRY / REGISTRY_USER / REGISTRY_PASSWORD from deployRoot/.env and
* run "docker login" so that subsequent pulls succeed on private registries.
*/
private void dockerLogin(Consumer<String> 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());
}
}
/**
* 启动一个独立的 detached 容器,在 tenant-service 被停止后重建它。
*/
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 boolean isRunning(String service) {
try {
Process p = new ProcessBuilder(
"docker", "ps", "-q",
"--filter", "label=com.docker.compose.service=" + service
).redirectErrorStream(true).start();
String out = new String(p.getInputStream().readAllBytes()).trim();
p.waitFor();
return !out.isEmpty();
} catch (Exception 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 void exec(Consumer<String> 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);
}
}
}