发布入口接入灰度开关
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:
2026-09-22 17:12:04 +08:00
parent 1bd5fe9ff0
commit e5460280ce
13 changed files with 396 additions and 45 deletions
@@ -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() {