mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-15 04:18:20 +00:00
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:
parent
2e78f79e83
commit
dbb52c317c
7 changed files with 304 additions and 0 deletions
|
|
@ -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
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue