fix(feature-flags): close Phase 22 KEEP-VERIFY — 2 real defects + doc overclaims

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).
This commit is contained in:
WorkBuddy
2026-09-18 11:00:08 +08:00
parent 0580da0c62
commit 2fbdbc32d0
5 changed files with 289 additions and 11 deletions
@@ -34,20 +34,57 @@ public class FeatureFlagController {
/**
* 更新单个 Feature Flag
* PUT /api/v1/operator/feature-flags/{flagName}
* Body: { "value": true }
* Body: { "value": <boolean|int|string> }
*
* <p>支持布尔 / 整数 / 字符串三类 flag(此前仅支持布尔,导致 7 个非布尔 flag
* 经 API 更新必失败)。未知 flag 或缺失 value 返回 400,且不会静默把功能置为关闭。
*/
@PutMapping("/{flagName}")
public ApiResponse<Map<String, Object>> updateFlag(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String flagName,
@RequestBody Map<String, Boolean> body) {
boolean value = body.getOrDefault("value", false);
// 使用反射更新对应字段
@RequestBody Map<String, Object> 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,
@@ -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)真实守卫测试。
*
* <p>背景(KEEP-VERIFY 复验发现):
* <ul>
* <li>D-22-A(High):原 {@code updateFlag} 仅支持布尔 flag(反射 {@code getMethod("set"+cap, boolean.class)}),
* 7 个非布尔 flag(livenessProvider / localEvidenceAllowedTypes / counselingAttachmentsBucket /
* localEvidenceMaxFiles / localEvidenceMaxFileSizeMB / localEvidenceRetentionDays)经 API 更新必失败。</li>
* <li>D-22-B(Medium):原 {@code body.getOrDefault("value", false)} 缺 value 时静默置 false(可能误关功能),
* 且非布尔类型转型会崩。</li>
* </ul>
*
* <p>本测试直接驱动控制器方法(与 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<Map<String, Object>> resp = controller.getAllFlags();
assertTrue(resp.isSuccess());
Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> resp = controller.updateFlag(principal, "livenessProvider", Map.of("value", "third-party"));
assertTrue(resp.isSuccess());
assertEquals("third-party", fixture.getLivenessProvider());
}
@Test
void updateIntFlagIsReflected() {
ApiResponse<Map<String, Object>> resp = controller.updateFlag(principal, "localEvidenceMaxFiles", Map.of("value", 3));
assertTrue(resp.isSuccess());
assertEquals(3, fixture.getLocalEvidenceMaxFiles());
}
@Test
void updateUnknownFlagReturnsBadRequest() {
ApiResponse<Map<String, Object>> resp = controller.updateFlag(principal, "noSuchFlag", Map.of("value", true));
assertFalse(resp.isSuccess());
}
@Test
void updateMissingValueReturnsBadRequest() {
ApiResponse<Map<String, Object>> resp = controller.updateFlag(principal, "counselingEnabled", Map.of());
assertFalse(resp.isSuccess());
}
@Test
void updateBooleanFlagWithWrongTypeReturnsBadRequestAndDoesNotSilentlyDisable() {
ApiResponse<Map<String, Object>> resp = controller.updateFlag(principal, "counselingEnabled", Map.of("value", "not-a-bool"));
assertFalse(resp.isSuccess());
assertTrue(fixture.isCounselingEnabled(), "类型不匹配不应静默把功能置为关闭");
}
}