发布灰度默认关闭:修掉客户端“看得到点不动”与后台看不到 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
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:
@@ -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!(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user