diff --git a/backend/src/main/java/com/danyang/match/platform/shared/config/FeatureFlagConfig.java b/backend/src/main/java/com/danyang/match/platform/shared/config/FeatureFlagConfig.java index e0897eb..7013c53 100644 --- a/backend/src/main/java/com/danyang/match/platform/shared/config/FeatureFlagConfig.java +++ b/backend/src/main/java/com/danyang/match/platform/shared/config/FeatureFlagConfig.java @@ -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 开关 + * + *

取值优先级(V048 起):数据库覆盖({@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,这两项与真实链路无关。 + + /** + * 覆盖值的持久化服务。 + * + *

用字段注入而非构造器注入:{@code @ConfigurationProperties} 依赖无参构造 + setter 绑定, + * 加 final 字段会把它变成构造器绑定,破坏既有绑定方式。 + */ + @Autowired + private FeatureFlagOverrideService overrideService; + + /** + * 默认配置快照(yml + Java 字段默认值),在首次 {@link #reloadFromPersistence()} 时捕获。 + * + *

用途:DELETE 覆盖后要能回到「没改过」的那个值。快照必须在应用覆盖之前取, + * 且只取一次 —— 否则第二次 reload 会把上一次的覆盖值误当成默认值。 + */ + private final Map defaultsSnapshot = new LinkedHashMap<>(); + + /** + * 从数据库加载覆盖值并应用。容器启动时由 {@code @PostConstruct} 触发一次。 + * + *

为什么必须先回到默认再应用覆盖:该方法可能被重复调用(运维手工触发 / 测试), + * 若不清回默认,历史覆盖会滞留 —— 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); + } + + /** + * 应用一条来自数据库的覆盖值。 + * + *

两类异常都是故意 fail-fast:数据库是 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 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 的值(按字段声明类型分派)。 + * + *

与控制器共用同一份分派逻辑 —— 此前分派写在 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 的持久化类型标记。 + * + *

回读时以字段声明类型为准,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 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 getAllFlags() { - java.util.Map flags = new java.util.HashMap<>(); + public Map getAllFlags() { + Map flags = new HashMap<>(); flags.put("activityEnabled", activityEnabled); flags.put("matchmakerEnabled", matchmakerEnabled); flags.put("chatImageEnabled", chatImageEnabled); 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 13e9e83..14b9ff4 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 @@ -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": } + * Body: { "value": <boolean|int|string> } * - *

支持布尔 / 整数 / 字符串三类 flag(此前仅支持布尔,导致 7 个非布尔 flag - * 经 API 更新必失败)。未知 flag 或缺失 value 返回 400,且不会静默把功能置为关闭。 + *

顺序刻意是「先改内存、再落库」:落库成功但内存没改,会让运维看到旧值; + * 反过来(先落库再改内存)若落库失败,内存却已经改了 —— 那才是真正的分裂状态。 + * 内存改动失败时直接返回 400,不会有任何一行被写进数据库。 + * + *

支持布尔 / 整数 / 字符串三类 flag(此前仅支持布尔,导致若干非布尔 flag + * 经 API 更新必失败)。未知 flag、缺失 value 或类型不匹配返回 400, + * 且不会静默把功能置为关闭,也不会落库。 */ @PutMapping("/{flagName}") public ApiResponse> updateFlag( @@ -46,53 +54,68 @@ public class FeatureFlagController { @RequestBody Map 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} + * + *

没有这个端点,一次写错的覆盖只能靠手工改库才能撤销。 + */ + @DeleteMapping("/{flagName}") + public ApiResponse> 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)); } } diff --git a/backend/src/main/java/com/danyang/match/platform/shared/featureflag/FeatureFlagOverride.java b/backend/src/main/java/com/danyang/match/platform/shared/featureflag/FeatureFlagOverride.java new file mode 100644 index 0000000..11e7ad4 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/shared/featureflag/FeatureFlagOverride.java @@ -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)。 + * + *

刻意不继承项目 BaseEntity:本表无软删语义(删除即恢复默认), + * 也不需要 id / created_at —— 与 {@code IdempotencyRecord} 同一取舍。 + * + *

