feat(flags): PUT /operator/feature-flags 真持久化(V048)

此前 updateFlag 只改 FeatureFlagConfig 内存单例的字段:重启即回默认值、
多实例互不一致,表现为「后台显示已关闭,一次发布后功能又回来了」。

- V048 新建 feature_flag_overrides(flag_key 主键;值存字符串、回读按字段
  声明类型解析;updated_by 记审计)。updated_by 必须建 uuid 而非 varchar,
  否则 UUIDStringTypeHandler 以 uuid 发参数会在 PG 抛
  operator does not exist: character varying = uuid(同 V046 的 actor_id)。
- FeatureFlagOverride/Mapper/Service:upsert 走「先 UPDATE、0 行再 INSERT」,
  不用 PG 的 ON CONFLICT(H2 测试库不认,同 IdempotencyMapper 取舍)。
- FeatureFlagConfig.reloadFromPersistence() 挂 @PostConstruct:该时机晚于
  @ConfigurationProperties 绑定,故 DB 覆盖压得住 yml 默认值。
- PUT 先改内存再落库(内存改动失败即 400,不写库);新增 DELETE 恢复默认。
- 覆盖值无法解析时启动 fail-fast,不静默回退默认(否则「配了不生效」)。

守卫 FeatureFlagPersistenceGuardTest 11 例,核心是「另起实例重新加载后改动仍在」
(重启等价路径),并自检 @PostConstruct 未被摘掉。
负向验证:注入不落库 + 摘钩子 -> 恰好 5 例变红,其余不受影响;还原 md5 一致。

