发布入口接入灰度开关
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
- 后端:GET /api/runtime/frontend-config 新增 gameDistributionPublishEnabled,复用 `game-distribution:publish` 判据(未配置或 enabled=false 时对已登录作者默认开放,显式收紧后只放行白名单/灰度命中用户,匿名恒为 false),前端入口与写入口共用同一事实源。 - 后端用例:新增 frontend_runtime_config_game_distribution_publish_is_scoped_to_authenticated_gate,覆盖默认开放、enabled=true 无白名单、白名单命中、deny 名单、enabled=false 回退与 rolloutPercent=100。 - 网页:平台壳按灰度隐藏「发布游戏 / 发布新版本」入口;/games/publish 直接访问时渲染「发布功能正在灰度中」并提供重新检查;读取失败按放行处理,由后端写入口把关并返回可读文案。 - AGC:新增 readGamePublishAvailability(同一运行时配置字段),只有命中才把发布回调交给 DirectProject 聊天头;字段缺失或读取失败按不开放处理。 - 文档:玩法链路、后端数据契约与实施计划记录灰度口径、入口行为与验证命令。
This commit is contained in:
@@ -93,6 +93,7 @@ import {
|
||||
type ResourceReferenceInsertEventDetail,
|
||||
} from './features/project-workspace/resourceReferences';
|
||||
import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog';
|
||||
import { readGamePublishAvailability } from './services/gameDistributionPublish';
|
||||
import {
|
||||
setAgcPluginProjectPath,
|
||||
startAvailableAgcEditorPlugins,
|
||||
@@ -367,6 +368,8 @@ export function App({
|
||||
const [publishPackageResult, setPublishPackageResult] =
|
||||
useState<LocalProjectExportPackageResult | null>(null);
|
||||
const [publishPanelOpen, setPublishPanelOpen] = useState(false);
|
||||
// 发布灰度:只有命中的账号才把「发布到游戏广场」入口交给聊天头;读取失败按不开放处理。
|
||||
const [gamePublishAllowed, setGamePublishAllowed] = useState(false);
|
||||
const [projectChatError, setProjectChatError] = useState('');
|
||||
const [designAgentTransientReply, setDesignAgentTransientReplyVisible] =
|
||||
useState('');
|
||||
@@ -1185,6 +1188,20 @@ export function App({
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void readGamePublishAvailability()
|
||||
.then((allowed) => {
|
||||
if (!cancelled) setGamePublishAllowed(allowed);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setGamePublishAllowed(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [localProject?.projectPath]);
|
||||
|
||||
/**
|
||||
* 导出试玩包并打开发布面板。
|
||||
*
|
||||
@@ -2308,7 +2325,9 @@ export function App({
|
||||
ensureConversationReadAllowed={ensureDirectHistoryReadAllowed}
|
||||
ensureConversationWriteAllowed={ensureDirectTurnWriteAllowed}
|
||||
initialTurn={initialDirectTurn}
|
||||
onRequestGamePublish={requestGamePublish}
|
||||
onRequestGamePublish={
|
||||
gamePublishAllowed ? requestGamePublish : undefined
|
||||
}
|
||||
projectPath={localProject?.projectPath ?? projectPath ?? null}
|
||||
ref={directProjectChatRef}
|
||||
/>
|
||||
|
||||
@@ -34,6 +34,28 @@ export type GameDistributionPublishMetadata = {
|
||||
/** 游戏截图上限与服务端 `MAX_GAME_SCREENSHOTS` 保持一致。 */
|
||||
export const MAX_AGC_GAME_SCREENSHOTS = 6;
|
||||
|
||||
type FrontendRuntimeConfigResponse = {
|
||||
imageEditorAgentSidebarEnabled?: boolean;
|
||||
agcTemplateLibraryEnabled?: boolean;
|
||||
gameDistributionPublishEnabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取当前账号的游戏发布灰度。
|
||||
*
|
||||
* 与网页端共用 `/api/runtime/frontend-config`:后端未配置 `game-distribution:publish`
|
||||
* 时对已登录作者默认开放,运营收紧后只有白名单/灰度命中的作者返回 `true`。读取失败按
|
||||
* “不开放入口”处理——写入口本身还会再拦一次,AGC 面板不作为唯一把关点。
|
||||
*/
|
||||
export async function readGamePublishAvailability(): Promise<boolean> {
|
||||
const config = await requestClientApi<FrontendRuntimeConfigResponse>(
|
||||
'/api/runtime/frontend-config',
|
||||
{ method: 'GET' },
|
||||
'读取发布灰度配置失败',
|
||||
);
|
||||
return config?.gameDistributionPublishEnabled === true;
|
||||
}
|
||||
|
||||
export type GameDistributionPublishResult = {
|
||||
gameId: string;
|
||||
versionId: string;
|
||||
|
||||
@@ -15,7 +15,10 @@ vi.mock('../src/services/errorReporting', () => ({
|
||||
}));
|
||||
|
||||
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { publishLocalProjectGame } from '../src/services/gameDistributionPublish';
|
||||
import {
|
||||
publishLocalProjectGame,
|
||||
readGamePublishAvailability,
|
||||
} from '../src/services/gameDistributionPublish';
|
||||
|
||||
const MANIFEST = {
|
||||
projectId: 'local-proj-1',
|
||||
@@ -164,3 +167,27 @@ test('截图超过 6 张时在创建游戏前失败关闭', async () => {
|
||||
).rejects.toThrow('游戏截图最多 6 张');
|
||||
expect(fetchClientHttp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('发布灰度按后端运行时配置判定,命中才开放入口', async () => {
|
||||
fetchClientHttp.mockResolvedValueOnce(
|
||||
jsonResponse({ gameDistributionPublishEnabled: true }),
|
||||
);
|
||||
await expect(readGamePublishAvailability()).resolves.toBe(true);
|
||||
expect(fetchClientHttp.mock.calls[0]?.[0]).toBe(
|
||||
'/api/runtime/frontend-config',
|
||||
);
|
||||
|
||||
fetchClientHttp.mockResolvedValueOnce(
|
||||
jsonResponse({ gameDistributionPublishEnabled: false }),
|
||||
);
|
||||
await expect(readGamePublishAvailability()).resolves.toBe(false);
|
||||
|
||||
// 老后端/字段缺失时按未命中处理,入口不暴露。
|
||||
fetchClientHttp.mockResolvedValueOnce(jsonResponse({}));
|
||||
await expect(readGamePublishAvailability()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test('发布灰度读取失败时抛出,由调用方按不开放处理', async () => {
|
||||
fetchClientHttp.mockRejectedValueOnce(new Error('network down'));
|
||||
await expect(readGamePublishAvailability()).rejects.toThrow();
|
||||
});
|
||||
|
||||
@@ -113,6 +113,9 @@
|
||||
- 第三次合并 master(`500835407` → `fdc14404b`:DirectProject 回合三态、投影 memo、宿主崩溃后的回合收口、策划对话布局修复、AGC release 每日调度):git 自动合并无冲突,但产生了**静默拼接缺陷**——新加的聊天头 CSS 被并进了 master 策划态分组选择器中间,导致 `.project-chat-topbar-status` 在策划态丢失 `font-size`(`chatDialogFrameLayout` 用例抓到)。修复方式是按 master 原文重建分组规则、把发布入口规则独立成块,并把重复的状态规则删掉。
|
||||
- 合并后复核:全量 `npm test` 393 文件 / 4374 用例通过,root / AGC / admin-web 三端 typecheck 通过,发布入口(聊天头「发布到游戏广场」→ 试玩包导出 → 发布面板)在新回合三态下保持接线。
|
||||
|
||||
- 发布入口灰度下发:`GET /api/runtime/frontend-config` 新增 `gameDistributionPublishEnabled`,复用既有 `is_game_distribution_publish_enabled_for_user`(未配置 `game-distribution:publish` 或 `enabled=false` 时对已登录作者默认开放,显式收紧后只放行白名单/灰度命中,匿名恒为 false),避免前端入口与写入口出现两套判据。网页端 `PlatformEntryActiveFlowShell` 据此隐藏「发布游戏 / 发布新版本」入口,`/games/publish` 直接访问时渲染「发布功能正在灰度中」并提供重新检查;AGC 端 `readGamePublishAvailability` 同样读该字段,只有命中才把发布回调交给 DirectProject 聊天头。
|
||||
- 灰度验证:`cargo test -p api-server frontend_runtime_config`(6 passed,含新增的 `frontend_runtime_config_game_distribution_publish_is_scoped_to_authenticated_gate`:无 gate 行 → 登录作者 true/匿名 false;`enabled=true` 无白名单 → false;白名单命中 → true;`deny_user_ids` → false;`enabled=false` → true;`rolloutPercent=100` → true)、网页发布页 15 用例(含灰度未命中隐藏表单与「重新检查」放行)、平台壳 18 用例(含广场入口按灰度隐藏/显示)、AGC 发布服务 6 用例(含字段缺失与读取失败按不开放处理)。
|
||||
|
||||
## 尚未完成
|
||||
|
||||
- 真实独立发行域名、通配 TLS 与 CDN 仍属部署侧:边缘模板与门禁已就绪,本地已用真实 nginx 验证按主机映射、Cookie 403 与命名空间隔离,但仍需在真实域名/证书下跑一次“审核通过 → 游玩 → 换版 → 下架”并确认 CDN TTL 不超过 60 秒窗口。
|
||||
|
||||
@@ -663,7 +663,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
|
||||
- Rust 结构体:`GameDistributionGame`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`
|
||||
- 用途:游戏分发稳定身份与公开版本指针。保存 owner、标题/简介/分类资料、设备与输入声明、`publication_revision`、当前 `active_version_id`、可见性和游玩计数;标签与输入模式按版本化 JSON 保存,展示资料由 `api-server` 通过 `spacetime-client` 归一后返回。
|
||||
- 公开素材:游戏行末尾追加可空 `cover_object_key` 与 `screenshots_json`(截图 `{assetId, objectKey}` 数组);创建游戏时 `api-server` 就复核封面/截图素材存在且属于当前作者(不存在 400、他人素材 403),创建版本时按同一口径再次复核并派生对象键。只有可见性为 `published` 且存在有效 `active_version_id` 的游戏,其封面/截图素材才在 `/api/assets/read-url` 上获得匿名读授权。
|
||||
- 公开素材:游戏行末尾追加可空 `cover_object_key` 与 `screenshots_json`(截图 `{assetId, objectKey}` 数组);创建游戏时 `api-server` 就复核封面/截图素材存在且属于当前作者(不存在 400、他人素材 403),创建版本时按同一口径再次复核并派生对象键。 发布写入受灰度配置键 `game-distribution:publish` 约束:未配置或 `enabled=false` 默认开放,显式收紧后写入口(创建游戏/版本、确认包、送审、审核通过激活)返回 503 `GAME_DISTRIBUTION_PUBLISH_DISABLED`,读取与安全下架保持可用;同一判据在 `GET /api/runtime/frontend-config` 以 `gameDistributionPublishEnabled` 下发给前端入口,匿名恒为 `false`。只有可见性为 `published` 且存在有效 `active_version_id` 的游戏,其封面/截图素材才在 `/api/assets/read-url` 上获得匿名读授权。
|
||||
- 复用规则:末尾可空列 `local_project_id` 保存发布方本地项目标识(AGC 的 `manifest.projectId`)。同一 `owner_user_id` 再次以相同 `local_project_id` 创建游戏时复用既有 `game_id` 并只新增版本,避免“更新”被实现成新建游戏;该字段只是复用提示,不构成所有权或路径凭证,也不能用于跨账号匹配。
|
||||
- 索引:`by_game_distribution_game_owner_user_id` 用于作者私有游戏列表;`game_id` 为主键。公开目录只返回 `visibility = published` 且存在有效 `active_version_id` 的投影。
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
2. 所有运行依赖都必须在发行包内。资源 URL 使用与发行版本目录兼容的相对地址;前导 `/assets`、本地文件 URL、外部脚本/样式/媒体/字体地址均不属于可接受发行合同。客户端给出可操作错误,服务器仍独立校验;静态校验不能代替运行时 CSP 阻断。
|
||||
3. 建议首版限额:压缩包 100 MiB、展开总量 250 MiB、单文件 64 MiB、最多 10,000 个文件、展开/压缩比不超过 100。服务端拒绝加密 ZIP、重复或大小写冲突路径、绝对路径、`..`、符号链接/重解析点、设备文件和嵌套压缩包;拒绝 `.agent`、版本控制目录、`node_modules`、凭据文件与源码映射文件。超限返回明确错误,不截断后继续发布。
|
||||
4. 提交声明 ZIP 的 SHA-256 与字节数,服务端对收到的真实 ZIP 重新计算,再对展开文件建立相对路径、字节数和 SHA-256 清单。摘要不一致、缺文件或入口损坏时停止;只有 metadata 而没有已确认完整对象的提交必须失败。
|
||||
5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。
|
||||
5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。发布入口按灰度下发:后端灰度配置键固定为 `game-distribution:publish`(后台「灰度发布配置」可改,支持 `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。未配置该键、或 `enabled=false` 时对已登录作者默认开放;显式 `enabled=true` 后只有白名单或灰度命中的作者拿到开放状态,匿名恒为不开放。发布入口的开放状态随 `/api/runtime/frontend-config` 的 `gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 聊天头「发布到游戏广场」按钮据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。
|
||||
6. `supportedDevices` 至少包含 `desktop` 或 `mobile`;`inputModes` 来自 `keyboard`、`mouse`、`touch`;声明移动端必须包含 `touch`。`orientation` 为 `landscape`、`portrait` 或 `responsive`。这些是待人工复核的作者声明,目录只显示已经随版本审核通过的值。
|
||||
7. 原始 ZIP、未审核展开目录、审核资料均为私有对象;公开版本不暴露源码镜像键、本地路径、访问凭据或私有账号元数据。运行文件只能由发行网关按游戏、版本和文件白名单读取,不能绕过网关访问公开 OSS bucket。
|
||||
8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff`、`Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP。带平台 `Cookie` 的请求一律 `403`,避免发行文件被主站同源读取;发行网关必须部署在独立来源。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。
|
||||
|
||||
@@ -1005,6 +1005,11 @@ mod tests {
|
||||
payload["imageEditorAgentSidebarEnabled"],
|
||||
Value::Bool(false)
|
||||
);
|
||||
// 未登录不开放发布入口,避免灰度对游客下发可用状态。
|
||||
assert_eq!(
|
||||
payload["gameDistributionPublishEnabled"],
|
||||
Value::Bool(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1052,6 +1057,50 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn frontend_runtime_config_game_distribution_publish_is_scoped_to_authenticated_gate() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
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)];
|
||||
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));
|
||||
gate.enabled = true;
|
||||
gate.allow_user_ids.clear();
|
||||
gate.deny_user_ids.clear();
|
||||
gate.rollout_percent = 100;
|
||||
cases.push((vec![gate], true));
|
||||
|
||||
for (gates, expected) in cases {
|
||||
state.set_test_feature_gate_config(gates);
|
||||
let app = build_router(state.clone());
|
||||
for authenticated in [false, true] {
|
||||
let mut request = Request::builder().uri("/api/runtime/frontend-config");
|
||||
if authenticated {
|
||||
request = request.header("authorization", format!("Bearer {token}"));
|
||||
}
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request.body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload = read_json_response(response).await;
|
||||
assert_eq!(
|
||||
payload["gameDistributionPublishEnabled"],
|
||||
Value::Bool(authenticated && expected)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn frontend_runtime_config_returns_agent_sidebar_env_flag() {
|
||||
let config = AppConfig {
|
||||
|
||||
@@ -16,6 +16,9 @@ use crate::{
|
||||
pub struct FrontendRuntimeConfigResponse {
|
||||
pub image_editor_agent_sidebar_enabled: bool,
|
||||
pub agc_template_library_enabled: bool,
|
||||
/// 游戏发布灰度:运营未配置 `game-distribution:publish` 时对已登录作者默认开放,
|
||||
/// 显式收紧后只有白名单/灰度命中的作者拿到 `true`;匿名一律 `false`(发布必须先登录)。
|
||||
pub game_distribution_publish_enabled: bool,
|
||||
}
|
||||
|
||||
pub async fn get_frontend_runtime_config(
|
||||
@@ -57,6 +60,22 @@ pub async fn get_frontend_runtime_config(
|
||||
}))
|
||||
})?;
|
||||
|
||||
let game_distribution_publish_enabled = match user_id {
|
||||
Some(user_id) => state
|
||||
.is_game_distribution_publish_enabled_for_user(Some(user_id))
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message("读取前端运行时配置失败")
|
||||
.with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
})?,
|
||||
// 未登录时发布入口不开放:灰度只面向已登录作者,写入口也要求 Bearer 会话。
|
||||
None => false,
|
||||
};
|
||||
|
||||
Ok((
|
||||
[
|
||||
(header::CACHE_CONTROL, "no-store"),
|
||||
@@ -67,6 +86,7 @@ pub async fn get_frontend_runtime_config(
|
||||
FrontendRuntimeConfigResponse {
|
||||
image_editor_agent_sidebar_enabled,
|
||||
agc_template_library_enabled,
|
||||
game_distribution_publish_enabled,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -21,6 +21,14 @@ import {
|
||||
import { resolvePublishMetadataError } from './gamePublishMetadata';
|
||||
import { GamePublishPage } from './GamePublishPage';
|
||||
|
||||
const loadFrontendRuntimeConfigMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
// 发布灰度由后端运行时配置决定;这里默认放行,灰度用例单独覆盖。
|
||||
vi.mock('../../services/frontendRuntimeConfigService', () => ({
|
||||
loadFrontendRuntimeConfig: (...args: unknown[]) =>
|
||||
loadFrontendRuntimeConfigMock(...args),
|
||||
}));
|
||||
|
||||
// 只替换真实上传与换签,保留常量与本地校验,避免测试真的打 OSS。
|
||||
vi.mock('./gamePublishAssets', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./gamePublishAssets')>();
|
||||
@@ -82,19 +90,41 @@ async function buildZipFile() {
|
||||
});
|
||||
}
|
||||
|
||||
function renderPage(authValue: AuthValue | null = createAuthValue()) {
|
||||
function renderPublishPage(
|
||||
authValue: AuthValue | null,
|
||||
updateGameId: string | null = null,
|
||||
) {
|
||||
return render(
|
||||
<AuthUiContext.Provider value={authValue}>
|
||||
<GamePublishPage onBack={vi.fn()} onOpenMyGames={vi.fn()} />
|
||||
<GamePublishPage
|
||||
onBack={vi.fn()}
|
||||
onOpenMyGames={vi.fn()}
|
||||
updateGameId={updateGameId}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** 渲染并等待发布灰度检查结束(放行后才渲染表单)。 */
|
||||
async function renderPage(authValue: AuthValue | null = createAuthValue()) {
|
||||
const result = renderPublishPage(authValue);
|
||||
if (authValue) {
|
||||
await screen.findByLabelText('游戏名称');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildImageFile(name: string, type = 'image/png') {
|
||||
return new File(['image-bytes'], name, { type });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
loadFrontendRuntimeConfigMock.mockReset();
|
||||
loadFrontendRuntimeConfigMock.mockResolvedValue({
|
||||
imageEditorAgentSidebarEnabled: false,
|
||||
agcTemplateLibraryEnabled: false,
|
||||
gameDistributionPublishEnabled: true,
|
||||
});
|
||||
vi.mocked(uploadGamePublishImageAsset).mockReset();
|
||||
vi.mocked(resolveGamePublishImagePreview).mockReset();
|
||||
vi.mocked(resolveGamePublishImagePreview).mockResolvedValue('');
|
||||
@@ -199,15 +229,15 @@ test('元数据校验与服务端口径一致', () => {
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
test('未登录时不提交且提示登录', () => {
|
||||
renderPage(null);
|
||||
test('未登录时不提交且提示登录', async () => {
|
||||
await renderPage(null);
|
||||
expect(screen.getByText('登录后才能发布游戏。')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: /提交审核/u }));
|
||||
expect(createGame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('缺少发行包时不上传', async () => {
|
||||
renderPage();
|
||||
await renderPage();
|
||||
fireEvent.change(screen.getByLabelText('游戏名称'), {
|
||||
target: { value: '测试游戏' },
|
||||
});
|
||||
@@ -221,7 +251,7 @@ test('缺少发行包时不上传', async () => {
|
||||
});
|
||||
|
||||
test('缺少封面时本地先拦截且不创建游戏', async () => {
|
||||
renderPage();
|
||||
await renderPage();
|
||||
const file = await buildZipFile();
|
||||
fireEvent.change(screen.getByLabelText('游戏名称'), {
|
||||
target: { value: '测试游戏' },
|
||||
@@ -243,7 +273,7 @@ test('封面上传失败时给出原因且不提交', async () => {
|
||||
vi.mocked(uploadGamePublishImageAsset).mockRejectedValue(
|
||||
new Error('游戏封面过大,请压缩后再上传(当前 9.0MB,最多 6MB)。'),
|
||||
);
|
||||
renderPage();
|
||||
await renderPage();
|
||||
fireEvent.change(screen.getByLabelText(/游戏封面/u), {
|
||||
target: { files: [buildImageFile('huge.png')] },
|
||||
});
|
||||
@@ -257,7 +287,7 @@ test('封面上传失败时给出原因且不提交', async () => {
|
||||
});
|
||||
|
||||
test('截图超过 6 张时本地拦截', async () => {
|
||||
renderPage();
|
||||
await renderPage();
|
||||
const files = Array.from({ length: 7 }, (_, index) =>
|
||||
buildImageFile(`shot-${index}.png`),
|
||||
);
|
||||
@@ -289,7 +319,7 @@ test('按创建游戏、创建版本、上传、送审顺序提交并展示审
|
||||
version: { status: 'pending_review' },
|
||||
});
|
||||
|
||||
renderPage();
|
||||
await renderPage();
|
||||
const file = await buildZipFile();
|
||||
fireEvent.change(screen.getByLabelText('游戏名称'), {
|
||||
target: { value: '测试游戏' },
|
||||
@@ -332,7 +362,7 @@ test('后端失败时不伪造成功', async () => {
|
||||
vi.mocked(createGame).mockRejectedValue(
|
||||
new Error('游戏分发服务暂不可用(503)'),
|
||||
);
|
||||
renderPage();
|
||||
await renderPage();
|
||||
const file = await buildZipFile();
|
||||
fireEvent.change(screen.getByLabelText('游戏名称'), {
|
||||
target: { value: '测试游戏' },
|
||||
@@ -373,7 +403,7 @@ test('同一账号回到发布页可以沿用原版本继续送审', async () =>
|
||||
version: { status: 'pending_review' },
|
||||
});
|
||||
|
||||
renderPage();
|
||||
await renderPage();
|
||||
|
||||
expect(await screen.findByText(/上次发布未完成|发行包已上传/u)).toBeTruthy();
|
||||
fireEvent.click(await screen.findByRole('button', { name: '继续送审' }));
|
||||
@@ -397,7 +427,7 @@ test('同一账号回到发布页可以沿用原版本继续送审', async () =>
|
||||
|
||||
test('换账号不回读上一账号的发布草稿', async () => {
|
||||
writeDraft('user-1');
|
||||
renderPage(
|
||||
await renderPage(
|
||||
createAuthValue({
|
||||
user: { ...createAuthValue().user!, id: 'user-2' },
|
||||
}),
|
||||
@@ -480,19 +510,12 @@ test('更新模式在既有 gameId 下创建新版本并沿用公开修订号',
|
||||
version: { status: 'pending_review' },
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
<GamePublishPage
|
||||
onBack={vi.fn()}
|
||||
onOpenMyGames={vi.fn()}
|
||||
updateGameId="game-1"
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
renderPublishPage(createAuthValue(), 'game-1');
|
||||
|
||||
expect(
|
||||
await screen.findByText(/正在为《星轨防线》发布新版本 v3/u),
|
||||
).toBeTruthy();
|
||||
await screen.findByLabelText('游戏名称');
|
||||
expect(screen.getByLabelText('游戏名称')).toHaveProperty('value', '星轨防线');
|
||||
expect(screen.getByLabelText('分类')).toHaveProperty('value', '动作');
|
||||
|
||||
@@ -580,19 +603,12 @@ test('线上封面没有冻结素材时要求重新选择封面', async () => {
|
||||
},
|
||||
} as never);
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
<GamePublishPage
|
||||
onBack={vi.fn()}
|
||||
onOpenMyGames={vi.fn()}
|
||||
updateGameId="game-1"
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
renderPublishPage(createAuthValue(), 'game-1');
|
||||
|
||||
expect(
|
||||
await screen.findByText(/本次发布需要重新选择一次封面图片/u),
|
||||
).toBeTruthy();
|
||||
await screen.findByLabelText('游戏名称');
|
||||
const file = await buildZipFile();
|
||||
fireEvent.change(screen.getByLabelText(/发行包 ZIP/u), {
|
||||
target: { files: [file] },
|
||||
@@ -610,15 +626,7 @@ test('线上封面没有冻结素材时要求重新选择封面', async () => {
|
||||
test('更新模式读不到归属游戏时失败关闭且不提交', async () => {
|
||||
vi.mocked(listMyGames).mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
<GamePublishPage
|
||||
onBack={vi.fn()}
|
||||
onOpenMyGames={vi.fn()}
|
||||
updateGameId="game-missing"
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
renderPublishPage(createAuthValue(), 'game-missing');
|
||||
|
||||
expect(
|
||||
await screen.findByText('找不到这个游戏,或它不属于当前账号'),
|
||||
@@ -629,3 +637,34 @@ test('更新模式读不到归属游戏时失败关闭且不提交', async () =>
|
||||
);
|
||||
expect(createGameVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('灰度未命中时隐藏发布表单并给出可操作提示', async () => {
|
||||
loadFrontendRuntimeConfigMock.mockResolvedValue({
|
||||
imageEditorAgentSidebarEnabled: false,
|
||||
agcTemplateLibraryEnabled: false,
|
||||
gameDistributionPublishEnabled: false,
|
||||
});
|
||||
renderPublishPage(createAuthValue());
|
||||
|
||||
expect(await screen.findByText('发布功能正在灰度中')).toBeTruthy();
|
||||
expect(screen.getByText(/当前账号还没有发布入口/u)).not.toBeNull();
|
||||
expect(screen.queryByLabelText('游戏名称')).toBeNull();
|
||||
expect(createGame).not.toHaveBeenCalled();
|
||||
|
||||
// 灰度开放后「重新检查」应放行并渲染表单。
|
||||
loadFrontendRuntimeConfigMock.mockResolvedValue({
|
||||
imageEditorAgentSidebarEnabled: false,
|
||||
agcTemplateLibraryEnabled: false,
|
||||
gameDistributionPublishEnabled: true,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新检查' }));
|
||||
expect(await screen.findByLabelText('游戏名称')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('灰度配置读取失败时不拦前端,由后端写入口把关', async () => {
|
||||
loadFrontendRuntimeConfigMock.mockRejectedValue(new Error('network down'));
|
||||
await renderPage();
|
||||
|
||||
expect(screen.getByLabelText('游戏名称')).not.toBeNull();
|
||||
expect(screen.queryByText('发布功能正在灰度中')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
GameDistributionOrientation,
|
||||
GameDistributionVersionDetail,
|
||||
} from '../../../packages/shared/src/contracts/gameDistribution';
|
||||
import { loadFrontendRuntimeConfig } from '../../services/frontendRuntimeConfigService';
|
||||
import {
|
||||
createGame,
|
||||
createGameVersion,
|
||||
@@ -187,6 +188,11 @@ export function GamePublishPage({
|
||||
GamePublishImageAsset[]
|
||||
>([]);
|
||||
const [isUploadingAsset, setIsUploadingAsset] = useState(false);
|
||||
// 发布灰度:`checking` 期间不渲染表单,避免白名单外的作者先上传再被后端拒绝。
|
||||
const [publishGate, setPublishGate] = useState<
|
||||
'checking' | 'allowed' | 'blocked'
|
||||
>('checking');
|
||||
const [publishGateReloadSeed, setPublishGateReloadSeed] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [publishedGameId, setPublishedGameId] = useState('');
|
||||
@@ -251,6 +257,34 @@ export function GamePublishPage({
|
||||
};
|
||||
}, [canPublish, updateGameId]);
|
||||
|
||||
// 发布灰度:读后端运行时配置判定当前账号是否在灰度内;未登录时交给登录提示处理。
|
||||
useEffect(() => {
|
||||
if (!canPublish) {
|
||||
setPublishGate('allowed');
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setPublishGate('checking');
|
||||
loadFrontendRuntimeConfig()
|
||||
.then((config) => {
|
||||
if (cancelled) return;
|
||||
setPublishGate(
|
||||
config.gameDistributionPublishEnabled === false
|
||||
? 'blocked'
|
||||
: 'allowed',
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
// 读不到灰度配置时按“不拦前端、由后端写入口把关”处理:后端仍是唯一事实源,
|
||||
// 收紧期间会在提交时返回 503 与可读文案,不会静默写入。
|
||||
setPublishGate('allowed');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canPublish, currentUserId, publishGateReloadSeed]);
|
||||
|
||||
// 只在当前账号与草稿 owner 一致时回读版本;换账号时忽略草稿,绝不展示上一账号状态。
|
||||
useEffect(() => {
|
||||
if (updateGameId) return undefined;
|
||||
@@ -606,6 +640,47 @@ export function GamePublishPage({
|
||||
}
|
||||
}
|
||||
|
||||
// 灰度检查期间与未命中时都不渲染表单:避免白名单外的作者先上传素材再被写入口拒绝。
|
||||
if (canPublish && publishGate === 'checking') {
|
||||
return (
|
||||
<div className="game-page game-publish-page">
|
||||
<button type="button" className="game-back-button" onClick={onBack}>
|
||||
<ArrowLeft /> 返回游戏广场
|
||||
</button>
|
||||
<PlatformStatusMessage tone="info" surface="platform">
|
||||
<span>正在检查发布灰度…</span>
|
||||
</PlatformStatusMessage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (canPublish && publishGate === 'blocked') {
|
||||
return (
|
||||
<div className="game-page game-publish-page">
|
||||
<button type="button" className="game-back-button" onClick={onBack}>
|
||||
<ArrowLeft /> 返回游戏广场
|
||||
</button>
|
||||
<div className="game-toolbar">
|
||||
<div className="game-toolbar__title">
|
||||
<span className="game-eyebrow">发布游戏</span>
|
||||
<h2>发布功能正在灰度中</h2>
|
||||
</div>
|
||||
</div>
|
||||
<PlatformStatusMessage tone="info" surface="platform">
|
||||
<span>
|
||||
当前账号还没有发布入口,开放后可以直接在这里上传游戏;已公开的游戏不受影响,仍可正常游玩。
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPublishGateReloadSeed((current) => current + 1)}
|
||||
>
|
||||
重新检查
|
||||
</button>
|
||||
</PlatformStatusMessage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="game-page game-publish-page">
|
||||
<button type="button" className="game-back-button" onClick={onBack}>
|
||||
|
||||
@@ -58,6 +58,15 @@ const gameDistributionMock = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('../../services/gameDistributionClient', () => gameDistributionMock);
|
||||
|
||||
const frontendRuntimeConfigMock = vi.hoisted(() => ({
|
||||
loadFrontendRuntimeConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/frontendRuntimeConfigService', () => ({
|
||||
loadFrontendRuntimeConfig: (...args: unknown[]) =>
|
||||
frontendRuntimeConfigMock.loadFrontendRuntimeConfig(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../auth/AuthUiContext', () => ({
|
||||
useAuthUi: () => authUiMock.value,
|
||||
}));
|
||||
@@ -206,6 +215,12 @@ describe('PlatformEntryActiveFlowShell', () => {
|
||||
for (const mock of Object.values(gameDistributionMock)) {
|
||||
mock.mockReset();
|
||||
}
|
||||
frontendRuntimeConfigMock.loadFrontendRuntimeConfig.mockReset();
|
||||
frontendRuntimeConfigMock.loadFrontendRuntimeConfig.mockResolvedValue({
|
||||
imageEditorAgentSidebarEnabled: false,
|
||||
agcTemplateLibraryEnabled: false,
|
||||
gameDistributionPublishEnabled: true,
|
||||
});
|
||||
authUiMock.value.openLoginModal.mockReset();
|
||||
responsiveMock.isDesktopLayout = true;
|
||||
});
|
||||
@@ -850,3 +865,47 @@ describe('PlatformEntryActiveFlowShell', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('游戏发布灰度入口', () => {
|
||||
function loginAsAuthor() {
|
||||
authUiMock.value.user = {
|
||||
id: 'user-1',
|
||||
publicUserCode: '100001',
|
||||
displayName: '测试作者',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
};
|
||||
authUiMock.value.canAccessProtectedData = true;
|
||||
}
|
||||
|
||||
it('灰度未命中时广场不展示发布入口', async () => {
|
||||
loginAsAuthor();
|
||||
frontendRuntimeConfigMock.loadFrontendRuntimeConfig.mockResolvedValue({
|
||||
imageEditorAgentSidebarEnabled: false,
|
||||
agcTemplateLibraryEnabled: false,
|
||||
gameDistributionPublishEnabled: false,
|
||||
});
|
||||
gameDistributionMock.listGames.mockResolvedValue([]);
|
||||
|
||||
render(<StatefulPlatformEntryFlowShell initialStage="games" />);
|
||||
|
||||
expect(await screen.findByText('游戏广场')).toBeTruthy();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('button', { name: '发布游戏' })).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it('灰度命中时广场展示发布入口', async () => {
|
||||
loginAsAuthor();
|
||||
gameDistributionMock.listGames.mockResolvedValue([]);
|
||||
|
||||
render(<StatefulPlatformEntryFlowShell initialStage="games" />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '发布游戏' }),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
pushAppHistoryPath,
|
||||
replaceAppHistoryPath,
|
||||
} from '../../routing/activeAppPageRoutes';
|
||||
import { loadFrontendRuntimeConfig } from '../../services/frontendRuntimeConfigService';
|
||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
@@ -280,6 +281,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSelectionStage,
|
||||
}: PlatformEntryFlowShellProps) {
|
||||
const authUi = useAuthUi();
|
||||
// 发布灰度:命中才暴露「发布游戏 / 发布新版本」入口;读取失败按放行处理,由后端写入口把关。
|
||||
const [gamePublishGateAllowed, setGamePublishGateAllowed] = useState(true);
|
||||
const gamePublishGateUserId = authUi?.user?.id ?? '';
|
||||
const [dashboard, setDashboard] = useState<ProfileDashboardSummary | null>(
|
||||
null,
|
||||
);
|
||||
@@ -398,6 +402,29 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSelectionStage('games', { path: '/games' });
|
||||
}, [setSelectionStage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gamePublishGateUserId) {
|
||||
// 未登录时不展示发布入口,登录后由 authUi 变化触发重新读取。
|
||||
setGamePublishGateAllowed(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
loadFrontendRuntimeConfig()
|
||||
.then((config) => {
|
||||
if (!cancelled) {
|
||||
setGamePublishGateAllowed(
|
||||
config.gameDistributionPublishEnabled !== false,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setGamePublishGateAllowed(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [gamePublishGateUserId]);
|
||||
|
||||
const openGamePublish = useCallback(() => {
|
||||
setSelectionStage('game-publish', { path: '/games/publish' });
|
||||
}, [setSelectionStage]);
|
||||
@@ -731,7 +758,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
searchKeyword={activeSearchKeyword}
|
||||
onOpenDetail={openGameDetail}
|
||||
onOpenMyGames={openMyGames}
|
||||
onOpenPublish={openGamePublish}
|
||||
onOpenPublish={
|
||||
gamePublishGateAllowed ? openGamePublish : undefined
|
||||
}
|
||||
/>
|
||||
) : selectionStage === 'game-publish' ? (
|
||||
<GamePublishPage
|
||||
@@ -743,7 +772,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
<MyGamesPage
|
||||
onBack={openGames}
|
||||
onOpenDetail={openGameDetail}
|
||||
onPublishVersion={openGamePublishVersion}
|
||||
onPublishVersion={
|
||||
gamePublishGateAllowed
|
||||
? openGamePublishVersion
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : selectionStage === 'game-detail' ? (
|
||||
<GameDetailPage
|
||||
|
||||
@@ -5,6 +5,11 @@ const FRONTEND_RUNTIME_CONFIG_API = '/api/runtime/frontend-config';
|
||||
export type FrontendRuntimeConfig = {
|
||||
imageEditorAgentSidebarEnabled: boolean;
|
||||
agcTemplateLibraryEnabled: boolean;
|
||||
/**
|
||||
* 游戏发布灰度开关:后端未配置 `game-distribution:publish` 时对已登录作者默认开放,
|
||||
* 显式收紧后只有白名单/灰度命中的作者为 `true`,匿名恒为 `false`。
|
||||
*/
|
||||
gameDistributionPublishEnabled: boolean;
|
||||
};
|
||||
|
||||
export async function loadFrontendRuntimeConfig() {
|
||||
|
||||
Reference in New Issue
Block a user