From ce64c8fa608258909a57527c09590981eed61ed2 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Thu, 11 Jun 2026 20:04:47 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=A7=81=E6=9C=89?= =?UTF-8?q?=E5=8C=96=E4=B8=80=E9=94=AE=E6=9B=B4=E6=96=B0=E4=B8=89=E4=B8=AA?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Jenkinsfile | 24 ++++- .../controller/SystemUpdateController.java | 34 +++++++ .../tenant/service/SystemUpdateService.java | 95 +++++++++++++++++-- 3 files changed, 138 insertions(+), 15 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index ea90299..84bf95b 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -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 """ diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/SystemUpdateController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/SystemUpdateController.java index c999789..d930e06 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/SystemUpdateController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/SystemUpdateController.java @@ -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() { diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java index 31ea49d..8c15b72 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java @@ -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:///api/versions/manifest + * 以便自动感知公有端发布的新版本。 */ @SuppressWarnings("unchecked") public Map 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 resp = client.send(req, HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() == 200) { + Map 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 emit, List 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 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 emit, String composeFile) { @@ -898,15 +957,28 @@ public class SystemUpdateService { // ── Docker 工具方法 ───────────────────────────────────────────────────────── - private void dockerLogin(Consumer emit) { + /** @return true 表示登录成功或无需登录(无凭据时返回 false) */ + private boolean dockerLogin(Consumer 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; } }