From 688cf7a971dbf1613b4213c0da30e8cd295d3c47 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 23 Sep 2026 10:38:01 +0000 Subject: [PATCH 1/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AD=96=E5=88=92?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=E9=87=8D=E5=A4=8D=E6=92=AD=E6=94=BE=E5=B9=B6?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E4=BC=AA=E6=B5=81=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按消息 ID 合并实时正文与正式消息,保留逐步显示进度 分离请求收尾与动画结束,隔离旧回合事件及异步返回 独立展示工具状态,保留 Provider 重试的正文重置语义 补充重复终态、整块回复、多消息及回合隔离回归测试 同步更新策划展示规范与排障记录 --- apps/ai-game-creator-shell/src/App.tsx | 252 ++++++------------ .../planning/PlanningChatView.tsx | 113 +++++--- .../planning/useDesignReplyAnimation.ts | 113 ++++++++ .../tests/appSurface/design-agent.suite.ts | 165 ++++++++++++ .../tests/designReplyAnimation.test.tsx | 99 +++++++ docs/project-memory/shared-memory/pitfalls.md | 4 + ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 5 + 7 files changed, 530 insertions(+), 221 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/planning/useDesignReplyAnimation.ts create mode 100644 apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 9fe8fc245..8a9c7e24b 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -116,6 +116,7 @@ import { type DirectProjectInitialTurn, } from './view/project-development/chat/DirectProjectChatView'; import { PlanningChatView } from './view/project-development/planning/PlanningChatView'; +import { useDesignReplyAnimation } from './view/project-development/planning/useDesignReplyAnimation'; import type { ProjectManifestSnapshotMetadata } from './view/project-development/projectResourceLiveUpdateModel'; function isPersistableDirectCodexConversationMessage(message: ChatMessage) { @@ -366,23 +367,31 @@ export function App({ // 发布灰度:只有命中的账号才把「发布到游戏广场」入口交给聊天头;读取失败按不开放处理。 const [gamePublishAllowed, setGamePublishAllowed] = useState(false); const [projectChatError, setProjectChatError] = useState(''); - const [designAgentTransientReply, setDesignAgentTransientReplyVisible] = - useState(''); - const designAgentTransientReplyTargetRef = useRef(''); - const designAgentVisibleReplyRef = useRef(''); - const designAgentPendingViewRef = useRef<{ - clientTurnId: string; - projectPath: string; - view: DesignView; - } | null>(null); + const designReplyAnimation = useDesignReplyAnimation(); + const [designAgentStatus, setDesignAgentStatus] = useState(''); const [designAgentReasoning, setDesignAgentReasoning] = useState(''); - function setDesignAgentTransientReplyTarget(next: string) { - designAgentTransientReplyTargetRef.current = next; - if (!next) { - designAgentVisibleReplyRef.current = ''; - setDesignAgentTransientReplyVisible(''); - } + function isCurrentDesignTurn(projectPath: string, clientTurnId: string) { + const tracked = designAgentTurnRef.current; + return ( + localProjectPathRef.current === projectPath && + tracked?.projectPath === projectPath && + tracked.clientTurnId === clientTurnId + ); + } + + function beginDesignTurn(projectPath: string, clientTurnId: string) { + designAgentTurnRef.current = { projectPath, clientTurnId }; + designAgentReasoningTurnRef.current = { projectPath, clientTurnId }; + designReplyAnimation.reset( + latestMessagesRef.current.flatMap((message) => + message.messageId ? [message.messageId] : [], + ), + ); + setDesignAgentStatus(''); + setDesignAgentReasoning(''); + setProjectChatError(''); + setChatAgentBusy(true); } function designAgentEventSubscriptionReady() { @@ -407,35 +416,12 @@ export function App({ designAgentEventSubscriptionResolveRef.current = null; } - useEffect(() => { - const timer = window.setInterval(() => { - const target = designAgentTransientReplyTargetRef.current; - setDesignAgentTransientReplyVisible((current) => { - if (!target) { - designAgentVisibleReplyRef.current = ''; - return ''; - } - const prefix = target.startsWith(current) ? current : ''; - if (prefix === target) { - designAgentVisibleReplyRef.current = target; - return target; - } - const remaining = target.length - prefix.length; - const step = remaining > 160 ? 4 : remaining > 48 ? 2 : 1; - const next = target.slice(0, prefix.length + step); - designAgentVisibleReplyRef.current = next; - return next; - }); - }, 50); - return () => window.clearInterval(timer); - }, []); - /** * 策划 Agent 的实时事件流:本轮流式正文、思考过程和回合中途的视图都靠它推给界面。 * * 订阅建立是异步的,而回合由一个 invoke 发起;`designAgentEventSubscriptionReady()` - * 让回合等监听器挂好再开始,避免开头几个事件丢掉。事件只认当前项目;有在跑的回合时 - * 还要认本轮 `clientTurnId`,迟到的上一轮事件不会画到这一轮上。 + * 让回合等监听器挂好再开始,避免开头几个事件丢掉。正文和视图严格匹配活动回合, + * reasoning 另按原回合接收迟到补充,不能让过期视图重播正文。 */ useEffect(() => { const ready = createDesignAgentEventSubscriptionReady(); @@ -452,19 +438,7 @@ export function App({ let disposed = false; void subscribeTauriEvent('design-agent-update', (event) => { const payload = event.payload; - const tracked = designAgentTurnRef.current; - if ( - payload.projectPath !== localProjectPathRef.current || - (tracked && payload.clientTurnId !== tracked.clientTurnId) - ) { - return; - } - if ( - (payload.kind === 'text' || payload.kind === 'tool') && - payload.text - ) { - setDesignAgentTransientReplyTarget(payload.text); - } + if (payload.projectPath !== localProjectPathRef.current) return; if (payload.reasoningText != null) { const reasoningTurn = designAgentReasoningTurnRef.current; if ( @@ -474,8 +448,21 @@ export function App({ setDesignAgentReasoning(payload.reasoningText); } } + if (!isCurrentDesignTurn(payload.projectPath, payload.clientTurnId)) + return; + if ( + payload.kind === 'text' && + payload.messageId && + payload.text != null + ) { + designReplyAnimation.receiveText(payload.messageId, payload.text); + if (payload.text) setDesignAgentStatus(''); + } + if (payload.kind === 'tool' && payload.text != null) { + setDesignAgentStatus(payload.text); + } if (payload.view) { - applyDesignAgentViewAfterTransient( + applyDesignAgentTurnView( payload.view, payload.projectPath, payload.clientTurnId, @@ -557,67 +544,18 @@ export function App({ latestMessagesRef.current = conversation; } - function commitDesignAgentView(view: DesignView, projectPath: string) { - const pendingTurnId = designAgentPendingViewRef.current?.clientTurnId; - designAgentPendingViewRef.current = null; - applyDesignView(view, projectPath); - setDesignAgentReasoning(''); - setDesignAgentTransientReplyTarget(''); - if (designAgentTurnRef.current?.clientTurnId === pendingTurnId) { - designAgentTurnRef.current = null; - } - } - - function applyDesignAgentViewAfterTransient( + function applyDesignAgentTurnView( view: DesignView, projectPath: string, clientTurnId: string, ) { - let target = designAgentTransientReplyTargetRef.current; - const tracked = designAgentTurnRef.current; - if (!target.trim() && !view.running) { - const latestAssistantText = [...view.messages] - .reverse() - .find((message) => message.role !== 'user' && message.text.trim()) - ?.text.trim(); - if (latestAssistantText) { - setDesignAgentTransientReplyTarget(latestAssistantText); - target = latestAssistantText; - } - } - if ( - !view.running && - tracked?.clientTurnId === clientTurnId && - target.trim() && - designAgentVisibleReplyRef.current !== target - ) { - designAgentPendingViewRef.current = { - clientTurnId, - projectPath, - view, - }; - return; - } - commitDesignAgentView(view, projectPath); + if (!isCurrentDesignTurn(projectPath, clientTurnId)) return; + designReplyAnimation.receiveView(view); + applyDesignView(view, projectPath); + setDesignAgentReasoning(''); + if (!view.running) setDesignAgentStatus(''); } - useEffect(() => { - const timer = window.setInterval(() => { - const pending = designAgentPendingViewRef.current; - if (!pending) { - return; - } - const target = designAgentTransientReplyTargetRef.current; - if (target && designAgentVisibleReplyRef.current !== target) { - return; - } - commitDesignAgentView(pending.view, pending.projectPath); - }, 50); - return () => window.clearInterval(timer); - // 收尾定时器只需注册一次;它读取 refs,避免随每次渲染重建。 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - async function hydrateDesignAgentSession(nextProjectPath: string) { const invoke = resolveTauriInvoke(); if (!invoke || !nextProjectPath.trim()) { @@ -647,32 +585,18 @@ export function App({ setProjectChatError('需要在 Tauri App 内运行。'); return; } - designAgentTurnRef.current = { - projectPath: nextProjectPath, - clientTurnId, - }; - designAgentReasoningTurnRef.current = { - projectPath: nextProjectPath, - clientTurnId, - }; - designAgentPendingViewRef.current = null; + beginDesignTurn(nextProjectPath, clientTurnId); await designAgentEventSubscriptionReady(); - setChatAgentBusy(true); - setProjectChatError(''); - setDesignAgentTransientReplyTarget(''); - setDesignAgentReasoning(''); + if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) return; try { const view = await invoke('continue_design_agent_session', { projectPath: nextProjectPath, clientTurnId, input, }); - if (localProjectPathRef.current !== nextProjectPath) { - return; - } - applyDesignAgentViewAfterTransient(view, nextProjectPath, clientTurnId); + applyDesignAgentTurnView(view, nextProjectPath, clientTurnId); } catch (error) { - if (localProjectPathRef.current !== nextProjectPath) { + if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) { return; } const message = error instanceof Error ? error.message : String(error); @@ -680,12 +604,13 @@ export function App({ requestRuntimeConfigOpen(); } setProjectChatError(message); + designReplyAnimation.discardUnpersisted(); } finally { - if (!designAgentPendingViewRef.current) { + if (isCurrentDesignTurn(nextProjectPath, clientTurnId)) { designAgentTurnRef.current = null; - setDesignAgentTransientReplyTarget(''); + setDesignAgentStatus(''); + setChatAgentBusy(false); } - setChatAgentBusy(false); } } @@ -876,7 +801,8 @@ export function App({ }, [ messages, projectChatError, - designAgentTransientReply, + designReplyAnimation.replies, + designAgentStatus, designAgentReasoning, designAgentView, pendingUiConfirmation, @@ -1369,8 +1295,8 @@ export function App({ setChatFilesImporting(false); setChatFileImportNotice(''); setProjectChatError(''); - setDesignAgentTransientReplyTarget(''); - designAgentPendingViewRef.current = null; + designReplyAnimation.reset(); + setDesignAgentStatus(''); setDesignAgentReasoning(''); setDesignAgentActive(planningStartMode); designAgentActiveRef.current = planningStartMode; @@ -2295,7 +2221,8 @@ export function App({ pendingConfirmation={pendingUiConfirmation} projectPath={localProject?.projectPath ?? projectPath} conversationMessages={messages} - transientReply={designAgentTransientReply} + replyAnimations={designReplyAnimation.replies} + designStatus={designAgentStatus} showDesignReasoning={designAgentActive} designReasoning={designAgentReasoning} designReasoningEntries={ @@ -2313,66 +2240,37 @@ export function App({ return; } const clientTurnId = createAgentChatRunId('design-agent-turn'); - designAgentTurnRef.current = { - projectPath: nextProjectPath, - clientTurnId, - }; - designAgentReasoningTurnRef.current = { - projectPath: nextProjectPath, - clientTurnId, - }; - designAgentPendingViewRef.current = null; - setDesignAgentTransientReplyTarget(''); - setDesignAgentReasoning(''); - setChatAgentBusy(true); + beginDesignTurn(nextProjectPath, clientTurnId); void designAgentEventSubscriptionReady() - .then(() => - invoke('decide_design_phase', { + .then(() => { + if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) + return null; + return invoke('decide_design_phase', { projectPath: nextProjectPath, clientTurnId, requestId, approved, - }), - ) + }); + }) .then((view) => { - if ( - localProjectPathRef.current !== nextProjectPath || - designAgentTurnRef.current?.projectPath !== - nextProjectPath || - designAgentTurnRef.current?.clientTurnId !== clientTurnId - ) { - return; - } - applyDesignAgentViewAfterTransient( + if (!view) return; + applyDesignAgentTurnView( view, nextProjectPath, clientTurnId, ); }) .catch((error) => { - if ( - localProjectPathRef.current !== nextProjectPath || - designAgentTurnRef.current?.projectPath !== - nextProjectPath || - designAgentTurnRef.current?.clientTurnId !== clientTurnId - ) { + if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) return; - } setProjectChatError(String(error)); + designReplyAnimation.discardUnpersisted(); }) .finally(() => { - if ( - localProjectPathRef.current !== nextProjectPath || - designAgentTurnRef.current?.projectPath !== - nextProjectPath || - designAgentTurnRef.current?.clientTurnId !== clientTurnId - ) { + if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) return; - } - if (!designAgentPendingViewRef.current) { - designAgentTurnRef.current = null; - setDesignAgentTransientReplyTarget(''); - } + designAgentTurnRef.current = null; + setDesignAgentStatus(''); setChatAgentBusy(false); }); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx index d0b7d74d4..645a8e957 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx @@ -31,6 +31,7 @@ import { DesignAgentPendingActions, DesignAgentPhaseStatus, } from './DesignAgentSurface'; +import type { DesignReplyAnimation } from './useDesignReplyAnimation'; /** 后台任务失败文案要过一遍运行态错误解释器再给用户看。 */ function planningMessageText(message: Pick) { @@ -80,7 +81,8 @@ type PlanningChatViewProps = { onSubmit: FormEventHandler; pendingConfirmation: PendingUiConfirmation | null; projectPath: string; - transientReply: string; + replyAnimations?: DesignReplyAnimation[]; + designStatus?: string; showDesignReasoning?: boolean; designReasoning?: string; designReasoningEntries?: DesignReasoningEntry[]; @@ -117,7 +119,8 @@ export function PlanningChatView({ onSubmit, pendingConfirmation, projectPath, - transientReply, + replyAnimations = [], + designStatus = '', showDesignReasoning = false, designReasoning = '', designReasoningEntries = [], @@ -169,34 +172,67 @@ export function PlanningChatView({ const handleDesignClarify = onDesignClarify ?? (() => undefined); const handleDesignRetry = onDesignRetry ?? (() => undefined); - const renderMessage = (message: ChatMessage, index: number) => ( -
- - - - {showDesignReasoning && message.reasoningText ? ( - - ) : null} - {message.role === 'user' && message.updatedAt ? ( - - ) : null} -
+ const animationsById = new Map( + replyAnimations.map((reply) => [reply.messageId, reply]), ); + const displayedMessages = [...visibleMessages]; + const conversationIds = new Set( + conversationMessages.map((message) => message.messageId), + ); + for (const reply of replyAnimations) { + if ( + !reply.persisted && + reply.target && + !conversationIds.has(reply.messageId) + ) { + displayedMessages.push({ + role: 'assistant', + messageId: reply.messageId, + text: reply.target, + }); + } + } + const renderMessage = (message: ChatMessage, index: number) => { + const animation = message.messageId + ? animationsById.get(message.messageId) + : undefined; + const streaming = Boolean( + animation && + (!animation.persisted || animation.visible !== animation.target), + ); + return ( +
+ + + + {showDesignReasoning && message.reasoningText ? ( + + ) : null} + {message.role === 'user' && message.updatedAt ? ( + + ) : null} +
+ ); + }; return (
@@ -251,7 +287,7 @@ export function PlanningChatView({ ) : null} - {visibleMessages.map(renderMessage)} + {displayedMessages.map(renderMessage)} {showDesignReasoning ? designReasoningEntries .filter((entry) => !entry.messageId) @@ -269,20 +305,9 @@ export function PlanningChatView({ label="策划 Agent 思考过程" /> ) : null} - {transientReply ? ( -
- - - + {designStatus ? ( +
+ {designStatus}
) : null}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/planning/useDesignReplyAnimation.ts b/apps/ai-game-creator-shell/src/view/project-development/planning/useDesignReplyAnimation.ts new file mode 100644 index 000000000..d8c6ef578 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/planning/useDesignReplyAnimation.ts @@ -0,0 +1,113 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { DesignView } from '../../../app/types'; + +export type DesignReplyAnimation = { + messageId: string; + target: string; + visible: string; + persisted: boolean; +}; + +/** 正文动画只管理显示进度,不延迟正式会话状态或参与请求收尾。 */ +export function useDesignReplyAnimation() { + const entriesRef = useRef([]); + const historyIdsRef = useRef(new Set()); + const [replies, setReplies] = useState([]); + const publish = useCallback((next: DesignReplyAnimation[]) => { + entriesRef.current = next; + setReplies(next); + }, []); + + const reset = useCallback( + (historyIds: string[] = []) => { + historyIdsRef.current = new Set(historyIds); + publish([]); + }, + [publish], + ); + + const receiveText = useCallback( + (messageId: string, target: string) => { + if (historyIdsRef.current.has(messageId)) return; + const previous = entriesRef.current.find( + (entry) => entry.messageId === messageId, + ); + // 空文本是同一 Provider 请求的 attempt 重置,不能清掉正式回复。 + if (previous?.persisted) return; + const next = { + messageId, + target, + visible: target.startsWith(previous?.visible ?? '') + ? (previous?.visible ?? '') + : '', + persisted: false, + }; + publish( + previous + ? entriesRef.current.map((entry) => + entry.messageId === messageId ? next : entry, + ) + : [...entriesRef.current, next], + ); + }, + [publish], + ); + + const receiveView = useCallback( + (view: DesignView) => { + let next = [...entriesRef.current]; + for (const message of view.messages) { + if ( + message.role !== 'assistant' || + historyIdsRef.current.has(message.id) + ) + continue; + const index = next.findIndex((entry) => entry.messageId === message.id); + const previous = next[index]; + const entry = { + messageId: message.id, + target: message.text, + visible: message.text.startsWith(previous?.visible ?? '') + ? (previous?.visible ?? '') + : '', + persisted: true, + }; + if (index < 0) next.push(entry); + else next[index] = entry; + } + if (!view.running) { + const persistedIds = new Set( + view.messages.map((message) => message.id), + ); + next = next.filter((entry) => persistedIds.has(entry.messageId)); + } + publish(next); + }, + [publish], + ); + + const discardUnpersisted = useCallback(() => { + publish(entriesRef.current.filter((entry) => entry.persisted)); + }, [publish]); + + useEffect(() => { + const timer = window.setInterval(() => { + let changed = false; + const next = entriesRef.current.map((entry) => { + const remaining = entry.target.length - entry.visible.length; + if (remaining <= 0) return entry; + changed = true; + const step = remaining > 160 ? 4 : remaining > 48 ? 2 : 1; + return { + ...entry, + visible: entry.target.slice(0, entry.visible.length + step), + }; + }); + if (changed) publish(next); + }, 50); + return () => window.clearInterval(timer); + }, [publish]); + + return { replies, reset, receiveText, receiveView, discardUnpersisted }; +} diff --git a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts index 79f1e49b7..71baf3f5b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts @@ -405,11 +405,159 @@ export function registerDesignAgentSurfaceTests() { ); }); + it('keeps one Design Agent reply when repeated completion arrives after typing catches up', async () => { + const harness = createProjectChatRuntimeHarness({ + designAgentView: designConversationView(), + }); + const originalInvoke = harness.invoke.getMockImplementation()!; + let finish!: (view: unknown) => void; + harness.invoke.mockImplementation((command, args) => + command === 'continue_design_agent_session' + ? new Promise((resolve) => { + finish = resolve; + }) + : originalInvoke(command, args), + ); + renderDesignAgent(harness); + const input = await screen.findByLabelText('项目需求'); + await expectDesignModelReady(); + await setComposerText(input, '只回复 pong'); + fireEvent.submit(input.closest('form') as HTMLFormElement); + await waitFor(() => expect(finish).toBeDefined()); + const call = harness.invoke.mock.calls.find( + ([command]) => command === 'continue_design_agent_session', + )!; + const clientTurnId = String( + (call[1] as { clientTurnId: string }).clientTurnId, + ); + const messageId = `${clientTurnId}:response:0`; + const view = { + ...designConversationView(), + messages: [{ id: messageId, role: 'assistant', text: 'pong' }], + }; + const emit = (payload: Record) => + harness.emitDesignAgentEvent({ + projectPath: harness.projectPath, + clientTurnId, + ...payload, + }); + act(() => emit({ kind: 'text', messageId, text: 'pong' })); + await screen.findByText('pong'); + act(() => emit({ kind: 'state', view })); + act(() => emit({ kind: 'state', view })); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + expect(screen.getAllByText('pong')).toHaveLength(1); + expect(screen.queryByLabelText('策划 Agent 实时回复')).toBeNull(); + await act(async () => finish(view)); + expect(screen.getAllByText('pong')).toHaveLength(1); + expect( + harness.invoke.mock.calls.filter( + ([command]) => command === 'continue_design_agent_session', + ), + ).toHaveLength(1); + }); + + for (const firstDelivery of ['event', 'command'] as const) { + it(`animates a whole Design Agent reply once when ${firstDelivery} completes first`, async () => { + const history = { id: 'history', role: 'assistant', text: '历史回复' }; + const harness = createProjectChatRuntimeHarness({ + designAgentView: { ...designConversationView(), messages: [history] }, + }); + const originalInvoke = harness.invoke.getMockImplementation()!; + const requests: { + clientTurnId: string; + finish: (view: unknown) => void; + }[] = []; + harness.invoke.mockImplementation((command, args) => + command === 'continue_design_agent_session' + ? new Promise((resolve) => { + requests.push({ + clientTurnId: String(args?.clientTurnId), + finish: resolve, + }); + }) + : originalInvoke(command, args), + ); + renderDesignAgent(harness); + await screen.findByText(history.text); + await expectDesignModelReady(); + const input = screen.getByLabelText('项目需求'); + await setComposerText(input, '继续'); + fireEvent.submit(input.closest('form')!); + await waitFor(() => expect(requests).toHaveLength(1)); + const request = requests[0]; + const messageId = `${request.clientTurnId}:response:0`; + const reply = '整块返回也逐步显示'; + const terminal = { + ...designConversationView(), + messages: [history, { id: messageId, role: 'assistant', text: reply }], + }; + const emit = (payload: Record) => + harness.emitDesignAgentEvent({ + projectPath: harness.projectPath, + clientTurnId: request.clientTurnId, + ...payload, + }); + if (firstDelivery === 'event') + act(() => emit({ kind: 'state', view: terminal })); + else await act(async () => request.finish(terminal)); + const bubble = screen.getByLabelText('策划 Agent 实时回复'); + expect(bubble.textContent).not.toBe(reply); + expect(screen.getAllByText(history.text)).toHaveLength(1); + expect( + screen.getByRole('button', { name: '发送' }).hasAttribute('disabled'), + ).toBe(false); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + const prefix = bubble.textContent; + expect(prefix?.length).toBeGreaterThan(0); + act(() => { + emit({ kind: 'state', view: terminal }); + emit({ kind: 'state', view: terminal }); + emit({ kind: 'text', messageId, text: '' }); + }); + expect(bubble.textContent).toBe(prefix); + await screen.findByText(reply); + expect(screen.getByText(reply).closest('.message')).toBe(bubble); + expect( + document.querySelectorAll(`[data-message-id="${messageId}"]`), + ).toHaveLength(1); + expect(screen.queryByLabelText('策划 Agent 实时回复')).toBeNull(); + + // 用户可在旧 invoke 尚未返回时开始下一轮;旧 finally 不能解锁新回合。 + await setComposerText(input, '下一轮'); + fireEvent.submit(input.closest('form')!); + await waitFor(() => expect(requests).toHaveLength(2)); + act(() => { + emit({ kind: 'text', messageId: 'stale', text: '迟到旧回复' }); + emit({ kind: 'state', view: terminal }); + }); + await act(async () => request.finish(terminal)); + expect( + screen.getByRole('button', { name: '思考中' }).hasAttribute('disabled'), + ).toBe(true); + expect(screen.queryByText('迟到旧回复')).toBeNull(); + await act(async () => requests[1].finish(terminal)); + }); + } + it('follows streamed Design Agent content until the user scrolls up', async () => { const harness = createProjectChatRuntimeHarness({ designAgentView: designConversationView(), designAgentContinueView: designConversationView(), }); + const originalInvoke = harness.invoke.getMockImplementation()!; + let finish!: () => void; + const gate = new Promise((resolve) => { + finish = resolve; + }); + harness.invoke.mockImplementation(async (command, args) => { + if (command === 'continue_design_agent_session') await gate; + return originalInvoke(command, args); + }); renderDesignAgent(harness); const input = await screen.findByLabelText('项目需求'); @@ -459,11 +607,26 @@ export function registerDesignAgentSurfaceTests() { projectPath: harness.projectPath, clientTurnId, kind: 'text', + messageId: `${clientTurnId}:response:0`, text: '正在补充关卡节奏', }), ); await screen.findByLabelText('策划 Agent 实时回复'); await waitFor(() => expect(messageList.scrollTop).toBe(1000)); + const replyBeforeTool = screen.getByLabelText('策划 Agent 实时回复'); + const prefixBeforeTool = replyBeforeTool.textContent; + act(() => + harness.emitDesignAgentEvent({ + projectPath: harness.projectPath, + clientTurnId, + kind: 'tool', + text: '正在读取方案文件', + }), + ); + expect(screen.getByLabelText('策划 Agent 工具状态').textContent).toBe( + '正在读取方案文件', + ); + expect(replyBeforeTool.textContent).toBe(prefixBeforeTool); messageList.scrollTop = 120; fireEvent.scroll(messageList); @@ -478,6 +641,7 @@ export function registerDesignAgentSurfaceTests() { projectPath: harness.projectPath, clientTurnId, kind: 'text', + messageId: `${clientTurnId}:response:0`, text: '正在补充关卡节奏与多人规则', }); }); @@ -488,6 +652,7 @@ export function registerDesignAgentSurfaceTests() { ).toContain('多人规则'), ); expect(messageList.scrollTop).toBe(120); + await act(async () => finish()); }); it('shows only known optimistic send times and does not invent persisted times', async () => { diff --git a/apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx b/apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx new file mode 100644 index 000000000..e51bb4fb3 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +import type { DesignView } from '../src/app/types'; +import { useDesignReplyAnimation } from '../src/view/project-development/planning/useDesignReplyAnimation'; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +function view(messages: DesignView['messages'], running = false): DesignView { + return { + session: { + sessionId: 's', + projectId: 'p', + currentPhase: 'concept', + approvedPhases: [], + pendingApproval: null, + pendingClarification: null, + turnIndex: 1, + lastError: null, + }, + messages, + running, + canRetry: false, + }; +} + +it('keeps each reply progress across tool snapshots and repeated completion', () => { + const { result } = renderHook(useDesignReplyAnimation); + const history = { id: 'history', role: 'assistant', text: '历史' }; + const first = { id: 't:response:0', role: 'assistant', text: '第一条回复' }; + const second = { id: 't:response:1', role: 'assistant', text: '第二条回复' }; + act(() => { + result.current.reset([history.id]); + result.current.receiveText(first.id, first.text); + vi.advanceTimersByTime(100); + }); + expect(result.current.replies[0].visible).toBe('第一'); + act(() => + result.current.receiveView( + view( + [history, first, { id: 'tool', role: 'tool', text: '工具已完成' }], + true, + ), + ), + ); + expect(result.current.replies).toHaveLength(1); + expect(result.current.replies[0].visible).toBe('第一'); + act(() => result.current.receiveText(second.id, second.text)); + const terminal = view([history, first, second]); + act(() => { + result.current.receiveView(terminal); + result.current.receiveView(terminal); + }); + expect(result.current.replies.map((reply) => reply.visible)).toEqual([ + '第一', + '', + ]); + act(() => vi.advanceTimersByTime(500)); + act(() => result.current.receiveView(terminal)); + expect(result.current.replies.map((reply) => reply.visible)).toEqual([ + first.text, + second.text, + ]); +}); + +it('resets only an unpersisted retry attempt and discards failed partial output', () => { + const { result } = renderHook(useDesignReplyAnimation); + act(() => { + result.current.receiveText('t:response:0', '尝试失败'); + vi.advanceTimersByTime(100); + }); + expect(result.current.replies[0].visible).toBe('尝试'); + act(() => result.current.receiveText('t:response:0', '')); + expect(result.current.replies[0].visible).toBe(''); + const saved = { id: 't:response:0', role: 'assistant', text: '成功' }; + act(() => { + result.current.receiveText(saved.id, saved.text); + result.current.receiveView(view([saved], true)); + vi.advanceTimersByTime(100); + result.current.receiveText(saved.id, ''); + result.current.receiveText('t:response:1', '未被接受的文本'); + result.current.receiveView(view([saved])); + }); + expect(result.current.replies).toEqual([ + { messageId: saved.id, target: '成功', visible: '成功', persisted: true }, + ]); + act(() => { + result.current.receiveText('t:response:2', '连接中断'); + result.current.discardUnpersisted(); + }); + expect(result.current.replies).toHaveLength(1); + act(() => result.current.reset([saved.id])); + expect(result.current.replies).toEqual([]); +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 56fa9611a..d9b3ca0f6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,9 @@ # 踩坑与排障记录 +## 策划回复的重复终态不能重新启动伪流式 + +策划 Runtime 会通过状态事件与命令返回交付同一份最终视图。若前端清空临时正文后再拿“最后一条非用户历史消息”回填动画,就会出现正式回复旁又播放一遍、播放后消失的假重试。正文应按 `messageId` 保存显示进度,与正式消息共用一个气泡;请求完成不清动画,不延迟正式业务状态。Provider 自动重试复用消息 ID 并发送空文本,只允许重置未持久化的该条回复。正文、工具状态和 reasoning 分开;事件与异步命令收尾均检查项目及活动回合,旧请求不能覆盖新回合。详见 [AGC 实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)。 + ## Direct 宿主继续请求不能重发原始用户条目 原始 `direct_user_item` 同时参与历史持久化和模型输入转换;验收或错误反馈更新了 prompt 后,如果发送层仍优先转换原始条目,模型会收到重复的用户输入,而本地历史按 itemId 去重后只显示一次。首次请求与宿主继续必须显式区分:首次保留结构化输入,继续发送当次反馈,原始条目只保留历史与事件关联职责。GUI、CLI 的两条循环都要覆盖;只改反馈文本或清空原始条目不完整。见 [Direct 宿主继续请求输入修复](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-09-23-direct-宿主继续请求输入修复)。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 25451c57d..ea2b95d76 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -255,6 +255,11 @@ Rust 分片日志在失败时输出有界 stdout 尾部中的失败段,保留 - 工作区状态条复用现有聊天状态条的字号、间距和状态点。长项目名允许换行,不能撑宽面板;长错误、澄清和批量文件导入结果在受限区域内完整可读,不能挤走输入框。输入编辑器保留原有高度上限,模型/推理菜单继续允许弹出面板。 - 已在消息底部时,实时正文与思考增高后继续跟随;用户主动上滚阅读历史后不自动抢回底部,发送新消息后恢复跟随。阶段/待处理卡片改变消息可用高度时,仍遵守同一跟随意图。滚动仅为临时 UI 状态,不写入正式会话。 - 历史消息只有收到真实发送时间才能显示时间。现有策划持久消息不含逐消息时间,读取或刷新时保持未知,不以当前时刻补造。当前会话刚提交的乐观消息可以显示已知发送时间;正式快照覆盖后不补造或猜配旧消息时间。 +- 策划正文保留伪流式,实时文本与正式消息按后端 `messageId` 共用一个显示位置;事件归属由项目与 `clientTurnId` 约束。同一消息的重复状态事件及命令返回只更新正文目标,不重置播放进度,不能从最后一条历史消息猜测本轮回复。没有流事件的整块新回复也逐步显示;已加载历史不重播。 +- 请求完成立即应用正式视图、释放业务忙碌状态,未完成的正文动画继续播放;动画完成只改变显示状态。一个回合内多条正文分别保留身份;工具状态独立显示,不能覆盖正文。真实 Provider 重试的空文本事件仅清空尚未持久化的对应消息;已经持久化的消息不受迟到文本重置。新回合开始时收起上轮动画并显示完整历史,切换项目清理临时显示;上轮迟到事件和命令返回不得污染新回合。 +- 终态以正式视图为准,丢弃没有进入正式消息的失败尝试文本,错误继续由既有错误区显示。正文呈现回归使用模拟原生事件与真实 React 组件,覆盖动画已追平/未追平时的重复终态、命令先返回、整块输出、工具间多条正文、重试清空、历史恢复和迟到回合。此项不改 Provider 重试、后端协议或持久数据,无迁移要求。 + +正文呈现验收由 `appSurface/design-agent.suite.ts` 与 `designReplyAnimation.test.tsx` 覆盖:整块返回仍播放、同 ID 气泡原位接管、重复终态与命令返回幂等、多正文与重试空串、工具提示不覆盖正文、上轮请求不能结束新回合。重复终态复现用例在修复前实现上失败;`appSurface.test.ts`、动画 hook 和会话恢复测试合计 217 项通过、9 项原有跳过。App typecheck(含配置检查)、定向 ESLint、编码和文档索引检查通过。本次使用模拟原生事件及 React/jsdom,不含真实 Provider 或安装包 GUI 演练。 - ≤760px 时资源区与对话区单列排列,策划工作台在固定外壳内纵向滚动,用户向下滚动可到达输入区。布局使用内容高度,并以同等或更高选择器优先级覆盖外壳的 `height: 100%`;资源区明确为 560px,对话区高度为 `clamp(560px, calc(100dvh - 154px), 900px)`。文件树与消息列表各自内部滚动,长内容不增加两块面板高度。>760px 继续共用外壳剩余高度,不启用工作台整体滚动。正式主窗最小宽度不变,窄屏验收覆盖浏览器响应式布局。 - 审批、澄清、导入期间禁用、错误重试、项目归属和发送权限沿用现有行为。此次调整不改变 Runtime、API、持久协议或数据库,无数据迁移;不重做开发 Agent 的对话布局。 From 2c778fb027d32cbc352072bfdf4fb9aa00336f42 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 23 Sep 2026 11:03:03 +0000 Subject: [PATCH 2/5] =?UTF-8?q?=E8=A1=A5=E5=85=A8=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E4=BD=9C=E7=94=A8=E5=9F=9F=E9=87=8D=E7=BD=AE=E6=97=B6=E7=9A=84?= =?UTF-8?q?=E5=BF=99=E7=A2=8C=E6=80=81=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重置聊天状态时同步清空 chatAgentBusy,保留旧请求的回合归属检查。 同步更新策划会话的作用域清理约定。 --- apps/ai-game-creator-shell/src/App.tsx | 1 + .../【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 8a9c7e24b..59635c242 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -1292,6 +1292,7 @@ export function App({ * 策划会话与设计 Agent 视图都属于上一个项目;项目身份一变就不能留到下一个项目里。 */ function resetChatState() { + setChatAgentBusy(false); setChatFilesImporting(false); setChatFileImportNotice(''); setProjectChatError(''); diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index ea2b95d76..3306d27ba 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -256,7 +256,7 @@ Rust 分片日志在失败时输出有界 stdout 尾部中的失败段,保留 - 已在消息底部时,实时正文与思考增高后继续跟随;用户主动上滚阅读历史后不自动抢回底部,发送新消息后恢复跟随。阶段/待处理卡片改变消息可用高度时,仍遵守同一跟随意图。滚动仅为临时 UI 状态,不写入正式会话。 - 历史消息只有收到真实发送时间才能显示时间。现有策划持久消息不含逐消息时间,读取或刷新时保持未知,不以当前时刻补造。当前会话刚提交的乐观消息可以显示已知发送时间;正式快照覆盖后不补造或猜配旧消息时间。 - 策划正文保留伪流式,实时文本与正式消息按后端 `messageId` 共用一个显示位置;事件归属由项目与 `clientTurnId` 约束。同一消息的重复状态事件及命令返回只更新正文目标,不重置播放进度,不能从最后一条历史消息猜测本轮回复。没有流事件的整块新回复也逐步显示;已加载历史不重播。 -- 请求完成立即应用正式视图、释放业务忙碌状态,未完成的正文动画继续播放;动画完成只改变显示状态。一个回合内多条正文分别保留身份;工具状态独立显示,不能覆盖正文。真实 Provider 重试的空文本事件仅清空尚未持久化的对应消息;已经持久化的消息不受迟到文本重置。新回合开始时收起上轮动画并显示完整历史,切换项目清理临时显示;上轮迟到事件和命令返回不得污染新回合。 +- 请求完成立即应用正式视图、释放业务忙碌状态,未完成的正文动画继续播放;动画完成只改变显示状态。一个回合内多条正文分别保留身份;工具状态独立显示,不能覆盖正文。真实 Provider 重试的空文本事件仅清空尚未持久化的对应消息;已经持久化的消息不受迟到文本重置。新回合开始时收起上轮动画并显示完整历史,切换项目清理临时显示;重置聊天作用域时同时清空活动回合与忙碌态,不依赖旧请求返回或新会话恢复来解锁输入。上轮迟到事件和命令返回不得污染新回合。 - 终态以正式视图为准,丢弃没有进入正式消息的失败尝试文本,错误继续由既有错误区显示。正文呈现回归使用模拟原生事件与真实 React 组件,覆盖动画已追平/未追平时的重复终态、命令先返回、整块输出、工具间多条正文、重试清空、历史恢复和迟到回合。此项不改 Provider 重试、后端协议或持久数据,无迁移要求。 正文呈现验收由 `appSurface/design-agent.suite.ts` 与 `designReplyAnimation.test.tsx` 覆盖:整块返回仍播放、同 ID 气泡原位接管、重复终态与命令返回幂等、多正文与重试空串、工具提示不覆盖正文、上轮请求不能结束新回合。重复终态复现用例在修复前实现上失败;`appSurface.test.ts`、动画 hook 和会话恢复测试合计 217 项通过、9 项原有跳过。App typecheck(含配置检查)、定向 ESLint、编码和文档索引检查通过。本次使用模拟原生事件及 React/jsdom,不含真实 Provider 或安装包 GUI 演练。 From c38d07044a57ffa71c22c75cb933a78382799ffa Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:04:24 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E6=89=93=E5=BC=80=E6=97=B6=E8=87=AA=E7=BB=98=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E6=A0=8F=E7=AA=97=E5=8F=A3=E6=8C=89=E9=92=AE=E5=A4=B1?= =?UTF-8?q?=E6=95=88=20-=20ThemedModal=20=E7=9A=84=E7=84=A6=E7=82=B9?= =?UTF-8?q?=E9=99=B7=E9=98=B1=E5=8F=AA=E6=94=BE=E8=A1=8C=E8=90=BD=E5=9C=A8?= =?UTF-8?q?=20[data-window-chrome-bar]=20=E5=86=85=E7=9A=84=E7=82=B9?= =?UTF-8?q?=E5=87=BB=EF=BC=8C=E6=A0=87=E9=A2=98=E6=A0=8F=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E4=B8=8E=E6=9C=80=E5=B0=8F=E5=8C=96/=E6=9C=80=E5=A4=A7?= =?UTF-8?q?=E5=8C=96/=E5=85=B3=E9=97=AD=E6=81=A2=E5=A4=8D=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=EF=BC=8C=E5=B7=A5=E4=BD=9C=E5=8C=BA=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E4=BB=8D=E8=A2=AB=E6=8B=A6=E4=BD=8F=20-=20Wi?= =?UTF-8?q?ndowChrome=20=E6=A0=87=E9=A2=98=E6=A0=8F=E5=8A=A0=20data-window?= =?UTF-8?q?-chrome-bar=20=E6=A0=87=E8=AE=B0=EF=BC=8C=E4=BD=9C=E4=B8=BA?= =?UTF-8?q?=E8=BF=99=E6=9D=A1=E7=BA=A6=E5=AE=9A=E7=9A=84=E5=94=AF=E4=B8=80?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E7=82=B9=20-=20styles.css=20=E6=98=8E?= =?UTF-8?q?=E7=A1=AE=E3=80=8C=E5=85=A8=E5=B1=8F=E5=BC=B9=E5=B1=82=E4=B8=80?= =?UTF-8?q?=E5=BE=8B=E4=BB=8E=E6=A0=87=E9=A2=98=E6=A0=8F=E4=B8=8B=E6=96=B9?= =?UTF-8?q?=E5=BC=80=E5=A7=8B=E3=80=8D=EF=BC=8C.app-update-overlay=20?= =?UTF-8?q?=E4=BB=8E=20inset:0=20=E6=94=B9=E4=B8=BA=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E6=A0=8F=E4=B8=8B=E6=96=B9=EF=BC=8C.game-publish-progress-over?= =?UTF-8?q?lay=20=E6=98=BE=E5=BC=8F=E5=A3=B0=E6=98=8E=20top=20-=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20tests/windowChromeOverlayContract.test.ts?= =?UTF-8?q?=20=E8=A6=86=E7=9B=96=207=20=E4=B8=AA=E5=85=A8=E5=B1=8F?= =?UTF-8?q?=E5=BC=B9=E5=B1=82=EF=BC=9BthemedModal=20/=20WindowChrome=20?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E8=A1=A5=E3=80=8C=E6=A0=87=E9=A2=98=E6=A0=8F?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E6=94=BE=E8=A1=8C=20+=20=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E7=82=B9=E5=87=BB=E4=BB=8D=E8=A2=AB=E6=8B=A6=E3=80=8D?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=EF=BC=9BgamePublishFeedback=20=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E6=8C=89=E6=96=B0=E5=8F=A3=E5=BE=84=E6=96=AD=E8=A8=80?= =?UTF-8?q?=20-=20pitfalls=20=E8=AE=B0=E5=BD=95=E8=AF=A5=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E5=A4=B1=E6=95=88=E7=9A=84=E6=9C=BA=E5=88=B6=E4=B8=8E=E7=8E=B0?= =?UTF-8?q?=E8=A1=8C=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/WindowChrome.tsx | 6 ++- .../src/components/modal/ThemedModal.tsx | 18 +++++++ apps/ai-game-creator-shell/src/styles.css | 20 +++++-- .../tests/WindowChrome.test.tsx | 54 ++++++++++++++++++- .../tests/gamePublishFeedback.test.tsx | 6 ++- .../tests/themedModal.test.tsx | 47 ++++++++++++++++ .../tests/windowChromeOverlayContract.test.ts | 53 ++++++++++++++++++ docs/project-memory/shared-memory/pitfalls.md | 8 +++ 8 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts diff --git a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx index e12c46cd8..19d87059d 100644 --- a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx +++ b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx @@ -149,7 +149,11 @@ export function WindowChrome({ children }: WindowChromeProps) {
{appUpdateCheckEnabled ? : null} -
+
panelRef.current!, returnFocusOnDeactivate: true, + allowOutsideClick: (event) => isWindowChromeBarTarget(event.target), }} >
({ + 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 +49,14 @@ function ActiveRunsSetter({ } describe('WindowChrome', () => { + beforeEach(() => { + nativeWindow.minimize.mockReset(); + nativeWindow.toggleMaximize.mockReset(); + nativeWindow.isMaximized.mockReset(); + nativeWindow.close.mockReset(); + delete (window as unknown as Record).__TAURI_INTERNALS__; + }); + it('renders the陶泥儿 brand, default title, and controls', async () => { const user = userEvent.setup(); render( @@ -141,4 +162,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).__TAURI_INTERNALS__ = {}; + + render( + + undefined} ariaLabel="测试弹窗"> + + + , + ); + 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); + }); }); diff --git a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx index 91b84b4a7..786c790a7 100644 --- a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx +++ b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx @@ -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%)'); diff --git a/apps/ai-game-creator-shell/tests/themedModal.test.tsx b/apps/ai-game-creator-shell/tests/themedModal.test.tsx index 5242722e0..3d0be715e 100644 --- a/apps/ai-game-creator-shell/tests/themedModal.test.tsx +++ b/apps/ai-game-creator-shell/tests/themedModal.test.tsx @@ -35,6 +35,34 @@ function ModalHarness({ noFocusableContent = false }) { ); } +/** + * 标题栏在模态之外,但它是窗口边框:弹窗打开时最小化 / 最大化 / 关闭必须照常可点。 + * 工作区内容反过来仍要被模态挡住,不能因为放行标题栏就一起漏过去。 + */ +function WindowChromeHarness({ + onMinimize, + onWorkspaceClick, +}: { + onMinimize: () => void; + onWorkspaceClick: () => void; +}) { + return ( + <> +
+ +
+ + undefined} ariaLabel="测试弹窗"> + + + + ); +} + 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( + , + ); + 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(); + }); }); diff --git a/apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts b/apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts new file mode 100644 index 000000000..b9bbb16d9 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts @@ -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(); + 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)'); + }); +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 56fa9611a..e2daabf13 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,13 @@ # 踩坑与排障记录 +## 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-宿主继续请求输入修复)。 From c2c5e1ced549ccb8f7e6a1961ea7ed4ccada506a Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:04:45 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E5=8F=91=E8=A1=8C=E5=8C=85=E4=B8=8A?= =?UTF-8?q?=E9=99=90=E6=8F=90=E5=8D=87=E5=88=B0=20200=20MiB=20=E5=B9=B6?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20AGC=20=E5=88=86=E7=89=87=E7=BB=AD=E4=BC=A0?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=20-=20=E5=8F=91=E8=A1=8C=E5=8C=85=E4=B8=8A?= =?UTF-8?q?=E9=99=90=20100=E2=86=92200=20MiB=E3=80=81=E5=B1=95=E5=BC=80?= =?UTF-8?q?=E6=80=BB=E9=87=8F=20250=E2=86=92500=20MiB=EF=BC=8C=E6=95=B4?= =?UTF-8?q?=E5=8C=85=E8=B7=AF=E7=94=B1=E8=AF=B7=E6=B1=82=E4=BD=93=E4=B8=8A?= =?UTF-8?q?=E9=99=90=E7=BB=A7=E7=BB=AD=E4=BB=8E=E5=8C=85=E4=B8=8A=E9=99=90?= =?UTF-8?q?=E6=B4=BE=E7=94=9F=EF=BC=9B=E5=8F=91=E8=A1=8C=E9=9D=99=E6=80=81?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E8=BF=9B=E7=A8=8B=E5=86=85=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E9=A2=84=E7=AE=97=E6=8F=90=E5=88=B0=20256=20MiB=20-=20?= =?UTF-8?q?=E5=8F=8D=E4=BB=A3=E6=94=BE=E8=A1=8C=E9=87=8F=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=94=BE=E5=AE=BD=E5=88=B0=20210=20MiB=EF=BC=9ANginx=20?= =?UTF-8?q?=E4=B8=89=E4=BB=BD=E6=A8=A1=E6=9D=BF=E7=9A=84=20client=5Fmax=5F?= =?UTF-8?q?body=5Fsize=E3=80=81Pingora=20=E7=BD=91=E5=85=B3=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=80=BC=E4=B8=8E=20env=20=E6=A0=B7=E4=BE=8B=E3=80=81?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E5=AF=B9=E7=85=A7=E7=9F=A9=E9=98=B5=20-=20pl?= =?UTF-8?q?atform-oss=20=E6=96=B0=E5=A2=9E=E5=86=85=E9=83=A8=E5=AF=B9?= =?UTF-8?q?=E8=B1=A1=E8=BF=BD=E5=8A=A0=E5=86=99=20append=5Finternal=5Fobje?= =?UTF-8?q?ct(=5Fwith=5Fretry)=EF=BC=8C=E4=BB=A5=20OSS=20=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E7=9A=84=20next-append-position=20=E4=BD=9C=E4=B8=BA?= =?UTF-8?q?=E6=9D=83=E5=A8=81=E5=B7=B2=E6=94=B6=E5=AD=97=E8=8A=82=20-=20ap?= =?UTF-8?q?i-server=20=E6=96=B0=E5=A2=9E=20upload-state=20/=20chunk=20/=20?= =?UTF-8?q?complete=20/=20reset=20=E5=9B=9B=E6=9D=A1=E5=88=86=E7=89=87?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=EF=BC=8C=E6=8A=BD=E5=87=BA=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=20confirm=5Fvalidated=5Fpackage=EF=BC=9B?= =?UTF-8?q?=E5=81=8F=E7=A7=BB=E4=B8=8D=E7=AC=A6=E8=BF=94=E5=9B=9E=20409=20?= =?UTF-8?q?=E4=B8=8E=E6=9D=83=E5=A8=81=E5=81=8F=E7=A7=BB=EF=BC=8C=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E5=A4=B1=E8=B4=A5=E5=88=A0=E9=99=A4=E5=8D=8A=E5=8C=85?= =?UTF-8?q?=E5=B9=B6=E8=90=BD=20upload=5Ffailed=20-=20AGC=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=8E=9F=E7=94=9F=E4=B8=8A=E4=BC=A0=E5=99=A8=20game?= =?UTF-8?q?=5Fpackage=5Fupload.rs=EF=BC=88=E5=86=85=E5=AE=B9=E5=AF=BB?= =?UTF-8?q?=E5=9D=80=E6=9A=82=E5=AD=98=E3=80=81=E5=88=86=E7=89=87=E7=BB=AD?= =?UTF-8?q?=E4=BC=A0=E3=80=81=E5=8F=97=E6=8E=A7=E9=87=8D=E8=AF=95=E3=80=81?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E4=BA=8B=E4=BB=B6=EF=BC=89=E4=B8=8E=20prepar?= =?UTF-8?q?e=20/=20upload=20=E4=B8=A4=E6=9D=A1=E5=91=BD=E4=BB=A4=EF=BC=8C?= =?UTF-8?q?=E9=80=80=E5=BD=B9=E6=95=B4=E5=8C=85=E5=9B=9E=E4=BC=A0=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=20-=20=E6=B8=B2=E6=9F=93=E8=BF=9B=E7=A8=8B=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=20prepare=20=E2=86=92=20=E5=88=9B=E5=BB=BA=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=20=E2=86=92=20=E5=88=9B=E5=BB=BA=E7=89=88=E6=9C=AC=20?= =?UTF-8?q?=E2=86=92=20=E5=8E=9F=E7=94=9F=E5=88=86=E7=89=87=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=20=E2=86=92=20=E9=80=81=E5=AE=A1=EF=BC=8CLocalProject?= =?UTF-8?q?ExportPackagePayload=20=E6=95=B4=E5=8C=85=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E9=80=80=E5=BD=B9=20-=20=E5=90=8C=E6=97=B6=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=20live=20=E7=94=A8=E4=BE=8B=E6=97=A0=E6=B3=95=E6=8C=87?= =?UTF-8?q?=E5=90=91=E6=9C=AC=E5=9C=B0=E6=A0=88=E7=9A=84=E4=B8=A4=E5=A4=84?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD=E9=97=AE=E9=A2=98=EF=BC=9A?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E5=9F=BA=E5=9D=80=E6=8C=89=E4=BC=A0=E5=85=A5?= =?UTF-8?q?=20URL=20=E9=80=89=E6=8B=A9=EF=BC=8C=E6=A1=A5=E6=8E=A5=E5=B1=82?= =?UTF-8?q?=E6=8A=8A=20jsdom=20realm=20=E7=9A=84=20Headers=20/=20Blob=20/?= =?UTF-8?q?=20FormData=20=E9=99=8D=E7=BA=A7=E6=88=90=20Node=20=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E5=80=BC=20-=20=E6=B5=8B=E8=AF=95=EF=BC=9Aplatform-os?= =?UTF-8?q?s=2074=E3=80=81api-server=20game=5Fdistribution=2023=E3=80=81AG?= =?UTF-8?q?C=20=E5=8E=9F=E7=94=9F=204=E3=80=81=E5=8F=91=E5=B8=83=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E5=89=8D=E7=AB=AF=2020=EF=BC=9Blive=20=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E8=A1=A5=E7=9C=9F=E5=AE=9E=E6=A0=88=E3=80=8C=E4=B8=AD?= =?UTF-8?q?=E6=96=AD=20=E2=86=92=20=E7=BB=AD=E4=BC=A0=20=E2=86=92=20?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E3=80=8D=E6=96=AD=E8=A8=80=EF=BC=88=E5=88=86?= =?UTF-8?q?=E7=89=87=E5=81=8F=E7=A7=BB=E5=BA=8F=E5=88=97=20[0,=208388608]?= =?UTF-8?q?=EF=BC=89=20-=20=E6=96=87=E6=A1=A3=EF=BC=9A=E7=8E=A9=E6=B3=95?= =?UTF-8?q?=E5=88=9B=E4=BD=9C=E4=B8=BB=E8=A7=84=E8=8C=83=E7=9A=84=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E5=90=88=E5=90=8C=E3=80=81=E8=BF=90=E7=BB=B4=E4=B8=8E?= =?UTF-8?q?=20Pingora=20=E6=96=87=E6=A1=A3=E3=80=81=E5=86=B3=E7=AD=96?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E3=80=81=E5=8F=91=E8=A1=8C=E9=87=8C=E7=A8=8B?= =?UTF-8?q?=E7=A2=91=E5=8F=A3=E5=BE=84=EF=BC=8C=E4=BB=A5=E5=8F=8A=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=9A=84=E7=BB=AD=E4=BC=A0=E9=87=8C=E7=A8=8B=E7=A2=91?= =?UTF-8?q?=E4=B8=8E=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src-tauri/src/commands.rs | 73 ++- .../src-tauri/src/game_package_upload.rs | 533 +++++++++++++++++ .../src-tauri/src/main.rs | 4 +- apps/ai-game-creator-shell/src/app/types.ts | 17 +- .../src/services/gameDistributionPublish.ts | 60 +- .../tests/gameDistributionPublish.test.ts | 78 +-- .../tests/gameDistributionPublishLive.test.ts | 296 +++++++++- deploy/container/nginx.conf | 5 +- deploy/nginx/README.md | 4 +- deploy/nginx/genarrative-dev-http.conf | 5 +- deploy/nginx/genarrative.conf | 5 +- deploy/pingora/nginx-route-parity.matrix.json | 4 +- deploy/pingora/pingora-gateway.env.example | 2 +- ...ž施计划】AGC发行包分片续传上传-2026-09-23.md | 49 ++ ...里程碑】AGC发行包分片续传上传-2026-09-23.md | 58 ++ ...‹碑】游戏分发目录详情与在线游玩-2026-09-18.md | 2 +- .../shared-memory/decision-log.md | 15 + ...开发运维】Pingora独立网关试点-2026-06-11.md | 2 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- ...�玩法创作】平台入口与玩法链路-2026-05-15.md | 7 +- .../src/modules/game_distribution.rs | 536 ++++++++++++++++-- .../module-game-distribution/src/package.rs | 49 +- server-rs/crates/pingora-gateway/src/main.rs | 4 +- server-rs/crates/platform-oss/src/lib.rs | 124 +++- .../game-distribution/gameZipPackage.test.ts | 10 +- .../game-distribution/gameZipPackage.ts | 4 +- 26 files changed, 1779 insertions(+), 169 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs create mode 100644 docs/project-memory/plans/【实施计划】AGC发行包分片续传上传-2026-09-23.md create mode 100644 docs/project-memory/plans/【里程碑】AGC发行包分片续传上传-2026-09-23.md diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index a18bdb369..4dbb39ad7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -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 { +) -> Result { 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 { + 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 { + 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] diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs new file mode 100644 index 000000000..c52745b45 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs @@ -0,0 +1,533 @@ +//! AGC 发行包的分片续传上传。 +//! +//! 一键发布不再把整包字节交给 WebView:这里在原生进程中读取本地(或暂存)的试玩包, +//! 按服务端下发的分片大小顺序发送,并以服务端返回的**权威偏移**续传;进度通过事件 +//! 回传渲染进程。传输失败、应用重启后重新发布都只补传缺失字节,不重放整包。 + +use std::{ + fs::{self, File}, + io::{Read, Seek, SeekFrom, Write}, + path::{Path, PathBuf}, + time::Duration, +}; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +pub(crate) const GAME_PACKAGE_UPLOAD_PROGRESS_EVENT: &str = "game-package-upload-progress"; + +const AGC_CLIENT_MARKER_HEADER: &str = "X-Genarrative-Client"; +const AGC_CLIENT_MARKER_VALUE: &str = "agc"; +const UPLOAD_OFFSET_HEADER: &str = "x-genarrative-upload-offset"; +/// 服务端下发的分片大小上限;客户端只按服务端给的值发,超过它必然被拒。 +const MAX_CHUNK_BYTES: u64 = 8 * 1024 * 1024; +const CHUNK_MAX_ATTEMPTS: usize = 4; +const CHUNK_RETRY_DELAY: Duration = Duration::from_millis(500); +const UPLOAD_STAGING_DIR_NAME: &str = "game-package-staging"; + +/// 已暂存(归一化后)的发行包:内容寻址,重启后同一包复用同一个文件,续传才有意义。 +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StagedGamePackage { + pub(crate) staging_path: String, + pub(crate) package_sha256: String, + pub(crate) package_size_bytes: u64, + pub(crate) package_file_count: u32, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GamePackageUploadOutcome { + pub(crate) version_id: String, + pub(crate) status: String, + pub(crate) uploaded_bytes: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ChunkPlan { + offset: u64, + length: u64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct PackageUploadState { + #[serde(default)] + received_bytes: u64, + #[serde(default)] + chunk_bytes: u64, + #[serde(default)] + declared_package_bytes: u64, +} + +/// 暂存目录:放在应用数据目录下,不写进项目、也不参与项目快照同步。 +pub(crate) fn game_package_upload_staging_dir(app_data_dir: &Path) -> PathBuf { + app_data_dir.join(UPLOAD_STAGING_DIR_NAME) +} + +/// 把归一化后的发行包暂存到内容寻址文件,返回续传所需的元数据。 +pub(crate) fn stage_game_package_bytes( + staging_dir: &Path, + package_sha256: &str, + package_bytes: &[u8], +) -> Result { + if package_bytes.is_empty() { + return Err("发行包内容为空,请重新导出试玩包".to_string()); + } + if package_sha256.len() != 64 || !package_sha256.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("发行包摘要无效,请重新导出试玩包".to_string()); + } + fs::create_dir_all(staging_dir).map_err(|error| format!("创建发行包暂存目录失败:{error}"))?; + let staging_path = staging_dir.join(format!("{package_sha256}.zip")); + if !staging_path.is_file() { + let temporary_path = staging_dir.join(format!("{package_sha256}.zip.tmp")); + let mut file = File::create(&temporary_path) + .map_err(|error| format!("写入发行包暂存文件失败:{error}"))?; + file.write_all(package_bytes) + .map_err(|error| format!("写入发行包暂存文件失败:{error}"))?; + file.sync_all() + .map_err(|error| format!("刷写发行包暂存文件失败:{error}"))?; + drop(file); + fs::rename(&temporary_path, &staging_path) + .map_err(|error| format!("提交发行包暂存文件失败:{error}"))?; + } + Ok(StagedGamePackage { + staging_path: staging_path.to_string_lossy().to_string(), + package_sha256: package_sha256.to_string(), + package_size_bytes: package_bytes.len() as u64, + package_file_count: 0, + }) +} + +/// 暂存文件必须落在暂存目录内;渲染进程不能借这条命令读任意路径。 +pub(crate) fn ensure_staging_path_in_dir( + staging_dir: &Path, + staging_path: &str, +) -> Result { + let candidate = PathBuf::from(staging_path.trim()); + let parent = candidate + .parent() + .ok_or_else(|| "发行包暂存路径无效".to_string())?; + let canonical_parent = parent + .canonicalize() + .map_err(|error| format!("发行包暂存目录不可用:{error}"))?; + let canonical_dir = staging_dir + .canonicalize() + .map_err(|error| format!("发行包暂存目录不可用:{error}"))?; + if canonical_parent != canonical_dir { + return Err("发行包暂存路径越界".to_string()); + } + if !candidate.is_file() { + return Err("发行包暂存文件不存在,请重新导出试玩包".to_string()); + } + Ok(candidate) +} + +/// 下一次要发送的分片;`None` 表示整包已收齐。 +fn next_chunk_plan(received: u64, total: u64, chunk_bytes: u64) -> Option { + if received >= total || chunk_bytes == 0 { + return None; + } + Some(ChunkPlan { + offset: received, + length: chunk_bytes.min(total - received), + }) +} + +fn read_chunk(file: &mut File, plan: ChunkPlan) -> Result, String> { + file.seek(SeekFrom::Start(plan.offset)) + .map_err(|error| format!("读取发行包失败:{error}"))?; + let mut buffer = vec![0_u8; usize::try_from(plan.length).unwrap_or(0)]; + file.read_exact(&mut buffer) + .map_err(|error| format!("读取发行包失败:{error}"))?; + Ok(buffer) +} + +fn upload_url(base_url: &str, version_id: &str, suffix: &str) -> String { + format!( + "{}/api/game-distribution/versions/{version_id}/package{suffix}", + base_url.trim_end_matches('/') + ) +} + +/// 服务端错误信封:兼容带 envelope 的 `{ error: { code, message } }` 与旧形状。 +fn parse_server_error(status: u16, body: &str) -> (Option, Option) { + let parsed = serde_json::from_str::(body).ok(); + let error = parsed + .as_ref() + .and_then(|value| value.get("error")) + .cloned(); + let code = error + .as_ref() + .and_then(|value| value.get("code")) + .or_else(|| parsed.as_ref().and_then(|value| value.get("code"))) + .and_then(Value::as_str) + .map(str::to_string); + let message = error + .as_ref() + .and_then(|value| value.get("message")) + .or_else(|| parsed.as_ref().and_then(|value| value.get("message"))) + .and_then(Value::as_str) + .map(str::to_string); + let _ = status; + (code, message) +} + +/// 从 409 响应里取权威已收字节;取不到就返回 `None`,由调用方按失败处理。 +fn parse_received_bytes(body: &str) -> Option { + let parsed = serde_json::from_str::(body).ok()?; + let error = parsed.get("error").unwrap_or(&parsed); + error + .get("details") + .and_then(|details| details.get("receivedBytes")) + .or_else(|| error.get("receivedBytes")) + .and_then(Value::as_u64) +} + +fn platform_request( + client: &reqwest::Client, + access_token: &str, + method: reqwest::Method, + url: &str, +) -> reqwest::RequestBuilder { + client + .request(method, url) + .header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {access_token}"), + ) +} + +async fn read_upload_state( + client: &reqwest::Client, + base_url: &str, + version_id: &str, + access_token: &str, +) -> Result { + let response = platform_request( + client, + access_token, + reqwest::Method::GET, + &upload_url(base_url, version_id, "/upload-state"), + ) + .send() + .await + .map_err(|error| format!("无法连接登录服务,请确认配套后端或 API 代理已启动后重试:{error}"))?; + let status = response.status().as_u16(); + let body = response + .text() + .await + .map_err(|error| format!("读取上传状态失败:{error}"))?; + if status >= 400 { + let (_, message) = parse_server_error(status, &body); + return Err(message.unwrap_or_else(|| format!("读取上传状态失败(HTTP {status})"))); + } + let parsed: Value = serde_json::from_str(&body) + .map_err(|error| format!("上传状态响应不是合法 JSON:{error}"))?; + let payload = parsed.get("data").unwrap_or(&parsed); + serde_json::from_value(payload.clone()) + .map_err(|error| format!("上传状态响应缺少字段:{error}")) +} + +/// 上传一个分片;返回服务端确认后的已收字节。 +async fn upload_chunk( + client: &reqwest::Client, + base_url: &str, + version_id: &str, + access_token: &str, + idempotency_key: &str, + plan: ChunkPlan, + body: Vec, +) -> Result { + let response = platform_request( + client, + access_token, + reqwest::Method::PUT, + &upload_url(base_url, version_id, "/chunk"), + ) + .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") + .header(UPLOAD_OFFSET_HEADER, plan.offset.to_string()) + .header("Idempotency-Key", format!("{idempotency_key}:chunk")) + .body(body) + .send() + .await + .map_err(|error| ChunkUploadError::Retryable(format!("发行包分片发送失败:{error}")))?; + let status = response.status().as_u16(); + let text = response + .text() + .await + .map_err(|error| ChunkUploadError::Retryable(format!("读取分片响应失败:{error}")))?; + if status == 409 { + let (code, message) = parse_server_error(status, &text); + if code.as_deref() == Some("PACKAGE_UPLOAD_OFFSET_MISMATCH") { + let received = parse_received_bytes(&text).ok_or_else(|| { + ChunkUploadError::Fatal("分片偏移不一致,但服务端未返回权威偏移".to_string()) + })?; + return Ok(received); + } + return Err(ChunkUploadError::Fatal( + message.unwrap_or_else(|| "发行包分片被拒绝".to_string()), + )); + } + if status >= 500 || status == 408 || status == 429 { + let (_, message) = parse_server_error(status, &text); + return Err(ChunkUploadError::Retryable( + message.unwrap_or_else(|| format!("发行包分片上传失败(HTTP {status})")), + )); + } + if status >= 400 { + let (_, message) = parse_server_error(status, &text); + return Err(ChunkUploadError::Fatal( + message.unwrap_or_else(|| format!("发行包分片被拒绝(HTTP {status})")), + )); + } + let parsed: Value = serde_json::from_str(&text) + .map_err(|error| ChunkUploadError::Retryable(format!("分片响应不是合法 JSON:{error}")))?; + let payload = parsed.get("data").unwrap_or(&parsed); + payload + .get("receivedBytes") + .and_then(Value::as_u64) + .ok_or_else(|| ChunkUploadError::Fatal("分片响应缺少 receivedBytes".to_string())) +} + +enum ChunkUploadError { + Retryable(String), + Fatal(String), +} + +async fn complete_upload( + client: &reqwest::Client, + base_url: &str, + version_id: &str, + access_token: &str, + idempotency_key: &str, +) -> Result { + let response = platform_request( + client, + access_token, + reqwest::Method::POST, + &upload_url(base_url, version_id, "/complete"), + ) + .header("Idempotency-Key", format!("{idempotency_key}:complete")) + .send() + .await + .map_err(|error| format!("完成发行包上传失败:{error}"))?; + let status = response.status().as_u16(); + let text = response + .text() + .await + .map_err(|error| format!("读取完成响应失败:{error}"))?; + if status >= 400 { + let (_, message) = parse_server_error(status, &text); + return Err(message.unwrap_or_else(|| format!("完成发行包上传失败(HTTP {status})"))); + } + let parsed: Value = + serde_json::from_str(&text).map_err(|error| format!("完成响应不是合法 JSON:{error}"))?; + let payload = parsed.get("data").unwrap_or(&parsed); + Ok(GamePackageUploadOutcome { + version_id: payload + .get("versionId") + .and_then(Value::as_str) + .unwrap_or(version_id) + .to_string(), + status: payload + .get("status") + .and_then(Value::as_str) + .unwrap_or("uploaded") + .to_string(), + uploaded_bytes: 0, + }) +} + +pub(crate) struct GamePackageUploadRequest<'a> { + pub(crate) staging_path: &'a Path, + pub(crate) version_id: &'a str, + pub(crate) api_base_url: &'a str, + pub(crate) access_token: &'a str, + pub(crate) idempotency_key: &'a str, +} + +/// 分片续传主循环:权威偏移来自服务端,失败按可重试分类退避,偏移不符立即按权威偏移继续。 +pub(crate) async fn upload_staged_game_package( + client: &reqwest::Client, + request: GamePackageUploadRequest<'_>, + mut on_progress: impl FnMut(u64, u64), +) -> Result { + let total_bytes = fs::metadata(request.staging_path) + .map_err(|error| format!("读取发行包暂存文件失败:{error}"))? + .len(); + if total_bytes == 0 { + return Err("发行包暂存文件为空,请重新导出试玩包".to_string()); + } + let state = read_upload_state( + client, + request.api_base_url, + request.version_id, + request.access_token, + ) + .await?; + if state.declared_package_bytes != 0 && state.declared_package_bytes != total_bytes { + return Err(format!( + "本地发行包与版本声明的体积不一致(本地 {} 字节,声明 {} 字节),请重新导出后再发布", + total_bytes, state.declared_package_bytes + )); + } + let chunk_bytes = if state.chunk_bytes == 0 { + MAX_CHUNK_BYTES + } else { + state.chunk_bytes.min(MAX_CHUNK_BYTES) + }; + let mut received = state.received_bytes.min(total_bytes); + on_progress(received, total_bytes); + let mut file = File::open(request.staging_path) + .map_err(|error| format!("打开发行包暂存文件失败:{error}"))?; + while let Some(plan) = next_chunk_plan(received, total_bytes, chunk_bytes) { + let body = read_chunk(&mut file, plan)?; + let mut attempt = 1_usize; + loop { + match upload_chunk( + client, + request.api_base_url, + request.version_id, + request.access_token, + request.idempotency_key, + plan, + body.clone(), + ) + .await + { + Ok(next_received) => { + received = next_received.min(total_bytes); + on_progress(received, total_bytes); + break; + } + Err(ChunkUploadError::Fatal(error)) => return Err(error), + Err(ChunkUploadError::Retryable(error)) => { + if attempt >= CHUNK_MAX_ATTEMPTS { + return Err(format!("{error}(已尝试 {attempt} 次,可重新发布续传)")); + } + attempt += 1; + tokio::time::sleep(CHUNK_RETRY_DELAY).await; + } + } + } + // 权威偏移可能在重试期间前进(例如响应丢失后服务端已写入),按服务端口径对齐。 + let authoritative = read_upload_state( + client, + request.api_base_url, + request.version_id, + request.access_token, + ) + .await?; + received = authoritative.received_bytes.min(total_bytes); + on_progress(received, total_bytes); + } + let mut outcome = complete_upload( + client, + request.api_base_url, + request.version_id, + request.access_token, + request.idempotency_key, + ) + .await?; + outcome.uploaded_bytes = total_bytes; + Ok(outcome) +} + +/// 进度事件的载荷形状(渲染进程按它显示进度)。 +pub(crate) fn progress_event_payload( + version_id: &str, + received_bytes: u64, + total_bytes: u64, +) -> Value { + json!({ + "versionId": version_id, + "receivedBytes": received_bytes, + "totalBytes": total_bytes, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chunk_plan_covers_whole_package_without_gaps_or_overlap() { + let total = 20 * 1024 * 1024 + 3; + let chunk = 8 * 1024 * 1024; + let mut received = 0_u64; + let mut plans = Vec::new(); + while let Some(plan) = next_chunk_plan(received, total, chunk) { + plans.push(plan); + received += plan.length; + } + assert_eq!(received, total); + assert_eq!(plans.len(), 3); + assert_eq!(plans[0].offset, 0); + assert_eq!(plans[1].offset, chunk); + assert_eq!(plans[2].length, 4 * 1024 * 1024 + 3); + assert!(next_chunk_plan(total, total, chunk).is_none()); + assert!(next_chunk_plan(0, total, 0).is_none()); + } + + #[test] + fn offset_mismatch_response_yields_authoritative_position() { + let body = r#"{"error":{"code":"PACKAGE_UPLOAD_OFFSET_MISMATCH","message":"偏移不一致","details":{"provider":"game-distribution","receivedBytes":16777216}}}"#; + assert_eq!(parse_received_bytes(body), Some(16_777_216)); + let (code, message) = parse_server_error(409, body); + assert_eq!(code.as_deref(), Some("PACKAGE_UPLOAD_OFFSET_MISMATCH")); + assert_eq!(message.as_deref(), Some("偏移不一致")); + + let legacy = + r#"{"error":{"code":"PACKAGE_UPLOAD_INCOMPLETE","message":"未收齐"},"meta":{}}"#; + assert_eq!(parse_received_bytes(legacy), None); + assert_eq!( + parse_server_error(409, legacy).0.as_deref(), + Some("PACKAGE_UPLOAD_INCOMPLETE") + ); + } + + #[test] + fn upload_url_joins_base_without_double_slash() { + assert_eq!( + upload_url("https://dev.genarrative.world/", "ver-1", "/chunk"), + "https://dev.genarrative.world/api/game-distribution/versions/ver-1/package/chunk" + ); + assert_eq!( + upload_url("http://127.0.0.1:10001", "ver 1", "/complete"), + "http://127.0.0.1:10001/api/game-distribution/versions/ver 1/package/complete" + ); + } + + #[test] + fn staging_file_is_content_addressed_and_reused() { + let dir = std::env::temp_dir().join(format!( + "agc-game-package-staging-test-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + let sha = "a".repeat(64); + let first = stage_game_package_bytes(&dir, &sha, b"package-bytes").expect("首次暂存应成功"); + let second = + stage_game_package_bytes(&dir, &sha, b"package-bytes").expect("重复暂存应复用"); + assert_eq!(first.staging_path, second.staging_path); + assert_eq!(first.package_size_bytes, 13); + assert!(Path::new(&first.staging_path).is_file()); + assert_eq!( + fs::read(&first.staging_path).expect("读取暂存文件"), + b"package-bytes" + ); + + let bad = stage_game_package_bytes(&dir, "not-a-sha", b"x"); + assert!(bad.is_err()); + let empty = stage_game_package_bytes(&dir, &sha, b""); + assert!(empty.is_err()); + + let outside = ensure_staging_path_in_dir(&dir, "/etc/passwd"); + assert!(outside.is_err()); + let inside = ensure_staging_path_in_dir(&dir, &first.staging_path); + assert!(inside.is_ok()); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 256c38c3d..3b356fcd6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index d1a93d69f..73f45325f 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -796,12 +796,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 { diff --git a/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts b/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts index 5e1a96402..993ccd754 100644 --- a/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts +++ b/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts @@ -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( - 'read_local_project_export_package', + // 整包字节只留在原生进程:这里拿到的是归一化后的摘要与内容寻址暂存路径, + // 上传由原生侧按服务端分片大小完成,中断后同一暂存文件可直接续传。 + const staged = await args.invoke( + '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( + '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, }; } diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts b/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts index 9110172ea..e4aa8e332 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts @@ -14,6 +14,12 @@ vi.mock('../src/services/errorReporting', () => ({ captureClientError: vi.fn(), })); +// 原生侧上传需要登录凭据;这里只钉住「取到了 token」这一件事。 +vi.mock('../src/services/clientApi', async (importOriginal) => ({ + ...(await importOriginal()), + 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) => { + 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, diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts b/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts index 552b6c26c..b79228dd1 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts @@ -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 { const maybeArrayBuffer = ( blob as Blob & { arrayBuffer?: () => Promise } @@ -120,7 +183,10 @@ async function registerAuthor(): Promise { return data.token; } -async function buildExportPayload(): Promise { +async function buildStagedPackage(): Promise<{ + staged: StagedGamePackage; + bytes: Uint8Array; +}> { const zip = new JSZip(); const indexHtml = 'AGC Live' + @@ -129,28 +195,169 @@ async function buildExportPayload(): Promise { '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 { + return await unwrap( + 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 { + 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 { + 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) => { + 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 }; diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf index 9e34fbaca..6d3284ce0 100644 --- a/deploy/container/nginx.conf +++ b/deploy/container/nginx.conf @@ -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; diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md index c3c4bff34..6d2b2dffe 100644 --- a/deploy/nginx/README.md +++ b/deploy/nginx/README.md @@ -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 diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index f210766ab..640ef088c 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -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; diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index 87bf769e3..b7d1c433a 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -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; diff --git a/deploy/pingora/nginx-route-parity.matrix.json b/deploy/pingora/nginx-route-parity.matrix.json index 59eaaa850..f1c90ee98 100644 --- a/deploy/pingora/nginx-route-parity.matrix.json +++ b/deploy/pingora/nginx-route-parity.matrix.json @@ -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;" diff --git a/deploy/pingora/pingora-gateway.env.example b/deploy/pingora/pingora-gateway.env.example index 467d559d2..d85eb293a 100644 --- a/deploy/pingora/pingora-gateway.env.example +++ b/deploy/pingora/pingora-gateway.env.example @@ -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 diff --git a/docs/project-memory/plans/【实施计划】AGC发行包分片续传上传-2026-09-23.md b/docs/project-memory/plans/【实施计划】AGC发行包分片续传上传-2026-09-23.md new file mode 100644 index 000000000..21aa5c760 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC发行包分片续传上传-2026-09-23.md @@ -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`:内容寻址暂存(`/game-package-staging/.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` 同量级;分片路径不再让整包驻留客户端。 diff --git a/docs/project-memory/plans/【里程碑】AGC发行包分片续传上传-2026-09-23.md b/docs/project-memory/plans/【里程碑】AGC发行包分片续传上传-2026-09-23.md new file mode 100644 index 000000000..a881e7e88 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC发行包分片续传上传-2026-09-23.md @@ -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. **半包回收**:本里程碑只标记未完成会话,不做定时清理;回收另立里程碑(涉及「不得删除仍被公开版本引用的对象」口径)。 diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md index 226855815..e55f03b43 100644 --- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md @@ -120,7 +120,7 @@ ### 行为与验收 - [ ] 真实环境中完整跑通“首次上传 → 校验 → 审核 → 公开 → 游客游玩 → 更新待审旧版在线 → 新版切换 → 下架撤销”。 -- [ ] 100 MiB 包与获批文件数/展开量边界有可复核耗时、内存和失败证据;校验不会执行上传代码,服务资源有界。 +- [ ] 200 MiB 包(现行上限,见 2026-09-23 决策记录)与获批文件数/展开量边界有可复核耗时、内存和失败证据;校验不会执行上传代码,服务资源有界。已有证据覆盖 100 MiB 档,上限提升后的档位待复跑。 - [ ] 校验执行器重启可恢复,审核积压与失败可观测,清理不删除仍被公开版本引用的文件。 - [ ] CDN purge 失败时仍在获批缓存 TTL 内拒绝新资源;明确已下载脚本无法远程抹除的边界。 - [ ] 发布/回滚步骤保留当前公开版本,能关闭新提交和新版本激活;部署路由、缓存、响应头、日志脱敏和告警完成检查。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 265ec8690..a11c2b6ff 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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` 内部也算一次合法命中。 diff --git a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md index 74b666676..d5d5e3f34 100644 --- a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md +++ b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md @@ -488,7 +488,7 @@ dev 根盘空间在安装后曾接近满盘;2026-06-17 进入 canary 前已清 | `GENARRATIVE_PINGORA_GATEWAY_ACME_ROOT` | `/var/www/html` | ACME challenge 静态目录。 | | `GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_FILE` | `/var/lib/genarrative/maintenance/enabled` | 存在即进入维护模式。 | | `GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO` | `http` | 写入 `X-Forwarded-Proto` 的值。 | -| `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES` | `67108864` | `/api` 通用路由的 `Content-Length` 上限。 | +| `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES` | `220200960` | `/api` 通用路由的 `Content-Length` 上限(210 MiB,覆盖游戏发行包 PUT 的 200 MiB + 1 KiB 路由上限)。 | | `GENARRATIVE_PINGORA_GATEWAY_COMPRESSION_ALGORITHMS` | `gzip` | 当前唯一允许的压缩算法白名单;Pingora 正式化口径固定为 gzip-only,Brotli 继续由 Nginx / 前置代理承担。 | | `GENARRATIVE_PINGORA_GATEWAY_GZIP_ENABLED` | `true` | 是否启用 gzip 响应压缩。 | | `GENARRATIVE_PINGORA_GATEWAY_GZIP_LEVEL` | `5` | gzip 压缩等级,必须在 `0..=9`。 | diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 8fcaf11f0..ee9e75881 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -893,7 +893,7 @@ worker 被硬杀或断电后,lease 过期任务只有尚未耗尽 `max_attempt - Server provision 不再通过 Windows helper 下载,也不再通过 Linux build 节点中转 SpacetimeDB / otelcol 工具包;Linux build 节点只负责从内网 Git 源准备 provision 脚本和配置并上传给目标 agent。`Prepare Provision Tools` 在目标 dev / release agent 工作区内先检查 `/usr/local/bin/otelcol-contrib` 与 `${SPACETIME_ROOT}/bin/current`:SpacetimeDB 必须同时匹配运行版本 `2.8.3` 和 commit `8e410d28...` 才能复用;只有缺失或版本 / commit 不匹配时才使用 `PROVISION_DOWNLOADS_DIR` 里的本地包或从配置的下载源准备官方 `v2.8.3` 资产。`SPACETIME_EXPECTED_COMMIT` 与下载根必须成对调整,安装结果也执行同一 commit 门禁。otelcol-contrib 当前锁定 `0.151.0`;如果目标服务器下载需要代理,在 `PROVISION_DOWNLOAD_PROXY` 配置目标机可访问的 HTTP 代理。 - 除 `Genarrative-Server-Provision` 外,`Genarrative-Stdb-Module-Build`、`Genarrative-Web-Build`、`Genarrative-Api-Build`、`Genarrative-*Deploy`、`Genarrative-Database-Import/Export`、`Genarrative-Full-Build-And-Deploy` 和 `Genarrative-Notify-Email` 的生产流水线现都以 Linux agent 为主,仍按各自 Jenkinsfile 的 checkout 口径执行。Server provision 不使用公网备用 Git 源,目标部署 agent 也不再需要访问源码 Git remote。 - `otelcol-contrib.service` 作为可选系统服务加入 provision,默认监听 `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制,服务 unit 见 `deploy/systemd/otelcol-contrib.service`。该服务必须存在系统用户 / 组 `otelcol`,并且 `/etc/otelcol/genarrative-debug.yaml` 已安装到目标机;若看到 `status=217/USER` 或 `Failed to determine user credentials`,优先检查 `getent passwd otelcol`,再补齐 `/etc/otelcol` 配置目录并重启服务。 -- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;通用 API 使用 `genarrative_api_rps`,后台 API 使用 `genarrative_admin_rps`。通用 `/api` location 保留 `client_max_body_size 64m` 作为编辑器图片、视频和文档请求的反代兜底,真实大小仍由路由与业务校验负责。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先核对 release 模板与实际媒体大小。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效。 +- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;通用 API 使用 `genarrative_api_rps`,后台 API 使用 `genarrative_admin_rps`。通用 `/api` location 保留 `client_max_body_size 210m` 作为编辑器图片、视频、文档请求与游戏发行包 PUT(路由上限 200 MiB + 1 KiB)的反代兜底,真实大小仍由路由与业务校验负责;使用 Pingora 网关时 `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES` 必须同步不低于该值,否则网关侧会先返回 413。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先核对 release 模板与实际媒体大小。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效。 容器化隔离部署方案单独放在 `deploy/container/`,用于本机或预发模拟现有的 Linux release + Nginx + OTLP Collector 非 BgFilter 拓扑,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径。当前 compose 没有 `bgfilter-worker`,不构成完整 BgFilter 预发拓扑,也不覆盖任何会触发 BgFilter 的现役任务;它只用于非 BgFilter 路径,或通过下述 unsupported job smoke 验证外部生成队列的 claim / fail 回写和 API-only 更新。当前容器模拟参数保留 `genarrative-release` 的 CPU、`nofile=4096` 与 `worker_connections=768` 采样口径,并在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=2g`、`api-server cpus=2.0 mem_limit=1g`、`external-generation-worker cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`。完整模块首次实例化会超过旧 `896m` cgroup 上限,因此 SpacetimeDB 必须使用 `2g`;这不改变生产服务资源合同。容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,只增加 Tokio worker 调度并发,不突破 `api-server cpus=2.0` 的 CPU 配额;容器默认 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,可用 `npm run container:up -- --scale external-generation-worker=N external-generation-worker` 验证不经过 BgFilter 的外部生成 worker 动态扩缩容,`inline` 模式不参与该验证: diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index accee82c1..e30e1e175 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -84,13 +84,14 @@ 1. AGC 发布取当前 npm 工程已成功构建的 `dist/` 内容,重新检查入口和实际字节;ZIP 内部必须把 `dist/index.html` 归一化为根 `index.html`,其余路径相对发行根保持不变。不得上传整个项目、源码快照或仅发送本地路径。网页 ZIP 同样要求根 `index.html`,不猜测并自动剥离多层目录。 2. 所有运行依赖都必须在发行包内。资源 URL 使用与发行版本目录兼容的相对地址;前导 `/assets`、本地文件 URL、外部脚本/样式/媒体/字体地址均不属于可接受发行合同。客户端给出可操作错误,服务器仍独立校验;静态校验不能代替运行时 CSP 阻断。 -3. 建议首版限额:压缩包 100 MiB、展开总量 250 MiB、单文件 64 MiB、最多 10,000 个文件、展开/压缩比不超过 100。服务端拒绝加密 ZIP、重复或大小写冲突路径、绝对路径、`..`、符号链接/重解析点、设备文件和嵌套压缩包;拒绝 `.agent`、版本控制目录、`node_modules`、凭据文件与源码映射文件。超限返回明确错误,不截断后继续发布。 +3. 现行限额:压缩包 200 MiB、展开总量 500 MiB、单文件 64 MiB、最多 10,000 个文件、展开/压缩比不超过 100。压缩包上限同时决定 `api-server` 的发行包路由请求体上限(200 MiB + 1 KiB)与反代放行量:Nginx 通用 `/api` location 为 `client_max_body_size 210m`,Pingora 网关为 `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES=220200960`;三者必须同时满足,否则合法包会在反代或路由层被 413。服务端拒绝加密 ZIP、重复或大小写冲突路径、绝对路径、`..`、符号链接/重解析点、设备文件和嵌套压缩包;拒绝 `.agent`、版本控制目录、`node_modules`、凭据文件与源码映射文件。超限返回明确错误,不截断后继续发布。超过约 100 MiB 的包在 `api-server` 会带来数百 MB 的瞬时内存占用,发布窗口与实例规格需按容量验证基线预留。 4. 提交声明 ZIP 的 SHA-256 与字节数,服务端对收到的真实 ZIP 重新计算,再对展开文件建立相对路径、字节数和 SHA-256 清单。摘要不一致、缺文件或入口损坏时停止;只有 metadata 而没有已确认完整对象的提交必须失败。 5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者不需要自己构建或打 ZIP:AGC 发布时对 `game/` 子工程按需执行 `npm install`(复用 `project.bootstrap`)与 `npm run build`(复用 `project.verify` 的受控 npm 运行器,脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义),再把 `game/dist` 归一化成根 `index.html` 的发行包上传;已有可玩入口(`game/index.html` 或 `dist/index.html`)时跳过构建。Phaser 4 + Vite 已按此口径端到端验证(构建产物、发行网关与网页沙箱播放)。发布入口按灰度下发:后端灰度配置键固定为 `game-distribution:publish`(后台「灰度发布配置」可改,支持 `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。灰度默认关闭:未配置该键、或 `enabled=false` 时,未登录与已登录作者都拿到不开放(发布入口不渲染、写入口 503);运营在后台创建该键并 `enabled=true` 后,只有白名单 / 灰度比例 / 用户标签命中的作者拿到开放状态。发布入口的开放状态随 `/api/runtime/frontend-config` 的 `gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 聊天头「发布到游戏广场」按钮据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。AGC 发布面板不展示 ZIP 路径、文件数或体积等技术摘要;一句话简介与分类可根据有界、脱敏的创作上下文免费生成(不扣用户泥点,仍可编辑),分类必须收敛到上述白名单;游戏封面支持基于项目上下文生成,生成走现役图片生成与泥点扣费链路,产物必须登记为当前账号平台素材后才能作为 `coverAssetId` 提交。 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}`(含尾斜杠)等价于该游戏的 `index.html`,`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`,避免发行文件被主站同源读取;发行网关必须部署在独立来源。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。 9. 审核通过时必须提交绝对 HTTPS `entryUrl`,且不接受凭据、query 和 fragment;服务端不根据请求 Host 或本地路径拼默认发行地址,避免把内网地址或主站来源写进公开投影。 非生产环境额外允许 http 回环地址(`127.0.0.1` / `localhost` / `[::1]`),口径与前端 `normalizeGameEntryUrl` 一致,便于本地在没有 TLS 的情况下验证内嵌游玩;生产环境只接受 HTTPS。 +10. 上传有两条等价入口,共用同一版本状态机、摘要口径、幂等键与校验规则:① 整包入口——网页端与旧客户端对 `versionId` 直接 `PUT` 整包字节,原子、不可续传,仍受发行包上限与请求体限制约束;② 分片续传入口——AGC 原生一键发布对同一 `versionId` 顺序上传固定大小的分片,再以单独的完成动作收口。分片大小由服务端下发且固定(现为 8 MiB,随发行包上限 200 MiB 取整到 25 片以内),客户端不得自行改变;分片续传入口只补传缺失字节,任何分片重复或乱序都不得造成重复写入。AGC 侧必须由原生进程直接读取本地试玩包并按分片发送,整包字节不得经过 WebView IPC 往返,也不得整包驻留宿主内存。 ### 身份、状态、审核与更新 @@ -106,7 +107,9 @@ ### 幂等、并发与恢复 - 所有创建、提交、审核、撤销和下架动作携带 `Idempotency-Key`。服务端以认证主体、动作和 key 保存请求摘要与结果;同 key 同请求返回原结果,同 key 不同请求返回 `409 IDEMPOTENCY_CONFLICT`。至少保留 30 天;客户端超出恢复窗口先回读记录,不能把未知结果自动当作失败重发。 -- 同一个版本只能确认一份 ZIP:中断重传仍使用同 `versionId` 和摘要,已确认相同字节直接返回成功,不同摘要返回 409。上传中同版本第二个写入返回 `409 UPLOAD_IN_PROGRESS`;未确认半包不会进入校验。首版整包重传,不宣称支持分片断点续传。 +- 同一个版本只能确认一份 ZIP:中断重传仍使用同 `versionId` 和摘要,已确认相同字节直接返回成功,不同摘要返回 409。上传中同版本第二个写入返回 `409 UPLOAD_IN_PROGRESS`;未确认半包不会进入校验。 +- 分片续传以「服务端已收字节」为唯一权威偏移:客户端带上自己认为的偏移上传分片,与服务端记录不一致时服务端返回 `409` 与权威偏移,客户端按权威偏移继续,不重放也不跳段。传输失败、网络中断、客户端进程退出或应用重启后,同一 `versionId` 重新发布只补传缺失分片;已收字节数由服务端持久化事实决定,不依赖客户端本地记录。 +- 分片会话在全部字节到齐并执行完成动作之前,不进入包校验、不确认版本、不改变任何公开可见性,半包对象也不服务给发行网关。完成动作里校验失败时删除该半包对象并把版本落到 `upload_failed`(`recoveryAction=reupload`);作者要重新上传同一版本时必须先显式重置分片会话,重置后已收字节归零,不允许在半包之上续写不同字节。 - 重复提交同一次 AGC 操作不得创建第二个游戏或版本;原生端持久保存操作 ID、目标游戏/版本和 key,网页保存恢复标识并以服务端回读为准。相同 ZIP 用于不同资料修订时允许新版本,不能仅按包摘要吞掉新的发布意图。 - 公开版本切换、作者下架和管理员审核必须带 `expectedPublicationRevision`,在持久化事务中比较并推进。并发变化返回 `409 PUBLICATION_CONFLICT`;旧送审版本不能在用户已发布更新或下架之后静默覆盖状态。审核员重新查看现状后才能提交新的明确动作。 - 网络中断或响应丢失后先查询原操作/版本;服务端恢复 `validating` 的在途任务并按版本身份幂等续作,不另建版本。登录失效保留私有草稿和恢复标识,重新登录同账号后继续;换账号不能读取或接管原账号操作。 diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index 108f29425..ac56a40c2 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -19,7 +19,10 @@ use module_game_distribution::{ validate_release_zip, }; use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmMessage, LlmRunRequest}; -use platform_oss::{OssGetObjectRequest, OssInternalPutObjectRequest, OssObjectAccess}; +use platform_oss::{ + OssAppendInternalObjectRequest, OssDeleteObjectRequest, OssGetObjectRequest, + OssInternalPutObjectRequest, OssObjectAccess, +}; use serde::Deserialize; use serde_json::{Value, json}; use shared_contracts::game_distribution::{ @@ -49,6 +52,13 @@ use crate::{ }; pub(crate) const MAX_PACKAGE_REQUEST_BODY_BYTES: usize = MAX_PACKAGE_BYTES as usize + 1024; +/// 分片续传的固定分片大小:200 MiB 上限下最多 25 片,单片远低于反代放行量。 +/// 客户端只能使用服务端下发的值,不得自行改变分片边界,否则权威偏移会立刻对不上。 +pub(crate) const PACKAGE_UPLOAD_CHUNK_BYTES: usize = 8 * 1024 * 1024; +/// 分片路由的请求体放行量:分片大小 + 1 KiB 头部余量。 +pub(crate) const MAX_PACKAGE_CHUNK_REQUEST_BODY_BYTES: usize = PACKAGE_UPLOAD_CHUNK_BYTES + 1024; +/// 分片偏移由客户端显式声明,服务端以对象当前长度为唯一权威。 +const PACKAGE_UPLOAD_OFFSET_HEADER: &str = "x-genarrative-upload-offset"; const MAX_LIST_LIMIT: u32 = 48; const MAX_IDEMPOTENCY_KEY_CHARS: usize = 128; const MAX_PACKAGE_MANIFEST_JSON_BYTES: usize = 2 * 1024 * 1024; @@ -60,7 +70,9 @@ const GAME_DISTRIBUTION_PUBLISHED_STATUS: &str = "published"; const GAME_DISTRIBUTION_OSS_PUT_MAX_ATTEMPTS: usize = 3; const GAME_DISTRIBUTION_OSS_PUT_RETRY_DELAYS_MS: [u64; 2] = [250, 500]; const RELEASE_PACKAGE_CACHE_MAX_ENTRIES: usize = 4; -const RELEASE_PACKAGE_CACHE_MAX_BYTES: usize = 200 * 1024 * 1024; +/// 缓存字节预算必须比单个发行包上限大出一档,否则 200 MiB 档的包只能刚好自占整份预算, +/// 任何并发的小包都会被立刻挤掉。 +const RELEASE_PACKAGE_CACHE_MAX_BYTES: usize = 256 * 1024 * 1024; /// 发行静态资源的进程内缓存。 /// @@ -179,6 +191,23 @@ pub fn router(state: AppState) -> Router { "/api/game-distribution/versions/{version_id}/package", put(upload_package).layer(DefaultBodyLimit::max(MAX_PACKAGE_REQUEST_BODY_BYTES)), ) + .route( + "/api/game-distribution/versions/{version_id}/package/upload-state", + get(package_upload_state), + ) + .route( + "/api/game-distribution/versions/{version_id}/package/chunk", + put(upload_package_chunk) + .layer(DefaultBodyLimit::max(MAX_PACKAGE_CHUNK_REQUEST_BODY_BYTES)), + ) + .route( + "/api/game-distribution/versions/{version_id}/package/complete", + post(complete_package_upload), + ) + .route( + "/api/game-distribution/versions/{version_id}/package/reset", + post(reset_package_upload), + ) .route( "/api/game-distribution/versions/{version_id}/submit", post(submit_version), @@ -630,46 +659,10 @@ async fn upload_package( return Err(mapped); } }; - if manifest.package_sha256 != expected.package_sha256 - || manifest.package_bytes != expected.package_bytes - || u32::try_from(manifest.files.len()).unwrap_or(u32::MAX) != expected.package_file_count - || expected.package_entry_path != "index.html" - { - warn!( - request_id = ctx.request_id(), - operation = "package_rejected", - game_id = %expected.game_id, - version_id = %version_id, - code = "PACKAGE_MISMATCH", - declared_bytes = expected.package_bytes, - actual_bytes = manifest.package_bytes, - declared_file_count = expected.package_file_count, - actual_file_count = u32::try_from(manifest.files.len()).unwrap_or(u32::MAX), - elapsed_ms = ctx.elapsed(), - "发行包与版本声明不一致" - ); - let error = AppError::from_status(StatusCode::CONFLICT) - .with_code("PACKAGE_MISMATCH") - .with_details(json!({ - "provider": "game-distribution", - "message": "发行包摘要、体积、文件数或入口与版本声明不一致", - })); - record_upload_failure( - &state, - &owner_user_id, - &version_id, - &idempotency_key, - "PACKAGE_MISMATCH", - "发行包摘要、体积、文件数或入口与版本声明不一致".to_string(), - ) - .await; - return Err(error); - } let package_object_key = format!( "{GAME_DISTRIBUTION_OBJECT_PREFIX}{}/{version_id}.zip", expected.game_id ); - let package_manifest_json = package_manifest_json(&manifest)?; let oss = state.project_snapshot_oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("游戏发行包 OSS 未配置") })?; @@ -697,7 +690,8 @@ async fn upload_package( None => false, }; if !skipped { - // 单次 100 MiB PUT 在本机实测 12 秒上下,偶发传输失败会让作者白传一次; + // 单次 100 MiB 档 PUT 在本机实测 12 秒上下(上限提升到 200 MiB 后单次耗时与失败 + // 暴露面同步放大),偶发传输失败会让作者白传一次; // 这里按 platform-oss 既有的可重试分类做受控重试(只重试传输/超时/408/429/5xx)。 oss.put_internal_object_with_retry( state.editor_oss_http_client(), @@ -714,28 +708,93 @@ async fn upload_package( .await .map_err(|error| map_oss_error(error, "aliyun-oss"))?; } + confirm_validated_package( + &state, + &ctx, + &owner_user_id, + &version_id, + &expected, + &manifest, + package_object_key, + &idempotency_key, + skipped, + ) + .await +} + +/// 校验通过后的共同收口:声明比对 → 确认 → 结构化事件。 +/// +/// 整包 `PUT` 与分片续传的完成动作共用这条路径,两种入口的校验、幂等与事件口径必须一致; +/// 任何入口都不得绕过它直接写版本状态。 +#[allow(clippy::too_many_arguments)] +async fn confirm_validated_package( + state: &AppState, + ctx: &RequestContext, + owner_user_id: &str, + version_id: &str, + expected: &GameDistributionVersionRecord, + manifest: &ReleasePackageManifest, + package_object_key: String, + idempotency_key: &str, + oss_put_skipped: bool, +) -> Result, AppError> { + if manifest.package_sha256 != expected.package_sha256 + || manifest.package_bytes != expected.package_bytes + || u32::try_from(manifest.files.len()).unwrap_or(u32::MAX) != expected.package_file_count + || expected.package_entry_path != "index.html" + { + warn!( + request_id = ctx.request_id(), + operation = "package_rejected", + game_id = %expected.game_id, + version_id = %version_id, + code = "PACKAGE_MISMATCH", + declared_bytes = expected.package_bytes, + actual_bytes = manifest.package_bytes, + declared_file_count = expected.package_file_count, + actual_file_count = u32::try_from(manifest.files.len()).unwrap_or(u32::MAX), + elapsed_ms = ctx.elapsed(), + "发行包与版本声明不一致" + ); + let error = AppError::from_status(StatusCode::CONFLICT) + .with_code("PACKAGE_MISMATCH") + .with_details(json!({ + "provider": "game-distribution", + "message": "发行包摘要、体积、文件数或入口与版本声明不一致", + })); + record_upload_failure( + state, + owner_user_id, + version_id, + idempotency_key, + "PACKAGE_MISMATCH", + "发行包摘要、体积、文件数或入口与版本声明不一致".to_string(), + ) + .await; + return Err(error); + } + let package_manifest_json = package_manifest_json(manifest)?; let request_digest = compute_request_digest( - &serde_json::to_vec(&(version_id.as_str(), manifest.package_sha256.as_str())) + &serde_json::to_vec(&(version_id, manifest.package_sha256.as_str())) .map_err(|error| internal(error.to_string()))?, ); let log_game_id = expected.game_id.clone(); let log_package_bytes = manifest.package_bytes; let log_file_count = u32::try_from(manifest.files.len()).unwrap_or(u32::MAX); let log_sha_prefix = manifest.package_sha256.chars().take(12).collect::(); - let log_oss_put_skipped = skipped; let confirmed = state .spacetime_client() .confirm_game_distribution_package( spacetime_client::GameDistributionConfirmPackageRecordInput { - version_id, - owner_user_id, - package_sha256: manifest.package_sha256, + version_id: version_id.to_string(), + owner_user_id: owner_user_id.to_string(), + package_sha256: manifest.package_sha256.clone(), package_bytes: manifest.package_bytes, package_file_count: u32::try_from(manifest.files.len()).unwrap_or(u32::MAX), package_entry_path: "index.html".to_string(), package_object_key, package_manifest_json, - idempotency_key, + idempotency_key: idempotency_key.to_string(), request_digest, updated_at_micros: now_micros(), }, @@ -750,16 +809,341 @@ async fn upload_package( package_bytes = log_package_bytes, file_count = log_file_count, sha256_prefix = %log_sha_prefix, - oss_put_skipped = log_oss_put_skipped, + oss_put_skipped, elapsed_ms = ctx.elapsed(), "发行包已确认" ); Ok(json_success_body( - Some(&ctx), + Some(ctx), json!({ "versionId": confirmed.0.version_id, "status": confirmed.0.status }), )) } +/// 分片续传的状态查询:客户端拿到的「已收字节」来自 OSS 对象事实,不依赖本地记录, +/// 因此进程重启、换机器或换网络后都能从权威偏移继续。 +async fn package_upload_state( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + Path(version_id): Path, +) -> Result, AppError> { + let owner_user_id = auth.claims().user_id().to_string(); + ensure_publish_enabled(&state, Some(owner_user_id.as_str())).await?; + let version = load_owner_version_or_404(&state, owner_user_id, version_id.clone()).await?; + let oss = game_distribution_oss_client(&state)?; + let object_key = game_distribution_package_object_key(&version.game_id, &version_id); + let received_bytes = staged_package_bytes(&state, oss, &object_key).await?; + Ok(json_success_body( + Some(&ctx), + json!({ + "versionId": version_id, + "status": version.status, + "chunkBytes": PACKAGE_UPLOAD_CHUNK_BYTES, + "declaredPackageBytes": version.package_bytes, + "receivedBytes": received_bytes, + }), + )) +} + +/// 分片写入。 +/// +/// 客户端声明的偏移必须等于服务端已收字节;不一致时返回 409 与权威偏移, +/// 由客户端按权威偏移续传 —— 这样重放与乱序都不会造成重复写入。 +async fn upload_package_chunk( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + headers: HeaderMap, + Path(version_id): Path, + body: Bytes, +) -> Result, AppError> { + require_octet_stream_content_type(&headers)?; + let owner_user_id = auth.claims().user_id().to_string(); + ensure_publish_enabled(&state, Some(owner_user_id.as_str())).await?; + // 分片级重放由偏移语义保证,这里仍要求幂等键,保持与其它写入口一致的调用约定。 + let _idempotency_key = idempotency_key(&headers)?; + let offset = package_upload_offset(&headers)?; + if body.is_empty() { + return Err(bad_request("发行包分片内容不能为空")); + } + if body.len() > PACKAGE_UPLOAD_CHUNK_BYTES { + return Err(AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE) + .with_code("PACKAGE_CHUNK_TOO_LARGE") + .with_message("发行包分片超过服务端下发的大小")); + } + let version = load_owner_version_or_404(&state, owner_user_id, version_id.clone()).await?; + let chunk_bytes = u64::try_from(body.len()).unwrap_or(u64::MAX); + let end = offset + .checked_add(chunk_bytes) + .ok_or_else(|| bad_request("发行包分片偏移溢出"))?; + if end > version.package_bytes { + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_code("PACKAGE_UPLOAD_EXCEEDS_DECLARED") + .with_details(json!({ + "provider": "game-distribution", + "declaredPackageBytes": version.package_bytes, + "receivedBytes": offset, + "message": "分片写入会超过版本声明的发行包大小", + }))); + } + let oss = game_distribution_oss_client(&state)?; + let object_key = game_distribution_package_object_key(&version.game_id, &version_id); + let received_bytes = staged_package_bytes(&state, oss, &object_key).await?; + if offset != received_bytes { + warn!( + request_id = ctx.request_id(), + operation = "package_chunk_offset_mismatch", + game_id = %version.game_id, + version_id = %version_id, + declared_offset = offset, + received_bytes, + "发行包分片偏移与服务端已收字节不一致" + ); + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_code("PACKAGE_UPLOAD_OFFSET_MISMATCH") + .with_message("分片偏移与服务端已收字节不一致,请按权威偏移续传") + .with_details(json!({ + "provider": "game-distribution", + "receivedBytes": received_bytes, + }))); + } + let appended = oss + .append_internal_object_with_retry( + state.editor_oss_http_client(), + OssAppendInternalObjectRequest { + object_key, + content_type: Some("application/zip".to_string()), + position: offset, + body: body.to_vec(), + }, + GAME_DISTRIBUTION_OSS_PUT_MAX_ATTEMPTS, + &GAME_DISTRIBUTION_OSS_PUT_RETRY_DELAYS_MS, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + info!( + request_id = ctx.request_id(), + operation = "package_chunk_stored", + game_id = %version.game_id, + version_id = %version_id, + offset, + chunk_bytes = appended.appended_bytes, + received_bytes = appended.next_position, + elapsed_ms = ctx.elapsed(), + "发行包分片已写入" + ); + Ok(json_success_body( + Some(&ctx), + json!({ + "versionId": version_id, + "chunkBytes": PACKAGE_UPLOAD_CHUNK_BYTES, + "receivedBytes": appended.next_position, + }), + )) +} + +/// 分片续传的完成动作:全部字节到齐后才回读整包、校验并确认。 +/// +/// 校验失败时删除半包对象并把版本落到 `upload_failed`,避免半包留在对象键上拖住后续重传。 +async fn complete_package_upload( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + headers: HeaderMap, + Path(version_id): Path, +) -> Result, AppError> { + let owner_user_id = auth.claims().user_id().to_string(); + ensure_publish_enabled(&state, Some(owner_user_id.as_str())).await?; + let idempotency_key = idempotency_key(&headers)?; + let version = + load_owner_version_or_404(&state, owner_user_id.clone(), version_id.clone()).await?; + let oss = game_distribution_oss_client(&state)?; + let object_key = game_distribution_package_object_key(&version.game_id, &version_id); + let received_bytes = staged_package_bytes(&state, oss, &object_key).await?; + if received_bytes == 0 { + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_code("PACKAGE_UPLOAD_NOT_STARTED") + .with_details(json!({ + "provider": "game-distribution", + "declaredPackageBytes": version.package_bytes, + "receivedBytes": 0, + "message": "该版本还没有任何已收分片", + }))); + } + if received_bytes != version.package_bytes { + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_code("PACKAGE_UPLOAD_INCOMPLETE") + .with_message("发行包分片尚未收齐") + .with_details(json!({ + "provider": "game-distribution", + "declaredPackageBytes": version.package_bytes, + "receivedBytes": received_bytes, + }))); + } + let body = oss + .get_object( + state.editor_oss_http_client(), + OssGetObjectRequest { + object_key: object_key.clone(), + max_bytes: MAX_PACKAGE_BYTES as usize, + }, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let manifest = match validate_release_zip(&body) { + Ok(manifest) => manifest, + Err(error) => { + let reason = format!("{error:?}"); + warn!( + request_id = ctx.request_id(), + operation = "package_rejected", + game_id = %version.game_id, + version_id = %version_id, + code = "PACKAGE_VALIDATION_FAILED", + reason = %reason, + uploaded_bytes = body.len(), + elapsed_ms = ctx.elapsed(), + "发行包校验失败" + ); + let mapped = map_package_error(error); + if let Err(delete_error) = oss + .delete_object( + state.editor_oss_http_client(), + OssDeleteObjectRequest { + object_key: object_key.clone(), + }, + ) + .await + { + warn!( + request_id = ctx.request_id(), + operation = "package_staging_delete_failed", + version_id = %version_id, + error = %delete_error, + "校验失败的半包对象删除失败,需要人工确认对象键状态" + ); + } + record_upload_failure( + &state, + &owner_user_id, + &version_id, + &idempotency_key, + "PACKAGE_VALIDATION_FAILED", + reason, + ) + .await; + return Err(mapped); + } + }; + confirm_validated_package( + &state, + &ctx, + &owner_user_id, + &version_id, + &version, + &manifest, + object_key, + &idempotency_key, + true, + ) + .await +} + +/// 显式重置分片会话:删除半包对象并把已收字节归零。 +/// +/// 只有尚未确认过发行包的版本能重置;已确认的版本必须新建版本,不能在半包之上续写不同字节。 +async fn reset_package_upload( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + headers: HeaderMap, + Path(version_id): Path, +) -> Result, AppError> { + let owner_user_id = auth.claims().user_id().to_string(); + ensure_publish_enabled(&state, Some(owner_user_id.as_str())).await?; + let _idempotency_key = idempotency_key(&headers)?; + let version = load_owner_version_or_404(&state, owner_user_id, version_id.clone()).await?; + if !matches!(version.status.as_str(), "awaiting_upload" | "upload_failed") { + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_code("PACKAGE_UPLOAD_RESET_NOT_ALLOWED") + .with_message("该版本已经确认过发行包,重新上传请新建版本")); + } + let oss = game_distribution_oss_client(&state)?; + let object_key = game_distribution_package_object_key(&version.game_id, &version_id); + oss.delete_object( + state.editor_oss_http_client(), + OssDeleteObjectRequest { + object_key: object_key.clone(), + }, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + info!( + request_id = ctx.request_id(), + operation = "package_upload_reset", + game_id = %version.game_id, + version_id = %version_id, + elapsed_ms = ctx.elapsed(), + "发行包分片会话已重置" + ); + Ok(json_success_body( + Some(&ctx), + json!({ "versionId": version_id, "receivedBytes": 0 }), + )) +} + +fn game_distribution_oss_client(state: &AppState) -> Result<&platform_oss::OssClient, AppError> { + state.project_snapshot_oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("游戏发行包 OSS 未配置") + }) +} + +fn game_distribution_package_object_key(game_id: &str, version_id: &str) -> String { + format!("{GAME_DISTRIBUTION_OBJECT_PREFIX}{game_id}/{version_id}.zip") +} + +/// 已收字节的权威来源:对象存在时的长度;确定不存在时是 0,其它失败按上游错误上报。 +async fn staged_package_bytes( + state: &AppState, + oss: &platform_oss::OssClient, + object_key: &str, +) -> Result { + let head = oss + .head_internal_object(state.editor_oss_http_client(), object_key) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + Ok(head.map(|object| object.content_length).unwrap_or(0)) +} + +fn require_octet_stream_content_type(headers: &HeaderMap) -> Result<(), AppError> { + let content_type = headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| { + value + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + }); + if content_type.as_deref() != Some("application/octet-stream") { + return Err(bad_request("发行包分片必须使用 application/octet-stream")); + } + Ok(()) +} + +fn package_upload_offset(headers: &HeaderMap) -> Result { + let raw = headers + .get(PACKAGE_UPLOAD_OFFSET_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| bad_request("缺少发行包分片偏移"))?; + raw.parse::() + .map_err(|_| bad_request("发行包分片偏移必须是非负整数")) +} + async fn submit_version( State(state): State, Extension(ctx): Extension, @@ -1969,6 +2353,64 @@ mod tests { } } + #[test] + fn package_request_body_limit_covers_max_package_bytes() { + // 口径约束:发行包路由的请求体放行量必须覆盖包体上限,否则合法包会在 + // `DefaultBodyLimit` 处被 413,而 ZIP 校验根本没机会执行。 + assert!(MAX_PACKAGE_REQUEST_BODY_BYTES > MAX_PACKAGE_BYTES as usize); + } + + #[test] + fn package_chunk_size_stays_inside_declared_limits() { + // 分片必须能整除式地覆盖 200 MiB 档发行包(最多 25 片),且分片放行量要留出头部余量。 + assert_eq!(PACKAGE_UPLOAD_CHUNK_BYTES, 8 * 1024 * 1024); + assert!(PACKAGE_UPLOAD_CHUNK_BYTES < MAX_PACKAGE_BYTES as usize); + assert!(MAX_PACKAGE_CHUNK_REQUEST_BODY_BYTES > PACKAGE_UPLOAD_CHUNK_BYTES); + assert!( + (MAX_PACKAGE_BYTES as usize).div_ceil(PACKAGE_UPLOAD_CHUNK_BYTES) <= 25, + "200 MiB 档发行包的分片数必须不超过 25 片" + ); + } + + #[test] + fn package_upload_offset_requires_non_negative_integer() { + let mut headers = HeaderMap::new(); + assert!(package_upload_offset(&headers).is_err()); + + headers.insert(PACKAGE_UPLOAD_OFFSET_HEADER, HeaderValue::from_static(" ")); + assert!(package_upload_offset(&headers).is_err()); + + headers.insert(PACKAGE_UPLOAD_OFFSET_HEADER, HeaderValue::from_static("-1")); + assert!(package_upload_offset(&headers).is_err()); + + headers.insert( + PACKAGE_UPLOAD_OFFSET_HEADER, + HeaderValue::from_static("8388608"), + ); + assert_eq!( + package_upload_offset(&headers).expect("合法偏移"), + PACKAGE_UPLOAD_CHUNK_BYTES as u64 + ); + } + + #[test] + fn package_chunk_content_type_must_be_octet_stream() { + let mut headers = HeaderMap::new(); + assert!(require_octet_stream_content_type(&headers).is_err()); + + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/zip"), + ); + assert!(require_octet_stream_content_type(&headers).is_err()); + + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/octet-stream"), + ); + assert!(require_octet_stream_content_type(&headers).is_ok()); + } + #[test] fn metadata_rejects_mobile_games_without_touch_support() { let mut payload = metadata(); diff --git a/server-rs/crates/module-game-distribution/src/package.rs b/server-rs/crates/module-game-distribution/src/package.rs index 5e8faf9f5..feb85a54e 100644 --- a/server-rs/crates/module-game-distribution/src/package.rs +++ b/server-rs/crates/module-game-distribution/src/package.rs @@ -6,8 +6,13 @@ use std::{ use sha2::{Digest, Sha256}; -pub const MAX_PACKAGE_BYTES: u64 = 100 * 1024 * 1024; -pub const MAX_EXPANDED_BYTES: u64 = 250 * 1024 * 1024; +/// 发行包体积上限。反代放行量与路由请求体上限都从它派生:Nginx +/// `client_max_body_size`、Pingora `MAX_API_BODY_BYTES` 必须同步放宽,否则合法包会在 +/// 到达 `api-server` 之前被拒。 +pub const MAX_PACKAGE_BYTES: u64 = 200 * 1024 * 1024; +/// 展开总量上限保持压缩包上限的 2.5 倍余量:包体本身基本不可再压时展开量约等于包体, +/// 纯文本 / JSON 资源占比高的包仍要有足够空间。 +pub const MAX_EXPANDED_BYTES: u64 = 500 * 1024 * 1024; pub const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; pub const MAX_FILE_COUNT: usize = 10_000; pub const MAX_COMPRESSION_RATIO: u64 = 100; @@ -187,6 +192,23 @@ mod tests { output.into_inner() } + /// 存储型条目的压缩包;用于构造体积可控且不参与 deflate 的大包。 + fn stored_archive(files: &[(&str, &[u8])]) -> Vec { + let mut output = Cursor::new(Vec::new()); + let mut writer = ZipWriter::new(&mut output); + for (path, content) in files { + writer + .start_file( + *path, + SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored), + ) + .expect("zip entry"); + writer.write_all(content).expect("zip content"); + } + writer.finish().expect("finish zip"); + output.into_inner() + } + #[test] fn accepts_root_entry_and_returns_file_manifest() { let bytes = archive(&[("index.html", b""), ("assets/a.txt", b"a")]); @@ -222,4 +244,27 @@ mod tests { Err(ReleasePackageError::InvalidPath) ); } + + #[test] + fn keeps_expansion_headroom_over_package_limit() { + // 口径约束:发行包上限调整时,展开总量至少要留出两倍余量, + // 否则高文本占比的合法包会在展开量检查处被误拒。 + assert!(MAX_EXPANDED_BYTES >= MAX_PACKAGE_BYTES.saturating_mul(2)); + } + + #[test] + fn accepts_package_above_the_previous_hundred_mib_limit() { + // 上限从 100 MiB 提到 200 MiB 的回归防护:两个 50 MiB 存储型条目组成 100 MiB + // 出头的包,旧上限会在这里判 PackageTooLarge,新上限必须放行并给出完整清单。 + let chunk = vec![0_u8; 50 * 1024 * 1024]; + let bytes = stored_archive(&[ + ("index.html", b""), + ("assets/a.bin", chunk.as_slice()), + ("assets/b.bin", chunk.as_slice()), + ]); + assert!(bytes.len() as u64 > 100 * 1024 * 1024); + let manifest = validate_release_zip(&bytes).expect("package above 100 MiB"); + assert_eq!(manifest.package_bytes, bytes.len() as u64); + assert_eq!(manifest.files.len(), 3); + } } diff --git a/server-rs/crates/pingora-gateway/src/main.rs b/server-rs/crates/pingora-gateway/src/main.rs index 88af466f2..9c1a6e877 100644 --- a/server-rs/crates/pingora-gateway/src/main.rs +++ b/server-rs/crates/pingora-gateway/src/main.rs @@ -37,7 +37,9 @@ const DEFAULT_WEB_ROOT: &str = "/srv/genarrative/web"; const DEFAULT_ACME_ROOT: &str = "/var/www/html"; const DEFAULT_MAINTENANCE_FILE: &str = "/var/lib/genarrative/maintenance/enabled"; const DEFAULT_MAINTENANCE_PAGE_FILE: &str = "/var/lib/genarrative/maintenance/page.html"; -const DEFAULT_MAX_API_BODY_BYTES: u64 = 64 * 1024 * 1024; +// 通用 /api 路由的放行上限;必须覆盖游戏发行包 PUT(路由 `DefaultBodyLimit` 200 MiB + 1 KiB), +// 具体接口的真实上限仍由 api-server 逐路由校验。 +const DEFAULT_MAX_API_BODY_BYTES: u64 = 210 * 1024 * 1024; const DEFAULT_GZIP_LEVEL: u32 = 5; const DEFAULT_GZIP_MIN_LENGTH_BYTES: u64 = 1024; const DEFAULT_UPSTREAM_CONNECT_TIMEOUT_MS: u64 = 3_000; diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 32b3d02f6..4cce2d31a 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -132,6 +132,29 @@ pub struct OssInternalPutObjectRequest { pub body: Vec, } +/// 内部对象的追加写请求(OSS AppendObject)。 +/// +/// `position = 0` 表示追加到当前末尾;`position > 0` 必须等于对象当前长度, +/// 否则上游直接失败 —— 分片续传依赖这条语义保证重放不会重复写入。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OssAppendInternalObjectRequest { + pub object_key: String, + pub content_type: Option, + pub position: u64, + pub body: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OssAppendInternalObjectResponse { + pub provider: &'static str, + pub bucket: String, + pub endpoint: String, + pub object_key: String, + pub appended_bytes: u64, + /// 下一次可写位置,由 OSS 返回,是「已收字节」的权威值。 + pub next_position: u64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct OssPutObjectRequest { pub prefix: LegacyAssetPrefix, @@ -1018,6 +1041,105 @@ impl OssClient { .await } + /// 内部对象追加写,返回 OSS 给出的下一次可写位置(已收字节的权威值)。 + /// + /// 与整对象 PUT 的区别是「可续写」:`position = 0` 追加到当前末尾, + /// `position > 0` 必须与对象当前长度一致(不一致时 OSS 判失败,不会重复写入)。 + /// 因此续传只需要回读对象长度,再从未写位置继续。 + pub async fn append_internal_object( + &self, + client: &reqwest::Client, + request: OssAppendInternalObjectRequest, + ) -> Result { + let object_key = normalize_internal_object_key(&request.object_key)?; + if request.body.is_empty() { + return Err(OssError::InvalidRequest( + "服务端内部对象追加内容不能为空".to_string(), + )); + } + let content_type = normalize_optional_value(request.content_type); + let mut target_url = + build_object_url(&self.config.bucket, &self.config.endpoint, &object_key).map_err( + |error| { + request_error( + OssRequestOperation::Put, + &format!("构造 OSS 对象 URL 失败:{error}"), + ) + }, + )?; + target_url + .query_pairs_mut() + .append_pair("append", "") + .append_pair("position", &request.position.to_string()); + let appended_bytes = u64::try_from(request.body.len()) + .map_err(|_| OssError::InvalidRequest("追加内容大小超出可支持范围".to_string()))?; + let headers = BTreeMap::new(); + let builder = signed_request_builder( + client, + &self.config, + Method::POST, + Some(&object_key), + target_url, + content_type.as_deref(), + &headers, + )? + .header(reqwest::header::CONTENT_LENGTH, appended_bytes) + .body(request.body); + let response = builder + .send() + .await + .map_err(|error| request_error_from_reqwest(OssRequestOperation::Put, error))?; + if !response.status().is_success() { + return Err(request_status_error( + OssRequestOperation::Put, + response.status().as_u16(), + format!("OSS AppendObject 失败,状态码:{}", response.status()), + )); + } + let next_position = response + .headers() + .get("x-oss-next-append-position") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.trim().parse::().ok()) + .ok_or_else(|| { + OssError::InvalidRequest("OSS AppendObject 未返回 next-append-position".to_string()) + })?; + Ok(OssAppendInternalObjectResponse { + provider: OSS_PROVIDER, + bucket: self.config.bucket.clone(), + endpoint: self.config.endpoint.clone(), + object_key, + appended_bytes, + next_position, + }) + } + + /// 内部对象追加写的受控重试,判定与退避口径和 `put_internal_object_with_retry` 一致。 + pub async fn append_internal_object_with_retry( + &self, + client: &reqwest::Client, + request: OssAppendInternalObjectRequest, + max_attempts: usize, + retry_delays_ms: &[u64], + ) -> Result { + if max_attempts == 0 { + return Err(OssError::InvalidConfig( + "内部对象追加重试次数至少为 1".to_string(), + )); + } + if retry_delays_ms.len() < max_attempts.saturating_sub(1) { + return Err(OssError::InvalidConfig( + "内部对象追加重试缺少退避配置".to_string(), + )); + } + let log_key = request.object_key.clone(); + run_internal_put_with_retry(max_attempts, retry_delays_ms, &log_key, move || { + let request = request.clone(); + async move { self.append_internal_object(client, request).await } + }) + .await + } + async fn put_internal_object_bytes( &self, client: &reqwest::Client, @@ -1709,7 +1831,7 @@ where max_attempts, retry_delay_ms = delay_ms, error = %error, - "OSS 内部对象 PUT 失败,按退避重试" + "OSS 内部对象写入失败,按退避重试" ); sleep(std::time::Duration::from_millis(delay_ms)).await; attempt += 1; diff --git a/src/components/game-distribution/gameZipPackage.test.ts b/src/components/game-distribution/gameZipPackage.test.ts index d4f4181a0..ca2a4a826 100644 --- a/src/components/game-distribution/gameZipPackage.test.ts +++ b/src/components/game-distribution/gameZipPackage.test.ts @@ -3,7 +3,7 @@ import JSZip from 'jszip'; import { describe, expect, it } from 'vitest'; -import { prepareGamePackage } from './gameZipPackage'; +import { GAME_PACKAGE_MAX_BYTES, prepareGamePackage } from './gameZipPackage'; async function buildZip(files: Record) { const zip = new JSZip(); @@ -52,4 +52,12 @@ describe('prepareGamePackage', () => { ), ).rejects.toThrow('发行包不是有效的 ZIP'); }); + + it('超过上限时在读取字节之前就失败关闭', async () => { + // 只伪造体积,不真的分配 200 MiB;预检必须在读字节之前拦下。 + const oversized = { size: GAME_PACKAGE_MAX_BYTES + 1 } as unknown as File; + await expect(prepareGamePackage(oversized)).rejects.toThrow( + '发行包不能超过 200 MiB', + ); + }); }); diff --git a/src/components/game-distribution/gameZipPackage.ts b/src/components/game-distribution/gameZipPackage.ts index eb8497f18..fcc25c8da 100644 --- a/src/components/game-distribution/gameZipPackage.ts +++ b/src/components/game-distribution/gameZipPackage.ts @@ -1,6 +1,6 @@ import JSZip from 'jszip'; -export const GAME_PACKAGE_MAX_BYTES = 100 * 1024 * 1024; +export const GAME_PACKAGE_MAX_BYTES = 200 * 1024 * 1024; export const GAME_PACKAGE_MAX_FILE_COUNT = 10_000; export type PreparedGamePackage = { @@ -59,7 +59,7 @@ export async function prepareGamePackage( throw new Error('请选择非空的发行包 ZIP'); } if (file.size > GAME_PACKAGE_MAX_BYTES) { - throw new Error('发行包不能超过 100 MiB'); + throw new Error('发行包不能超过 200 MiB'); } const bytes = await readGamePackageBytes(file); let archive: JSZip; From f0ef0689109c87dc0043f8eaf0304e30aff5594b Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:07:37 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E6=A0=8F=E5=93=81=E7=89=8C=E6=A0=87=E7=AD=BE=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E5=AF=B9=E6=B8=A0=E9=81=93=E4=BA=A7=E5=93=81=E5=90=8D=E7=9A=84?= =?UTF-8?q?=E6=96=AD=E8=A8=80=20-=20WindowChrome=20=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E6=94=B9=E7=94=A8=20appMetadata=20=E7=9A=84=20APP=5FNAME?= =?UTF-8?q?=EF=BC=88dev=20=E6=B8=A0=E9=81=93=E4=B8=BA=E9=99=B6=E6=B3=A5?= =?UTF-8?q?=E5=84=BF=E5=BC=80=E5=8F=91=E7=89=88=EF=BC=89=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E7=A1=AC=E7=BC=96=E7=A0=81=E6=97=A7=E4=BA=A7=E5=93=81?= =?UTF-8?q?=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ai-game-creator-shell/tests/WindowChrome.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/tests/WindowChrome.test.tsx b/apps/ai-game-creator-shell/tests/WindowChrome.test.tsx index 5f3578646..72b8169eb 100644 --- a/apps/ai-game-creator-shell/tests/WindowChrome.test.tsx +++ b/apps/ai-game-creator-shell/tests/WindowChrome.test.tsx @@ -4,6 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; 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'; @@ -66,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();