This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user