fix: 失败草稿重试复用原会话

This commit is contained in:
2026-06-05 22:49:07 +08:00
parent 6a03575d68
commit dcbf02bbda
4 changed files with 255 additions and 15 deletions

View File

@@ -1601,10 +1601,18 @@
- 现象:拼图生成页已经收到 VectorEngine 图片编辑失败并进入重试态,但用户返回草稿 Tab 后同一草稿仍显示“生成中”连续触发多个拼图生成时失败后还可能只剩一条新增草稿或者只看到标题为“第1关”的半成品空壳抓大鹅后台失败时也可能没有任何通知点击草稿又像重新开始生成。
- 原因:前端失败 notice 只更新生成页局部状态pending 作品架条目在失败时被清掉或被非 `generating` 状态误映射为 `ready`;后端作品摘要也可能短暂仍是 `generationStatus=generating`。如果失败消息没有写入 notice用户离开生成页后不会弹出 `PlatformErrorDialog`;如果打开草稿只看持久化 `generating`,就会绕过失败态恢复。
- 处理:失败时按 session 保留 pending 作品架条目并标记 `failed`,失败 notice 保存错误消息并触发带来源的 `PlatformErrorDialog`;拼图契约没有 `failed` 枚举pending 拼图映射为 `idle`,同时用本地失败 notice 覆盖持久化生成中状态和旧的“正在生成”摘要。点击失败草稿应优先用 notice / 后端 session / fallback payload 组装失败生成页,不能重新从 0 秒启动新进度;拼图失败半成品没有有效 `workTitle` 时,作品架标题回退为“拼图草稿”。
- 处理:失败时按 session 保留 pending 作品架条目并标记 `failed`,失败 notice 保存错误消息并触发带来源的 `PlatformErrorDialog`;拼图契约没有 `failed` 枚举pending 拼图映射为 `idle`,同时用本地失败 notice 覆盖持久化生成中状态和旧的“正在生成”摘要。点击失败草稿应优先用 notice / 后端 session / fallback payload 组装失败生成页,不能重新从 0 秒启动新进度;失败页点击重新生成必须优先复用当前 `sessionId` 执行编译 action不得因存在表单缓存 payload 就调用 create-session。拼图失败半成品没有有效 `workTitle` 时,作品架标题回退为“拼图草稿”。
- 验证:`node node_modules/vitest/vitest.mjs run src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed parallel puzzle|background match3d"`
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/custom-world-home/creationWorkShelf.ts``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 生成失败重试不要走新建草稿
- 现象:拼图或抓大鹅生成失败后,在失败页点击“重新生成”,作品架里多出一份新的草稿,原失败草稿仍留在列表里。
- 原因:重试 handler 曾优先读取缓存的表单 payload 并调用 create-session 路径;失败草稿按 session 留在作品架是正确行为,于是重试动作额外创建了第二份草稿。
- 处理:只要当前失败页还能恢复到原 `sessionId`,重试就走该 session 的 compile action只有没有可恢复 session 时,才允许用表单 payload 重新创建草稿。
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed .* draft retry reuses current session"`
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 汪汪声浪草稿试玩不要写正式 run
- 现象:如果草稿结果页试玩和发布后 runtime 共用同一写成绩路径,未发布或未确认资源的草稿试玩会污染正式单局、排行榜和作品统计。

View File

@@ -54,7 +54,7 @@
4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。
5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。
6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 优先使用后端 session 的 `updatedAt`,没有 session 时再使用作品摘要 `updatedAt`,不得因重新进入页面从 0 秒重新计时。
7. 生成失败必须按 session 独立记录,不能用一个失败打断或覆盖同玩法的其它生成任务。失败 notice 需要保存错误消息并覆盖作品架本地状态:即使后端摘要暂时仍是 `generationStatus=generating` 或只写出半成品投影,草稿卡也不得继续显示“生成中”,点击后必须进入失败 / 重试生成页,不能重新创建一轮生成拼图这类失败半成品若没有有效 `workTitle`作品架标题回退为“拼图草稿”不暴露“第1关”空壳。
7. 生成失败必须按 session 独立记录,不能用一个失败打断或覆盖同玩法的其它生成任务。失败 notice 需要保存错误消息并覆盖作品架本地状态:即使后端摘要暂时仍是 `generationStatus=generating` 或只写出半成品投影,草稿卡也不得继续显示“生成中”,点击后必须进入失败 / 重试生成页,不能重新创建一轮生成。失败页点击重新生成时必须优先复用当前可恢复 `sessionId` 执行编译 action只有没有可恢复 session 时才允许回退到新建草稿。拼图这类失败半成品若没有有效 `workTitle`作品架标题回退为“拼图草稿”不暴露“第1关”空壳。
8. 从草稿 Tab 作品架打开草稿工作区、生成页或结果页时,返回按钮必须回到草稿 Tab 的同一作品架语境;从创作 Tab 新建或直接进入创作链路时才回到创作 Tab。平台壳层需要显式记录本次创作流的返回来源不能让结果页返回动作固定跳到创作入口。
9. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
10. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。

