发布灰度默认关闭:修掉客户端“看得到点不动”与后台看不到 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:
@@ -175,6 +175,107 @@ test('灰度发布页可选择模板库并默认启用零比例灰度', async ()
|
||||
);
|
||||
});
|
||||
|
||||
test('灰度发布页可选择游戏发布开关,默认保持「未开启即开放」语义', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
||||
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
|
||||
'game-distribution',
|
||||
]);
|
||||
|
||||
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
||||
'game-distribution:publish',
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value,
|
||||
).toBe('publish');
|
||||
// 该开关的语义是「未配置/关闭 = 默认开放」,所以选中后不能默认打开收紧。
|
||||
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
|
||||
false,
|
||||
);
|
||||
expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
|
||||
'0',
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText('描述') as HTMLTextAreaElement).value,
|
||||
).toContain('游戏发布入口灰度');
|
||||
});
|
||||
|
||||
test('灰度发布页保存游戏发布开关时写入白名单与比例', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
|
||||
gates: [
|
||||
...configResponse.gates,
|
||||
{
|
||||
gateKey: 'game-distribution:publish',
|
||||
enabled: true,
|
||||
rolloutPercent: 20,
|
||||
allowUserIds: ['user-internal'],
|
||||
allowUserTags: [],
|
||||
denyUserIds: [],
|
||||
description: '游戏发布入口灰度',
|
||||
updatedAt: '2026-09-22T10:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
||||
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
|
||||
'game-distribution',
|
||||
]);
|
||||
fireEvent.click(screen.getByLabelText('启用'));
|
||||
fireEvent.change(screen.getByLabelText('灰度比例'), {
|
||||
target: { value: '20' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('允许用户 ID'), {
|
||||
target: { value: 'user-internal' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('描述'), {
|
||||
target: { value: '游戏发布入口灰度' },
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '保存配置' }));
|
||||
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(upsertAdminFeatureGateConfig).toHaveBeenCalledWith('admin-token', {
|
||||
gateKey: 'game-distribution:publish',
|
||||
enabled: true,
|
||||
rolloutPercent: 20,
|
||||
allowUserIds: ['user-internal'],
|
||||
allowUserTags: [],
|
||||
denyUserIds: [],
|
||||
description: '游戏发布入口灰度',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('未创建的预设开关在后台可见并可一键配置', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
const row = await screen.findByText('game-distribution:publish');
|
||||
expect(row).not.toBeNull();
|
||||
// 该开关默认未创建:列表里给出「配置」入口,点击后按默认关闭填充表单。
|
||||
const configureButton = row.closest('tr')?.querySelector('button');
|
||||
expect(configureButton).not.toBeNull();
|
||||
await user.click(configureButton!);
|
||||
|
||||
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
||||
'game-distribution:publish',
|
||||
);
|
||||
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('灰度发布页保存时转换数组和百分比', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
|
||||
|
||||
@@ -28,6 +28,7 @@ interface GateTargetOption {
|
||||
const GATE_PREFIX_LABELS: Record<string, string> = {
|
||||
'image-editor': '画布',
|
||||
agc: '客户端',
|
||||
'game-distribution': '游戏分发',
|
||||
};
|
||||
|
||||
const FIXED_GATE_TARGETS: GateTargetOption[] = [
|
||||
@@ -45,6 +46,14 @@ const FIXED_GATE_TARGETS: GateTargetOption[] = [
|
||||
label: 'Agent 侧边栏',
|
||||
description: '画布 Agent 入口灰度',
|
||||
},
|
||||
{
|
||||
prefix: 'game-distribution',
|
||||
suffix: 'publish',
|
||||
key: 'game-distribution:publish',
|
||||
label: '游戏发布',
|
||||
description:
|
||||
'游戏发布入口灰度:未配置或关闭时对已登录作者默认开放,开启后只放行白名单 / 灰度命中',
|
||||
},
|
||||
];
|
||||
|
||||
export function AdminGrayReleaseConfigPage({
|
||||
@@ -197,6 +206,11 @@ export function AdminGrayReleaseConfigPage({
|
||||
setErrorMessage('');
|
||||
}
|
||||
|
||||
// 预设里尚未创建行的开关也要可见:运营需要先看到 key 才能配置灰度。
|
||||
const unconfiguredGateTargets = FIXED_GATE_TARGETS.filter(
|
||||
(option) => !gates.some((gate) => gate.gateKey === option.key),
|
||||
);
|
||||
|
||||
function buildPayload(): AdminUpsertFeatureGateConfigRequest {
|
||||
return {
|
||||
gateKey: gateKey.trim(),
|
||||
@@ -443,6 +457,53 @@ export function AdminGrayReleaseConfigPage({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>可配置开关</h3>
|
||||
<span>{unconfiguredGateTargets.length}</span>
|
||||
</div>
|
||||
{unconfiguredGateTargets.length ? (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Gate</th>
|
||||
<th>说明</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{unconfiguredGateTargets.map((option) => (
|
||||
<tr key={option.key}>
|
||||
<td>
|
||||
{option.key}
|
||||
<small>
|
||||
{GATE_PREFIX_LABELS[option.prefix] ?? option.prefix} ·{' '}
|
||||
{option.label}
|
||||
</small>
|
||||
</td>
|
||||
<td>{option.description}</td>
|
||||
<td>
|
||||
<button
|
||||
className="admin-text-button"
|
||||
type="button"
|
||||
onClick={() => applyGateTarget(option)}
|
||||
>
|
||||
配置
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-empty-state">
|
||||
{isLoading ? '加载中' : '预设开关都已创建'}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{confirmDialog}
|
||||
|
||||
@@ -1206,16 +1206,27 @@ export function App({
|
||||
* 权限口径沿用本地命令:`project.export_package` 需要确认时先入队,确认后再导出;
|
||||
* 导出结果只留在壳里,发布面板关闭即丢弃,不写入项目。
|
||||
*/
|
||||
/**
|
||||
* 发布相关提示同时写工作台状态与 DirectProject 对话。
|
||||
*
|
||||
* 普通项目走 `DirectProjectChatView` 时并不渲染工作台状态行,只写 workspaceStatus
|
||||
* 会让「点了发布没反应」;这里统一通过聊天容器的 announce 出口回话。
|
||||
*/
|
||||
function announcePublishMessage(message: string) {
|
||||
setWorkspaceStatus(message);
|
||||
directProjectChatRef.current?.announce(message);
|
||||
}
|
||||
|
||||
async function requestGamePublish() {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
setWorkspaceStatus('需要在 Tauri App 内发布');
|
||||
announcePublishMessage('需要在 Tauri App 内发布');
|
||||
return;
|
||||
}
|
||||
const nextProjectPath =
|
||||
resolveChatProjectPath(localProject) ?? projectPath.trim();
|
||||
if (!nextProjectPath) {
|
||||
setWorkspaceStatus('先打开一个项目再发布');
|
||||
announcePublishMessage('先打开一个项目再发布');
|
||||
return;
|
||||
}
|
||||
const runExport = async () => {
|
||||
@@ -1224,7 +1235,9 @@ export function App({
|
||||
'export_local_project_package',
|
||||
{ projectPath: nextProjectPath },
|
||||
);
|
||||
setWorkspaceStatus(`已导出本地试玩包:${result.packageRelativePath}`);
|
||||
announcePublishMessage(
|
||||
`已构建并打包试玩包:${result.packageRelativePath}`,
|
||||
);
|
||||
setPublishPackageResult(result);
|
||||
setPublishPanelOpen(true);
|
||||
appendLocalPermissionLog(
|
||||
@@ -1233,7 +1246,7 @@ export function App({
|
||||
'project.export_package',
|
||||
);
|
||||
} catch (error) {
|
||||
setWorkspaceStatus(
|
||||
announcePublishMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user