feat(tenant): split update/reset ops, remove bootstrap app auto-creation

- SystemUpdateService: split runUpdate() (pull+recreate) and runReset() (recreate only)
- SystemUpdateController: add POST /api/system/reset endpoint
- SdkAppProvisioningService: remove ensureBootstrapApp/ensureApp/ensureFeatureDefaults; resolveApp now throws 404 instead of auto-creating
- SdkAppInitializer: remove ensureBootstrapApp call; only runs one-time migration marking existing system apps as isDefault=true
- PrivateTenantBootstrapInitializer: remove bootstrap app creation; only ensures admin tenant account exists

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-05-22 15:33:20 +08:00
父节点 9728dbb002
当前提交 32aa3c0eef
共有 5 个文件被更改,包括 73 次插入276 次删除

查看文件

@ -1,10 +1,7 @@
package com.xuqm.tenant.config; package com.xuqm.tenant.config;
import com.xuqm.tenant.entity.AppEntity;
import com.xuqm.tenant.entity.TenantEntity; import com.xuqm.tenant.entity.TenantEntity;
import com.xuqm.tenant.repository.AppRepository;
import com.xuqm.tenant.repository.TenantRepository; import com.xuqm.tenant.repository.TenantRepository;
import com.xuqm.tenant.service.SdkAppProvisioningService;
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;
@ -30,8 +27,6 @@ public class PrivateTenantBootstrapInitializer implements ApplicationRunner {
private final PrivateDeploymentProperties deployProps; private final PrivateDeploymentProperties deployProps;
private final TenantRepository tenantRepository; private final TenantRepository tenantRepository;
private final AppRepository appRepository;
private final SdkAppProvisioningService provisioningService;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
@Value("${tenant.bootstrap.email:admin@customer.com}") @Value("${tenant.bootstrap.email:admin@customer.com}")
@ -43,18 +38,11 @@ public class PrivateTenantBootstrapInitializer implements ApplicationRunner {
@Value("${tenant.bootstrap.username:admin}") @Value("${tenant.bootstrap.username:admin}")
private String bootstrapUsername; private String bootstrapUsername;
@Value("${sdk.bootstrap-app-key:ak_private_default}")
private String bootstrapAppKey;
public PrivateTenantBootstrapInitializer(PrivateDeploymentProperties deployProps, public PrivateTenantBootstrapInitializer(PrivateDeploymentProperties deployProps,
TenantRepository tenantRepository, TenantRepository tenantRepository,
AppRepository appRepository,
SdkAppProvisioningService provisioningService,
PasswordEncoder passwordEncoder) { PasswordEncoder passwordEncoder) {
this.deployProps = deployProps; this.deployProps = deployProps;
this.tenantRepository = tenantRepository; this.tenantRepository = tenantRepository;
this.appRepository = appRepository;
this.provisioningService = provisioningService;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
} }
@ -84,16 +72,5 @@ public class PrivateTenantBootstrapInitializer implements ApplicationRunner {
} else { } else {
log.info("[PRIVATE] Bootstrap tenant already exists: {}", bootstrapEmail); log.info("[PRIVATE] Bootstrap tenant already exists: {}", bootstrapEmail);
} }
AppEntity app = appRepository.findByAppKey(bootstrapAppKey).orElse(null);
if (app == null) {
provisioningService.ensureApp(bootstrapAppKey, true);
log.info("[PRIVATE] Bootstrap app created: {}", bootstrapAppKey);
} else if (!app.isDefault() || app.isDeletable()) {
app.setDefault(true);
app.setDeletable(false);
appRepository.save(app);
log.info("[PRIVATE] Bootstrap app marked as system app: {}", bootstrapAppKey);
}
} }
} }

查看文件

