feat: add managed online updates
Signed Release / release (push) Successful in 9m24s

This commit is contained in:
Qiufeng
2026-08-04 20:05:50 +08:00
parent 4f2ea26e8e
commit d3892320dd
20 changed files with 1365 additions and 111 deletions
@@ -15,6 +15,7 @@ public class UpdateProperties {
private String token = "";
private String helperCommand = "";
private String stateFile = "./runtime/update-state.json";
private String configFile = "";
private boolean allowInsecureHttp;
private int requestTimeoutSeconds = 15;
@@ -74,6 +75,14 @@ public class UpdateProperties {
this.stateFile = stateFile;
}
public String getConfigFile() {
return configFile;
}
public void setConfigFile(String configFile) {
this.configFile = configFile;
}
public boolean isAllowInsecureHttp() {
return allowInsecureHttp;
}
@@ -0,0 +1,256 @@
package com.kaidi.oa.service;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.config.UpdateProperties;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.PosixFilePermission;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/** Persists the administrator-managed updater source without exposing credentials. */
@Service
public class SystemUpdateConfigService {
private static final long MAX_CONFIG_BYTES = 1024 * 1024;
private static final Pattern REPOSITORY_PATTERN = Pattern.compile(
"^[A-Za-z0-9][A-Za-z0-9._-]{0,99}/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$");
private static final Pattern TOKEN_PATTERN = Pattern.compile("^[A-Za-z0-9._-]{1,512}$");
private static final Set<String> CHANNELS = Set.of("stable", "preview");
private final UpdateProperties properties;
public SystemUpdateConfigService(UpdateProperties properties) {
this.properties = properties;
}
public synchronized UpdateConfig get() {
return new UpdateConfig(
properties.isEnabled(),
value(properties.getGiteaBaseUrl()),
value(properties.getRepository()),
normalizedChannel(properties.getChannel()),
properties.getToken() != null && !properties.getToken().isBlank(),
properties.isAllowInsecureHttp()
);
}
public synchronized UpdateConfig save(UpdateConfigRequest request) {
if (request == null) {
throw new ApiException(400, "更新配置不能为空");
}
String baseUrl = normalizeBaseUrl(request.giteaBaseUrl(), request.allowInsecureHttp(), request.enabled());
String repository = normalizeRepository(request.repository());
String channel = normalizedChannel(request.channel());
String token = value(properties.getToken());
String suppliedToken = value(request.token());
if (request.clearToken() && !suppliedToken.isBlank()) {
throw new ApiException(400, "不能同时清除并设置 Gitea Token");
}
if (request.clearToken()) {
token = "";
} else if (!suppliedToken.isBlank()) {
if (!TOKEN_PATTERN.matcher(suppliedToken).matches()) {
throw new ApiException(400, "Gitea Token 格式无效");
}
token = suppliedToken;
}
LinkedHashMap<String, String> changes = new LinkedHashMap<>();
changes.put("OA_UPDATE_ENABLED", Boolean.toString(request.enabled()));
changes.put("OA_UPDATE_GITEA_BASE_URL", baseUrl);
changes.put("OA_UPDATE_REPOSITORY", repository);
changes.put("OA_UPDATE_CHANNEL", channel);
changes.put("OA_UPDATE_TOKEN", token);
changes.put("OA_UPDATE_ALLOW_INSECURE_HTTP", Boolean.toString(request.allowInsecureHttp()));
persist(changes);
properties.setEnabled(request.enabled());
properties.setGiteaBaseUrl(baseUrl);
properties.setRepository(repository);
properties.setChannel(channel);
properties.setToken(token);
properties.setAllowInsecureHttp(request.allowInsecureHttp());
return get();
}
private void persist(Map<String, String> changes) {
String configuredPath = value(properties.getConfigFile());
if (configuredPath.isBlank()) {
throw new ApiException(503, "当前运行方式未提供可写的 ERP_CONFIG_FILE");
}
Path target = Path.of(configuredPath).toAbsolutePath().normalize();
try {
if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || !Files.isWritable(target)) {
throw new ApiException(503, "ERP 配置文件不存在或不可写");
}
if (Files.size(target) > MAX_CONFIG_BYTES) {
throw new ApiException(503, "ERP 配置文件异常过大");
}
BasicFileAttributes before = Files.readAttributes(
target, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
List<String> original = Files.readAllLines(target, StandardCharsets.UTF_8);
List<String> updated = replaceWhitelistedSettings(original, changes);
byte[] content = (String.join("\n", updated) + "\n").getBytes(StandardCharsets.UTF_8);
atomicWrite(target, content, before);
} catch (ApiException exception) {
throw exception;
} catch (IOException | RuntimeException exception) {
throw new ApiException(503, "无法保存更新配置,请检查 ERP 配置文件权限");
}
}
private List<String> replaceWhitelistedSettings(List<String> original, Map<String, String> changes) {
List<String> result = new ArrayList<>(original.size() + changes.size());
Set<String> written = new java.util.HashSet<>();
for (String line : original) {
String matched = null;
for (String key : changes.keySet()) {
if (line.startsWith(key + "=")) {
matched = key;
break;
}
}
if (matched == null) {
result.add(line);
} else if (written.add(matched)) {
result.add(setting(matched, changes.get(matched)));
}
}
for (Map.Entry<String, String> entry : changes.entrySet()) {
if (written.add(entry.getKey())) {
result.add(setting(entry.getKey(), entry.getValue()));
}
}
return result;
}
private void atomicWrite(Path target, byte[] content, BasicFileAttributes before) throws IOException {
Path parent = target.getParent();
if (parent == null || !Files.isDirectory(parent, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("configuration parent is unavailable");
}
Path temporary = Files.createTempFile(parent, ".erp.env-", ".tmp");
try {
try {
Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(
target, LinkOption.NOFOLLOW_LINKS);
Files.setPosixFilePermissions(temporary, permissions);
} catch (UnsupportedOperationException ignored) {
// Production targets POSIX systems; keep local non-POSIX tests portable.
}
try (FileChannel channel = FileChannel.open(
temporary, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
channel.write(ByteBuffer.wrap(content));
channel.force(true);
}
BasicFileAttributes current = Files.readAttributes(
target, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (current.size() != before.size() || !current.lastModifiedTime().equals(before.lastModifiedTime())) {
throw new IOException("configuration changed concurrently");
}
try {
Files.move(temporary, target,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
throw new IOException("atomic configuration replacement is unavailable", exception);
}
} finally {
Files.deleteIfExists(temporary);
}
}
private static String normalizeBaseUrl(String input, boolean allowInsecureHttp, boolean required) {
String value = value(input).replaceAll("/+$", "");
if (value.isBlank()) {
if (required) {
throw new ApiException(400, "启用在线更新时必须填写 Gitea 地址");
}
return "";
}
URI uri;
try {
uri = URI.create(value);
} catch (IllegalArgumentException exception) {
throw new ApiException(400, "Gitea 地址格式无效");
}
String scheme = value(uri.getScheme()).toLowerCase(Locale.ROOT);
if (!("https".equals(scheme) || (allowInsecureHttp && "http".equals(scheme)))) {
throw new ApiException(400, "Gitea 地址必须使用 HTTPS");
}
if (uri.getHost() == null || uri.getUserInfo() != null || uri.getRawQuery() != null
|| uri.getRawFragment() != null || uri.getPort() > 65535) {
throw new ApiException(400, "Gitea 地址格式无效");
}
return value;
}
private static String normalizeRepository(String input) {
String repository = value(input);
if (!REPOSITORY_PATTERN.matcher(repository).matches()) {
throw new ApiException(400, "仓库格式必须为 owner/repository");
}
return repository;
}
private static String normalizedChannel(String input) {
String channel = value(input).toLowerCase(Locale.ROOT);
if (channel.isBlank()) {
return "stable";
}
if (!CHANNELS.contains(channel)) {
throw new ApiException(400, "更新通道只支持 stable 或 preview");
}
return channel;
}
private static String setting(String key, String value) {
if (value.indexOf('\0') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0) {
throw new ApiException(400, "更新配置不能包含换行符");
}
return key + "='" + value.replace("'", "'\\''") + "'";
}
private static String value(String value) {
return value == null ? "" : value.trim();
}
public record UpdateConfig(
boolean enabled,
String giteaBaseUrl,
String repository,
String channel,
boolean tokenConfigured,
boolean allowInsecureHttp
) {
}
public record UpdateConfigRequest(
boolean enabled,
String giteaBaseUrl,
String repository,
String channel,
String token,
boolean clearToken,
boolean allowInsecureHttp
) {
}
}
@@ -21,13 +21,16 @@ import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.PosixFilePermission;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Matcher;
@@ -43,6 +46,10 @@ public class SystemUpdateService {
+ "(?:-([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?"
+ "(?:\\+([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_ASSETS = 32;
private static final int MAX_ASSET_NAME_CHARS = 255;
private static final int MAX_ASSET_URL_CHARS = 4_096;
private final UpdateProperties properties;
private final ObjectMapper objectMapper;
@@ -76,12 +83,33 @@ public class SystemUpdateService {
public UpdateStatus status() {
UpdateStatus persisted = readHelperState();
return persisted == null ? state.get() : persisted;
if (persisted != null) {
state.set(persisted);
return persisted;
}
return state.get();
}
public void ensureConfigurationMutable() {
if (isActivePhase(status().phase())) {
throw new ApiException(409, "更新进行中,暂时不能修改更新源");
}
}
public synchronized void configurationChanged() {
UpdateStatus idle = UpdateStatus.idle(isConfigured(), currentVersion);
state.set(idle);
persistState(idle);
}
public synchronized UpdateStatus check() {
requireConfigured();
state.set(state.get().withPhase(UpdatePhase.CHECKING, 5, "正在检查 Gitea Release"));
if (isActivePhase(status().phase())) {
throw new ApiException(409, "更新任务正在执行");
}
UpdateStatus checking = state.get().withPhase(UpdatePhase.CHECKING, 5, "正在检查 Gitea Release");
state.set(checking);
persistState(checking);
try {
ReleaseInfo release = fetchLatestRelease();
boolean available = compareVersions(release.version(), currentVersion) > 0;
@@ -100,9 +128,12 @@ public class SystemUpdateService {
null
);
state.set(checked);
persistState(checked);
return checked;
} catch (RuntimeException e) {
state.set(state.get().failed(safeMessage(e)));
UpdateStatus failed = state.get().failed(safeMessage(e));
state.set(failed);
persistState(failed);
throw e;
}
}
@@ -150,6 +181,7 @@ public class SystemUpdateService {
UpdateStatus starting = checked.withPhase(UpdatePhase.STARTING, 1, "更新助手已启动");
state.set(starting);
persistState(starting);
process.onExit().thenAccept(completed -> {
installRunning.set(false);
if (completed.exitValue() != 0) {
@@ -182,6 +214,7 @@ public class SystemUpdateService {
try {
HttpResponse<String> response = httpClient.send(builder.build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
requireSameOrigin(response.uri(), configuredBaseUri(), "Gitea Release API");
if (response.statusCode() == 404) {
throw new ApiException(404, "Gitea 尚未发布 Release");
}
@@ -213,11 +246,21 @@ public class SystemUpdateService {
&& "stable".equalsIgnoreCase(properties.getChannel())) {
throw new ApiException(502, "稳定频道拒绝预发布版本");
}
if (!root.path("assets").isArray() || root.path("assets").size() > MAX_RELEASE_ASSETS) {
throw new ApiException(502, "Release 文件列表无效");
}
List<ReleaseAsset> assets = new ArrayList<>();
for (JsonNode node : root.path("assets")) {
String name = text(node, "name");
String downloadUrl = text(node, "browser_download_url");
if (name.isBlank() || name.length() > MAX_ASSET_NAME_CHARS
|| downloadUrl.isBlank() || downloadUrl.length() > MAX_ASSET_URL_CHARS) {
throw new ApiException(502, "Release 文件信息无效");
}
validateAssetUrl(downloadUrl);
assets.add(new ReleaseAsset(
text(node, "name"),
text(node, "browser_download_url"),
name,
downloadUrl,
node.path("size").asLong(0)
));
}
@@ -234,7 +277,11 @@ public class SystemUpdateService {
// An invalid optional timestamp must not hide an otherwise valid release.
}
}
return new ReleaseInfo(version, text(root, "body"), publishedAt, List.copyOf(assets));
String notes = text(root, "body");
if (notes.length() > MAX_RELEASE_NOTES_CHARS) {
notes = notes.substring(0, MAX_RELEASE_NOTES_CHARS) + "\n\n[发布说明过长,已截断]";
}
return new ReleaseInfo(version, notes, publishedAt, List.copyOf(assets));
}
private void requireConfigured() {
@@ -244,16 +291,15 @@ public class SystemUpdateService {
if (properties.getGiteaBaseUrl() == null || properties.getGiteaBaseUrl().isBlank()) {
throw new ApiException(503, "尚未配置 Gitea 地址");
}
URI uri;
try {
uri = URI.create(properties.getGiteaBaseUrl().trim());
} catch (IllegalArgumentException e) {
throw new ApiException(500, "Gitea 地址格式无效");
}
URI uri = configuredBaseUri();
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
if (!"https".equals(scheme) && !(properties.isAllowInsecureHttp() && "http".equals(scheme))) {
throw new ApiException(503, "更新服务器必须使用 HTTPS");
}
String repository = properties.getRepository() == null ? "" : properties.getRepository().trim();
if (!repository.matches("^[A-Za-z0-9][A-Za-z0-9._-]{0,99}/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$")) {
throw new ApiException(500, "更新仓库配置无效");
}
}
private UpdateStatus readHelperState() {
@@ -272,10 +318,15 @@ public class SystemUpdateService {
String helperVersion = blankToNull(text(root, "version"));
boolean updateAvailable = helperVersion != null
&& phase != UpdatePhase.SUCCEEDED
&& phase != UpdatePhase.UP_TO_DATE
&& compareVersions(helperVersion, currentVersion) > 0;
Instant helperUpdatedAt = parseInstant(text(root, "updatedAt"));
Instant publishedAt = parseInstant(text(root, "publishedAt"));
String releaseNotes = root.path("releaseNotes").isTextual()
? root.path("releaseNotes").asText() : memory.releaseNotes();
List<ReleaseAsset> assets = readAssets(root.path("assets"), memory.assets());
return new UpdateStatus(
properties.isEnabled(),
isConfigured(),
currentVersion,
helperVersion,
updateAvailable,
@@ -283,9 +334,9 @@ public class SystemUpdateService {
Math.max(0, Math.min(100, root.path("progress").asInt(0))),
text(root, "message"),
helperUpdatedAt == null ? memory.checkedAt() : helperUpdatedAt,
memory.publishedAt(),
memory.releaseNotes(),
memory.assets(),
publishedAt == null ? memory.publishedAt() : publishedAt,
releaseNotes,
assets,
blankToNull(text(root, "error"))
);
} catch (Exception e) {
@@ -300,6 +351,137 @@ public class SystemUpdateService {
.toAbsolutePath().normalize();
}
private void persistState(UpdateStatus status) {
Path target = statePath();
Path parent = target.getParent();
Path temporary = null;
try {
if (parent != null) {
Files.createDirectories(parent);
}
byte[] content = objectMapper.writeValueAsBytes(java.util.Map.ofEntries(
java.util.Map.entry("phase", status.phase().name()),
java.util.Map.entry("progress", status.progress()),
java.util.Map.entry("message", Objects.requireNonNullElse(status.message(), "")),
java.util.Map.entry("version", Objects.requireNonNullElse(status.latestVersion(), "")),
java.util.Map.entry("error", Objects.requireNonNullElse(status.error(), "")),
java.util.Map.entry("updatedAt", Instant.now().toString()),
java.util.Map.entry("publishedAt", status.publishedAt() == null ? "" : status.publishedAt().toString()),
java.util.Map.entry("releaseNotes", Objects.requireNonNullElse(status.releaseNotes(), "")),
java.util.Map.entry("assets", Objects.requireNonNullElse(status.assets(), List.of()))
));
if (content.length > MAX_STATE_BYTES) {
throw new IOException("update state is too large");
}
Path directory = parent == null ? Path.of(".").toAbsolutePath().normalize() : parent;
temporary = Files.createTempFile(directory, ".update-state-", ".tmp");
Files.write(temporary, content, StandardOpenOption.TRUNCATE_EXISTING);
try {
Files.setPosixFilePermissions(temporary, Set.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.GROUP_READ));
} catch (UnsupportedOperationException ignored) {
// Keep local non-POSIX tests portable.
}
Files.move(temporary, target,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
temporary = null;
} catch (Exception exception) {
log.warn("Unable to persist update state {}: {}", target, exception.getMessage());
} finally {
if (temporary != null) {
try {
Files.deleteIfExists(temporary);
} catch (IOException ignored) {
// Best effort cleanup only.
}
}
}
}
private List<ReleaseAsset> readAssets(JsonNode node, List<ReleaseAsset> fallback) {
if (!node.isArray()) {
return fallback == null ? List.of() : fallback;
}
if (node.size() > MAX_RELEASE_ASSETS) {
return List.of();
}
List<ReleaseAsset> assets = new ArrayList<>();
for (JsonNode asset : node) {
String name = text(asset, "name");
String downloadUrl = text(asset, "downloadUrl");
if (name.isBlank() || downloadUrl.isBlank()) {
continue;
}
validateAssetUrl(downloadUrl);
assets.add(new ReleaseAsset(name, downloadUrl, asset.path("size").asLong(0)));
}
return List.copyOf(assets);
}
private URI configuredBaseUri() {
URI uri;
try {
uri = URI.create(Objects.requireNonNullElse(properties.getGiteaBaseUrl(), "").trim());
} catch (IllegalArgumentException exception) {
throw new ApiException(500, "Gitea 地址格式无效");
}
if (uri.getHost() == null || uri.getUserInfo() != null || uri.getRawQuery() != null
|| uri.getRawFragment() != null || uri.getPort() > 65535) {
throw new ApiException(500, "Gitea 地址格式无效");
}
return uri;
}
private void validateAssetUrl(String value) {
URI asset;
try {
asset = URI.create(value);
} catch (IllegalArgumentException exception) {
throw new ApiException(502, "Release 文件地址无效");
}
if (asset.getUserInfo() != null || asset.getRawFragment() != null) {
throw new ApiException(502, "Release 文件地址无效");
}
requireSameOrigin(asset, configuredBaseUri(), "Release 文件");
}
private static void requireSameOrigin(URI actual, URI expected, String label) {
String actualScheme = Objects.requireNonNullElse(actual.getScheme(), "").toLowerCase(Locale.ROOT);
String expectedScheme = Objects.requireNonNullElse(expected.getScheme(), "").toLowerCase(Locale.ROOT);
String actualHost = Objects.requireNonNullElse(actual.getHost(), "").toLowerCase(Locale.ROOT);
String expectedHost = Objects.requireNonNullElse(expected.getHost(), "").toLowerCase(Locale.ROOT);
if (!actualScheme.equals(expectedScheme) || !actualHost.equals(expectedHost)
|| effectivePort(actual) != effectivePort(expected)) {
throw new ApiException(502, label + "跳转到了未受信任的服务器");
}
}
private static int effectivePort(URI uri) {
if (uri.getPort() >= 0) {
return uri.getPort();
}
return "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
}
private boolean isConfigured() {
return properties.isEnabled()
&& properties.getGiteaBaseUrl() != null
&& !properties.getGiteaBaseUrl().isBlank()
&& properties.getRepository() != null
&& !properties.getRepository().isBlank();
}
private static boolean isActivePhase(UpdatePhase phase) {
return phase == UpdatePhase.STARTING
|| phase == UpdatePhase.DOWNLOADING
|| phase == UpdatePhase.VERIFYING
|| phase == UpdatePhase.INSTALLING
|| phase == UpdatePhase.RESTARTING
|| phase == UpdatePhase.ROLLING_BACK;
}
private static String text(JsonNode node, String field) {
JsonNode value = node.path(field);
return value.isTextual() ? value.asText().trim() : "";
@@ -4,6 +4,7 @@ import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.domain.SysUser;
import com.kaidi.oa.service.AuthService;
import com.kaidi.oa.service.AuthorizationService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.GetMapping;
@@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeSet;
/**
* Authentication endpoints. Issues an opaque token on login; the frontend sends
@@ -25,10 +27,13 @@ public class AuthController {
private final AuthService authService;
private final CurrentUserResolver currentUser;
private final AuthorizationService authorizationService;
public AuthController(AuthService authService, CurrentUserResolver currentUser) {
public AuthController(AuthService authService, CurrentUserResolver currentUser,
AuthorizationService authorizationService) {
this.authService = authService;
this.currentUser = currentUser;
this.authorizationService = authorizationService;
}
public record LoginRequest(@NotBlank String loginName, @NotBlank String password) {
@@ -65,6 +70,7 @@ public class AuthController {
map.put("deptId", user.getDeptId());
map.put("title", user.getTitle());
map.put("email", user.getEmail());
map.put("roles", new TreeSet<>(authorizationService.roleCodesOf(user.getId())));
return map;
}
}
@@ -1,12 +1,16 @@
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiResp;
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.UpdateStatus;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.GetMapping;
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.RestController;
@@ -17,9 +21,24 @@ import org.springframework.web.bind.annotation.RestController;
public class SystemUpdateController {
private final SystemUpdateService updateService;
private final SystemUpdateConfigService configService;
public SystemUpdateController(SystemUpdateService updateService) {
public SystemUpdateController(SystemUpdateService updateService, SystemUpdateConfigService configService) {
this.updateService = updateService;
this.configService = configService;
}
@GetMapping("/config")
public ApiResp<UpdateConfig> config() {
return ApiResp.ok(configService.get());
}
@PutMapping("/config")
public ApiResp<UpdateConfig> saveConfig(@Valid @RequestBody UpdateConfigRequest request) {
updateService.ensureConfigurationMutable();
UpdateConfig saved = configService.save(request);
updateService.configurationChanged();
return ApiResp.ok(saved);
}
@GetMapping("/status")