mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(system): add runtime-toggleable feature flag store
This commit is contained in:
parent
d0706239ea
commit
4f96a46900
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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');
|
||||
@ -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);
|
||||
Loading…
Reference in New Issue
Block a user