@ -2,7 +2,6 @@ package com.xuqm.tenant.config;
import com.xuqm.tenant.entity.AppEntity; import com.xuqm.tenant.entity.AppEntity;
import com.xuqm.tenant.repository.AppRepository; import com.xuqm.tenant.repository.AppRepository;
import com.xuqm.tenant.service.SdkAppProvisioningService;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner; import org.springframework.boot.ApplicationRunner;
@ -11,8 +10,6 @@ import org.springframework.stereotype.Component;
@Component @Component
public class SdkAppInitializer implements ApplicationRunner { public class SdkAppInitializer implements ApplicationRunner {
private final SdkAppProvisioningService provisioningService;
private final PrivateDeploymentProperties deployProps;
private final AppRepository appRepository; private final AppRepository appRepository;
@Value("${sdk.bootstrap-app-key:ak_demo_chat}") @Value("${sdk.bootstrap-app-key:ak_demo_chat}")
@ -21,20 +18,13 @@ public class SdkAppInitializer implements ApplicationRunner {
@Value("${sdk.im-platform-app-key:ak_409e217e4aa14254ad73ad3c}") @Value("${sdk.im-platform-app-key:ak_409e217e4aa14254ad73ad3c}")
private String imPlatformAppKey; private String imPlatformAppKey;
public SdkAppInitializer(SdkAppProvisioningService provisioningService, public SdkAppInitializer(AppRepository appRepository) {
PrivateDeploymentProperties deployProps,
AppRepository appRepository) {
this.provisioningService = provisioningService;
this.deployProps = deployProps;
this.appRepository = appRepository; this.appRepository = appRepository;
} }
@Override @Override
public void run(ApplicationArguments args) { public void run(ApplicationArguments args) {
migrateExistingSystemApps(); migrateExistingSystemApps();
if (!deployProps.isPrivate()) {
provisioningService.ensureBootstrapApp();
}
} }
private void migrateExistingSystemApps() { private void migrateExistingSystemApps() {

查看文件

@ -23,8 +23,8 @@ public class SystemUpdateController {
} }
/** /**
* 触发私有化部署一键升级以流式文本返回进度日志 * 拉取最新镜像并重建所有容器耗时较长 docker pull
* PRIVATE 模式可用需要 JWT 认证租户账号即可 * PRIVATE 模式可用
*/ */
@PostMapping(value = "/update", produces = MediaType.TEXT_PLAIN_VALUE) @PostMapping(value = "/update", produces = MediaType.TEXT_PLAIN_VALUE)
public ResponseEntity<StreamingResponseBody> update() { public ResponseEntity<StreamingResponseBody> update() {
@ -33,16 +33,30 @@ public class SystemUpdateController {
.contentType(MediaType.TEXT_PLAIN) .contentType(MediaType.TEXT_PLAIN)
.body(out -> out.write("此接口仅在私有化部署可用\n".getBytes())); .body(out -> out.write("此接口仅在私有化部署可用\n".getBytes()));
} }
return stream(emit -> updateService.runUpdate(emit));
}
StreamingResponseBody body = outputStream -> { /**
updateService.runUpdate(line -> { * 不拉取新镜像直接用当前本地镜像重建所有容器速度快适合修复异常服务
* PRIVATE 模式可用
*/
@PostMapping(value = "/reset", produces = MediaType.TEXT_PLAIN_VALUE)
public ResponseEntity<StreamingResponseBody> reset() {
if (!deployProps.isPrivate()) {
return ResponseEntity.status(403)
.contentType(MediaType.TEXT_PLAIN)
.body(out -> out.write("此接口仅在私有化部署可用\n".getBytes()));
}
return stream(emit -> updateService.runReset(emit));
}
private ResponseEntity<StreamingResponseBody> stream(java.util.function.Consumer<java.util.function.Consumer<String>> action) {
StreamingResponseBody body = outputStream -> action.accept(line -> {
try { try {
outputStream.write((line + "\n").getBytes()); outputStream.write((line + "\n").getBytes());
outputStream.flush(); outputStream.flush();
} catch (Exception ignored) {} } catch (Exception ignored) {}
}); });
};
return ResponseEntity.ok() return ResponseEntity.ok()
.contentType(MediaType.TEXT_PLAIN) .contentType(MediaType.TEXT_PLAIN)
.body(body); .body(body);

查看文件

@ -2,181 +2,23 @@ package com.xuqm.tenant.service;
import com.xuqm.common.exception.BusinessException; import com.xuqm.common.exception.BusinessException;
import com.xuqm.tenant.entity.AppEntity; import com.xuqm.tenant.entity.AppEntity;
import com.xuqm.tenant.entity.FeatureServiceEntity;
import com.xuqm.tenant.entity.TenantEntity;
import com.xuqm.tenant.repository.AppRepository; import com.xuqm.tenant.repository.AppRepository;
import com.xuqm.tenant.repository.FeatureServiceRepository;
import com.xuqm.tenant.repository.TenantRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.List;
import java.util.UUID;
@Service @Service
public class SdkAppProvisioningService { public class SdkAppProvisioningService {
private static final SecureRandom RANDOM = new SecureRandom();
private final AppRepository appRepository; private final AppRepository appRepository;
private final TenantRepository tenantRepository;
private final FeatureServiceRepository featureServiceRepository;
private final PasswordEncoder passwordEncoder;
@Value("${sdk.bootstrap-app-key:ak_demo_chat}") public SdkAppProvisioningService(AppRepository appRepository) {
private String bootstrapAppKey;
@Value("${sdk.bootstrap-app-name:Demo Chat}")
private String bootstrapAppName;
@Value("${sdk.bootstrap-app-package:com.xuqm.demo}")
private String bootstrapAppPackage;
@Value("${sdk.bootstrap-app-description:XuqmGroup demo app}")
private String bootstrapAppDescription;
public SdkAppProvisioningService(AppRepository appRepository,
TenantRepository tenantRepository,
FeatureServiceRepository featureServiceRepository,
PasswordEncoder passwordEncoder) {
this.appRepository = appRepository; this.appRepository = appRepository;
this.tenantRepository = tenantRepository;
this.featureServiceRepository = featureServiceRepository;
this.passwordEncoder = passwordEncoder;
}
@Transactional
public AppEntity ensureBootstrapApp() {
return ensureApp(bootstrapAppKey, true);
} }
@Transactional @Transactional
public AppEntity resolveApp(String appKey) { public AppEntity resolveApp(String appKey) {
return appRepository.findByAppKey(appKey) return appRepository.findByAppKey(appKey)
.or(() -> appRepository.findById(appKey)) .or(() -> appRepository.findById(appKey))
.orElseGet(() -> { .orElseThrow(() -> new BusinessException(404, "App not found: " + appKey));
if (!bootstrapAppKey.equals(appKey)) {
throw new BusinessException(404, "App not found: " + appKey);
}
return ensureApp(appKey, true);
});
}
@Transactional
public AppEntity ensureApp(String appKey, boolean bootstrapDefaults) {
AppEntity existing = appRepository.findByAppKey(appKey).orElse(null);
if (existing != null) {
if (bootstrapDefaults) {
ensureFeatureDefaults(existing);
}
return existing;
}
TenantEntity owner = resolveOwnerTenant();
AppEntity app = new AppEntity();
app.setId(UUID.randomUUID().toString());
app.setTenantId(owner.getId());
app.setPackageName(bootstrapAppPackage);
app.setName(bootstrapAppName);
app.setDescription(bootstrapAppDescription);
app.setIconUrl(null);
app.setAppKey(appKey);
app.setAppSecret(generateSecret());
app.setCreatedAt(LocalDateTime.now());
app.setDefault(true);
app.setDeletable(false);
app = appRepository.save(app);
if (bootstrapDefaults) {
ensureFeatureDefaults(app);
}
return app;
}
private TenantEntity resolveOwnerTenant() {
return tenantRepository.findFirstByOrderByCreatedAtAsc()
.orElseGet(this::createBootstrapTenant);
}
private TenantEntity createBootstrapTenant() {
TenantEntity tenant = new TenantEntity();
tenant.setId(UUID.randomUUID().toString());
tenant.setUsername("system");
tenant.setPassword(passwordEncoder.encode(generateSecret()));
tenant.setEmail("system@xuqinmin.com");
tenant.setNickname("System");
tenant.setPhone(null);
tenant.setType(TenantEntity.Type.MAIN);
tenant.setStatus(TenantEntity.Status.ACTIVE);
tenant.setParentId(null);
tenant.setCreatedAt(LocalDateTime.now());
return tenantRepository.save(tenant);
}
private void ensureFeatureDefaults(AppEntity app) {
featureServiceRepository.findByAppKeyAndServiceType(app.getAppKey(), FeatureServiceEntity.ServiceType.IM)
.stream()
.findFirst()
.orElseGet(() -> {
FeatureServiceEntity feature = new FeatureServiceEntity();
feature.setId(UUID.randomUUID().toString());
feature.setAppKey(app.getAppKey());
feature.setPlatform(FeatureServiceEntity.Platform.ANDROID);
feature.setServiceType(FeatureServiceEntity.ServiceType.IM);
feature.setEnabled(true);
feature.setConfig("""
{"allowStrangerMessage":false,"allowFriendRequest":true,"friendRequestMode":"REQUIRE_CONFIRM","allowGroupJoinRequest":true,"blacklistSendSuccess":true,"messageRecallMinutes":2,"historyRetentionDays":7,"conversationPullLimit":100,"allowMultiDeviceLogin":true,"multiClientConversationDeleteSync":false}
""".trim());
feature.setCreatedAt(LocalDateTime.now());
return featureServiceRepository.save(feature);
});
for (FeatureServiceEntity.Platform platform : List.of(
FeatureServiceEntity.Platform.ANDROID,
FeatureServiceEntity.Platform.IOS,
FeatureServiceEntity.Platform.HARMONY)) {
for (FeatureServiceEntity.ServiceType serviceType : List.of(
FeatureServiceEntity.ServiceType.PUSH,
FeatureServiceEntity.ServiceType.UPDATE,
FeatureServiceEntity.ServiceType.LICENSE)) {
featureServiceRepository.findByAppKeyAndPlatformAndServiceType(app.getAppKey(), platform, serviceType)
.orElseGet(() -> {
FeatureServiceEntity feature = new FeatureServiceEntity();
feature.setId(UUID.randomUUID().toString());
feature.setAppKey(app.getAppKey());
feature.setPlatform(platform);
feature.setServiceType(serviceType);
feature.setEnabled(true);
feature.setConfig(switch (serviceType) {
case UPDATE -> switch (platform) {
case ANDROID -> """
{"defaultStoreTargets":[],"defaultPublishMode":"MANUAL","defaultPublishImmediately":false,"defaultScheduledPublishAt":"","defaultAutoPublishAfterReview":false,"defaultWebhookUrl":"","defaultForceUpdate":false,"defaultGrayEnabled":false,"defaultGrayPercent":0,"defaultPackageName":"","defaultAppStoreUrl":"","defaultMarketUrl":""}
""".trim();
case IOS -> """
{"defaultStoreTargets":["APP_STORE"],"defaultPublishMode":"MANUAL","defaultPublishImmediately":false,"defaultScheduledPublishAt":"","defaultAutoPublishAfterReview":false,"defaultWebhookUrl":"","defaultForceUpdate":false,"defaultGrayEnabled":false,"defaultGrayPercent":0,"defaultPackageName":"","defaultAppStoreUrl":"","defaultMarketUrl":""}
""".trim();
case HARMONY -> """
{"defaultStoreTargets":[],"defaultPublishMode":"MANUAL","defaultPublishImmediately":false,"defaultScheduledPublishAt":"","defaultAutoPublishAfterReview":false,"defaultWebhookUrl":"","defaultForceUpdate":false,"defaultGrayEnabled":false,"defaultGrayPercent":0,"defaultPackageName":"","defaultAppStoreUrl":"","defaultMarketUrl":""}
""".trim();
};
case LICENSE -> "{\"maxDevices\":1}";
default -> null;
});
feature.setCreatedAt(LocalDateTime.now());
return featureServiceRepository.save(feature);
});
}
}
}
private String generateSecret() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
} }
} }

查看文件

@ -28,43 +28,49 @@ public class SystemUpdateService {
@Value("${PRIVATE_DEPLOY_ROOT:/opt/xuqm-private}") @Value("${PRIVATE_DEPLOY_ROOT:/opt/xuqm-private}")
private String deployRoot; private String deployRoot;
/** 拉取最新镜像并重建所有容器。 */
public void runUpdate(Consumer<String> emit) { public void runUpdate(Consumer<String> emit) {
String composeFile = deployRoot + "/docker-compose.yml"; String composeFile = deployRoot + "/docker-compose.yml";
// Step 1: authenticate to registry
dockerLogin(emit); dockerLogin(emit);
// Step 2: apply any pending config patches (idempotent)
patchConfigs(emit); patchConfigs(emit);
// Step 3: pull images
emit.accept(">>> 拉取最新镜像..."); emit.accept(">>> 拉取最新镜像...");
for (String svc : OTHER_SERVICES) { for (String svc : OTHER_SERVICES) {
if (isRunning(svc)) {
emit.accept(" pulling " + svc + " ..."); emit.accept(" pulling " + svc + " ...");
exec(emit, "docker", "compose", "-f", composeFile, "pull", "--quiet", svc); exec(emit, "docker", "compose", "-f", composeFile, "pull", "--quiet", svc);
} }
}
emit.accept(" pulling tenant-service ..."); emit.accept(" pulling tenant-service ...");
exec(emit, "docker", "compose", "-f", composeFile, "pull", "--quiet", "tenant-service"); exec(emit, "docker", "compose", "-f", composeFile, "pull", "--quiet", "tenant-service");
emit.accept(">>> 镜像拉取完成"); emit.accept(">>> 镜像拉取完成");
// Step 4: restart other services (nginx last so patched conf is applied) restartAndSelfUpdate(emit, composeFile);
emit.accept(">>> 重启各服务..."); }
/** 不拉取新镜像,直接用当前本地镜像重建所有容器。 */
public void runReset(Consumer<String> emit) {
String composeFile = deployRoot + "/docker-compose.yml";
patchConfigs(emit);
restartAndSelfUpdate(emit, composeFile);
}
// Shared core
private void restartAndSelfUpdate(Consumer<String> emit, String composeFile) {
emit.accept(">>> 重建各服务容器...");
for (String svc : OTHER_SERVICES) { for (String svc : OTHER_SERVICES) {
if (isRunning(svc)) {
emit.accept(" restarting " + svc + " ..."); emit.accept(" restarting " + svc + " ...");
exec(emit, "docker", "compose", "-f", composeFile, exec(emit, "docker", "compose", "-f", composeFile,
"up", "-d", "--no-deps", "--force-recreate", svc); "up", "-d", "--no-deps", "--force-recreate", svc);
emit.accept(" " + svc + ""); emit.accept(" " + svc + "");
} }
}
// Step 5: self-update tenant-service via detached helper container
emit.accept(">>> 启动自更新助手容器..."); emit.accept(">>> 启动自更新助手容器...");
String selfImage = getCurrentImage(); String selfImage = getCurrentImage();
if (selfImage == null) { if (selfImage == null) {
emit.accept(">>> [错误] 无法获取当前 tenant-service 镜像名,请检查容器标签或手动执行更新。"); emit.accept(">>> [错误] 无法获取当前 tenant-service 镜像名,请手动执行:");
emit.accept(">>> docker compose -f " + composeFile + " up -d --no-deps --force-recreate tenant-service");
emit.accept("DONE"); emit.accept("DONE");
return; return;
} }
@ -80,10 +86,8 @@ public class SystemUpdateService {
} }
} }
/** // Config patchers
* 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) { private void patchConfigs(Consumer<String> emit) {
emit.accept(">>> 检查并修复配置文件..."); emit.accept(">>> 检查并修复配置文件...");
patchNginxFileRoute(emit); patchNginxFileRoute(emit);
@ -91,24 +95,20 @@ public class SystemUpdateService {
patchDockerComposeFileService(emit); patchDockerComposeFileService(emit);
} }
/**
* nginx: add exact-match location for /api/system/update with 600s timeout so that
* docker pull (which may be silent for minutes) doesn't hit the 60s proxy_read_timeout
* on the generic /api/ block and cause ERR_INCOMPLETE_CHUNKED_ENCODING.
*/
private void patchNginxUpdateTimeout(Consumer<String> emit) { private void patchNginxUpdateTimeout(Consumer<String> emit) {
Path conf = Paths.get(deployRoot, "config", "nginx", "conf.d", "xuqm.conf"); Path conf = Paths.get(deployRoot, "config", "nginx", "conf.d", "xuqm.conf");
if (!Files.exists(conf)) return; if (!Files.exists(conf)) return;
try { try {
String content = Files.readString(conf); String content = Files.readString(conf);
if (content.contains("location = /api/system/update")) return; // 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/ {"; String anchor = " # 核心 API兜底,在所有具体 /api/xxx/ 之后)\n location /api/ {";
if (!content.contains(anchor)) { if (!content.contains(anchor)) {
emit.accept(" [跳过] nginx 更新超时补丁锚点未找到,请手动检查"); emit.accept(" [跳过] nginx 更新超时补丁锚点未找到,请手动检查");
return; return;
} }
String injection = " # 一键更新docker pull 可能耗时数分钟,需要更长超时(精确匹配,优先于 /api/ 前缀)\n" String injection = " # 一键更新/重置:操作耗时较长,需要更长超时(精确匹配,优先于 /api/ 前缀)\n"
+ " location = /api/system/update {\n" + " location ~ ^/api/system/(update|reset)$ {\n"
+ " set $svc tenant-service;\n" + " set $svc tenant-service;\n"
+ " proxy_pass http://$svc:9001;\n" + " proxy_pass http://$svc:9001;\n"
+ " proxy_set_header Host $host;\n" + " proxy_set_header Host $host;\n"
@ -120,13 +120,12 @@ public class SystemUpdateService {
+ anchor; + anchor;
String patched = content.replace(anchor, injection); String patched = content.replace(anchor, injection);
Files.writeString(conf, patched, StandardOpenOption.TRUNCATE_EXISTING); Files.writeString(conf, patched, StandardOpenOption.TRUNCATE_EXISTING);
emit.accept(" [已修复] nginx: 补齐 /api/system/update 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());
} }
} }
/** nginx: location /file/ must be location /api/file/ to match the file controller path. */
private void patchNginxFileRoute(Consumer<String> emit) { private void patchNginxFileRoute(Consumer<String> emit) {
Path conf = Paths.get(deployRoot, "config", "nginx", "conf.d", "xuqm.conf"); Path conf = Paths.get(deployRoot, "config", "nginx", "conf.d", "xuqm.conf");
if (!Files.exists(conf)) return; if (!Files.exists(conf)) return;
@ -141,10 +140,6 @@ public class SystemUpdateService {
} }
} }
/**
* 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) { private void patchDockerComposeFileService(Consumer<String> emit) {
Path composeFile = Paths.get(deployRoot, "docker-compose.yml"); Path composeFile = Paths.get(deployRoot, "docker-compose.yml");
if (!Files.exists(composeFile)) return; if (!Files.exists(composeFile)) return;
@ -155,8 +150,6 @@ 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 = "";
// 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"; 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 文件-服务补丁锚点未找到,请手动检查");
@ -173,22 +166,8 @@ public class SystemUpdateService {
} }
} }
private String readEnvValue(Path envFile, String key) { // Docker helpers
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) { private void dockerLogin(Consumer<String> emit) {
try { try {
String registry = null, user = null, password = null; String registry = null, user = null, password = null;
@ -217,9 +196,6 @@ public class SystemUpdateService {
} }
} }
/**
* 启动一个独立的 detached 容器 tenant-service 被停止后重建它
*/
private boolean spawnSelfUpdater(String composeFile, String image) { private boolean spawnSelfUpdater(String composeFile, String image) {
try { try {
new ProcessBuilder("docker", "rm", "-f", "xuqm-self-updater") new ProcessBuilder("docker", "rm", "-f", "xuqm-self-updater")
@ -248,20 +224,6 @@ public class SystemUpdateService {
} }
} }
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() { private String getCurrentImage() {
try { try {
Process p = new ProcessBuilder( Process p = new ProcessBuilder(
@ -277,6 +239,18 @@ public class SystemUpdateService {
} }
} }
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<String> emit, String... cmd) { private void exec(Consumer<String> emit, String... cmd) {
try { try {
Process p = new ProcessBuilder(cmd) Process p = new ProcessBuilder(cmd)