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());