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>
这个提交包含在:
XuqmGroup 2026-06-11 20:04:47 +08:00
父节点 e42a4e3172
当前提交 ce64c8fa60
共有 3 个文件被更改,包括 138 次插入15 次删除

24
Jenkinsfile vendored
查看文件

@ -117,17 +117,31 @@ pipeline {
// 合并更新 versions.json只改动本次构建涉及的服务,不覆盖其它服务或 web 条目) // 合并更新 versions.json只改动本次构建涉及的服务,不覆盖其它服务或 web 条目)
def releasedAt = new Date().format("yyyy-MM-dd'T'HH:mm:ss'Z'", TimeZone.getTimeZone('UTC')) def releasedAt = new Date().format("yyyy-MM-dd'T'HH:mm:ss'Z'", TimeZone.getTimeZone('UTC'))
def serviceEntries = serviceVersions.collect { svc, ver -> def builtJson = groovy.json.JsonOutput.toJson(serviceVersions)
"d.setdefault('services', {})['${svc}'] = {'version': '${ver}'}" def allSvcsList = groovy.json.JsonOutput.toJson(['tenant-service', 'im-service', 'push-service', 'update-service', 'file-service', 'license-service', 'demo-service'])
}.join('\n')
def updateScript = """\ def updateScript = """\
import json, os import json, os
path = '${env.VERSIONS_FILE}' path = '${env.VERSIONS_FILE}'
d = json.load(open(path)) if os.path.exists(path) else {} 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}' d['releasedAt'] = '${releasedAt}'
json.dump(d, open(path, 'w'), indent=2, ensure_ascii=False) json.dump(d, open(path, 'w'), indent=2, ensure_ascii=False)
print('versions.json updated') print('versions.json updated, platformVersion=' + d.get('platformVersion', ''))
""".stripIndent() """.stripIndent()
writeFile file: 'update_versions.py', text: updateScript writeFile file: 'update_versions.py', text: updateScript
bat """ bat """

查看文件

@ -2,6 +2,7 @@ package com.xuqm.tenant.controller;
import com.xuqm.tenant.config.PrivateDeploymentProperties; import com.xuqm.tenant.config.PrivateDeploymentProperties;
import com.xuqm.tenant.service.SystemUpdateService; import com.xuqm.tenant.service.SystemUpdateService;
import org.springframework.beans.factory.annotation.Value;
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.GetMapping;
@ -12,6 +13,9 @@ import org.springframework.web.bind.annotation.RequestParam;
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.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -21,6 +25,10 @@ import com.xuqm.common.model.ApiResponse;
@RequestMapping("/api/system") @RequestMapping("/api/system")
public class SystemUpdateController { 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 PrivateDeploymentProperties deployProps;
private final SystemUpdateService updateService; private final SystemUpdateService updateService;
@ -30,6 +38,32 @@ public class SystemUpdateController {
this.updateService = updateService; this.updateService = updateService;
} }
/**
* 公开接口返回当前 versions.json 内容无需鉴权
* 私有部署服务器在 .env 中配置 VERSIONS_MANIFEST_URL 指向此地址后
* 可自动感知公有端发布的新版本
* 公有/私有模式均可访问
*/
@GetMapping("/versions/manifest")
public ResponseEntity<?> versionsManifest() {
// 私有模式优先读自身 deployRoot本机 versions.json反映本地已有版本
// 公有模式读 VERSIONS_JSON_PATHJenkins 更新的 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 模式可用。 */ /** 返回当前正在运行的服务列表。仅 PRIVATE 模式可用。 */
@GetMapping("/services") @GetMapping("/services")
public ResponseEntity<?> services() { public ResponseEntity<?> services() {

查看文件

@ -65,6 +65,8 @@ public class SystemUpdateService {
this.deployProps = deployProps; 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") @SuppressWarnings("unchecked")
public Map<String, Object> readRemoteVersions() { 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"); Path localVersions = Paths.get(deployRoot, "versions.json");
if (Files.exists(localVersions)) { if (Files.exists(localVersions)) {
try { try {
@ -246,7 +283,12 @@ public class SystemUpdateService {
public void runSelectiveUpdate(Consumer<String> emit, List<String> services) { public void runSelectiveUpdate(Consumer<String> emit, List<String> services) {
String composeFile = deployRoot + "/docker-compose.yml"; 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); patchConfigs(emit);
runSchemaMigrations(emit); runSchemaMigrations(emit);
@ -529,8 +571,9 @@ public class SystemUpdateService {
migrate_v20260101_drop_device_id_unique_index(emit); migrate_v20260101_drop_device_id_unique_index(emit);
migrate_v20260527_push_license_operation_logs(emit); migrate_v20260527_push_license_operation_logs(emit);
migrate_v20260527_fix_orphan_tenant_data(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(">>> 数据库迁移检查完成"); 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) { private void restartAndSelfUpdate(Consumer<String> emit, String composeFile) {
@ -898,15 +957,28 @@ public class SystemUpdateService {
// Docker 工具方法 // Docker 工具方法
private void dockerLogin(Consumer<String> emit) { /** @return true 表示登录成功或无需登录(无凭据时返回 false */
private boolean dockerLogin(Consumer<String> emit) {
try { try {
String registry = null, user = null, password = null; 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(); 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_USER=")) user = line.substring("REGISTRY_USER=".length()).trim();
else if (line.startsWith("REGISTRY_PASSWORD=")) password = line.substring("REGISTRY_PASSWORD=".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; String host = registry.contains("/") ? registry.substring(0, registry.indexOf('/')) : registry;
ProcessBuilder pb = new ProcessBuilder("docker", "login", host, "-u", user, "--password-stdin") ProcessBuilder pb = new ProcessBuilder("docker", "login", host, "-u", user, "--password-stdin")
.redirectErrorStream(true); .redirectErrorStream(true);
@ -917,12 +989,15 @@ public class SystemUpdateService {
String out = new String(p.getInputStream().readAllBytes()).trim(); String out = new String(p.getInputStream().readAllBytes()).trim();
int code = p.waitFor(); int code = p.waitFor();
if (code == 0) { if (code == 0) {
emit.accept(" 已完成镜像仓库登录"); emit.accept(" 已完成镜像仓库登录 (" + host + ")");
return true;
} else { } else {
emit.accept(" [警告] 镜像仓库登录失败,将使用本地缓存(" + out + ""); emit.accept(" [错误] 镜像仓库登录失败: " + out);
return false;
} }
} catch (Exception e) { } catch (Exception e) {
emit.accept(" [警告] 读取仓库凭据失败: " + e.getMessage()); emit.accept(" [错误] 读取仓库凭据失败: " + e.getMessage());
return false;
} }
} }