diff --git a/backend/src/main/java/com/danyang/match/platform/shared/config/FeatureFlagConfig.java b/backend/src/main/java/com/danyang/match/platform/shared/config/FeatureFlagConfig.java
index e0897eb..7013c53 100644
--- a/backend/src/main/java/com/danyang/match/platform/shared/config/FeatureFlagConfig.java
+++ b/backend/src/main/java/com/danyang/match/platform/shared/config/FeatureFlagConfig.java
@@ -1,16 +1,38 @@
package com.danyang.match.platform.shared.config;
+import com.danyang.match.platform.shared.featureflag.FeatureFlagOverrideService;
+import jakarta.annotation.PostConstruct;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+
/**
* Feature Flag 配置
* V1.1: 新增 counseling / premarital / content / serviceLocation / localEligibility 开关
+ *
+ *
取值优先级(V048 起):数据库覆盖({@code feature_flag_overrides})
+ * > yml / 环境变量 > Java 字段默认值。
+ * 数据库覆盖在 {@link #reloadFromPersistence()} 中于容器启动时应用 —— 该时机晚于
+ * {@code @ConfigurationProperties} 绑定,因此压得住 yml 默认值;若反过来就会
+ * 被 yml 静默盖掉。
*/
+@Slf4j
@Component
@ConfigurationProperties(prefix = "features")
public class FeatureFlagConfig {
+ public static final String TYPE_BOOLEAN = "boolean";
+ public static final String TYPE_INT = "int";
+ public static final String TYPE_STRING = "string";
+
// ---- V1.0 已有 Flag ----
private boolean activityEnabled = true;
private boolean matchmakerEnabled = true;
@@ -49,6 +71,164 @@ public class FeatureFlagConfig {
// ⚠️ 2026-09-19 移除:counselingAttachmentsPrivate / counselingAttachmentsBucket 从未被读取 ——
// 咨询附件的存储实走 object-storage.bucket-private,这两项与真实链路无关。
+
+ /**
+ * 覆盖值的持久化服务。
+ *
+ *
用字段注入而非构造器注入:{@code @ConfigurationProperties} 依赖无参构造 + setter 绑定,
+ * 加 final 字段会把它变成构造器绑定,破坏既有绑定方式。
+ */
+ @Autowired
+ private FeatureFlagOverrideService overrideService;
+
+ /**
+ * 默认配置快照(yml + Java 字段默认值),在首次 {@link #reloadFromPersistence()} 时捕获。
+ *
+ *
用途:DELETE 覆盖后要能回到「没改过」的那个值。快照必须在应用覆盖之前取,
+ * 且只取一次 —— 否则第二次 reload 会把上一次的覆盖值误当成默认值。
+ */
+ private final Map defaultsSnapshot = new LinkedHashMap<>();
+
+ /**
+ * 从数据库加载覆盖值并应用。容器启动时由 {@code @PostConstruct} 触发一次。
+ *
+ * 为什么必须先回到默认再应用覆盖:该方法可能被重复调用(运维手工触发 / 测试),
+ * 若不清回默认,历史覆盖会滞留 —— reload 就不再是「反映数据库当前状态」。
+ */
+ @PostConstruct
+ public void reloadFromPersistence() {
+ if (defaultsSnapshot.isEmpty()) {
+ defaultsSnapshot.putAll(getAllFlags());
+ } else {
+ applyValues(defaultsSnapshot);
+ }
+ if (overrideService == null) {
+ // 单元测试中手工 new 出来的实例(不走 Spring 生命周期):无持久化服务,保持默认值。
+ return;
+ }
+ overrideService.loadOverrides().forEach(this::applyPersistedValue);
+ }
+
+ /**
+ * 应用一条来自数据库的覆盖值。
+ *
+ *
两类异常都是故意 fail-fast:数据库是 Source of Truth,
+ * 一行写坏的覆盖若被静默忽略,表现为「后台显示已改、实际没生效」,比启动失败更难查。
+ * 修法明确:删掉那一行(DELETE /operator/feature-flags/{flagName})。
+ */
+ private void applyPersistedValue(String flagName, String rawValue) {
+ if (!getAllFlags().containsKey(flagName)) {
+ log.warn("忽略数据库中已不存在的 flag 覆盖: {}(可由 DELETE /operator/feature-flags/{} 清理)",
+ flagName, flagName);
+ return;
+ }
+ if (rawValue == null) {
+ throw new IllegalStateException("flag 覆盖值为 null,拒绝启动: " + flagName);
+ }
+ Object parsed;
+ try {
+ parsed = parseRawValue(flagName, rawValue);
+ } catch (NumberFormatException e) {
+ throw new IllegalStateException(
+ "flag 覆盖值无法按声明类型解析,拒绝启动: " + flagName + " = " + rawValue
+ + "(期望 " + valueTypeOf(flagName) + ")", e);
+ }
+ setFlagValue(flagName, parsed);
+ }
+
+ private void applyValues(Map values) {
+ values.forEach(this::setFlagValue);
+ }
+
+ /** 按字段声明类型把字符串解析回原类型。 */
+ private Object parseRawValue(String flagName, String rawValue) {
+ String type = valueTypeOf(flagName);
+ if (TYPE_BOOLEAN.equals(type)) {
+ return Boolean.parseBoolean(rawValue);
+ }
+ if (TYPE_INT.equals(type)) {
+ return Integer.valueOf(rawValue);
+ }
+ return rawValue;
+ }
+
+ /**
+ * 设置单个 flag 的值(按字段声明类型分派)。
+ *
+ * 与控制器共用同一份分派逻辑 —— 此前分派写在 Controller 里,
+ * 一旦新增类型就会出现「落库用一套、内存用另一套」的漂移。
+ *
+ * @throws ClassCastException value 类型与 flag 声明类型不匹配
+ * @throws NoSuchFieldException flag 不存在
+ */
+ public void setFlagValue(String flagName, Object value) {
+ try {
+ Field field = FeatureFlagConfig.class.getDeclaredField(flagName);
+ Class> type = field.getType();
+ Method setter;
+ if (type == boolean.class || type == Boolean.class) {
+ setter = FeatureFlagConfig.class.getMethod("set" + capitalize(flagName), boolean.class);
+ setter.invoke(this, (Boolean) value);
+ } else if (type == int.class || type == Integer.class) {
+ setter = FeatureFlagConfig.class.getMethod("set" + capitalize(flagName), int.class);
+ setter.invoke(this, ((Number) value).intValue());
+ } else if (type == String.class) {
+ setter = FeatureFlagConfig.class.getMethod("set" + capitalize(flagName), String.class);
+ setter.invoke(this, Objects.toString(value, null));
+ } else {
+ throw new IllegalArgumentException("不支持的 Flag 类型: " + type.getSimpleName());
+ }
+ } catch (NoSuchFieldException | NoSuchMethodException e) {
+ throw new IllegalArgumentException("未知的 Feature Flag: " + flagName, e);
+ } catch (ReflectiveOperationException e) {
+ Throwable cause = e.getCause() == null ? e : e.getCause();
+ if (cause instanceof ClassCastException) {
+ throw (ClassCastException) cause;
+ }
+ throw new IllegalStateException("无法更新 Flag: " + flagName, cause);
+ }
+ }
+
+ /**
+ * 该 flag 的持久化类型标记。
+ *
+ *
回读时以字段声明类型为准,value_type 只用于记录与排障 ——
+ * 若以 DB 里的 value_type 为准,改一次字段名就会让历史数据按错的类型解析。
+ */
+ public String valueTypeOf(String flagName) {
+ try {
+ Class> type = FeatureFlagConfig.class.getDeclaredField(flagName).getType();
+ if (type == boolean.class || type == Boolean.class) {
+ return TYPE_BOOLEAN;
+ }
+ if (type == int.class || type == Integer.class) {
+ return TYPE_INT;
+ }
+ return TYPE_STRING;
+ } catch (NoSuchFieldException e) {
+ throw new IllegalArgumentException("未知的 Feature Flag: " + flagName, e);
+ }
+ }
+
+ /** 恢复为默认配置(删除覆盖后用)。 */
+ public void resetToDefault(String flagName) {
+ Object def = defaultsSnapshot.get(flagName);
+ if (def == null) {
+ log.warn("默认配置快照为空,无法恢复 {}: reloadFromPersistence() 尚未执行", flagName);
+ return;
+ }
+ setFlagValue(flagName, def);
+ }
+
+ /** 默认配置快照(只读副本),供排障与测试核对。 */
+ public Map getDefaultsSnapshot() {
+ return Map.copyOf(defaultsSnapshot);
+ }
+
+ private String capitalize(String s) {
+ return s.substring(0, 1).toUpperCase() + s.substring(1);
+ }
+
// ==================== Getters & Setters ====================
public boolean isActivityEnabled() { return activityEnabled; }
@@ -96,8 +276,8 @@ public class FeatureFlagConfig {
/**
* 获取所有 Feature Flag 的当前值(供 /operator/feature-flags 接口使用)
*/
- public java.util.Map getAllFlags() {
- java.util.Map flags = new java.util.HashMap<>();
+ public Map getAllFlags() {
+ Map flags = new HashMap<>();
flags.put("activityEnabled", activityEnabled);
flags.put("matchmakerEnabled", matchmakerEnabled);
flags.put("chatImageEnabled", chatImageEnabled);
diff --git a/backend/src/main/java/com/danyang/match/platform/shared/controller/FeatureFlagController.java b/backend/src/main/java/com/danyang/match/platform/shared/controller/FeatureFlagController.java
index 13e9e83..14b9ff4 100644
--- a/backend/src/main/java/com/danyang/match/platform/shared/controller/FeatureFlagController.java
+++ b/backend/src/main/java/com/danyang/match/platform/shared/controller/FeatureFlagController.java
@@ -2,6 +2,8 @@ package com.danyang.match.platform.shared.controller;
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import com.danyang.match.platform.shared.dto.ApiResponse;
+import com.danyang.match.platform.shared.enums.ApiResponseCode;
+import com.danyang.match.platform.shared.featureflag.FeatureFlagOverrideService;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -12,7 +14,7 @@ import java.util.Map;
/**
* Feature Flag 管理控制器
- * V1.1 Phase 22
+ * V1.1 Phase 22;V048 起 PUT 真正落库(此前仅改内存单例,重启即丢)。
*/
@RestController
@RequestMapping("/operator/feature-flags")
@@ -21,6 +23,7 @@ import java.util.Map;
public class FeatureFlagController {
private final FeatureFlagConfig featureFlagConfig;
+ private final FeatureFlagOverrideService featureFlagOverrideService;
/**
* 获取所有 Feature Flag 当前值
@@ -32,12 +35,17 @@ public class FeatureFlagController {
}
/**
- * 更新单个 Feature Flag
+ * 更新单个 Feature Flag(**持久化**)
* PUT /api/v1/operator/feature-flags/{flagName}
- * Body: { "value": }
+ * Body: { "value": <boolean|int|string> }
*
- * 支持布尔 / 整数 / 字符串三类 flag(此前仅支持布尔,导致 7 个非布尔 flag
- * 经 API 更新必失败)。未知 flag 或缺失 value 返回 400,且不会静默把功能置为关闭。
+ *
顺序刻意是「先改内存、再落库」:落库成功但内存没改,会让运维看到旧值;
+ * 反过来(先落库再改内存)若落库失败,内存却已经改了 —— 那才是真正的分裂状态。
+ * 内存改动失败时直接返回 400,不会有任何一行被写进数据库。
+ *
+ *
支持布尔 / 整数 / 字符串三类 flag(此前仅支持布尔,导致若干非布尔 flag
+ * 经 API 更新必失败)。未知 flag、缺失 value 或类型不匹配返回 400,
+ * 且不会静默把功能置为关闭,也不会落库。
*/
@PutMapping("/{flagName}")
public ApiResponse