feat(admin): add system settings storage

SkillHub has no mechanism for settings an operator can change without a
redeploy: the only per-deployment knobs live in application.yml, and the
only stored preferences are per-user notification preferences.

Add a generic store. One row holds one setting group serialized as JSON,
so a group can gain fields without a schema migration.

Reads take the caller's defaults:

    <T> T get(String settingKey, Class<T> type, T defaults)

which gives two properties worth keeping:

- A group nobody has overridden has no row, and resolves to whatever the
  deployment configured. Configuration-file-only deployments keep working
  exactly as before, and an upgrade changes no behaviour.
- A stored document that can no longer be parsed also falls back to the
  defaults, with a warning. One malformed row must not take down the flows
  that read settings, such as login.

Groups are deserialized with unknown fields ignored so a rolling upgrade
can read documents written by a newer node.

No consumer yet; the following commit adds the first one.
This commit is contained in:
青柠 2026-08-13 18:17:02 +08:00 • committed by XiaoSeS
parent 2e78f79e83
commit dbb52c317c
7 changed files with 304 additions and 0 deletions

View file

@ -0,0 +1,15 @@
-- V44__system_setting.sql
--
-- Operator-configurable platform settings.
--
-- Each row holds one setting group as a JSON document so a group can gain
-- fields without a schema migration. A row is written only when an operator
-- overrides a group; an absent row means "use the configured defaults", which
-- keeps configuration-file-only deployments working unchanged.
CREATE TABLE system_setting (
setting_key VARCHAR(128) PRIMARY KEY,
setting_value JSONB NOT NULL,
updated_by VARCHAR(128) REFERENCES user_account(id),
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View file

@ -0,0 +1,70 @@
package com.iflytek.skillhub.domain.setting;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.Instant;
/**
* One operator-configurable setting group, stored as a JSON document.
*/
@Entity
@Table(name = "system_setting")
public class SystemSetting {
@Id
@Column(name = "setting_key", nullable = false, length = 128)
private String settingKey;
@Column(name = "setting_value", nullable = false)
@JdbcTypeCode(SqlTypes.JSON)
private String settingValue;
@Column(name = "updated_by", length = 128)
private String updatedBy;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
protected SystemSetting() {
}
public SystemSetting(String settingKey, String settingValue, String updatedBy, Instant updatedAt) {
this.settingKey = settingKey;
this.settingValue = settingValue;
this.updatedBy = updatedBy;
this.updatedAt = updatedAt;
}
public String getSettingKey() {
return settingKey;
}
public String getSettingValue() {
return settingValue;
}
public void setSettingValue(String settingValue) {
this.settingValue = settingValue;
}
public String getUpdatedBy() {
return updatedBy;
}
public void setUpdatedBy(String updatedBy) {
this.updatedBy = updatedBy;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
}
}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.domain.setting;
import java.util.Optional;
public interface SystemSettingRepository {
Optional<SystemSetting> findBySettingKey(String settingKey);
SystemSetting save(SystemSetting setting);
}

View file

@ -0,0 +1,78 @@
package com.iflytek.skillhub.domain.setting;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Instant;
import java.util.Optional;
/**
* Reads and writes operator-configurable setting groups.
*
* <p>Every read carries the caller's defaults so that a deployment which has never touched a group
* — or which configures it entirely through {@code application.yml} — behaves exactly as it did
* before the group existed.
*/
@Service
public class SystemSettingService {
private static final Logger log = LoggerFactory.getLogger(SystemSettingService.class);
private final SystemSettingRepository systemSettingRepository;
private final ObjectMapper objectMapper;
private final Clock clock;
public SystemSettingService(SystemSettingRepository systemSettingRepository,
ObjectMapper objectMapper,
Clock clock) {
this.systemSettingRepository = systemSettingRepository;
this.objectMapper = objectMapper;
this.clock = clock;
}
/**
* Returns the stored group, or {@code defaults} when the group has never been overridden.
*
* <p>A stored document that can no longer be parsed also falls back to {@code defaults}: a
* malformed row must not take down the flows that read settings, such as login.
*/
@Transactional(readOnly = true)
public <T> T get(String settingKey, Class<T> type, T defaults) {
Optional<SystemSetting> stored = systemSettingRepository.findBySettingKey(settingKey);
if (stored.isEmpty()) {
return defaults;
}
try {
return objectMapper.readValue(stored.get().getSettingValue(), type);
} catch (Exception e) {
log.warn("Falling back to defaults for system setting '{}': stored value is not readable as {}",
settingKey, type.getSimpleName(), e);
return defaults;
}
}
/**
* Overwrites a setting group and records who changed it.
*/
@Transactional
public <T> T put(String settingKey, T value, String updatedBy) {
String json;
try {
json = objectMapper.writeValueAsString(value);
} catch (Exception e) {
throw new IllegalArgumentException("System setting '" + settingKey + "' is not serializable", e);
}
Instant now = Instant.now(clock);
SystemSetting setting = systemSettingRepository.findBySettingKey(settingKey)
.orElseGet(() -> new SystemSetting(settingKey, json, updatedBy, now));
setting.setSettingValue(json);
setting.setUpdatedBy(updatedBy);
setting.setUpdatedAt(now);
systemSettingRepository.save(setting);
return value;
}
}

View file

@ -0,0 +1,10 @@
/**
* Operator-configurable platform settings.
*
* <p>Settings are grouped: one {@link com.iflytek.skillhub.domain.setting.SystemSetting} row holds
* one group serialized as JSON. Callers read a group through
* {@link com.iflytek.skillhub.domain.setting.SystemSettingService} with a typed default, so a group
* that has never been overridden resolves to the deployment's configured defaults rather than to
* {@code null}.
*/
package com.iflytek.skillhub.domain.setting;

View file

@ -0,0 +1,110 @@
package com.iflytek.skillhub.domain.setting;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SystemSettingServiceTest {
private static final String KEY = "demo.group";
private static final Instant NOW = Instant.parse("2026-01-02T03:04:05Z");
@JsonIgnoreProperties(ignoreUnknown = true)
record DemoSettings(boolean enabled, String template) {
}
@Mock
private SystemSettingRepository systemSettingRepository;
private SystemSettingService service;
@BeforeEach
void setUp() {
service = new SystemSettingService(
systemSettingRepository,
new ObjectMapper(),
Clock.fixed(NOW, ZoneOffset.UTC));
}
@Test
void getReturnsDefaultsWhenGroupWasNeverOverridden() {
DemoSettings defaults = new DemoSettings(false, "${username}");
when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.empty());
assertSame(defaults, service.get(KEY, DemoSettings.class, defaults));
}
@Test
void getReturnsStoredGroupWhenPresent() {
when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.of(
new SystemSetting(KEY, "{\"enabled\":true,\"template\":\"${username}-space\"}", "usr_1", NOW)));
DemoSettings resolved = service.get(KEY, DemoSettings.class, new DemoSettings(false, "${username}"));
assertEquals(new DemoSettings(true, "${username}-space"), resolved);
}
@Test
void getIgnoresUnknownFieldsSoOlderNodesCanReadNewerDocuments() {
when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.of(
new SystemSetting(KEY, "{\"enabled\":true,\"template\":\"x\",\"addedLater\":42}", "usr_1", NOW)));
assertEquals(new DemoSettings(true, "x"),
service.get(KEY, DemoSettings.class, new DemoSettings(false, "${username}")));
}
@Test
void getFallsBackToDefaultsWhenStoredDocumentIsMalformed() {
DemoSettings defaults = new DemoSettings(false, "${username}");
when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.of(
new SystemSetting(KEY, "not json", "usr_1", NOW)));
assertSame(defaults, service.get(KEY, DemoSettings.class, defaults));
}
@Test
void putStoresSerializedGroupWithActorAndTimestamp() {
when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.empty());
service.put(KEY, new DemoSettings(true, "${username}"), "usr_admin");
ArgumentCaptor<SystemSetting> captor = ArgumentCaptor.forClass(SystemSetting.class);
verify(systemSettingRepository).save(captor.capture());
SystemSetting saved = captor.getValue();
assertEquals(KEY, saved.getSettingKey());
assertEquals("{\"enabled\":true,\"template\":\"${username}\"}", saved.getSettingValue());
assertEquals("usr_admin", saved.getUpdatedBy());
assertEquals(NOW, saved.getUpdatedAt());
}
@Test
void putOverwritesExistingRowInPlace() {
SystemSetting existing = new SystemSetting(KEY, "{\"enabled\":false,\"template\":\"old\"}",
"usr_previous", NOW.minusSeconds(60));
when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.of(existing));
service.put(KEY, new DemoSettings(true, "new"), "usr_admin");
verify(systemSettingRepository).save(any(SystemSetting.class));
assertEquals("{\"enabled\":true,\"template\":\"new\"}", existing.getSettingValue());
assertEquals("usr_admin", existing.getUpdatedBy());
assertEquals(NOW, existing.getUpdatedAt());
}
}

View file

@ -0,0 +1,13 @@
package com.iflytek.skillhub.infra.jpa;
import com.iflytek.skillhub.domain.setting.SystemSetting;
import com.iflytek.skillhub.domain.setting.SystemSettingRepository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface SystemSettingJpaRepository extends JpaRepository<SystemSetting, String>, SystemSettingRepository {
Optional<SystemSetting> findBySettingKey(String settingKey);
}