diff --git a/Dockerfile b/Dockerfile index c62e0ee..069520e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ ARG SERVICE_MODULE=tenant-service +ARG SERVICE_VERSION=0.0.0 FROM --platform=linux/amd64 maven:3.9.9-eclipse-temurin-21 AS build ARG SERVICE_MODULE @@ -40,6 +41,10 @@ RUN --mount=type=cache,target=/root/.m2 \ FROM --platform=linux/amd64 eclipse-temurin:21-jre-alpine WORKDIR /app ARG SERVICE_MODULE +ARG SERVICE_VERSION + +LABEL com.xuqm.service="${SERVICE_MODULE}" +LABEL com.xuqm.version="${SERVICE_VERSION}" # curl is required by update-service (MI store upload uses ProcessBuilder curl to handle # Expect:100-continue and force HTTP/1.1 for large multipart uploads) @@ -47,5 +52,6 @@ RUN apk add --no-cache curl docker-cli docker-compose COPY --from=build /workspace/${SERVICE_MODULE}/target/${SERVICE_MODULE}-0.1.0-SNAPSHOT.jar /app/app.jar COPY VERSION /app/VERSION +RUN echo "${SERVICE_VERSION}" > /app/SERVICE_VERSION ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/Jenkinsfile b/Jenkinsfile index 76ccde1..86874c7 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -2,8 +2,10 @@ pipeline { agent any parameters { - choice(name: 'SERVICE', choices: ['tenant-service', 'im-service', 'push-service', 'update-service', 'demo-service', 'file-service', 'license-service'], description: '要构建的服务模块') - string(name: 'IMAGE_TAG', defaultValue: 'latest', description: '镜像 Tag(如 v1.2.3 或 latest)') + choice(name: 'SERVICE', choices: ['all', 'tenant-service', 'im-service', 'push-service', 'update-service', 'demo-service', 'file-service', 'license-service'], description: '要构建的服务模块(all = 全部)') + choice(name: 'VERSION_STRATEGY', choices: ['patch', 'minor', 'major', 'date'], description: '版本升级策略:patch=修复, minor=新功能, major=大版本, date=日期版本') + string(name: 'CUSTOM_VERSION', defaultValue: '', description: '自定义版本号(留空则自动计算)') + string(name: 'CHANGELOG', defaultValue: '', description: '更新日志(多行文本)') booleanParam(name: 'DEPLOY', defaultValue: true, description: '构建后是否自动部署到生产服务器') } @@ -18,8 +20,8 @@ pipeline { } options { - timeout(time: 30, unit: 'MINUTES') - buildDiscarder(logRotator(numToKeepStr: '20')) + timeout(time: 60, unit: 'MINUTES') + buildDiscarder(logRotator(numToKeepStr: '30')) disableConcurrentBuilds() } @@ -35,64 +37,163 @@ pipeline { } } + stage('Calculate Version') { + steps { + script { + def strategy = params.VERSION_STRATEGY + def custom = params.CUSTOM_VERSION?.trim() + + if (custom) { + env.SERVICE_VERSION = custom + } else if (strategy == 'date') { + def now = new Date() + def datePart = now.format('yyyy.M.d') + def buildNum = env.BUILD_NUMBER + env.SERVICE_VERSION = "${datePart}.${buildNum}" + } else { + // 读取当前版本并按策略递增 + def versionFile = 'VERSION' + def current = '1.0.0' + if (fileExists(versionFile)) { + current = readFile(versionFile).trim() + // 兼容旧格式 2026.05.20-private.3 → 取最后三段 + def parts = current.replaceAll(/[^0-9.]/, '').split('\\.') + if (parts.length >= 3) { + current = "${parts[-3]}.${parts[-2]}.${parts[-1]}" + } + } + def vParts = current.split('\\.') + def major = (vParts[0] ?: '1').toInteger() + def minor = (vParts[1] ?: '0').toInteger() + def patch = (vParts[2] ?: '0').toInteger() + + switch (strategy) { + case 'major': + major++; minor = 0; patch = 0; break + case 'minor': + minor++; patch = 0; break + case 'patch': + default: + patch++; break + } + env.SERVICE_VERSION = "${major}.${minor}.${patch}" + } + + echo "Service version: ${env.SERVICE_VERSION}" + writeFile file: 'SERVICE_VERSION', text: env.SERVICE_VERSION + + // 更新 VERSION 文件(平台版本) + def now = new Date() + def platformVersion = now.format('yyyy.M.d') + ".${env.BUILD_NUMBER}" + env.PLATFORM_VERSION = platformVersion + writeFile file: 'VERSION', text: platformVersion + echo "Platform version: ${platformVersion}" + } + } + } + stage('Docker Build & Push') { steps { withCredentials([string(credentialsId: 'ACR_PASSWORD', variable: 'ACR_PASS')]) { script { - // 自动递增 VERSION 文件中的构建号 - def versionFile = 'VERSION' - if (fileExists(versionFile)) { - def version = readFile(versionFile).trim() - // 格式: 2026.05.20-private.3 → 递增末尾数字 - def matcher = version =~ /^(.+\.)(\d+)$/ - if (matcher.matches()) { - def prefix = matcher.group(1) - def buildNum = matcher.group(2).toInteger() + 1 - def newVersion = "${prefix}${buildNum}" - writeFile file: versionFile, text: newVersion - echo "VERSION: ${version} → ${newVersion}" - } + def services = params.SERVICE == 'all' + ? ['tenant-service', 'im-service', 'push-service', 'update-service', 'file-service', 'license-service'] + : [params.SERVICE] + + for (svc in services) { + def imageName = "${ACR_REGISTRY}/${ACR_NAMESPACE}/${svc}:${env.PLATFORM_VERSION}" + echo "Building ${svc} version ${env.SERVICE_VERSION}..." + + bat """ + docker login ${ACR_REGISTRY} -u ${ACR_USERNAME} -p %ACR_PASS% + docker pull --platform=linux/amd64 ${imageName} || echo Pull failed, will build fresh + docker build --platform=linux/amd64 \ + --build-arg SERVICE_MODULE=${svc} \ + --build-arg SERVICE_VERSION=${env.SERVICE_VERSION} \ + --build-arg BUILDKIT_INLINE_CACHE=1 \ + --cache-from ${imageName} \ + -t ${imageName} . + docker push ${imageName} + docker rmi ${imageName} || exit 0 + """ } - def imageName = "${ACR_REGISTRY}/${ACR_NAMESPACE}/${params.SERVICE}:${params.IMAGE_TAG}" - bat """ - docker login ${ACR_REGISTRY} -u ${ACR_USERNAME} -p %ACR_PASS% - docker pull --platform=linux/amd64 ${imageName} || echo Pull failed, will build fresh - docker build --platform=linux/amd64 --build-arg SERVICE_MODULE=${params.SERVICE} --build-arg BUILDKIT_INLINE_CACHE=1 --cache-from ${imageName} -t ${imageName} . - docker push ${imageName} - docker rmi ${imageName} || exit 0 - """ } } } } + stage('Generate versions.json') { + steps { + script { + def services = [ + 'tenant-service', 'im-service', 'push-service', + 'update-service', 'file-service', 'license-service', + 'nginx', 'tenant-web' + ] + + def servicesMap = [:] + for (svc in services) { + servicesMap[svc] = [ + version: env.SERVICE_VERSION, + changed: params.SERVICE == 'all' || params.SERVICE == svc + ] + } + + def versionsJson = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson([ + platformVersion: env.PLATFORM_VERSION, + serviceVersion: env.SERVICE_VERSION, + releasedAt: new Date().format("yyyy-MM-dd'T'HH:mm:ss'Z'", TimeZone.getTimeZone('UTC')), + changelog: params.CHANGELOG ?: '', + services: servicesMap + ])) + + writeFile file: 'versions.json', text: versionsJson + echo "versions.json generated:" + echo versionsJson + + // 推送到 Registry(作为 OCI artifact 或上传到 CDN) + // 这里先保存为构建产物,后续可配置推送到 OSS/CDN + archiveArtifacts artifacts: 'versions.json', fingerprint: true + } + } + } + stage('Deploy to Production') { when { expression { return params.DEPLOY } } steps { lock('prod-deploy') { withCredentials([sshUserPrivateKey(credentialsId: 'PROD_SSH_KEY', keyFileVariable: 'SSH_KEY')]) { script { - def imageName = "${ACR_REGISTRY}/${ACR_NAMESPACE}/${params.SERVICE}:${params.IMAGE_TAG}" - def remoteCmd = """ - set -e - # 清理悬空镜像,避免 containerd 存储损坏 - docker image prune -f 2>/dev/null || true - # 拉取镜像(失败则清理后重试) - if ! docker pull ${imageName}; then - echo 'Pull failed, cleaning containerd cache and retrying...' - docker system prune -f - docker pull ${imageName} - fi - # 部署 - docker compose -f ${COMPOSE_FILE} up -d --no-deps --force-recreate ${params.SERVICE} - # 清理旧镜像 - docker image prune -f - """.stripIndent() - retry(2) { - bat """ - ssh -i "%SSH_KEY%" -o StrictHostKeyChecking=no ${PROD_USER}@${PROD_HOST} "${remoteCmd}" - """ + def services = params.SERVICE == 'all' + ? ['tenant-service', 'im-service', 'push-service', 'update-service', 'file-service', 'license-service'] + : [params.SERVICE] + + for (svc in services) { + def imageName = "${ACR_REGISTRY}/${ACR_NAMESPACE}/${svc}:${env.PLATFORM_VERSION}" + def remoteCmd = """ + set -e + docker image prune -f 2>/dev/null || true + if ! docker pull ${imageName}; then + echo 'Pull failed, cleaning containerd cache and retrying...' + docker system prune -f + docker pull ${imageName} + fi + docker compose -f ${COMPOSE_FILE} up -d --no-deps --force-recreate ${svc} + docker image prune -f + """.stripIndent() + + echo "Deploying ${svc}..." + retry(2) { + bat """ + ssh -i "%SSH_KEY%" -o StrictHostKeyChecking=no ${PROD_USER}@${PROD_HOST} "${remoteCmd}" + """ + } } + + // 上传 versions.json 到服务器 + bat """ + scp -i "%SSH_KEY%" -o StrictHostKeyChecking=no versions.json ${PROD_USER}@${PROD_HOST}:/opt/xuqm/deploy/versions.json + """ } } } @@ -101,7 +202,7 @@ pipeline { } post { - success { echo "✅ ${params.SERVICE}:${params.IMAGE_TAG} 构建部署成功" } + success { echo "✅ ${params.SERVICE} v${env.SERVICE_VERSION} (platform ${env.PLATFORM_VERSION}) 构建部署成功" } failure { echo "❌ 构建失败,请检查日志" } } } 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 9c4df74..c999789 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 @@ -15,6 +15,8 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo import java.util.List; import java.util.Map; +import com.xuqm.common.model.ApiResponse; + @RestController @RequestMapping("/api/system") public class SystemUpdateController { @@ -65,6 +67,19 @@ public class SystemUpdateController { return ResponseEntity.ok(Map.of("data", Map.of("currentVersion", currentVersion))); } + /** + * 检查是否有可用更新。 + * 对比本地版本与 versions.json 中的最新版本,返回各服务的版本差异。 + * 仅 PRIVATE 模式可用。 + */ + @GetMapping("/check-update") + public ResponseEntity checkUpdate() { + if (!deployProps.isPrivate()) { + return ResponseEntity.status(403).body(Map.of("message", "此接口仅在私有化部署可用")); + } + return ResponseEntity.ok(updateService.checkForUpdates()); + } + /** * 拉取最新镜像并重建所有容器。耗时较长(需 docker pull)。 * 仅 PRIVATE 模式可用。 @@ -79,6 +94,21 @@ public class SystemUpdateController { return stream(emit -> updateService.runUpdate(emit)); } + /** + * 选择性更新:只更新指定的服务。 + * 仅 PRIVATE 模式可用。 + */ + @PostMapping(value = "/update-selective", produces = MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity updateSelective( + @RequestParam(required = false) List services) { + if (!deployProps.isPrivate()) { + return ResponseEntity.status(403) + .contentType(MediaType.TEXT_PLAIN) + .body(out -> out.write("此接口仅在私有化部署可用\n".getBytes())); + } + return stream(emit -> updateService.runSelectiveUpdate(emit, 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 5400aca..31ea49d 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 @@ -10,9 +10,16 @@ import org.springframework.stereotype.Service; import com.xuqm.tenant.config.PrivateDeploymentProperties; import javax.sql.DataSource; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -22,6 +29,8 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.Statement; +import java.time.Duration; +import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; @@ -145,16 +154,109 @@ public class SystemUpdateService { return "unknown"; } - /** 拉取最新镜像并重建所有容器。 */ - public void runUpdate(Consumer emit) { + /** + * 读取本地各服务的版本号(从 Docker 镜像 LABEL 中读取)。 + */ + public Map readLocalServiceVersions() { + Map versions = new LinkedHashMap<>(); + List allServices = new ArrayList<>(OTHER_SERVICES); + allServices.add("tenant-service"); + for (String svc : allServices) { + try { + Process p = new ProcessBuilder( + "docker", "inspect", "--format", + "{{index .Config.Labels \"com.xuqm.version\"}}", + "xuqmgroup/" + svc + ":latest" + ).redirectErrorStream(true).start(); + String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); + p.waitFor(); + versions.put(svc, out.isEmpty() ? "unknown" : out); + } catch (Exception e) { + versions.put(svc, "unknown"); + } + } + return versions; + } + + /** + * 读取远端 versions.json(从 deployRoot 或远程 URL)。 + */ + @SuppressWarnings("unchecked") + public Map readRemoteVersions() { + // 优先读本地缓存 + Path localVersions = Paths.get(deployRoot, "versions.json"); + if (Files.exists(localVersions)) { + try { + String json = Files.readString(localVersions); + return new ObjectMapper().readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + log.warn("Failed to read local versions.json: {}", e.getMessage()); + } + } + return Map.of(); + } + + /** + * 检查是否有可用更新。 + * 对比本地平台版本与远端 versions.json 中的平台版本。 + */ + @SuppressWarnings("unchecked") + public Map checkForUpdates() { + String localPlatform = readCurrentVersion(); + Map remote = readRemoteVersions(); + String remotePlatform = remote.getOrDefault("platformVersion", "").toString(); + boolean hasUpdate = !remotePlatform.isEmpty() && !remotePlatform.equals(localPlatform); + + Map result = new LinkedHashMap<>(); + result.put("currentVersion", localPlatform); + result.put("latestVersion", remotePlatform); + result.put("hasUpdate", hasUpdate); + result.put("releasedAt", remote.getOrDefault("releasedAt", "")); + result.put("changelog", remote.getOrDefault("changelog", "")); + + // 各服务版本对比 + Map localVersions = readLocalServiceVersions(); + Map remoteServices = remote.get("services") instanceof Map + ? (Map) remote.get("services") : Map.of(); + + Map services = new LinkedHashMap<>(); + for (String svc : localVersions.keySet()) { + Map svcInfo = new LinkedHashMap<>(); + svcInfo.put("current", localVersions.get(svc)); + Object remoteSvc = remoteServices.get(svc); + if (remoteSvc instanceof Map) { + Map rMap = (Map) remoteSvc; + svcInfo.put("latest", rMap.getOrDefault("version", "")); + svcInfo.put("changed", rMap.getOrDefault("changed", false)); + } else { + svcInfo.put("latest", ""); + svcInfo.put("changed", false); + } + services.put(svc, svcInfo); + } + result.put("services", services); + + return result; + } + + /** + * 选择性更新:只拉取指定服务的镜像并重建。 + * @param services 要更新的服务列表,为空则更新所有 + */ + public void runSelectiveUpdate(Consumer emit, List services) { String composeFile = deployRoot + "/docker-compose.yml"; dockerLogin(emit); patchConfigs(emit); runSchemaMigrations(emit); - emit.accept(">>> 拉取最新镜像..."); - for (String svc : OTHER_SERVICES) { + List toUpdate = services != null && !services.isEmpty() + ? services : new ArrayList<>(OTHER_SERVICES); + // 确保 tenant-service 在最后 + toUpdate.remove("tenant-service"); + + emit.accept(">>> 拉取镜像(" + toUpdate.size() + " 个服务)..."); + for (String svc : toUpdate) { emit.accept(" pulling " + svc + " ..."); exec(emit, "docker", "compose", "-f", composeFile, "pull", "--quiet", svc); } @@ -165,6 +267,11 @@ public class SystemUpdateService { restartAndSelfUpdate(emit, composeFile); } + /** 拉取最新镜像并重建所有容器。 */ + public void runUpdate(Consumer emit) { + runSelectiveUpdate(emit, null); + } + /** 保留数据,重置容器和数据库表结构。 */ public void runReset(Consumer emit) { String composeFile = deployRoot + "/docker-compose.yml";