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
@@ -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 写入';