From 2fbdbc32d0601a4831af9125dbe3153702e0d144 Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Fri, 18 Sep 2026 11:00:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(feature-flags):=20close=20Phase=2022=20KEEP?= =?UTF-8?q?-VERIFY=20=E2=80=94=202=20real=20defects=20+=20doc=20overclaims?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KEEP-VERIFY re-audit of the Feature Flag configuration extension (STATUS's "Phase 22 部署运维配置扩展"): marked ✅ but contained real code defects and overstated completion. Real defects fixed: - D-22-A (High): FeatureFlagController.updateFlag used reflection `getMethod("set"+cap, boolean.class)` and only supported boolean flags. 7 non-boolean flags (livenessProvider, localEvidenceAllowedTypes, counselingAttachmentsBucket, localEvidenceMaxFiles, localEvidenceMaxFileSizeMB, localEvidenceRetentionDays) failed to update via the API. Now dispatches by field type (boolean/int/String). - D-22-B (Medium): `body.getOrDefault("value", false)` silently set false when value was missing and crashed on non-boolean casts. Now returns 400 on missing value / type mismatch and leaves the original value unchanged. Doc overclaims corrected (no silent scope creep): - T01-T05 module-toggle flags (isCounselingEnabled etc.) have zero business call sites -> dead config (exposed but not gating). Left for each module's own phase to wire; documented as residual. - T07 private-storage bucket flags have zero references; storage already uses object-storage.bucket-private. Documented as residual. - STATUS claimed "application.yml defaults (15 flags)"; yml has no `features:` block, defaults are Java field defaults. Functionally fine, docs corrected. T06 upload policy verified actually enforced in LocalEligibilityService + PhotoService (kept ✅). Guard tests: new FeatureFlagControllerTest (7 cases: 21 keys exposed, boolean/ string/int update reflected, unknown flag / missing value / wrong type -> 400). Negative validation: reverting to boolean-only dispatch made exactly 2 update cases fail red, then green after restore — proves guards are real. Full regression: 657 tests / 0 failures / 0 errors / 3 skipped (baseline 650, +7). --- .../controller/FeatureFlagController.java | 53 ++++++-- .../controller/FeatureFlagControllerTest.java | 113 ++++++++++++++++++ docs/JIRA_BACKLOG.md | 3 +- ...1-Recovery-Completion-Report-2026-09-18.md | 112 +++++++++++++++++ docs/STATUS.md | 19 ++- 5 files changed, 289 insertions(+), 11 deletions(-) create mode 100644 backend/src/test/java/com/danyang/match/platform/shared/controller/FeatureFlagControllerTest.java create mode 100644 docs/Phase-22-V11-Recovery-Completion-Report-2026-09-18.md 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 990b362..13e9e83 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 @@ -34,20 +34,57 @@ public class FeatureFlagController { /** * 更新单个 Feature Flag * PUT /api/v1/operator/feature-flags/{flagName} - * Body: { "value": true } + * Body: { "value": } + * + *

支持布尔 / 整数 / 字符串三类 flag(此前仅支持布尔,导致 7 个非布尔 flag + * 经 API 更新必失败)。未知 flag 或缺失 value 返回 400,且不会静默把功能置为关闭。 */ @PutMapping("/{flagName}") public ApiResponse> updateFlag( @AuthenticationPrincipal OperatorPrincipal principal, @PathVariable String flagName, - @RequestBody Map body) { - boolean value = body.getOrDefault("value", false); - // 使用反射更新对应字段 + @RequestBody Map body) { + if (!featureFlagConfig.getAllFlags().containsKey(flagName)) { + return ApiResponse.error( + com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, + "未知的 Feature Flag: " + flagName); + } + if (!body.containsKey("value")) { + return ApiResponse.error( + com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, + "请求体必须包含 value 字段"); + } + Object value = body.get("value"); try { - java.lang.reflect.Method setter = featureFlagConfig.getClass() - .getMethod("set" + capitalize(flagName), boolean.class); - setter.invoke(featureFlagConfig, value); - return ApiResponse.ok(Map.of("flagName", flagName, "value", value, "updated", true)); + 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)); + } catch (ClassCastException e) { + return ApiResponse.error( + com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, + "value 类型与 Flag " + flagName + " 不匹配: " + e.getMessage()); } catch (Exception e) { return ApiResponse.error( com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, diff --git a/backend/src/test/java/com/danyang/match/platform/shared/controller/FeatureFlagControllerTest.java b/backend/src/test/java/com/danyang/match/platform/shared/controller/FeatureFlagControllerTest.java new file mode 100644 index 0000000..6e1bdce --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/shared/controller/FeatureFlagControllerTest.java @@ -0,0 +1,113 @@ +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.security.principal.OperatorPrincipal; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.transaction.annotation.Transactional; + +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.assertTrue; + +/** + * Phase 22(部署运维配置扩展 — Feature Flag)真实守卫测试。 + * + *

背景(KEEP-VERIFY 复验发现): + *

+ * + *

本测试直接驱动控制器方法(与 BannerOperatorPhase19IntegrationTest 同一装配模式), + * 使用隔离的 {@link FeatureFlagConfig} 实例,避免污染共享单例影响其他读取默认 flag 的测试。 + */ +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class FeatureFlagControllerTest { + + @MockBean + private StringRedisTemplate stringRedisTemplate; + + private FeatureFlagConfig fixture; + private FeatureFlagController controller; + private OperatorPrincipal principal; + + @BeforeEach + void setUp() { + fixture = new FeatureFlagConfig(); + controller = new FeatureFlagController(fixture); + principal = new OperatorPrincipal("00000000-0000-0000-0000-000000000099"); + } + + @Test + void getAllFlagsReturnsEveryDeclaredFlag() { + ApiResponse> resp = controller.getAllFlags(); + assertTrue(resp.isSuccess()); + Map flags = resp.getData(); + // 21 个 flag 必须全部暴露,否则运维看不到/改不了某个配置(含此前仅布尔可更新的死区) + assertEquals(21, flags.size()); + for (String key : new String[]{ + "activityEnabled", "matchmakerEnabled", "chatImageEnabled", "appealEnabled", + "counselingEnabled", "premaritalEnabled", "contentEnabled", "serviceLocationEnabled", + "localEligibilityEnabled", "referralEnabled", "mutualConsentEnabled", "serviceProgramEnabled", + "livenessEnabled", "livenessProvider", "localEvidenceUploadEnabled", + "localEvidenceMaxFiles", "localEvidenceMaxFileSizeMB", "localEvidenceAllowedTypes", + "localEvidenceRetentionDays", "counselingAttachmentsPrivate", "counselingAttachmentsBucket" + }) { + assertTrue(flags.containsKey(key), "缺少暴露的 flag: " + key); + } + } + + @Test + void updateBooleanFlagIsReflected() { + ApiResponse> resp = controller.updateFlag(principal, "counselingEnabled", Map.of("value", false)); + assertTrue(resp.isSuccess()); + assertFalse(fixture.isCounselingEnabled()); + assertEquals(false, fixture.getAllFlags().get("counselingEnabled")); + } + + @Test + void updateStringFlagIsReflected() { + ApiResponse> resp = controller.updateFlag(principal, "livenessProvider", Map.of("value", "third-party")); + assertTrue(resp.isSuccess()); + assertEquals("third-party", fixture.getLivenessProvider()); + } + + @Test + void updateIntFlagIsReflected() { + ApiResponse> resp = controller.updateFlag(principal, "localEvidenceMaxFiles", Map.of("value", 3)); + assertTrue(resp.isSuccess()); + assertEquals(3, fixture.getLocalEvidenceMaxFiles()); + } + + @Test + void updateUnknownFlagReturnsBadRequest() { + ApiResponse> resp = controller.updateFlag(principal, "noSuchFlag", Map.of("value", true)); + assertFalse(resp.isSuccess()); + } + + @Test + void updateMissingValueReturnsBadRequest() { + ApiResponse> resp = controller.updateFlag(principal, "counselingEnabled", Map.of()); + assertFalse(resp.isSuccess()); + } + + @Test + void updateBooleanFlagWithWrongTypeReturnsBadRequestAndDoesNotSilentlyDisable() { + ApiResponse> resp = controller.updateFlag(principal, "counselingEnabled", Map.of("value", "not-a-bool")); + assertFalse(resp.isSuccess()); + assertTrue(fixture.isCounselingEnabled(), "类型不匹配不应静默把功能置为关闭"); + } +} diff --git a/docs/JIRA_BACKLOG.md b/docs/JIRA_BACKLOG.md index e55dbb9..f7561d5 100644 --- a/docs/JIRA_BACKLOG.md +++ b/docs/JIRA_BACKLOG.md @@ -178,7 +178,8 @@ | Phase 18 申诉复验 | 已完成(2026-09-17) | 见 `Phase-18-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **8 处真实缺陷**(**D1(Critical,必崩级盲区)测试库缺 `appeals` 表 + `AppealServiceTest` 9 例中 6 例 `assertTrue(true)` 假绿 → 申诉真实 DB 路径零覆盖**;**D2(High,只在 PG 上炸)`escalateAppeal` 向 `operatorId` 发用户通知撞 `notifications.user_id → users(id)` FK → 升级事务整体回滚**;**D3(High,只在 PG 上炸)`appealType` 未校验生产 `chk_appeal_type` 白名单**;D4 escalated 死胡同(前后端均无法再决策);D5 `processAppeal` 先读后写并发竞态;D6 三处查询漏 `deleted_at` 过滤;D7 硬编码 `resolved`;D8 证据字段契约 `evidenceFileIds`);补 `appeals` 表并移出白名单入 `REQUIRED_IN_TEST_SCHEMA`;新增原子认领 `markReviewingIfProcessable`;新增 `AppealPhase18IntegrationTest`(15 用例),删除假绿 `AppealServiceTest`(9 用例);负向验证(误伤其他处置 → Gate 用例精准变红并还原);后端 **494 tests 全绿**;admin-web `vue-tsc` EXITCODE=0;**Phase 19 尚未获准** | | Phase 19 统一运营后台 + 用户端公益服务页面复验 | 已完成(2026-09-17) | 见 `Phase-19-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **25 处真实缺陷**(**D20 服务站公告能力整条不可达:Service + V042 表都在、全仓零端点**;**D21 服务站 `create/update` 越权写:类级 `service_location.view` 兜底,`service_location.manage` 是从未使用的死权限**;D22 该控制器未纳入权限覆盖守卫;**D2/D3 Banner 校验 500 + 软删 Banner 仍上首页**;**D5/D6 Content 发布死路 + 分类停用不可恢复**;D10~D14 ServiceProgram 审批死路/草稿泄露/成员数恒 0/退出未实现/撞唯一键;D9/D23 测试库缺 `content_categories` 与 `service_location_announcements` → 零覆盖;D18 预约 IDOR;D24 迁移基线未跟进 V042);补 V042 公告表 + 运营端五端点 + 用户端只读端点 + 后台公告管理 UI + 小程序 U50 公告区块;新增 3 组守卫(24+5+5 用例)并把服务站运营端收入权限覆盖扫描;负向验证 3 注入 → 4 用例精准变红;后端 **602 tests 全绿**;admin-web `vue-tsc`/`vite build` 双绿;小程序 `tsc` EXIT=0;ui-tests 14/14;**Phase 20 尚未获准** | | Phase 20 Analytics 数据统计与管理驾驶舱复验 | 已完成(2026-09-17) | 见 `Phase-20-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **20 处真实缺陷**(**D1 实名人数用 `profile_status != 'rejected'` 冒充 → 把"资料还在填"算成已实名**;**D3 Chat Started 直接数会话行数 → 系统通知计入**;**D4 Match 不分来源 → 10 对互选被当 10 对成功引用**;**D5 活动漏斗全部硬编码 0 + 一行把"用户数"当"活动数"的死代码**;**D6 Banner 曝光/点击硬编码 0**;**D12 系统性软删缺陷:`BaseEntity.deletedAt` 无 `@TableLogic` + 全局无逻辑删除配置 → `selectCount` 把软删数据全部计入且无任何报错**,修复 45 处统计调用(含 2 轮复验补扫出的 5 处次级路径);**D15 三套独立实现** → 抽 `AnalyticsMetricsCalculator` 统一口径;**D13/权限 授予脱节:`data_viewer` 空壳角色 + `platform_admin` 被 `id >= '20000000-…'` 条件漏掉 V1.0 老权限段 → 整个模块除 super_admin 外全员 403**;**D14 `AnalyticsServiceTest` = 5×`assertTrue(true)` 假绿 → 删除**;D20 `getDailySummary` 无边界校验;前端 `AnalyticsView.vue` 未接 `/v11-dashboard`);建立指标字典 `MetricCatalog`(36 指标 × 12 字段)+ `docs/ANALYTICS_METRICS.md`;新增 V043 授权(**刻意排除 `organization_admin`** —— 相关表无组织维度,无范围全量数据即越权);新增守卫 `MetricCatalogGuardTest`(11) / `AnalyticsMetricsPhase20IntegrationTest`(21) / `AnalyticsSoftDeleteGuardTest`(4,结构性) / `AnalyticsPermissionGrantTest`(6);**跨阶段修复 X1~X3**(**注销写入 `user_identities` 状态域的 `'failed'` → 违反 V017 `chk_elig_verify_status` → 真实 PG 上账号注销 100% 不可用**;`checkCanWithdraw` 资格拦截死代码;测试库补两条 CHECK 后暴露 `ActivityPhase14Test` 夹具违规)并新增 `LocalEligibilityStatusVocabularyGuardTest`(9);负向验证两轮 7 注入 → 7 用例精准变红并还原(`md5sum` 逐字节一致);后端 **648 tests 全绿**;admin-web `vue-tsc` EXIT=0;**Phase 21 已复验关闭(2026-09-18,650 tests 全绿)** | -| Phase 21 账户生命周期增量复验 | 已完成(2026-09-18) | 见 `docs/Phase-21-V11-Recovery-Completion-Report-2026-09-18.md`;KEEP-VERIFY 复验发现 STATUS 标✅ 但含 **6 处真实缺陷 + 假绿测试**:① `AuthService.checkAccountStatus` 不拒 `CLOSED` → 注销后仍可登录(Gate ❌→✅);② `notifyActiveMatches` 为 TODO 桩 → 注销未关闭活跃 Match/会话(Gate ❌→✅,已实现关闭 Match + `chatService.closeConversationForMatch`);③ `cancelPendingActivityRegistrations` 为 TODO 桩 → 未来活动报名未关闭(Gate ❌→✅);④ `anonymizeServiceHistory` 已实现却**从未被 `closeAccount` 调用(死代码)** → 个人/咨询正文暴露(Gate ❌→✅,已接线 + 脱敏身份证占位符 + `counseling_followups` 正文);⑤ `closeAccount` 在 blockers 处硬抛异常使 `cancelXxx` 永远不可达(死代码矛盾 → 信息性日志 + 继续清理);⑥ 删除 7×`assertTrue(true)` 假绿 → 替换为 11 用例真实 DB 集成测试 + 3 用例登录守卫;跨模块防线(推荐引擎/匹配资格/Capability 已拒非 NORMAL)确认有效;后端 **650 tests 全绿**(起点 648,净 +2);**Phase 22 尚未获准** | +| Phase 21 账户生命周期增量复验 | 已完成(2026-09-18) | 见 `docs/Phase-21-V11-Recovery-Completion-Report-2026-09-18.md`;KEEP-VERIFY 复验发现 STATUS 标✅ 但含 **6 处真实缺陷 + 假绿测试**:① `AuthService.checkAccountStatus` 不拒 `CLOSED` → 注销后仍可登录(Gate ❌→✅);② `notifyActiveMatches` 为 TODO 桩 → 注销未关闭活跃 Match/会话(Gate ❌→✅,已实现关闭 Match + `chatService.closeConversationForMatch`);③ `cancelPendingActivityRegistrations` 为 TODO 桩 → 未来活动报名未关闭(Gate ❌→✅);④ `anonymizeServiceHistory` 已实现却**从未被 `closeAccount` 调用(死代码)** → 个人/咨询正文暴露(Gate ❌→✅,已接线 + 脱敏身份证占位符 + `counseling_followups` 正文);⑤ `closeAccount` 在 blockers 处硬抛异常使 `cancelXxx` 永远不可达(死代码矛盾 → 信息性日志 + 继续清理);⑥ 删除 7×`assertTrue(true)` 假绿 → 替换为 11 用例真实 DB 集成测试 + 3 用例登录守卫;跨模块防线(推荐引擎/匹配资格/Capability 已拒非 NORMAL)确认有效;后端 **650 tests 全绿**(起点 648,净 +2);**Phase 22 已复验关闭(2026-09-18,657 tests 全绿)** | +| Phase 22 部署运维配置扩展(Feature Flag)复验 | 已完成(2026-09-18) | 见 `docs/Phase-22-V11-Recovery-Completion-Report-2026-09-18.md`;KEEP-VERIFY 复验发现 STATUS 标✅ 但含 **2 处真实代码缺陷 + 2 处文档夸大**:① D-22-A(High)`FeatureFlagController.updateFlag` 仅支持布尔 flag(反射 `getMethod("set"+cap, boolean.class)`)→ 7 个非布尔 flag 经 API 更新必失败,已改为按字段类型分派 boolean/int/String;② D-22-B(Medium)`body.getOrDefault("value", false)` 缺 value 静默置 false + 非布尔转型崩,已改为缺 value/类型错→400 且不改原值;T01–T05 模块开关(`isCounselingEnabled` 等)全仓无业务调用点 → 死配置(暴露未 gate,遗留交各模块 Phase);T06 上传策略实测已在 `LocalEligibilityService`+`PhotoService` 真强制 ✅;T07 私有桶零引用 → 死配置(遗留);yml 无 `features:` 块、默认值实为 Java 字段默认(文档纠正);新增 `FeatureFlagControllerTest`(7 用例)+ 负向验证(仅布尔缺陷版恰 2 用例变红);后端 **657 tests 全绿**(起点 650,净 +7);**Phase 23 尚未获准** | | UI-CLEANUP-20260913 | 已完成 | 废弃 UI 代码、冲突规范、旧计划和无效验收证据已清理;`ui-document-baseline-contracts` 锁定唯一视觉入口 | | UI-ADMIN-20260913 | 已完成(待桌面端目视复验) | 管理端全局框架与工作台已按规格交付;18 modules + 22 routes,vue-tsc 与 vite build 全绿 | | UI-MINIAPP-20260913 | 已完成 | 小程序 11 个核心页面已重构;11/11 页 TS-only,2026-09-16 通过用户验收 | diff --git a/docs/Phase-22-V11-Recovery-Completion-Report-2026-09-18.md b/docs/Phase-22-V11-Recovery-Completion-Report-2026-09-18.md new file mode 100644 index 0000000..67fd1ee --- /dev/null +++ b/docs/Phase-22-V11-Recovery-Completion-Report-2026-09-18.md @@ -0,0 +1,112 @@ +# 善缘丹阳 V1.1 — Phase 22 部署运维配置扩展(Feature Flag)KEEP-VERIFY 复验 Completion Report + +> 日期:2026-09-18 | 复验纪律:历史 ✅ 不可信;逐条核对运行时路径与生产约束;发现真实缺陷即修;补测试保真度;守卫解析真实 DDL/真实 SQL;单段分支提交 + tag + bundle + 回读。 +> 范围说明:计划文件 `docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md` 的 Phase 22 实为「Deployment / Ops 基础设施」(Docker/CI/备份/密钥),与 STATUS.md 遗留区「Phase 22 部署运维配置扩展」所指的 **Feature Flag 配置扩展** 是两份不同工作。本 Phase 复验的是后者(用户确认的「部署运维配置扩展」标签),即 7 个 V1.1 Feature Flag + `FeatureFlagController` + `application.yml` 默认值。计划的 Deployment/Ops 基础设施 Phase 22 为另一块更大的运维任务,需另行确认。 + +--- + +## 一、复验起点:STATUS 声明 vs 实际运行时路径 + +STATUS 遗留区「Phase 22 部署运维配置扩展 ✅ 已完成」逐条声明(P22-T01~T07、FeatureFlagController API、application.yml 默认配置)。KEEP-VERIFY 逐项核对真实代码路径后,结论如下: + +| STATUS 声明 | 复验实测 | 结论 | +|---|---|---| +| T01 counselingEnabled flag ✅ | `isCounselingEnabled()` 全仓无业务调用点 | ❌ 死配置(暴露但未 gate) | +| T02 premaritalEnabled flag ✅ | `isPremaritalEnabled()` 全仓无业务调用点 | ❌ 死配置 | +| T03 contentEnabled flag ✅ | `isContentEnabled()` 全仓无业务调用点 | ❌ 死配置 | +| T04 serviceLocationEnabled flag ✅ | `isServiceLocationEnabled()` 全仓无业务调用点 | ❌ 死配置 | +| T05 localEligibilityEnabled flag ✅ | `isLocalEligibilityEnabled()` 全仓无调用点(模块开关未接) | ❌ 死配置(但上传策略已接,见下) | +| T06 local evidence upload policy ✅ | `LocalEligibilityService` 强制 `isLocalEvidenceUploadEnabled` + `getLocalEvidenceMaxFiles`;`PhotoService` 强制 `getLocalEvidenceMaxFileSizeMB` + `getLocalEvidenceAllowedTypes` | ✅ 已真强制 | +| T07 private storage for counseling attachments ✅ | `counselingAttachmentsPrivate` / `counselingAttachmentsBucket` 全仓只在 `FeatureFlagConfig` 定义与 `getAllFlags()` 暴露,业务零引用(counseling 模块无附件存储代码引用) | ❌ 死配置 | +| FeatureFlagController API ✅ (GET/PUT) | GET 正常;**PUT 仅支持布尔 flag**(反射 `getMethod("set"+cap, boolean.class)`),7 个非布尔 flag 更新必失败 | ❌→✅ 已修复(D-22-A) | +| application.yml 默认配置 ✅ (15 个 V1.1 flag) | `backend/src/main/resources/*.yml` 无 `features:` 块,仅 Java 字段默认值生效 | ⚠️ 夸大声明(功能正常,文档纠正) | + +--- + +## 二、真实缺陷(修复) + +| 编号 | 严重度 | 缺陷 | 修复 | +|---|---|---|---| +| D-22-A | High | `FeatureFlagController.updateFlag` 用反射 `getMethod("set"+cap, boolean.class)` 只支持布尔 flag。7 个非布尔 flag(`livenessProvider`、`localEvidenceAllowedTypes`、`counselingAttachmentsBucket`、`localEvidenceMaxFiles`、`localEvidenceMaxFileSizeMB`、`localEvidenceRetentionDays`)经 API 更新必抛 `NoSuchMethodException` → 返回错误,运维无法经接口修改这些配置。 | 改为按字段类型分派:`boolean` → `setXxx(boolean)`、`int` → `setXxx(int)`、`String` → `setXxx(String)`;未知类型返回明确错误。 | +| D-22-B | Medium | `updateFlag` 用 `body.getOrDefault("value", false)`:缺 `value` 时静默置 `false`(可能误关功能);非布尔值转型 `((Boolean) value)` 直接崩。 | 改为:缺 `value` → 400;`value` 按字段类型校验后转型;类型不匹配返回 400 且**不**改变原值。 | +| D-22-C | Doc/Accuracy | STATUS 将 T01–T05、T07 标 ✅,但对应 flag 从未被业务读取(死配置)。 | 不静默扩权改写各模块;在报告中如实降级为「配置暴露但未强制」,列为遗留项交各模块 Phase 处理。 | +| D-22-D | Doc/Accuracy | STATUS 称「application.yml 默认配置(15 个 flag)」,但 yml 无 `features:` 块。 | 在报告/STATUS 中纠正:默认值来自 `FeatureFlagConfig` 的 Java 字段默认值,非 yml。功能不受影响。 | + +> 注:T06 上传策略经实测已真强制(`LocalEligibilityService` + `PhotoService`),保留 ✅,无需修复。 + +--- + +## 三、未静默扩权的遗留项(文档化,交后续) + +1. **模块开关死配置(T01–T05)**:`counselingEnabled` / `premaritalEnabled` / `contentEnabled` / `serviceLocationEnabled` / `localEligibilityEnabled`(模块级)在后端无调用点 → 运维无法通过 flag 关闭对应模块。属「配置暴露但未 gate」,需在各模块自身 Phase 接线(建议加控制器入口 `@PreAuthorize`/服务层 `featureFlags.isXxxEnabled()` 判断),不在本 Phase 静默铺开。 +2. **T07 私有存储桶死配置**:`counselingAttachmentsPrivate` / `counselingAttachmentsBucket` 无业务引用;咨询附件存储本就走 `object-storage.bucket-private`(已私有)。当前 flag 为「专用桶覆盖」旋钮,未接线,属可选增强。 +3. **PUT 变更仅内存生效**:`FeatureFlagConfig` 为单例 `@Component`,运行时修改不持久化,重启后回到默认值。计划/STATUS 未要求持久化,记为已知限制(如需跨重启保留,需接 DB/可刷新配置,属范围外)。 +4. **计划 Phase 22(Deployment/Ops 基础设施)**:Docker / CI / 备份恢复 / 密钥注入 / 回滚 等尚未纳入本 Phase,需另行确认后单独复验。 + +--- + +## 四、守卫测试(新增) + +`backend/src/test/java/com/danyang/match/platform/shared/controller/FeatureFlagControllerTest.java`(7 用例,直接驱动控制器方法,使用隔离的 `FeatureFlagConfig` 实例避免污染共享单例): + +1. `getAllFlagsReturnsEveryDeclaredFlag` — 21 个 flag 全部暴露(size=21 + 逐项 containsKey)。 +2. `updateBooleanFlagIsReflected` — 布尔 flag 更新后反映到配置。 +3. `updateStringFlagIsReflected` — **字符串 flag(D-22-A 修复点)** 更新生效。 +4. `updateIntFlagIsReflected` — **整数 flag(D-22-A 修复点)** 更新生效。 +5. `updateUnknownFlagReturnsBadRequest` — 未知 flag → 400。 +6. `updateMissingValueReturnsBadRequest` — 缺 value → 400(D-22-B 修复点)。 +7. `updateBooleanFlagWithWrongTypeReturnsBadRequestAndDoesNotSilentlyDisable` — 类型不匹配 → 400 且原值不变(D-22-B 修复点)。 + +既有 `ConfigTest.testFeatureFlagDefaults` 保留(校验 Java 默认值)。 + +--- + +## 五、负向验证(守卫非假绿) + +将控制器分发逻辑改回「仅布尔」(模拟 D-22-A 修复前缺陷,保留 `Map` 签名以保证可编译),重跑 `FeatureFlagControllerTest`: + +``` +[ERROR] Tests run: 7, Failures: 2, Errors: 0, Skipped: 0 +[ERROR] FeatureFlagControllerTest.updateIntFlagIsReflected <<< FAILURE! expected: but was: +[ERROR] FeatureFlagControllerTest.updateStringFlagIsReflected <<< FAILURE! expected: but was: +``` + +→ 恰好 2 个关于非布尔更新的用例精准变红,其余 5 个通过;还原修复版后全绿。**证明守卫真实有效,非假绿。** + +--- + +## 六、全量回归 + +离线容器( `danyang-match-backend-test:latest`,挂载 `/proj`,命名卷 `danyang-m2-cache`,`--memory=3g -e MAVEN_OPTS=-Xmx256m`): + +``` +Tests run: 657, Failures: 0, Errors: 0, Skipped: 3 — BUILD SUCCESS +``` + +(起点 650,净 +7 = 新增 `FeatureFlagControllerTest` 7 用例)。 + +--- + +## 七、Gate 评估 + +| Gate | 结论 | +|---|---| +| 配置基础设施可经 API 读写(含非布尔) | ✅ 修复 D-22-A/B 后 GET/PUT 覆盖 21 个 flag(布尔/整数/字符串) | +| 未知 flag / 缺 value / 类型错 → 明确拒绝,不静默误关 | ✅ D-22-B | +| 上传策略真强制 | ✅ T06 已在 `LocalEligibilityService` + `PhotoService` 生效 | +| 模块开关真 gate 功能 | ❌ 遗留(T01–T05 死配置,交各模块 Phase) | +| 私有存储桶真生效 | ❌ 遗留(T07 死配置) | + +**结论:KEEP-VERIFY 复验关闭。修复 2 处真实代码缺陷(D-22-A High、D-22-B Medium)+ 纠正 2 处文档夸大(D-22-C/D);补 7 守卫用例;全量 657 绿;负向验证精准。4 项遗留已文档化,未静默扩权。** + +--- + +## 八、Git 物证 + +- 提交:`(见 `git log` 最新一条)` +- Tag:`phase22-recovery-2026-09-18` +- Bundle:`.workbuddy/backup/phase22-recovery-2026-09-18.bundle` +- 改动文件: + - `backend/src/main/java/com/danyang/match/platform/shared/controller/FeatureFlagController.java`(D-22-A/B) + - `backend/src/test/java/com/danyang/match/platform/shared/controller/FeatureFlagControllerTest.java`(新增守卫) + - `docs/STATUS.md`、`docs/JIRA_BACKLOG.md`(同步复验结论) diff --git a/docs/STATUS.md b/docs/STATUS.md index c704bc2..ef130ed 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,9 +2,9 @@ > **2026-09-17 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 20 COMPLETE / UI REDESIGN ACCEPTED BY USER / GIT CLEAN`。** > -> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)、Phase 13A(婚恋咨询与婚前辅导)、Phase 14(多类型公益活动)、Phase 15(活动报名、审核与签到)、Phase 16(活动互选与双方开启沟通确认)、Phase 17(举报与风控)、Phase 18(申诉)、Phase 19(统一运营后台 + 用户端公益服务页面)、Phase 20(Analytics 数据统计与管理驾驶舱)复验已关闭**,十四轮共修复 130+ 处真实缺陷(含 3 处隐藏编译缺陷),并各自补齐测试保真度与守卫。**Phase 21 起需用户确认后启动。** +> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)、Phase 13A(婚恋咨询与婚前辅导)、Phase 14(多类型公益活动)、Phase 15(活动报名、审核与签到)、Phase 16(活动互选与双方开启沟通确认)、Phase 17(举报与风控)、Phase 18(申诉)、Phase 19(统一运营后台 + 用户端公益服务页面)、Phase 20(Analytics 数据统计与管理驾驶舱)复验已关闭**,十四轮共修复 130+ 处真实缺陷(含 3 处隐藏编译缺陷),并各自补齐测试保真度与守卫。**Phase 21、22 复验已关闭(2026-09-18);Phase 23 起需用户确认后启动。** -最后更新: 2026-09-17 — 分支 `main`(`feat-ui-v11` 保留为冗余锚点) +最后更新: 2026-09-18 — 分支 `main`(`feat-ui-v11` 保留为冗余锚点) ## 当前唯一有效状态 @@ -569,6 +569,21 @@ MinIO: localhost:9003 (凭证由本地环境变量提供) | 报告 | `docs/Phase-21-V11-Recovery-Completion-Report-2026-09-18.md` | | 遗留(未静默扩权) | ① 红娘服务进行中拦截:数据模型无 `matchmaker_services` 表,仍硬编码 0(TODO);② 数据保留"配置+人工审核"删除流程属产品决策未实现;③ 隐私设置接口仍为 TODO 桩 | +### Phase 22 — 部署运维配置扩展(Feature Flag)✅(KEEP-VERIFY 复验关闭,2026-09-18) +| 项 | 复验结论 | +|------|------| +| 范围 | STATUS 遗留区「Phase 22 部署运维配置扩展」= 7 个 V1.1 Feature Flag + `FeatureFlagController` + 默认值(计划文件 Phase 22 实为 Deployment/Ops 基础设施,另属一块任务,未纳入本 Phase) | +| D-22-A(High) | `updateFlag` 仅支持布尔 flag(反射 `getMethod("set"+cap, boolean.class)`)→ 7 个非布尔 flag 经 API 更新必失败;已改为按字段类型分派 boolean/int/String | +| D-22-B(Medium) | `body.getOrDefault("value", false)` 缺 value 静默置 false + 非布尔转型崩;已改为缺 value/类型错→400 且不改原值 | +| T01–T05 模块开关 | 复验发现 `isCounselingEnabled` / `isPremaritalEnabled` / `isContentEnabled` / `isServiceLocationEnabled` / `isLocalEligibilityEnabled`(模块级)全仓无业务调用点 → **死配置**(暴露但未 gate);不静默扩权,留交各模块 Phase 接线 | +| T06 上传策略 | 实测已在 `LocalEligibilityService`(启用开关 + 数量上限)+ `PhotoService`(大小 + 类型)**真强制** ✅ | +| T07 私有存储桶 | `counselingAttachmentsPrivate` / `Bucket` 全仓零引用 → **死配置**(存储本就走 `object-storage.bucket-private`);可选增强,遗留 | +| yml 声明夸大 | STATUS 称「application.yml 默认配置(15 flag)」,实际 yml 无 `features:` 块,默认值来自 Java 字段默认值;功能不受影响,文档纠正 | +| 守卫测试 | 新增 `FeatureFlagControllerTest`(7 用例:21 键全暴露 / 布尔·字符串·整数更新生效 / 未知 flag·缺 value·类型错→400);负向验证(仅布尔缺陷版)恰 2 用例精准变红,还原全绿 | +| 全量回归 | **657 tests / 0 failures / 0 errors / 3 skipped(起点 650,净 +7)** | +| 报告 | `docs/Phase-22-V11-Recovery-Completion-Report-2026-09-18.md` | +| 遗留(未静默扩权) | ① T01–T05 模块开关死配置;② T07 私有桶死配置;③ PUT 仅内存生效(不持久化,计划未要求);④ 计划 Phase 22 Deployment/Ops 基础设施尚未复验 | + **Phase 20(Analytics数据统计扩展)— 已完成** ✅ ### Phase 22 — 部署运维配置扩展 ✅ 已完成