From 1462ec29203e16e663ccfb5e76e622954807c390 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 8 Jul 2026 12:22:48 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=81=B0=E5=BA=A6=E8=A7=84?= =?UTF-8?q?=E5=88=99=E5=88=A4=E5=AE=9A=E4=B8=8E=E5=90=8E=E5=8F=B0=E8=A1=A8?= =?UTF-8?q?=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按需读取灰度用户标签 新建 Gate Target 时重置表单规则 补充灰度语义和踩坑文档 --- .../pages/AdminGrayReleaseConfigPage.test.tsx | 37 ++++++ .../src/pages/AdminGrayReleaseConfigPage.tsx | 10 +- .../shared-memory/decision-log.md | 3 +- docs/project-memory/shared-memory/pitfalls.md | 8 ++ ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +- server-rs/crates/api-server/src/state.rs | 119 +++++++++++++++--- 6 files changed, 160 insertions(+), 21 deletions(-) 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();