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>
这个提交包含在:
父节点
9728dbb002
当前提交
32aa3c0eef
@ -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)
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户