一张表只有 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; +} diff --git a/backend/src/main/java/com/danyang/match/platform/shared/featureflag/FeatureFlagOverrideMapper.java b/backend/src/main/java/com/danyang/match/platform/shared/featureflag/FeatureFlagOverrideMapper.java new file mode 100644 index 0000000..440ae31 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/shared/featureflag/FeatureFlagOverrideMapper.java @@ -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} 落库操作。 + * + *

与 {@code IdempotencyMapper} 同一约定:全部用显式 SQL 并给列起别名, + * 不依赖 {@code map-underscore-to-camel-case}(该配置一旦被改动,隐式映射会静默失效)。 + * 刻意不 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 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); + + /** + * 新增覆盖行。 + * + *

刻意不用 {@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); +} diff --git a/backend/src/main/java/com/danyang/match/platform/shared/featureflag/FeatureFlagOverrideService.java b/backend/src/main/java/com/danyang/match/platform/shared/featureflag/FeatureFlagOverrideService.java new file mode 100644 index 0000000..92b3cc7 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/shared/featureflag/FeatureFlagOverrideService.java @@ -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 覆盖值的读写。 + * + *

存在意义:让「运维在后台改过的开关」在重启后仍然生效(V048)。 + * 读写都只针对 {@code feature_flag_overrides},不碰内存状态 —— 内存状态由 + * {@code FeatureFlagConfig} 负责,两者职责分开,避免"改了内存没落库"这类半截修复。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class FeatureFlagOverrideService { + + private final FeatureFlagOverrideMapper mapper; + + /** + * 读取全部覆盖值。 + * + * @return flagKey → 原始字符串值(未解析) + */ + public Map loadOverrides() { + List rows = mapper.findAll(); + Map 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; + } + + /** + * 写入(或更新)一条覆盖。 + * + *

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); + } +} diff --git a/backend/src/main/resources/db/migration/V048__persist_feature_flag_overrides.sql b/backend/src/main/resources/db/migration/V048__persist_feature_flag_overrides.sql new file mode 100644 index 0000000..6c3d911 --- /dev/null +++ b/backend/src/main/resources/db/migration/V048__persist_feature_flag_overrides.sql @@ -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 写入'; diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java index 4cc5154..63646ed 100644 --- a/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java +++ b/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java @@ -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 模块零真实覆盖(只能写假测试)。 diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java index 4e4e5c8..af3ff0f 100644 --- a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java @@ -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/"; 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 index a6998e3..01cb0e2 100644 --- 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 @@ -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"); } diff --git a/backend/src/test/java/com/danyang/match/platform/shared/controller/FeatureFlagPersistenceGuardTest.java b/backend/src/test/java/com/danyang/match/platform/shared/controller/FeatureFlagPersistenceGuardTest.java new file mode 100644 index 0000000..9f000da --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/shared/controller/FeatureFlagPersistenceGuardTest.java @@ -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)。 + * + *

背景:PUT /operator/feature-flags/{flagName} 此前只改内存单例的字段 —— + * 重启即回默认值,多实例互不一致。运维在后台关掉开关后,一次发布就把它改回来, + * 而后台仍显示「已关闭」:显示与事实不符。 + * + *

本守卫要钉的核心不变量:改动在「另起一个配置实例重新加载」之后仍然存在 + * —— 这正是「重启」的等价路径。只断言「PUT 后内存值变了」是假绿:那在改造前也是绿的。 + * + *

装配说明:一律使用隔离的 {@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> 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> 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> 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> 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 body = new HashMap<>(); + body.put("value", null); + ApiResponse> 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()); + } +} diff --git a/backend/src/test/resources/schema-h2.sql b/backend/src/test/resources/schema-h2.sql index 34a600f..649e36d 100644 --- a/backend/src/test/resources/schema-h2.sql +++ b/backend/src/test/resources/schema-h2.sql @@ -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')) +); diff --git a/docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md b/docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md index 3654372..c3a04da 100644 --- a/docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md +++ b/docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md @@ -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 绿*