优化灰度规则判定与后台表单
按需读取灰度用户标签 新建 Gate Target 时重置表单规则 补充灰度语义和踩坑文档
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user