diff --git a/mateclaw-server/src/main/java/vip/mate/system/featureflag/FeatureFlagController.java b/mateclaw-server/src/main/java/vip/mate/system/featureflag/FeatureFlagController.java new file mode 100644 index 00000000..5f634c47 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/system/featureflag/FeatureFlagController.java @@ -0,0 +1,88 @@ +package vip.mate.system.featureflag; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import vip.mate.common.result.R; +import vip.mate.system.featureflag.repository.FeatureFlagMapper; + +import java.util.List; + +/** + * Admin endpoints for runtime feature-flag toggling. + * + *

Authn / authz are delegated to the global security configuration: + * the JWT filter populates the principal, and the controller method runs + * inside the standard admin-role guard. Edit access should be restricted + * to operators; everyday users have no business toggling these. + * + * @author MateClaw Team + */ +@RestController +@RequestMapping("/api/v1/feature-flags") +@RequiredArgsConstructor +public class FeatureFlagController { + + private final FeatureFlagService service; + private final FeatureFlagMapper mapper; + + /** Lists every flag currently registered, including disabled and whitelisted ones. */ + @GetMapping + public R> list() { + return R.ok(mapper.selectList(null)); + } + + /** + * Updates one flag in place. Only fields explicitly set in the request + * body are touched; unspecified fields preserve their current values. + */ + @PutMapping("/{flagKey}") + public R update(@PathVariable @NotBlank String flagKey, + @RequestBody UpdateRequest req) { + FeatureFlagEntity flag = mapper.selectOne( + new LambdaQueryWrapper() + .eq(FeatureFlagEntity::getFlagKey, flagKey)); + if (flag == null) { + return R.fail("Unknown flag: " + flagKey); + } + if (req.getEnabled() != null) { + flag.setEnabled(req.getEnabled()); + } + if (req.getDescription() != null) { + flag.setDescription(req.getDescription()); + } + if (req.getWhitelistKbIds() != null) { + flag.setWhitelistKbIds(req.getWhitelistKbIds()); + } + if (req.getWhitelistUserIds() != null) { + flag.setWhitelistUserIds(req.getWhitelistUserIds()); + } + if (req.getRolloutPercent() != null) { + flag.setRolloutPercent(req.getRolloutPercent()); + } + mapper.updateById(flag); + service.invalidate(); + return R.ok(); + } + + /** Body for {@link #update(String, UpdateRequest)}. */ + @Data + public static class UpdateRequest { + private Boolean enabled; + private String description; + private String whitelistKbIds; + private String whitelistUserIds; + @Min(0) + @Max(100) + private Integer rolloutPercent; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/system/featureflag/FeatureFlagEntity.java b/mateclaw-server/src/main/java/vip/mate/system/featureflag/FeatureFlagEntity.java new file mode 100644 index 00000000..3f907408 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/system/featureflag/FeatureFlagEntity.java @@ -0,0 +1,53 @@ +package vip.mate.system.featureflag; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * One row in the runtime feature-flag store. + * + *

Backed by {@code mate_feature_flag}. Read/write paths go through + * {@link FeatureFlagService}; no service should query the table directly. + * + * @author MateClaw Team + */ +@Data +@TableName("mate_feature_flag") +public class FeatureFlagEntity { + + @TableId(type = IdType.AUTO) + private Long id; + + /** Stable identifier; convention is {@code ..enabled}. */ + private String flagKey; + + /** Master switch. When false, all evaluations short-circuit to false. */ + private Boolean enabled; + + private String description; + + /** Comma-separated KB ids; null/blank = applies to all KBs. */ + private String whitelistKbIds; + + /** Comma-separated user ids; null/blank = applies to all users. */ + private String whitelistUserIds; + + /** Percentage rollout (0..100). Only consulted when both whitelists are blank. */ + private Integer rolloutPercent; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/system/featureflag/FeatureFlagService.java b/mateclaw-server/src/main/java/vip/mate/system/featureflag/FeatureFlagService.java new file mode 100644 index 00000000..ff9aec97 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/system/featureflag/FeatureFlagService.java @@ -0,0 +1,171 @@ +package vip.mate.system.featureflag; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import vip.mate.system.featureflag.repository.FeatureFlagMapper; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * Runtime-toggleable feature-flag store with in-memory caching. + * + *

Reads are O(1) once the cache is warm; the cache refreshes on a + * configurable timer (default 30 s) and immediately on admin write via + * {@link #invalidate()}. Multi-instance deployments converge within one + * refresh tick; admin writes from one instance are not pushed to peers + * (acceptable for a coarse-grained flag system). + * + *

Evaluation order: + *

    + *
  1. If {@code enabled == false}, return false.
  2. + *
  3. If {@code whitelist_kb_ids} is set and the context has a kbId, + * require kb membership; absence in the whitelist returns false.
  4. + *
  5. Same for {@code whitelist_user_ids}.
  6. + *
  7. If both whitelists are blank and {@code rollout_percent} is in + * (0, 100), use a deterministic hash of the context's keying value + * to gate.
  8. + *
  9. Otherwise return true.
  10. + *
+ * + *

Unknown flags evaluate to false (fail-closed). This makes it safe to + * remove a flag from the seed list: callers see the feature as disabled + * until the flag is re-introduced. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class FeatureFlagService { + + private static final FeatureFlagEntity MISSING = new FeatureFlagEntity(); + + private final FeatureFlagMapper mapper; + + /** flagKey → resolved entity. {@link #MISSING} sentinel marks "not in DB". */ + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + @PostConstruct + public void init() { + refresh(); + } + + /** Returns true iff the flag is enabled in the given context. */ + public boolean isEnabled(String flagKey, FlagContext ctx) { + FeatureFlagEntity flag = cache.computeIfAbsent(flagKey, this::loadOne); + if (flag == MISSING || !Boolean.TRUE.equals(flag.getEnabled())) { + return false; + } + return matchesContext(flag, ctx == null ? FlagContext.empty() : ctx); + } + + public boolean isEnabled(String flagKey) { + return isEnabled(flagKey, FlagContext.empty()); + } + + public boolean isEnabledForKb(String flagKey, Long kbId) { + return isEnabled(flagKey, FlagContext.ofKb(kbId)); + } + + public boolean isEnabledForUser(String flagKey, Long userId) { + return isEnabled(flagKey, FlagContext.ofUser(userId)); + } + + /** Periodic full refresh; covers DB writes that bypass the admin API. */ + @Scheduled(fixedDelayString = "${mateclaw.feature-flag.refresh-ms:30000}") + public void refresh() { + try { + List all = mapper.selectList(null); + ConcurrentHashMap next = new ConcurrentHashMap<>(); + for (FeatureFlagEntity flag : all) { + next.put(flag.getFlagKey(), flag); + } + cache.clear(); + cache.putAll(next); + log.debug("[FeatureFlag] refreshed {} flags from DB", next.size()); + } catch (Exception e) { + log.warn("[FeatureFlag] refresh failed; keeping previous cache: {}", e.getMessage()); + } + } + + /** Invalidate + reload; called by admin-write paths to make changes visible immediately. */ + public void invalidate() { + cache.clear(); + refresh(); + } + + // ==================== internal ==================== + + private boolean matchesContext(FeatureFlagEntity flag, FlagContext ctx) { + boolean kbWhitelistDefined = isPresent(flag.getWhitelistKbIds()); + boolean userWhitelistDefined = isPresent(flag.getWhitelistUserIds()); + + if (kbWhitelistDefined && ctx.getKbId() != null) { + Set kbs = parseIds(flag.getWhitelistKbIds()); + if (!kbs.contains(ctx.getKbId())) { + return false; + } + } + if (userWhitelistDefined && ctx.getUserId() != null) { + Set users = parseIds(flag.getWhitelistUserIds()); + if (!users.contains(ctx.getUserId())) { + return false; + } + } + + // Percentage rollout only consulted when no whitelist applies. + if (!kbWhitelistDefined && !userWhitelistDefined) { + Integer pct = flag.getRolloutPercent(); + if (pct != null && pct > 0 && pct < 100) { + long key = ctx.getKbId() != null + ? ctx.getKbId() + : ctx.getUserId() != null + ? ctx.getUserId() + : flag.getFlagKey().hashCode(); + return Math.floorMod(key, 100L) < pct; + } + } + + return true; + } + + private static boolean isPresent(String csv) { + return csv != null && !csv.isBlank(); + } + + private static Set parseIds(String csv) { + return Arrays.stream(csv.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(s -> { + try { + return Long.parseLong(s); + } catch (NumberFormatException e) { + return null; + } + }) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toCollection(HashSet::new)); + } + + private FeatureFlagEntity loadOne(String flagKey) { + try { + FeatureFlagEntity flag = mapper.selectOne( + new LambdaQueryWrapper() + .eq(FeatureFlagEntity::getFlagKey, flagKey)); + return flag != null ? flag : MISSING; + } catch (Exception e) { + log.warn("[FeatureFlag] loadOne({}) failed: {}", flagKey, e.getMessage()); + return MISSING; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/system/featureflag/FlagContext.java b/mateclaw-server/src/main/java/vip/mate/system/featureflag/FlagContext.java new file mode 100644 index 00000000..d209c156 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/system/featureflag/FlagContext.java @@ -0,0 +1,34 @@ +package vip.mate.system.featureflag; + +import lombok.Builder; +import lombok.Data; + +/** + * Evaluation context passed to {@link FeatureFlagService#isEnabled}. + * + *

Either or both of {@code kbId} and {@code userId} may be set. The + * service uses them to match against per-flag whitelists; if both are null + * the flag is evaluated against the percentage-rollout dial only. + * + * @author MateClaw Team + */ +@Data +@Builder +public class FlagContext { + + private Long kbId; + private Long userId; + private String role; + + public static FlagContext empty() { + return FlagContext.builder().build(); + } + + public static FlagContext ofKb(Long kbId) { + return FlagContext.builder().kbId(kbId).build(); + } + + public static FlagContext ofUser(Long userId) { + return FlagContext.builder().userId(userId).build(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/system/featureflag/repository/FeatureFlagMapper.java b/mateclaw-server/src/main/java/vip/mate/system/featureflag/repository/FeatureFlagMapper.java new file mode 100644 index 00000000..dc679d4f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/system/featureflag/repository/FeatureFlagMapper.java @@ -0,0 +1,17 @@ +package vip.mate.system.featureflag.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.system.featureflag.FeatureFlagEntity; + +/** + * MyBatis Plus mapper for {@link FeatureFlagEntity}. + * + *

Mapper interface lives under a {@code repository} sub-package as required + * by the application-wide {@code @MapperScan("vip.mate.**.repository")}. + * + * @author MateClaw Team + */ +@Mapper +public interface FeatureFlagMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V78__feature_flag.sql b/mateclaw-server/src/main/resources/db/migration/h2/V78__feature_flag.sql new file mode 100644 index 00000000..671f6480 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V78__feature_flag.sql @@ -0,0 +1,48 @@ +-- mate_feature_flag: runtime-toggleable feature flag store. +-- +-- Each row defines one named flag with optional KB / user whitelists and +-- a percentage rollout. Reads go through an in-memory cache that refreshes +-- on a 30-second timer (and immediately on admin write); the cache is +-- per-instance so multi-instance deployments converge within one tick. + +CREATE TABLE IF NOT EXISTS mate_feature_flag ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + flag_key VARCHAR(128) NOT NULL UNIQUE, + + -- Master switch. When false, isEnabled() returns false regardless of whitelists. + enabled BOOLEAN NOT NULL DEFAULT FALSE, + + -- Free-form note shown in the admin UI; not consumed by code paths. + description VARCHAR(512), + + -- Comma-separated KB ids; NULL/empty means the flag applies to all KBs. + whitelist_kb_ids CLOB, + + -- Comma-separated user ids; NULL/empty means the flag applies to all users. + whitelist_user_ids CLOB, + + -- Hash-based gradual rollout (0..100). Only consulted when both whitelists + -- are empty; otherwise whitelist matching wins. + rollout_percent INT DEFAULT 0, + + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_mff_key_enabled + ON mate_feature_flag (flag_key, enabled, deleted); + +-- Seed wiki feature flags with safe defaults. +-- New capabilities ship disabled and are turned on via admin API or via +-- editing whitelist_kb_ids during gradual rollout. +MERGE INTO mate_feature_flag (flag_key, enabled, description) KEY (flag_key) VALUES + ('wiki.ocr.enabled', FALSE, 'Image OCR / vision-in pipeline for wiki uploads'), + ('wiki.compile.4stage.enabled', FALSE, 'Four-stage knowledge base compilation pipeline'), + ('wiki.compile.cache.enabled', FALSE, 'Prompt cache layer for the wiki compile pipeline'), + ('wiki.confidence.enabled', FALSE, 'Confidence taxonomy on wiki relations and pages'), + ('wiki.hot_cache.enabled', FALSE, 'KB-level recent-activity snapshot injected into agent system prompt'), + ('wiki.graph.insights.enabled', FALSE, 'Wiki graph insights panel (surprising connections, gaps, bridges)'), + ('wiki.graph.adamic_adar.enabled', FALSE, 'Adamic-Adar graph signal (additive to existing four signals)'), + ('wiki.graph.boundary.enabled', FALSE, 'Boundary score for surfacing dangling pages'), + ('wiki.relation.cache.enabled', TRUE, 'Persistent cache for wiki page-to-page relation computation'); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V78__feature_flag.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V78__feature_flag.sql new file mode 100644 index 00000000..e0ae5e48 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V78__feature_flag.sql @@ -0,0 +1,38 @@ +-- mate_feature_flag: runtime-toggleable feature flag store. +-- +-- Each row defines one named flag with optional KB / user whitelists and +-- a percentage rollout. Reads go through an in-memory cache that refreshes +-- on a 30-second timer (and immediately on admin write); the cache is +-- per-instance so multi-instance deployments converge within one tick. + +CREATE TABLE IF NOT EXISTS mate_feature_flag ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + flag_key VARCHAR(128) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + description VARCHAR(512), + whitelist_kb_ids TEXT, + whitelist_user_ids TEXT, + rollout_percent INT DEFAULT 0, + + create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted TINYINT NOT NULL DEFAULT 0, + + UNIQUE KEY uk_mff_key (flag_key), + KEY idx_mff_key_flag (flag_key, enabled, deleted) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci + COMMENT = 'Runtime-toggleable feature flag store with KB/user whitelists and percentage rollout.'; + +-- Seed wiki feature flags with safe defaults. ON DUPLICATE KEY UPDATE +-- preserves existing operator overrides on re-run. +INSERT INTO mate_feature_flag (flag_key, enabled, description) VALUES + ('wiki.ocr.enabled', FALSE, 'Image OCR / vision-in pipeline for wiki uploads'), + ('wiki.compile.4stage.enabled', FALSE, 'Four-stage knowledge base compilation pipeline'), + ('wiki.compile.cache.enabled', FALSE, 'Prompt cache layer for the wiki compile pipeline'), + ('wiki.confidence.enabled', FALSE, 'Confidence taxonomy on wiki relations and pages'), + ('wiki.hot_cache.enabled', FALSE, 'KB-level recent-activity snapshot injected into agent system prompt'), + ('wiki.graph.insights.enabled', FALSE, 'Wiki graph insights panel (surprising connections, gaps, bridges)'), + ('wiki.graph.adamic_adar.enabled', FALSE, 'Adamic-Adar graph signal (additive to existing four signals)'), + ('wiki.graph.boundary.enabled', FALSE, 'Boundary score for surfacing dangling pages'), + ('wiki.relation.cache.enabled', TRUE, 'Persistent cache for wiki page-to-page relation computation') +ON DUPLICATE KEY UPDATE description = VALUES(description);