diff --git a/src/main/java/com/youlai/boot/app/update/component/ApkDeltaUpdater.java b/src/main/java/com/youlai/boot/app/update/component/ApkDeltaUpdater.java new file mode 100644 index 00000000..03778e3a --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/component/ApkDeltaUpdater.java @@ -0,0 +1,487 @@ +package com.youlai.boot.app.update.component; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +/** + * 基于 HDiffPatch(hdiffz)的 APK 整包二进制差分工具。 + *

+ * 差分按整包逐字节进行,还原产物与新包完全一致,不破坏签名与 zipalign 对齐。 + * 各平台可执行文件以已解压形式随包置于 {@code resources/hdiffpatch/<平台>/}, + * 运行时按 {@code os.name}/{@code os.arch} 选择平台目录,仅在本地工作目录缺文件时 + * 从 classpath 复制一次;生产环境可用 {@code -Dttstd.hdiffpatch.dir=<目录>} 直接指向 + * 预置好的 {@code hdiffz},完全跳过复制。 + *

+ * 分档以 {@code max(oldSize,newSize)} 为准:流式差分内存由旧包匹配表决定,新包很小但 + * 旧包很大时同样要按旧包选档。补丁率超过 {@link #FULL_PACKAGE_THRESHOLD} 时省流失去意义, + * 应回退全量下发。 + */ +public final class ApkDeltaUpdater { + + /** 内置二进制对应的 HDiffPatch 版本。 */ + public static final String HDIFFPATCH_VERSION = "5.1.3"; + + /** 补丁率超过该值即失去省流意义,应回退全量下发。 */ + public static final double FULL_PACKAGE_THRESHOLD = 0.40; + + /** 指向现成 hdiffz 所在目录的系统属性,配置后不再从 classpath 复制。 */ + public static final String BIN_DIR_PROPERTY = "ttstd.hdiffpatch.dir"; + + /** 覆盖单次 hdiffz 执行超时(秒)的系统属性,0 表示不限时。 */ + public static final String TIMEOUT_PROPERTY = "ttstd.hdiffpatch.timeout"; + + /** 单次 hdiffz 执行的默认超时(秒)。 */ + public static final long DEFAULT_TIMEOUT_SECONDS = 1800L; + + private static final long MB = 1024L * 1024L; + private static final String RESOURCE_DIR = "/hdiffpatch/"; + private static final String WORK_DIR_PROPERTY = "ttstd.hdiffpatch.workdir"; + private static final String DEFAULT_WORK_DIR_NAME = "ttstd-hdiffpatch"; + private static final Logger LOGGER = LoggerFactory.getLogger(ApkDeltaUpdater.class); + + private static Platform detectedPlatform; + private static Path preparedBinaryDir; + + private ApkDeltaUpdater() { + } + + // ========================================================== + // 一、平台探测与可执行文件准备 + // ========================================================== + + /** 内置了可执行文件的平台,folder 对应 resources 下的目录名。 */ + public enum Platform { + WINDOWS_X64("windows64", true), + LINUX_X64("linux64", false), + LINUX_ARM64("linux_arm64", false), + MACOS("macos", false); + + private final String folder; + private final boolean windows; + + Platform(String folder, boolean windows) { + this.folder = folder; + this.windows = windows; + } + + public String folder() { + return folder; + } + + public boolean isWindows() { + return windows; + } + + public String hdiffzName() { + return windows ? "hdiffz.exe" : "hdiffz"; + } + + public String hpatchzName() { + return windows ? "hpatchz.exe" : "hpatchz"; + } + } + + /** 按 os.name / os.arch 探测当前平台(结果缓存)。 */ + public static synchronized Platform currentPlatform() { + if (detectedPlatform == null) { + detectedPlatform = detectPlatform(); + } + return detectedPlatform; + } + + public static Platform detectPlatform() { + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + String arch = System.getProperty("os.arch", "").toLowerCase(Locale.ROOT); + + if (os.contains("win")) { + if (is64Bit(arch) && !arch.contains("arm")) { + return Platform.WINDOWS_X64; + } + } else if (os.contains("mac") || os.contains("darwin")) { + return Platform.MACOS; + } else if (os.contains("linux")) { + if (arch.contains("aarch64") || arch.contains("arm64")) { + return Platform.LINUX_ARM64; + } + if (is64Bit(arch)) { + return Platform.LINUX_X64; + } + } + throw new IllegalStateException("当前平台未内置 hdiffz:os.name=" + os + ", os.arch=" + arch + + ";可用 -D" + BIN_DIR_PROPERTY + "=<含 hdiffz 的目录> 指定"); + } + + private static boolean is64Bit(String arch) { + return arch.contains("64") || arch.contains("aarch64"); + } + + /** + * hdiffz 所在目录:优先外部预置目录,否则用本地工作目录中的副本 + * (副本缺失时从 classpath 复制一次,已存在且可执行则直接复用)。 + */ + public static synchronized Path binaryDir() throws IOException { + Platform platform = currentPlatform(); + String override = System.getProperty(BIN_DIR_PROPERTY); + if (override != null && !override.isBlank()) { + Path dir = Path.of(override).toAbsolutePath().normalize(); + requireHdiffz(dir, platform); + return dir; + } + if (preparedBinaryDir != null && preparedBinaryDir.getFileName().toString().equals(platform.folder()) + && isReady(preparedBinaryDir, platform)) { + return preparedBinaryDir; + } + Path dir = workDir().resolve(platform.folder()); + if (!isReady(dir, platform)) { + Files.createDirectories(dir); + copyFromResources(dir, platform); + requireHdiffz(dir, platform); + } + preparedBinaryDir = dir; + return dir; + } + + /** 本地工作目录:跨进程与重启复用,默认落在临时目录下。 */ + private static Path workDir() { + String configured = System.getProperty(WORK_DIR_PROPERTY); + if (configured != null && !configured.isBlank()) { + return Path.of(configured).toAbsolutePath().normalize(); + } + return Path.of(System.getProperty("java.io.tmpdir"), DEFAULT_WORK_DIR_NAME); + } + + private static boolean isReady(Path dir, Platform platform) { + return isUsable(dir.resolve(platform.hdiffzName()), platform); + } + + private static void requireHdiffz(Path dir, Platform platform) throws IOException { + Path hdiffz = dir.resolve(platform.hdiffzName()); + if (!Files.isRegularFile(hdiffz)) { + throw new IOException("目录中缺少 " + platform.hdiffzName() + ": " + dir); + } + if (!isUsable(hdiffz, platform)) { + throw new IOException(platform.hdiffzName() + " 不可执行(目录被 noexec 挂载,或缺少执行位):" + + hdiffz + ";可用 -D" + BIN_DIR_PROPERTY + "=<可执行目录> 指向预置二进制"); + } + } + + private static boolean isUsable(Path exe, Platform platform) { + try { + return Files.isRegularFile(exe) && Files.size(exe) > 0 + && (platform.isWindows() || Files.isExecutable(exe)); + } catch (IOException e) { + return false; + } + } + + /** 把当前平台的可执行文件从 classpath 复制到工作目录,先写临时文件再原子替换。 */ + private static void copyFromResources(Path dir, Platform platform) throws IOException { + for (String name : List.of(platform.hdiffzName(), platform.hpatchzName())) { + Path target = dir.resolve(name); + if (isUsable(target, platform)) { + continue; + } + String resource = RESOURCE_DIR + platform.folder() + "/" + name; + try (InputStream in = ApkDeltaUpdater.class.getResourceAsStream(resource)) { + if (in == null) { + throw new IOException("classpath 缺少内置二进制: " + resource); + } + Path temp = Files.createTempFile(dir, "." + name + "-", ".tmp"); + try (OutputStream out = Files.newOutputStream(temp)) { + in.transferTo(out); + } + makeExecutable(temp); + try { + Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + // 并发复制时另一进程可能已放好可用文件 + if (!isUsable(target, platform)) { + throw e; + } + } finally { + Files.deleteIfExists(temp); + } + } + } + } + + private static void makeExecutable(Path file) { + try { + Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rwxr-xr-x")); + } catch (UnsupportedOperationException | IOException e) { + file.toFile().setExecutable(true, false); + } + } + + // ========================================================== + // 二、按文件大小分档 + // ========================================================== + + /** 档位(上界不含):SMALL < 64MB < MEDIUM < 128MB < LARGE < 256MB + * < XLARGE < 512MB < HUGE。 */ + public enum SizeTier { + SMALL(64 * MB, List.of("-s-32", "-c-lzma2-9", "-p-2")), + MEDIUM(128 * MB, List.of("-s-64", "-c-lzma2-9", "-p-2")), + LARGE(256 * MB, List.of("-s-64", "-SD-256k", "-c-lzma2-9", "-p-2")), + XLARGE(512 * MB, List.of("-s-128", "-SD-512k", "-c-lzma2-9", "-p-2")), + HUGE(Long.MAX_VALUE, List.of("-s-256", "-SD-1m", "-c-lzma2-9", "-p-2")); + + private final long upperBoundExclusive; + private final List diffArgs; + + SizeTier(long upperBoundExclusive, List diffArgs) { + this.upperBoundExclusive = upperBoundExclusive; + this.diffArgs = diffArgs; + } + + public long upperBoundExclusive() { + return upperBoundExclusive; + } + + /** 档位对应的 hdiffz 参数(不含输入输出文件)。 */ + public List diffArgs() { + return diffArgs; + } + + /** 人类可读的大小区间。 */ + public String range() { + return switch (this) { + case SMALL -> "< 64MB"; + case MEDIUM -> "64~128MB"; + case LARGE -> "128~256MB"; + case XLARGE -> "256~512MB"; + case HUGE -> ">= 512MB"; + }; + } + + /** 根据文件大小(字节)归档。 */ + public static SizeTier of(long fileSize) { + for (SizeTier tier : values()) { + if (fileSize < tier.upperBoundExclusive) { + return tier; + } + } + return HUGE; + } + } + + /** + * 一次差分的参数计划。 + * + * @param tier 命中的档位 + * @param diffArgs hdiffz 参数 + */ + public record DeltaPlan(SizeTier tier, List diffArgs) { + + public DeltaPlan { + diffArgs = List.copyOf(diffArgs); + } + + /** 按 old/new 中较大者生成计划:流式内存由旧包匹配表决定。 */ + public static DeltaPlan forFiles(File oldFile, File newFile) { + return of(SizeTier.of(Math.max(oldFile.length(), newFile.length()))); + } + + public static DeltaPlan of(SizeTier tier) { + return new DeltaPlan(tier, tier.diffArgs()); + } + } + + // ========================================================== + // 三、生成差分 + // ========================================================== + + /** 按 old/new 中较大者自动分档,生成 patch。 */ + public static void createPatch(File oldFile, File newFile, File patchFile) + throws IOException, InterruptedException { + createPatch(oldFile, newFile, patchFile, DeltaPlan.forFiles(oldFile, newFile)); + } + + /** + * 按指定计划生成 patch。hdiffz 默认带回验,产出即可用;生成后计算补丁率, + * 超过 {@link #FULL_PACKAGE_THRESHOLD} 时输出回退全量的告警。 + */ + public static void createPatch(File oldFile, File newFile, File patchFile, DeltaPlan plan) + throws IOException, InterruptedException { + requireFile(oldFile, "oldFile"); + requireFile(newFile, "newFile"); + long startNanos = System.nanoTime(); + + Path target = patchFile.toPath().toAbsolutePath().normalize(); + Path temp = createTempOutput(target); + try { + List args = new ArrayList<>(plan.diffArgs()); + args.add("-f"); + args.add(oldFile.getPath()); + args.add(newFile.getPath()); + args.add(temp.toString()); + + ProcessResult result = exec(hdiffzPath(), args); + result.requireSuccess("hdiffz"); + Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING); + } finally { + Files.deleteIfExists(temp); + } + + double ratio = patchRatio(newFile, patchFile); + LOGGER.info("[createPatch] tier={} diff={} old={} new={} patch={} ratio={} 耗时 {}", + plan.tier(), String.join(" ", plan.diffArgs()), oldFile.length(), newFile.length(), + patchFile.length(), String.format(Locale.ROOT, "%.1f%%", ratio * 100), + formatDuration(System.nanoTime() - startNanos)); + if (ratio > FULL_PACKAGE_THRESHOLD) { + LOGGER.warn("[createPatch] 补丁率超过 {}%,建议回退全量下发", + (int) (FULL_PACKAGE_THRESHOLD * 100)); + } + } + + /** 补丁率:patch 大小 / 新包大小。 */ + public static double patchRatio(File newFile, File patchFile) { + long newSize = Math.max(1L, newFile.length()); + return (double) patchFile.length() / newSize; + } + + private static Path hdiffzPath() throws IOException { + return binaryDir().resolve(currentPlatform().hdiffzName()); + } + + // ========================================================== + // 四、进程调用 + // ========================================================== + + private record ProcessResult(int exitCode, String output) { + + void requireSuccess(String tool) throws IOException { + if (exitCode == 0) { + return; + } + String text = output.stripTrailing(); + if (text.length() > 4000) { + text = "..." + text.substring(text.length() - 4000); + } + throw new IOException(tool + " 执行失败 (exit=" + exitCode + ")\n" + text); + } + } + + private static ProcessResult exec(Path exe, List args) + throws IOException, InterruptedException { + List command = new ArrayList<>(args.size() + 1); + command.add(exe.toString()); + command.addAll(args); + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + try { + // 子进程不读取 stdin,关闭父进程侧写端以释放句柄 + process.getOutputStream().close(); + } catch (IOException ignored) { + // 忽略:不影响执行结果 + } + + StringBuffer output = new StringBuffer(); + Charset charset = Charset.forName(System.getProperty("native.encoding", "UTF-8")); + Thread drainer = new Thread(() -> { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), charset))) { + for (String line; (line = reader.readLine()) != null; ) { + output.append(line).append(System.lineSeparator()); + } + } catch (IOException ignored) { + // 进程被强制结束时管道关闭,读取中断即可 + } + }, "ttstd-hdiffpatch-io"); + drainer.setDaemon(true); + drainer.start(); + + long timeoutSeconds = timeoutSeconds(); + boolean finished; + try { + if (timeoutSeconds > 0) { + finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS); + } else { + process.waitFor(); + finished = true; + } + } catch (InterruptedException e) { + process.destroyForcibly(); + joinQuietly(drainer); + throw e; + } + if (!finished) { + process.destroyForcibly(); + joinQuietly(drainer); + throw new IOException("子进程执行超时(" + timeoutSeconds + "s): " + exe + + ";可用 -D" + TIMEOUT_PROPERTY + "=<秒> 调整,0 表示不限时"); + } + joinQuietly(drainer); + return new ProcessResult(process.exitValue(), output.toString()); + } + + private static void joinQuietly(Thread thread) { + try { + thread.join(5000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private static long timeoutSeconds() { + String value = System.getProperty(TIMEOUT_PROPERTY); + if (value == null || value.isBlank()) { + return DEFAULT_TIMEOUT_SECONDS; + } + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + LOGGER.warn("{} 无效: {},使用默认 {}s", TIMEOUT_PROPERTY, value, DEFAULT_TIMEOUT_SECONDS); + return DEFAULT_TIMEOUT_SECONDS; + } + } + + /** 在目标同目录创建临时输出文件,保证成功后 move 不跨文件系统。 */ + private static Path createTempOutput(Path target) throws IOException { + Path dir = target.getParent(); + Path name = target.getFileName(); + if (dir == null || name == null) { + throw new IOException("无法确定输出目录: " + target); + } + return Files.createTempFile(dir, "." + name + "-", ".tmp"); + } + + private static void requireFile(File file, String name) throws IOException { + if (file == null || !file.isFile()) { + throw new IOException(name + " 不存在或不是文件: " + file); + } + } + + /** 时长格式化:ms / s / min / h。 */ + private static String formatDuration(long nanos) { + double ms = nanos / 1_000_000.0; + if (ms < 1_000.0) { + return String.format(Locale.ROOT, "%.3f ms", ms); + } + double totalSeconds = ms / 1_000.0; + if (totalSeconds < 60.0) { + return String.format(Locale.ROOT, "%.3f s", totalSeconds); + } + long minutes = (long) (totalSeconds / 60.0); + if (minutes < 60) { + return String.format(Locale.ROOT, "%d min %.3f s", minutes, totalSeconds - minutes * 60.0); + } + long hours = minutes / 60; + return String.format(Locale.ROOT, "%d h %d min %.3f s", + hours, minutes % 60, totalSeconds - minutes * 60.0); + } +} diff --git a/src/main/java/com/youlai/boot/app/update/component/RemotePackageDownloader.java b/src/main/java/com/youlai/boot/app/update/component/RemotePackageDownloader.java new file mode 100644 index 00000000..b345395f --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/component/RemotePackageDownloader.java @@ -0,0 +1,144 @@ +package com.youlai.boot.app.update.component; + +import cn.hutool.core.util.StrUtil; +import com.aliyun.oss.model.GetObjectRequest; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.support.file.service.impl.AliyunFileServiceImpl; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Path; +import java.time.Duration; + +/** + * 安装包回拉器 + *

+ * 更新安装包只存对象存储地址、服务端不留副本,差分时需要把新旧两个包取回本地。 + * 阿里云 OSS 对新建桶禁止用默认公网域名以文件 URL 方式分发 {@code .apk} + * (返回 400 {@code ApkDownloadForbidden}),因此本桶对象改走 OSS SDK 的 + * Header 签名下载,不受该限制;其余存储(S3 匿名只读、自定义域名)仍按 HTTP 读取。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RemotePackageDownloader { + + /** 安装包可达上百 MB,给足读取时间但不无限等待 */ + private static final Duration REQUEST_TIMEOUT = Duration.ofMinutes(15); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + + private final ObjectProvider aliyunStorage; + + /** + * 下载安装包到指定目录 + * + * @param url 安装包访问地址 + * @param target 落盘文件 + * @param scene 用途描述,用于错误信息 + * @return 落盘后的文件 + */ + public File download(String url, File target, String scene) { + if (StrUtil.isBlank(url)) { + throw new BusinessException(scene + "地址为空"); + } + AliyunFileServiceImpl aliyun = aliyunStorage.getIfAvailable(); + if (aliyun != null && isDefaultBucketHost(url, aliyun)) { + return downloadByOssSdk(aliyun, url, target, scene); + } + return downloadByHttp(url, target, scene); + } + + /** + * 阿里云 OSS SDK 下载:请求走 {@code Authorization} 头签名,不受默认域名禁止分发 APK 的限制。 + */ + private File downloadByOssSdk(AliyunFileServiceImpl aliyun, String url, File target, String scene) { + String objectKey = objectKeyOf(url); + try { + aliyun.getAliyunOssClient().getObject(new GetObjectRequest(aliyun.getBucket(), objectKey), target); + } catch (Exception e) { + log.error("[download] {} 从 OSS 下载失败 {} ({})", scene, url, objectKey, e); + throw new BusinessException(scene + "从 OSS 下载失败:" + e.getMessage()); + } + return requireDownloaded(target, url, scene); + } + + private File downloadByHttp(String url, File target, String scene) { + if (!url.startsWith("http://") && !url.startsWith("https://")) { + throw new BusinessException(scene + "地址不可直接下载:" + url); + } + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .timeout(REQUEST_TIMEOUT) + .GET() + .build(); + try { + HttpResponse response = HTTP_CLIENT.send(request, + HttpResponse.BodyHandlers.ofFile(target.toPath())); + if (response.statusCode() != 200) { + throw new BusinessException(scene + "下载失败(HTTP " + response.statusCode() + "):" + url); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new BusinessException(scene + "下载被中断"); + } catch (IOException e) { + throw new BusinessException(scene + "下载失败:" + e.getMessage()); + } + return requireDownloaded(target, url, scene); + } + + private File requireDownloaded(File target, String url, String scene) { + if (!target.isFile() || target.length() <= 0) { + throw new BusinessException(scene + "下载内容为空:" + url); + } + log.info("[download] {} 下载完成 {} <- {}", scene, target.length(), url); + return target; + } + + /** + * 判断地址主机是否为 OSS 默认公网域名 {@code {bucket}.{endpoint}};自定义域名/CDN 返回 false。 + */ + private boolean isDefaultBucketHost(String url, AliyunFileServiceImpl aliyun) { + String host = hostOf(url); + return host != null && host.equalsIgnoreCase(defaultBucketHost(aliyun)); + } + + private String defaultBucketHost(AliyunFileServiceImpl aliyun) { + String endpoint = StrUtil.removeSuffix(StrUtil.trim(aliyun.getEndpoint()), "/"); + int schemeEnd = endpoint.indexOf("://"); + String endpointHost = schemeEnd > 0 ? endpoint.substring(schemeEnd + 3) : endpoint; + return aliyun.getBucket() + "." + endpointHost; + } + + /** + * 取访问地址中的对象 key(URL 路径去掉前导 {@code /},对象名不含需转义字符)。 + */ + private String objectKeyOf(String url) { + String path = hostOf(url) == null ? url : URI.create(url).getPath(); + path = StrUtil.removePrefix(StrUtil.trim(path), "/"); + if (StrUtil.isBlank(path)) { + throw new BusinessException("无法从地址解析对象名:" + url); + } + return path; + } + + private String hostOf(String url) { + if (!url.startsWith("http://") && !url.startsWith("https://")) { + return null; + } + try { + return URI.create(url).getHost(); + } catch (IllegalArgumentException e) { + return null; + } + } +} diff --git a/src/main/java/com/youlai/boot/app/update/component/UpdatePackageUploader.java b/src/main/java/com/youlai/boot/app/update/component/UpdatePackageUploader.java new file mode 100644 index 00000000..9b15728d --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/component/UpdatePackageUploader.java @@ -0,0 +1,158 @@ +package com.youlai.boot.app.update.component; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.crypto.digest.DigestUtil; +import com.youlai.boot.app.packages.component.ApkMetaParser; +import com.youlai.boot.app.packages.component.ApkMetaResult; +import com.youlai.boot.app.packages.component.ByteArrayMultipartFile; +import com.youlai.boot.app.packages.component.FileMultipartFile; +import com.youlai.boot.app.update.model.vo.AppUpdateFileVO; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.support.file.model.FileInfo; +import com.youlai.boot.support.file.service.FileService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.List; + +/** + * 更新用安装包的落盘与解析 + *

+ * 全局更新安装包与省流更新的基线包共用同一套规则:校验扩展名 → 落临时文件 → + * 计算 MD5/SHA1/SHA256 → 上传到指定业务目录 → 解析包名/版本/图标。图标目录为空时 + * 不提取图标(基线包只需要包名与版本号)。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class UpdatePackageUploader { + + /** 设备可直接安装的安装包类型(aab 需经应用商店分发,不参与 OTA) */ + private static final List INSTALLABLE_EXT = List.of("apk", "apks", "apkm", "xapk"); + + private final FileService fileService; + + /** + * 保存安装包并解析元数据 + * + * @param file 安装包文件 + * @param packageDir 安装包存储目录({@code StorageDir} 常量) + * @param iconDir 图标存储目录,为空表示不提取图标 + * @param packageName 包名补充值(解析失败时使用) + * @param versionName 版本名称补充值 + * @param versionCode 版本号补充值 + * @param appLabel 应用名称补充值 + */ + public AppUpdateFileVO store(MultipartFile file, String packageDir, String iconDir, + String packageName, String versionName, String versionCode, String appLabel) { + if (file == null || file.isEmpty()) { + throw new BusinessException("请选择安装包文件"); + } + String originalName = file.getOriginalFilename(); + String ext = FileUtil.getSuffix(originalName); + if (ext == null || !INSTALLABLE_EXT.contains(ext.toLowerCase())) { + throw new BusinessException("仅支持上传 apk / apks / apkm / xapk 安装包"); + } + String lowerExt = ext.toLowerCase(); + + // 元数据解析需要可随机访问的文件,云存储上传也只能读取一次流,先落临时文件 + File tempFile = null; + try { + tempFile = File.createTempFile("app-update-", "." + lowerExt); + try (InputStream in = file.getInputStream()) { + Files.copy(in, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + FileUtil.del(tempFile); + log.error("更新安装包读取失败", e); + throw new BusinessException("安装包读取失败"); + } + + try { + long fileSize = tempFile.length(); + AppUpdateFileVO vo = new AppUpdateFileVO(); + vo.setFileName(originalName); + vo.setFileSize(fileSize); + vo.setFileSizeText(FileUtil.readableFileSize(fileSize)); + vo.setMd5(DigestUtil.md5Hex(tempFile)); + vo.setSha1(DigestUtil.sha1Hex(tempFile)); + vo.setSha256(DigestUtil.sha256Hex(tempFile)); + vo.setParsed(false); + + FileInfo stored = fileService.uploadFile( + new FileMultipartFile("file", originalName, "application/octet-stream", tempFile), packageDir); + if (stored == null || !StringUtils.hasText(stored.getUrl())) { + throw new BusinessException("安装包上传失败"); + } + vo.setFileUrl(stored.getUrl()); + + ApkMetaResult meta = null; + try { + meta = ApkMetaParser.parse(tempFile); + } catch (Exception e) { + log.warn("{} 元数据解析失败,使用补充信息:{}", lowerExt, e.getMessage()); + } + + vo.setPackageName(firstNonBlank(meta != null ? meta.getPackageName() : null, packageName)); + vo.setAppLabel(firstNonBlank(meta != null ? meta.getAppLabel() : null, appLabel)); + vo.setVersionName(firstNonBlank(meta != null ? meta.getVersionName() : null, versionName)); + vo.setVersionCode(meta != null && meta.getVersionCode() != null + ? meta.getVersionCode() + : parseLongSafe(versionCode)); + // 部分容器格式解析异常时会返回空结果,只有包名和版本号都拿到才算解析成功 + vo.setParsed(StringUtils.hasText(vo.getPackageName()) && vo.getVersionCode() != null); + + if (StringUtils.hasText(iconDir) + && meta != null && meta.getIconBytes() != null && meta.getIconBytes().length > 0) { + vo.setIconUrl(uploadIcon(meta.getIconBytes(), iconDir)); + } + return vo; + } finally { + FileUtil.del(tempFile); + } + } + + private String uploadIcon(byte[] iconBytes, String iconDir) { + try { + String iconName = DigestUtil.md5Hex(iconBytes) + ".png"; + FileInfo iconInfo = fileService.uploadFile( + new ByteArrayMultipartFile("file", iconName, "image/png", iconBytes), iconDir); + if (iconInfo == null || !StringUtils.hasText(iconInfo.getUrl())) { + return null; + } + return iconInfo.getUrl(); + } catch (Exception e) { + // 图标不是更新的必要信息,上传失败不影响安装包入库 + log.warn("更新图标上传失败:{}", e.getMessage()); + return null; + } + } + + private String firstNonBlank(String... values) { + for (String value : values) { + if (StringUtils.hasText(value)) { + return value.trim(); + } + } + return null; + } + + private Long parseLongSafe(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/java/com/youlai/boot/app/update/controller/AppUpdateGrayController.java b/src/main/java/com/youlai/boot/app/update/controller/AppUpdateGrayController.java new file mode 100644 index 00000000..48307d97 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/controller/AppUpdateGrayController.java @@ -0,0 +1,117 @@ +package com.youlai.boot.app.update.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.app.update.model.form.AppUpdateGrayForm; +import com.youlai.boot.app.update.model.query.AppUpdateReleasePageQuery; +import com.youlai.boot.app.update.model.vo.AppUpdateFileVO; +import com.youlai.boot.app.update.model.vo.AppUpdateReleaseVO; +import com.youlai.boot.app.update.service.AppUpdateGrayService; +import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.common.enums.ActionTypeEnum; +import com.youlai.boot.common.enums.LogModuleEnum; +import com.youlai.boot.common.result.PageResult; +import com.youlai.boot.common.result.Result; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +/** + * 灰度更新发布管理控制层 + */ +@Tag(name = "应用更新-灰度更新") +@RestController +@RequestMapping("/api/v1/app/update/gray") +@RequiredArgsConstructor +public class AppUpdateGrayController { + + private final AppUpdateGrayService grayService; + + @Operation(summary = "上传灰度更新安装包(自动解析包名/版本/图标)") + @PostMapping("/upload") + public Result upload( + @Parameter(description = "安装包文件(apk/apks/apkm/xapk)") @RequestPart("file") MultipartFile file, + @Parameter(description = "应用包名(解析失败时使用)") @RequestParam(required = false) String packageName, + @Parameter(description = "版本名称(解析失败时使用)") @RequestParam(required = false) String versionName, + @Parameter(description = "版本号(解析失败时使用)") @RequestParam(required = false) String versionCode, + @Parameter(description = "应用名称(解析失败时使用)") @RequestParam(required = false) String appLabel + ) { + return Result.success(grayService.upload(file, packageName, versionName, versionCode, appLabel)); + } + + @Operation(summary = "灰度更新发布单分页列表") + @GetMapping("/page") + public PageResult page( + @Parameter(description = "页码,从1开始") @RequestParam(defaultValue = "1") Integer pageNum, + @Parameter(description = "每页条数") @RequestParam(defaultValue = "10") Integer pageSize, + @Parameter(description = "状态:DRAFT/PUBLISHED/STOPPED") @RequestParam(required = false) String status, + @Parameter(description = "关键字(应用名/包名/版本名称)") @RequestParam(required = false) String keywords + ) { + AppUpdateReleasePageQuery query = new AppUpdateReleasePageQuery(); + query.setStatus(status); + query.setKeywords(keywords); + org.springframework.data.domain.Page springPage = + grayService.page(query, pageNum, pageSize); + IPage iPage = new Page<>(pageNum, pageSize, springPage.getTotalElements()); + iPage.setRecords(springPage.getContent()); + return PageResult.success(iPage); + } + + @Operation(summary = "灰度发布单详情(含目标设备序列号)") + @GetMapping("/{id}") + public Result detail(@Parameter(description = "发布单ID") @PathVariable String id) { + return Result.success(grayService.detail(id)); + } + + @Operation(summary = "创建灰度更新草稿") + @PostMapping + @Log(module = LogModuleEnum.APP, value = ActionTypeEnum.INSERT) + public Result create(@RequestBody AppUpdateGrayForm form) { + return Result.success(grayService.create(form)); + } + + @Operation(summary = "编辑灰度更新草稿") + @PutMapping("/{id}") + @Log(module = LogModuleEnum.APP, value = ActionTypeEnum.UPDATE) + public Result update( + @Parameter(description = "发布单ID") @PathVariable String id, + @RequestBody AppUpdateGrayForm form + ) { + return Result.success(grayService.update(id, form)); + } + + @Operation(summary = "发布灰度更新") + @PostMapping("/{id}/publish") + @Log(module = LogModuleEnum.APP, value = ActionTypeEnum.UPDATE) + public Result publish(@Parameter(description = "发布单ID") @PathVariable String id) { + return Result.success(grayService.publish(id)); + } + + @Operation(summary = "停止灰度更新") + @PostMapping("/{id}/stop") + @Log(module = LogModuleEnum.APP, value = ActionTypeEnum.DISABLE) + public Result stop(@Parameter(description = "发布单ID") @PathVariable String id) { + return Result.success(grayService.stop(id)); + } + + @Operation(summary = "上架 / 下架灰度更新") + @PutMapping("/{id}/listed") + @Log(module = LogModuleEnum.APP, value = ActionTypeEnum.UPDATE) + public Result updateListed( + @Parameter(description = "发布单ID") @PathVariable String id, + @Parameter(description = "true=上架,false=下架") @RequestParam Boolean listed + ) { + return Result.success(grayService.updateListed(id, Boolean.TRUE.equals(listed))); + } + + @Operation(summary = "删除灰度更新发布单") + @DeleteMapping("/{id}") + @Log(module = LogModuleEnum.APP, value = ActionTypeEnum.DELETE) + public Result remove(@Parameter(description = "发布单ID") @PathVariable String id) { + grayService.remove(id); + return Result.success(true); + } +} diff --git a/src/main/java/com/youlai/boot/app/update/controller/AppUpdatePatchController.java b/src/main/java/com/youlai/boot/app/update/controller/AppUpdatePatchController.java new file mode 100644 index 00000000..c66388eb --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/controller/AppUpdatePatchController.java @@ -0,0 +1,129 @@ +package com.youlai.boot.app.update.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.app.update.model.form.AppUpdatePatchForm; +import com.youlai.boot.app.update.model.query.AppUpdatePatchPageQuery; +import com.youlai.boot.app.update.model.vo.AppUpdateFileVO; +import com.youlai.boot.app.update.model.vo.AppUpdatePatchVO; +import com.youlai.boot.app.update.model.vo.AppUpdateReleaseVO; +import com.youlai.boot.app.update.service.AppUpdatePatchService; +import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.common.enums.ActionTypeEnum; +import com.youlai.boot.common.enums.LogModuleEnum; +import com.youlai.boot.common.result.PageResult; +import com.youlai.boot.common.result.Result; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * 省流更新差分补丁管理控制层 + */ +@Tag(name = "应用更新-省流更新") +@RestController +@RequestMapping("/api/v1/app/update/save-flow") +@RequiredArgsConstructor +public class AppUpdatePatchController { + + private final AppUpdatePatchService patchService; + + @Operation(summary = "上传补录的旧安装包(不在发布单里的历史基线包)") + @PostMapping("/upload-base") + public Result uploadBasePackage( + @Parameter(description = "安装包文件(apk/apks/apkm/xapk)") @RequestPart("file") MultipartFile file + ) { + return Result.success(patchService.uploadBasePackage(file)); + } + + @Operation(summary = "目标发布单可勾选的历史源版本") + @GetMapping("/base-versions") + public Result> baseVersions( + @Parameter(description = "目标发布单ID") @RequestParam String targetReleaseId + ) { + return Result.success(patchService.baseCandidates(targetReleaseId)); + } + + @Operation(summary = "差分补丁分页列表") + @GetMapping("/page") + public PageResult page( + @Parameter(description = "页码,从1开始") @RequestParam(defaultValue = "1") Integer pageNum, + @Parameter(description = "每页条数") @RequestParam(defaultValue = "10") Integer pageSize, + @Parameter(description = "生成状态:GENERATING/READY/FAILED") @RequestParam(required = false) String status, + @Parameter(description = "目标渠道编码") @RequestParam(required = false) String channelCode, + @Parameter(description = "关键字(应用名/包名/版本名称)") @RequestParam(required = false) String keywords + ) { + AppUpdatePatchPageQuery query = new AppUpdatePatchPageQuery(); + query.setStatus(status); + query.setChannelCode(channelCode); + query.setKeywords(keywords); + org.springframework.data.domain.Page springPage = + patchService.page(query, pageNum, pageSize); + + IPage iPage = new Page<>(pageNum, pageSize, springPage.getTotalElements()); + iPage.setRecords(springPage.getContent()); + return PageResult.success(iPage); + } + + @Operation(summary = "差分补丁详情") + @GetMapping("/{id}") + public Result detail(@Parameter(description = "补丁ID") @PathVariable String id) { + return Result.success(patchService.detail(id)); + } + + @Operation(summary = "提交差分生成任务(一个源版本一条记录)") + @PostMapping + @Log(module = LogModuleEnum.APP, value = ActionTypeEnum.INSERT) + public Result> create(@RequestBody AppUpdatePatchForm form) { + return Result.success(patchService.create(form)); + } + + @Operation(summary = "上架 / 下架差分补丁") + @PutMapping("/{id}/listed") + @Log(module = LogModuleEnum.APP, value = ActionTypeEnum.UPDATE) + public Result updateListed( + @Parameter(description = "补丁ID") @PathVariable String id, + @Parameter(description = "true=上架,false=下架") @RequestParam Boolean listed + ) { + return Result.success(patchService.updateListed(id, Boolean.TRUE.equals(listed))); + } + + @Operation(summary = "开启 / 关闭差分补丁的强制更新") + @PutMapping("/{id}/force-update") + @Log(module = LogModuleEnum.APP, value = ActionTypeEnum.UPDATE) + public Result updateForceUpdate( + @Parameter(description = "补丁ID") @PathVariable String id, + @Parameter(description = "true=强制更新,false=可选更新") @RequestParam Boolean forceUpdate + ) { + return Result.success(patchService.updateForceUpdate(id, Boolean.TRUE.equals(forceUpdate))); + } + + @Operation(summary = "重新生成差分补丁") + @PostMapping("/{id}/regenerate") + @Log(module = LogModuleEnum.APP, value = ActionTypeEnum.UPDATE) + public Result regenerate(@Parameter(description = "补丁ID") @PathVariable String id) { + return Result.success(patchService.regenerate(id)); + } + + @Operation(summary = "删除差分补丁") + @DeleteMapping("/{id}") + @Log(module = LogModuleEnum.APP, value = ActionTypeEnum.DELETE) + public Result remove(@Parameter(description = "补丁ID") @PathVariable String id) { + patchService.remove(id); + return Result.success(true); + } +} diff --git a/src/main/java/com/youlai/boot/app/update/controller/AppUpdateReleaseController.java b/src/main/java/com/youlai/boot/app/update/controller/AppUpdateReleaseController.java new file mode 100644 index 00000000..4a1c30d2 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/controller/AppUpdateReleaseController.java @@ -0,0 +1,120 @@ +package com.youlai.boot.app.update.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.app.update.model.constant.UpdateType; +import com.youlai.boot.app.update.model.form.AppUpdateReleaseForm; +import com.youlai.boot.app.update.model.query.AppUpdateReleasePageQuery; +import com.youlai.boot.app.update.model.vo.AppUpdateFileVO; +import com.youlai.boot.app.update.model.vo.AppUpdateReleaseVO; +import com.youlai.boot.app.update.service.AppUpdateReleaseService; +import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.common.enums.ActionTypeEnum; +import com.youlai.boot.common.enums.LogModuleEnum; +import com.youlai.boot.common.result.PageResult; +import com.youlai.boot.common.result.Result; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +/** + * 全局更新发布管理控制层 + */ +@Tag(name = "应用更新-全局更新") +@RestController +@RequestMapping("/api/v1/app/update/global") +@RequiredArgsConstructor +public class AppUpdateReleaseController { + + private final AppUpdateReleaseService releaseService; + + @Operation(summary = "上传全局更新安装包(自动解析包名/版本/图标)") + @PostMapping("/upload") + public Result upload( + @Parameter(description = "安装包文件(apk/apks/apkm/xapk)") @RequestPart("file") MultipartFile file, + @Parameter(description = "应用包名(解析失败时使用)") @RequestParam(required = false) String packageName, + @Parameter(description = "版本名称(解析失败时使用)") @RequestParam(required = false) String versionName, + @Parameter(description = "版本号(解析失败时使用)") @RequestParam(required = false) String versionCode, + @Parameter(description = "应用名称(解析失败时使用)") @RequestParam(required = false) String appLabel + ) { + return Result.success(releaseService.upload(file, packageName, versionName, versionCode, appLabel)); + } + + @Operation(summary = "全局更新发布单分页列表") + @GetMapping("/page") + public PageResult page( + @Parameter(description = "页码,从1开始") @RequestParam(defaultValue = "1") Integer pageNum, + @Parameter(description = "每页条数") @RequestParam(defaultValue = "10") Integer pageSize, + @Parameter(description = "状态:DRAFT/PUBLISHED/STOPPED") @RequestParam(required = false) String status, + @Parameter(description = "关键字(应用名/包名/版本名称)") @RequestParam(required = false) String keywords + ) { + AppUpdateReleasePageQuery query = new AppUpdateReleasePageQuery(); + query.setUpdateType(UpdateType.GLOBAL); + query.setStatus(status); + query.setKeywords(keywords); + org.springframework.data.domain.Page springPage = + releaseService.page(query, pageNum, pageSize); + + IPage iPage = new Page<>(pageNum, pageSize, springPage.getTotalElements()); + iPage.setRecords(springPage.getContent()); + return PageResult.success(iPage); + } + + @Operation(summary = "发布单详情") + @GetMapping("/{id}") + public Result detail(@Parameter(description = "发布单ID") @PathVariable String id) { + return Result.success(releaseService.detail(id)); + } + + @Operation(summary = "创建全局更新草稿") + @PostMapping + @Log(module = LogModuleEnum.APP, value = ActionTypeEnum.INSERT) + public Result create(@RequestBody AppUpdateReleaseForm form) { + return Result.success(releaseService.create(form)); + } + + @Operation(summary = "编辑全局更新草稿") + @PutMapping("/{id}") + @Log(module = LogModuleEnum.APP, value = ActionTypeEnum.UPDATE) + public Result update( + @Parameter(description = "发布单ID") @PathVariable String id, + @RequestBody AppUpdateReleaseForm form + ) { + return Result.success(releaseService.update(id, form)); + } + + @Operation(summary = "发布全局更新") + @PostMapping("/{id}/publish") + @Log(module = LogModuleEnum.APP, value = ActionTypeEnum.UPDATE) + public Result publish(@Parameter(description = "发布单ID") @PathVariable String id) { + return Result.success(releaseService.publish(id)); + } + + @Operation(summary = "停止全局更新") + @PostMapping("/{id}/stop") + @Log(module = LogModuleEnum.APP, value = ActionTypeEnum.DISABLE) + public Result stop(@Parameter(description = "发布单ID") @PathVariable String id) { + return Result.success(releaseService.stop(id)); + } + + @Operation(summary = "上架 / 下架全局更新") + @PutMapping("/{id}/listed") + @Log(module = LogModuleEnum.APP, value = ActionTypeEnum.UPDATE) + public Result updateListed( + @Parameter(description = "发布单ID") @PathVariable String id, + @Parameter(description = "true=上架,false=下架") @RequestParam Boolean listed + ) { + return Result.success(releaseService.updateListed(id, Boolean.TRUE.equals(listed))); + } + + @Operation(summary = "删除全局更新发布单") + @DeleteMapping("/{id}") + @Log(module = LogModuleEnum.APP, value = ActionTypeEnum.DELETE) + public Result remove(@Parameter(description = "发布单ID") @PathVariable String id) { + releaseService.remove(id); + return Result.success(true); + } +} diff --git a/src/main/java/com/youlai/boot/app/update/controller/UpdateChannelController.java b/src/main/java/com/youlai/boot/app/update/controller/UpdateChannelController.java new file mode 100644 index 00000000..7f2058a6 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/controller/UpdateChannelController.java @@ -0,0 +1,59 @@ +package com.youlai.boot.app.update.controller; + +import com.youlai.boot.app.update.model.form.UpdateChannelForm; +import com.youlai.boot.app.update.model.vo.UpdateChannelVO; +import com.youlai.boot.app.update.service.UpdateChannelService; +import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.common.enums.ActionTypeEnum; +import com.youlai.boot.common.enums.LogModuleEnum; +import com.youlai.boot.common.result.Result; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 更新渠道管理控制层 + */ +@Tag(name = "应用更新-渠道管理") +@RestController +@RequestMapping("/api/v1/app/update/channels") +@RequiredArgsConstructor +public class UpdateChannelController { + + private final UpdateChannelService updateChannelService; + + @Operation(summary = "渠道列表") + @GetMapping("/list") + public Result> list() { + return Result.success(updateChannelService.listChannels()); + } + + @Operation(summary = "新增渠道") + @PostMapping + @Log(module = LogModuleEnum.APP, value = ActionTypeEnum.INSERT) + public Result create(@RequestBody UpdateChannelForm form) { + return Result.success(updateChannelService.create(form)); + } + + @Operation(summary = "更新渠道") + @PutMapping("/{id}") + @Log(module = LogModuleEnum.APP, value = ActionTypeEnum.UPDATE) + public Result update( + @Parameter(description = "渠道ID") @PathVariable String id, + @RequestBody UpdateChannelForm form + ) { + return Result.success(updateChannelService.update(id, form)); + } + + @Operation(summary = "删除渠道") + @DeleteMapping("/{id}") + @Log(module = LogModuleEnum.APP, value = ActionTypeEnum.DELETE) + public Result remove(@Parameter(description = "渠道ID") @PathVariable String id) { + updateChannelService.remove(id); + return Result.success(true); + } +} diff --git a/src/main/java/com/youlai/boot/app/update/model/constant/GrayTargetType.java b/src/main/java/com/youlai/boot/app/update/model/constant/GrayTargetType.java new file mode 100644 index 00000000..bb84df3b --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/model/constant/GrayTargetType.java @@ -0,0 +1,16 @@ +package com.youlai.boot.app.update.model.constant; + +/** + * 灰度更新的下发目标 + */ +public final class GrayTargetType { + + /** 按渠道:该渠道下所有已安装该应用的设备 */ + public static final String CHANNEL = "CHANNEL"; + + /** 按设备:仅列表内的设备序列号(忽略渠道) */ + public static final String SN = "SN"; + + private GrayTargetType() { + } +} diff --git a/src/main/java/com/youlai/boot/app/update/model/constant/PatchBaseSource.java b/src/main/java/com/youlai/boot/app/update/model/constant/PatchBaseSource.java new file mode 100644 index 00000000..e4288673 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/model/constant/PatchBaseSource.java @@ -0,0 +1,16 @@ +package com.youlai.boot.app.update.model.constant; + +/** + * 省流更新差分补丁的源包(基线包)来源 + */ +public final class PatchBaseSource { + + /** 历史发布单:源包就是该发布单的安装包 */ + public static final String RELEASE = "RELEASE"; + + /** 补录旧包:单独上传的旧安装包,仅用于生成差分 */ + public static final String UPLOAD = "UPLOAD"; + + private PatchBaseSource() { + } +} diff --git a/src/main/java/com/youlai/boot/app/update/model/constant/PatchStatus.java b/src/main/java/com/youlai/boot/app/update/model/constant/PatchStatus.java new file mode 100644 index 00000000..c33e2226 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/model/constant/PatchStatus.java @@ -0,0 +1,21 @@ +package com.youlai.boot.app.update.model.constant; + +/** + * 省流更新差分补丁的生成状态 + *

+ * 上下架与强制更新独立于该状态;补丁能否下发还要看目标发布单是否已发布且上架。 + */ +public final class PatchStatus { + + /** 生成中:排队或差分任务执行中,不参与检查更新 */ + public static final String GENERATING = "GENERATING"; + + /** 可用:补丁已生成并入库,可下发 */ + public static final String READY = "READY"; + + /** 生成失败:需重新生成或删除 */ + public static final String FAILED = "FAILED"; + + private PatchStatus() { + } +} diff --git a/src/main/java/com/youlai/boot/app/update/model/constant/UpdateStatus.java b/src/main/java/com/youlai/boot/app/update/model/constant/UpdateStatus.java new file mode 100644 index 00000000..7ac2cc87 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/model/constant/UpdateStatus.java @@ -0,0 +1,19 @@ +package com.youlai.boot.app.update.model.constant; + +/** + * 更新发布单状态 + */ +public final class UpdateStatus { + + /** 草稿:可编辑、可删除,不参与设备更新检查 */ + public static final String DRAFT = "DRAFT"; + + /** 已发布:参与设备更新检查 */ + public static final String PUBLISHED = "PUBLISHED"; + + /** 已停止:不再下发给设备,可删除或重新发布 */ + public static final String STOPPED = "STOPPED"; + + private UpdateStatus() { + } +} diff --git a/src/main/java/com/youlai/boot/app/update/model/constant/UpdateType.java b/src/main/java/com/youlai/boot/app/update/model/constant/UpdateType.java new file mode 100644 index 00000000..94521d1e --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/model/constant/UpdateType.java @@ -0,0 +1,22 @@ +package com.youlai.boot.app.update.model.constant; + +/** + * 更新类型 + */ +public final class UpdateType { + + /** 全局更新:下发给所有设备 */ + public static final String GLOBAL = "GLOBAL"; + + /** 灰度更新:按比例/批次下发 */ + public static final String GRAY = "GRAY"; + + /** 省流更新:受流量/网络条件约束的下发 */ + public static final String SAVE_FLOW = "SAVE_FLOW"; + + /** 热更新:不重新安装安装包的资源更新 */ + public static final String HOT = "HOT"; + + private UpdateType() { + } +} diff --git a/src/main/java/com/youlai/boot/app/update/model/document/AppUpdatePatchDocument.java b/src/main/java/com/youlai/boot/app/update/model/document/AppUpdatePatchDocument.java new file mode 100644 index 00000000..84b6246a --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/model/document/AppUpdatePatchDocument.java @@ -0,0 +1,187 @@ +package com.youlai.boot.app.update.model.document; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.LocalDateTime; + +/** + * 省流更新差分补丁文档(MongoDB) + *

+ * 一条记录 = 一个目标发布单 + 一个源版本 + 一个差分文件。同一目标版本发布给 N 个历史版本, + * 就有 N 条记录,各自独立生成、独立上下架。 + *

+ * 目标包与源包的文件地址与校验值在创建时快照,后续发布单变更不影响已生成的补丁。 + */ +@Schema(description = "省流更新差分补丁") +@Data +@Document(collection = "app_update_patch") +public class AppUpdatePatchDocument { + + @Schema(description = "补丁ID") + @Id + private String id; + + @Schema(description = "目标发布单ID(全局更新)") + @Field("target_release_id") + private String targetReleaseId; + + @Schema(description = "渠道编码(继承目标发布单)") + @Field("channel_code") + private String channelCode; + + @Schema(description = "渠道名称") + @Field("channel_name") + private String channelName; + + @Schema(description = "应用包名") + @Field("package_name") + private String packageName; + + @Schema(description = "应用名称") + @Field("app_label") + private String appLabel; + + @Schema(description = "应用图标地址(快照自目标发布单)") + @Field("icon_url") + private String iconUrl; + + @Schema(description = "目标版本名称") + @Field("target_version_name") + private String targetVersionName; + + @Schema(description = "目标版本号") + @Field("target_version_code") + private Long targetVersionCode; + + @Schema(description = "目标安装包大小(字节,用于展示压缩率)") + @Field("target_file_size") + private Long targetFileSize; + + @Schema(description = "目标安装包大小(人类可读)") + @Field("target_file_size_text") + private String targetFileSizeText; + + @Schema(description = "目标安装包MD5(回拉后校验,防止取到损坏或错误的文件)") + @Field("target_md5") + private String targetMd5; + + @Schema(description = "目标安装包SHA256(设备合并后自校验)") + @Field("target_sha256") + private String targetSha256; + + @Schema(description = "源版本名称") + @Field("base_version_name") + private String baseVersionName; + + @Schema(description = "源版本号(设备当前版本命中即可用差分)") + @Field("base_version_code") + private Long baseVersionCode; + + @Schema(description = "源包来源:RELEASE(历史发布单)/ UPLOAD(补录旧包)") + @Field("base_source") + private String baseSource; + + @Schema(description = "源包对应的发布单ID(补录时为空)") + @Field("base_release_id") + private String baseReleaseId; + + @Schema(description = "源安装包地址(hdiffz 的 old 输入)") + @Field("base_file_url") + private String baseFileUrl; + + @Schema(description = "源安装包文件名") + @Field("base_file_name") + private String baseFileName; + + @Schema(description = "源安装包MD5(回拉后校验,防止取到损坏或错误的文件)") + @Field("base_md5") + private String baseMd5; + + @Schema(description = "源安装包SHA256(设备合并前自校验本地包)") + @Field("base_sha256") + private String baseSha256; + + @Schema(description = "目标安装包地址(无差分或差分失败时的全量兜底)") + @Field("target_file_url") + private String targetFileUrl; + + @Schema(description = "差分补丁地址") + @Field("patch_url") + private String patchUrl; + + @Schema(description = "差分补丁文件名") + @Field("patch_file_name") + private String patchFileName; + + @Schema(description = "差分补丁大小(字节)") + @Field("patch_size") + private Long patchSize; + + @Schema(description = "差分补丁大小(人类可读)") + @Field("patch_size_text") + private String patchSizeText; + + @Schema(description = "补丁率(补丁大小 / 目标包大小)") + @Field("patch_ratio") + private Double patchRatio; + + @Schema(description = "差分补丁MD5") + @Field("md5") + private String md5; + + @Schema(description = "差分补丁SHA1") + @Field("sha1") + private String sha1; + + @Schema(description = "差分补丁SHA256") + @Field("sha256") + private String sha256; + + @Schema(description = "差分算法及版本(设备端据此选择 hpatchz)") + @Field("patch_algorithm") + private String patchAlgorithm; + + @Schema(description = "生成时的 hdiffz 分档") + @Field("size_tier") + private String sizeTier; + + @Schema(description = "生成状态:GENERATING/READY/FAILED") + @Field("status") + private String status; + + @Schema(description = "失败原因") + @Field("error_message") + private String errorMessage; + + /** + * 生成成功但运营层面给出的建议(如补丁率过高时建议改用全量),为空表示无建议。 + * 有建议时默认不上架,运营可自行决定上架与否。 + */ + @Schema(description = "生成结果建议,如建议改用全量下发") + @Field("advice") + private String advice; + + @Schema(description = "是否上架(关闭后检查更新不下发该补丁)") + @Field("listed") + private Boolean listed; + + @Schema(description = "是否强制更新(仅上架后可开启)") + @Field("force_update") + private Boolean forceUpdate; + + @Schema(description = "生成完成时间") + @Field("generated_at") + private LocalDateTime generatedAt; + + @Schema(description = "创建时间") + @Field("create_time") + private LocalDateTime createTime; + + @Schema(description = "更新时间") + @Field("update_time") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/youlai/boot/app/update/model/document/AppUpdateReleaseDocument.java b/src/main/java/com/youlai/boot/app/update/model/document/AppUpdateReleaseDocument.java new file mode 100644 index 00000000..28f76a3f --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/model/document/AppUpdateReleaseDocument.java @@ -0,0 +1,121 @@ +package com.youlai.boot.app.update.model.document; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 应用更新发布单文档(MongoDB) + *

+ * 发布单在创建时快照安装包信息,后续安装包记录变更不影响已创建的发布单。 + */ +@Schema(description = "应用更新发布单") +@Data +@Document(collection = "app_update_release") +public class AppUpdateReleaseDocument { + + @Schema(description = "发布单ID") + @Id + private String id; + + @Schema(description = "更新类型:GLOBAL/GRAY/SAVE_FLOW/HOT") + @Field("update_type") + private String updateType; + + @Schema(description = "渠道编码") + @Field("channel_code") + private String channelCode; + + @Schema(description = "渠道名称(发布时快照)") + @Field("channel_name") + private String channelName; + + @Schema(description = "灰度目标类型:CHANNEL=按渠道,SN=按设备序列号(仅灰度更新使用)") + @Field("target_type") + private String targetType; + + @Schema(description = "目标设备序列号列表(仅灰度更新按 SN 下发时使用)") + @Field("sn_list") + private List snList; + + @Schema(description = "应用包名") + @Field("package_name") + private String packageName; + + @Schema(description = "应用名称") + @Field("app_label") + private String appLabel; + + @Schema(description = "版本名称") + @Field("version_name") + private String versionName; + + @Schema(description = "版本号") + @Field("version_code") + private Long versionCode; + + @Schema(description = "安装包下载地址") + @Field("file_url") + private String fileUrl; + + @Schema(description = "安装包文件名") + @Field("file_name") + private String fileName; + + @Schema(description = "安装包大小(字节)") + @Field("file_size") + private Long fileSize; + + @Schema(description = "安装包大小(人类可读)") + @Field("file_size_text") + private String fileSizeText; + + @Schema(description = "安装包MD5") + @Field("md5") + private String md5; + + @Schema(description = "安装包SHA1") + @Field("sha1") + private String sha1; + + @Schema(description = "安装包SHA256") + @Field("sha256") + private String sha256; + + @Schema(description = "应用图标地址") + @Field("icon_url") + private String iconUrl; + + @Schema(description = "是否强制更新") + @Field("force_update") + private Boolean forceUpdate; + + @Schema(description = "是否上架(关闭后设备与家属端无法获取该更新)") + @Field("listed") + private Boolean listed; + + @Schema(description = "更新说明") + @Field("release_notes") + private String releaseNotes; + + @Schema(description = "状态:DRAFT/PUBLISHED/STOPPED") + @Field("status") + private String status; + + @Schema(description = "发布时间") + @Field("published_at") + private LocalDateTime publishedAt; + + @Schema(description = "创建时间") + @Field("create_time") + private LocalDateTime createTime; + + @Schema(description = "更新时间") + @Field("update_time") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/youlai/boot/app/update/model/document/UpdateChannelDocument.java b/src/main/java/com/youlai/boot/app/update/model/document/UpdateChannelDocument.java new file mode 100644 index 00000000..ca9379df --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/model/document/UpdateChannelDocument.java @@ -0,0 +1,52 @@ +package com.youlai.boot.app.update.model.document; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.LocalDateTime; + +/** + * 更新渠道文档(MongoDB) + *

+ * code 为业务唯一编码,与设备端上报的 channel 对应,供后续按渠道定向发布使用。 + */ +@Schema(description = "更新渠道") +@Data +@Document(collection = "app_update_channel") +public class UpdateChannelDocument { + + @Schema(description = "渠道ID") + @Id + private String id; + + @Schema(description = "渠道编码(唯一,与设备端上报的 channel 对应)") + @Field("code") + private String code; + + @Schema(description = "渠道名称") + @Field("name") + private String name; + + @Schema(description = "渠道描述") + @Field("description") + private String description; + + @Schema(description = "是否启用") + @Field("enabled") + private Boolean enabled; + + @Schema(description = "排序(越小越靠前)") + @Field("sort") + private Integer sort; + + @Schema(description = "创建时间") + @Field("create_time") + private LocalDateTime createTime; + + @Schema(description = "更新时间") + @Field("update_time") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/youlai/boot/app/update/model/form/AppUpdateGrayForm.java b/src/main/java/com/youlai/boot/app/update/model/form/AppUpdateGrayForm.java new file mode 100644 index 00000000..a47b5fff --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/model/form/AppUpdateGrayForm.java @@ -0,0 +1,68 @@ +package com.youlai.boot.app.update.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * 灰度更新发布单表单(新增/更新草稿) + */ +@Schema(description = "灰度更新发布单表单") +@Data +public class AppUpdateGrayForm { + + @Schema(description = "安装包访问地址(由灰度更新上传接口返回)") + private String fileUrl; + + @Schema(description = "安装包原始文件名") + private String fileName; + + @Schema(description = "安装包大小(字节)") + private Long fileSize; + + @Schema(description = "安装包大小(人类可读)") + private String fileSizeText; + + @Schema(description = "安装包MD5") + private String md5; + + @Schema(description = "安装包SHA1") + private String sha1; + + @Schema(description = "安装包SHA256") + private String sha256; + + @Schema(description = "应用图标地址") + private String iconUrl; + + @Schema(description = "目标类型:CHANNEL=按渠道,SN=按设备序列号") + private String targetType; + + @Schema(description = "渠道编码(targetType=CHANNEL 时必填)") + private String channelCode; + + @Schema(description = "目标设备序列号(targetType=SN 时必填,忽略渠道)") + private List snList; + + @Schema(description = "应用包名") + private String packageName; + + @Schema(description = "应用名称") + private String appLabel; + + @Schema(description = "版本名称") + private String versionName; + + @Schema(description = "版本号") + private Long versionCode; + + @Schema(description = "是否强制更新(仅上架后可开启)") + private Boolean forceUpdate; + + @Schema(description = "是否上架") + private Boolean listed; + + @Schema(description = "更新说明") + private String releaseNotes; +} diff --git a/src/main/java/com/youlai/boot/app/update/model/form/AppUpdatePatchForm.java b/src/main/java/com/youlai/boot/app/update/model/form/AppUpdatePatchForm.java new file mode 100644 index 00000000..a3236789 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/model/form/AppUpdatePatchForm.java @@ -0,0 +1,25 @@ +package com.youlai.boot.app.update.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * 省流更新差分补丁创建表单 + *

+ * 一个目标发布单 + 若干个源版本,提交后每个源版本生成一条补丁记录并排队差分。 + */ +@Schema(description = "省流更新差分补丁创建表单") +@Data +public class AppUpdatePatchForm { + + @Schema(description = "目标发布单ID(必须是已发布的全局更新)") + private String targetReleaseId; + + @Schema(description = "勾选的历史源版本发布单ID列表") + private List baseReleaseIds; + + @Schema(description = "补录的旧安装包列表(不在发布单里的历史包)") + private List basePackages; +} diff --git a/src/main/java/com/youlai/boot/app/update/model/form/AppUpdateReleaseForm.java b/src/main/java/com/youlai/boot/app/update/model/form/AppUpdateReleaseForm.java new file mode 100644 index 00000000..f8c57f9d --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/model/form/AppUpdateReleaseForm.java @@ -0,0 +1,60 @@ +package com.youlai.boot.app.update.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 全局更新发布单表单(新增/更新草稿) + */ +@Schema(description = "全局更新发布单表单") +@Data +public class AppUpdateReleaseForm { + + @Schema(description = "安装包访问地址(由全局更新上传接口返回)") + private String fileUrl; + + @Schema(description = "安装包原始文件名") + private String fileName; + + @Schema(description = "安装包大小(字节)") + private Long fileSize; + + @Schema(description = "安装包大小(人类可读)") + private String fileSizeText; + + @Schema(description = "安装包MD5") + private String md5; + + @Schema(description = "安装包SHA1") + private String sha1; + + @Schema(description = "安装包SHA256") + private String sha256; + + @Schema(description = "应用图标地址") + private String iconUrl; + + @Schema(description = "渠道编码") + private String channelCode; + + @Schema(description = "应用包名") + private String packageName; + + @Schema(description = "应用名称") + private String appLabel; + + @Schema(description = "版本名称") + private String versionName; + + @Schema(description = "版本号") + private Long versionCode; + + @Schema(description = "是否强制更新(仅上架后可开启)") + private Boolean forceUpdate; + + @Schema(description = "是否上架") + private Boolean listed; + + @Schema(description = "更新说明") + private String releaseNotes; +} diff --git a/src/main/java/com/youlai/boot/app/update/model/form/PatchBasePackageForm.java b/src/main/java/com/youlai/boot/app/update/model/form/PatchBasePackageForm.java new file mode 100644 index 00000000..95b95126 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/model/form/PatchBasePackageForm.java @@ -0,0 +1,38 @@ +package com.youlai.boot.app.update.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 补录的基线安装包(历史发布单之外的旧包) + *

+ * 由「上传源安装包」接口返回后原样提交,服务端按校验值回拉文件生成差分。 + */ +@Schema(description = "补录的基线安装包") +@Data +public class PatchBasePackageForm { + + @Schema(description = "安装包访问地址") + private String fileUrl; + + @Schema(description = "安装包文件名") + private String fileName; + + @Schema(description = "应用包名") + private String packageName; + + @Schema(description = "应用名称") + private String appLabel; + + @Schema(description = "版本名称") + private String versionName; + + @Schema(description = "版本号") + private Long versionCode; + + @Schema(description = "安装包MD5(回拉后校验)") + private String md5; + + @Schema(description = "安装包SHA256(回拉后校验)") + private String sha256; +} diff --git a/src/main/java/com/youlai/boot/app/update/model/form/UpdateChannelForm.java b/src/main/java/com/youlai/boot/app/update/model/form/UpdateChannelForm.java new file mode 100644 index 00000000..20064533 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/model/form/UpdateChannelForm.java @@ -0,0 +1,27 @@ +package com.youlai.boot.app.update.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 更新渠道表单(新增/更新) + */ +@Schema(description = "更新渠道表单") +@Data +public class UpdateChannelForm { + + @Schema(description = "渠道编码(唯一,与设备端上报的 channel 对应)") + private String code; + + @Schema(description = "渠道名称") + private String name; + + @Schema(description = "渠道描述") + private String description; + + @Schema(description = "是否启用") + private Boolean enabled; + + @Schema(description = "排序(越小越靠前)") + private Integer sort; +} diff --git a/src/main/java/com/youlai/boot/app/update/model/form/UpdateCheckForm.java b/src/main/java/com/youlai/boot/app/update/model/form/UpdateCheckForm.java new file mode 100644 index 00000000..9fd08c56 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/model/form/UpdateCheckForm.java @@ -0,0 +1,32 @@ +package com.youlai.boot.app.update.model.form; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 检查应用更新请求(家属端 / 设备端共用) + */ +@Schema(description = "检查应用更新请求") +@Data +public class UpdateCheckForm { + + @Schema(description = "当前应用包名") + @JsonProperty("package_name") + private String packageName; + + @Schema(description = "当前应用渠道编码") + private String channel; + + @Schema(description = "当前应用 versionCode") + @JsonProperty("version_code") + private Long versionCode; + + /** + * 已安装 APK 文件的 SHA-256(读取 {@code ApplicationInfo.sourceDir} 计算)。 + * 省流补丁按该哈希命中基线;缺省时只下发全量包。 + */ + @Schema(description = "已安装 APK 的 SHA-256,用于命中差分基线") + @JsonProperty("installed_sha256") + private String installedSha256; +} diff --git a/src/main/java/com/youlai/boot/app/update/model/query/AppUpdatePatchPageQuery.java b/src/main/java/com/youlai/boot/app/update/model/query/AppUpdatePatchPageQuery.java new file mode 100644 index 00000000..790e56b1 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/model/query/AppUpdatePatchPageQuery.java @@ -0,0 +1,21 @@ +package com.youlai.boot.app.update.model.query; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 省流更新差分补丁分页查询参数 + */ +@Schema(description = "省流更新差分补丁分页查询参数") +@Data +public class AppUpdatePatchPageQuery { + + @Schema(description = "生成状态:GENERATING/READY/FAILED") + private String status; + + @Schema(description = "目标渠道编码(快照自目标发布单)") + private String channelCode; + + @Schema(description = "关键字(应用名/包名/版本名称)") + private String keywords; +} diff --git a/src/main/java/com/youlai/boot/app/update/model/query/AppUpdateReleasePageQuery.java b/src/main/java/com/youlai/boot/app/update/model/query/AppUpdateReleasePageQuery.java new file mode 100644 index 00000000..019dc03a --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/model/query/AppUpdateReleasePageQuery.java @@ -0,0 +1,21 @@ +package com.youlai.boot.app.update.model.query; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 应用更新发布单分页查询参数 + */ +@Schema(description = "应用更新发布单分页查询参数") +@Data +public class AppUpdateReleasePageQuery { + + @Schema(description = "更新类型:GLOBAL/GRAY/SAVE_FLOW/HOT") + private String updateType; + + @Schema(description = "状态:DRAFT/PUBLISHED/STOPPED") + private String status; + + @Schema(description = "关键字(应用名/包名/版本名称)") + private String keywords; +} diff --git a/src/main/java/com/youlai/boot/app/update/model/vo/AppUpdateFileVO.java b/src/main/java/com/youlai/boot/app/update/model/vo/AppUpdateFileVO.java new file mode 100644 index 00000000..d008cfe0 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/model/vo/AppUpdateFileVO.java @@ -0,0 +1,51 @@ +package com.youlai.boot.app.update.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 全局更新安装包上传结果 + */ +@Schema(description = "全局更新安装包上传结果") +@Data +public class AppUpdateFileVO { + + @Schema(description = "安装包访问地址") + private String fileUrl; + + @Schema(description = "安装包原始文件名") + private String fileName; + + @Schema(description = "安装包大小(字节)") + private Long fileSize; + + @Schema(description = "安装包大小(人类可读)") + private String fileSizeText; + + @Schema(description = "安装包MD5") + private String md5; + + @Schema(description = "安装包SHA1") + private String sha1; + + @Schema(description = "安装包SHA256") + private String sha256; + + @Schema(description = "应用图标地址") + private String iconUrl; + + @Schema(description = "应用包名(解析失败时为空)") + private String packageName; + + @Schema(description = "应用名称(解析失败时为空)") + private String appLabel; + + @Schema(description = "版本名称(解析失败时为空)") + private String versionName; + + @Schema(description = "版本号(解析失败时为空)") + private Long versionCode; + + @Schema(description = "是否成功解析出版本信息") + private Boolean parsed; +} diff --git a/src/main/java/com/youlai/boot/app/update/model/vo/AppUpdatePatchVO.java b/src/main/java/com/youlai/boot/app/update/model/vo/AppUpdatePatchVO.java new file mode 100644 index 00000000..a02f568b --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/model/vo/AppUpdatePatchVO.java @@ -0,0 +1,131 @@ +package com.youlai.boot.app.update.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 省流更新差分补丁视图对象 + */ +@Schema(description = "省流更新差分补丁") +@Data +public class AppUpdatePatchVO { + + @Schema(description = "补丁ID") + private String id; + + @Schema(description = "目标发布单ID") + private String targetReleaseId; + + @Schema(description = "渠道编码") + private String channelCode; + + @Schema(description = "渠道名称") + private String channelName; + + @Schema(description = "应用包名") + private String packageName; + + @Schema(description = "应用名称") + private String appLabel; + + @Schema(description = "应用图标地址") + private String iconUrl; + + @Schema(description = "目标版本名称") + private String targetVersionName; + + @Schema(description = "目标版本号") + private Long targetVersionCode; + + @Schema(description = "目标安装包大小(字节)") + private Long targetFileSize; + + @Schema(description = "目标安装包大小(人类可读)") + private String targetFileSizeText; + + @Schema(description = "目标安装包SHA256") + private String targetSha256; + + @Schema(description = "源版本名称") + private String baseVersionName; + + @Schema(description = "源版本号") + private Long baseVersionCode; + + @Schema(description = "源包来源:RELEASE/UPLOAD") + private String baseSource; + + @Schema(description = "源包对应的发布单ID") + private String baseReleaseId; + + @Schema(description = "源安装包地址") + private String baseFileUrl; + + @Schema(description = "源安装包文件名") + private String baseFileName; + + @Schema(description = "源安装包SHA256") + private String baseSha256; + + @Schema(description = "差分补丁地址") + private String patchUrl; + + @Schema(description = "差分补丁文件名") + private String patchFileName; + + @Schema(description = "差分补丁大小(字节)") + private Long patchSize; + + @Schema(description = "差分补丁大小(人类可读)") + private String patchSizeText; + + @Schema(description = "补丁率(补丁大小 / 目标包大小)") + private Double patchRatio; + + @Schema(description = "差分补丁MD5") + private String md5; + + @Schema(description = "差分补丁SHA1") + private String sha1; + + @Schema(description = "差分补丁SHA256") + private String sha256; + + @Schema(description = "差分算法及版本") + private String patchAlgorithm; + + @Schema(description = "生成时的 hdiffz 分档") + private String sizeTier; + + @Schema(description = "生成状态:GENERATING/READY/FAILED") + private String status; + + @Schema(description = "失败原因") + private String errorMessage; + + @Schema(description = "生成结果建议,如建议改用全量下发") + private String advice; + + @Schema(description = "是否上架") + private Boolean listed; + + @Schema(description = "是否强制更新") + private Boolean forceUpdate; + + @Schema(description = "目标发布单状态(DRAFT/PUBLISHED/STOPPED)") + private String targetReleaseStatus; + + @Schema(description = "目标发布单是否上架") + private Boolean targetListed; + + @Schema(description = "生成完成时间") + private LocalDateTime generatedAt; + + @Schema(description = "创建时间") + private LocalDateTime createTime; + + @Schema(description = "更新时间") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/youlai/boot/app/update/model/vo/AppUpdateReleaseVO.java b/src/main/java/com/youlai/boot/app/update/model/vo/AppUpdateReleaseVO.java new file mode 100644 index 00000000..39a4fcbf --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/model/vo/AppUpdateReleaseVO.java @@ -0,0 +1,93 @@ +package com.youlai.boot.app.update.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 应用更新发布单视图对象 + */ +@Schema(description = "应用更新发布单") +@Data +public class AppUpdateReleaseVO { + + @Schema(description = "发布单ID") + private String id; + + @Schema(description = "更新类型:GLOBAL/GRAY/SAVE_FLOW/HOT") + private String updateType; + + @Schema(description = "渠道编码") + private String channelCode; + + @Schema(description = "渠道名称") + private String channelName; + + @Schema(description = "灰度目标类型:CHANNEL=按渠道,SN=按设备序列号") + private String targetType; + + @Schema(description = "目标设备数量(灰度按 SN 下发时的设备数)") + private Integer snCount; + + @Schema(description = "目标设备序列号列表(仅详情返回,避免列表携带大量 SN)") + private List snList; + + @Schema(description = "应用包名") + private String packageName; + + @Schema(description = "应用名称") + private String appLabel; + + @Schema(description = "版本名称") + private String versionName; + + @Schema(description = "版本号") + private Long versionCode; + + @Schema(description = "安装包下载地址") + private String fileUrl; + + @Schema(description = "安装包文件名") + private String fileName; + + @Schema(description = "安装包大小(字节)") + private Long fileSize; + + @Schema(description = "安装包大小(人类可读)") + private String fileSizeText; + + @Schema(description = "安装包MD5") + private String md5; + + @Schema(description = "安装包SHA1") + private String sha1; + + @Schema(description = "安装包SHA256") + private String sha256; + + @Schema(description = "应用图标地址") + private String iconUrl; + + @Schema(description = "是否强制更新") + private Boolean forceUpdate; + + @Schema(description = "是否上架") + private Boolean listed; + + @Schema(description = "更新说明") + private String releaseNotes; + + @Schema(description = "状态:DRAFT/PUBLISHED/STOPPED") + private String status; + + @Schema(description = "发布时间") + private LocalDateTime publishedAt; + + @Schema(description = "创建时间") + private LocalDateTime createTime; + + @Schema(description = "更新时间") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/youlai/boot/app/update/model/vo/UpdateChannelVO.java b/src/main/java/com/youlai/boot/app/update/model/vo/UpdateChannelVO.java new file mode 100644 index 00000000..09e91235 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/model/vo/UpdateChannelVO.java @@ -0,0 +1,38 @@ +package com.youlai.boot.app.update.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 更新渠道视图对象 + */ +@Schema(description = "更新渠道") +@Data +public class UpdateChannelVO { + + @Schema(description = "渠道ID") + private String id; + + @Schema(description = "渠道编码") + private String code; + + @Schema(description = "渠道名称") + private String name; + + @Schema(description = "渠道描述") + private String description; + + @Schema(description = "是否启用") + private Boolean enabled; + + @Schema(description = "排序") + private Integer sort; + + @Schema(description = "创建时间") + private LocalDateTime createTime; + + @Schema(description = "更新时间") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/youlai/boot/app/update/model/vo/UpdateCheckVO.java b/src/main/java/com/youlai/boot/app/update/model/vo/UpdateCheckVO.java new file mode 100644 index 00000000..7144a2de --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/model/vo/UpdateCheckVO.java @@ -0,0 +1,74 @@ +package com.youlai.boot.app.update.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 检查应用更新结果(家属端 / 设备端共用) + */ +@Schema(description = "检查应用更新结果") +@Data +public class UpdateCheckVO { + + @Schema(description = "是否存在可用更新") + private Boolean hasUpdate; + + @Schema(description = "发布单ID") + private String releaseId; + + @Schema(description = "渠道编码") + private String channelCode; + + @Schema(description = "渠道名称") + private String channelName; + + @Schema(description = "应用名称") + private String appLabel; + + @Schema(description = "应用包名") + private String packageName; + + @Schema(description = "版本名称") + private String versionName; + + @Schema(description = "版本号") + private Long versionCode; + + @Schema(description = "安装包下载地址") + private String fileUrl; + + @Schema(description = "安装包文件名") + private String fileName; + + @Schema(description = "安装包大小(字节)") + private Long fileSize; + + @Schema(description = "安装包大小(人类可读)") + private String fileSizeText; + + @Schema(description = "安装包MD5") + private String md5; + + @Schema(description = "安装包SHA1") + private String sha1; + + @Schema(description = "安装包SHA256") + private String sha256; + + @Schema(description = "应用图标地址") + private String iconUrl; + + @Schema(description = "是否强制更新") + private Boolean forceUpdate; + + @Schema(description = "更新说明") + private String releaseNotes; + + @Schema(description = "差分补丁信息(命中当前版本且补丁可用时返回,否则按全量安装包更新)") + private UpdatePatchVO patch; + + @Schema(description = "发布时间") + private LocalDateTime publishedAt; +} diff --git a/src/main/java/com/youlai/boot/app/update/model/vo/UpdatePatchVO.java b/src/main/java/com/youlai/boot/app/update/model/vo/UpdatePatchVO.java new file mode 100644 index 00000000..9486f040 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/model/vo/UpdatePatchVO.java @@ -0,0 +1,50 @@ +package com.youlai.boot.app.update.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 检查更新时下发的差分补丁信息 + *

+ * 仅当检查更新请求带上已安装包的 SHA-256 且命中补丁的 {@code baseSha256}、补丁已生成并上架时返回。 + * 设备端还原流程:校验本地安装包 {@code baseSha256} → 下载 {@code url} 并校验 + * {@code sha256} → 用 {@code algorithm} 指定的 hpatchz 还原 → 校验还原结果为 + * {@code newSha256} → 安装;任一步失败回退外层的全量安装包地址。 + */ +@Schema(description = "差分补丁信息") +@Data +public class UpdatePatchVO { + + @Schema(description = "补丁ID") + private String patchId; + + @Schema(description = "补丁下载地址") + private String url; + + @Schema(description = "补丁文件名") + private String fileName; + + @Schema(description = "补丁大小(字节)") + private Long size; + + @Schema(description = "补丁大小(人类可读)") + private String sizeText; + + @Schema(description = "补丁MD5") + private String md5; + + @Schema(description = "补丁SHA1") + private String sha1; + + @Schema(description = "补丁SHA256") + private String sha256; + + @Schema(description = "差分算法及版本,如 HDiffPatch/5.1.3") + private String algorithm; + + @Schema(description = "期望的本地安装包SHA256(还原前自校验)") + private String baseSha256; + + @Schema(description = "还原后的安装包SHA256(安装前自校验)") + private String newSha256; +} diff --git a/src/main/java/com/youlai/boot/app/update/repository/AppUpdatePatchRepository.java b/src/main/java/com/youlai/boot/app/update/repository/AppUpdatePatchRepository.java new file mode 100644 index 00000000..bb3a7fba --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/repository/AppUpdatePatchRepository.java @@ -0,0 +1,71 @@ +package com.youlai.boot.app.update.repository; + +import com.youlai.boot.app.update.model.document.AppUpdatePatchDocument; +import com.youlai.boot.app.update.model.query.AppUpdatePatchPageQuery; +import org.springframework.data.domain.Page; + +import java.util.List; +import java.util.Optional; + +/** + * 省流更新差分补丁仓储 + */ +public interface AppUpdatePatchRepository { + + /** + * 分页查询差分补丁 + */ + Page page(AppUpdatePatchPageQuery query, int pageNum, int pageSize); + + /** + * 根据ID查询 + */ + Optional findById(String id); + + /** + * 保存 + */ + AppUpdatePatchDocument save(AppUpdatePatchDocument document); + + /** + * 根据ID删除 + */ + void deleteById(String id); + + /** + * 查询目标发布单下的全部差分补丁(删除发布单时级联清理) + */ + List findByTargetReleaseId(String targetReleaseId); + + /** + * 判断目标发布单下是否已有该基线文件的补丁 + *

+ * 按源文件 SHA-256 判重:同一 versionCode 的不同构建是两个不同基线,可以各有一条补丁。 + */ + boolean existsByTargetAndBaseSha256(String targetReleaseId, String baseSha256); + + /** + * 查询目标发布单下命中设备已安装包哈希的可用补丁 + * + * @param targetReleaseId 已决定的目标发布单ID(渠道与上架范围由其决定) + * @param installedSha256 设备已安装 APK 的 SHA-256 + */ + Optional findReadyByInstalledSha256(String targetReleaseId, String installedSha256); + + /** + * 判断补丁地址是否仍被其他补丁记录引用 + */ + boolean existsByPatchUrl(String patchUrl, String excludeId); + + /** + * 判断补录基线包地址是否仍被其他补丁记录引用 + */ + boolean existsByBaseFileUrl(String baseFileUrl, String excludeId); + + /** + * 把停留在生成中的补丁标记为失败(进程重启导致任务丢失) + * + * @return 被处理的记录数 + */ + long failUnfinished(); +} diff --git a/src/main/java/com/youlai/boot/app/update/repository/AppUpdateReleaseRepository.java b/src/main/java/com/youlai/boot/app/update/repository/AppUpdateReleaseRepository.java new file mode 100644 index 00000000..000e051a --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/repository/AppUpdateReleaseRepository.java @@ -0,0 +1,104 @@ +package com.youlai.boot.app.update.repository; + +import com.youlai.boot.app.update.model.document.AppUpdateReleaseDocument; +import com.youlai.boot.app.update.model.query.AppUpdateReleasePageQuery; +import org.springframework.data.domain.Page; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +/** + * 应用更新发布单仓储 + */ +public interface AppUpdateReleaseRepository { + + /** + * 分页查询发布单 + */ + Page page(AppUpdateReleasePageQuery query, int pageNum, int pageSize); + + /** + * 根据ID查询 + */ + Optional findById(String id); + + /** + * 保存 + */ + AppUpdateReleaseDocument save(AppUpdateReleaseDocument document); + + /** + * 根据ID删除 + */ + void deleteById(String id); + + /** + * 查询指定渠道、指定应用在设备当前版本之后、已发布且已上架的最高版本发布单 + * + * @param updateType 更新类型 + * @param packageName 应用包名 + * @param channelCode 渠道编码 + * @param currentVersion 设备当前 versionCode + */ + Optional findLatestPublished(String updateType, String packageName, + String channelCode, long currentVersion); + + /** + * 停止同渠道同应用的其他已发布发布单(发布新版本即接管生效版本) + * + * @param channelCode 渠道编码 + * @param packageName 应用包名 + * @param excludeId 本次发布的发布单ID + * @return 被停止的发布单数量 + */ + long stopOthersPublished(String channelCode, String packageName, String excludeId); + + /** + * 查询命中该设备的最高可下发灰度版本:按渠道(target_type=CHANNEL)或按设备序列号(target_type=SN) + * + * @param packageName 应用包名 + * @param channelCode 设备当前渠道编码,可为 null(家属端未上报渠道时仍可命中 SN 灰度) + * @param sn 设备序列号,可为 null(家属端无设备序列号) + * @param currentVersion 设备当前 versionCode + */ + Optional findLatestGrayPublished(String packageName, String channelCode, + String sn, long currentVersion); + + /** + * 停止同应用下目标重叠的其他已发布灰度(新批次接管相同渠道或相同设备) + * + * @param packageName 应用包名 + * @param targetType 灰度目标类型:CHANNEL / SN + * @param channelCode 渠道编码(targetType=CHANNEL) + * @param snList 设备序列号列表(targetType=SN) + * @param excludeId 本次发布的发布单ID + * @return 被停止的发布单数量 + */ + long stopOthersGrayPublished(String packageName, String targetType, String channelCode, + List snList, String excludeId); + + /** + * 判断安装包地址是否仍被其他发布单引用 + * + * @param fileUrl 安装包地址 + * @param excludeId 排除的发布单ID,可为 null + */ + boolean existsByFileUrl(String fileUrl, String excludeId); + + /** + * 批量查询发布单(差分补丁列表补充目标发布单信息) + */ + List findByIds(Collection ids); + + /** + * 查询可作为差分基线的历史发布单:同包名、版本号低于目标、已发布或已停止, + * 且安装包是带校验值的单文件 APK(差分按文件哈希命中设备,与渠道无关) + * + * @param packageName 应用包名 + * @param updateType 更新类型 + * @param targetVersionCode 目标版本号,只取低于它的版本 + */ + List findBaseCandidates(String packageName, String updateType, + Long targetVersionCode); +} diff --git a/src/main/java/com/youlai/boot/app/update/repository/UpdateChannelRepository.java b/src/main/java/com/youlai/boot/app/update/repository/UpdateChannelRepository.java new file mode 100644 index 00000000..6c7a855d --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/repository/UpdateChannelRepository.java @@ -0,0 +1,37 @@ +package com.youlai.boot.app.update.repository; + +import com.youlai.boot.app.update.model.document.UpdateChannelDocument; + +import java.util.List; +import java.util.Optional; + +/** + * 更新渠道仓储 + */ +public interface UpdateChannelRepository { + + /** + * 查询全部渠道(按 sort 升序、创建时间降序) + */ + List listAll(); + + /** + * 根据编码查询(用于唯一性校验) + */ + Optional findByCode(String code); + + /** + * 根据ID查询 + */ + Optional findById(String id); + + /** + * 保存 + */ + UpdateChannelDocument save(UpdateChannelDocument document); + + /** + * 根据ID删除 + */ + void deleteById(String id); +} diff --git a/src/main/java/com/youlai/boot/app/update/repository/impl/AppUpdatePatchRepositoryImpl.java b/src/main/java/com/youlai/boot/app/update/repository/impl/AppUpdatePatchRepositoryImpl.java new file mode 100644 index 00000000..a4f6acc9 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/repository/impl/AppUpdatePatchRepositoryImpl.java @@ -0,0 +1,140 @@ +package com.youlai.boot.app.update.repository.impl; + +import com.youlai.boot.app.update.model.constant.PatchStatus; +import com.youlai.boot.app.update.model.document.AppUpdatePatchDocument; +import com.youlai.boot.app.update.model.query.AppUpdatePatchPageQuery; +import com.youlai.boot.app.update.repository.AppUpdatePatchRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * 省流更新差分补丁仓储实现 + */ +@Repository +public class AppUpdatePatchRepositoryImpl implements AppUpdatePatchRepository { + + private final MongoTemplate mongoTemplate; + + public AppUpdatePatchRepositoryImpl(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + @Override + public Page page(AppUpdatePatchPageQuery query, int pageNum, int pageSize) { + List andCriteria = new ArrayList<>(); + if (StringUtils.hasText(query.getStatus())) { + andCriteria.add(Criteria.where("status").is(query.getStatus())); + } + if (StringUtils.hasText(query.getChannelCode())) { + andCriteria.add(Criteria.where("channel_code").is(query.getChannelCode())); + } + if (StringUtils.hasText(query.getKeywords())) { + String kw = query.getKeywords().trim(); + andCriteria.add(new Criteria().orOperator( + Criteria.where("app_label").regex(kw, "i"), + Criteria.where("package_name").regex(kw, "i"), + Criteria.where("target_version_name").regex(kw, "i"), + Criteria.where("base_version_name").regex(kw, "i") + )); + } + + Criteria criteria = new Criteria(); + if (!andCriteria.isEmpty()) { + criteria.andOperator(andCriteria.toArray(new Criteria[0])); + } + + PageRequest pageRequest = PageRequest.of(pageNum - 1, pageSize); + Query pageQuery = new Query(criteria) + .with(Sort.by(Sort.Direction.DESC, "create_time")) + .with(pageRequest); + List list = mongoTemplate.find(pageQuery, AppUpdatePatchDocument.class); + long total = mongoTemplate.count(new Query(criteria), AppUpdatePatchDocument.class); + return new PageImpl<>(list, pageRequest, total); + } + + @Override + public Optional findById(String id) { + return Optional.ofNullable(mongoTemplate.findById(id, AppUpdatePatchDocument.class)); + } + + @Override + public AppUpdatePatchDocument save(AppUpdatePatchDocument document) { + return mongoTemplate.save(document); + } + + @Override + public void deleteById(String id) { + mongoTemplate.remove(new Query(Criteria.where("_id").is(id)), AppUpdatePatchDocument.class); + } + + @Override + public List findByTargetReleaseId(String targetReleaseId) { + Query query = new Query(Criteria.where("target_release_id").is(targetReleaseId)) + .with(Sort.by(Sort.Direction.ASC, "base_version_code")); + return mongoTemplate.find(query, AppUpdatePatchDocument.class); + } + + @Override + public boolean existsByTargetAndBaseSha256(String targetReleaseId, String baseSha256) { + Query query = new Query(Criteria.where("target_release_id").is(targetReleaseId) + .and("base_sha256").is(baseSha256)); + return mongoTemplate.exists(query, AppUpdatePatchDocument.class); + } + + @Override + public Optional findReadyByInstalledSha256(String targetReleaseId, + String installedSha256) { + Query query = new Query(Criteria.where("target_release_id").is(targetReleaseId) + .and("base_sha256").is(installedSha256) + .and("status").is(PatchStatus.READY) + .and("listed").is(true) + .and("patch_url").ne(null)) + .with(Sort.by(Sort.Direction.DESC, "create_time")) + .limit(1); + return Optional.ofNullable(mongoTemplate.findOne(query, AppUpdatePatchDocument.class)); + } + + @Override + public boolean existsByPatchUrl(String patchUrl, String excludeId) { + return existsReferenced("patch_url", patchUrl, excludeId); + } + + @Override + public boolean existsByBaseFileUrl(String baseFileUrl, String excludeId) { + return existsReferenced("base_file_url", baseFileUrl, excludeId); + } + + private boolean existsReferenced(String field, String value, String excludeId) { + if (!StringUtils.hasText(value)) { + return false; + } + Criteria criteria = Criteria.where(field).is(value); + if (StringUtils.hasText(excludeId)) { + criteria = criteria.and("_id").ne(excludeId); + } + return mongoTemplate.exists(new Query(criteria), AppUpdatePatchDocument.class); + } + + @Override + public long failUnfinished() { + Update update = new Update() + .set("status", PatchStatus.FAILED) + .set("error_message", "服务重启导致生成任务中断,请重新生成") + .set("update_time", LocalDateTime.now()); + return mongoTemplate.updateMulti(new Query(Criteria.where("status").is(PatchStatus.GENERATING)), + update, AppUpdatePatchDocument.class).getModifiedCount(); + } +} diff --git a/src/main/java/com/youlai/boot/app/update/repository/impl/AppUpdateReleaseRepositoryImpl.java b/src/main/java/com/youlai/boot/app/update/repository/impl/AppUpdateReleaseRepositoryImpl.java new file mode 100644 index 00000000..545c8925 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/repository/impl/AppUpdateReleaseRepositoryImpl.java @@ -0,0 +1,192 @@ +package com.youlai.boot.app.update.repository.impl; + +import com.youlai.boot.app.update.model.constant.GrayTargetType; +import com.youlai.boot.app.update.model.constant.UpdateStatus; +import com.youlai.boot.app.update.model.constant.UpdateType; +import com.youlai.boot.app.update.model.document.AppUpdateReleaseDocument; +import com.youlai.boot.app.update.model.query.AppUpdateReleasePageQuery; +import com.youlai.boot.app.update.repository.AppUpdateReleaseRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +/** + * 应用更新发布单仓储实现 + */ +@Repository +public class AppUpdateReleaseRepositoryImpl implements AppUpdateReleaseRepository { + + private final MongoTemplate mongoTemplate; + + public AppUpdateReleaseRepositoryImpl(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + @Override + public Page page(AppUpdateReleasePageQuery query, int pageNum, int pageSize) { + List andCriteria = new ArrayList<>(); + if (StringUtils.hasText(query.getUpdateType())) { + andCriteria.add(Criteria.where("update_type").is(query.getUpdateType())); + } + if (StringUtils.hasText(query.getStatus())) { + andCriteria.add(Criteria.where("status").is(query.getStatus())); + } + if (StringUtils.hasText(query.getKeywords())) { + String kw = query.getKeywords().trim(); + andCriteria.add(new Criteria().orOperator( + Criteria.where("app_label").regex(kw, "i"), + Criteria.where("package_name").regex(kw, "i"), + Criteria.where("version_name").regex(kw, "i") + )); + } + + Criteria criteria = new Criteria(); + if (!andCriteria.isEmpty()) { + criteria.andOperator(andCriteria.toArray(new Criteria[0])); + } + + PageRequest pageRequest = PageRequest.of(pageNum - 1, pageSize); + Query pageQuery = new Query(criteria) + .with(Sort.by(Sort.Direction.DESC, "create_time")) + .with(pageRequest); + List list = mongoTemplate.find(pageQuery, AppUpdateReleaseDocument.class); + long total = mongoTemplate.count(new Query(criteria), AppUpdateReleaseDocument.class); + return new PageImpl<>(list, pageRequest, total); + } + + @Override + public Optional findById(String id) { + return Optional.ofNullable(mongoTemplate.findById(id, AppUpdateReleaseDocument.class)); + } + + @Override + public AppUpdateReleaseDocument save(AppUpdateReleaseDocument document) { + return mongoTemplate.save(document); + } + + @Override + public void deleteById(String id) { + Query query = new Query(Criteria.where("_id").is(id)); + mongoTemplate.remove(query, AppUpdateReleaseDocument.class); + } + + @Override + public Optional findLatestPublished(String updateType, String packageName, + String channelCode, long currentVersion) { + Query query = new Query(Criteria.where("update_type").is(updateType) + .and("package_name").is(packageName) + .and("channel_code").is(channelCode) + .and("status").is(UpdateStatus.PUBLISHED) + .and("listed").is(true) + .and("version_code").gt(currentVersion)) + .with(Sort.by(Sort.Direction.DESC, "version_code")) + .limit(1); + return Optional.ofNullable(mongoTemplate.findOne(query, AppUpdateReleaseDocument.class)); + } + + @Override + public long stopOthersPublished(String channelCode, String packageName, String excludeId) { + Query query = new Query(Criteria.where("channel_code").is(channelCode) + .and("package_name").is(packageName) + .and("status").is(UpdateStatus.PUBLISHED) + .and("_id").ne(excludeId)); + Update update = new Update() + .set("status", UpdateStatus.STOPPED) + .set("update_time", LocalDateTime.now()); + return mongoTemplate.updateMulti(query, update, AppUpdateReleaseDocument.class).getModifiedCount(); + } + + @Override + public Optional findLatestGrayPublished(String packageName, String channelCode, + String sn, long currentVersion) { + List hitTargets = new ArrayList<>(); + if (StringUtils.hasText(channelCode)) { + hitTargets.add(new Criteria().andOperator( + Criteria.where("target_type").is(GrayTargetType.CHANNEL), + Criteria.where("channel_code").is(channelCode))); + } + if (StringUtils.hasText(sn)) { + hitTargets.add(new Criteria().andOperator( + Criteria.where("target_type").is(GrayTargetType.SN), + Criteria.where("sn_list").is(sn))); + } + if (hitTargets.isEmpty()) { + return Optional.empty(); + } + Query query = new Query(Criteria.where("update_type").is(UpdateType.GRAY) + .and("package_name").is(packageName) + .and("status").is(UpdateStatus.PUBLISHED) + .and("listed").is(true) + .and("version_code").gt(currentVersion) + .orOperator(hitTargets)) + .with(Sort.by(Sort.Direction.DESC, "version_code")) + .limit(1); + return Optional.ofNullable(mongoTemplate.findOne(query, AppUpdateReleaseDocument.class)); + } + + @Override + public long stopOthersGrayPublished(String packageName, String targetType, String channelCode, + List snList, String excludeId) { + Criteria occupied = GrayTargetType.CHANNEL.equals(targetType) + ? new Criteria().andOperator( + Criteria.where("target_type").is(GrayTargetType.CHANNEL), + Criteria.where("channel_code").is(channelCode)) + : new Criteria().andOperator( + Criteria.where("target_type").is(GrayTargetType.SN), + Criteria.where("sn_list").in(snList)); + Query query = new Query(Criteria.where("update_type").is(UpdateType.GRAY) + .and("package_name").is(packageName) + .and("status").is(UpdateStatus.PUBLISHED) + .and("_id").ne(excludeId) + .orOperator(List.of(occupied))); + Update update = new Update() + .set("status", UpdateStatus.STOPPED) + .set("update_time", LocalDateTime.now()); + return mongoTemplate.updateMulti(query, update, AppUpdateReleaseDocument.class).getModifiedCount(); + } + + @Override + public boolean existsByFileUrl(String fileUrl, String excludeId) { + Criteria criteria = Criteria.where("file_url").is(fileUrl); + if (StringUtils.hasText(excludeId)) { + criteria = criteria.and("_id").ne(excludeId); + } + return mongoTemplate.exists(new Query(criteria), AppUpdateReleaseDocument.class); + } + + @Override + public List findByIds(Collection ids) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + return mongoTemplate.find(new Query(Criteria.where("_id").in(ids)), AppUpdateReleaseDocument.class); + } + + @Override + public List findBaseCandidates(String packageName, String updateType, + Long targetVersionCode) { + Query query = new Query(Criteria.where("package_name").is(packageName) + .and("update_type").is(updateType) + .and("status").in(UpdateStatus.PUBLISHED, UpdateStatus.STOPPED) + .and("file_url").ne(null) + .and("md5").ne(null) + .and("sha256").ne(null) + .and("file_name").regex("\\.apk$", "i") + .and("version_code").lt(targetVersionCode)) + .with(Sort.by(Sort.Direction.DESC, "version_code")); + return mongoTemplate.find(query, AppUpdateReleaseDocument.class); + } +} diff --git a/src/main/java/com/youlai/boot/app/update/repository/impl/UpdateChannelRepositoryImpl.java b/src/main/java/com/youlai/boot/app/update/repository/impl/UpdateChannelRepositoryImpl.java new file mode 100644 index 00000000..0fc2e936 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/repository/impl/UpdateChannelRepositoryImpl.java @@ -0,0 +1,54 @@ +package com.youlai.boot.app.update.repository.impl; + +import com.youlai.boot.app.update.model.document.UpdateChannelDocument; +import com.youlai.boot.app.update.repository.UpdateChannelRepository; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * 更新渠道仓储实现 + */ +@Repository +public class UpdateChannelRepositoryImpl implements UpdateChannelRepository { + + private final MongoTemplate mongoTemplate; + + public UpdateChannelRepositoryImpl(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + @Override + public List listAll() { + Query query = new Query() + .with(Sort.by(Sort.Order.asc("sort"), Sort.Order.desc("create_time"))); + return mongoTemplate.find(query, UpdateChannelDocument.class); + } + + @Override + public Optional findByCode(String code) { + Query query = new Query(Criteria.where("code").is(code)); + return Optional.ofNullable(mongoTemplate.findOne(query, UpdateChannelDocument.class)); + } + + @Override + public Optional findById(String id) { + return Optional.ofNullable(mongoTemplate.findById(id, UpdateChannelDocument.class)); + } + + @Override + public UpdateChannelDocument save(UpdateChannelDocument document) { + return mongoTemplate.save(document); + } + + @Override + public void deleteById(String id) { + Query query = new Query(Criteria.where("_id").is(id)); + mongoTemplate.remove(query, UpdateChannelDocument.class); + } +} diff --git a/src/main/java/com/youlai/boot/app/update/service/AppUpdateGrayService.java b/src/main/java/com/youlai/boot/app/update/service/AppUpdateGrayService.java new file mode 100644 index 00000000..be1c7100 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/service/AppUpdateGrayService.java @@ -0,0 +1,71 @@ +package com.youlai.boot.app.update.service; + +import com.youlai.boot.app.update.model.form.AppUpdateGrayForm; +import com.youlai.boot.app.update.model.query.AppUpdateReleasePageQuery; +import com.youlai.boot.app.update.model.vo.AppUpdateFileVO; +import com.youlai.boot.app.update.model.vo.AppUpdateReleaseVO; +import org.springframework.data.domain.Page; +import org.springframework.web.multipart.MultipartFile; + +/** + * 灰度更新发布单服务 + *

+ * 灰度发布单与全局发布单同存一份记录,按 {@code update_type=GRAY} 区分; + * 灰度目标为「渠道」或「设备序列号列表」,命中设备优先下发灰度版本。 + */ +public interface AppUpdateGrayService { + + /** + * 上传灰度更新安装包(独立存储目录,不进入应用安装包库) + * + * @param file 安装包文件(apk / apks / apkm / xapk) + * @param packageName 包名补充值(解析失败时使用) + * @param versionName 版本名称补充值 + * @param versionCode 版本号补充值 + * @param appLabel 应用名称补充值 + */ + AppUpdateFileVO upload(MultipartFile file, String packageName, String versionName, + String versionCode, String appLabel); + + /** + * 分页查询灰度发布单 + */ + Page page(AppUpdateReleasePageQuery query, int pageNum, int pageSize); + + /** + * 灰度发布单详情(含目标设备序列号列表) + */ + AppUpdateReleaseVO detail(String id); + + /** + * 创建灰度更新草稿 + */ + AppUpdateReleaseVO create(AppUpdateGrayForm form); + + /** + * 更新灰度更新草稿 + */ + AppUpdateReleaseVO update(String id, AppUpdateGrayForm form); + + /** + * 删除灰度发布单(已发布需先停止) + */ + void remove(String id); + + /** + * 发布:同应用下目标重叠的已发布灰度自动停止 + */ + AppUpdateReleaseVO publish(String id); + + /** + * 停止发布 + */ + AppUpdateReleaseVO stop(String id); + + /** + * 上架 / 下架 + * + * @param listed true=上架,false=下架 + */ + AppUpdateReleaseVO updateListed(String id, boolean listed); +} diff --git a/src/main/java/com/youlai/boot/app/update/service/AppUpdatePatchService.java b/src/main/java/com/youlai/boot/app/update/service/AppUpdatePatchService.java new file mode 100644 index 00000000..6e682f62 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/service/AppUpdatePatchService.java @@ -0,0 +1,73 @@ +package com.youlai.boot.app.update.service; + +import com.youlai.boot.app.update.model.form.AppUpdatePatchForm; +import com.youlai.boot.app.update.model.query.AppUpdatePatchPageQuery; +import com.youlai.boot.app.update.model.vo.AppUpdateFileVO; +import com.youlai.boot.app.update.model.vo.AppUpdatePatchVO; +import com.youlai.boot.app.update.model.vo.AppUpdateReleaseVO; +import org.springframework.data.domain.Page; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * 省流更新差分补丁服务 + *

+ * 一条补丁记录 = 一个目标发布单 + 一个源安装包(按源文件 SHA-256 匹配设备)。提交后异步生成差分, + * 生成结果(补丁地址、大小、校验值、状态)写回该记录。 + */ +public interface AppUpdatePatchService { + + /** + * 上传补录的旧安装包(不在发布单里的历史包) + *

+ * 仅支持单文件 .apk,且必须解析出包名与版本号。 + */ + AppUpdateFileVO uploadBasePackage(MultipartFile file); + + /** + * 查询目标发布单可勾选的历史源版本:同包名、版本号低于目标、已发布或已停止的单文件 APK, + * 与渠道无关(补丁下发范围由目标发布单决定) + */ + List baseCandidates(String targetReleaseId); + + /** + * 提交差分生成任务,每个源版本生成一条补丁记录 + */ + List create(AppUpdatePatchForm form); + + /** + * 分页查询差分补丁 + */ + Page page(AppUpdatePatchPageQuery query, int pageNum, int pageSize); + + /** + * 补丁详情 + */ + AppUpdatePatchVO detail(String id); + + /** + * 上下架补丁;下架同时关闭强制更新 + */ + AppUpdatePatchVO updateListed(String id, boolean listed); + + /** + * 设置强制更新;仅上架的补丁可开启 + */ + AppUpdatePatchVO updateForceUpdate(String id, boolean forceUpdate); + + /** + * 重新生成失败的补丁 + */ + AppUpdatePatchVO regenerate(String id); + + /** + * 删除补丁,未被引用的差分文件一并清理 + */ + void remove(String id); + + /** + * 目标发布单删除时级联清理其全部补丁 + */ + void removeByTargetRelease(String targetReleaseId); +} diff --git a/src/main/java/com/youlai/boot/app/update/service/AppUpdateReleaseService.java b/src/main/java/com/youlai/boot/app/update/service/AppUpdateReleaseService.java new file mode 100644 index 00000000..ee04e5db --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/service/AppUpdateReleaseService.java @@ -0,0 +1,83 @@ +package com.youlai.boot.app.update.service; + +import com.youlai.boot.app.update.model.form.AppUpdateReleaseForm; +import com.youlai.boot.app.update.model.query.AppUpdateReleasePageQuery; +import com.youlai.boot.app.update.model.vo.AppUpdateFileVO; +import com.youlai.boot.app.update.model.vo.AppUpdateReleaseVO; +import com.youlai.boot.app.update.model.vo.UpdateCheckVO; +import org.springframework.data.domain.Page; +import org.springframework.web.multipart.MultipartFile; + +/** + * 应用更新发布单服务 + */ +public interface AppUpdateReleaseService { + + /** + * 上传全局更新安装包(独立存储目录,不进入应用安装包库) + * + * @param file 安装包文件(apk / apks / apkm / xapk) + * @param packageName 包名补充值(解析失败时使用) + * @param versionName 版本名称补充值 + * @param versionCode 版本号补充值 + * @param appLabel 应用名称补充值 + */ + AppUpdateFileVO upload(MultipartFile file, String packageName, String versionName, + String versionCode, String appLabel); + + /** + * 分页查询发布单 + */ + Page page(AppUpdateReleasePageQuery query, int pageNum, int pageSize); + + /** + * 发布单详情 + */ + AppUpdateReleaseVO detail(String id); + + /** + * 创建全局更新草稿 + */ + AppUpdateReleaseVO create(AppUpdateReleaseForm form); + + /** + * 更新全局更新草稿 + */ + AppUpdateReleaseVO update(String id, AppUpdateReleaseForm form); + + /** + * 删除发布单(已发布需先停止) + */ + void remove(String id); + + /** + * 发布 + */ + AppUpdateReleaseVO publish(String id); + + /** + * 停止发布 + */ + AppUpdateReleaseVO stop(String id); + + /** + * 上架 / 下架 + * + * @param listed true=上架,false=下架 + */ + AppUpdateReleaseVO updateListed(String id, boolean listed); + + /** + * 检查更新(家属端 / 设备端共用) + *

+ * 先按「渠道或设备 SN」匹配灰度更新,再匹配全局更新,取版本更高的一侧下发。 + * + * @param packageName 当前应用包名 + * @param channelCode 当前应用渠道编码 + * @param sn 设备序列号(设备端传入,用于命中按 SN 下发的灰度;家属端为 null) + * @param currentVersion 当前 versionCode + * @param installedSha256 已安装 APK 的 SHA-256,命中差分基线时随全量包一起下发补丁 + */ + UpdateCheckVO checkUpdate(String packageName, String channelCode, String sn, + Long currentVersion, String installedSha256); +} diff --git a/src/main/java/com/youlai/boot/app/update/service/UpdateChannelService.java b/src/main/java/com/youlai/boot/app/update/service/UpdateChannelService.java new file mode 100644 index 00000000..695466ef --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/service/UpdateChannelService.java @@ -0,0 +1,32 @@ +package com.youlai.boot.app.update.service; + +import com.youlai.boot.app.update.model.form.UpdateChannelForm; +import com.youlai.boot.app.update.model.vo.UpdateChannelVO; + +import java.util.List; + +/** + * 更新渠道服务 + */ +public interface UpdateChannelService { + + /** + * 渠道列表(按排序返回) + */ + List listChannels(); + + /** + * 新增渠道 + */ + UpdateChannelVO create(UpdateChannelForm form); + + /** + * 更新渠道 + */ + UpdateChannelVO update(String id, UpdateChannelForm form); + + /** + * 删除渠道 + */ + void remove(String id); +} diff --git a/src/main/java/com/youlai/boot/app/update/service/impl/AppUpdateGrayServiceImpl.java b/src/main/java/com/youlai/boot/app/update/service/impl/AppUpdateGrayServiceImpl.java new file mode 100644 index 00000000..d708d81a --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/service/impl/AppUpdateGrayServiceImpl.java @@ -0,0 +1,336 @@ +package com.youlai.boot.app.update.service.impl; + +import com.youlai.boot.app.update.component.UpdatePackageUploader; +import com.youlai.boot.app.update.model.constant.GrayTargetType; +import com.youlai.boot.app.update.model.constant.UpdateStatus; +import com.youlai.boot.app.update.model.constant.UpdateType; +import com.youlai.boot.app.update.model.document.AppUpdateReleaseDocument; +import com.youlai.boot.app.update.model.document.UpdateChannelDocument; +import com.youlai.boot.app.update.model.form.AppUpdateGrayForm; +import com.youlai.boot.app.update.model.query.AppUpdateReleasePageQuery; +import com.youlai.boot.app.update.model.vo.AppUpdateFileVO; +import com.youlai.boot.app.update.model.vo.AppUpdateReleaseVO; +import com.youlai.boot.app.update.repository.AppUpdateReleaseRepository; +import com.youlai.boot.app.update.repository.UpdateChannelRepository; +import com.youlai.boot.app.update.service.AppUpdateGrayService; +import com.youlai.boot.common.config.StorageDir; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.support.file.service.FileService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +/** + * 灰度更新发布单服务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AppUpdateGrayServiceImpl implements AppUpdateGrayService { + + /** 单次灰度最多覆盖的设备数 */ + private static final int MAX_SN_COUNT = 5000; + + /** 单个设备序列号最大长度 */ + private static final int MAX_SN_LENGTH = 64; + + private final AppUpdateReleaseRepository releaseRepository; + private final UpdateChannelRepository channelRepository; + + /** 文件存储服务:由 storage.type 决定激活 s3 / aliyun / local 实现 */ + private final FileService fileService; + private final UpdatePackageUploader packageUploader; + + @Override + public AppUpdateFileVO upload(MultipartFile file, String packageName, String versionName, + String versionCode, String appLabel) { + return packageUploader.store(file, StorageDir.UPDATE_GRAY_PACKAGE, StorageDir.UPDATE_GRAY_ICON, + packageName, versionName, versionCode, appLabel); + } + + @Override + public Page page(AppUpdateReleasePageQuery query, int pageNum, int pageSize) { + query.setUpdateType(UpdateType.GRAY); + Page page = releaseRepository.page(query, pageNum, pageSize); + return page.map(doc -> toVO(doc, false)); + } + + @Override + public AppUpdateReleaseVO detail(String id) { + return toVO(getDocument(id), true); + } + + @Override + public AppUpdateReleaseVO create(AppUpdateGrayForm form) { + validateForm(form); + + AppUpdateReleaseDocument doc = new AppUpdateReleaseDocument(); + doc.setId(UUID.randomUUID().toString()); + doc.setUpdateType(UpdateType.GRAY); + doc.setStatus(UpdateStatus.DRAFT); + doc.setCreateTime(LocalDateTime.now()); + doc.setUpdateTime(LocalDateTime.now()); + applyForm(doc, form); + return toVO(releaseRepository.save(doc), true); + } + + @Override + public AppUpdateReleaseVO update(String id, AppUpdateGrayForm form) { + validateForm(form); + AppUpdateReleaseDocument doc = getDocument(id); + if (!UpdateStatus.DRAFT.equals(doc.getStatus())) { + throw new BusinessException("仅草稿状态的发布单可编辑"); + } + + String oldFileUrl = doc.getFileUrl(); + String oldIconUrl = doc.getIconUrl(); + applyForm(doc, form); + doc.setUpdateTime(LocalDateTime.now()); + AppUpdateReleaseDocument saved = releaseRepository.save(doc); + + if (StringUtils.hasText(oldFileUrl) && !Objects.equals(oldFileUrl, saved.getFileUrl()) + && !releaseRepository.existsByFileUrl(oldFileUrl, saved.getId())) { + deleteRemoteQuietly(oldFileUrl, "安装包"); + } + if (StringUtils.hasText(oldIconUrl) && !Objects.equals(oldIconUrl, saved.getIconUrl())) { + deleteRemoteQuietly(oldIconUrl, "图标"); + } + return toVO(saved, true); + } + + @Override + public void remove(String id) { + AppUpdateReleaseDocument doc = getDocument(id); + if (UpdateStatus.PUBLISHED.equals(doc.getStatus())) { + throw new BusinessException("已发布的更新需先停止后再删除"); + } + releaseRepository.deleteById(id); + if (StringUtils.hasText(doc.getFileUrl()) && !releaseRepository.existsByFileUrl(doc.getFileUrl(), id)) { + deleteRemoteQuietly(doc.getFileUrl(), "安装包"); + } + if (StringUtils.hasText(doc.getIconUrl())) { + deleteRemoteQuietly(doc.getIconUrl(), "图标"); + } + } + + @Override + public AppUpdateReleaseVO publish(String id) { + AppUpdateReleaseDocument doc = getDocument(id); + if (UpdateStatus.PUBLISHED.equals(doc.getStatus())) { + throw new BusinessException("该发布单已发布"); + } + validateReleaseContent(doc); + if (GrayTargetType.CHANNEL.equals(doc.getTargetType())) { + UpdateChannelDocument channel = getChannel(doc.getChannelCode()); + if (!isEnabled(channel)) { + throw new BusinessException("渠道已停用,无法发布"); + } + doc.setChannelName(channel.getName()); + } + doc.setStatus(UpdateStatus.PUBLISHED); + doc.setPublishedAt(LocalDateTime.now()); + doc.setUpdateTime(LocalDateTime.now()); + // 同应用下目标重叠(同渠道或同设备)的灰度由本次发布接管 + long superseded = releaseRepository.stopOthersGrayPublished(doc.getPackageName(), doc.getTargetType(), + doc.getChannelCode(), doc.getSnList(), doc.getId()); + if (superseded > 0) { + log.info("{} 灰度发布 {},自动停止 {} 个目标重叠的灰度版本", + doc.getPackageName(), doc.getVersionName(), superseded); + } + return toVO(releaseRepository.save(doc), true); + } + + @Override + public AppUpdateReleaseVO stop(String id) { + AppUpdateReleaseDocument doc = getDocument(id); + if (!UpdateStatus.PUBLISHED.equals(doc.getStatus())) { + throw new BusinessException("仅已发布的更新可停止"); + } + doc.setStatus(UpdateStatus.STOPPED); + doc.setUpdateTime(LocalDateTime.now()); + return toVO(releaseRepository.save(doc), true); + } + + @Override + public AppUpdateReleaseVO updateListed(String id, boolean listed) { + AppUpdateReleaseDocument doc = getDocument(id); + if (listed && GrayTargetType.CHANNEL.equals(doc.getTargetType()) + && !isEnabled(getChannel(doc.getChannelCode()))) { + throw new BusinessException("渠道已停用,无法上架"); + } + doc.setListed(listed); + if (!listed) { + doc.setForceUpdate(false); + } + doc.setUpdateTime(LocalDateTime.now()); + return toVO(releaseRepository.save(doc), true); + } + + private AppUpdateReleaseDocument getDocument(String id) { + AppUpdateReleaseDocument doc = releaseRepository.findById(id) + .orElseThrow(() -> new BusinessException("发布单不存在")); + if (!UpdateType.GRAY.equals(doc.getUpdateType())) { + throw new BusinessException("不是灰度更新发布单"); + } + return doc; + } + + private void validateForm(AppUpdateGrayForm form) { + if (form == null) { + throw new BusinessException("发布单信息不能为空"); + } + if (!StringUtils.hasText(form.getFileUrl())) { + throw new BusinessException("请先上传安装包"); + } + if (!StringUtils.hasText(form.getFileName())) { + throw new BusinessException("安装包文件名不能为空"); + } + if (!StringUtils.hasText(form.getPackageName())) { + throw new BusinessException("应用包名不能为空"); + } + if (!StringUtils.hasText(form.getVersionName())) { + throw new BusinessException("版本名称不能为空"); + } + if (form.getVersionCode() == null || form.getVersionCode() <= 0) { + throw new BusinessException("版本号必须为大于 0 的整数"); + } + if (!GrayTargetType.CHANNEL.equals(form.getTargetType()) && !GrayTargetType.SN.equals(form.getTargetType())) { + throw new BusinessException("请选择灰度目标:按渠道或按设备 SN"); + } + if (GrayTargetType.CHANNEL.equals(form.getTargetType()) && !StringUtils.hasText(form.getChannelCode())) { + throw new BusinessException("请选择渠道"); + } + if (Boolean.TRUE.equals(form.getForceUpdate()) && !Boolean.TRUE.equals(form.getListed())) { + throw new BusinessException("未上架的更新不能开启强制更新"); + } + if (form.getReleaseNotes() != null && form.getReleaseNotes().length() > 2000) { + throw new BusinessException("更新说明不能超过 2000 个字符"); + } + } + + private void validateReleaseContent(AppUpdateReleaseDocument doc) { + if (!StringUtils.hasText(doc.getFileUrl())) { + throw new BusinessException("发布单缺少安装包下载地址,无法发布"); + } + if (!StringUtils.hasText(doc.getPackageName())) { + throw new BusinessException("发布单缺少应用包名,无法发布"); + } + if (doc.getVersionCode() == null || doc.getVersionCode() <= 0) { + throw new BusinessException("发布单缺少有效的版本号,无法发布"); + } + if (GrayTargetType.CHANNEL.equals(doc.getTargetType()) && !StringUtils.hasText(doc.getChannelCode())) { + throw new BusinessException("发布单缺少渠道,无法发布"); + } + if (GrayTargetType.SN.equals(doc.getTargetType()) + && (doc.getSnList() == null || doc.getSnList().isEmpty())) { + throw new BusinessException("发布单缺少目标设备,无法发布"); + } + } + + private void applyForm(AppUpdateReleaseDocument doc, AppUpdateGrayForm form) { + doc.setFileUrl(form.getFileUrl()); + doc.setFileName(form.getFileName()); + doc.setFileSize(form.getFileSize()); + doc.setFileSizeText(form.getFileSizeText()); + doc.setMd5(form.getMd5()); + doc.setSha1(form.getSha1()); + doc.setSha256(form.getSha256()); + doc.setIconUrl(form.getIconUrl()); + doc.setPackageName(form.getPackageName().trim()); + doc.setAppLabel(firstNonBlank(form.getAppLabel(), form.getPackageName().trim())); + doc.setVersionName(form.getVersionName().trim()); + doc.setVersionCode(form.getVersionCode()); + boolean listed = Boolean.TRUE.equals(form.getListed()); + doc.setListed(listed); + doc.setForceUpdate(listed && Boolean.TRUE.equals(form.getForceUpdate())); + doc.setReleaseNotes(form.getReleaseNotes()); + doc.setTargetType(form.getTargetType()); + if (GrayTargetType.CHANNEL.equals(form.getTargetType())) { + UpdateChannelDocument channel = getChannel(form.getChannelCode()); + doc.setChannelCode(channel.getCode()); + doc.setChannelName(channel.getName()); + doc.setSnList(null); + } else { + doc.setChannelCode(null); + doc.setChannelName(null); + doc.setSnList(normalizeSnList(form.getSnList())); + } + } + + /** + * 设备序列号去空、去重并校验数量与长度 + */ + private List normalizeSnList(List snList) { + if (snList == null || snList.isEmpty()) { + throw new BusinessException("请填写目标设备 SN"); + } + LinkedHashSet unique = new LinkedHashSet<>(); + for (String sn : snList) { + if (!StringUtils.hasText(sn)) { + continue; + } + String value = sn.trim(); + if (value.length() > MAX_SN_LENGTH) { + throw new BusinessException("设备 SN 不能超过 " + MAX_SN_LENGTH + " 个字符:" + value); + } + unique.add(value); + } + if (unique.isEmpty()) { + throw new BusinessException("请填写目标设备 SN"); + } + if (unique.size() > MAX_SN_COUNT) { + throw new BusinessException("单次灰度最多 " + MAX_SN_COUNT + " 台设备,当前 " + unique.size() + " 台"); + } + return new ArrayList<>(unique); + } + + private UpdateChannelDocument getChannel(String channelCode) { + if (!StringUtils.hasText(channelCode)) { + throw new BusinessException("发布单未设置渠道"); + } + return channelRepository.findByCode(channelCode.trim()) + .orElseThrow(() -> new BusinessException("渠道不存在:" + channelCode)); + } + + private boolean isEnabled(UpdateChannelDocument channel) { + return channel.getEnabled() == null || channel.getEnabled(); + } + + private void deleteRemoteQuietly(String url, String scene) { + try { + fileService.deleteFile(url); + } catch (Exception e) { + log.warn("删除灰度更新{}失败:{}", scene, url, e); + } + } + + private String firstNonBlank(String... values) { + for (String value : values) { + if (StringUtils.hasText(value)) { + return value.trim(); + } + } + return null; + } + + private AppUpdateReleaseVO toVO(AppUpdateReleaseDocument doc, boolean withSnList) { + AppUpdateReleaseVO vo = new AppUpdateReleaseVO(); + BeanUtils.copyProperties(doc, vo); + if (!withSnList) { + vo.setSnList(null); + } + vo.setSnCount(doc.getSnList() == null ? null : doc.getSnList().size()); + return vo; + } +} diff --git a/src/main/java/com/youlai/boot/app/update/service/impl/AppUpdatePatchServiceImpl.java b/src/main/java/com/youlai/boot/app/update/service/impl/AppUpdatePatchServiceImpl.java new file mode 100644 index 00000000..f4a76a88 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/service/impl/AppUpdatePatchServiceImpl.java @@ -0,0 +1,612 @@ +package com.youlai.boot.app.update.service.impl; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.crypto.digest.DigestUtil; +import com.youlai.boot.app.packages.component.FileMultipartFile; +import com.youlai.boot.app.update.component.ApkDeltaUpdater; +import com.youlai.boot.app.update.component.RemotePackageDownloader; +import com.youlai.boot.app.update.component.UpdatePackageUploader; +import com.youlai.boot.app.update.model.constant.PatchBaseSource; +import com.youlai.boot.app.update.model.constant.PatchStatus; +import com.youlai.boot.app.update.model.constant.UpdateStatus; +import com.youlai.boot.app.update.model.constant.UpdateType; +import com.youlai.boot.app.update.model.document.AppUpdatePatchDocument; +import com.youlai.boot.app.update.model.document.AppUpdateReleaseDocument; +import com.youlai.boot.app.update.model.document.UpdateChannelDocument; +import com.youlai.boot.app.update.model.form.AppUpdatePatchForm; +import com.youlai.boot.app.update.model.form.PatchBasePackageForm; +import com.youlai.boot.app.update.model.query.AppUpdatePatchPageQuery; +import com.youlai.boot.app.update.model.vo.AppUpdateFileVO; +import com.youlai.boot.app.update.model.vo.AppUpdatePatchVO; +import com.youlai.boot.app.update.model.vo.AppUpdateReleaseVO; +import com.youlai.boot.app.update.repository.AppUpdatePatchRepository; +import com.youlai.boot.app.update.repository.AppUpdateReleaseRepository; +import com.youlai.boot.app.update.repository.UpdateChannelRepository; +import com.youlai.boot.app.update.service.AppUpdatePatchService; +import com.youlai.boot.common.config.StorageDir; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.support.file.model.FileInfo; +import com.youlai.boot.support.file.service.FileService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.nio.file.Files; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 省流更新差分补丁服务实现 + *

+ * 安装包只存对象存储地址、服务端不留副本,因此差分需要把源包与目标包都回拉到本地: + * 按 MD5 校验回拉文件 → hdiffz 生成差分 → 计算校验值后上传补丁;补丁率超阈值时只记一条建议并默认下架, + * 不改变真实的生成状态。生成任务由单线程队列串行执行,避免多个百 MB 级任务同时占内存。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AppUpdatePatchServiceImpl implements AppUpdatePatchService { + + /** 差分算法标识,设备端据此选择还原方式 */ + private static final String PATCH_ALGORITHM = "HDiffPatch/" + ApkDeltaUpdater.HDIFFPATCH_VERSION; + + private static final String PATCH_EXTENSION = ".patch"; + + /** 差分只支持单文件 APK */ + private static final String APK_EXTENSION = ".apk"; + + /** 补丁文件名里带的基线哈希长度,用于区分同一 versionCode 的不同构建 */ + private static final int BASE_FILE_NAME_HASH_LENGTH = 8; + + /** 失败原因入库长度上限 */ + private static final int ERROR_MESSAGE_MAX_LENGTH = 500; + + private final AppUpdatePatchRepository patchRepository; + private final AppUpdateReleaseRepository releaseRepository; + private final UpdateChannelRepository channelRepository; + private final FileService fileService; + private final UpdatePackageUploader packageUploader; + private final RemotePackageDownloader packageDownloader; + + /** 差分生成队列:单线程串行,进程内不持久化排队状态 */ + private ExecutorService generator; + + @PostConstruct + void startGenerator() { + long interrupted = patchRepository.failUnfinished(); + if (interrupted > 0) { + log.warn("上次进程退出时有 {} 个差分补丁未生成完成,已标记为失败", interrupted); + } + generator = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "update-patch-generator"); + thread.setDaemon(true); + return thread; + }); + } + + @PreDestroy + void stopGenerator() { + if (generator == null) { + return; + } + generator.shutdown(); + try { + // 给正在执行的 hdiffz 留出收尾时间,超时的任务由下次启动的 failUnfinished 处理 + if (!generator.awaitTermination(10, TimeUnit.SECONDS)) { + generator.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + generator.shutdownNow(); + } + } + + @Override + public AppUpdateFileVO uploadBasePackage(MultipartFile file) { + requireSingleApkFile(file.getOriginalFilename(), "补录的源安装包"); + AppUpdateFileVO vo = packageUploader.store(file, StorageDir.UPDATE_BASE_PACKAGE, null, + null, null, null, null); + // 源包靠文件哈希匹配设备,解析不出来就无法差分 + if (!Boolean.TRUE.equals(vo.getParsed())) { + deleteRemoteQuietly(vo.getFileUrl()); + throw new BusinessException("未能从安装包解析出应用包名与版本号,无法作为差分源包"); + } + return vo; + } + + @Override + public List baseCandidates(String targetReleaseId) { + AppUpdateReleaseDocument target = requireTarget(targetReleaseId); + return releaseRepository + .findBaseCandidates(target.getPackageName(), UpdateType.GLOBAL, target.getVersionCode()) + .stream() + .map(release -> { + AppUpdateReleaseVO vo = new AppUpdateReleaseVO(); + BeanUtils.copyProperties(release, vo); + return vo; + }) + .toList(); + } + + @Override + public List create(AppUpdatePatchForm form) { + if (form == null) { + throw new BusinessException("差分补丁信息不能为空"); + } + AppUpdateReleaseDocument target = requireTarget(form.getTargetReleaseId()); + requirePublished(target); + + Map pending = new LinkedHashMap<>(); + for (String baseReleaseId : distinctIds(form.getBaseReleaseIds())) { + AppUpdateReleaseDocument base = releaseRepository.findById(baseReleaseId) + .orElseThrow(() -> new BusinessException("源版本发布单不存在:" + baseReleaseId)); + requireBaseOfTarget(base, target); + AppUpdatePatchDocument patch = newPatch(target, base.getVersionName(), base.getVersionCode(), + PatchBaseSource.RELEASE, base.getId(), base.getFileUrl(), base.getFileName(), + base.getMd5(), base.getSha256()); + if (pending.putIfAbsent(patch.getBaseSha256(), patch) != null) { + throw new BusinessException("源版本 " + base.getVersionName() + " 重复提交"); + } + } + + List basePackages = form.getBasePackages() == null + ? List.of() : form.getBasePackages(); + for (PatchBasePackageForm basePackage : basePackages) { + requireBasePackageForm(basePackage, target); + AppUpdatePatchDocument patch = newPatch(target, basePackage.getVersionName(), + basePackage.getVersionCode(), PatchBaseSource.UPLOAD, null, basePackage.getFileUrl(), + basePackage.getFileName(), basePackage.getMd5(), basePackage.getSha256()); + if (pending.putIfAbsent(patch.getBaseSha256(), patch) != null) { + throw new BusinessException("源安装包 " + basePackage.getFileName() + " 重复提交"); + } + } + + if (pending.isEmpty()) { + throw new BusinessException("请至少选择一个源版本或补录一个旧安装包"); + } + for (AppUpdatePatchDocument patch : pending.values()) { + if (patchRepository.existsByTargetAndBaseSha256(patch.getTargetReleaseId(), patch.getBaseSha256())) { + throw new BusinessException("目标版本 " + target.getVersionName() + " 已存在源版本 " + + patch.getBaseVersionName() + " 的差分补丁,如需重做请先删除"); + } + } + + List created = new ArrayList<>(pending.size()); + for (AppUpdatePatchDocument patch : pending.values()) { + AppUpdatePatchDocument saved = patchRepository.save(patch); + created.add(toVO(saved, target)); + submitGeneration(saved.getId()); + } + return created; + } + + @Override + public Page page(AppUpdatePatchPageQuery query, int pageNum, int pageSize) { + Page page = patchRepository.page(query, pageNum, pageSize); + Set targetIds = page.getContent().stream() + .map(AppUpdatePatchDocument::getTargetReleaseId) + .filter(StringUtils::hasText) + .collect(Collectors.toSet()); + Map targets = releaseRepository.findByIds(targetIds).stream() + .collect(Collectors.toMap(AppUpdateReleaseDocument::getId, release -> release, (a, b) -> a)); + return page.map(patch -> toVO(patch, targets.get(patch.getTargetReleaseId()))); + } + + @Override + public AppUpdatePatchVO detail(String id) { + AppUpdatePatchDocument patch = getDocument(id); + return toVO(patch, releaseRepository.findById(patch.getTargetReleaseId()).orElse(null)); + } + + @Override + public AppUpdatePatchVO updateListed(String id, boolean listed) { + AppUpdatePatchDocument patch = getDocument(id); + if (listed) { + requireDeliverable(patch); + } + patch.setListed(listed); + if (!listed) { + patch.setForceUpdate(false); + } + patch.setUpdateTime(LocalDateTime.now()); + AppUpdatePatchDocument saved = patchRepository.save(patch); + return toVO(saved, releaseRepository.findById(saved.getTargetReleaseId()).orElse(null)); + } + + @Override + public AppUpdatePatchVO updateForceUpdate(String id, boolean forceUpdate) { + AppUpdatePatchDocument patch = getDocument(id); + if (forceUpdate) { + if (!Boolean.TRUE.equals(patch.getListed())) { + throw new BusinessException("未上架的差分补丁不能开启强制更新"); + } + requireDeliverable(patch); + } + patch.setForceUpdate(forceUpdate); + patch.setUpdateTime(LocalDateTime.now()); + AppUpdatePatchDocument saved = patchRepository.save(patch); + return toVO(saved, releaseRepository.findById(saved.getTargetReleaseId()).orElse(null)); + } + + @Override + public AppUpdatePatchVO regenerate(String id) { + AppUpdatePatchDocument patch = getDocument(id); + if (PatchStatus.GENERATING.equals(patch.getStatus())) { + throw new BusinessException("该差分补丁正在生成中"); + } + requirePublished(releaseRepository.findById(patch.getTargetReleaseId()) + .orElseThrow(() -> new BusinessException("目标发布单不存在,无法重新生成"))); + patch.setStatus(PatchStatus.GENERATING); + patch.setErrorMessage(null); + patch.setAdvice(null); + patch.setUpdateTime(LocalDateTime.now()); + AppUpdatePatchDocument saved = patchRepository.save(patch); + submitGeneration(saved.getId()); + return toVO(saved, releaseRepository.findById(saved.getTargetReleaseId()).orElse(null)); + } + + @Override + public void remove(String id) { + AppUpdatePatchDocument patch = getDocument(id); + patchRepository.deleteById(id); + deletePatchFileQuietly(patch.getPatchUrl(), id); + deleteBaseFileQuietly(patch, id); + } + + @Override + public void removeByTargetRelease(String targetReleaseId) { + List patches = patchRepository.findByTargetReleaseId(targetReleaseId); + if (patches.isEmpty()) { + return; + } + for (AppUpdatePatchDocument patch : patches) { + patchRepository.deleteById(patch.getId()); + } + for (AppUpdatePatchDocument patch : patches) { + deletePatchFileQuietly(patch.getPatchUrl(), null); + deleteBaseFileQuietly(patch, null); + } + } + + // ========================================================== + // 差分生成 + // ========================================================== + + private void submitGeneration(String patchId) { + generator.submit(() -> generate(patchId)); + } + + private void generate(String patchId) { + AppUpdatePatchDocument patch = patchRepository.findById(patchId).orElse(null); + if (patch == null) { + return; + } + String oldPatchUrl = patch.getPatchUrl(); + File workDir = null; + try { + workDir = Files.createTempDirectory("ttstd-update-patch-").toFile(); + File baseFile = packageDownloader.download(patch.getBaseFileUrl(), + new File(workDir, "base.apk"), "差分源包"); + verifyMd5(baseFile, patch.getBaseMd5(), "差分源包"); + File targetFile = packageDownloader.download(patch.getTargetFileUrl(), + new File(workDir, "target.apk"), "差分目标包"); + verifyMd5(targetFile, patch.getTargetMd5(), "差分目标包"); + + ApkDeltaUpdater.DeltaPlan plan = ApkDeltaUpdater.DeltaPlan.forFiles(baseFile, targetFile); + File patchFile = new File(workDir, patch.getPatchFileName()); + ApkDeltaUpdater.createPatch(baseFile, targetFile, patchFile, plan); + + double ratio = ApkDeltaUpdater.patchRatio(targetFile, patchFile); + + FileInfo stored = fileService.uploadFile( + new FileMultipartFile("file", patch.getPatchFileName(), "application/octet-stream", patchFile), + StorageDir.UPDATE_PATCH); + if (stored == null || !StringUtils.hasText(stored.getUrl())) { + saveOutcome(patchId, oldPatchUrl, PatchOutcome.failed("差分补丁上传失败")); + return; + } + + // 补丁率超阈值不代表生成失败:差分确实做出来了,只是下发全量更省流量,作为建议给出并默认下架 + String advice = ratio > ApkDeltaUpdater.FULL_PACKAGE_THRESHOLD ? String.format( + "补丁率 %.1f%% 高于 %.0f%%,比全量下发更费流量,建议改用全量更新", + ratio * 100, ApkDeltaUpdater.FULL_PACKAGE_THRESHOLD * 100) : null; + saveOutcome(patchId, oldPatchUrl, PatchOutcome.ready(stored.getUrl(), patchFile.length(), ratio, + DigestUtil.md5Hex(patchFile), DigestUtil.sha1Hex(patchFile), DigestUtil.sha256Hex(patchFile), + plan.tier().name(), advice)); + } catch (Exception e) { + log.error("差分补丁 {} 生成失败", patchId, e); + saveOutcome(patchId, oldPatchUrl, PatchOutcome.failed(e.getMessage())); + } finally { + if (workDir != null) { + FileUtil.del(workDir); + } + } + } + + /** + * 回写生成结果 + *

+ * 只覆盖差分相关字段,运营在生成期间改动的上下架与强制更新以最新记录为准; + * 生成失败、或带建议(不划算)时一律下架,避免误下发。 + */ + private void saveOutcome(String patchId, String oldPatchUrl, PatchOutcome outcome) { + AppUpdatePatchDocument latest = patchRepository.findById(patchId).orElse(null); + if (latest == null) { + log.info("差分补丁 {} 已删除,丢弃本次生成结果", patchId); + return; + } + latest.setStatus(outcome.ready() ? PatchStatus.READY : PatchStatus.FAILED); + latest.setPatchUrl(outcome.patchUrl()); + latest.setPatchSize(outcome.patchSize()); + latest.setPatchSizeText(outcome.patchSize() == null + ? null : FileUtil.readableFileSize(outcome.patchSize())); + latest.setPatchRatio(outcome.patchRatio()); + latest.setMd5(outcome.md5()); + latest.setSha1(outcome.sha1()); + latest.setSha256(outcome.sha256()); + latest.setSizeTier(outcome.sizeTier()); + latest.setGeneratedAt(outcome.ready() ? LocalDateTime.now() : null); + latest.setErrorMessage(outcome.ready() + ? null : truncate(outcome.errorMessage(), ERROR_MESSAGE_MAX_LENGTH)); + latest.setAdvice(outcome.advice()); + if (!outcome.ready() || outcome.advice() != null) { + latest.setListed(false); + latest.setForceUpdate(false); + } + latest.setUpdateTime(LocalDateTime.now()); + patchRepository.save(latest); + deletePatchFileQuietly(oldPatchUrl, patchId); + } + + /** 一次差分生成的结果快照 */ + private record PatchOutcome(boolean ready, String patchUrl, Long patchSize, Double patchRatio, + String md5, String sha1, String sha256, String sizeTier, + String advice, String errorMessage) { + + static PatchOutcome ready(String patchUrl, long patchSize, double patchRatio, String md5, String sha1, + String sha256, String sizeTier, String advice) { + return new PatchOutcome(true, patchUrl, patchSize, patchRatio, md5, sha1, sha256, sizeTier, + advice, null); + } + + static PatchOutcome failed(String errorMessage) { + return new PatchOutcome(false, null, null, null, null, null, null, null, null, errorMessage); + } + } + + private void verifyMd5(File file, String expectedMd5, String scene) { + if (!StringUtils.hasText(expectedMd5)) { + throw new BusinessException(scene + "缺少MD5校验值,无法确认回拉文件是否完整"); + } + String actual = DigestUtil.md5Hex(file); + if (!expectedMd5.equalsIgnoreCase(actual)) { + throw new BusinessException(scene + "MD5 校验不一致,对象存储文件可能已损坏或被替换,期望 " + + expectedMd5 + ",实际 " + actual); + } + } + + // ========================================================== + // 校验 + // ========================================================== + + private AppUpdateReleaseDocument requireTarget(String targetReleaseId) { + if (!StringUtils.hasText(targetReleaseId)) { + throw new BusinessException("请选择目标版本(已发布的全局更新)"); + } + AppUpdateReleaseDocument target = releaseRepository.findById(targetReleaseId.trim()) + .orElseThrow(() -> new BusinessException("目标发布单不存在")); + if (!UpdateType.GLOBAL.equals(target.getUpdateType())) { + throw new BusinessException("差分补丁只支持全局更新发布单"); + } + return target; + } + + private void requirePublished(AppUpdateReleaseDocument target) { + if (!UpdateStatus.PUBLISHED.equals(target.getStatus())) { + throw new BusinessException("仅已发布的全局更新可制作省流包"); + } + if (!StringUtils.hasText(target.getFileUrl()) || !StringUtils.hasText(target.getMd5()) + || !StringUtils.hasText(target.getSha256())) { + throw new BusinessException("目标发布单缺少安装包或校验值,无法生成差分"); + } + requireSingleApkFile(target.getFileName(), "目标安装包"); + } + + private void requireBaseOfTarget(AppUpdateReleaseDocument base, AppUpdateReleaseDocument target) { + if (Objects.equals(base.getId(), target.getId())) { + throw new BusinessException("源版本不能是目标版本自身"); + } + if (!UpdateStatus.PUBLISHED.equals(base.getStatus()) && !UpdateStatus.STOPPED.equals(base.getStatus())) { + throw new BusinessException("源版本 " + base.getVersionName() + " 未发布过,不能作为差分基线"); + } + if (!StringUtils.hasText(base.getMd5()) || !StringUtils.hasText(base.getSha256())) { + throw new BusinessException("源版本 " + base.getVersionName() + + " 的发布单缺少安装包校验值,无法按哈希匹配设备"); + } + requireSingleApkFile(base.getFileName(), "源版本 " + base.getVersionName() + " 的安装包"); + requireLowerVersion(base.getPackageName(), base.getVersionName(), base.getVersionCode(), target); + } + + private void requireBasePackageForm(PatchBasePackageForm basePackage, AppUpdateReleaseDocument target) { + if (basePackage == null) { + throw new BusinessException("补录的源安装包信息不完整"); + } + if (!StringUtils.hasText(basePackage.getFileUrl()) || !StringUtils.hasText(basePackage.getMd5()) + || !StringUtils.hasText(basePackage.getSha256())) { + throw new BusinessException("补录的源安装包缺少地址或校验值,请重新上传"); + } + if (!StringUtils.hasText(basePackage.getVersionName())) { + throw new BusinessException("补录的源安装包缺少版本名称"); + } + requireSingleApkFile(basePackage.getFileName(), "补录的源安装包"); + requireLowerVersion(basePackage.getPackageName(), basePackage.getVersionName(), + basePackage.getVersionCode(), target); + } + + /** + * 差分只对单文件 APK 生效:xapk / apks / apkm 安装后没有唯一的已安装文件, + * 设备无法给出与服务端一致的 SHA-256 来命中基线。 + */ + private void requireSingleApkFile(String fileName, String scene) { + if (fileName == null || !fileName.toLowerCase(Locale.ROOT).endsWith(APK_EXTENSION)) { + throw new BusinessException(scene + "必须是单文件 .apk,.xapk / .apks / .apkm 无法制作省流包"); + } + } + + private void requireLowerVersion(String packageName, String baseVersionName, Long baseVersionCode, + AppUpdateReleaseDocument target) { + if (!Objects.equals(packageName, target.getPackageName())) { + throw new BusinessException("源版本 " + baseVersionName + " 与目标版本不是同一个应用"); + } + if (baseVersionCode == null || baseVersionCode >= target.getVersionCode()) { + throw new BusinessException("源版本 " + baseVersionName + " 的版本号必须低于目标版本 " + + target.getVersionName()); + } + } + + /** 补丁可下发的条件:已生成完成、目标发布单已发布且上架、渠道启用 */ + private void requireDeliverable(AppUpdatePatchDocument patch) { + if (!PatchStatus.READY.equals(patch.getStatus())) { + throw new BusinessException("差分补丁生成完成后才能上架"); + } + AppUpdateReleaseDocument target = releaseRepository.findById(patch.getTargetReleaseId()) + .orElseThrow(() -> new BusinessException("目标发布单不存在")); + if (!UpdateStatus.PUBLISHED.equals(target.getStatus()) || !Boolean.TRUE.equals(target.getListed())) { + throw new BusinessException("目标版本已下架或未发布,差分补丁不能上架"); + } + UpdateChannelDocument channel = channelRepository.findByCode(patch.getChannelCode()) + .orElseThrow(() -> new BusinessException("渠道不存在:" + patch.getChannelCode())); + if (channel.getEnabled() != null && !channel.getEnabled()) { + throw new BusinessException("渠道已停用,差分补丁不能上架"); + } + } + + private AppUpdatePatchDocument getDocument(String id) { + return patchRepository.findById(id) + .orElseThrow(() -> new BusinessException("差分补丁不存在")); + } + + // ========================================================== + // 组装 + // ========================================================== + + private AppUpdatePatchDocument newPatch(AppUpdateReleaseDocument target, String baseVersionName, + Long baseVersionCode, String baseSource, String baseReleaseId, + String baseFileUrl, String baseFileName, String baseMd5, + String baseSha256) { + AppUpdatePatchDocument patch = new AppUpdatePatchDocument(); + patch.setId(UUID.randomUUID().toString()); + patch.setTargetReleaseId(target.getId()); + patch.setChannelCode(target.getChannelCode()); + patch.setChannelName(target.getChannelName()); + patch.setPackageName(target.getPackageName()); + patch.setAppLabel(target.getAppLabel()); + patch.setIconUrl(target.getIconUrl()); + patch.setTargetVersionName(target.getVersionName()); + patch.setTargetVersionCode(target.getVersionCode()); + patch.setTargetFileSize(target.getFileSize()); + patch.setTargetFileSizeText(target.getFileSizeText()); + patch.setTargetMd5(target.getMd5()); + patch.setTargetSha256(target.getSha256()); + patch.setTargetFileUrl(target.getFileUrl()); + patch.setBaseVersionName(baseVersionName); + patch.setBaseVersionCode(baseVersionCode); + patch.setBaseSource(baseSource); + patch.setBaseReleaseId(baseReleaseId); + patch.setBaseFileUrl(baseFileUrl); + patch.setBaseFileName(baseFileName); + patch.setBaseMd5(baseMd5); + // 设备上报的已安装包哈希按小写十六进制比对,入库前先归一 + patch.setBaseSha256(normalizeSha256(baseSha256)); + patch.setPatchFileName(target.getPackageName() + "_" + baseVersionCode + "-" + target.getVersionCode() + + "_" + normalizeSha256(baseSha256).substring(0, BASE_FILE_NAME_HASH_LENGTH) + PATCH_EXTENSION); + patch.setPatchAlgorithm(PATCH_ALGORITHM); + patch.setStatus(PatchStatus.GENERATING); + patch.setListed(true); + patch.setForceUpdate(false); + patch.setCreateTime(LocalDateTime.now()); + patch.setUpdateTime(LocalDateTime.now()); + return patch; + } + + private List distinctIds(List ids) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + return ids.stream() + .filter(StringUtils::hasText) + .map(String::trim) + .distinct() + .toList(); + } + + private AppUpdatePatchVO toVO(AppUpdatePatchDocument patch, AppUpdateReleaseDocument target) { + AppUpdatePatchVO vo = new AppUpdatePatchVO(); + BeanUtils.copyProperties(patch, vo); + if (target != null) { + vo.setTargetReleaseStatus(target.getStatus()); + vo.setTargetListed(target.getListed()); + } + return vo; + } + + // ========================================================== + // 文件清理 + // ========================================================== + + private void deletePatchFileQuietly(String patchUrl, String excludeId) { + if (!StringUtils.hasText(patchUrl) || patchRepository.existsByPatchUrl(patchUrl, excludeId)) { + return; + } + deleteRemoteQuietly(patchUrl); + } + + private void deleteBaseFileQuietly(AppUpdatePatchDocument patch, String excludeId) { + if (!PatchBaseSource.UPLOAD.equals(patch.getBaseSource())) { + return; + } + String baseFileUrl = patch.getBaseFileUrl(); + if (!StringUtils.hasText(baseFileUrl) || patchRepository.existsByBaseFileUrl(baseFileUrl, excludeId)) { + return; + } + deleteRemoteQuietly(baseFileUrl); + } + + private void deleteRemoteQuietly(String url) { + try { + fileService.deleteFile(url); + } catch (Exception e) { + log.warn("删除省流更新文件失败:{}", url, e); + } + } + + private String truncate(String value, int maxLength) { + if (value == null) { + return "生成失败"; + } + String text = value.trim(); + return text.length() <= maxLength ? text : text.substring(0, maxLength); + } + + private String normalizeSha256(String sha256) { + return sha256.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/com/youlai/boot/app/update/service/impl/AppUpdateReleaseServiceImpl.java b/src/main/java/com/youlai/boot/app/update/service/impl/AppUpdateReleaseServiceImpl.java new file mode 100644 index 00000000..dda30e49 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/service/impl/AppUpdateReleaseServiceImpl.java @@ -0,0 +1,371 @@ +package com.youlai.boot.app.update.service.impl; + +import com.youlai.boot.app.update.component.UpdatePackageUploader; +import com.youlai.boot.app.update.model.constant.UpdateStatus; +import com.youlai.boot.app.update.model.constant.UpdateType; +import com.youlai.boot.app.update.model.document.AppUpdatePatchDocument; +import com.youlai.boot.app.update.model.document.AppUpdateReleaseDocument; +import com.youlai.boot.app.update.model.document.UpdateChannelDocument; +import com.youlai.boot.app.update.model.form.AppUpdateReleaseForm; +import com.youlai.boot.app.update.model.query.AppUpdateReleasePageQuery; +import com.youlai.boot.app.update.model.vo.AppUpdateFileVO; +import com.youlai.boot.app.update.model.vo.AppUpdateReleaseVO; +import com.youlai.boot.app.update.model.vo.UpdateCheckVO; +import com.youlai.boot.app.update.model.vo.UpdatePatchVO; +import com.youlai.boot.app.update.repository.AppUpdatePatchRepository; +import com.youlai.boot.app.update.repository.AppUpdateReleaseRepository; +import com.youlai.boot.app.update.repository.UpdateChannelRepository; +import com.youlai.boot.app.update.service.AppUpdatePatchService; +import com.youlai.boot.app.update.service.AppUpdateReleaseService; +import com.youlai.boot.common.config.StorageDir; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.support.file.model.FileInfo; +import com.youlai.boot.support.file.service.FileService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +/** + * 应用更新发布单服务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AppUpdateReleaseServiceImpl implements AppUpdateReleaseService { + + private final AppUpdateReleaseRepository releaseRepository; + private final UpdateChannelRepository channelRepository; + private final AppUpdatePatchRepository patchRepository; + + /** 文件存储服务:由 storage.type 决定激活 s3 / aliyun / local 实现 */ + private final FileService fileService; + private final UpdatePackageUploader packageUploader; + + /** 发布单删除时级联清理其差分补丁 */ + private final AppUpdatePatchService patchService; + + @Override + public AppUpdateFileVO upload(MultipartFile file, String packageName, String versionName, + String versionCode, String appLabel) { + return packageUploader.store(file, StorageDir.UPDATE_PACKAGE, StorageDir.UPDATE_ICON, + packageName, versionName, versionCode, appLabel); + } + + @Override + public Page page(AppUpdateReleasePageQuery query, int pageNum, int pageSize) { + Page page = releaseRepository.page(query, pageNum, pageSize); + return page.map(this::toVO); + } + + @Override + public AppUpdateReleaseVO detail(String id) { + return toVO(getDocument(id)); + } + + @Override + public AppUpdateReleaseVO create(AppUpdateReleaseForm form) { + validateForm(form); + UpdateChannelDocument channel = getChannel(form.getChannelCode()); + + AppUpdateReleaseDocument doc = new AppUpdateReleaseDocument(); + doc.setId(UUID.randomUUID().toString()); + doc.setUpdateType(UpdateType.GLOBAL); + doc.setStatus(UpdateStatus.DRAFT); + doc.setCreateTime(LocalDateTime.now()); + doc.setUpdateTime(LocalDateTime.now()); + applyForm(doc, form, channel); + return toVO(releaseRepository.save(doc)); + } + + @Override + public AppUpdateReleaseVO update(String id, AppUpdateReleaseForm form) { + validateForm(form); + AppUpdateReleaseDocument doc = getDocument(id); + if (!UpdateStatus.DRAFT.equals(doc.getStatus())) { + throw new BusinessException("仅草稿状态的发布单可编辑"); + } + UpdateChannelDocument channel = getChannel(form.getChannelCode()); + + String oldFileUrl = doc.getFileUrl(); + String oldIconUrl = doc.getIconUrl(); + applyForm(doc, form, channel); + doc.setUpdateTime(LocalDateTime.now()); + AppUpdateReleaseDocument saved = releaseRepository.save(doc); + + if (StringUtils.hasText(oldFileUrl) && !Objects.equals(oldFileUrl, saved.getFileUrl()) + && !releaseRepository.existsByFileUrl(oldFileUrl, saved.getId())) { + deleteRemoteQuietly(oldFileUrl, "安装包"); + } + if (StringUtils.hasText(oldIconUrl) && !Objects.equals(oldIconUrl, saved.getIconUrl())) { + deleteRemoteQuietly(oldIconUrl, "图标"); + } + return toVO(saved); + } + + @Override + public void remove(String id) { + AppUpdateReleaseDocument doc = getDocument(id); + if (UpdateStatus.PUBLISHED.equals(doc.getStatus())) { + throw new BusinessException("已发布的更新需先停止后再删除"); + } + releaseRepository.deleteById(id); + patchService.removeByTargetRelease(id); + // 安装包可能还是其他差分补丁的源包,被引用时不能删文件 + if (StringUtils.hasText(doc.getFileUrl()) && !releaseRepository.existsByFileUrl(doc.getFileUrl(), id) + && !patchRepository.existsByBaseFileUrl(doc.getFileUrl(), null)) { + deleteRemoteQuietly(doc.getFileUrl(), "安装包"); + } + if (StringUtils.hasText(doc.getIconUrl())) { + deleteRemoteQuietly(doc.getIconUrl(), "图标"); + } + } + + @Override + public AppUpdateReleaseVO publish(String id) { + AppUpdateReleaseDocument doc = getDocument(id); + if (UpdateStatus.PUBLISHED.equals(doc.getStatus())) { + throw new BusinessException("该发布单已发布"); + } + validateReleaseContent(doc); + UpdateChannelDocument channel = getChannel(doc.getChannelCode()); + if (!isEnabled(channel)) { + throw new BusinessException("渠道已停用,无法发布"); + } + doc.setChannelName(channel.getName()); + doc.setStatus(UpdateStatus.PUBLISHED); + doc.setPublishedAt(LocalDateTime.now()); + doc.setUpdateTime(LocalDateTime.now()); + // 同一渠道同一应用只有本次发布的版本生效(该渠道的灰度也一并停止),其余已发布版本自动停止 + long superseded = releaseRepository + .stopOthersPublished(doc.getChannelCode(), doc.getPackageName(), doc.getId()); + if (superseded > 0) { + log.info("{}(渠道 {})发布 {},自动停止 {} 个旧的已发布版本", + doc.getPackageName(), doc.getChannelCode(), doc.getVersionName(), superseded); + } + return toVO(releaseRepository.save(doc)); + } + + @Override + public AppUpdateReleaseVO stop(String id) { + AppUpdateReleaseDocument doc = getDocument(id); + if (!UpdateStatus.PUBLISHED.equals(doc.getStatus())) { + throw new BusinessException("仅已发布的更新可停止"); + } + doc.setStatus(UpdateStatus.STOPPED); + doc.setUpdateTime(LocalDateTime.now()); + return toVO(releaseRepository.save(doc)); + } + + @Override + public AppUpdateReleaseVO updateListed(String id, boolean listed) { + AppUpdateReleaseDocument doc = getDocument(id); + if (listed) { + if (!isEnabled(getChannel(doc.getChannelCode()))) { + throw new BusinessException("渠道已停用,无法上架"); + } + } + doc.setListed(listed); + if (!listed) { + doc.setForceUpdate(false); + } + doc.setUpdateTime(LocalDateTime.now()); + return toVO(releaseRepository.save(doc)); + } + + @Override + public UpdateCheckVO checkUpdate(String packageName, String channelCode, String sn, Long currentVersion, + String installedSha256) { + UpdateCheckVO vo = new UpdateCheckVO(); + vo.setHasUpdate(false); + if (!StringUtils.hasText(packageName) || !StringUtils.hasText(channelCode) || currentVersion == null) { + return vo; + } + String baseSha256 = StringUtils.hasText(installedSha256) ? installedSha256.trim().toLowerCase() : null; + String deviceSn = StringUtils.hasText(sn) ? sn.trim() : null; + Optional gray = releaseRepository + .findLatestGrayPublished(packageName.trim(), channelCode.trim(), deviceSn, currentVersion); + Optional global = releaseRepository + .findLatestPublished(UpdateType.GLOBAL, packageName.trim(), channelCode.trim(), currentVersion); + // 命中灰度优先;全局正式包版本更高时下发全局,避免灰度设备停在旧版本 + Optional hit = global; + if (gray.isPresent() && (global.isEmpty() + || gray.get().getVersionCode() >= global.get().getVersionCode())) { + hit = gray; + } + return hit.map(release -> toCheckVO(release, baseSha256)).orElse(vo); + } + + private AppUpdateReleaseDocument getDocument(String id) { + return releaseRepository.findById(id) + .orElseThrow(() -> new BusinessException("发布单不存在")); + } + + private void validateForm(AppUpdateReleaseForm form) { + if (form == null) { + throw new BusinessException("发布单信息不能为空"); + } + if (!StringUtils.hasText(form.getFileUrl())) { + throw new BusinessException("请先上传安装包"); + } + if (!StringUtils.hasText(form.getFileName())) { + throw new BusinessException("安装包文件名不能为空"); + } + if (!StringUtils.hasText(form.getChannelCode())) { + throw new BusinessException("请选择渠道"); + } + if (!StringUtils.hasText(form.getPackageName())) { + throw new BusinessException("应用包名不能为空"); + } + if (!StringUtils.hasText(form.getVersionName())) { + throw new BusinessException("版本名称不能为空"); + } + if (form.getVersionCode() == null || form.getVersionCode() <= 0) { + throw new BusinessException("版本号必须为大于 0 的整数"); + } + if (Boolean.TRUE.equals(form.getForceUpdate()) && !Boolean.TRUE.equals(form.getListed())) { + throw new BusinessException("未上架的更新不能开启强制更新"); + } + if (form.getReleaseNotes() != null && form.getReleaseNotes().length() > 2000) { + throw new BusinessException("更新说明不能超过 2000 个字符"); + } + } + + private void validateReleaseContent(AppUpdateReleaseDocument doc) { + if (!StringUtils.hasText(doc.getFileUrl())) { + throw new BusinessException("发布单缺少安装包下载地址,无法发布"); + } + if (!StringUtils.hasText(doc.getChannelCode())) { + throw new BusinessException("发布单缺少渠道,无法发布"); + } + if (!StringUtils.hasText(doc.getPackageName())) { + throw new BusinessException("发布单缺少应用包名,无法发布"); + } + if (doc.getVersionCode() == null || doc.getVersionCode() <= 0) { + throw new BusinessException("发布单缺少有效的版本号,无法发布"); + } + } + + private UpdateChannelDocument getChannel(String channelCode) { + if (!StringUtils.hasText(channelCode)) { + throw new BusinessException("发布单未设置渠道"); + } + return channelRepository.findByCode(channelCode.trim()) + .orElseThrow(() -> new BusinessException("渠道不存在:" + channelCode)); + } + + private boolean isEnabled(UpdateChannelDocument channel) { + return channel.getEnabled() == null || channel.getEnabled(); + } + + private void applyForm(AppUpdateReleaseDocument doc, AppUpdateReleaseForm form, UpdateChannelDocument channel) { + doc.setFileUrl(form.getFileUrl()); + doc.setFileName(form.getFileName()); + doc.setFileSize(form.getFileSize()); + doc.setFileSizeText(form.getFileSizeText()); + doc.setMd5(form.getMd5()); + doc.setSha1(form.getSha1()); + doc.setSha256(form.getSha256()); + doc.setIconUrl(form.getIconUrl()); + doc.setChannelCode(channel.getCode()); + doc.setChannelName(channel.getName()); + doc.setPackageName(form.getPackageName().trim()); + doc.setAppLabel(firstNonBlank(form.getAppLabel(), form.getPackageName().trim())); + doc.setVersionName(form.getVersionName().trim()); + doc.setVersionCode(form.getVersionCode()); + boolean listed = Boolean.TRUE.equals(form.getListed()); + doc.setListed(listed); + doc.setForceUpdate(listed && Boolean.TRUE.equals(form.getForceUpdate())); + doc.setReleaseNotes(form.getReleaseNotes()); + } + + private void deleteRemoteQuietly(String url, String scene) { + try { + fileService.deleteFile(url); + } catch (Exception e) { + log.warn("删除全局更新{}失败:{}", scene, url, e); + } + } + + private String firstNonBlank(String... values) { + for (String value : values) { + if (StringUtils.hasText(value)) { + return value.trim(); + } + } + return null; + } + + private UpdateCheckVO toCheckVO(AppUpdateReleaseDocument doc, String installedSha256) { + UpdateCheckVO vo = new UpdateCheckVO(); + vo.setHasUpdate(true); + vo.setReleaseId(doc.getId()); + vo.setChannelCode(doc.getChannelCode()); + vo.setChannelName(doc.getChannelName()); + vo.setAppLabel(doc.getAppLabel()); + vo.setPackageName(doc.getPackageName()); + vo.setVersionName(doc.getVersionName()); + vo.setVersionCode(doc.getVersionCode()); + vo.setFileUrl(doc.getFileUrl()); + vo.setFileName(doc.getFileName()); + vo.setFileSize(doc.getFileSize()); + vo.setFileSizeText(doc.getFileSizeText()); + vo.setMd5(doc.getMd5()); + vo.setSha1(doc.getSha1()); + vo.setSha256(doc.getSha256()); + vo.setIconUrl(doc.getIconUrl()); + vo.setForceUpdate(doc.getForceUpdate()); + vo.setReleaseNotes(doc.getReleaseNotes()); + vo.setPublishedAt(doc.getPublishedAt()); + attachPatch(vo, doc, installedSha256); + return vo; + } + + /** + * 设备已安装包的 SHA-256 命中差分基线且补丁可用时下发补丁信息 + *

+ * 补丁与全量包一起返回:设备端合成后校验 newSha256 失败就回退全量下载, + * 补丁可单独设置强制更新:只想对某个老版本强制时开补丁的开关即可。 + */ + private void attachPatch(UpdateCheckVO vo, AppUpdateReleaseDocument release, String installedSha256) { + if (!StringUtils.hasText(installedSha256)) { + return; + } + Optional available = patchRepository + .findReadyByInstalledSha256(release.getId(), installedSha256); + if (available.isEmpty()) { + return; + } + AppUpdatePatchDocument patch = available.get(); + UpdatePatchVO patchVO = new UpdatePatchVO(); + patchVO.setPatchId(patch.getId()); + patchVO.setUrl(patch.getPatchUrl()); + patchVO.setFileName(patch.getPatchFileName()); + patchVO.setSize(patch.getPatchSize()); + patchVO.setSizeText(patch.getPatchSizeText()); + patchVO.setMd5(patch.getMd5()); + patchVO.setSha1(patch.getSha1()); + patchVO.setSha256(patch.getSha256()); + patchVO.setAlgorithm(patch.getPatchAlgorithm()); + patchVO.setBaseSha256(patch.getBaseSha256()); + patchVO.setNewSha256(patch.getTargetSha256()); + vo.setPatch(patchVO); + if (Boolean.TRUE.equals(patch.getForceUpdate())) { + vo.setForceUpdate(true); + } + } + + private AppUpdateReleaseVO toVO(AppUpdateReleaseDocument doc) { + AppUpdateReleaseVO vo = new AppUpdateReleaseVO(); + BeanUtils.copyProperties(doc, vo); + return vo; + } +} diff --git a/src/main/java/com/youlai/boot/app/update/service/impl/UpdateChannelServiceImpl.java b/src/main/java/com/youlai/boot/app/update/service/impl/UpdateChannelServiceImpl.java new file mode 100644 index 00000000..953fb941 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/update/service/impl/UpdateChannelServiceImpl.java @@ -0,0 +1,113 @@ +package com.youlai.boot.app.update.service.impl; + +import com.youlai.boot.app.update.model.document.UpdateChannelDocument; +import com.youlai.boot.app.update.model.form.UpdateChannelForm; +import com.youlai.boot.app.update.model.vo.UpdateChannelVO; +import com.youlai.boot.app.update.repository.UpdateChannelRepository; +import com.youlai.boot.app.update.service.UpdateChannelService; +import com.youlai.boot.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * 更新渠道服务实现 + */ +@Service +@RequiredArgsConstructor +public class UpdateChannelServiceImpl implements UpdateChannelService { + + private static final Pattern CODE_PATTERN = Pattern.compile("^[A-Za-z0-9_-]{2,64}$"); + + private final UpdateChannelRepository channelRepository; + + @Override + public List listChannels() { + return channelRepository.listAll().stream() + .map(this::toVO) + .collect(Collectors.toList()); + } + + @Override + public UpdateChannelVO create(UpdateChannelForm form) { + validateForm(form); + String code = form.getCode().trim(); + if (channelRepository.findByCode(code).isPresent()) { + throw new BusinessException("渠道编码已存在:" + code); + } + + UpdateChannelDocument doc = new UpdateChannelDocument(); + doc.setCode(code); + doc.setName(form.getName().trim()); + doc.setDescription(form.getDescription()); + doc.setEnabled(form.getEnabled() == null || form.getEnabled()); + doc.setSort(form.getSort() == null ? 0 : form.getSort()); + doc.setId(UUID.randomUUID().toString()); + doc.setCreateTime(LocalDateTime.now()); + doc.setUpdateTime(LocalDateTime.now()); + return toVO(channelRepository.save(doc)); + } + + @Override + public UpdateChannelVO update(String id, UpdateChannelForm form) { + validateForm(form); + UpdateChannelDocument doc = channelRepository.findById(id) + .orElseThrow(() -> new BusinessException("渠道不存在")); + String code = form.getCode().trim(); + channelRepository.findByCode(code).ifPresent(existing -> { + if (!existing.getId().equals(id)) { + throw new BusinessException("渠道编码已存在:" + code); + } + }); + + doc.setCode(code); + doc.setName(form.getName().trim()); + doc.setDescription(form.getDescription()); + doc.setEnabled(form.getEnabled() == null || form.getEnabled()); + doc.setSort(form.getSort() == null ? 0 : form.getSort()); + doc.setUpdateTime(LocalDateTime.now()); + return toVO(channelRepository.save(doc)); + } + + @Override + public void remove(String id) { + if (channelRepository.findById(id).isEmpty()) { + throw new BusinessException("渠道不存在"); + } + channelRepository.deleteById(id); + } + + private void validateForm(UpdateChannelForm form) { + if (form == null) { + throw new BusinessException("渠道信息不能为空"); + } + if (!StringUtils.hasText(form.getCode())) { + throw new BusinessException("渠道编码不能为空"); + } + if (!CODE_PATTERN.matcher(form.getCode().trim()).matches()) { + throw new BusinessException("渠道编码仅支持 2-64 位字母、数字、下划线或短横线"); + } + if (!StringUtils.hasText(form.getName())) { + throw new BusinessException("渠道名称不能为空"); + } + if (form.getName().trim().length() > 64) { + throw new BusinessException("渠道名称不能超过 64 个字符"); + } + if (form.getDescription() != null && form.getDescription().length() > 200) { + throw new BusinessException("渠道描述不能超过 200 个字符"); + } + } + + private UpdateChannelVO toVO(UpdateChannelDocument doc) { + UpdateChannelVO vo = new UpdateChannelVO(); + BeanUtils.copyProperties(doc, vo); + return vo; + } +} diff --git a/src/main/java/com/youlai/boot/client/controller/ClientUpdateController.java b/src/main/java/com/youlai/boot/client/controller/ClientUpdateController.java new file mode 100644 index 00000000..00351493 --- /dev/null +++ b/src/main/java/com/youlai/boot/client/controller/ClientUpdateController.java @@ -0,0 +1,42 @@ +package com.youlai.boot.client.controller; + +import com.youlai.boot.app.update.model.form.UpdateCheckForm; +import com.youlai.boot.app.update.model.vo.UpdateCheckVO; +import com.youlai.boot.app.update.service.AppUpdateReleaseService; +import com.youlai.boot.common.result.Result; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +/** + * 家属端应用更新控制层 + *

+ * 按客户端令牌鉴权({@code clientType=client}),与服务端设备端签名校验区分。 + */ +@Tag(name = "客户端-应用更新") +@RestController +@RequestMapping("/api/v1/client/update") +@RequiredArgsConstructor +public class ClientUpdateController { + + private final AppUpdateReleaseService releaseService; + + @Operation(summary = "检查应用更新(需登录令牌)") + @PostMapping("/check") + public Result checkUpdate(@RequestBody UpdateCheckForm form) { + if (form == null || !StringUtils.hasText(form.getPackageName())) { + return Result.failed("应用包名不能为空"); + } + if (!StringUtils.hasText(form.getChannel())) { + return Result.failed("应用渠道不能为空"); + } + if (form.getVersionCode() == null) { + return Result.failed("应用版本号不能为空"); + } + return Result.success(releaseService.checkUpdate( + form.getPackageName(), form.getChannel(), null, form.getVersionCode(), + form.getInstalledSha256())); + } +} diff --git a/src/main/java/com/youlai/boot/common/config/StorageDir.java b/src/main/java/com/youlai/boot/common/config/StorageDir.java index 2c898908..43fec459 100644 --- a/src/main/java/com/youlai/boot/common/config/StorageDir.java +++ b/src/main/java/com/youlai/boot/common/config/StorageDir.java @@ -21,6 +21,24 @@ public interface StorageDir { /** 应用安装包(apk / apks / aab / apkm / xapk) */ String PACKAGE = "app/package"; + /** 全局更新安装包(与应用安装包库分开存放) */ + String UPDATE_PACKAGE = "app/update-package"; + + /** 全局更新安装包解析出的图标 */ + String UPDATE_ICON = "app/update-icon"; + + /** 灰度更新安装包(与全局更新分开存放) */ + String UPDATE_GRAY_PACKAGE = "app/update-gray-package"; + + /** 灰度更新安装包解析出的图标 */ + String UPDATE_GRAY_ICON = "app/update-gray-icon"; + + /** 省流更新的差分补丁(由新旧安装包 hdiffz 生成) */ + String UPDATE_PATCH = "app/update-patch"; + + /** 省流更新的基线安装包(补录的旧包,仅用于生成差分) */ + String UPDATE_BASE_PACKAGE = "app/update-base-package"; + /** 安装包应用截图 */ String SCREENSHOT = "app/screenshot"; diff --git a/src/main/java/com/youlai/boot/common/enums/LogModuleEnum.java b/src/main/java/com/youlai/boot/common/enums/LogModuleEnum.java index 7f8389c6..2f3c99ec 100644 --- a/src/main/java/com/youlai/boot/common/enums/LogModuleEnum.java +++ b/src/main/java/com/youlai/boot/common/enums/LogModuleEnum.java @@ -29,6 +29,7 @@ public enum LogModuleEnum implements IBaseEnum { CODEGEN(11, "代码生成"), FORM(12, "动态表单"), WORKFLOW(13, "工作流"), + APP(14, "应用管理"), DEVICE(15, "设备SN管理"), MOBILE(16, "移动设备管理"), OTHER(99, "其他"); diff --git a/src/main/java/com/youlai/boot/device/controller/DeviceUpdateController.java b/src/main/java/com/youlai/boot/device/controller/DeviceUpdateController.java new file mode 100644 index 00000000..b1e65d85 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/controller/DeviceUpdateController.java @@ -0,0 +1,47 @@ +package com.youlai.boot.device.controller; + +import com.youlai.boot.app.update.model.form.UpdateCheckForm; +import com.youlai.boot.app.update.model.vo.UpdateCheckVO; +import com.youlai.boot.app.update.service.AppUpdateReleaseService; +import com.youlai.boot.common.result.Result; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +/** + * 设备应用更新控制层 + *

+ * 请求走 {@code /api/v1/sn/**} 的 ECDSA 签名校验,无需 JWT。 + */ +@Tag(name = "16.移动设备管理") +@RestController +@RequestMapping("/api/v1/sn") +@RequiredArgsConstructor +public class DeviceUpdateController { + + private final AppUpdateReleaseService releaseService; + + @Operation(summary = "检查应用更新(设备签名校验,无需登录令牌)") + @PostMapping("/check_update") + public Result checkUpdate( + @RequestHeader(value = "X-Device-SN") String sn, + @RequestBody UpdateCheckForm form + ) { + if (!StringUtils.hasText(sn)) { + return Result.failed("设备序列号不能为空"); + } + if (form == null || !StringUtils.hasText(form.getPackageName())) { + return Result.failed("应用包名不能为空"); + } + if (!StringUtils.hasText(form.getChannel())) { + return Result.failed("应用渠道不能为空"); + } + if (form.getVersionCode() == null) { + return Result.failed("应用版本号不能为空"); + } + return Result.success(releaseService.checkUpdate( + form.getPackageName(), form.getChannel(), sn, form.getVersionCode(), form.getInstalledSha256())); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 3817124a..49283e63 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -183,7 +183,7 @@ security: # 文件存储 storage: # 存储类型:s3 | aliyun | local - type: aliyun + type: s3 # 上传限制 upload: # 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源) @@ -196,10 +196,10 @@ storage: - gif # S3 对象存储(S3 协议兼容服务,默认部署 RustFS) s3: - endpoint: http://111.229.83.153:9000 - access-key: bybaddp7zyARpgNbEGKf - secret-key: p9rBdQZPBIJcMH23iyFkZkXmmawbmwPlk3JLlaaj - bucket: public + endpoint: https://s3.ttstd.com + access-key: SEAWEED797625204B1BF432 + secret-key: kxigyXBKOVg7PVtzSGCvuMThMJMzqkGX + bucket: ttstd-public # 自定义域名:配置后文件 URL 走域名,留空则用 endpoint(IP)格式 domain: # 阿里云 OSS diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 5a787cd2..0abb1f8b 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -178,7 +178,7 @@ security: # 文件存储 storage: # 存储类型:s3 | aliyun | local - type: aliyun + type: s3 # 上传限制 upload: # 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源) @@ -191,10 +191,10 @@ storage: - gif # S3 对象存储(S3 协议兼容服务,默认部署 RustFS) s3: - endpoint: http://localhost:9000 - access-key: rustfs-admin - secret-key: rustfs-admin - bucket: public + endpoint: https://s3.ttstd.com + access-key: SEAWEED797625204B1BF432 + secret-key: kxigyXBKOVg7PVtzSGCvuMThMJMzqkGX + bucket: ttstd-public # 自定义域名:配置后文件 URL 走域名,留空则用 endpoint(IP)格式 domain: # 阿里云 OSS diff --git a/src/main/resources/hdiffpatch/linux64/hdiffz b/src/main/resources/hdiffpatch/linux64/hdiffz new file mode 100644 index 00000000..8f4b1205 Binary files /dev/null and b/src/main/resources/hdiffpatch/linux64/hdiffz differ diff --git a/src/main/resources/hdiffpatch/linux64/hpatchz b/src/main/resources/hdiffpatch/linux64/hpatchz new file mode 100644 index 00000000..14b510c8 Binary files /dev/null and b/src/main/resources/hdiffpatch/linux64/hpatchz differ diff --git a/src/main/resources/hdiffpatch/linux_arm64/hdiffz b/src/main/resources/hdiffpatch/linux_arm64/hdiffz new file mode 100644 index 00000000..6ceade22 Binary files /dev/null and b/src/main/resources/hdiffpatch/linux_arm64/hdiffz differ diff --git a/src/main/resources/hdiffpatch/linux_arm64/hpatchz b/src/main/resources/hdiffpatch/linux_arm64/hpatchz new file mode 100644 index 00000000..09d57215 Binary files /dev/null and b/src/main/resources/hdiffpatch/linux_arm64/hpatchz differ diff --git a/src/main/resources/hdiffpatch/macos/hdiffz b/src/main/resources/hdiffpatch/macos/hdiffz new file mode 100644 index 00000000..8cd7955a Binary files /dev/null and b/src/main/resources/hdiffpatch/macos/hdiffz differ diff --git a/src/main/resources/hdiffpatch/macos/hpatchz b/src/main/resources/hdiffpatch/macos/hpatchz new file mode 100644 index 00000000..901a1f1b Binary files /dev/null and b/src/main/resources/hdiffpatch/macos/hpatchz differ diff --git a/src/main/resources/hdiffpatch/windows64/hdiffz.exe b/src/main/resources/hdiffpatch/windows64/hdiffz.exe new file mode 100644 index 00000000..f343cc17 Binary files /dev/null and b/src/main/resources/hdiffpatch/windows64/hdiffz.exe differ diff --git a/src/main/resources/hdiffpatch/windows64/hpatchz.exe b/src/main/resources/hdiffpatch/windows64/hpatchz.exe new file mode 100644 index 00000000..66bf2d5e Binary files /dev/null and b/src/main/resources/hdiffpatch/windows64/hpatchz.exe differ diff --git a/src/test/java/com/youlai/boot/app/update/component/ApkDeltaUpdaterTest.java b/src/test/java/com/youlai/boot/app/update/component/ApkDeltaUpdaterTest.java new file mode 100644 index 00000000..85369bda --- /dev/null +++ b/src/test/java/com/youlai/boot/app/update/component/ApkDeltaUpdaterTest.java @@ -0,0 +1,102 @@ +package com.youlai.boot.app.update.component; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * APK 增量差分工具测试。 + *

+ * 覆盖两点:当前平台能定位到内置的 {@code hdiffz}(且与 classpath 资源逐字节一致, + * 防止资源被过滤/损坏);对高度相似的文件生成的补丁明显小于新包。 + */ +@DisplayName("APK 增量差分工具测试") +class ApkDeltaUpdaterTest { + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final int SIZE = 4 * 1024 * 1024; + private static final int CHANGED_OFFSET = 1024 * 1024; + private static final int CHANGED_LENGTH = 256 * 1024; + + @Test + @DisplayName("当前平台应定位到可用的内置 hdiffz,且与资源逐字节一致") + void shouldPrepareBinaryForCurrentPlatform() throws IOException { + ApkDeltaUpdater.Platform platform = ApkDeltaUpdater.currentPlatform(); + assertNotNull(platform); + + Path hdiffz = ApkDeltaUpdater.binaryDir().resolve(platform.hdiffzName()); + assertTrue(Files.isRegularFile(hdiffz), "hdiffz 不存在: " + hdiffz); + assertTrue(platform.isWindows() || Files.isExecutable(hdiffz), "hdiffz 不可执行: " + hdiffz); + assertEquals(sha256OfResource("/hdiffpatch/" + platform.folder() + "/" + platform.hdiffzName()), + sha256OfFile(hdiffz), "释放到工作目录的二进制与 classpath 资源不一致"); + } + + @Test + @DisplayName("相似文件应生成明显小于新包的补丁") + void shouldCreateCompactPatchForSimilarFiles(@TempDir Path dir) + throws IOException, InterruptedException { + byte[] oldBytes = randomBytes(SIZE); + byte[] newBytes = Arrays.copyOf(oldBytes, SIZE); + System.arraycopy(randomBytes(CHANGED_LENGTH), 0, newBytes, CHANGED_OFFSET, CHANGED_LENGTH); + + File oldFile = dir.resolve("old.bin").toFile(); + File newFile = dir.resolve("new.bin").toFile(); + File patchFile = dir.resolve("delta.patch").toFile(); + Files.write(oldFile.toPath(), oldBytes); + Files.write(newFile.toPath(), newBytes); + + ApkDeltaUpdater.DeltaPlan plan = ApkDeltaUpdater.DeltaPlan.forFiles(oldFile, newFile); + assertEquals(ApkDeltaUpdater.SizeTier.SMALL, plan.tier(), "4MB 应落在 SMALL 档"); + + ApkDeltaUpdater.createPatch(oldFile, newFile, patchFile, plan); + + long patchSize = patchFile.length(); + assertTrue(patchSize > 0, "补丁为空"); + // 仅改动 6.25% 内容,补丁应远小于新包(未改动部分由 hdiffz 以 copy 指令引用) + assertTrue(patchSize < newFile.length() / 4, + "补丁过大:" + patchSize + " / " + newFile.length()); + } + + private static byte[] randomBytes(int length) { + byte[] bytes = new byte[length]; + SECURE_RANDOM.nextBytes(bytes); + return bytes; + } + + private static String sha256OfResource(String resource) throws IOException { + try (InputStream in = ApkDeltaUpdater.class.getResourceAsStream(resource)) { + assertNotNull(in, "classpath 缺少内置二进制: " + resource); + return sha256(in.readAllBytes()); + } + } + + private static String sha256OfFile(Path file) throws IOException { + return sha256(Files.readAllBytes(file)); + } + + private static String sha256(byte[] bytes) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + StringBuilder text = new StringBuilder(); + for (byte b : digest.digest(bytes)) { + text.append(String.format("%02x", b)); + } + return text.toString(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +}