feat(system): add runtime-toggleable feature flag store

This commit is contained in:
matevip 2026-05-02 19:04:12 +08:00
parent d0706239ea
commit 4f96a46900
7 changed files with 449 additions and 0 deletions

View File

@ -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.
*
* <p>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<FeatureFlagEntity>> 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<Void> update(@PathVariable @NotBlank String flagKey,
@RequestBody UpdateRequest req) {
FeatureFlagEntity flag = mapper.selectOne(
new LambdaQueryWrapper<FeatureFlagEntity>()
.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;
}
}

View File

@ -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.
*
* <p>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 <module>.<capability>.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;
}

View File

@ -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.
*
* <p>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).
*
* <p>Evaluation order:
* <ol>
* <li>If {@code enabled == false}, return false.</li>
* <li>If {@code whitelist_kb_ids} is set and the context has a kbId,
* require kb membership; absence in the whitelist returns false.</li>
* <li>Same for {@code whitelist_user_ids}.</li>
* <li>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.</li>
* <li>Otherwise return true.</li>
* </ol>
*
* <p>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<String, FeatureFlagEntity> 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<FeatureFlagEntity> all = mapper.selectList(null);
ConcurrentHashMap<String, FeatureFlagEntity> 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<Long> kbs = parseIds(flag.getWhitelistKbIds());
if (!kbs.contains(ctx.getKbId())) {
return false;
}
}
if (userWhitelistDefined && ctx.getUserId() != null) {
Set<Long> 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<Long> 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<FeatureFlagEntity>()
.eq(FeatureFlagEntity::getFlagKey, flagKey));
return flag != null ? flag : MISSING;
} catch (Exception e) {
log.warn("[FeatureFlag] loadOne({}) failed: {}", flagKey, e.getMessage());
return MISSING;
}
}
}

View File

@ -0,0 +1,34 @@
package vip.mate.system.featureflag;
import lombok.Builder;
import lombok.Data;
/**
* Evaluation context passed to {@link FeatureFlagService#isEnabled}.
*
* <p>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();
}
}

View File

@ -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}.
*
* <p>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<FeatureFlagEntity> {
}

View File

@ -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');

View File

@ -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);