合并主线更新并保留双方排障记录
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m24s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m1s
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 / Native shell tests (pull_request) Has been cancelled
Project CI / Backend tests (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 lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m24s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m1s
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 / Native shell tests (pull_request) Has been cancelled
Project CI / Backend tests (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 lane 1/2 (pull_request) Has been cancelled
合并 origin/master 的上传续传、标题栏弹窗及测试更新。 解决排障文档冲突,保留策划回复与标题栏问题记录。
This commit is contained in:
@@ -5935,14 +5935,81 @@ pub(crate) async fn export_local_project_package(
|
||||
export_local_project_package_for_publish_at(root).await
|
||||
}
|
||||
|
||||
/// 把归一化后的发行包落到内容寻址的暂存文件,返回分片续传所需的元数据。
|
||||
///
|
||||
/// 发布链路从此只把「暂存路径 + 摘要 + 体积」交给渲染进程:整包字节不再经过
|
||||
/// WebView IPC,续传时也复用同一个暂存文件(同名同内容)。
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_project_export_package(
|
||||
pub(crate) fn prepare_local_project_game_package(
|
||||
app: tauri::AppHandle,
|
||||
project_path: String,
|
||||
package_relative_path: String,
|
||||
) -> Result<LocalProjectExportPackagePayload, String> {
|
||||
) -> Result<crate::game_package_upload::StagedGamePackage, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "project.export_package")?;
|
||||
read_local_project_export_package_at(root, package_relative_path.trim())
|
||||
let payload = read_local_project_export_package_at(root, package_relative_path.trim())?;
|
||||
let staging_dir = game_package_upload_staging_dir(&app)?;
|
||||
let mut staged = crate::game_package_upload::stage_game_package_bytes(
|
||||
&staging_dir,
|
||||
&payload.package_sha256,
|
||||
&payload.package_bytes,
|
||||
)?;
|
||||
staged.package_file_count = u32::try_from(payload.files.len()).unwrap_or(u32::MAX);
|
||||
Ok(staged)
|
||||
}
|
||||
|
||||
/// 分片续传上传暂存的发行包;进度通过 `game-package-upload-progress` 事件回传。
|
||||
#[tauri::command]
|
||||
pub(crate) async fn upload_local_project_game_package(
|
||||
app: tauri::AppHandle,
|
||||
staging_path: String,
|
||||
version_id: String,
|
||||
api_base_url: String,
|
||||
access_token: String,
|
||||
idempotency_key: String,
|
||||
) -> Result<crate::game_package_upload::GamePackageUploadOutcome, String> {
|
||||
let staging_dir = game_package_upload_staging_dir(&app)?;
|
||||
let resolved_path =
|
||||
crate::game_package_upload::ensure_staging_path_in_dir(&staging_dir, &staging_path)?;
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(|error| format!("创建上传客户端失败:{error}"))?;
|
||||
let version_id = version_id.trim().to_string();
|
||||
if version_id.is_empty() {
|
||||
return Err("缺少发行版本标识".to_string());
|
||||
}
|
||||
let emit_handle = app.clone();
|
||||
let progress_version_id = version_id.clone();
|
||||
crate::game_package_upload::upload_staged_game_package(
|
||||
&client,
|
||||
crate::game_package_upload::GamePackageUploadRequest {
|
||||
staging_path: &resolved_path,
|
||||
version_id: &version_id,
|
||||
api_base_url: api_base_url.trim(),
|
||||
access_token: access_token.trim(),
|
||||
idempotency_key: idempotency_key.trim(),
|
||||
},
|
||||
move |received_bytes, total_bytes| {
|
||||
let _ = emit_handle.emit(
|
||||
crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT,
|
||||
crate::game_package_upload::progress_event_payload(
|
||||
&progress_version_id,
|
||||
received_bytes,
|
||||
total_bytes,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn game_package_upload_staging_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
app.path()
|
||||
.app_data_dir()
|
||||
.map(|app_data_root| {
|
||||
crate::game_package_upload::game_package_upload_staging_dir(&app_data_root)
|
||||
})
|
||||
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -132,6 +132,7 @@ mod editor_adapter;
|
||||
mod editor_adapters;
|
||||
mod environment_check;
|
||||
pub mod error_report;
|
||||
mod game_package_upload;
|
||||
mod git_inspect;
|
||||
mod goal;
|
||||
mod http_client;
|
||||
@@ -2757,7 +2758,8 @@ fn main() {
|
||||
build_local_project_index,
|
||||
create_local_project_checkpoint,
|
||||
export_local_project_package,
|
||||
read_local_project_export_package,
|
||||
prepare_local_project_game_package,
|
||||
upload_local_project_game_package,
|
||||
list_local_project_export_packages,
|
||||
diff_local_project_checkpoint,
|
||||
restore_local_project_checkpoint,
|
||||
|
||||
@@ -459,11 +459,7 @@ export interface AgentRuntimeResult {
|
||||
}
|
||||
|
||||
export type AgentRuntimeResponseStreamStatus =
|
||||
| 'streaming'
|
||||
| 'ready'
|
||||
| 'committed'
|
||||
| 'discarded'
|
||||
| 'failed';
|
||||
'streaming' | 'ready' | 'committed' | 'discarded' | 'failed';
|
||||
|
||||
export interface AgentRuntimeResponseStream {
|
||||
schemaVersion: string;
|
||||
@@ -569,22 +565,13 @@ export interface GameCreatorAgentLlmConfigStatus {
|
||||
}
|
||||
|
||||
export type GameCreatorLlmApiKind =
|
||||
| 'openai_responses'
|
||||
| 'openai_chat'
|
||||
| 'anthropic';
|
||||
'openai_responses' | 'openai_chat' | 'anthropic';
|
||||
export type GameCreatorAgentMode =
|
||||
| 'codex_app_server'
|
||||
| 'codex_cli'
|
||||
| 'provider';
|
||||
'codex_app_server' | 'codex_cli' | 'provider';
|
||||
export type RuntimeLlmProviderPresetId =
|
||||
| 'custom'
|
||||
| 'openai'
|
||||
| 'deepseek'
|
||||
| 'anthropic'
|
||||
| 'ark';
|
||||
'custom' | 'openai' | 'deepseek' | 'anthropic' | 'ark';
|
||||
export type RuntimeAgentLlmProviderPresetId =
|
||||
| 'inherit'
|
||||
| RuntimeLlmProviderPresetId;
|
||||
'inherit' | RuntimeLlmProviderPresetId;
|
||||
|
||||
export interface GameCreatorLlmConfig {
|
||||
customEnabled?: boolean;
|
||||
@@ -796,12 +783,21 @@ export interface LocalProjectExportPackageFileDigest {
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface LocalProjectExportPackagePayload {
|
||||
packageRelativePath: string;
|
||||
packageBytes: number[];
|
||||
/**
|
||||
* 已暂存的归一化发行包:发布链路只传递这个摘要与路径,整包字节留在原生进程里,
|
||||
* 不再经过 WebView IPC。
|
||||
*/
|
||||
export interface StagedGamePackage {
|
||||
stagingPath: string;
|
||||
packageSha256: string;
|
||||
packageSizeBytes: number;
|
||||
files: LocalProjectExportPackageFileDigest[];
|
||||
packageFileCount: number;
|
||||
}
|
||||
|
||||
export interface GamePackageUploadOutcome {
|
||||
versionId: string;
|
||||
status: string;
|
||||
uploadedBytes: number;
|
||||
}
|
||||
|
||||
export interface LocalProjectExportPackageSummary {
|
||||
@@ -935,9 +931,7 @@ export type GameCreatorDirectToolCallKind =
|
||||
| 'other';
|
||||
|
||||
export type GameCreatorDirectToolCallStatus =
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
'running' | 'completed' | 'failed';
|
||||
|
||||
export interface GameCreatorDirectToolCallChange {
|
||||
path: string;
|
||||
|
||||
@@ -149,7 +149,11 @@ export function WindowChrome({ children }: WindowChromeProps) {
|
||||
<WindowChromeContext.Provider value={contextValue}>
|
||||
<div className="window-chrome">
|
||||
{appUpdateCheckEnabled ? <AppUpdateNotice /> : null}
|
||||
<header className="window-chrome__bar" aria-label="窗口标题栏">
|
||||
<header
|
||||
className="window-chrome__bar"
|
||||
data-window-chrome-bar
|
||||
aria-label="窗口标题栏"
|
||||
>
|
||||
<div className="window-chrome__leading">
|
||||
<div
|
||||
className="window-chrome__brand"
|
||||
|
||||
@@ -4,6 +4,23 @@ import { createPortal } from 'react-dom';
|
||||
|
||||
type ThemedModalTheme = 'light' | 'dark';
|
||||
|
||||
/**
|
||||
* 自绘标题栏的标记:它是窗口边框,不属于模态内容。
|
||||
*
|
||||
* focus-trap 默认会拦下模态之外的所有点击(`click` 事件在 document 捕获阶段直接
|
||||
* `stopImmediatePropagation`),所以任何弹窗打开时「最小化 / 最大化 / 关闭」和标题栏
|
||||
* 拖拽都会静默失效。这里只对落在标题栏内的目标放行;页面内容仍然由遮罩和焦点陷阱
|
||||
* 挡在模态之外,点空白处不会误触底层界面。
|
||||
*/
|
||||
const WINDOW_CHROME_BAR_SELECTOR = '[data-window-chrome-bar]';
|
||||
|
||||
function isWindowChromeBarTarget(target: EventTarget | null) {
|
||||
return (
|
||||
target instanceof Element &&
|
||||
target.closest(WINDOW_CHROME_BAR_SELECTOR) !== null
|
||||
);
|
||||
}
|
||||
|
||||
export type ThemedModalProps = {
|
||||
open: boolean;
|
||||
ariaLabel: string;
|
||||
@@ -55,6 +72,7 @@ export function ThemedModal({
|
||||
escapeDeactivates: false,
|
||||
fallbackFocus: () => panelRef.current!,
|
||||
returnFocusOnDeactivate: true,
|
||||
allowOutsideClick: (event) => isWindowChromeBarTarget(event.target),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -7,10 +7,12 @@ import type {
|
||||
GameDistributionOrientation,
|
||||
} from '../../../../packages/shared/src/contracts/gameDistribution';
|
||||
import type {
|
||||
LocalProjectExportPackagePayload,
|
||||
GamePackageUploadOutcome,
|
||||
StagedGamePackage,
|
||||
TauriInvoke,
|
||||
} from '../app/types';
|
||||
import { requestClientApi } from './clientApi';
|
||||
import { getStoredAuthAccessToken, requestClientApi } from './clientApi';
|
||||
import { getClientServerBaseUrl } from './clientHttp';
|
||||
|
||||
export type GameDistributionPublishMetadata = {
|
||||
title: string;
|
||||
@@ -328,20 +330,20 @@ export async function publishLocalProjectGame(args: {
|
||||
if (!projectPath || !packageRelativePath) {
|
||||
throw new Error('发布需要绑定本地项目和试玩包');
|
||||
}
|
||||
const payload = await args.invoke<LocalProjectExportPackagePayload>(
|
||||
'read_local_project_export_package',
|
||||
// 整包字节只留在原生进程:这里拿到的是归一化后的摘要与内容寻址暂存路径,
|
||||
// 上传由原生侧按服务端分片大小完成,中断后同一暂存文件可直接续传。
|
||||
const staged = await args.invoke<StagedGamePackage>(
|
||||
'prepare_local_project_game_package',
|
||||
{ projectPath, packageRelativePath },
|
||||
);
|
||||
if (
|
||||
!payload.packageBytes.length ||
|
||||
payload.packageSizeBytes !== payload.packageBytes.length ||
|
||||
payload.files.length === 0
|
||||
!staged.stagingPath.trim() ||
|
||||
staged.packageSha256.length !== 64 ||
|
||||
staged.packageSizeBytes <= 0 ||
|
||||
staged.packageFileCount <= 0
|
||||
) {
|
||||
throw new Error('本地发行包摘要无效,请重新导出试玩包');
|
||||
}
|
||||
if (payload.packageRelativePath !== packageRelativePath) {
|
||||
throw new Error('本地发行包路径已变化,请重新导出试玩包');
|
||||
}
|
||||
|
||||
const metadata = normalizeMetadata(args.manifest, args.metadata);
|
||||
const localProjectId = args.manifest.projectId.trim();
|
||||
@@ -369,9 +371,9 @@ export async function publishLocalProjectGame(args: {
|
||||
|
||||
const versionRequest: GameDistributionCreateVersionRequest = {
|
||||
localProjectId,
|
||||
packageSha256: payload.packageSha256,
|
||||
packageBytes: payload.packageSizeBytes,
|
||||
packageFileCount: payload.files.length,
|
||||
packageSha256: staged.packageSha256,
|
||||
packageBytes: staged.packageSizeBytes,
|
||||
packageFileCount: staged.packageFileCount,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata,
|
||||
};
|
||||
@@ -391,23 +393,19 @@ export async function publishLocalProjectGame(args: {
|
||||
throw new Error('创建发行版本未返回版本 ID');
|
||||
}
|
||||
|
||||
const packageBody = new Blob([new Uint8Array(payload.packageBytes)], {
|
||||
type: 'application/zip',
|
||||
});
|
||||
const uploaded = await requestClientApi<{
|
||||
versionId: string;
|
||||
status: string;
|
||||
}>(
|
||||
`/api/game-distribution/versions/${encodeURIComponent(version.versionId)}/package`,
|
||||
const accessToken = getStoredAuthAccessToken();
|
||||
if (!accessToken) {
|
||||
throw new Error('陶泥儿登录凭据缺失,请重新登录');
|
||||
}
|
||||
const uploaded = await args.invoke<GamePackageUploadOutcome>(
|
||||
'upload_local_project_game_package',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Idempotency-Key': `${rootKey}:upload`,
|
||||
},
|
||||
body: packageBody,
|
||||
stagingPath: staged.stagingPath,
|
||||
versionId: version.versionId,
|
||||
apiBaseUrl: getClientServerBaseUrl(),
|
||||
accessToken,
|
||||
idempotencyKey: `${rootKey}:upload`,
|
||||
},
|
||||
'上传游戏发行包失败',
|
||||
);
|
||||
const submitted = await requestClientApi<{
|
||||
game?: { publicationRevision?: number };
|
||||
@@ -431,8 +429,8 @@ export async function publishLocalProjectGame(args: {
|
||||
versionId: version.versionId,
|
||||
versionNumber: version.versionNumber,
|
||||
status: submitted?.version?.status ?? uploaded?.status ?? 'pending_review',
|
||||
packageSha256: payload.packageSha256,
|
||||
packageSizeBytes: payload.packageSizeBytes,
|
||||
fileCount: payload.files.length,
|
||||
packageSha256: staged.packageSha256,
|
||||
packageSizeBytes: staged.packageSizeBytes,
|
||||
fileCount: staged.packageFileCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,7 +117,11 @@ body {
|
||||
.app-update-overlay {
|
||||
position: fixed;
|
||||
z-index: 260;
|
||||
inset: 0;
|
||||
/* 中文注释:全屏弹层一律从自绘标题栏下方开始,标题栏的最小化 / 最大化 / 关闭必须始终可用。 */
|
||||
top: var(--window-chrome-height);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
padding: 24px;
|
||||
background: rgb(35 20 12 / 48%);
|
||||
@@ -209,7 +213,13 @@ body {
|
||||
}
|
||||
|
||||
:root {
|
||||
/* 网页内自绘标题栏占用的顶部高度;portal 到 body 的固定弹层也要从它下方开始。 */
|
||||
/*
|
||||
* 网页内自绘标题栏占用的顶部高度。
|
||||
*
|
||||
* 约定:portal 到 body 的全屏固定弹层一律从标题栏下方开始(`top: var(--window-chrome-height)`)。
|
||||
* 标题栏是窗口边框,不是弹层内容 —— 弹出任何面板时「最小化 / 最大化 / 关闭」和拖拽都必须
|
||||
* 保持可用;模态内部的焦点陷阱也必须放行落在标题栏上的点击(见 `ThemedModal`)。
|
||||
*/
|
||||
--window-chrome-height: 50px;
|
||||
}
|
||||
|
||||
@@ -9744,7 +9754,11 @@ iframe.preview-frame {
|
||||
.game-publish-progress-overlay {
|
||||
position: fixed;
|
||||
z-index: 500;
|
||||
inset: 0;
|
||||
/* 中文注释:发布进行中仍然要能最小化 / 关闭窗口,遮罩只压住工作区。 */
|
||||
top: var(--window-chrome-height);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: rgb(35 24 19 / 62%);
|
||||
backdrop-filter: blur(3px);
|
||||
pointer-events: auto;
|
||||
|
||||
@@ -2,12 +2,26 @@
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { APP_NAME } from '../src/app/appMetadata';
|
||||
import type { GameCreatorDirectActiveTurn } from '../src/app/types';
|
||||
import { ThemedModal } from '../src/components/modal/ThemedModal';
|
||||
import { WindowChrome } from '../src/components/WindowChrome';
|
||||
import { useWindowChrome } from '../src/components/windowChromeContext';
|
||||
|
||||
const nativeWindow = vi.hoisted(() => ({
|
||||
minimize: vi.fn(),
|
||||
toggleMaximize: vi.fn(),
|
||||
isMaximized: vi.fn(),
|
||||
close: vi.fn(),
|
||||
label: 'client',
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/window', () => ({
|
||||
getCurrentWindow: () => nativeWindow,
|
||||
}));
|
||||
|
||||
function TitleSetter({ value }: { value: string }) {
|
||||
const { setTitle } = useWindowChrome();
|
||||
return (
|
||||
@@ -36,6 +50,14 @@ function ActiveRunsSetter({
|
||||
}
|
||||
|
||||
describe('WindowChrome', () => {
|
||||
beforeEach(() => {
|
||||
nativeWindow.minimize.mockReset();
|
||||
nativeWindow.toggleMaximize.mockReset();
|
||||
nativeWindow.isMaximized.mockReset();
|
||||
nativeWindow.close.mockReset();
|
||||
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
|
||||
});
|
||||
|
||||
it('renders the陶泥儿 brand, default title, and controls', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
@@ -45,7 +67,7 @@ describe('WindowChrome', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByRole('banner', { name: '窗口标题栏' })).toBeTruthy();
|
||||
expect(screen.getByLabelText('陶泥儿 GameAgent')).toBeTruthy();
|
||||
expect(screen.getByLabelText(`${APP_NAME} GameAgent`)).toBeTruthy();
|
||||
expect(screen.queryByLabelText('本地工作区')).toBeNull();
|
||||
expect(screen.getByText('创作工作台')).toBeTruthy();
|
||||
expect(screen.getByText('工作区内容')).toBeTruthy();
|
||||
@@ -141,4 +163,35 @@ describe('WindowChrome', () => {
|
||||
);
|
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(2);
|
||||
});
|
||||
|
||||
/**
|
||||
* 回归:发布面板等 ThemedModal 弹窗打开时,标题栏在模态之外,焦点陷阱曾把
|
||||
* 标题栏上的点击一起拦下 —— 三个窗口按钮看着正常但点不动。
|
||||
*/
|
||||
it('keeps the window controls working while a modal covers the workspace', async () => {
|
||||
const user = userEvent.setup();
|
||||
nativeWindow.minimize.mockResolvedValue(undefined);
|
||||
nativeWindow.toggleMaximize.mockResolvedValue(undefined);
|
||||
nativeWindow.close.mockResolvedValue(undefined);
|
||||
nativeWindow.isMaximized.mockResolvedValue(false);
|
||||
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ = {};
|
||||
|
||||
render(
|
||||
<WindowChrome>
|
||||
<ThemedModal open onClose={() => undefined} ariaLabel="测试弹窗">
|
||||
<button type="button">确认</button>
|
||||
</ThemedModal>
|
||||
</WindowChrome>,
|
||||
);
|
||||
await screen.findByRole('dialog', { name: '测试弹窗' });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '最小化' }));
|
||||
expect(nativeWindow.minimize).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '最大化' }));
|
||||
expect(nativeWindow.toggleMaximize).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '关闭' }));
|
||||
expect(nativeWindow.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,12 @@ vi.mock('../src/services/errorReporting', () => ({
|
||||
captureClientError: vi.fn(),
|
||||
}));
|
||||
|
||||
// 原生侧上传需要登录凭据;这里只钉住「取到了 token」这一件事。
|
||||
vi.mock('../src/services/clientApi', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../src/services/clientApi')>()),
|
||||
getStoredAuthAccessToken: () => 'test-access-token',
|
||||
}));
|
||||
|
||||
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
generateGameDistributionCover,
|
||||
@@ -29,6 +35,14 @@ const MANIFEST = {
|
||||
goal: '守住轨道城',
|
||||
} as unknown as GameCreationAppManifest;
|
||||
|
||||
/** 归一化发行包的暂存摘要;发布链路只应传递它,不再传整包字节。 */
|
||||
const STAGED_PACKAGE = {
|
||||
stagingPath: 'C:/app-data/game-package-staging/aaaa.zip',
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageSizeBytes: 1024,
|
||||
packageFileCount: 1,
|
||||
};
|
||||
|
||||
function jsonResponse(payload: unknown) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
@@ -66,23 +80,25 @@ test('发布时携带本地项目标识,让重复发布复用同一个平台
|
||||
status: 'awaiting_upload',
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ versionId: 'gamever_1', status: 'uploaded' }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ version: { status: 'pending_review' } }),
|
||||
);
|
||||
|
||||
const invokeCalls: Array<{ command: string; args: unknown }> = [];
|
||||
const result = await publishLocalProjectGame({
|
||||
invoke: (async (command: string) => {
|
||||
expect(command).toBe('read_local_project_export_package');
|
||||
return {
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
packageBytes: [1, 2, 3],
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageSizeBytes: 3,
|
||||
files: [{ path: 'index.html', sizeBytes: 3, sha256: 'a'.repeat(64) }],
|
||||
};
|
||||
invoke: (async (command: string, args?: Record<string, unknown>) => {
|
||||
invokeCalls.push({ command, args });
|
||||
if (command === 'prepare_local_project_game_package') {
|
||||
return STAGED_PACKAGE;
|
||||
}
|
||||
if (command === 'upload_local_project_game_package') {
|
||||
return {
|
||||
versionId: 'gamever_1',
|
||||
status: 'uploaded',
|
||||
uploadedBytes: STAGED_PACKAGE.packageSizeBytes,
|
||||
};
|
||||
}
|
||||
throw new Error(`未预期的命令:${command}`);
|
||||
}) as never,
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
@@ -113,18 +129,26 @@ test('发布时携带本地项目标识,让重复发布复用同一个平台
|
||||
|
||||
expect(result.gameId).toBe('game_1');
|
||||
expect(result.versionId).toBe('gamever_1');
|
||||
|
||||
// 关键回归:整包字节不再经过 IPC,上传交给原生侧按版本 ID + 暂存路径完成。
|
||||
const uploadCall = invokeCalls.find(
|
||||
(call) => call.command === 'upload_local_project_game_package',
|
||||
);
|
||||
expect(uploadCall?.args).toMatchObject({
|
||||
stagingPath: STAGED_PACKAGE.stagingPath,
|
||||
versionId: 'gamever_1',
|
||||
apiBaseUrl: 'https://dev.genarrative.world',
|
||||
accessToken: 'test-access-token',
|
||||
});
|
||||
expect(Object.keys(uploadCall?.args ?? {})).not.toContain('packageBytes');
|
||||
// 三次 HTTP:创建游戏、创建版本、送审;上传不再占用一条 HTTP 调用。
|
||||
expect(fetchClientHttp).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test('缺少本地项目标识时在发起请求前失败关闭', async () => {
|
||||
await expect(
|
||||
publishLocalProjectGame({
|
||||
invoke: (async () => ({
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
packageBytes: [1],
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageSizeBytes: 1,
|
||||
files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }],
|
||||
})) as never,
|
||||
invoke: (async () => STAGED_PACKAGE) as never,
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: { ...MANIFEST, projectId: ' ' } as GameCreationAppManifest,
|
||||
@@ -137,13 +161,7 @@ test('缺少本地项目标识时在发起请求前失败关闭', async () => {
|
||||
test('缺少封面时在创建游戏前失败关闭', async () => {
|
||||
await expect(
|
||||
publishLocalProjectGame({
|
||||
invoke: (async () => ({
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
packageBytes: [1],
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageSizeBytes: 1,
|
||||
files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }],
|
||||
})) as never,
|
||||
invoke: (async () => STAGED_PACKAGE) as never,
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: MANIFEST,
|
||||
@@ -156,13 +174,7 @@ test('缺少封面时在创建游戏前失败关闭', async () => {
|
||||
test('截图超过 6 张时在创建游戏前失败关闭', async () => {
|
||||
await expect(
|
||||
publishLocalProjectGame({
|
||||
invoke: (async () => ({
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
packageBytes: [1],
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageSizeBytes: 1,
|
||||
files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }],
|
||||
})) as never,
|
||||
invoke: (async () => STAGED_PACKAGE) as never,
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: MANIFEST,
|
||||
|
||||
@@ -7,18 +7,23 @@
|
||||
* npx vitest run apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts
|
||||
*
|
||||
* 开启后测试会注册一个临时作者,并通过真实的 `clientApi` / `clientHttp`(而不是
|
||||
* mock 请求层)调用 AGC 的发布函数,覆盖:本地导出包读取、创建游戏、同
|
||||
* `localProjectId` 复用游戏身份、真实 ZIP 上传、送审与版本回读。
|
||||
* mock 请求层)调用 AGC 的发布函数,覆盖:本地发行包暂存摘要、创建游戏、同
|
||||
* `localProjectId` 复用游戏身份、真实分片上传、送审与版本回读。
|
||||
*
|
||||
* jsdom 里没有 Tauri 运行时,`upload_local_project_game_package` 由本测试按服务端
|
||||
* 分片协议(upload-state → chunk → complete)代跑,等同于原生上传器的行为;
|
||||
* 原生实现自身的分片规划、权威偏移续传与错误分类在 Rust 单测里覆盖。
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
import JSZip from 'jszip';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type { LocalProjectExportPackagePayload } from '../src/app/types';
|
||||
import type { StagedGamePackage } from '../src/app/types';
|
||||
import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload';
|
||||
import { setStoredAuthAccessToken } from '../src/services/clientAuth';
|
||||
import { setClientServerSelection } from '../src/services/clientHttp';
|
||||
import { publishLocalProjectGame } from '../src/services/gameDistributionPublish';
|
||||
|
||||
/** 1x1 透明 PNG:真实上传一张合法图片作为封面,避免依赖本地素材文件。 */
|
||||
@@ -37,6 +42,12 @@ const liveBaseUrl = (process.env.GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL ?? '')
|
||||
.replace(/\/+$/u, '');
|
||||
const liveTest = liveBaseUrl ? test : test.skip;
|
||||
|
||||
// AGC 服务默认按渠道选 dev / release 域名;跑真实链路时把「平台服务器」切到传入的本地栈,
|
||||
// 否则请求会打到线上域名而不是这台机器上的 api-server。
|
||||
if (liveBaseUrl) {
|
||||
setClientServerSelection({ preset: 'custom', customBaseUrl: liveBaseUrl });
|
||||
}
|
||||
|
||||
const realFetch = globalThis.fetch.bind(globalThis);
|
||||
const ENVELOPE_HEADERS = { 'x-genarrative-response-envelope': 'v1' };
|
||||
|
||||
@@ -55,21 +66,73 @@ function installFetchBridge() {
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input;
|
||||
// jsdom realm 的 Headers / AbortSignal / Blob 都不是 undici 认得的类型(同 2026-09-20
|
||||
// 那条「跨 realm BodyInit 被 undici 拒绝」的坑):统一降级成 Node 侧能接受的原生值。
|
||||
const headers = init?.headers
|
||||
? Object.fromEntries(Array.from(new Headers(init.headers).entries()))
|
||||
: undefined;
|
||||
const signal = undefined;
|
||||
const body = init?.body;
|
||||
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
||||
// jsdom 的 FormData 同样不被 undici 接受:这里手工序列化成 multipart 字节。
|
||||
const multipart = await serializeFormData(body);
|
||||
return realFetch(url as string, {
|
||||
...init,
|
||||
headers: { ...headers, 'Content-Type': multipart.contentType },
|
||||
signal,
|
||||
body: multipart.body,
|
||||
});
|
||||
}
|
||||
if (typeof Blob !== 'undefined' && body instanceof Blob) {
|
||||
// jsdom 的 Blob/ArrayBuffer 属于另一个 realm,且旧版 jsdom 没有
|
||||
// Blob.arrayBuffer;统一读成字节后复制为 Node 侧 Buffer 再转发。
|
||||
const bytes = await readBlobBytes(body);
|
||||
return realFetch(url as string, {
|
||||
...init,
|
||||
headers,
|
||||
signal,
|
||||
body: Buffer.from(bytes),
|
||||
});
|
||||
}
|
||||
return realFetch(url as string, init);
|
||||
return realFetch(url as string, { ...init, headers, signal });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function serializeFormData(form: FormData): Promise<{
|
||||
body: Buffer;
|
||||
contentType: string;
|
||||
}> {
|
||||
const boundary = `----agcLive${Date.now().toString(16)}`;
|
||||
const chunks: Buffer[] = [];
|
||||
for (const [name, value] of form.entries()) {
|
||||
if (typeof value === 'string') {
|
||||
chunks.push(
|
||||
Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const bytes = await readBlobBytes(value);
|
||||
const fileName =
|
||||
(value as File).name || `agc-live-${Date.now().toString(16)}.bin`;
|
||||
const contentType = value.type || 'application/octet-stream';
|
||||
chunks.push(
|
||||
Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"; filename="${fileName}"\r\nContent-Type: ${contentType}\r\n\r\n`,
|
||||
),
|
||||
);
|
||||
chunks.push(Buffer.from(bytes));
|
||||
chunks.push(Buffer.from('\r\n'));
|
||||
}
|
||||
chunks.push(Buffer.from(`--${boundary}--\r\n`));
|
||||
return {
|
||||
body: Buffer.concat(chunks),
|
||||
contentType: `multipart/form-data; boundary=${boundary}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function readBlobBytes(blob: Blob): Promise<Uint8Array> {
|
||||
const maybeArrayBuffer = (
|
||||
blob as Blob & { arrayBuffer?: () => Promise<ArrayBuffer> }
|
||||
@@ -120,7 +183,10 @@ async function registerAuthor(): Promise<string> {
|
||||
return data.token;
|
||||
}
|
||||
|
||||
async function buildExportPayload(): Promise<LocalProjectExportPackagePayload> {
|
||||
async function buildStagedPackage(): Promise<{
|
||||
staged: StagedGamePackage;
|
||||
bytes: Uint8Array;
|
||||
}> {
|
||||
const zip = new JSZip();
|
||||
const indexHtml =
|
||||
'<!doctype html><html><head><meta charset="utf-8"><title>AGC Live</title>' +
|
||||
@@ -129,28 +195,169 @@ async function buildExportPayload(): Promise<LocalProjectExportPackagePayload> {
|
||||
'window.__agcLive=1;document.documentElement.dataset.booted="agc";';
|
||||
zip.file('index.html', indexHtml);
|
||||
zip.file('assets/app.js', appJs);
|
||||
// 让发行包超过单个分片(8 MiB):分片续传只有跨片才有意义,随机字节保证不可压缩。
|
||||
zip.file('assets/bulk.bin', randomBytes(9 * 1024 * 1024));
|
||||
const bytes = await zip.generateAsync({ type: 'uint8array' });
|
||||
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
||||
return {
|
||||
packageRelativePath: 'dist/game.zip',
|
||||
packageBytes: Array.from(bytes),
|
||||
packageSha256: sha256,
|
||||
packageSizeBytes: bytes.length,
|
||||
files: [
|
||||
{
|
||||
path: 'index.html',
|
||||
sizeBytes: Buffer.byteLength(indexHtml),
|
||||
sha256: createHash('sha256').update(indexHtml).digest('hex'),
|
||||
},
|
||||
{
|
||||
path: 'assets/app.js',
|
||||
sizeBytes: Buffer.byteLength(appJs),
|
||||
sha256: createHash('sha256').update(appJs).digest('hex'),
|
||||
},
|
||||
],
|
||||
staged: {
|
||||
stagingPath: '/tmp/agc-live-staging/game.zip',
|
||||
packageSha256: sha256,
|
||||
packageSizeBytes: bytes.length,
|
||||
packageFileCount: 3,
|
||||
},
|
||||
bytes,
|
||||
};
|
||||
}
|
||||
|
||||
type PackageUploadState = {
|
||||
receivedBytes: number;
|
||||
chunkBytes: number;
|
||||
declaredPackageBytes: number;
|
||||
};
|
||||
|
||||
function packageAuthHeaders(token: string) {
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...ENVELOPE_HEADERS,
|
||||
};
|
||||
}
|
||||
|
||||
/** 读取服务端权威已收字节(原生上传器同样以它为准)。 */
|
||||
async function readPackageUploadState(
|
||||
versionId: string,
|
||||
token: string,
|
||||
): Promise<PackageUploadState> {
|
||||
return await unwrap<PackageUploadState>(
|
||||
await realFetch(
|
||||
apiUrl(
|
||||
`/api/game-distribution/versions/${versionId}/package/upload-state`,
|
||||
),
|
||||
{ headers: packageAuthHeaders(token) },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 上传一个分片;偏移由调用方按权威偏移给出。 */
|
||||
async function uploadPackageChunk(input: {
|
||||
versionId: string;
|
||||
token: string;
|
||||
idempotencyKey: string;
|
||||
offset: number;
|
||||
body: Uint8Array;
|
||||
}): Promise<number> {
|
||||
const response = await realFetch(
|
||||
apiUrl(`/api/game-distribution/versions/${input.versionId}/package/chunk`),
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...packageAuthHeaders(input.token),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'x-genarrative-upload-offset': String(input.offset),
|
||||
'Idempotency-Key': `${input.idempotencyKey}:chunk`,
|
||||
},
|
||||
body: Buffer.from(input.body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`分片上传失败:${response.status} ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
const payload = (await response.json()) as {
|
||||
data?: { receivedBytes?: number };
|
||||
receivedBytes?: number;
|
||||
};
|
||||
return payload.data?.receivedBytes ?? payload.receivedBytes ?? input.offset;
|
||||
}
|
||||
|
||||
async function completePackageUpload(input: {
|
||||
versionId: string;
|
||||
token: string;
|
||||
idempotencyKey: string;
|
||||
}) {
|
||||
return await unwrap<{ versionId: string; status: string }>(
|
||||
await realFetch(
|
||||
apiUrl(
|
||||
`/api/game-distribution/versions/${input.versionId}/package/complete`,
|
||||
),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...packageAuthHeaders(input.token),
|
||||
'Idempotency-Key': `${input.idempotencyKey}:complete`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 从权威偏移继续发送剩余分片,返回本次实际发送过的偏移序列。 */
|
||||
async function uploadRemainingChunks(input: {
|
||||
versionId: string;
|
||||
bytes: Uint8Array;
|
||||
token: string;
|
||||
idempotencyKey: string;
|
||||
}): Promise<number[]> {
|
||||
const state = await readPackageUploadState(input.versionId, input.token);
|
||||
const sentOffsets: number[] = [];
|
||||
let received = state.receivedBytes;
|
||||
while (received < input.bytes.length) {
|
||||
const length = Math.min(state.chunkBytes, input.bytes.length - received);
|
||||
await uploadPackageChunk({
|
||||
versionId: input.versionId,
|
||||
token: input.token,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
offset: received,
|
||||
body: input.bytes.subarray(received, received + length),
|
||||
});
|
||||
sentOffsets.push(received);
|
||||
received = (await readPackageUploadState(input.versionId, input.token))
|
||||
.receivedBytes;
|
||||
}
|
||||
return sentOffsets;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按服务端分片协议上传整包:与原生上传器同一套请求形状,用于验证服务端合同。
|
||||
* 第一次调用会**只传第一片就停下**,模拟传输中断;后续调用按权威偏移续传,
|
||||
* 因此这里能直接证明「中断后不重传已收字节」。
|
||||
*/
|
||||
async function uploadStagedPackageViaProtocol(input: {
|
||||
versionId: string;
|
||||
bytes: Uint8Array;
|
||||
token: string;
|
||||
idempotencyKey: string;
|
||||
sentOffsets: number[];
|
||||
}) {
|
||||
const state = await readPackageUploadState(input.versionId, input.token);
|
||||
if (state.receivedBytes === 0) {
|
||||
const firstLength = Math.min(state.chunkBytes, input.bytes.length);
|
||||
await uploadPackageChunk({
|
||||
versionId: input.versionId,
|
||||
token: input.token,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
offset: 0,
|
||||
body: input.bytes.subarray(0, firstLength),
|
||||
});
|
||||
input.sentOffsets.push(0);
|
||||
}
|
||||
input.sentOffsets.push(
|
||||
...(await uploadRemainingChunks({
|
||||
versionId: input.versionId,
|
||||
bytes: input.bytes,
|
||||
token: input.token,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
})),
|
||||
);
|
||||
const completed = await completePackageUpload({
|
||||
versionId: input.versionId,
|
||||
token: input.token,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
});
|
||||
return { versionId: completed.versionId, status: completed.status };
|
||||
}
|
||||
|
||||
liveTest(
|
||||
'AGC 发布函数在真实后端完成创建、上传、送审并在重复发布时复用游戏身份',
|
||||
async () => {
|
||||
@@ -158,22 +365,46 @@ liveTest(
|
||||
const token = await registerAuthor();
|
||||
setStoredAccessToken(token);
|
||||
|
||||
const payload = await buildExportPayload();
|
||||
const { staged, bytes } = await buildStagedPackage();
|
||||
const stamp = String(Date.now());
|
||||
const manifest = {
|
||||
projectId: `agc-live-${stamp}`,
|
||||
name: `AGC 真实发布${stamp.slice(-4)}`,
|
||||
goal: '验证 AGC 一键发布链路',
|
||||
} as unknown as GameCreationAppManifest;
|
||||
const invoke = vi.fn(async () => payload);
|
||||
// 记录本次发布实际发送过的分片偏移,用来证明「中断后不重传已收字节」。
|
||||
const sentOffsets: number[] = [];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'prepare_local_project_game_package') {
|
||||
return staged;
|
||||
}
|
||||
if (command === 'upload_local_project_game_package') {
|
||||
const uploaded = await uploadStagedPackageViaProtocol({
|
||||
versionId: String(args?.versionId ?? ''),
|
||||
bytes,
|
||||
token,
|
||||
idempotencyKey: String(args?.idempotencyKey ?? ''),
|
||||
sentOffsets,
|
||||
});
|
||||
return {
|
||||
versionId: uploaded.versionId,
|
||||
status: uploaded.status,
|
||||
uploadedBytes: bytes.length,
|
||||
};
|
||||
}
|
||||
throw new Error(`未预期的命令:${command}`);
|
||||
},
|
||||
);
|
||||
// 服务端要求发布必须带封面:真实走一遍凭证 → 直传 → confirm。
|
||||
const uploadedCover = await uploadPlatformMediaAsset({
|
||||
file: buildLiveCoverFile(),
|
||||
assetKind: 'game_distribution_cover',
|
||||
pathSegments: ['game-distribution', 'cover', stamp],
|
||||
entityId: 'game-distribution-cover',
|
||||
// jsdom 里没有 Tauri HTTP 插件,复用测试注入的 fetch bridge 直连 dev OSS。
|
||||
fetchImpl: (input, init) => realFetch(apiUrl(input), init),
|
||||
// jsdom 里没有 Tauri HTTP 插件:直传也走同一个桥,跨 realm 的 FormData 会被
|
||||
// 先序列化成 Node 侧 multipart 字节,否则 OSS 会以 405 拒绝。
|
||||
fetchImpl: (input, init) => globalThis.fetch(input, init),
|
||||
});
|
||||
expect(uploadedCover.assetObjectId).toMatch(/\S/u);
|
||||
const metadata = {
|
||||
@@ -198,7 +429,18 @@ liveTest(
|
||||
});
|
||||
expect(first.status).toBe('pending_review');
|
||||
expect(first.versionNumber).toBe(1);
|
||||
expect(first.packageSha256).toBe(payload.packageSha256);
|
||||
expect(first.packageSha256).toBe(staged.packageSha256);
|
||||
// 分片续传证据:第一片(偏移 0)只发送一次;中断后的续传从权威偏移开始,
|
||||
// 已收字节不重放、也不跳段。
|
||||
expect(sentOffsets[0]).toBe(0);
|
||||
expect(sentOffsets.filter((offset) => offset === 0)).toHaveLength(1);
|
||||
expect(sentOffsets[1]).toBeGreaterThan(0);
|
||||
expect(sentOffsets).toEqual(
|
||||
Array.from(
|
||||
{ length: Math.ceil(staged.packageSizeBytes / 8 / 1024 / 1024) },
|
||||
(_, index) => index * 8 * 1024 * 1024,
|
||||
),
|
||||
);
|
||||
|
||||
const readResult = await unwrap<{
|
||||
version: { versionId: string; status: string; recoveryAction: string };
|
||||
|
||||
@@ -266,7 +266,11 @@ describe('客户端发布入口的可见反馈', () => {
|
||||
1440,
|
||||
);
|
||||
expect(declaration(overlay, 'position')).toBe('fixed');
|
||||
expect(declaration(overlay, 'inset')).toBe('0');
|
||||
// 遮罩从自绘标题栏下方开始:发布进行中仍然要能最小化 / 关闭窗口。
|
||||
expect(declaration(overlay, 'top')).toBe('var(--window-chrome-height)');
|
||||
expect(declaration(overlay, 'right')).toBe('0');
|
||||
expect(declaration(overlay, 'bottom')).toBe('0');
|
||||
expect(declaration(overlay, 'left')).toBe('0');
|
||||
expect(declaration(overlay, 'z-index')).toBe('500');
|
||||
expect(declaration(overlay, 'pointer-events')).toBe('auto');
|
||||
expect(declaration(overlay, 'background')).toBe('rgb(35 24 19 / 62%)');
|
||||
|
||||
@@ -35,6 +35,34 @@ function ModalHarness({ noFocusableContent = false }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标题栏在模态之外,但它是窗口边框:弹窗打开时最小化 / 最大化 / 关闭必须照常可点。
|
||||
* 工作区内容反过来仍要被模态挡住,不能因为放行标题栏就一起漏过去。
|
||||
*/
|
||||
function WindowChromeHarness({
|
||||
onMinimize,
|
||||
onWorkspaceClick,
|
||||
}: {
|
||||
onMinimize: () => void;
|
||||
onWorkspaceClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="window-chrome__bar" data-window-chrome-bar>
|
||||
<button type="button" onClick={onMinimize}>
|
||||
最小化
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={onWorkspaceClick}>
|
||||
工作区按钮
|
||||
</button>
|
||||
<ThemedModal open onClose={() => undefined} ariaLabel="测试弹窗">
|
||||
<button type="button">确认</button>
|
||||
</ThemedModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ThemedModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLElement.prototype, 'getClientRects').mockImplementation(
|
||||
@@ -105,4 +133,23 @@ describe('ThemedModal', () => {
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
expect(document.activeElement).toBe(opener);
|
||||
});
|
||||
|
||||
it('lets window title bar clicks through while workspace clicks stay trapped', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMinimize = vi.fn();
|
||||
const onWorkspaceClick = vi.fn();
|
||||
render(
|
||||
<WindowChromeHarness
|
||||
onMinimize={onMinimize}
|
||||
onWorkspaceClick={onWorkspaceClick}
|
||||
/>,
|
||||
);
|
||||
await screen.findByRole('dialog', { name: '测试弹窗' });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '最小化' }));
|
||||
expect(onMinimize).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '工作区按钮' }));
|
||||
expect(onWorkspaceClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { repoPath } from './repoPath';
|
||||
import { parseStyleSheet } from './styleCascade';
|
||||
|
||||
const STYLES_PATH = repoPath('apps/ai-game-creator-shell/src/styles.css');
|
||||
|
||||
/**
|
||||
* 全屏弹层清单:每一层都必须从自绘标题栏下方开始。
|
||||
*
|
||||
* 标题栏是窗口边框,不是弹层内容 —— 只要有一个全屏遮罩盖住它,弹窗打开时
|
||||
* 「最小化 / 最大化 / 关闭」就会被挡住。焦点陷阱那一半的问题见
|
||||
* `themedModal.test.tsx` 与 `WindowChrome.test.tsx`;新增全屏弹层时把类名加进这份清单。
|
||||
*/
|
||||
const WINDOW_CHROME_SAFE_OVERLAYS = [
|
||||
// ThemedModal 与共享弹层的通用遮罩:top 由这条规则统一抬到标题栏下方。
|
||||
'.fixed.inset-0',
|
||||
'.app-update-overlay',
|
||||
'.game-publish-progress-overlay',
|
||||
'.launcher-dialog-backdrop',
|
||||
'.settings-overlay',
|
||||
'.game-approval-backdrop',
|
||||
'.project-chat-settings-backdrop',
|
||||
] as const;
|
||||
|
||||
function declarationsForSelector(css: string, selector: string) {
|
||||
const merged = new Map<string, string>();
|
||||
for (const rule of parseStyleSheet(css)) {
|
||||
if (!rule.selectors.includes(selector)) {
|
||||
continue;
|
||||
}
|
||||
for (const [property, value] of rule.declarations) {
|
||||
merged.set(property, value);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
describe('窗口标题栏与全屏弹层的层叠约定', () => {
|
||||
const css = readFileSync(STYLES_PATH, 'utf8');
|
||||
|
||||
it.each(WINDOW_CHROME_SAFE_OVERLAYS)('%s 从标题栏下方开始', (selector) => {
|
||||
const declarations = declarationsForSelector(css, selector);
|
||||
expect(
|
||||
declarations.get('top'),
|
||||
`${selector} 必须声明 top: var(--window-chrome-height)`,
|
||||
).toBe('var(--window-chrome-height)');
|
||||
});
|
||||
});
|
||||
@@ -90,8 +90,9 @@ http {
|
||||
|
||||
location ~ ^/api(?:/|$) {
|
||||
default_type application/json;
|
||||
# 中文注释:创作接口会携带参考图 Data URL,Nginx 只放行到 api-server;真实大小限制仍由路由 DefaultBodyLimit 和业务字节校验负责。
|
||||
client_max_body_size 64m;
|
||||
# 中文注释:创作接口会携带参考图 Data URL,游戏发行包 PUT 更大,Nginx 只负责放行到 api-server;
|
||||
# 真实大小限制仍由路由 DefaultBodyLimit(发行包 200 MiB + 1 KiB)和业务字节校验负责。
|
||||
client_max_body_size 210m;
|
||||
limit_conn genarrative_api_conn 64;
|
||||
limit_req zone=genarrative_api_rps burst=64 nodelay;
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
## 请求体大小
|
||||
|
||||
- 生产、开发服和容器模板都在通用 `location ~ ^/api(?:/|$)` 内设置 `client_max_body_size 64m`。
|
||||
- 该值只用于让携带参考图 Data URL 的创作接口抵达 `api-server`;不要把它当作业务上传上限。Rust 路由仍通过 `DefaultBodyLimit` 和解码后字节校验限制具体接口,例如拼图参考图路由只放宽到 12 MiB 请求体,图片字节继续按业务规则拒绝。
|
||||
- 生产、开发服和容器模板都在通用 `location ~ ^/api(?:/|$)` 内设置 `client_max_body_size 210m`。
|
||||
- 该值只用于让携带参考图 Data URL 的创作接口和游戏发行包 PUT(路由上限 200 MiB + 1 KiB)抵达 `api-server`;不要把它当作业务上传上限。Rust 路由仍通过 `DefaultBodyLimit` 和解码后字节校验限制具体接口,例如拼图参考图路由只放宽到 12 MiB 请求体,图片字节继续按业务规则拒绝。Pingora 网关侧的 `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES` 必须同样不低于该值,否则请求会在网关层被 413。
|
||||
- 若线上看到 `413 Request Entity Too Large`,并且 access log 里 `request_time=0.000 upstream_status=-`,通常是 Nginx 没有加载该模板或未 reload;先执行 `nginx -T | grep client_max_body_size` 和 `nginx -t` 再检查 `api-server`。
|
||||
|
||||
## gzip
|
||||
|
||||
@@ -119,8 +119,9 @@ server {
|
||||
# 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
|
||||
location ~ ^/api(?:/|$) {
|
||||
default_type application/json;
|
||||
# 中文注释:创作接口会携带参考图 Data URL,Nginx 只放行到 api-server;真实大小限制仍由路由 DefaultBodyLimit 和业务字节校验负责。
|
||||
client_max_body_size 64m;
|
||||
# 中文注释:创作接口会携带参考图 Data URL,游戏发行包 PUT 更大,Nginx 只负责放行到 api-server;
|
||||
# 真实大小限制仍由路由 DefaultBodyLimit(发行包 200 MiB + 1 KiB)和业务字节校验负责。
|
||||
client_max_body_size 210m;
|
||||
limit_conn genarrative_api_conn 64;
|
||||
limit_req zone=genarrative_api_rps burst=64 nodelay;
|
||||
|
||||
|
||||
@@ -139,8 +139,9 @@ server {
|
||||
# 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
|
||||
location ~ ^/api(?:/|$) {
|
||||
default_type application/json;
|
||||
# 中文注释:创作接口会携带参考图 Data URL,Nginx 只放行到 api-server;真实大小限制仍由路由 DefaultBodyLimit 和业务字节校验负责。
|
||||
client_max_body_size 64m;
|
||||
# 中文注释:创作接口会携带参考图 Data URL,游戏发行包 PUT 更大,Nginx 只负责放行到 api-server;
|
||||
# 真实大小限制仍由路由 DefaultBodyLimit(发行包 200 MiB + 1 KiB)和业务字节校验负责。
|
||||
client_max_body_size 210m;
|
||||
limit_conn genarrative_api_conn 64;
|
||||
limit_req zone=genarrative_api_rps burst=64 nodelay;
|
||||
|
||||
|
||||
@@ -124,14 +124,14 @@
|
||||
"nginx": {
|
||||
"production": [
|
||||
"location ~ ^/api(?:/|$)",
|
||||
"client_max_body_size 64m;",
|
||||
"client_max_body_size 210m;",
|
||||
"limit_conn genarrative_api_conn 64;",
|
||||
"limit_req zone=genarrative_api_rps burst=64 nodelay;",
|
||||
"add_header X-Accel-Buffering no always;"
|
||||
],
|
||||
"development": [
|
||||
"location ~ ^/api(?:/|$)",
|
||||
"client_max_body_size 64m;",
|
||||
"client_max_body_size 210m;",
|
||||
"limit_conn genarrative_api_conn 64;",
|
||||
"limit_req zone=genarrative_api_rps burst=64 nodelay;",
|
||||
"add_header X-Accel-Buffering no always;"
|
||||
|
||||
@@ -28,7 +28,7 @@ GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_FILE=/var/lib/genarrative/maintenance/en
|
||||
GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE=/var/lib/genarrative/maintenance/page.html
|
||||
|
||||
GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=http
|
||||
GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES=67108864
|
||||
GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES=220200960
|
||||
# gzip 默认开启;等级和最小响应长度对齐 Nginx gzip_comp_level 5 / gzip_min_length 1024。
|
||||
# Pingora 正式化口径固定为 gzip-only;br / zstd 不进入当前网关,Brotli 继续由 Nginx / 前置代理承担。
|
||||
GENARRATIVE_PINGORA_GATEWAY_COMPRESSION_ALGORITHMS=gzip
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# AGC 发行包分片续传上传实施计划
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Version | 1.0 |
|
||||
| Status | runtime-smoke-passed(存储原语、服务端入口、原生上传器、渲染进程接线与真实栈分片续传 smoke 均已落地) |
|
||||
| Date | 2026-09-23 |
|
||||
| Parent Milestone | `docs/project-memory/plans/【里程碑】AGC发行包分片续传上传-2026-09-23.md` |
|
||||
|
||||
## 修改边界与顺序
|
||||
|
||||
1. **存储原语(已完成)**:`server-rs/crates/platform-oss/src/lib.rs` 新增 `append_internal_object` / `append_internal_object_with_retry` 与 `OssAppendInternalObjectRequest` / `OssAppendInternalObjectResponse`;复用现役 V4 签名助手 `signed_request_builder`(查询串已参与签名)与 `run_internal_put_with_retry` 的可重试分类。`position = 0` 追加到末尾,`position > 0` 必须等于对象当前长度;返回 `next_position` 作为权威已收字节。
|
||||
2. **服务端入口(已完成)**:`server-rs/crates/api-server/src/modules/game_distribution.rs`
|
||||
- 新增 `GET .../package/upload-state`、`PUT .../package/chunk`、`POST .../package/complete`、`POST .../package/reset` 四个路由,沿用作者鉴权、`game-distribution:publish` 灰度开关与 `Idempotency-Key` 约定;
|
||||
- 分片大小 `PACKAGE_UPLOAD_CHUNK_BYTES = 8 MiB`,分片请求体放行量为分片大小 + 1 KiB;
|
||||
- 从整包 `PUT` 抽出共享收口 `confirm_validated_package`(声明比对 → 确认 → 结构化事件),两种入口共用;
|
||||
- 新增 `game_distribution_oss_client` / `game_distribution_package_object_key` / `staged_package_bytes` / `require_octet_stream_content_type` / `package_upload_offset` 辅助函数;偏移不一致返回 `409 PACKAGE_UPLOAD_OFFSET_MISMATCH` 与权威偏移;未收齐返回 `409 PACKAGE_UPLOAD_INCOMPLETE`;校验失败删除半包并落 `upload_failed`。
|
||||
3. **AGC 原生上传器(已完成)**:新增 `apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs`:内容寻址暂存(`<appData>/game-package-staging/<sha256>.zip`,重启后同包复用同一文件)、`upload-state → chunk → complete` 循环、409 权威偏移续传(响应丢失后按服务端已收字节对齐,不重放不跳段)、仅对传输/超时/408/429/5xx 退避重试(默认 4 次尝试)、`game-package-upload-progress` 进度事件;暂存路径必须落在暂存目录内。命令 `prepare_local_project_game_package` / `upload_local_project_game_package` 已注册,整包回传命令 `read_local_project_export_package` 退役(`read_local_project_export_package_at` 仍供暂存使用)。
|
||||
4. **渲染进程接线(已完成)**:`apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts` 改为 `prepare`(拿摘要与暂存路径)→ 创建游戏 → 创建版本 → 原生分片上传 → 送审;`LocalProjectExportPackagePayload` 整包类型退役,改为 `StagedGamePackage` / `GamePackageUploadOutcome`;不再有任何整包字节进 IPC。
|
||||
5. **真实栈 smoke(已完成)**:本地 api-server + 真实 OSS bucket 上跑通「中断 → 续传 → 确认」。做法与证据:
|
||||
- 先用 `npm run dev:spacetime` 把当前模块发布到本地库(`genarrative-game-creator-dev`,自动迁移完成),再用 `npm run dev:api-server` 起 `127.0.0.1:8082`;
|
||||
- 本地库的 `feature_gate_config` 原本为空(发布开关默认关闭),用 `spacetime call … upsert_feature_gate_config` 写入 `game-distribution:publish enabled=true rollout=100`;
|
||||
- `GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL=http://127.0.0.1:8082 npx vitest run apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts` → **1 passed / 3.9s**(发行包 9.0 MiB,跨 8 MiB 分片边界);
|
||||
- 用例断言实际发送过的分片偏移序列等于 `[0, 8388608]`:第一片只发一次,中断后的续传从权威偏移开始,不重放也不跳段;
|
||||
- api-server 侧同一轮日志:`package_chunk_stored offset=0 chunk_bytes=8388608 received_bytes=8388608 elapsed_ms=201`、`package_chunk_stored offset=8388608 chunk_bytes=1049210 received_bytes=9437818 elapsed_ms=82`、`package_confirmed package_bytes=9437818 file_count=3 oss_put_skipped=true elapsed_ms=884`。
|
||||
- 为了能指向本地栈,用例还补了两处基础设施修正:把客户端平台基址切到传入的 base URL(`setClientServerSelection({preset:'custom'})`),以及桥接层把 jsdom realm 的 `Headers` / `Blob` / `FormData` 降级成 Node 侧原生值(`FormData` 手工序列化为 multipart 字节,否则 OSS 直传回 405)。
|
||||
|
||||
## 不改的部分
|
||||
|
||||
- 网页端发布路径与整包 `PUT` 语义不变;`MAX_PACKAGE_BYTES`、展开量、单文件与文件数上限不变。
|
||||
- 未新增 SpacetimeDB 表或字段:已收字节的事实来源是 OSS 对象长度,版本状态机沿用既有 `awaiting_upload → uploaded → …`。
|
||||
- 未引入半包定时清理任务。
|
||||
|
||||
## 验证命令
|
||||
|
||||
- `cargo test -p platform-oss`(74 passed)
|
||||
- `cargo test -p api-server game_distribution`(23 passed,含新增 `package_chunk_size_stays_inside_declared_limits`、`package_upload_offset_requires_non_negative_integer`、`package_chunk_content_type_must_be_octet_stream`)
|
||||
- `cargo fmt --all -- --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`
|
||||
- `cargo test game_package_upload`(AGC 原生侧 4 passed:分片规划无缝无重叠、409 权威偏移解析、URL 拼接、内容寻址暂存与路径校验)
|
||||
- `npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`、`npm run --workspace apps/ai-game-creator-shell typecheck`(含 `check-config.mjs` 的命令登记门禁)
|
||||
- `npx vitest run`(发布函数 6 passed、发布面板 9 passed、发布反馈 5 passed;真实链路用例在无 `GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL` 时按设计跳过)
|
||||
- 待做:真实栈 smoke(本地 api-server + 真实 OSS bucket 上跑「中断 → 续传 → 完成」,含 `x-oss-next-append-position` 语义确认)
|
||||
|
||||
## 风险与回滚点
|
||||
|
||||
- **对象可追加性**:`platform-oss` 之前没有追加写,首次真实调用需要在真实 bucket 上确认 `x-oss-next-append-position` 语义;失败时回滚点是 `platform-oss` 新增函数与四条路由(整包 `PUT` 不受影响,可独立回退)。
|
||||
- **半包对象**:分片写入直接落在版本键上,未完成时是半包。它不进公开目录、不服务发行网关;失败或作者重置时删除。若删除失败会记录 `package_staging_delete_failed` 告警,需要人工确认对象键状态。
|
||||
- **重置语义**:只有 `awaiting_upload` / `upload_failed` 允许重置,避免破坏已确认事实。
|
||||
- **内存**:完成动作按 200 MiB 上限回读整包再校验,峰值与整包 `PUT` 同量级;分片路径不再让整包驻留客户端。
|
||||
@@ -0,0 +1,58 @@
|
||||
# AGC 发行包分片续传上传
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Version | 1.0 |
|
||||
| Status | runtime-smoke-passed(真实栈「中断 → 续传 → 确认」已通过;AGC 真机一键发布与 200 MiB 档容量数据未验证) |
|
||||
| Date | 2026-09-23 |
|
||||
| Parent Spec | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`(真实发行包与资料合同第 10 条、幂等并发与恢复) |
|
||||
|
||||
## 背景与触发
|
||||
|
||||
AGC 一键发布今天把整包字节从 WebView 侧送出:`read_local_project_export_package` 先把 `packageBytes` 整包过一遍 IPC 回到渲染进程,渲染进程再用 `@tauri-apps/plugin-http` 发整包 `PUT`,而该插件会把 body 序列化成 `Array.from(new Uint8Array(buffer))` 再走一次 IPC。两次整包 IPC 决定了 AGC 实际可发布的包远小于服务端 200 MiB 上限,失败时表现为客户端侧传输错误(例如「无法连接登录服务」),服务端访问日志里没有这次请求;断流后也只能整包白传。本里程碑把上传下沉到原生侧并支持分片续传。
|
||||
|
||||
## 目标
|
||||
|
||||
1. AGC 一键发布由原生进程直接读取本地试玩包、按服务端下发的固定分片大小上传,整包字节不再经过 WebView IPC。
|
||||
2. 传输中断、网络失败、客户端进程退出或应用重启后,同一 `versionId` 只补传缺失字节,不白传整包。
|
||||
3. 分片入口与现役整包 `PUT` 共用同一版本状态机、摘要口径、幂等键与包校验;网页端发布路径不变。
|
||||
|
||||
## 不在本里程碑内
|
||||
|
||||
- 不改网页端发布路径(继续整包 `PUT`),不为浏览器实现续传。
|
||||
- 不做并行分片上传、不做客户端直传 OSS(分片仍经 `api-server` 转发,与今天整包路径同一出口)。
|
||||
- 不做「后台自动续传」:续传只在下一次发布动作或应用重启后的重试里发生,不引入常驻重传任务。
|
||||
- 不做未完成分片会话的定时清理任务;半包对象的回收单独开里程碑。
|
||||
- 不改发行包上限、展开量、单文件与文件数上限。
|
||||
|
||||
## 合同要点
|
||||
|
||||
- **入口与状态**:分片续传对既有 `versionId` 生效,版本状态沿用 `awaiting_upload → uploaded → …`;分片入口与整包入口互斥,同一版本同时只能有一个写入者,第二个写入返回 `409 UPLOAD_IN_PROGRESS`。
|
||||
- **权威偏移**:服务端记录的已收字节是唯一权威。客户端分片偏移与之不符时返回 `409` 与权威偏移,客户端按权威偏移续传;重复分片不得造成重复写入。
|
||||
- **完成动作**:全部字节到齐后才执行校验与确认;校验失败删除半包对象并把版本落到 `upload_failed`(`recoveryAction=reupload`)。重新上传同一版本前必须显式重置分片会话,重置后偏移归零,不允许在半包之上续写不同字节。
|
||||
- **可见性**:半包对象不进入公开目录、不服务发行网关、不改变当前公开版本;与既有「未通过审核不改变 `activeVersionId`」口径一致。
|
||||
- **原生侧边界**:原生上传只读本地试玩包并逐片发送,进度以事件回传渲染进程;渲染进程不再持有整包字节。
|
||||
|
||||
## 依赖
|
||||
|
||||
- `platform-oss`:需要一组可续写的对象写入原语(追加语义或等价的分片会话),以及读取已收字节的探测能力;现役只有整对象 `PUT`。
|
||||
- `api-server`:`modules/game_distribution.rs` 新增分片入口与完成动作,复用既有 `validate_release_zip`、OSS 上传重试分类、`package_confirmed` / `package_rejected` 可观测事件。
|
||||
- AGC:`src-tauri` 新增原生上传命令与进度事件,`src/services/gameDistributionPublish.ts` 改为调用原生命令;`read_local_project_export_package` 不再为发布回传整包字节。
|
||||
- 反代/网关:分片请求体远小于现役 210 MiB 放行量,沿用现有配置,不改限额。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. **不再整包过 IPC**:发布 200 MiB 档包时,渲染进程侧不出现整包字节(对照 `read_local_project_export_package` 的返回体与 IPC 报文大小),上传由原生进程完成。
|
||||
2. **续传生效**:上传中途断开传输后重发同一版本,只补传缺失分片;分片请求数、已传字节与最终包摘要三项均可复核。
|
||||
3. **跨重启续传**:上传中断时退出应用并重启,重新发布时服务端返回权威已收字节,客户端从该偏移继续,最终确认成功。
|
||||
4. **偏移与重复**:分片偏移不符返回 `409` 与权威偏移;重复提交同一分片不产生重复写入;同版本第二个写入者返回 `409 UPLOAD_IN_PROGRESS`。
|
||||
5. **失败关闭**:完成动作里校验失败(非法 ZIP、超限、压缩比越界等)删除半包对象、版本落 `upload_failed`,半包不出现在公开目录,也不影响当前公开版本。
|
||||
6. **兼容与回归**:整包 `PUT` 路径与既有测试保持绿;`npm run check:doc-index`、`npm run check:encoding`、`git diff --check` 通过;`check:spacetime-schema` 按是否新增持久字段决定是否纳入。
|
||||
7. **运行时证据(已获得)**:本地 api-server(`127.0.0.1:8082`,库 `genarrative-game-creator-dev`)+ 真实 OSS bucket 上跑通 `gameDistributionPublishLive.test.ts`:9.0 MiB 发行包跨 8 MiB 分片边界,第一片只发送一次,中断后续传从权威偏移 `8388608` 继续、第二片 `received_bytes=9437818`,最后 `package_confirmed`(`oss_put_skipped=true`);整轮 3.9s。**未获得**:AGC 真机(Tauri 运行时)一键发布的端到端运行,以及 200 MiB 档的耗时 / 内存容量数据。
|
||||
|
||||
## 待评审的决策点
|
||||
|
||||
1. **续写原语**:OSS 追加写(顺序、单对象、续传只需回读当前长度)对比 OSS Multipart(可并行、更通用但需要多组新操作)。建议追加写,顺序续传已满足本里程碑目标。
|
||||
2. **分片大小**:建议 8 MiB(200 MiB 上限 → 最多 25 片,单片请求体远低于现役放行量)。
|
||||
3. **重置语义**:建议只有显式重置(作者点「重新上传」或 `reupload` 恢复动作)才删除半包并归零;其余情况一律按权威偏移续传。
|
||||
4. **半包回收**:本里程碑只标记未完成会话,不做定时清理;回收另立里程碑(涉及「不得删除仍被公开版本引用的对象」口径)。
|
||||
@@ -120,7 +120,7 @@
|
||||
### 行为与验收
|
||||
|
||||
- [ ] 真实环境中完整跑通“首次上传 → 校验 → 审核 → 公开 → 游客游玩 → 更新待审旧版在线 → 新版切换 → 下架撤销”。
|
||||
- [ ] 100 MiB 包与获批文件数/展开量边界有可复核耗时、内存和失败证据;校验不会执行上传代码,服务资源有界。
|
||||
- [ ] 200 MiB 包(现行上限,见 2026-09-23 决策记录)与获批文件数/展开量边界有可复核耗时、内存和失败证据;校验不会执行上传代码,服务资源有界。已有证据覆盖 100 MiB 档,上限提升后的档位待复跑。
|
||||
- [ ] 校验执行器重启可恢复,审核积压与失败可观测,清理不删除仍被公开版本引用的文件。
|
||||
- [ ] CDN purge 失败时仍在获批缓存 TTL 内拒绝新资源;明确已下载脚本无法远程抹除的边界。
|
||||
- [ ] 发布/回滚步骤保留当前公开版本,能关闭新提交和新版本激活;部署路由、缓存、响应头、日志脱敏和告警完成检查。
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-09-23 自绘标题栏是窗口边框:弹层从它下方开始,焦点陷阱放行它
|
||||
|
||||
- 背景:AGC 打开任意一个 `ThemedModal` 弹窗(发布面板、发布进度、资源预览、账本、错误报告等)后,右上角「最小化 / 最大化 / 关闭」点击没有任何反应,标题栏拖拽也不能移动窗口;关掉弹窗立刻恢复。原因是标题栏在模态之外,而 `focus-trap-react` 在 document 捕获阶段监听 `mousedown`/`touchstart`/`click`,模态外的点击被 `preventDefault()` 且 `click` 直接 `stopImmediatePropagation()` —— React 的监听在更内层,事件到不了它,所以表现是「点了没反应」而不是报错。另有 `.app-update-overlay` 用 `inset: 0` 真的把标题栏盖住了。
|
||||
- 决策:把自绘标题栏定为**窗口边框**,不属于弹层内容:① portal 到 body 的全屏弹层一律 `top: var(--window-chrome-height)`,禁止用 `inset: 0` 盖住标题栏;② `ThemedModal` 的焦点陷阱用 `allowOutsideClick` 只放行落在 `[data-window-chrome-bar]` 内的目标,工作区内容的点击继续被拦住;③ `WindowChrome` 的标题栏加 `data-window-chrome-bar` 标记,作为这条约定的唯一契约点。
|
||||
- 影响范围:`apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx`、`apps/ai-game-creator-shell/src/components/WindowChrome.tsx`、`apps/ai-game-creator-shell/src/styles.css`(`:root` 注释、`.app-update-overlay`、`.game-publish-progress-overlay`)。
|
||||
- 验证方式:`tests/themedModal.test.tsx`(标题栏点击放行、工作区点击仍被拦)、`tests/WindowChrome.test.tsx`(弹窗打开时三个窗口按钮仍调用原生窗口 API)、`tests/windowChromeOverlayContract.test.ts`(7 个全屏弹层都从标题栏下方开始)、`tests/gamePublishFeedback.test.tsx` 与 appSurface(208 passed);两处新增用例都做过「去掉修复即失败」的反向确认。`npm run --workspace apps/ai-game-creator-shell typecheck`、eslint、`npm run check:encoding`、`git diff --check` 通过。
|
||||
|
||||
## 2026-09-23 游戏发行包上限提升到 200 MiB(反代放行量与发行缓存同步)
|
||||
|
||||
- 背景:游戏广场发行包上限原为 100 MiB(`module-game-distribution` 的 `MAX_PACKAGE_BYTES` 与网页端 `GAME_PACKAGE_MAX_BYTES`),而 Nginx 三份模板与 Pingora 网关的通用 `/api` 放行量是 64 MiB。上限只改一层没有意义:包体超过 100 MiB 时先在反代层被 413,`api-server` 的 ZIP 校验根本不会执行。
|
||||
- 决策:发行包上限 100 MiB → 200 MiB;展开总量 250 MiB → 500 MiB(保持 2.5 倍余量);单文件 64 MiB、最多 10,000 个文件、展开/压缩比 100 三条内容规则不变;发行包路由请求体上限继续从包上限派生(200 MiB + 1 KiB)。反代放行量统一放宽到 210 MiB:`deploy/nginx/genarrative.conf`、`deploy/nginx/genarrative-dev-http.conf`、`deploy/container/nginx.conf` 使用 `client_max_body_size 210m`,Pingora `DEFAULT_MAX_API_BODY_BYTES` 改为 `220200960` 并同步 `deploy/pingora/pingora-gateway.env.example`。发行静态资源进程内缓存字节预算 200 MiB → 256 MiB,让 200 MiB 档发行包仍能进缓存、且不独占整份预算。
|
||||
- 边界:包内单个文件仍不得超过 64 MiB;线上 Pingora 环境文件若仍写 `67108864`,必须在重启网关前同步改值,否则发行包 PUT 会在网关层被 413。AGC 一键发布经 `@tauri-apps/plugin-http` 传整包字节,实际可发布体积还受该传输方式限制,200 MiB 档的客户端容量需要单独验证。
|
||||
- 影响范围:`server-rs/crates/module-game-distribution/src/package.rs`、`server-rs/crates/api-server/src/modules/game_distribution.rs`、`server-rs/crates/pingora-gateway/src/main.rs`、`src/components/game-distribution/gameZipPackage.ts`、`deploy/{nginx,container,pingora}`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md`。
|
||||
- 验证方式:`cargo test -p module-game-distribution`(13 passed,其中 `accepts_package_above_the_previous_hundred_mib_limit` 用两个 50 MiB 存储型条目构造 100 MiB 出头的包;把上限临时改回 100 MiB 时该用例确实失败,证明它能守住新上限)、`cargo test -p api-server game_distribution`(20 passed,含新增的请求体上限覆盖包上限断言)、`cargo test -p pingora-gateway`(38 passed,含 `matches_nginx_route_parity_matrix`)、`npx vitest run src/components/game-distribution`(46 passed)、`cargo fmt --all -- --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。`npm run check:pingora-route-parity` 仍在 dev-http / 容器模板缺少 `/games` 等 SPA 路由处失败,改动前同样失败,与本次口径无关。200 MiB 档真实栈容量证据(上传耗时、api-server 峰值内存、超限 413 口径)尚未复跑,发布前需按阶段 D 脚本重跑一轮。
|
||||
|
||||
## 2026-09-23 引用名不允许空白:素材 / Skill / 附件共用 `normalizeMentionName`
|
||||
|
||||
- 背景:自动评审发现 `buildContentFromTextTokens` 在前缀重叠时会多插一枚芯片——素材显示名 `hero` 与 `hero v2` 并存时,粘贴 `看 @hero v2 这一版` 得到 `[chip hero]` + `[chip hero-v2]`(短名先按 index 平局抢位,长名成了补到末尾的孤儿)。根因不是匹配算法,而是**引用名自己带空白**:token 的边界规则是「前后为空白或行首行尾」,`@hero␠` 在 `@hero v2` 内部也算一次合法命中。
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
|
||||
策划 Runtime 会通过状态事件与命令返回交付同一份最终视图。若前端清空临时正文后再拿“最后一条非用户历史消息”回填动画,就会出现正式回复旁又播放一遍、播放后消失的假重试。正文应按 `messageId` 保存显示进度,与正式消息共用一个气泡;请求完成不清动画,不延迟正式业务状态。Provider 自动重试复用消息 ID 并发送空文本,只允许重置未持久化的该条回复。正文、工具状态和 reasoning 分开;事件与异步命令收尾均检查项目及活动回合,旧请求不能覆盖新回合。详见 [AGC 实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)。
|
||||
|
||||
## 2026-09-23 弹窗打开时自绘标题栏的最小化 / 最大化 / 关闭静默失效
|
||||
|
||||
- **现象**:AGC 打开「发布到游戏广场」面板(以及其它任何弹窗)后,右上角三个窗口按钮点了没有任何反应,拖拽标题栏也不能移动窗口;关掉弹窗立刻恢复。标题栏看着完全正常,遮罩也明显只压住了下面的工作区,所以很容易误判成「按钮自己坏了」或 Tauri 窗口 API 挂了。
|
||||
- **原因**:标题栏在模态之外,但它是窗口边框。`ThemedModal` 用的 `focus-trap-react` 在 **document 捕获阶段**监听 `mousedown`/`touchstart`/`click`:模态外的点击一律 `preventDefault()`,`click` 还会 `stopImmediatePropagation()`。React 的监听挂在 document 内的根容器上,捕获阶段就被掐掉的 `click` 永远到不了 React,于是既不报错也不执行 —— 与「焦点陷阱吞掉模态外点击」是同一类问题(见 2026-09-20 发布面板焦点陷阱那条)。另有一条独立的同类缺陷:`.app-update-overlay` 用 `inset: 0`,把标题栏真的盖住了,更新弹窗期间按钮被遮罩挡住。
|
||||
- **处理(现行口径)**:① 全屏弹层一律从标题栏下方开始(`top: var(--window-chrome-height)`),不得用 `inset: 0` 盖住标题栏;② `ThemedModal` 的焦点陷阱用 `allowOutsideClick` 只放行落在 `[data-window-chrome-bar]` 内的目标,工作区内容点击继续被拦;③ 新增全屏弹层时把类名补进 `apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts` 的清单。
|
||||
- **验证**:`npx vitest run apps/ai-game-creator-shell/tests/themedModal.test.tsx apps/ai-game-creator-shell/tests/WindowChrome.test.tsx apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts`(标题栏点击放行、工作区点击仍被拦、7 个全屏弹层都在标题栏下方);两个新增用例去掉修复后确实失败,确认能守住这条约定。
|
||||
- **关联**:`apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx`、`apps/ai-game-creator-shell/src/components/WindowChrome.tsx`、`apps/ai-game-creator-shell/src/styles.css`。
|
||||
|
||||
## Direct 宿主继续请求不能重发原始用户条目
|
||||
|
||||
原始 `direct_user_item` 同时参与历史持久化和模型输入转换;验收或错误反馈更新了 prompt 后,如果发送层仍优先转换原始条目,模型会收到重复的用户输入,而本地历史按 itemId 去重后只显示一次。首次请求与宿主继续必须显式区分:首次保留结构化输入,继续发送当次反馈,原始条目只保留历史与事件关联职责。GUI、CLI 的两条循环都要覆盖;只改反馈文本或清空原始条目不完整。见 [Direct 宿主继续请求输入修复](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-09-23-direct-宿主继续请求输入修复)。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user