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
@@ -21,7 +21,7 @@
| Phase 23 | ⚠️ **部分关闭** —— 44 个 `pass` 空壳已清、4 个真实 INV 行为守卫已补;**E2E 场景实现已完成(2026-09-19,见 Task 2.1)**;**INV-011 已补真实行为守卫(2026-09-19,见 Task 2.2,9 用例 + 两次注入验证)**。仅余 E2E 在 CI 上的首次实测绿 |
| Phase 23B | ✅ E2E 测试基建重建(conftest + seed + CI e2e job,本机活栈 49 → **65 passed**) |
| 横向加固 | ✅ HTTP 语义(105 处真实状态码)+ 身份收敛 + 幂等键(30 端点,V045/V046)+ 端口/入口守卫 |
| 后端回归 | **689 / 0 failures / 0 errors / 3 skipped**(2026-09-19 全量实测;含 9 例 INV-011 + 3 例 Mapper 绑定 + 8 例隐私契约/upsert) |
| 后端回归 | **700 / 0 failures / 0 errors / 3 skipped**(2026-09-19 全量实测;含 9 例 INV-011 + 3 例 Mapper 绑定 + 8 例隐私契约/upsert + 11 例 flag 持久化守卫) |
| UI 契约门禁 | **18/18 绿**(原文 14/14 已过期,见 Global Constraints) |
| 停点 | **Phase 24(UAT)启动门前 —— 需用户确认** |
@@ -319,9 +319,34 @@
- `getAllFlags()` 键数 21 → **14**;`FeatureFlagControllerTest` 相应更新,
布尔样例由 `counselingEnabled` 换成 `referralEnabled`。
- [x] `counselingAttachmentsPrivate` / `Bucket` 私有桶死配置(存储实走 `object-storage.bucket-private`)→ **同上,已移除**(除测试外零引用)。
- [ ] **`PUT /feature-flags` 不持久化** → 修复或明确标注为只读。**仍未处理**:
改动涉及运维流程(改的是运行时内存态,重启即失效),与"删死配置"风险不同,
需你确认是要做成真持久化,还是标注为只读后关掉该端点。
- [x] **`PUT /feature-flags` 不持久化** → **2026-09-19 已做成真持久化**(用户裁定:做成真持久化)。
- **缺陷性质**:`updateFlag` 只改 `FeatureFlagConfig` 这个内存单例的字段 ——
重启即回默认值、多实例互不一致。表现为「后台显示已关闭,一次发布后功能又回来了」。
- **改动**:
- `V048__persist_feature_flag_overrides.sql` 新建 `feature_flag_overrides`
(`flag_key` 主键;值一律存字符串,回读按字段声明类型解析;`updated_by` 记审计)。
⚠️ `updated_by` 必须建 **uuid**(不是 varchar):全局 `UUIDStringTypeHandler`
以 uuid 类型发参数,建成 varchar 会在 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 /{flagName}` 恢复默认 —— 没有它,一次写错的覆盖只能手工改库撤销。
- **取值优先级**:数据库覆盖 > yml/环境变量 > Java 字段默认值。
- **已知边界(如实记录,未扩权)**:① 数据库里写坏的覆盖值**启动即 fail-fast**
(`IllegalStateException`),不静默回退默认 —— 否则表现为「配了不生效」;
修法是删掉那一行。② **多实例**:其他实例的内存值不会自动刷新,
需各自重启(本项目为模块化单体、单 backend 部署,未引入配置中心)。
- **守卫**:新增 `FeatureFlagPersistenceGuardTest` 11 例,核心不变量是
「另起一个配置实例重新加载后改动仍在」(= 重启等价路径);
另有自检用例断言 `@PostConstruct` 未被摘掉 —— 否则钩子丢失时其余用例仍全绿。
- **负向验证**:注入 ①控制器不落库 ②摘掉启动钩子 → **恰好 5 例变红**
(`updateIsPersistedAndSurvivesReload` / `stringAndIntFlagsPersist` /
`repeatedUpdateKeepsSingleRow` / `typeMismatchIsRejectedWithoutChangingPersistedValue` /
`startupLoadHookIsDeclared`),`FeatureFlagControllerTest` 7 例不受影响;
还原后 md5 逐字节一致、`NEG-VAL` 残留 0。
### Task 3.3: 任务级 ⏳ 清偿
@@ -449,7 +474,7 @@
9. **本机跑 Java 测试的通道 —— 含两处「必挂」,漏掉任一处都会导出完全错误的结论**(工具性发现,可复用)
本机**无 JDK / 无 Maven / 无 `~/.m2`**(`mvn` 报 `Could not find Maven installation`),
测试镜像 `danyang-match-backend-test` 内含 Temurin JDK 21 + Maven 3.9.16 + 完整源码。
**正确命令**(2026-09-19 实测 `BUILD SUCCESS`,全量 **689 / 0 failures / 0 errors / 3 skipped**,约 6 分钟):
**正确命令**(2026-09-19 实测 `BUILD SUCCESS`,全量 **700 / 0 failures / 0 errors / 3 skipped**,约 6 分钟):
```bash
MSYS_NO_PATHCONV=1 docker run --rm \
-v "E:/develop/公益婚恋服务平台":/proj \
@@ -612,4 +637,4 @@
*审阅校正: 2026-09-19 — 按实测推翻 9 处失实断言(见附 A;其中 2 处是**对本轮自查中自身结论的更正** ——
「`-o` 不可用」「35 failures 是回归」两条均系命令/挂载所致,已如实留痕),登记 11 项新发现(见附 B)*
*执行进展: 2026-09-19 — Task 2.1(E2E 16 场景)、Task 2.2(INV-011 守卫)均已完成并验证;
后端全量回归 689 / 0 failures / 0 errors / 3 skipped;UI 契约门禁 18/18 绿*
后端全量回归 700 / 0 failures / 0 errors / 3 skipped;UI 契约门禁 18/18 绿*