优化灰度规则判定与后台表单

按需读取灰度用户标签

新建 Gate Target 时重置表单规则

补充灰度语义和踩坑文档
This commit is contained in:
2026-07-08 12:22:48 +08:00
parent 25cac367c6
commit 1462ec2920
6 changed files with 160 additions and 21 deletions
@@ -142,6 +142,43 @@ test('灰度发布页可选择已有 gate 编辑', async () => {
).toBe('');
});
test('灰度发布页选择新 target 时重置旧 gate 规则', async () => {
const user = userEvent.setup();
render(
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
);
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(
@@ -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 {
@@ -3868,6 +3868,7 @@
- 背景:平台需要把新功能先开放给部分用户,首个接入点是创作入口;灰度规则不能泄露用户标签或完整受众配置给普通前端。
- 决策:新增 SpacetimeDB `feature_gate_config` 表作为通用功能灰度事实源,后台通过 `/admin/api/feature-gates` 配置 gate。创作入口使用 `creation-entry:<id>` 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`
@@ -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`
@@ -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
+103 -16
View File
@@ -1,5 +1,5 @@
use std::{
collections::HashMap,
collections::{HashMap, HashSet},
error::Error,
fmt,
sync::{
@@ -730,7 +730,12 @@ impl AppState {
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
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::<HashSet<_>>();
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();