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,