发布灰度默认关闭:修掉客户端“看得到点不动”与后台看不到 key (#482)
Project CI / AI game creator shell Rust crates (push) Successful in 1m28s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m2s
Project CI / Backend tests (push) Successful in 5m19s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 6m59s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m19s
Project CI / Native shell tests (push) Successful in 6m48s
Project CI / Frontend tests (push) Successful in 2m54s
Project CI / Repository checks (push) Successful in 2m58s
Project CI / AI game creator shell web tests (push) Successful in 2m34s

## 解决什么

你反馈的两个现象都是真问题,这个 PR 一次修掉:

1. **没开灰度也能看到发布按钮**:发布开关此前是「无 gate 行 = 默认开放」,所以没配灰度时后端把 `gameDistributionPublishEnabled` 判成 true,客户端就渲染了发布入口。
2. **按钮点了没反应**:发布动作的提示原先只写 `workspaceStatus`,而普通项目走 `DirectProjectChatView` 时并不渲染工作台状态行,于是「先打开一个项目再发布」「构建失败…」这类信息全都看不见。
3. **后台看不到 key**:预设项此前在未合并的另一个 PR 里,而且后台只列「已创建」的 gate 行。

## 改成什么

- `is_game_distribution_publish_enabled_for_user` 要求 gate 行存在且 `enabled=true`:**灰度默认关闭**,未登录/无行/`enabled=false` 一律不开放;只有白名单 / 灰度比例 / 用户标签命中才开放。作者写入与新版本激活都按这条判据。
- 新增与作者无关的总开关 `is_game_distribution_publish_open`:管理员审核激活新版本只看「灰度是否开启」,不会因为审核员不在作者白名单里而被挡住(这是合入后立刻发现的回归,已修 + 有用例)。
- AGC 新增 `announcePublishMessage`:发布相关提示同时写工作台状态与 DirectProject 对话,点「发布到游戏广场」不会再像没反应。
- 后台「灰度发布配置」新增「可配置开关」列表:`game-distribution:publish` 等预设即使还没创建行也会显示,点「配置」按默认关闭填表,运营开启后填白名单/比例即可放量。

## 验证

- `npm run check:game-distribution-media-e2e`(真实 Phaser 构建产物)现在覆盖:灰度关闭 → 作者拿不到入口 + 写入口 503 → 开启灰度 → 真实封面/截图直传 → 创建游戏/版本/上传/送审 → 管理员审核通过 → 网关 index.html 与 bundle 200 → nosniff;全流程通过。
- 后端:`cargo test -p api-server game_distribution` 19 passed(含新增 `game_distribution_publish_open_ignores_author_allowlist_for_activation`)、`frontend_runtime_config` 6 passed。
- 前端:admin 灰度页 10 用例(含「未创建的预设开关可见并可一键配置」)、AGC 聊天头 3 用例、网页发布页 15 用例;三端 typecheck、`check:encoding`、`check:doc-index`、`check:rustfmt`、全量 `npm test`(400 文件 / 4441 用例)与 `check:repository-ci -- origin/master HEAD` 全绿。

## 上线注意

灰度默认关闭是**刻意**的:合入后到运营在后台把 `game-distribution:publish` 配置并开启之前,作者看不到发布入口、写入口 503;已公开游戏、目录、详情、发行网关、安全下架不受影响。

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/482
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
This commit was merged in pull request #482.
This commit is contained in:
2026-09-22 21:38:03 +08:00
committed by 段舒康
parent a56790eaa6
commit f502829fde
12 changed files with 377 additions and 50 deletions
+75 -13
View File
@@ -1063,15 +1063,15 @@ mod tests {
let user = seed_phone_user_with_password(&state, "13800138195", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &user, "sess_game_distribution_publish_gate");
let mut gate = test_feature_gate(module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY);
// 无 gate 行时默认开放:已登录作者拿到 true,匿名仍为 false。
let mut cases = vec![(vec![], true)];
// 灰度默认关闭:无 gate 行、enabled=false、rollout 0 都拿不到入口。
let mut cases = vec![(vec![], false)];
cases.push((vec![gate.clone()], false));
gate.allow_user_ids = vec![user.id.clone()];
cases.push((vec![gate.clone()], true));
gate.deny_user_ids = vec![user.id.clone()];
cases.push((vec![gate.clone()], false));
gate.enabled = false;
cases.push((vec![gate.clone()], true));
cases.push((vec![gate.clone()], false));
gate.enabled = true;
gate.allow_user_ids.clear();
gate.deny_user_ids.clear();
@@ -1361,6 +1361,52 @@ mod tests {
);
}
#[tokio::test]
async fn game_distribution_publish_open_ignores_author_allowlist_for_activation() {
let state = AppState::new(AppConfig::default()).expect("state should build");
// 默认关闭:作者判定与总开关都为 false。
assert!(
!state
.is_game_distribution_publish_enabled_for_user(None)
.await
.expect("author decision")
);
assert!(
!state
.is_game_distribution_publish_open()
.await
.expect("open decision")
);
// 开启但只放白名单作者:作者判定限白名单,管理员激活按总开关放行。
let mut gate = test_feature_gate(module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY);
gate.enabled = true;
gate.rollout_percent = 0;
gate.allow_user_ids = vec!["user-allowlisted".to_string()];
state.set_test_feature_gate_config(vec![gate]);
assert!(
state
.is_game_distribution_publish_enabled_for_user(Some("user-allowlisted"))
.await
.expect("allowlisted author decision"),
"白名单作者应拿到发布入口"
);
assert!(
!state
.is_game_distribution_publish_enabled_for_user(Some("user-other"))
.await
.expect("other author decision"),
"白名单外作者不应拿到发布入口"
);
assert!(
state
.is_game_distribution_publish_open()
.await
.expect("open decision"),
"灰度开启后管理员激活新版本不应被作者白名单挡住"
);
}
#[tokio::test]
async fn game_distribution_publish_switch_blocks_writes_but_keeps_reads_and_allowlist() {
let state = AppState::new(AppConfig::default()).expect("state should build");
@@ -1390,22 +1436,37 @@ mod tests {
.expect("request should build")
};
// 默认没有 gate 行:写入进入业务,不能被发布开关拦下。
let open = app
// 灰度默认关闭:没有 gate 行时发布写入必须 503 + 专用错误码。
let default_blocked = app
.clone()
.oneshot(publish_request())
.await
.expect("request should succeed");
assert_ne!(
open.status(),
StatusCode::SERVICE_UNAVAILABLE,
"默认状态不应拦截发布"
assert_eq!(default_blocked.status(), StatusCode::SERVICE_UNAVAILABLE);
let default_payload = read_json_response(default_blocked).await;
assert_eq!(
default_payload["error"]["code"],
"GAME_DISTRIBUTION_PUBLISH_DISABLED"
);
// 运营收紧到 rollout 0 且无白名单:作者写入 503 + 专用错误码。
state.set_test_feature_gate_config(vec![test_feature_gate(
module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY,
)]);
// gate 行 enabled=false 同样未开放。
let mut disabled_gate =
test_feature_gate(module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY);
disabled_gate.enabled = false;
state.set_test_feature_gate_config(vec![disabled_gate]);
let disabled = app
.clone()
.oneshot(publish_request())
.await
.expect("request should succeed");
assert_eq!(disabled.status(), StatusCode::SERVICE_UNAVAILABLE);
// 开启但 rollout 0 且无白名单:仍然拦截。
let mut zero_rollout =
test_feature_gate(module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY);
zero_rollout.enabled = true;
zero_rollout.rollout_percent = 0;
state.set_test_feature_gate_config(vec![zero_rollout]);
let blocked = app
.clone()
.oneshot(publish_request())
@@ -1433,6 +1494,7 @@ mod tests {
// 白名单内用户仍可发布(灰度放行)。
let mut gate = test_feature_gate(module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY);
gate.enabled = true;
gate.allow_user_ids = vec![user.id.clone()];
state.set_test_feature_gate_config(vec![gate]);
let allowed = app
@@ -1444,10 +1444,17 @@ fn private_version_payload(version: &GameDistributionVersionRecord) -> Value {
/// 拒绝审核与安全下架都不受影响,用于发布事故或回滚窗口期间“关投稿、保在线”。
/// 开关状态读取失败时按关闭处理,避免绕过运营刚下的收紧动作。
async fn ensure_publish_enabled(state: &AppState, user_id: Option<&str>) -> Result<(), AppError> {
match state
.is_game_distribution_publish_enabled_for_user(user_id)
.await
{
// 作者写入按白名单/灰度判定;管理员激活新版本没有作者身份,只按总开关判定,
// 否则审核通过会被作者灰度挡住。
let decision = match user_id {
Some(user_id) => {
state
.is_game_distribution_publish_enabled_for_user(Some(user_id))
.await
}
None => state.is_game_distribution_publish_open().await,
};
match decision {
Ok(true) => Ok(()),
Ok(false) => {
warn!(
+30 -9
View File
@@ -1181,23 +1181,44 @@ impl AppState {
Ok(module_runtime::is_feature_gate_allowed(gate, &user_context))
}
/// 游戏分发写入开关:默认开放,只有运营在灰度配置里显式收紧(白名单/灰度/全关)才拦截。
/// 读取、目录、详情、发行网关与安全下架不经过这里。
/// 游戏分发发布开关:灰度未配置时**不开放**(运营在后台配置后才对白名单/灰度命中
/// 的作者开放),未登录、gate 行缺失或 `enabled=false` 都返回 false。
/// 读取、目录、详情、发行网关与安全下架不经过这里,已公开游戏始终可玩。
pub async fn is_game_distribution_publish_enabled_for_user(
&self,
user_id: Option<&str>,
) -> Result<bool, SpacetimeClientError> {
let Some(user_id) = user_id.map(str::trim).filter(|id| !id.is_empty()) else {
return Ok(false);
};
let gates = self.get_feature_gate_config().await?;
let gate = gates
let Some(gate) = gates
.iter()
.find(|item| item.gate_key == module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY);
.find(|item| item.gate_key == module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY)
else {
return Ok(false);
};
if !gate.enabled {
return Ok(false);
}
let user_context = self
.feature_gate_user_context(
user_id,
gate.map(feature_gate_requires_user_tags).unwrap_or(false),
)
.feature_gate_user_context(Some(user_id), feature_gate_requires_user_tags(gate))
.await;
Ok(module_runtime::is_feature_gate_allowed(gate, &user_context))
Ok(module_runtime::is_feature_gate_allowed(
Some(gate),
&user_context,
))
}
/// 游戏分发发布总开关(与具体作者无关):gate 行存在且 `enabled=true` 即为开放。
///
/// 管理员审核通过(激活新版本)没有作者身份,只能按总开关判定;作者写入仍走
/// `is_game_distribution_publish_enabled_for_user` 的白名单/灰度判定。
pub async fn is_game_distribution_publish_open(&self) -> Result<bool, SpacetimeClientError> {
let gates = self.get_feature_gate_config().await?;
Ok(gates.iter().any(|gate| {
gate.gate_key == module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY && gate.enabled
}))
}
pub async fn is_agc_template_library_enabled_for_user(