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 条目)
|
// 合并更新 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_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 模式可用。 */
|
/** 返回当前正在运行的服务列表。仅 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户