feat: simplify online update center
Signed Release / release (push) Successful in 9m41s

This commit is contained in:
Qiufeng
2026-08-04 21:25:43 +08:00
parent d3892320dd
commit abba079dde
8 changed files with 382 additions and 302 deletions
@@ -47,6 +47,7 @@ public class SystemUpdateService {
+ "(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$");
private static final long MAX_STATE_BYTES = 64 * 1024;
private static final int MAX_RELEASE_NOTES_CHARS = 32_000;
private static final int MAX_RELEASE_HISTORY = 20;
private static final int MAX_RELEASE_ASSETS = 32;
private static final int MAX_ASSET_NAME_CHARS = 255;
private static final int MAX_ASSET_URL_CHARS = 4_096;
@@ -138,6 +139,24 @@ public class SystemUpdateService {
}
}
public List<ReleaseHistoryItem> releases() {
requireConfigured();
List<ReleaseInfo> releases = new ArrayList<>(fetchReleaseHistory());
releases.sort((left, right) -> compareVersions(right.version(), left.version()));
String latestVersion = releases.isEmpty() ? null : releases.get(0).version();
List<ReleaseHistoryItem> history = new ArrayList<>(releases.size());
for (ReleaseInfo release : releases) {
history.add(new ReleaseHistoryItem(
release.version(),
release.publishedAt(),
release.notes(),
sameVersion(release.version(), currentVersion),
latestVersion != null && sameVersion(release.version(), latestVersion)
));
}
return List.copyOf(history);
}
public UpdateStatus install(String requestedVersion) {
requireConfigured();
if (!isVersion(requestedVersion)) {
@@ -197,13 +216,57 @@ public class SystemUpdateService {
}
private ReleaseInfo fetchLatestRelease() {
JsonNode latest = fetchReleaseJson(releaseApiUri("/latest"));
if (!isStableChannel() || !isPrerelease(latest)) {
return parseRelease(latest);
}
ReleaseInfo selected = null;
JsonNode selectedNode = null;
for (JsonNode node : fetchReleaseList()) {
ReleaseInfo candidate = parseReleaseHistoryItem(node);
if (candidate != null && (selected == null
|| compareVersions(candidate.version(), selected.version()) > 0)) {
selected = candidate;
selectedNode = node;
}
}
if (selectedNode == null) {
throw new ApiException(404, "Gitea 尚未发布正式版本");
}
return parseRelease(selectedNode);
}
private List<ReleaseInfo> fetchReleaseHistory() {
List<ReleaseInfo> releases = new ArrayList<>();
for (JsonNode node : fetchReleaseList()) {
ReleaseInfo release = parseReleaseHistoryItem(node);
if (release != null) {
releases.add(release);
}
}
return List.copyOf(releases);
}
private JsonNode fetchReleaseList() {
JsonNode root = fetchReleaseJson(releaseApiUri("?limit=" + MAX_RELEASE_HISTORY + "&page=1"));
if (!root.isArray() || root.size() > MAX_RELEASE_HISTORY) {
throw new ApiException(502, "Gitea Release 历史数据无效");
}
return root;
}
private URI releaseApiUri(String suffix) {
String[] repository = properties.getRepository().split("/", 2);
if (repository.length != 2 || repository[0].isBlank() || repository[1].isBlank()) {
throw new ApiException(500, "更新仓库配置无效");
}
String base = properties.getGiteaBaseUrl().replaceAll("/+$", "");
URI uri = URI.create(base + "/api/v1/repos/" + encode(repository[0]) + "/"
+ encode(repository[1]) + "/releases/latest");
return URI.create(base + "/api/v1/repos/" + encode(repository[0]) + "/"
+ encode(repository[1]) + "/releases" + suffix);
}
private JsonNode fetchReleaseJson(URI uri) {
HttpRequest.Builder builder = HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(Math.max(1, properties.getRequestTimeoutSeconds())))
.header("Accept", "application/json")
@@ -221,7 +284,7 @@ public class SystemUpdateService {
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new ApiException(502, "Gitea Release API 返回 HTTP " + response.statusCode());
}
return parseRelease(objectMapper.readTree(response.body()));
return objectMapper.readTree(response.body());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ApiException(503, "检查更新被中断");
@@ -231,6 +294,22 @@ public class SystemUpdateService {
}
}
private ReleaseInfo parseReleaseHistoryItem(JsonNode root) {
String tag = text(root, "tag_name");
if (!isVersion(tag) || root.path("draft").asBoolean(false)) {
return null;
}
if (isPrerelease(root) && isStableChannel()) {
return null;
}
return new ReleaseInfo(
normalizeVersion(tag),
releaseNotes(root),
releasePublishedAt(root),
List.of()
);
}
private ReleaseInfo parseRelease(JsonNode root) {
String tag = text(root, "tag_name");
if (!isVersion(tag)) {
@@ -239,11 +318,7 @@ public class SystemUpdateService {
if (root.path("draft").asBoolean(false)) {
throw new ApiException(502, "最新 Release 仍是草稿");
}
Matcher versionMatcher = VERSION_PATTERN.matcher(tag);
versionMatcher.matches();
boolean taggedPrerelease = versionMatcher.group(4) != null;
if ((root.path("prerelease").asBoolean(false) || taggedPrerelease)
&& "stable".equalsIgnoreCase(properties.getChannel())) {
if (isPrerelease(root) && isStableChannel()) {
throw new ApiException(502, "稳定频道拒绝预发布版本");
}
if (!root.path("assets").isArray() || root.path("assets").size() > MAX_RELEASE_ASSETS) {
@@ -268,20 +343,36 @@ public class SystemUpdateService {
requireAsset(assets, "kaidi-erp-" + version + ".tar.gz");
requireAsset(assets, "SHA256SUMS");
requireAsset(assets, "SHA256SUMS.sig");
Instant publishedAt = null;
return new ReleaseInfo(version, releaseNotes(root), releasePublishedAt(root), List.copyOf(assets));
}
private boolean isStableChannel() {
return "stable".equalsIgnoreCase(properties.getChannel());
}
private static boolean isPrerelease(JsonNode root) {
Matcher matcher = VERSION_PATTERN.matcher(text(root, "tag_name"));
return root.path("prerelease").asBoolean(false) || (matcher.matches() && matcher.group(4) != null);
}
private static Instant releasePublishedAt(JsonNode root) {
String published = text(root, "published_at");
if (!published.isBlank()) {
try {
publishedAt = Instant.parse(published);
} catch (RuntimeException ignore) {
// An invalid optional timestamp must not hide an otherwise valid release.
}
if (published.isBlank()) {
return null;
}
try {
return Instant.parse(published);
} catch (RuntimeException ignore) {
return null;
}
}
private static String releaseNotes(JsonNode root) {
String notes = text(root, "body");
if (notes.length() > MAX_RELEASE_NOTES_CHARS) {
notes = notes.substring(0, MAX_RELEASE_NOTES_CHARS) + "\n\n[发布说明过长,已截断]";
return notes.substring(0, MAX_RELEASE_NOTES_CHARS) + "\n\n[发布说明过长,已截断]";
}
return new ReleaseInfo(version, notes, publishedAt, List.copyOf(assets));
return notes;
}
private void requireConfigured() {
@@ -604,6 +695,15 @@ public class SystemUpdateService {
public record ReleaseAsset(String name, String downloadUrl, long size) {
}
public record ReleaseHistoryItem(
String version,
Instant publishedAt,
String releaseNotes,
boolean current,
boolean latest
) {
}
public enum UpdatePhase {
IDLE,
CHECKING,
@@ -5,6 +5,7 @@ import com.kaidi.oa.service.SystemUpdateConfigService;
import com.kaidi.oa.service.SystemUpdateConfigService.UpdateConfig;
import com.kaidi.oa.service.SystemUpdateConfigService.UpdateConfigRequest;
import com.kaidi.oa.service.SystemUpdateService;
import com.kaidi.oa.service.SystemUpdateService.ReleaseHistoryItem;
import com.kaidi.oa.service.SystemUpdateService.UpdateStatus;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
@@ -15,6 +16,8 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** Administrator-only API for checking and installing signed Gitea releases. */
@RestController
@RequestMapping("/api/oa/system-update")
@@ -46,6 +49,11 @@ public class SystemUpdateController {
return ApiResp.ok(updateService.status());
}
@GetMapping("/releases")
public ApiResp<List<ReleaseHistoryItem>> releases() {
return ApiResp.ok(updateService.releases());
}
@PostMapping("/check")
public ApiResp<UpdateStatus> check() {
return ApiResp.ok(updateService.check());
@@ -14,6 +14,7 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.List;
import java.util.Properties;
import static org.assertj.core.api.Assertions.assertThat;
@@ -79,6 +80,86 @@ class SystemUpdateServiceTest {
}
}
@Test
void stableChannelFallsBackToNewestStableReleaseWhenLatestIsPrerelease() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/v1/repos/awaioi/ERP/releases/latest", exchange -> {
byte[] body = """
{"tag_name":"v0.3.7-rc.1","draft":false,"prerelease":true,"assets":[]}
""".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.createContext("/api/v1/repos/awaioi/ERP/releases", exchange -> {
String origin = "http://127.0.0.1:" + exchange.getLocalAddress().getPort();
byte[] body = ("""
[
{"tag_name":"v0.3.7-rc.1","draft":false,"prerelease":true,"assets":[]},
{"tag_name":"v0.3.5","draft":false,"prerelease":false,"assets":[]},
{"tag_name":"v0.3.6","body":"Latest stable","draft":false,"prerelease":false,
"published_at":"2026-08-04T10:00:00Z","assets":[
{"name":"kaidi-erp-0.3.6.tar.gz","browser_download_url":"%s/app","size":123},
{"name":"SHA256SUMS","browser_download_url":"%s/sums","size":64},
{"name":"SHA256SUMS.sig","browser_download_url":"%s/sig","size":64}
]}
]
""").formatted(origin, origin, origin).getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
try {
SystemUpdateService service = service(configuredProperties(server.getAddress().getPort()), "0.3.5");
SystemUpdateService.UpdateStatus status = service.check();
assertThat(status.latestVersion()).isEqualTo("0.3.6");
assertThat(status.updateAvailable()).isTrue();
assertThat(status.releaseNotes()).isEqualTo("Latest stable");
} finally {
server.stop(0);
}
}
@Test
void listsStableReleaseHistoryNewestFirstAndMarksCurrentVersion() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/v1/repos/awaioi/ERP/releases", exchange -> {
byte[] body = """
[
{"tag_name":"v0.3.4","body":"Older fixes","draft":false,"prerelease":false,
"published_at":"2026-08-03T10:00:00Z"},
{"tag_name":"v0.3.6","body":"Simplified updater","draft":false,"prerelease":false,
"published_at":"2026-08-04T10:00:00Z"},
{"tag_name":"v0.3.7-rc.1","body":"Preview","draft":false,"prerelease":true},
{"tag_name":"v0.3.8","body":"Draft","draft":true,"prerelease":false},
{"tag_name":"not-a-version","body":"Invalid","draft":false,"prerelease":false}
]
""".getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
try {
SystemUpdateService service = service(configuredProperties(server.getAddress().getPort()), "0.3.4");
List<SystemUpdateService.ReleaseHistoryItem> releases = service.releases();
assertThat(releases).extracting(SystemUpdateService.ReleaseHistoryItem::version)
.containsExactly("0.3.6", "0.3.4");
assertThat(releases.get(0).latest()).isTrue();
assertThat(releases.get(0).current()).isFalse();
assertThat(releases.get(1).current()).isTrue();
assertThat(releases.get(1).releaseNotes()).isEqualTo("Older fixes");
} finally {
server.stop(0);
}
}
@Test
void persistsReleaseMetadataAcrossServiceRestart() throws Exception {
Path stateFile = Files.createTempFile("system-update-metadata-", ".json");