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:
+182
-2
@@ -1,16 +1,38 @@
|
||||
package com.danyang.match.platform.shared.config;
|
||||
|
||||
import com.danyang.match.platform.shared.featureflag.FeatureFlagOverrideService;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Feature Flag 配置
|
||||
* V1.1: 新增 counseling / premarital / content / serviceLocation / localEligibility 开关
|
||||
*
|
||||
* <p><b>取值优先级(V048 起)</b>:数据库覆盖({@code feature_flag_overrides})
|
||||
* > yml / 环境变量 > Java 字段默认值。
|
||||
* 数据库覆盖在 {@link #reloadFromPersistence()} 中于容器启动时应用 —— 该时机晚于
|
||||
* {@code @ConfigurationProperties} 绑定,因此压得住 yml 默认值;若反过来就会
|
||||
* 被 yml 静默盖掉。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "features")
|
||||
public class FeatureFlagConfig {
|
||||
|
||||
public static final String TYPE_BOOLEAN = "boolean";
|
||||
public static final String TYPE_INT = "int";
|
||||
public static final String TYPE_STRING = "string";
|
||||
|
||||
// ---- V1.0 已有 Flag ----
|
||||
private boolean activityEnabled = true;
|
||||
private boolean matchmakerEnabled = true;
|
||||
@@ -49,6 +71,164 @@ public class FeatureFlagConfig {
|
||||
|
||||
// ⚠️ 2026-09-19 移除:counselingAttachmentsPrivate / counselingAttachmentsBucket 从未被读取 ——
|
||||
// 咨询附件的存储实走 object-storage.bucket-private,这两项与真实链路无关。
|
||||
|
||||
/**
|
||||
* 覆盖值的持久化服务。
|
||||
*
|
||||
* <p>用字段注入而非构造器注入:{@code @ConfigurationProperties} 依赖无参构造 + setter 绑定,
|
||||
* 加 final 字段会把它变成构造器绑定,破坏既有绑定方式。
|
||||
*/
|
||||
@Autowired
|
||||
private FeatureFlagOverrideService overrideService;
|
||||
|
||||
/**
|
||||
* 默认配置快照(yml + Java 字段默认值),在首次 {@link #reloadFromPersistence()} 时捕获。
|
||||
*
|
||||
* <p>用途:DELETE 覆盖后要能回到「没改过」的那个值。快照必须在<b>应用覆盖之前</b>取,
|
||||
* 且只取一次 —— 否则第二次 reload 会把上一次的覆盖值误当成默认值。
|
||||
*/
|
||||
private final Map<String, Object> defaultsSnapshot = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 从数据库加载覆盖值并应用。容器启动时由 {@code @PostConstruct} 触发一次。
|
||||
*
|
||||
* <p>为什么必须先回到默认再应用覆盖:该方法可能被重复调用(运维手工触发 / 测试),
|
||||
* 若不清回默认,历史覆盖会滞留 —— reload 就不再是「反映数据库当前状态」。
|
||||
*/
|
||||
@PostConstruct
|
||||
public void reloadFromPersistence() {
|
||||
if (defaultsSnapshot.isEmpty()) {
|
||||
defaultsSnapshot.putAll(getAllFlags());
|
||||
} else {
|
||||
applyValues(defaultsSnapshot);
|
||||
}
|
||||
if (overrideService == null) {
|
||||
// 单元测试中手工 new 出来的实例(不走 Spring 生命周期):无持久化服务,保持默认值。
|
||||
return;
|
||||
}
|
||||
overrideService.loadOverrides().forEach(this::applyPersistedValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用一条来自数据库的覆盖值。
|
||||
*
|
||||
* <p>两类异常都是<b>故意 fail-fast</b>:数据库是 Source of Truth,
|
||||
* 一行写坏的覆盖若被静默忽略,表现为「后台显示已改、实际没生效」,比启动失败更难查。
|
||||
* 修法明确:删掉那一行(DELETE /operator/feature-flags/{flagName})。
|
||||
*/
|
||||
private void applyPersistedValue(String flagName, String rawValue) {
|
||||
if (!getAllFlags().containsKey(flagName)) {
|
||||
log.warn("忽略数据库中已不存在的 flag 覆盖: {}(可由 DELETE /operator/feature-flags/{} 清理)",
|
||||
flagName, flagName);
|
||||
return;
|
||||
}
|
||||
if (rawValue == null) {
|
||||
throw new IllegalStateException("flag 覆盖值为 null,拒绝启动: " + flagName);
|
||||
}
|
||||
Object parsed;
|
||||
try {
|
||||
parsed = parseRawValue(flagName, rawValue);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalStateException(
|
||||
"flag 覆盖值无法按声明类型解析,拒绝启动: " + flagName + " = " + rawValue
|
||||
+ "(期望 " + valueTypeOf(flagName) + ")", e);
|
||||
}
|
||||
setFlagValue(flagName, parsed);
|
||||
}
|
||||
|
||||
private void applyValues(Map<String, Object> values) {
|
||||
values.forEach(this::setFlagValue);
|
||||
}
|
||||
|
||||
/** 按字段声明类型把字符串解析回原类型。 */
|
||||
private Object parseRawValue(String flagName, String rawValue) {
|
||||
String type = valueTypeOf(flagName);
|
||||
if (TYPE_BOOLEAN.equals(type)) {
|
||||
return Boolean.parseBoolean(rawValue);
|
||||
}
|
||||
if (TYPE_INT.equals(type)) {
|
||||
return Integer.valueOf(rawValue);
|
||||
}
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置单个 flag 的值(按字段声明类型分派)。
|
||||
*
|
||||
* <p>与控制器共用同一份分派逻辑 —— 此前分派写在 Controller 里,
|
||||
* 一旦新增类型就会出现「落库用一套、内存用另一套」的漂移。
|
||||
*
|
||||
* @throws ClassCastException value 类型与 flag 声明类型不匹配
|
||||
* @throws NoSuchFieldException flag 不存在
|
||||
*/
|
||||
public void setFlagValue(String flagName, Object value) {
|
||||
try {
|
||||
Field field = FeatureFlagConfig.class.getDeclaredField(flagName);
|
||||
Class<?> type = field.getType();
|
||||
Method setter;
|
||||
if (type == boolean.class || type == Boolean.class) {
|
||||
setter = FeatureFlagConfig.class.getMethod("set" + capitalize(flagName), boolean.class);
|
||||
setter.invoke(this, (Boolean) value);
|
||||
} else if (type == int.class || type == Integer.class) {
|
||||
setter = FeatureFlagConfig.class.getMethod("set" + capitalize(flagName), int.class);
|
||||
setter.invoke(this, ((Number) value).intValue());
|
||||
} else if (type == String.class) {
|
||||
setter = FeatureFlagConfig.class.getMethod("set" + capitalize(flagName), String.class);
|
||||
setter.invoke(this, Objects.toString(value, null));
|
||||
} else {
|
||||
throw new IllegalArgumentException("不支持的 Flag 类型: " + type.getSimpleName());
|
||||
}
|
||||
} catch (NoSuchFieldException | NoSuchMethodException e) {
|
||||
throw new IllegalArgumentException("未知的 Feature Flag: " + flagName, e);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
Throwable cause = e.getCause() == null ? e : e.getCause();
|
||||
if (cause instanceof ClassCastException) {
|
||||
throw (ClassCastException) cause;
|
||||
}
|
||||
throw new IllegalStateException("无法更新 Flag: " + flagName, cause);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 该 flag 的持久化类型标记。
|
||||
*
|
||||
* <p>回读时以<b>字段声明类型</b>为准,value_type 只用于记录与排障 ——
|
||||
* 若以 DB 里的 value_type 为准,改一次字段名就会让历史数据按错的类型解析。
|
||||
*/
|
||||
public String valueTypeOf(String flagName) {
|
||||
try {
|
||||
Class<?> type = FeatureFlagConfig.class.getDeclaredField(flagName).getType();
|
||||
if (type == boolean.class || type == Boolean.class) {
|
||||
return TYPE_BOOLEAN;
|
||||
}
|
||||
if (type == int.class || type == Integer.class) {
|
||||
return TYPE_INT;
|
||||
}
|
||||
return TYPE_STRING;
|
||||
} catch (NoSuchFieldException e) {
|
||||
throw new IllegalArgumentException("未知的 Feature Flag: " + flagName, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 恢复为默认配置(删除覆盖后用)。 */
|
||||
public void resetToDefault(String flagName) {
|
||||
Object def = defaultsSnapshot.get(flagName);
|
||||
if (def == null) {
|
||||
log.warn("默认配置快照为空,无法恢复 {}: reloadFromPersistence() 尚未执行", flagName);
|
||||
return;
|
||||
}
|
||||
setFlagValue(flagName, def);
|
||||
}
|
||||
|
||||
/** 默认配置快照(只读副本),供排障与测试核对。 */
|
||||
public Map<String, Object> getDefaultsSnapshot() {
|
||||
return Map.copyOf(defaultsSnapshot);
|
||||
}
|
||||
|
||||
private String capitalize(String s) {
|
||||
return s.substring(0, 1).toUpperCase() + s.substring(1);
|
||||
}
|
||||
|
||||
// ==================== Getters & Setters ====================
|
||||
|
||||
public boolean isActivityEnabled() { return activityEnabled; }
|
||||
@@ -96,8 +276,8 @@ public class FeatureFlagConfig {
|
||||
/**
|
||||
* 获取所有 Feature Flag 的当前值(供 /operator/feature-flags 接口使用)
|
||||
*/
|
||||
public java.util.Map<String, Object> getAllFlags() {
|
||||
java.util.Map<String, Object> flags = new java.util.HashMap<>();
|
||||
public Map<String, Object> getAllFlags() {
|
||||
Map<String, Object> flags = new HashMap<>();
|
||||
flags.put("activityEnabled", activityEnabled);
|
||||
flags.put("matchmakerEnabled", matchmakerEnabled);
|
||||
flags.put("chatImageEnabled", chatImageEnabled);
|
||||
|
||||
+59
-36
@@ -2,6 +2,8 @@ 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.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.featureflag.FeatureFlagOverrideService;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
@@ -12,7 +14,7 @@ import java.util.Map;
|
||||
|
||||
/**
|
||||
* Feature Flag 管理控制器
|
||||
* V1.1 Phase 22
|
||||
* V1.1 Phase 22;V048 起 PUT 真正落库(此前仅改内存单例,重启即丢)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/operator/feature-flags")
|
||||
@@ -21,6 +23,7 @@ import java.util.Map;
|
||||
public class FeatureFlagController {
|
||||
|
||||
private final FeatureFlagConfig featureFlagConfig;
|
||||
private final FeatureFlagOverrideService featureFlagOverrideService;
|
||||
|
||||
/**
|
||||
* 获取所有 Feature Flag 当前值
|
||||
@@ -32,12 +35,17 @@ public class FeatureFlagController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新单个 Feature Flag
|
||||
* 更新单个 Feature Flag(**持久化**)
|
||||
* PUT /api/v1/operator/feature-flags/{flagName}
|
||||
* Body: { "value": <boolean|int|string> }
|
||||
* Body: { "value": <boolean|int|string> }
|
||||
*
|
||||
* <p>支持布尔 / 整数 / 字符串三类 flag(此前仅支持布尔,导致 7 个非布尔 flag
|
||||
* 经 API 更新必失败)。未知 flag 或缺失 value 返回 400,且不会静默把功能置为关闭。
|
||||
* <p>顺序刻意是「先改内存、再落库」:落库成功但内存没改,会让运维看到旧值;
|
||||
* 反过来(先落库再改内存)若落库失败,内存却已经改了 —— 那才是真正的分裂状态。
|
||||
* 内存改动失败时直接返回 400,不会有任何一行被写进数据库。
|
||||
*
|
||||
* <p>支持布尔 / 整数 / 字符串三类 flag(此前仅支持布尔,导致若干非布尔 flag
|
||||
* 经 API 更新必失败)。未知 flag、缺失 value 或类型不匹配返回 400,
|
||||
* 且不会静默把功能置为关闭,也不会落库。
|
||||
*/
|
||||
@PutMapping("/{flagName}")
|
||||
public ApiResponse<Map<String, Object>> updateFlag(
|
||||
@@ -46,53 +54,68 @@ public class FeatureFlagController {
|
||||
@RequestBody Map<String, Object> body) {
|
||||
if (!featureFlagConfig.getAllFlags().containsKey(flagName)) {
|
||||
return ApiResponse.error(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
|
||||
ApiResponseCode.BAD_REQUEST,
|
||||
"未知的 Feature Flag: " + flagName);
|
||||
}
|
||||
if (!body.containsKey("value")) {
|
||||
return ApiResponse.error(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
|
||||
ApiResponseCode.BAD_REQUEST,
|
||||
"请求体必须包含 value 字段");
|
||||
}
|
||||
Object value = body.get("value");
|
||||
if (value == null) {
|
||||
// 字符串化后会变成字面量 "null",回读时无法与真正的 "null" 区分 —— 直接拒。
|
||||
return ApiResponse.error(
|
||||
ApiResponseCode.BAD_REQUEST,
|
||||
"value 不能为 null;要恢复默认请改用 DELETE");
|
||||
}
|
||||
try {
|
||||
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));
|
||||
featureFlagConfig.setFlagValue(flagName, value);
|
||||
} catch (ClassCastException e) {
|
||||
return ApiResponse.error(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
|
||||
ApiResponseCode.BAD_REQUEST,
|
||||
"value 类型与 Flag " + flagName + " 不匹配: " + e.getMessage());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ApiResponse.error(
|
||||
ApiResponseCode.BAD_REQUEST,
|
||||
"无法更新该 Flag: " + e.getMessage());
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
|
||||
ApiResponseCode.BAD_REQUEST,
|
||||
"无法更新该 Flag: " + e.getMessage());
|
||||
}
|
||||
// 内存已生效,落库使其成为持久事实(重启后仍生效)
|
||||
String operatorId = principal == null ? null : principal.id();
|
||||
featureFlagOverrideService.save(
|
||||
flagName,
|
||||
String.valueOf(featureFlagConfig.getAllFlags().get(flagName)),
|
||||
featureFlagConfig.valueTypeOf(flagName),
|
||||
operatorId);
|
||||
return ApiResponse.ok(Map.of(
|
||||
"flagName", flagName,
|
||||
"value", featureFlagConfig.getAllFlags().get(flagName),
|
||||
"updated", true,
|
||||
"persisted", true));
|
||||
}
|
||||
|
||||
private String capitalize(String s) {
|
||||
return s.substring(0, 1).toUpperCase() + s.substring(1);
|
||||
/**
|
||||
* 恢复为默认配置(删除数据库覆盖)
|
||||
* DELETE /api/v1/operator/feature-flags/{flagName}
|
||||
*
|
||||
* <p>没有这个端点,一次写错的覆盖只能靠手工改库才能撤销。
|
||||
*/
|
||||
@DeleteMapping("/{flagName}")
|
||||
public ApiResponse<Map<String, Object>> resetFlag(@PathVariable String flagName) {
|
||||
if (!featureFlagConfig.getAllFlags().containsKey(flagName)) {
|
||||
return ApiResponse.error(
|
||||
ApiResponseCode.BAD_REQUEST,
|
||||
"未知的 Feature Flag: " + flagName);
|
||||
}
|
||||
featureFlagOverrideService.delete(flagName);
|
||||
featureFlagConfig.resetToDefault(flagName);
|
||||
return ApiResponse.ok(Map.of(
|
||||
"flagName", flagName,
|
||||
"value", featureFlagConfig.getAllFlags().get(flagName),
|
||||
"reset", true));
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.danyang.match.platform.shared.featureflag;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* Feature Flag 的运行期覆盖记录。对应 {@code feature_flag_overrides}(V048)。
|
||||
*
|
||||
* <p>刻意<b>不</b>继承项目 BaseEntity:本表无软删语义(删除即恢复默认),
|
||||
* 也不需要 id / created_at —— 与 {@code IdempotencyRecord} 同一取舍。
|
||||
*
|
||||
* <p>一张表只有 14 行(每个 flag 至多一行),主键就是 flag_key。
|
||||
*/
|
||||
@Data
|
||||
public class FeatureFlagOverride {
|
||||
|
||||
public static final String TYPE_BOOLEAN = "boolean";
|
||||
public static final String TYPE_INT = "int";
|
||||
public static final String TYPE_STRING = "string";
|
||||
|
||||
private String flagKey;
|
||||
/** 一律以字符串存储;回读时按 FeatureFlagConfig 字段的声明类型解析。 */
|
||||
private String flagValue;
|
||||
private String valueType;
|
||||
private String updatedBy;
|
||||
private OffsetDateTime updatedAt;
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package com.danyang.match.platform.shared.featureflag;
|
||||
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* {@code feature_flag_overrides} 落库操作。
|
||||
*
|
||||
* <p>与 {@code IdempotencyMapper} 同一约定:全部用显式 SQL 并给列起别名,
|
||||
* 不依赖 {@code map-underscore-to-camel-case}(该配置一旦被改动,隐式映射会静默失效)。
|
||||
* 刻意<b>不</b> extends BaseMapper —— 本表主键是 flag_key 而非 id,
|
||||
* 且 MyBatis-Plus 对无 {@code @TableId} 的实体不会注入 selectById/updateById。
|
||||
*/
|
||||
@Mapper
|
||||
public interface FeatureFlagOverrideMapper {
|
||||
|
||||
@Select("""
|
||||
SELECT flag_key AS flagKey,
|
||||
flag_value AS flagValue,
|
||||
value_type AS valueType,
|
||||
updated_by AS updatedBy,
|
||||
updated_at AS updatedAt
|
||||
FROM feature_flag_overrides
|
||||
""")
|
||||
List<FeatureFlagOverride> findAll();
|
||||
|
||||
@Select("""
|
||||
SELECT flag_key AS flagKey,
|
||||
flag_value AS flagValue,
|
||||
value_type AS valueType,
|
||||
updated_by AS updatedBy,
|
||||
updated_at AS updatedAt
|
||||
FROM feature_flag_overrides
|
||||
WHERE flag_key = #{flagKey}
|
||||
""")
|
||||
FeatureFlagOverride findByKey(@Param("flagKey") String flagKey);
|
||||
|
||||
/**
|
||||
* @return 更新行数;0 表示此前无覆盖行
|
||||
*/
|
||||
@Update("""
|
||||
UPDATE feature_flag_overrides
|
||||
SET flag_value = #{flagValue},
|
||||
value_type = #{valueType},
|
||||
updated_by = #{updatedBy},
|
||||
updated_at = #{now}
|
||||
WHERE flag_key = #{flagKey}
|
||||
""")
|
||||
int updateValue(@Param("flagKey") String flagKey,
|
||||
@Param("flagValue") String flagValue,
|
||||
@Param("valueType") String valueType,
|
||||
@Param("updatedBy") String updatedBy,
|
||||
@Param("now") OffsetDateTime now);
|
||||
|
||||
/**
|
||||
* 新增覆盖行。
|
||||
*
|
||||
* <p>刻意不用 {@code ON CONFLICT (flag_key) DO UPDATE} —— 那是 PG 语法,
|
||||
* H2 测试库不认(同 IdempotencyMapper 的取舍)。upsert 由
|
||||
* {@link FeatureFlagOverrideService#save} 以「先 UPDATE、0 行再 INSERT」实现,
|
||||
* 两个库行为一致。
|
||||
*/
|
||||
@Insert("""
|
||||
INSERT INTO feature_flag_overrides
|
||||
(flag_key, flag_value, value_type, updated_by, updated_at)
|
||||
VALUES
|
||||
(#{flagKey}, #{flagValue}, #{valueType}, #{updatedBy}, #{now})
|
||||
""")
|
||||
int insert(@Param("flagKey") String flagKey,
|
||||
@Param("flagValue") String flagValue,
|
||||
@Param("valueType") String valueType,
|
||||
@Param("updatedBy") String updatedBy,
|
||||
@Param("now") OffsetDateTime now);
|
||||
|
||||
/** 删除覆盖即恢复默认配置。 */
|
||||
@Delete("DELETE FROM feature_flag_overrides WHERE flag_key = #{flagKey}")
|
||||
int deleteByKey(@Param("flagKey") String flagKey);
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package com.danyang.match.platform.shared.featureflag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Feature Flag 覆盖值的读写。
|
||||
*
|
||||
* <p>存在意义:让「运维在后台改过的开关」在重启后仍然生效(V048)。
|
||||
* 读写都只针对 {@code feature_flag_overrides},不碰内存状态 —— 内存状态由
|
||||
* {@code FeatureFlagConfig} 负责,两者职责分开,避免"改了内存没落库"这类半截修复。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FeatureFlagOverrideService {
|
||||
|
||||
private final FeatureFlagOverrideMapper mapper;
|
||||
|
||||
/**
|
||||
* 读取全部覆盖值。
|
||||
*
|
||||
* @return flagKey → 原始字符串值(未解析)
|
||||
*/
|
||||
public Map<String, String> loadOverrides() {
|
||||
List<FeatureFlagOverride> rows = mapper.findAll();
|
||||
Map<String, String> out = new LinkedHashMap<>();
|
||||
if (rows == null) {
|
||||
return out;
|
||||
}
|
||||
for (FeatureFlagOverride row : rows) {
|
||||
if (row.getFlagKey() == null || row.getFlagKey().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
out.put(row.getFlagKey(), row.getFlagValue());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入(或更新)一条覆盖。
|
||||
*
|
||||
* <p>upsert 走「先 UPDATE、0 行再 INSERT」而非 PG 的 {@code ON CONFLICT}:
|
||||
* H2 测试库不认后者(同 IdempotencyMapper 的取舍)。并发下两个请求可能同时
|
||||
* 得到 UPDATE=0 并都去 INSERT,其中一个会撞主键 —— 此处捕获后改走 UPDATE,
|
||||
* 保证最终只留一行(而不是把并发写暴露成 500)。
|
||||
*/
|
||||
@Transactional
|
||||
public void save(String flagKey, String flagValue, String valueType, String operatorId) {
|
||||
Objects.requireNonNull(flagKey, "flagKey 不能为空");
|
||||
Objects.requireNonNull(flagValue, "flagValue 不能为空");
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
int updated = mapper.updateValue(flagKey, flagValue, valueType, operatorId, now);
|
||||
if (updated > 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
mapper.insert(flagKey, flagValue, valueType, operatorId, now);
|
||||
} catch (DuplicateKeyException e) {
|
||||
log.debug("并发写入 flag 覆盖,回退为更新: flagKey={}", flagKey);
|
||||
mapper.updateValue(flagKey, flagValue, valueType, operatorId, now);
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除覆盖(恢复默认配置)。 */
|
||||
public void delete(String flagKey) {
|
||||
mapper.deleteByKey(flagKey);
|
||||
}
|
||||
}
|
||||
@@ -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 写入';
|
||||
+3
@@ -89,6 +89,9 @@ class EntityMigrationParityTest {
|
||||
// 运营后台「内容分类」tab 的增改停用全链路零覆盖。已补入 schema-h2.sql。
|
||||
"content_categories",
|
||||
"activities",
|
||||
// Phase 23 收尾(V048):flag 覆盖表。持久化若不在测试库里,
|
||||
// 「重启后仍生效」这条核心不变量就无从验证(只能断言内存值)。
|
||||
"feature_flag_overrides",
|
||||
"user_local_eligibilities",
|
||||
"local_eligibility_evidences",
|
||||
// Phase 13:红娘服务三表此前完全缺失 → matchmaker 模块零真实覆盖(只能写假测试)。
|
||||
|
||||
+3
-1
@@ -23,7 +23,9 @@ class MigrationValidationTest {
|
||||
// V046 修正其 actor_id 列类型(VARCHAR → uuid,否则参数以 uuid 到达时报
|
||||
// `operator does not exist: character varying = uuid`,机制整体 500)
|
||||
// Phase 23:V047 隐私设置补齐小程序端契约列(allow_activity_invitation / show_last_online)
|
||||
private static final int LATEST_MIGRATION = 47;
|
||||
// Phase 23 收尾:V048 feature_flag_overrides —— PUT /operator/feature-flags 真持久化
|
||||
// (此前只改内存单例,重启即丢、多实例不一致)
|
||||
private static final int LATEST_MIGRATION = 48;
|
||||
|
||||
private static final String MIGRATION_DIR =
|
||||
"backend/src/main/resources/db/migration/";
|
||||
|
||||
+11
-1
@@ -2,13 +2,16 @@ 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.featureflag.FeatureFlagOverrideService;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
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.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Map;
|
||||
@@ -42,6 +45,10 @@ class FeatureFlagControllerTest {
|
||||
@MockBean
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
/** 真实的覆盖持久化服务(V048 起 PUT 会落库;@Transactional 保证用例间回滚)。 */
|
||||
@Autowired
|
||||
private FeatureFlagOverrideService overrideService;
|
||||
|
||||
private FeatureFlagConfig fixture;
|
||||
private FeatureFlagController controller;
|
||||
private OperatorPrincipal principal;
|
||||
@@ -49,7 +56,10 @@ class FeatureFlagControllerTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
fixture = new FeatureFlagConfig();
|
||||
controller = new FeatureFlagController(fixture);
|
||||
ReflectionTestUtils.setField(fixture, "overrideService", overrideService);
|
||||
// 等价于容器启动时的 @PostConstruct:捕获默认快照并加载已有覆盖
|
||||
fixture.reloadFromPersistence();
|
||||
controller = new FeatureFlagController(fixture, overrideService);
|
||||
principal = new OperatorPrincipal("00000000-0000-0000-0000-000000000099");
|
||||
}
|
||||
|
||||
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
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.featureflag.FeatureFlagOverride;
|
||||
import com.danyang.match.platform.shared.featureflag.FeatureFlagOverrideMapper;
|
||||
import com.danyang.match.platform.shared.featureflag.FeatureFlagOverrideService;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
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.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.HashMap;
|
||||
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.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Feature Flag「真持久化」守卫(V048)。
|
||||
*
|
||||
* <p>背景:PUT /operator/feature-flags/{flagName} 此前只改内存单例的字段 ——
|
||||
* 重启即回默认值,多实例互不一致。运维在后台关掉开关后,一次发布就把它改回来,
|
||||
* 而后台仍显示「已关闭」:显示与事实不符。
|
||||
*
|
||||
* <p><b>本守卫要钉的核心不变量</b>:改动在「另起一个配置实例重新加载」之后仍然存在
|
||||
* —— 这正是「重启」的等价路径。只断言「PUT 后内存值变了」是假绿:那在改造前也是绿的。
|
||||
*
|
||||
* <p>装配说明:一律使用隔离的 {@link FeatureFlagConfig} 实例(反射注入真实的
|
||||
* {@link FeatureFlagOverrideService}),不 mutate Spring 单例 —— 单例的内存值不会随
|
||||
* {@code @Transactional} 回滚,会污染其他读取默认 flag 的测试。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class FeatureFlagPersistenceGuardTest {
|
||||
|
||||
private static final String OPERATOR_ID = "00000000-0000-0000-0000-000000000099";
|
||||
|
||||
@MockBean
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
@Autowired
|
||||
private FeatureFlagOverrideService overrideService;
|
||||
|
||||
@Autowired
|
||||
private FeatureFlagOverrideMapper overrideMapper;
|
||||
|
||||
private OperatorPrincipal principal;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
principal = new OperatorPrincipal(OPERATOR_ID);
|
||||
}
|
||||
|
||||
/** 模拟「进程启动」:手工构造配置实例并触发与 @PostConstruct 相同的加载动作。 */
|
||||
private FeatureFlagConfig bootConfig() {
|
||||
FeatureFlagConfig cfg = new FeatureFlagConfig();
|
||||
ReflectionTestUtils.setField(cfg, "overrideService", overrideService);
|
||||
cfg.reloadFromPersistence();
|
||||
return cfg;
|
||||
}
|
||||
|
||||
private FeatureFlagController controllerFor(FeatureFlagConfig cfg) {
|
||||
return new FeatureFlagController(cfg, overrideService);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 核心:持久 + 重启后仍生效
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void updateIsPersistedAndSurvivesReload() {
|
||||
FeatureFlagConfig cfg = bootConfig();
|
||||
assertTrue(cfg.isReferralEnabled(), "前置条件:referralEnabled 默认为 true");
|
||||
|
||||
ApiResponse<Map<String, Object>> resp =
|
||||
controllerFor(cfg).updateFlag(principal, "referralEnabled", Map.of("value", false));
|
||||
assertTrue(resp.isSuccess());
|
||||
assertFalse(cfg.isReferralEnabled(), "PUT 应立即在内存生效");
|
||||
|
||||
// 落库断言:不能只看内存 —— 内存改动是本改造之前就有的行为
|
||||
FeatureFlagOverride row = overrideMapper.findByKey("referralEnabled");
|
||||
assertNotNull(row, "PUT 后数据库应存在覆盖行,否则重启即丢失");
|
||||
assertEquals("false", row.getFlagValue());
|
||||
assertEquals("boolean", row.getValueType());
|
||||
assertEquals(OPERATOR_ID, row.getUpdatedBy(), "应记录修改人,便于审计");
|
||||
assertNotNull(row.getUpdatedAt());
|
||||
|
||||
// 重启等价路径:全新实例 + 重新加载
|
||||
FeatureFlagConfig afterRestart = bootConfig();
|
||||
assertFalse(afterRestart.isReferralEnabled(),
|
||||
"重启后应仍为 false —— 若为 true 说明覆盖没有被真正加载");
|
||||
}
|
||||
|
||||
/** 反证:没有覆盖行时必须保持默认,不能「永远读库」或凭空置反。 */
|
||||
@Test
|
||||
void reloadWithoutOverrideKeepsDefault() {
|
||||
overrideService.delete("matchmakerEnabled");
|
||||
FeatureFlagConfig cfg = bootConfig();
|
||||
assertTrue(cfg.isMatchmakerEnabled(), "无覆盖行时应保持默认 true");
|
||||
|
||||
assertNull(overrideMapper.findByKey("matchmakerEnabled"));
|
||||
}
|
||||
|
||||
/** 删除覆盖 = 恢复默认,且数据库不再留行。 */
|
||||
@Test
|
||||
void deleteRestoresDefaultAndRemovesRow() {
|
||||
FeatureFlagConfig cfg = bootConfig();
|
||||
controllerFor(cfg).updateFlag(principal, "serviceProgramEnabled", Map.of("value", false));
|
||||
assertFalse(cfg.isServiceProgramEnabled());
|
||||
|
||||
ApiResponse<Map<String, Object>> resp = controllerFor(cfg).resetFlag("serviceProgramEnabled");
|
||||
assertTrue(resp.isSuccess());
|
||||
assertTrue(cfg.isServiceProgramEnabled(), "DELETE 后应恢复默认 true");
|
||||
assertNull(overrideMapper.findByKey("serviceProgramEnabled"), "覆盖行应被删除");
|
||||
|
||||
// 重启后也不该再被覆盖
|
||||
assertTrue(bootConfig().isServiceProgramEnabled());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 非布尔类型同样要能持久化(D-22-A 同族:此前反射只支持布尔)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void stringAndIntFlagsPersist() {
|
||||
FeatureFlagConfig cfg = bootConfig();
|
||||
assertTrue(controllerFor(cfg).updateFlag(principal, "livenessProvider", Map.of("value", "third-party")).isSuccess());
|
||||
assertTrue(controllerFor(cfg).updateFlag(principal, "localEvidenceMaxFiles", Map.of("value", 3)).isSuccess());
|
||||
|
||||
FeatureFlagConfig afterRestart = bootConfig();
|
||||
assertEquals("third-party", afterRestart.getLivenessProvider());
|
||||
assertEquals(3, afterRestart.getLocalEvidenceMaxFiles());
|
||||
assertEquals("int", overrideMapper.findByKey("localEvidenceMaxFiles").getValueType());
|
||||
}
|
||||
|
||||
/** upsert:连续改同一个 flag 只留一行,且是最后一次的值。 */
|
||||
@Test
|
||||
void repeatedUpdateKeepsSingleRow() {
|
||||
FeatureFlagConfig cfg = bootConfig();
|
||||
controllerFor(cfg).updateFlag(principal, "localEvidenceRetentionDays", Map.of("value", 30));
|
||||
controllerFor(cfg).updateFlag(principal, "localEvidenceRetentionDays", Map.of("value", 90));
|
||||
|
||||
assertEquals("90", overrideMapper.findByKey("localEvidenceRetentionDays").getFlagValue());
|
||||
assertEquals(90, bootConfig().getLocalEvidenceRetentionDays());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 失败路径不得产生半截状态(改了库没改内存 / 改了内存没落库)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void unknownFlagIsRejectedAndNotPersisted() {
|
||||
FeatureFlagConfig cfg = bootConfig();
|
||||
ApiResponse<Map<String, Object>> resp =
|
||||
controllerFor(cfg).updateFlag(principal, "noSuchFlag", Map.of("value", true));
|
||||
assertFalse(resp.isSuccess());
|
||||
assertNull(overrideMapper.findByKey("noSuchFlag"), "未知 flag 不得写入数据库");
|
||||
}
|
||||
|
||||
@Test
|
||||
void typeMismatchIsRejectedWithoutChangingPersistedValue() {
|
||||
FeatureFlagConfig cfg = bootConfig();
|
||||
controllerFor(cfg).updateFlag(principal, "referralEnabled", Map.of("value", false));
|
||||
assertEquals("false", overrideMapper.findByKey("referralEnabled").getFlagValue());
|
||||
|
||||
ApiResponse<Map<String, Object>> resp =
|
||||
controllerFor(cfg).updateFlag(principal, "referralEnabled", Map.of("value", "not-a-bool"));
|
||||
assertFalse(resp.isSuccess());
|
||||
assertFalse(cfg.isReferralEnabled(), "类型不匹配不得静默把功能置为关闭/开启");
|
||||
assertEquals("false", overrideMapper.findByKey("referralEnabled").getFlagValue(),
|
||||
"失败请求不得改动已持久化的值");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullValueIsRejectedAndNotPersisted() {
|
||||
FeatureFlagConfig cfg = bootConfig();
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("value", null);
|
||||
ApiResponse<Map<String, Object>> resp = controllerFor(cfg).updateFlag(principal, "livenessProvider", body);
|
||||
assertFalse(resp.isSuccess(), "value=null 会被字符串化成字面量 null,必须拒绝");
|
||||
assertNull(overrideMapper.findByKey("livenessProvider"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingValueIsRejected() {
|
||||
FeatureFlagConfig cfg = bootConfig();
|
||||
assertFalse(controllerFor(cfg).updateFlag(principal, "referralEnabled", Map.of()).isSuccess());
|
||||
assertNull(overrideMapper.findByKey("referralEnabled"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 守卫自检:防止「加载钩子被摘掉,而所有行为用例仍然绿」
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void startupLoadHookIsDeclared() throws Exception {
|
||||
Method m = FeatureFlagConfig.class.getMethod("reloadFromPersistence");
|
||||
assertNotNull(m.getAnnotation(PostConstruct.class),
|
||||
"reloadFromPersistence 必须带 @PostConstruct —— 否则容器启动时不会加载数据库覆盖,"
|
||||
+ "持久化形同虚设,而本类其余用例(手工调用该方法)仍会全绿");
|
||||
}
|
||||
|
||||
/** 数据库里写坏的值必须 fail-fast,而不是被静默忽略成「配了不生效」。 */
|
||||
@Test
|
||||
void corruptPersistedValueFailsFast() {
|
||||
overrideService.save("localEvidenceMaxFiles", "not-a-number", "int", OPERATOR_ID);
|
||||
FeatureFlagConfig cfg = new FeatureFlagConfig();
|
||||
ReflectionTestUtils.setField(cfg, "overrideService", overrideService);
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class,
|
||||
cfg::reloadFromPersistence,
|
||||
"覆盖值无法解析时应拒绝加载,而不是静默回退默认值");
|
||||
assertTrue(ex.getMessage().contains("localEvidenceMaxFiles"), ex.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -633,3 +633,16 @@ CREATE TABLE review_items (
|
||||
|
||||
CREATE UNIQUE INDEX uk_idempotency_scope
|
||||
ON idempotency_records (actor_id, endpoint, idempotency_key);
|
||||
|
||||
-- V048: Feature Flag 运行期覆盖持久化
|
||||
-- 生产 updated_by 是 uuid(UUIDStringTypeHandler 以 uuid 类型发送参数,
|
||||
-- 建成 varchar 会在 PG 上报 operator does not exist,同 V046 的 actor_id);
|
||||
-- H2 侧沿用本库既有约定的 VARCHAR(36)。
|
||||
CREATE TABLE feature_flag_overrides (
|
||||
flag_key VARCHAR(64) PRIMARY KEY,
|
||||
flag_value VARCHAR(255) NOT NULL,
|
||||
value_type VARCHAR(16) NOT NULL,
|
||||
updated_by VARCHAR(36),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_flag_value_type CHECK (value_type IN ('boolean', 'int', 'string'))
|
||||
);
|
||||
|
||||
@@ -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 绿*
|
||||
|
||||
Reference in New Issue
Block a user