View File

@@ -9449,20 +9449,26 @@ export function PlatformEntryFlowShellImpl({
const executeSquareHoleAction = squareHoleFlow.executeAction;
const retryMatch3DDraftGeneration = useCallback(() => {
if (match3dFormDraftPayload && !match3dSession?.draft?.profileId) {
void createMatch3DDraftFromForm(match3dFormDraftPayload);
if (match3dSession?.sessionId) {
const retryPayload =
match3dFormDraftPayload ??
buildMatch3DFormPayloadFromSession(match3dSession);
void executeMatch3DAction({
action: 'match3d_compile_draft',
generateClickSound: retryPayload.generateClickSound,
});
return;
}
void executeMatch3DAction({
action: 'match3d_compile_draft',
generateClickSound: match3dFormDraftPayload?.generateClickSound,
});
if (match3dFormDraftPayload) {
void createMatch3DDraftFromForm(match3dFormDraftPayload);
return;
}
}, [
createMatch3DDraftFromForm,
executeMatch3DAction,
match3dFormDraftPayload,
match3dSession?.draft?.profileId,
match3dSession,
]);
const retrySquareHoleAssetGeneration = useCallback(() => {
@@ -10336,15 +10342,25 @@ export function PlatformEntryFlowShellImpl({
);
const retryPuzzleDraftGeneration = useCallback(() => {
if (puzzleFormDraftPayload) {
void createPuzzleDraftFromForm(puzzleFormDraftPayload);
if (puzzleSession?.sessionId) {
const retryPayload =
puzzleFormDraftPayload ??
buildPuzzleFormPayloadFromSession(puzzleSession);
void executePuzzleAction(
buildPuzzleCompileActionFromFormPayload(retryPayload),
);
return;
}
void executePuzzleAction(
buildPuzzleCompileActionFromFormPayload(puzzleFormDraftPayload),
);
}, [createPuzzleDraftFromForm, executePuzzleAction, puzzleFormDraftPayload]);
if (puzzleFormDraftPayload) {
void createPuzzleDraftFromForm(puzzleFormDraftPayload);
}
}, [
createPuzzleDraftFromForm,
executePuzzleAction,
puzzleFormDraftPayload,
puzzleSession,
]);
const retryVisualNovelDraftGeneration = useCallback(() => {
if (!visualNovelFormDraftPayload) {

View File

@@ -4228,6 +4228,115 @@ test('background match3d draft failure notifies and reopens failed retry page',
expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(1);
});
test('failed match3d draft retry reuses current session instead of creating another draft', async () => {
const user = userEvent.setup();
const failedSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-retry-failed-session',
draft: null,
stage: 'collecting_config',
updatedAt: '2026-05-18T12:05:00.000Z',
});
const persistedFailedWork: Match3DWorkSummary = {
workId: 'match3d-retry-failed-work',
profileId: 'match3d-retry-failed-profile',
ownerUserId: 'user-1',
sourceSessionId: failedSession.sessionId,
gameName: '重试抓鹅',
themeText: '霓虹水果摊',
summary: '抓大鹅素材生成失败,可重新打开处理。',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-18T12:05:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'generating',
generatedItemAssets: [],
};
let rejectCompile!: (reason?: unknown) => void;
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
session: failedSession,
});
vi.mocked(match3dCreationClient.executeAction).mockReturnValueOnce(
new Promise((_, reject) => {
rejectCompile = reject;
}),
);
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: failedSession,
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('抓大鹅'));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
await screen.findByRole('progressbar', { name: '抓大鹅草稿生成进度' });
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [persistedFailedWork],
});
await act(async () => {
rejectCompile(new Error('抓大鹅素材服务失败'));
await Promise.resolve();
});
const failureDialog = await screen.findByRole('dialog', {
name: '发生错误',
});
await user.click(within(failureDialog).getByRole('button', { name: '关闭' }));
const draftPanel = getPlatformTabPanel('saves');
await user.click(
await within(draftPanel).findByRole('button', {
name: /继续创作《(?:重试抓鹅|抓大鹅草稿)》/u,
}),
);
const reopenedFailureDialog = await screen.findByRole('dialog', {
name: '发生错误',
});
await user.click(
within(reopenedFailureDialog).getByRole('button', { name: '关闭' }),
);
vi.mocked(match3dCreationClient.executeAction).mockResolvedValueOnce({
session: buildMockMatch3DAgentSession({
sessionId: failedSession.sessionId,
stage: 'draft_ready',
draft: {
profileId: persistedFailedWork.profileId,
gameName: persistedFailedWork.gameName,
themeText: persistedFailedWork.themeText,
summary: persistedFailedWork.summary,
tags: persistedFailedWork.tags,
coverImageSrc: null,
referenceImageSrc: null,
clearCount: persistedFailedWork.clearCount,
difficulty: persistedFailedWork.difficulty,
generatedItemAssets: [],
},
}),
});
await user.click(await screen.findByRole('button', { name: '重新生成草稿' }));
await waitFor(() => {
expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(2);
});
expect(match3dCreationClient.createSession).toHaveBeenCalledTimes(1);
expect(match3dCreationClient.executeAction).toHaveBeenNthCalledWith(
2,
failedSession.sessionId,
expect.objectContaining({ action: 'match3d_compile_draft' }),
);
});
test('running match3d persisted draft reopens progress instead of unfinished result', async () => {
const user = userEvent.setup();
const runningSession = buildMockMatch3DAgentSession({
@@ -4921,6 +5030,113 @@ test('failed parallel puzzle generations stay as separate non-generating drafts'
.toBeTruthy();
});
test('failed puzzle draft retry reuses current session instead of creating another draft', async () => {
const user = userEvent.setup();
const failedSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-retry-failed-session',
draft: null,
stage: 'collecting_anchors',
updatedAt: '2026-05-18T12:00:00.000Z',
});
const persistedFailedWork: PuzzleWorkSummary = {
workId: `puzzle-work-${failedSession.sessionId}`,
profileId: `puzzle-profile-${failedSession.sessionId}`,
ownerUserId: 'user-1',
sourceSessionId: failedSession.sessionId,
authorDisplayName: '测试玩家',
workTitle: '',
workDescription: '一套雨夜猫街主题拼图。',
levelName: '第1关',
summary: '一套雨夜猫街主题拼图。',
themeTags: [],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'draft',
updatedAt: '2026-05-18T12:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
generationStatus: 'failed',
levels: [],
};
let rejectCompile!: (reason?: unknown) => void;
vi.mocked(createPuzzleAgentSession).mockResolvedValue({
session: failedSession,
});
vi.mocked(getPuzzleAgentSession).mockResolvedValue({
session: failedSession,
});
vi.mocked(executePuzzleAgentAction).mockReturnValueOnce(
new Promise((_, reject) => {
rejectCompile = reject;
}),
);
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
await screen.findByRole('progressbar', { name: '拼图图片生成进度' });
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [persistedFailedWork],
});
await act(async () => {
rejectCompile(new Error('拼图图片生成失败'));
await Promise.resolve();
});
const failureDialog = await screen.findByRole('dialog', {
name: '发生错误',
});
await user.click(within(failureDialog).getByRole('button', { name: '关闭' }));
const draftPanel = getPlatformTabPanel('saves');
await user.click(
await within(draftPanel).findByRole('button', {
name: /继续创作《[^》]+》/u,
}),
);
const reopenedFailureDialog = await screen.findByRole('dialog', {
name: '发生错误',
});
await user.click(
within(reopenedFailureDialog).getByRole('button', { name: '关闭' }),
);
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
operation: {
operationId: 'compile-puzzle-retry',
type: 'compile_puzzle_draft',
status: 'completed',
phaseLabel: '已完成',
phaseDetail: '草稿已生成',
progress: 1,
},
session: buildMockPuzzleAgentSession({
sessionId: failedSession.sessionId,
stage: 'ready_to_publish',
progressPercent: 100,
draft: buildReadyPuzzleDraft(),
}),
});
await user.click(await screen.findByRole('button', { name: '重新生成图片' }));
await waitFor(() => {
expect(executePuzzleAgentAction).toHaveBeenCalledTimes(2);
});
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
expect(executePuzzleAgentAction).toHaveBeenNthCalledWith(
2,
failedSession.sessionId,
expect.objectContaining({ action: 'compile_puzzle_draft' }),
);
});
test('running puzzle draft opens generation progress from draft tab', async () => {
const user = userEvent.setup();
const runningSession = buildMockPuzzleAgentSession({