diff --git a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx
index 1bef9696b..e7f82b84e 100644
--- a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx
+++ b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx
@@ -142,6 +142,43 @@ test('灰度发布页可选择已有 gate 编辑', async () => {
).toBe('');
});
+test('灰度发布页选择新 target 时重置旧 gate 规则', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.click(
+ await screen.findByRole('button', { name: 'editor.new-toolbar' }),
+ );
+ await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
+ 'creation-entry',
+ ]);
+ await user.selectOptions(screen.getByLabelText('Gate Key 目标'), ['match3d']);
+
+ expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
+ 'creation-entry:match3d',
+ );
+ expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
+ false,
+ );
+ expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
+ '0',
+ );
+ expect(
+ (screen.getByLabelText('允许用户 ID') as HTMLTextAreaElement).value,
+ ).toBe('');
+ expect(
+ (screen.getByLabelText('允许用户标签') as HTMLTextAreaElement).value,
+ ).toBe('');
+ expect(
+ (screen.getByLabelText('拒绝用户 ID') as HTMLTextAreaElement).value,
+ ).toBe('');
+ expect((screen.getByLabelText('描述') as HTMLTextAreaElement).value).toBe(
+ '3D 消除创作入口灰度',
+ );
+});
+
test('灰度发布页可通过创作入口生成 Gate Key', async () => {
const user = userEvent.setup();
render(
diff --git a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx
index b82444cf8..38d0e1de6 100644
--- a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx
+++ b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx
@@ -191,9 +191,13 @@ export function AdminGrayReleaseConfigPage({
setSelectedGateKey('');
setGatePrefix(option.prefix);
setGateKey(option.key);
- if (!description.trim() && option.description.trim()) {
- setDescription(option.description);
- }
+ setEnabled(false);
+ setRolloutPercent('0');
+ setAllowUserIds('');
+ setAllowUserTags('');
+ setDenyUserIds('');
+ setDescription(option.description);
+ setErrorMessage('');
}
function buildPayload(): AdminUpsertFeatureGateConfigRequest {
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index b2930a1f7..9e3f9c608 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -3868,6 +3868,7 @@
- 背景:平台需要把新功能先开放给部分用户,首个接入点是创作入口;灰度规则不能泄露用户标签或完整受众配置给普通前端。
- 决策:新增 SpacetimeDB `feature_gate_config` 表作为通用功能灰度事实源,后台通过 `/admin/api/feature-gates` 配置 gate。创作入口使用 `creation-entry:` gate key 约定;`api-server` 在 `/api/creation-entry/config` 和入口路由熔断中按可选登录用户、用户标签、用户 ID 黑白名单和稳定百分比做判定,只返回当前用户过滤后的入口状态。
- 后台:灰度页的 Gate Key 选择器按 `prefix:suffix` 两段式配置;后续新增固定功能灰度 key 时,必须同步维护后台下拉框的固定目标配置,避免运营手输 key。
-- 语义:未配置 gate 或 `enabled=false` 不限制访问;启用后黑名单用户 ID 优先,其次用户 ID 白名单、用户标签白名单、稳定百分比。前端只消费过滤后的 `visible/open`,不承接灰度规则真相。
+- 语义:未配置 gate 或 `enabled=false` 不限制访问;启用后黑名单用户 ID 优先,其次用户 ID 白名单、用户标签白名单、稳定百分比。`enabled=true` 且 `rolloutPercent=0` 是有意的 kill switch;后台选择尚不存在的新 target 时必须重置启用状态、比例和黑白名单,不能隐式继承上一条 gate 的规则。前端只消费过滤后的 `visible/open`,不承接灰度规则真相。
+- 性能:`api-server` 只在当前判定涉及的已启用 gate 配置了用户标签白名单时读取用户标签;不因无关 gate 或纯用户 ID / 百分比灰度触发额外标签读取。
- 影响范围:`feature_gate_config`、`spacetime-client` runtime facade、`api-server` 创作入口配置与路由熔断、`apps/admin-web` 灰度发布页。
- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p module-runtime --manifest-path server-rs/Cargo.toml feature_gate`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml creation_entry_feature_gate`、`npm run admin-web:typecheck`、后台灰度页 Vitest、`npm run check:encoding`、`git diff --check`。
diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index aad3ec7a2..2f9605ce5 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -2861,3 +2861,11 @@
- 处理:灰度配置读取必须走 `get_feature_gate_config` procedure 的事务快照,成功后再更新进程缓存;缓存只作为 procedure 暂时失败后的兜底。不要订阅或读取 `feature_gate_config` 本地表来判断后台配置。
- 验证:`RUSTC_WRAPPER= cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`;`RUSTC_WRAPPER= cargo test -p api-server --manifest-path server-rs/Cargo.toml frontend_runtime_config_denies_anonymous_agent_sidebar_when_gate_enabled`;`RUSTC_WRAPPER= cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_agent_api_returns_service_unavailable_when_sidebar_gate_denies_user`。
- 关联:`server-rs/crates/spacetime-client/src/runtime.rs`、`server-rs/crates/spacetime-client/src/lib.rs`、`server-rs/crates/api-server/src/frontend_runtime_config.rs`、`server-rs/crates/api-server/src/editor_agent.rs`。
+
+## 后台灰度新 target 不能继承旧规则
+
+- 现象:管理员先点开一条已有 gate,再从两段式下拉框选择一个尚不存在的新 target,保存后新 gate 可能带着上一条 gate 的启用状态、灰度比例和黑白名单。
+- 原因:新 target 分支如果只更新 gate key,会复用当前 React 表单状态;这些字段对运营不可见地跨 target 泄漏。
+- 处理:`applyGateTarget` 进入不存在的新 target 时必须重置为新建态:`enabled=false`、`rolloutPercent=0`、allow / deny 列表为空,并使用 target 默认描述。只有显式点已有 gate 才 `fillForm` 复制服务端规则。
+- 验证:`npm run test -- apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx`。
+- 关联:`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx`、`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx`。
diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
index c72151731..fe56ffbf0 100644
--- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
+++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
@@ -236,7 +236,9 @@ npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.servi
主站前端运行时配置由 `api-server` 下发;画板右侧 Agent 入口使用
`GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR=false` 默认关闭,需要开启时只改生产
-api-server 环境变量并重启 `api-server`,不再通过 `VITE_*` 构建期变量控制。
+api-server 环境变量并重启 `api-server`,不再通过 `VITE_*` 构建期变量控制。若后台再配置
+`image-editor:agent-sidebar` 灰度 gate,`enabled=true` 且 `rolloutPercent=0` 表示有意关闭该入口;
+只有当前判定涉及的已启用 gate 配置了用户标签白名单时,`api-server` 才读取用户标签。
```env
GENARRATIVE_DATABASE_BACKUP_DATA_DIR=/stdb
diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs
index 0b842d4a2..9fd400d49 100644
--- a/server-rs/crates/api-server/src/state.rs
+++ b/server-rs/crates/api-server/src/state.rs
@@ -1,5 +1,5 @@
use std::{
- collections::HashMap,
+ collections::{HashMap, HashSet},
error::Error,
fmt,
sync::{
@@ -730,7 +730,12 @@ impl AppState {
) -> Result {
let config = self.get_creation_entry_config().await?;
let gates = self.get_feature_gate_config().await?;
- let user_context = self.feature_gate_user_context(user_id).await;
+ let user_context = self
+ .feature_gate_user_context(
+ user_id,
+ creation_entry_feature_gates_require_user_tags(&config, &gates),
+ )
+ .await;
Ok(
module_runtime::apply_feature_gates_to_creation_entry_config(
config,
@@ -749,10 +754,15 @@ impl AppState {
}
let gates = self.get_feature_gate_config().await?;
- let user_context = self.feature_gate_user_context(user_id).await;
let gate = gates
.iter()
.find(|item| item.gate_key == module_runtime::IMAGE_EDITOR_AGENT_SIDEBAR_GATE_KEY);
+ let user_context = self
+ .feature_gate_user_context(
+ user_id,
+ gate.map(feature_gate_requires_user_tags).unwrap_or(false),
+ )
+ .await;
Ok(module_runtime::is_feature_gate_allowed(gate, &user_context))
}
@@ -796,24 +806,29 @@ impl AppState {
async fn feature_gate_user_context(
&self,
user_id: Option<&str>,
+ include_user_tags: bool,
) -> module_runtime::FeatureGateUserContext {
let user_id = user_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
- let user_tags = match user_id.as_ref() {
- Some(user_id) => match self.spacetime_client.get_user_tags(user_id.clone()).await {
- Ok(tags) => tags,
- Err(error) => {
- warn!(
- user_id = %user_id,
- error = %error,
- "读取灰度用户标签失败,按无标签继续判定"
- );
- vec![]
- }
- },
- None => vec![],
+ let user_tags = if include_user_tags {
+ match user_id.as_ref() {
+ Some(user_id) => match self.spacetime_client.get_user_tags(user_id.clone()).await {
+ Ok(tags) => tags,
+ Err(error) => {
+ warn!(
+ user_id = %user_id,
+ error = %error,
+ "读取灰度用户标签失败,按无标签继续判定"
+ );
+ vec![]
+ }
+ },
+ None => vec![],
+ }
+ } else {
+ vec![]
};
module_runtime::FeatureGateUserContext { user_id, user_tags }
}
@@ -1198,6 +1213,25 @@ impl AppState {
}
}
+fn feature_gate_requires_user_tags(gate: &module_runtime::FeatureGateConfigSnapshot) -> bool {
+ gate.enabled && !gate.allow_user_tags.is_empty()
+}
+
+fn creation_entry_feature_gates_require_user_tags(
+ config: &CreationEntryConfigResponse,
+ gates: &[module_runtime::FeatureGateConfigSnapshot],
+) -> bool {
+ let gate_keys = config
+ .creation_types
+ .iter()
+ .map(|entry| module_runtime::creation_entry_feature_gate_key(&entry.id))
+ .collect::>();
+
+ gates
+ .iter()
+ .any(|gate| gate_keys.contains(&gate.gate_key) && feature_gate_requires_user_tags(gate))
+}
+
#[cfg(test)]
impl AppState {
pub(crate) fn seed_test_refresh_session_for_user(
@@ -1763,6 +1797,46 @@ mod tests {
));
}
+ #[test]
+ fn feature_gate_user_tags_are_only_required_for_enabled_tag_allowlist() {
+ let mut gate = test_feature_gate("image-editor:agent-sidebar");
+
+ assert!(!feature_gate_requires_user_tags(&gate));
+
+ gate.allow_user_tags = vec!["beta".to_string()];
+ assert!(feature_gate_requires_user_tags(&gate));
+
+ gate.enabled = false;
+ assert!(!feature_gate_requires_user_tags(&gate));
+ }
+
+ #[test]
+ fn creation_entry_tag_lookup_only_needs_matching_enabled_tag_gate() {
+ let config = crate::creation_entry_config::test_creation_entry_config_response();
+
+ let mut unrelated_gate = test_feature_gate("image-editor:agent-sidebar");
+ unrelated_gate.allow_user_tags = vec!["beta".to_string()];
+ assert!(!creation_entry_feature_gates_require_user_tags(
+ &config,
+ &[unrelated_gate],
+ ));
+
+ let mut disabled_gate = test_feature_gate("creation-entry:puzzle");
+ disabled_gate.enabled = false;
+ disabled_gate.allow_user_tags = vec!["beta".to_string()];
+ assert!(!creation_entry_feature_gates_require_user_tags(
+ &config,
+ &[disabled_gate],
+ ));
+
+ let mut matching_gate = test_feature_gate("creation-entry:puzzle");
+ matching_gate.allow_user_tags = vec!["beta".to_string()];
+ assert!(creation_entry_feature_gates_require_user_tags(
+ &config,
+ &[matching_gate],
+ ));
+ }
+
#[test]
fn app_state_exposes_usable_ai_task_service() {
let state = AppState::new(AppConfig::default()).expect("state should build");
@@ -1830,6 +1904,19 @@ mod tests {
assert!(!client.config().official_fallback());
}
+ fn test_feature_gate(gate_key: &str) -> module_runtime::FeatureGateConfigSnapshot {
+ module_runtime::FeatureGateConfigSnapshot {
+ gate_key: gate_key.to_string(),
+ enabled: true,
+ rollout_percent: 0,
+ allow_user_ids: vec![],
+ allow_user_tags: vec![],
+ deny_user_ids: vec![],
+ description: String::new(),
+ updated_at_micros: 1,
+ }
+ }
+
#[test]
fn puzzle_api_state_exposes_puzzle_dependency_snapshot() {
let mut config = AppConfig::default();