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);
+ }
+ }
+}