From dbb52c317cc41652663c7217bfe052fb16b68207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=92=E6=9F=A0?= <865274218@qq.com> Date: Thu, 13 Aug 2026 18:17:02 +0800 Subject: [PATCH] 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 get(String settingKey, Class 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. --- .../db/migration/V44__system_setting.sql | 15 +++ .../domain/setting/SystemSetting.java | 70 +++++++++++ .../setting/SystemSettingRepository.java | 8 ++ .../domain/setting/SystemSettingService.java | 78 +++++++++++++ .../skillhub/domain/setting/package-info.java | 10 ++ .../setting/SystemSettingServiceTest.java | 110 ++++++++++++++++++ .../infra/jpa/SystemSettingJpaRepository.java | 13 +++ 7 files changed, 304 insertions(+) create mode 100644 server/skillhub-app/src/main/resources/db/migration/V44__system_setting.sql create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSetting.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/package-info.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/setting/SystemSettingServiceTest.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SystemSettingJpaRepository.java diff --git a/server/skillhub-app/src/main/resources/db/migration/V44__system_setting.sql b/server/skillhub-app/src/main/resources/db/migration/V44__system_setting.sql new file mode 100644 index 00000000..11f01954 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V44__system_setting.sql @@ -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 +); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSetting.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSetting.java new file mode 100644 index 00000000..2abc026b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSetting.java @@ -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; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingRepository.java new file mode 100644 index 00000000..ee6522cf --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingRepository.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.domain.setting; + +import java.util.Optional; + +public interface SystemSettingRepository { + Optional findBySettingKey(String settingKey); + SystemSetting save(SystemSetting setting); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingService.java new file mode 100644 index 00000000..6badc519 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingService.java @@ -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. + * + *

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. + * + *

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 get(String settingKey, Class type, T defaults) { + Optional 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 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; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/package-info.java new file mode 100644 index 00000000..6f4038ce --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/package-info.java @@ -0,0 +1,10 @@ +/** + * Operator-configurable platform settings. + * + *

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; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/setting/SystemSettingServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/setting/SystemSettingServiceTest.java new file mode 100644 index 00000000..581fd3fd --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/setting/SystemSettingServiceTest.java @@ -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 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()); + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SystemSettingJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SystemSettingJpaRepository.java new file mode 100644 index 00000000..82d4754a --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SystemSettingJpaRepository.java @@ -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, SystemSettingRepository { + Optional findBySettingKey(String settingKey); +}