全量回归 700 / 0 / 0 / 3(起点 689,净 +11);UI 契约门禁 18/18。
This commit is contained in:
WorkBuddy
2026-09-19 17:47:16 +08:00
parent 018626935a
commit d605c04707
12 changed files with 752 additions and 46 deletions
@@ -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 开关
*
* <p><b>取值优先级(V048 起)</b>:数据库覆盖({@code feature_flag_overrides})
* &gt; yml / 环境变量 &gt; 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,这两项与真实链路无关。
/**
* 覆盖值的持久化服务。
*
* <p>用字段注入而非构造器注入:{@code @ConfigurationProperties} 依赖无参构造 + setter 绑定,
* 加 final 字段会把它变成构造器绑定,破坏既有绑定方式。
*/
@Autowired
private FeatureFlagOverrideService overrideService;
/**
* 默认配置快照(yml + Java 字段默认值),在首次 {@link #reloadFromPersistence()} 时捕获。
*
* <p>用途:DELETE 覆盖后要能回到「没改过」的那个值。快照必须在<b>应用覆盖之前</b>取,
* 且只取一次 —— 否则第二次 reload 会把上一次的覆盖值误当成默认值。
*/
private final Map<String, Object> defaultsSnapshot = new LinkedHashMap<>();
/**
* 从数据库加载覆盖值并应用。容器启动时由 {@code @PostConstruct} 触发一次。
*
* <p>为什么必须先回到默认再应用覆盖:该方法可能被重复调用(运维手工触发 / 测试),
* 若不清回默认,历史覆盖会滞留 —— 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);
}
/**
* 应用一条来自数据库的覆盖值。
*
* <p>两类异常都是<b>故意 fail-fast</b>:数据库是 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<String, Object> 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 的值(按字段声明类型分派)。
*
* <p>与控制器共用同一份分派逻辑 —— 此前分派写在 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 的持久化类型标记。
*
* <p>回读时以<b>字段声明类型</b>为准,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<String, Object> 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<String, Object> getAllFlags() {
java.util.Map<String, Object> flags = new java.util.HashMap<>();
public Map<String, Object> getAllFlags() {
Map<String, Object> flags = new HashMap<>();
flags.put("activityEnabled", activityEnabled);
flags.put("matchmakerEnabled", matchmakerEnabled);
flags.put("chatImageEnabled", chatImageEnabled);
@@ -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": <boolean|int|string> }
* Body: { "value": &lt;boolean|int|string&gt; }
*
* <p>支持布尔 / 整数 / 字符串三类 flag(此前仅支持布尔,导致 7 个非布尔 flag
* 经 API 更新必失败)。未知 flag 或缺失 value 返回 400,且不会静默把功能置为关闭。
* <p>顺序刻意是「先改内存、再落库」:落库成功但内存没改,会让运维看到旧值;
* 反过来(先落库再改内存)若落库失败,内存却已经改了 —— 那才是真正的分裂状态。
* 内存改动失败时直接返回 400,不会有任何一行被写进数据库。
*
* <p>支持布尔 / 整数 / 字符串三类 flag(此前仅支持布尔,导致若干非布尔 flag
* 经 API 更新必失败)。未知 flag、缺失 value 或类型不匹配返回 400,
* 且不会静默把功能置为关闭,也不会落库。
*/
@PutMapping("/{flagName}")
public ApiResponse<Map<String, Object>> updateFlag(
@@ -46,53 +54,68 @@ public class FeatureFlagController {
@RequestBody Map<String, Object> body) {
if (!featureFlagConfig.getAllFlags().containsKey(flagName)) {
return ApiResponse.error(
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
ApiResponseCode.BAD_REQUEST,
"未知的 Feature Flag: " + flagName);
}
if (!body.containsKey("value")) {
return ApiResponse.error(
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
ApiResponseCode.BAD_REQUEST,
"请求体必须包含 value 字段");
}
Object value = body.get("value");
if (value == null) {
// 字符串化后会变成字面量 "null",回读时无法与真正的 "null" 区分 —— 直接拒。
return ApiResponse.error(
ApiResponseCode.BAD_REQUEST,
"value 不能为 null;要恢复默认请改用 DELETE");
}
try {
java.lang.reflect.Field field = featureFlagConfig.getClass().getDeclaredField(flagName);
Class<?> type = field.getType();
if (type == boolean.class || type == Boolean.class) {
boolean b = ((Boolean) value);
featureFlagConfig.getClass()
.getMethod("set" + capitalize(flagName), boolean.class)
.invoke(featureFlagConfig, b);
} else if (type == int.class || type == Integer.class) {
int i = ((Number) value).intValue();
featureFlagConfig.getClass()
.getMethod("set" + capitalize(flagName), int.class)
.invoke(featureFlagConfig, i);
} else if (type == String.class) {
featureFlagConfig.getClass()
.getMethod("set" + capitalize(flagName), String.class)
.invoke(featureFlagConfig, String.valueOf(value));
} else {
return ApiResponse.error(
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
"不支持的 Flag 类型: " + type.getSimpleName());
}
return ApiResponse.ok(Map.of(
"flagName", flagName,
"value", featureFlagConfig.getAllFlags().get(flagName),
"updated", true));
featureFlagConfig.setFlagValue(flagName, value);
} catch (ClassCastException e) {
return ApiResponse.error(
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
ApiResponseCode.BAD_REQUEST,
"value 类型与 Flag " + flagName + " 不匹配: " + e.getMessage());
} catch (IllegalArgumentException e) {
return ApiResponse.error(
ApiResponseCode.BAD_REQUEST,
"无法更新该 Flag: " + e.getMessage());
} catch (Exception e) {
return ApiResponse.error(
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
ApiResponseCode.BAD_REQUEST,
"无法更新该 Flag: " + e.getMessage());
}
// 内存已生效,落库使其成为持久事实(重启后仍生效)
String operatorId = principal == null ? null : principal.id();
featureFlagOverrideService.save(
flagName,
String.valueOf(featureFlagConfig.getAllFlags().get(flagName)),
featureFlagConfig.valueTypeOf(flagName),
operatorId);
return ApiResponse.ok(Map.of(
"flagName", flagName,
"value", featureFlagConfig.getAllFlags().get(flagName),
"updated", true,
"persisted", true));
}
private String capitalize(String s) {
return s.substring(0, 1).toUpperCase() + s.substring(1);
/**
* 恢复为默认配置(删除数据库覆盖)
* DELETE /api/v1/operator/feature-flags/{flagName}
*
* <p>没有这个端点,一次写错的覆盖只能靠手工改库才能撤销。
*/
@DeleteMapping("/{flagName}")
public ApiResponse<Map<String, Object>> resetFlag(@PathVariable String flagName) {
if (!featureFlagConfig.getAllFlags().containsKey(flagName)) {
return ApiResponse.error(
ApiResponseCode.BAD_REQUEST,
"未知的 Feature Flag: " + flagName);
}
featureFlagOverrideService.delete(flagName);
featureFlagConfig.resetToDefault(flagName);
return ApiResponse.ok(Map.of(
"flagName", flagName,
"value", featureFlagConfig.getAllFlags().get(flagName),
"reset", true));
}
}
@@ -0,0 +1,28 @@
package com.danyang.match.platform.shared.featureflag;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* Feature Flag 的运行期覆盖记录。对应 {@code feature_flag_overrides}(V048)。
*
* <p>刻意<b>不</b>继承项目 BaseEntity:本表无软删语义(删除即恢复默认),
* 也不需要 id / created_at —— 与 {@code IdempotencyRecord} 同一取舍。
*
* <p>一张表只有 14 行(每个 flag 至多一行),主键就是 flag_key。
*/
@Data
public class FeatureFlagOverride {
public static final String TYPE_BOOLEAN = "boolean";
public static final String TYPE_INT = "int";
public static final String TYPE_STRING = "string";
private String flagKey;
/** 一律以字符串存储;回读时按 FeatureFlagConfig 字段的声明类型解析。 */
private String flagValue;
private String valueType;
private String updatedBy;
private OffsetDateTime updatedAt;
}
@@ -0,0 +1,85 @@
package com.danyang.match.platform.shared.featureflag;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.OffsetDateTime;
import java.util.List;
/**
* {@code feature_flag_overrides} 落库操作。
*
* <p>与 {@code IdempotencyMapper} 同一约定:全部用显式 SQL 并给列起别名,
* 不依赖 {@code map-underscore-to-camel-case}(该配置一旦被改动,隐式映射会静默失效)。
* 刻意<b>不</b> extends BaseMapper —— 本表主键是 flag_key 而非 id,
* 且 MyBatis-Plus 对无 {@code @TableId} 的实体不会注入 selectById/updateById。
*/
@Mapper
public interface FeatureFlagOverrideMapper {
@Select("""
SELECT flag_key AS flagKey,
flag_value AS flagValue,
value_type AS valueType,
updated_by AS updatedBy,
updated_at AS updatedAt
FROM feature_flag_overrides
""")
List<FeatureFlagOverride> findAll();
@Select("""
SELECT flag_key AS flagKey,
flag_value AS flagValue,
value_type AS valueType,
updated_by AS updatedBy,
updated_at AS updatedAt
FROM feature_flag_overrides
WHERE flag_key = #{flagKey}
""")
FeatureFlagOverride findByKey(@Param("flagKey") String flagKey);
/**
* @return 更新行数;0 表示此前无覆盖行
*/
@Update("""
UPDATE feature_flag_overrides
SET flag_value = #{flagValue},
value_type = #{valueType},
updated_by = #{updatedBy},
updated_at = #{now}
WHERE flag_key = #{flagKey}
""")
int updateValue(@Param("flagKey") String flagKey,
@Param("flagValue") String flagValue,
@Param("valueType") String valueType,
@Param("updatedBy") String updatedBy,
@Param("now") OffsetDateTime now);
/**
* 新增覆盖行。
*
* <p>刻意不用 {@code ON CONFLICT (flag_key) DO UPDATE} —— 那是 PG 语法,
* H2 测试库不认(同 IdempotencyMapper 的取舍)。upsert 由
* {@link FeatureFlagOverrideService#save} 以「先 UPDATE、0 行再 INSERT」实现,
* 两个库行为一致。
*/
@Insert("""
INSERT INTO feature_flag_overrides
(flag_key, flag_value, value_type, updated_by, updated_at)
VALUES
(#{flagKey}, #{flagValue}, #{valueType}, #{updatedBy}, #{now})
""")
int insert(@Param("flagKey") String flagKey,
@Param("flagValue") String flagValue,
@Param("valueType") String valueType,
@Param("updatedBy") String updatedBy,
@Param("now") OffsetDateTime now);
/** 删除覆盖即恢复默认配置。 */
@Delete("DELETE FROM feature_flag_overrides WHERE flag_key = #{flagKey}")
int deleteByKey(@Param("flagKey") String flagKey);
}
@@ -0,0 +1,78 @@
package com.danyang.match.platform.shared.featureflag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Feature Flag 覆盖值的读写。
*
* <p>存在意义:让「运维在后台改过的开关」在重启后仍然生效(V048)。
* 读写都只针对 {@code feature_flag_overrides},不碰内存状态 —— 内存状态由
* {@code FeatureFlagConfig} 负责,两者职责分开,避免"改了内存没落库"这类半截修复。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FeatureFlagOverrideService {
private final FeatureFlagOverrideMapper mapper;
/**
* 读取全部覆盖值。
*
* @return flagKey → 原始字符串值(未解析)
*/
public Map<String, String> loadOverrides() {
List<FeatureFlagOverride> rows = mapper.findAll();
Map<String, String> out = new LinkedHashMap<>();
if (rows == null) {
return out;
}
for (FeatureFlagOverride row : rows) {
if (row.getFlagKey() == null || row.getFlagKey().isBlank()) {
continue;
}
out.put(row.getFlagKey(), row.getFlagValue());
}
return out;
}
/**
* 写入(或更新)一条覆盖。
*
* <p>upsert 走「先 UPDATE、0 行再 INSERT」而非 PG 的 {@code ON CONFLICT}:
* H2 测试库不认后者(同 IdempotencyMapper 的取舍)。并发下两个请求可能同时
* 得到 UPDATE=0 并都去 INSERT,其中一个会撞主键 —— 此处捕获后改走 UPDATE,
* 保证最终只留一行(而不是把并发写暴露成 500)。
*/
@Transactional
public void save(String flagKey, String flagValue, String valueType, String operatorId) {
Objects.requireNonNull(flagKey, "flagKey 不能为空");
Objects.requireNonNull(flagValue, "flagValue 不能为空");
OffsetDateTime now = OffsetDateTime.now();
int updated = mapper.updateValue(flagKey, flagValue, valueType, operatorId, now);
if (updated > 0) {
return;
}
try {
mapper.insert(flagKey, flagValue, valueType, operatorId, now);
} catch (DuplicateKeyException e) {
log.debug("并发写入 flag 覆盖,回退为更新: flagKey={}", flagKey);
mapper.updateValue(flagKey, flagValue, valueType, operatorId, now);
}
}
/** 删除覆盖(恢复默认配置)。 */
public void delete(String flagKey) {
mapper.deleteByKey(flagKey);
}
}
@@ -0,0 +1,31 @@
-- V048: Feature Flag 运行期覆盖持久化
--
-- 背景(Phase 23 收尾遗留项):PUT /api/v1/operator/feature-flags/{flagName}
-- 此前只修改 FeatureFlagConfig 这个内存单例的字段 —— 重启即回默认值,
-- 且多实例部署时各实例互不一致。运维在后台关掉某个开关后,一次发布就把它改回来了,
-- 而后台仍然显示"已关闭",是典型的「显示与事实不符」。
--
-- 本表让「运维改过的开关」成为持久事实:
-- * 存在一行 = 该 flag 被覆盖,启动时由 FeatureFlagConfig 加载并覆盖默认配置;
-- * 不存在 = 沿用 yml / Java 字段默认值。
-- DELETE /{flagName} 删除该行即恢复默认。
--
-- flag_value 一律存字符串,回读时按 FeatureFlagConfig 字段的**声明类型**解析
-- (value_type 仅作记录与自检用)——避免为每个类型各开一列。
CREATE TABLE IF NOT EXISTS feature_flag_overrides (
flag_key VARCHAR(64) PRIMARY KEY,
flag_value VARCHAR(255) NOT NULL,
value_type VARCHAR(16) NOT NULL,
-- ⚠️ 必须是 uuid(不是 varchar):项目全局注册了 UUIDStringTypeHandler,
-- 它会把合法 UUID 形式的 String 以 Types.OTHER(uuid)发送。建成 varchar 会抛
-- "operator does not exist: character varying = uuid"(同 V046 修的 actor_id)。
updated_by UUID,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT chk_flag_value_type CHECK (value_type IN ('boolean', 'int', 'string'))
);
COMMENT ON TABLE feature_flag_overrides
IS 'Feature Flag 运行期覆盖值;存在即覆盖默认配置,删除即恢复默认';
COMMENT ON COLUMN feature_flag_overrides.updated_by
IS '最后一次修改该 flag 的运营人员 ID;null 表示非经 API 写入';
@@ -89,6 +89,9 @@ class EntityMigrationParityTest {
// 运营后台「内容分类」tab 的增改停用全链路零覆盖。已补入 schema-h2.sql。
"content_categories",
"activities",
// Phase 23 收尾(V048):flag 覆盖表。持久化若不在测试库里,
// 「重启后仍生效」这条核心不变量就无从验证(只能断言内存值)。
"feature_flag_overrides",
"user_local_eligibilities",
"local_eligibility_evidences",
// Phase 13:红娘服务三表此前完全缺失 → matchmaker 模块零真实覆盖(只能写假测试)。
@@ -23,7 +23,9 @@ class MigrationValidationTest {
// V046 修正其 actor_id 列类型(VARCHAR → uuid,否则参数以 uuid 到达时报
// `operator does not exist: character varying = uuid`,机制整体 500)
// Phase 23:V047 隐私设置补齐小程序端契约列(allow_activity_invitation / show_last_online)
private static final int LATEST_MIGRATION = 47;
// Phase 23 收尾:V048 feature_flag_overrides —— PUT /operator/feature-flags 真持久化
// (此前只改内存单例,重启即丢、多实例不一致)
private static final int LATEST_MIGRATION = 48;
private static final String MIGRATION_DIR =
"backend/src/main/resources/db/migration/";
@@ -2,13 +2,16 @@ 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.featureflag.FeatureFlagOverrideService;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map;
@@ -42,6 +45,10 @@ class FeatureFlagControllerTest {
@MockBean
private StringRedisTemplate stringRedisTemplate;
/** 真实的覆盖持久化服务(V048 起 PUT 会落库;@Transactional 保证用例间回滚)。 */
@Autowired
private FeatureFlagOverrideService overrideService;
private FeatureFlagConfig fixture;
private FeatureFlagController controller;
private OperatorPrincipal principal;
@@ -49,7 +56,10 @@ class FeatureFlagControllerTest {
@BeforeEach
void setUp() {
fixture = new FeatureFlagConfig();
controller = new FeatureFlagController(fixture);
ReflectionTestUtils.setField(fixture, "overrideService", overrideService);
// 等价于容器启动时的 @PostConstruct:捕获默认快照并加载已有覆盖
fixture.reloadFromPersistence();
controller = new FeatureFlagController(fixture, overrideService);
principal = new OperatorPrincipal("00000000-0000-0000-0000-000000000099");
}
@@ -0,0 +1,228 @@
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.featureflag.FeatureFlagOverride;
import com.danyang.match.platform.shared.featureflag.FeatureFlagOverrideMapper;
import com.danyang.match.platform.shared.featureflag.FeatureFlagOverrideService;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import jakarta.annotation.PostConstruct;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.Transactional;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Feature Flag「真持久化」守卫(V048)。
*
* <p>背景:PUT /operator/feature-flags/{flagName} 此前只改内存单例的字段 ——
* 重启即回默认值,多实例互不一致。运维在后台关掉开关后,一次发布就把它改回来,
* 而后台仍显示「已关闭」:显示与事实不符。
*
* <p><b>本守卫要钉的核心不变量</b>:改动在「另起一个配置实例重新加载」之后仍然存在
* —— 这正是「重启」的等价路径。只断言「PUT 后内存值变了」是假绿:那在改造前也是绿的。
*
* <p>装配说明:一律使用隔离的 {@link FeatureFlagConfig} 实例(反射注入真实的
* {@link FeatureFlagOverrideService}),不 mutate Spring 单例 —— 单例的内存值不会随
* {@code @Transactional} 回滚,会污染其他读取默认 flag 的测试。
*/
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class FeatureFlagPersistenceGuardTest {
private static final String OPERATOR_ID = "00000000-0000-0000-0000-000000000099";
@MockBean
private StringRedisTemplate stringRedisTemplate;
@Autowired
private FeatureFlagOverrideService overrideService;
@Autowired
private FeatureFlagOverrideMapper overrideMapper;
private OperatorPrincipal principal;
@BeforeEach
void setUp() {
principal = new OperatorPrincipal(OPERATOR_ID);
}
/** 模拟「进程启动」:手工构造配置实例并触发与 @PostConstruct 相同的加载动作。 */
private FeatureFlagConfig bootConfig() {
FeatureFlagConfig cfg = new FeatureFlagConfig();
ReflectionTestUtils.setField(cfg, "overrideService", overrideService);
cfg.reloadFromPersistence();
return cfg;
}
private FeatureFlagController controllerFor(FeatureFlagConfig cfg) {
return new FeatureFlagController(cfg, overrideService);
}
// ------------------------------------------------------------------
// 核心:持久 + 重启后仍生效
// ------------------------------------------------------------------
@Test
void updateIsPersistedAndSurvivesReload() {
FeatureFlagConfig cfg = bootConfig();
assertTrue(cfg.isReferralEnabled(), "前置条件:referralEnabled 默认为 true");
ApiResponse<Map<String, Object>> resp =
controllerFor(cfg).updateFlag(principal, "referralEnabled", Map.of("value", false));
assertTrue(resp.isSuccess());
assertFalse(cfg.isReferralEnabled(), "PUT 应立即在内存生效");
// 落库断言:不能只看内存 —— 内存改动是本改造之前就有的行为
FeatureFlagOverride row = overrideMapper.findByKey("referralEnabled");
assertNotNull(row, "PUT 后数据库应存在覆盖行,否则重启即丢失");
assertEquals("false", row.getFlagValue());
assertEquals("boolean", row.getValueType());
assertEquals(OPERATOR_ID, row.getUpdatedBy(), "应记录修改人,便于审计");
assertNotNull(row.getUpdatedAt());
// 重启等价路径:全新实例 + 重新加载
FeatureFlagConfig afterRestart = bootConfig();
assertFalse(afterRestart.isReferralEnabled(),
"重启后应仍为 false —— 若为 true 说明覆盖没有被真正加载");
}
/** 反证:没有覆盖行时必须保持默认,不能「永远读库」或凭空置反。 */
@Test
void reloadWithoutOverrideKeepsDefault() {
overrideService.delete("matchmakerEnabled");
FeatureFlagConfig cfg = bootConfig();
assertTrue(cfg.isMatchmakerEnabled(), "无覆盖行时应保持默认 true");
assertNull(overrideMapper.findByKey("matchmakerEnabled"));
}
/** 删除覆盖 = 恢复默认,且数据库不再留行。 */
@Test
void deleteRestoresDefaultAndRemovesRow() {
FeatureFlagConfig cfg = bootConfig();
controllerFor(cfg).updateFlag(principal, "serviceProgramEnabled", Map.of("value", false));
assertFalse(cfg.isServiceProgramEnabled());
ApiResponse<Map<String, Object>> resp = controllerFor(cfg).resetFlag("serviceProgramEnabled");
assertTrue(resp.isSuccess());
assertTrue(cfg.isServiceProgramEnabled(), "DELETE 后应恢复默认 true");
assertNull(overrideMapper.findByKey("serviceProgramEnabled"), "覆盖行应被删除");
// 重启后也不该再被覆盖
assertTrue(bootConfig().isServiceProgramEnabled());
}
// ------------------------------------------------------------------
// 非布尔类型同样要能持久化(D-22-A 同族:此前反射只支持布尔)
// ------------------------------------------------------------------
@Test
void stringAndIntFlagsPersist() {
FeatureFlagConfig cfg = bootConfig();
assertTrue(controllerFor(cfg).updateFlag(principal, "livenessProvider", Map.of("value", "third-party")).isSuccess());
assertTrue(controllerFor(cfg).updateFlag(principal, "localEvidenceMaxFiles", Map.of("value", 3)).isSuccess());
FeatureFlagConfig afterRestart = bootConfig();
assertEquals("third-party", afterRestart.getLivenessProvider());
assertEquals(3, afterRestart.getLocalEvidenceMaxFiles());
assertEquals("int", overrideMapper.findByKey("localEvidenceMaxFiles").getValueType());
}
/** upsert:连续改同一个 flag 只留一行,且是最后一次的值。 */
@Test
void repeatedUpdateKeepsSingleRow() {
FeatureFlagConfig cfg = bootConfig();
controllerFor(cfg).updateFlag(principal, "localEvidenceRetentionDays", Map.of("value", 30));
controllerFor(cfg).updateFlag(principal, "localEvidenceRetentionDays", Map.of("value", 90));
assertEquals("90", overrideMapper.findByKey("localEvidenceRetentionDays").getFlagValue());
assertEquals(90, bootConfig().getLocalEvidenceRetentionDays());
}
// ------------------------------------------------------------------
// 失败路径不得产生半截状态(改了库没改内存 / 改了内存没落库)
// ------------------------------------------------------------------
@Test
void unknownFlagIsRejectedAndNotPersisted() {
FeatureFlagConfig cfg = bootConfig();
ApiResponse<Map<String, Object>> resp =
controllerFor(cfg).updateFlag(principal, "noSuchFlag", Map.of("value", true));
assertFalse(resp.isSuccess());
assertNull(overrideMapper.findByKey("noSuchFlag"), "未知 flag 不得写入数据库");
}
@Test
void typeMismatchIsRejectedWithoutChangingPersistedValue() {
FeatureFlagConfig cfg = bootConfig();
controllerFor(cfg).updateFlag(principal, "referralEnabled", Map.of("value", false));
assertEquals("false", overrideMapper.findByKey("referralEnabled").getFlagValue());
ApiResponse<Map<String, Object>> resp =
controllerFor(cfg).updateFlag(principal, "referralEnabled", Map.of("value", "not-a-bool"));
assertFalse(resp.isSuccess());
assertFalse(cfg.isReferralEnabled(), "类型不匹配不得静默把功能置为关闭/开启");
assertEquals("false", overrideMapper.findByKey("referralEnabled").getFlagValue(),
"失败请求不得改动已持久化的值");
}
@Test
void nullValueIsRejectedAndNotPersisted() {
FeatureFlagConfig cfg = bootConfig();
Map<String, Object> body = new HashMap<>();
body.put("value", null);
ApiResponse<Map<String, Object>> resp = controllerFor(cfg).updateFlag(principal, "livenessProvider", body);
assertFalse(resp.isSuccess(), "value=null 会被字符串化成字面量 null,必须拒绝");
assertNull(overrideMapper.findByKey("livenessProvider"));
}
@Test
void missingValueIsRejected() {
FeatureFlagConfig cfg = bootConfig();
assertFalse(controllerFor(cfg).updateFlag(principal, "referralEnabled", Map.of()).isSuccess());
assertNull(overrideMapper.findByKey("referralEnabled"));
}
// ------------------------------------------------------------------
// 守卫自检:防止「加载钩子被摘掉,而所有行为用例仍然绿」
// ------------------------------------------------------------------
@Test
void startupLoadHookIsDeclared() throws Exception {
Method m = FeatureFlagConfig.class.getMethod("reloadFromPersistence");
assertNotNull(m.getAnnotation(PostConstruct.class),
"reloadFromPersistence 必须带 @PostConstruct —— 否则容器启动时不会加载数据库覆盖,"
+ "持久化形同虚设,而本类其余用例(手工调用该方法)仍会全绿");
}
/** 数据库里写坏的值必须 fail-fast,而不是被静默忽略成「配了不生效」。 */
@Test
void corruptPersistedValueFailsFast() {
overrideService.save("localEvidenceMaxFiles", "not-a-number", "int", OPERATOR_ID);
FeatureFlagConfig cfg = new FeatureFlagConfig();
ReflectionTestUtils.setField(cfg, "overrideService", overrideService);
IllegalStateException ex = assertThrows(IllegalStateException.class,
cfg::reloadFromPersistence,
"覆盖值无法解析时应拒绝加载,而不是静默回退默认值");
assertTrue(ex.getMessage().contains("localEvidenceMaxFiles"), ex.getMessage());
}
}
+13
View File
@@ -633,3 +633,16 @@ CREATE TABLE review_items (
CREATE UNIQUE INDEX uk_idempotency_scope
ON idempotency_records (actor_id, endpoint, idempotency_key);
-- V048: Feature Flag 运行期覆盖持久化
-- 生产 updated_by 是 uuid(UUIDStringTypeHandler 以 uuid 类型发送参数,
-- 建成 varchar 会在 PG 上报 operator does not exist,同 V046 的 actor_id);
-- H2 侧沿用本库既有约定的 VARCHAR(36)。
CREATE TABLE feature_flag_overrides (
flag_key VARCHAR(64) PRIMARY KEY,
flag_value VARCHAR(255) NOT NULL,
value_type VARCHAR(16) NOT NULL,
updated_by VARCHAR(36),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT chk_flag_value_type CHECK (value_type IN ('boolean', 'int', 'string'))
);