fix: 修复私有化一键更新三个问题
1. Jenkinsfile: versions.json 补充 platformVersion 字段(取 tenant-service 版本), 并为每个服务添加 changed 标记;私有部署 checkForUpdates 依赖此字段判断是否有新版本, 缺失时始终返回 hasUpdate=false。 2. SystemUpdateService: dockerLogin 改为返回 boolean,凭据缺失/登录失败时 中止更新流程(原来失败后继续,导致 docker compose pull 静默失败仍用旧镜像)。 readRemoteVersions 新增 VERSIONS_MANIFEST_URL 远端拉取支持, 私有服务器可在 .env 中配置后自动同步公有端版本清单。 新增 migrate_v20260610_gray_mode_simplify_bookmark 迁移标记(实际 SQL 由 update-service 执行)。 3. SystemUpdateController: 新增 GET /api/system/versions/manifest 公开端点, 公有服务器部署后即可作为私有服务器的 VERSIONS_MANIFEST_URL 目标地址。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
父节点
e42a4e3172
当前提交
ce64c8fa60
24
Jenkinsfile
vendored
24
Jenkinsfile
vendored
@ -117,17 +117,31 @@ pipeline {
|
||||
|
||||
// 合并更新 versions.json(只改动本次构建涉及的服务,不覆盖其它服务或 web 条目)
|
||||
def releasedAt = new Date().format("yyyy-MM-dd'T'HH:mm:ss'Z'", TimeZone.getTimeZone('UTC'))
|
||||
def serviceEntries = serviceVersions.collect { svc, ver ->
|
||||
"d.setdefault('services', {})['${svc}'] = {'version': '${ver}'}"
|
||||
}.join('\n')
|
||||
def builtJson = groovy.json.JsonOutput.toJson(serviceVersions)
|
||||
def allSvcsList = groovy.json.JsonOutput.toJson(['tenant-service', 'im-service', 'push-service', 'update-service', 'file-service', 'license-service', 'demo-service'])
|
||||
def updateScript = """\
|
||||
import json, os
|
||||
path = '${env.VERSIONS_FILE}'
|
||||
d = json.load(open(path)) if os.path.exists(path) else {}
|
||||
${serviceEntries}
|
||||
built = ${builtJson}
|
||||
all_svcs = ${allSvcsList}
|
||||
d.setdefault('services', {})
|
||||
for s in all_svcs:
|
||||
d['services'].setdefault(s, {})
|
||||
if s in built:
|
||||
d['services'][s]['version'] = built[s]
|
||||
d['services'][s]['changed'] = True
|
||||
else:
|
||||
d['services'][s].setdefault('version', 'unknown')
|
||||
d['services'][s]['changed'] = False
|
||||
# platformVersion 取 tenant-service 版本(主服务),私有部署 check-update 用此字段判断是否有新版本
|
||||
if 'tenant-service' in built:
|
||||
d['platformVersion'] = built['tenant-service']
|
||||
elif 'platformVersion' not in d:
|
||||
d['platformVersion'] = list(built.values())[0] if built else 'unknown'
|
||||
d['releasedAt'] = '${releasedAt}'
|
||||
json.dump(d, open(path, 'w'), indent=2, ensure_ascii=False)
|
||||
print('versions.json updated')
|
||||
print('versions.json updated, platformVersion=' + d.get('platformVersion', ''))
|
||||
""".stripIndent()
|
||||
writeFile file: 'update_versions.py', text: updateScript
|
||||
bat """
|
||||
|
||||
@ -2,6 +2,7 @@ package com.xuqm.tenant.controller;
|
||||
|
||||
import com.xuqm.tenant.config.PrivateDeploymentProperties;
|
||||
import com.xuqm.tenant.service.SystemUpdateService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@ -12,6 +13,9 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@ -21,6 +25,10 @@ import com.xuqm.common.model.ApiResponse;
|
||||
@RequestMapping("/api/system")
|
||||
public class SystemUpdateController {
|
||||
|
||||
/** 公有服务器 versions.json 路径,私有服务器通过 PRIVATE_DEPLOY_ROOT + /versions.json 读取 */
|
||||
@Value("${VERSIONS_JSON_PATH:/opt/xuqm/deploy/versions.json}")
|
||||
private String versionsJsonPath;
|
||||
|
||||
private final PrivateDeploymentProperties deployProps;
|
||||
private final SystemUpdateService updateService;
|
||||
|
||||
@ -30,6 +38,32 @@ public class SystemUpdateController {
|
||||
this.updateService = updateService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开接口:返回当前 versions.json 内容,无需鉴权。
|
||||
* 私有部署服务器在 .env 中配置 VERSIONS_MANIFEST_URL 指向此地址后,
|
||||
* 可自动感知公有端发布的新版本。
|
||||
* 公有/私有模式均可访问。
|
||||
*/
|
||||
@GetMapping("/versions/manifest")
|
||||
public ResponseEntity<?> versionsManifest() {
|
||||
// 私有模式优先读自身 deployRoot(本机 versions.json,反映本地已有版本)
|
||||
// 公有模式读 VERSIONS_JSON_PATH(Jenkins 更新的 versions.json,反映最新可用版本)
|
||||
java.nio.file.Path f = deployProps.isPrivate()
|
||||
? Paths.get(updateService.getDeployRoot(), "versions.json")
|
||||
: Paths.get(versionsJsonPath);
|
||||
if (!Files.exists(f)) {
|
||||
return ResponseEntity.status(404).body(Map.of("message", "versions.json 尚未生成"));
|
||||
}
|
||||
try {
|
||||
String content = Files.readString(f);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(content);
|
||||
} catch (IOException e) {
|
||||
return ResponseEntity.status(500).body(Map.of("message", "读取 versions.json 失败"));
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回当前正在运行的服务列表。仅 PRIVATE 模式可用。 */
|
||||
@GetMapping("/services")
|
||||
public ResponseEntity<?> services() {
|
||||
|
||||
@ -65,6 +65,8 @@ public class SystemUpdateService {
|
||||
this.deployProps = deployProps;
|
||||
}
|
||||
|
||||
public String getDeployRoot() { return deployRoot; }
|
||||
|
||||
// ── 公开接口 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@ -179,11 +181,46 @@ public class SystemUpdateService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取远端 versions.json(从 deployRoot 或远程 URL)。
|
||||
* 读取版本清单(versions.json)。
|
||||
*
|
||||
* 优先级:
|
||||
* 1. 若 .env 中配置了 VERSIONS_MANIFEST_URL,则从远端拉取并缓存到本地
|
||||
* 2. 回退到本地 {deployRoot}/versions.json
|
||||
*
|
||||
* 私有部署服务器须在 .env 中添加:
|
||||
* VERSIONS_MANIFEST_URL=https://<ops-server>/api/versions/manifest
|
||||
* 以便自动感知公有端发布的新版本。
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> readRemoteVersions() {
|
||||
// 优先读本地缓存
|
||||
String manifestUrl = readEnvValue(Paths.get(deployRoot, ".env"), "VERSIONS_MANIFEST_URL");
|
||||
if (manifestUrl != null && !manifestUrl.isBlank()) {
|
||||
try {
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.build();
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(manifestUrl.trim()))
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() == 200) {
|
||||
Map<String, Object> remote = new ObjectMapper().readValue(resp.body(), new TypeReference<>() {});
|
||||
// 缓存到本地,避免网络故障时无法读取
|
||||
Files.writeString(Paths.get(deployRoot, "versions.json"), resp.body(),
|
||||
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
log.info("versions.json refreshed from {}", manifestUrl);
|
||||
return remote;
|
||||
} else {
|
||||
log.warn("versions manifest fetch returned HTTP {}", resp.statusCode());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to fetch versions from {}: {}", manifestUrl, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到本地缓存
|
||||
Path localVersions = Paths.get(deployRoot, "versions.json");
|
||||
if (Files.exists(localVersions)) {
|
||||
try {
|
||||
@ -246,7 +283,12 @@ public class SystemUpdateService {
|
||||
public void runSelectiveUpdate(Consumer<String> emit, List<String> services) {
|
||||
String composeFile = deployRoot + "/docker-compose.yml";
|
||||
|
||||
dockerLogin(emit);
|
||||
boolean loginOk = dockerLogin(emit);
|
||||
if (!loginOk) {
|
||||
emit.accept(" [错误] 镜像仓库登录失败,更新中止。请检查 " + deployRoot + "/.env 中的 REGISTRY/REGISTRY_USER/REGISTRY_PASSWORD 配置。");
|
||||
emit.accept("DONE");
|
||||
return;
|
||||
}
|
||||
patchConfigs(emit);
|
||||
runSchemaMigrations(emit);
|
||||
|
||||
@ -529,8 +571,9 @@ public class SystemUpdateService {
|
||||
migrate_v20260101_drop_device_id_unique_index(emit);
|
||||
migrate_v20260527_push_license_operation_logs(emit);
|
||||
migrate_v20260527_fix_orphan_tenant_data(emit);
|
||||
migrate_v20260610_gray_mode_simplify_bookmark(emit);
|
||||
// 新版本迁移在此追加,例如:
|
||||
// migrate_v20260601_add_app_extra_column(emit);
|
||||
// migrate_v20260701_xxx(emit);
|
||||
|
||||
emit.accept(">>> 数据库迁移检查完成");
|
||||
}
|
||||
@ -713,6 +756,22 @@ public class SystemUpdateService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v20260610_gray_mode_simplify 由 update-service 的 SchemaMigrationRunner 负责执行,
|
||||
* 此处仅在 tenant-service 迁移记录表中留下标记,避免版本跳跃引起混淆。
|
||||
* update-service 在每次重启时会幂等地应用其自身迁移,无需在此重复执行 SQL。
|
||||
*/
|
||||
private void migrate_v20260610_gray_mode_simplify_bookmark(Consumer<String> emit) {
|
||||
final String id = "v20260610_gray_mode_simplify";
|
||||
if (migrationApplied(id)) {
|
||||
emit.accept(" [已应用] " + id + " (update-service 负责执行)");
|
||||
return;
|
||||
}
|
||||
// update-service 重启后会自行执行实际迁移,此处只记录版本标记
|
||||
recordMigration(id, "GrayMode 简化(IM_PUSH_USERS/CUSTOMER_SYNC/CUSTOMER_CALLBACK → MEMBERS),由 update-service 执行");
|
||||
emit.accept(" [已记录] " + id + ": update-service 将在启动时执行实际迁移");
|
||||
}
|
||||
|
||||
// ── 重启核心 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private void restartAndSelfUpdate(Consumer<String> emit, String composeFile) {
|
||||
@ -898,15 +957,28 @@ public class SystemUpdateService {
|
||||
|
||||
// ── Docker 工具方法 ─────────────────────────────────────────────────────────
|
||||
|
||||
private void dockerLogin(Consumer<String> emit) {
|
||||
/** @return true 表示登录成功或无需登录(无凭据时返回 false) */
|
||||
private boolean dockerLogin(Consumer<String> emit) {
|
||||
try {
|
||||
String registry = null, user = null, password = null;
|
||||
for (String line : Files.readAllLines(Paths.get(deployRoot + "/.env"))) {
|
||||
Path envFile = Paths.get(deployRoot + "/.env");
|
||||
if (!Files.exists(envFile)) {
|
||||
emit.accept(" [错误] 找不到 " + deployRoot + "/.env,无法获取镜像仓库凭据");
|
||||
return false;
|
||||
}
|
||||
for (String line : Files.readAllLines(envFile)) {
|
||||
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;
|
||||
if (registry == null || registry.isBlank()) {
|
||||
emit.accept(" [错误] .env 中未配置 REGISTRY,无法拉取镜像");
|
||||
return false;
|
||||
}
|
||||
if (user == null || password == null || password.isBlank()) {
|
||||
emit.accept(" [错误] .env 中未配置 REGISTRY_USER / REGISTRY_PASSWORD");
|
||||
return false;
|
||||
}
|
||||
String host = registry.contains("/") ? registry.substring(0, registry.indexOf('/')) : registry;
|
||||
ProcessBuilder pb = new ProcessBuilder("docker", "login", host, "-u", user, "--password-stdin")
|
||||
.redirectErrorStream(true);
|
||||
@ -917,12 +989,15 @@ public class SystemUpdateService {
|
||||
String out = new String(p.getInputStream().readAllBytes()).trim();
|
||||
int code = p.waitFor();
|
||||
if (code == 0) {
|
||||
emit.accept(" 已完成镜像仓库登录");
|
||||
emit.accept(" 已完成镜像仓库登录 (" + host + ")");
|
||||
return true;
|
||||
} else {
|
||||
emit.accept(" [警告] 镜像仓库登录失败,将使用本地缓存(" + out + ")");
|
||||
emit.accept(" [错误] 镜像仓库登录失败: " + out);
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
emit.accept(" [警告] 读取仓库凭据失败: " + e.getMessage());
|
||||
emit.accept(" [错误] 读取仓库凭据失败: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户