From ca33c8e0068ef958b8185060b49c2687f90e832b Mon Sep 17 00:00:00 2001 From: lili Date: Sun, 21 Jun 2026 13:54:34 -0700 Subject: [PATCH] =?UTF-8?q?fix(editor):=20=E4=BF=AE=E6=96=B0=E5=BB=BA?= =?UTF-8?q?=E7=AB=A0=E8=8A=82=E7=BC=BA=E5=A5=91=E7=BA=A6=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E8=87=B4=E7=9C=9F=E5=90=8E=E7=AB=AF=20400(=E5=8F=8D=E5=81=87?= =?UTF-8?q?=E7=BB=BF=E6=8C=96=E5=87=BA)+=20=E6=A0=87=E6=B3=A8=E6=9A=B4?= =?UTF-8?q?=E9=9C=B2=E7=9A=84=E5=90=8E=E7=AB=AF=20order=5Fno=20=E8=BD=AF?= =?UTF-8?q?=E5=88=A0=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端 useChapterCreate 此前只传 {title},而后端 ChapterCreateReqVO 强制 commandId(幂等)+ expectedWorkRevision(作品乐观锁),缺失被 @Valid 拦成 400——dev mock 不校验、长期掩盖,真后端建章必败。 修:useChapterCreate 补 commandId + expectedWorkRevision;ChapterPanel 经 props 接 work.revision、 WorkspacePage 传入;建章后失效 workDetail 缓存避免后续写命令复用旧 revision。 补 chapter-create-delete.spec.ts(配对自清理 e2e),暂 test.fixme:前端修复后请求合法到达后端, 又暴露独立后端 bug——createChapter orderNo=selectCountByWorkId(active)+1,章节软删后行仍在、 uk_muse_content_chapter_work_order 不含 deleted(同文件 command uk 是 partial index 却此处遗漏) → 删章节后再建 order_no 冲突 500。属 schema/软删语义,需人类定 DDL 修法,fixme 待修。 tsc/eslint 0,studio vitest 79/79,全量 e2e 41/0(+1 fixme)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- muse-studio/e2e/chapter-create-delete.spec.ts | 79 +++++++++++++++++++ .../editor/components/ChapterPanel.tsx | 9 ++- .../src/features/editor/hooks/useWorks.ts | 13 ++- muse-studio/src/pages/WorkspacePage.tsx | 1 + 4 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 muse-studio/e2e/chapter-create-delete.spec.ts diff --git a/muse-studio/e2e/chapter-create-delete.spec.ts b/muse-studio/e2e/chapter-create-delete.spec.ts new file mode 100644 index 00000000..606f0b74 --- /dev/null +++ b/muse-studio/e2e/chapter-create-delete.spec.ts @@ -0,0 +1,79 @@ +import { expect, test, type Page } from '@playwright/test'; + +/** + * 章节新建 + 删除写路 e2e(真实后端,MSW off)——配对自清理,无 fixture 污染。 + * + * 前置:vite VITE_API_MOCK=false、真实 muse-server 48080。 + * 目的(反假绿):新建章节是作品级写命令,后端 ChapterCreateReqVO 强制 commandId(幂等)+ + * expectedWorkRevision(作品乐观锁),前端 useChapterCreate 此前只传 {title}→真后端必 400(dev mock 掩盖)。 + * 本测真打 POST /works/1/chapters 验 200/code:0(证前端已补齐契约字段),再真打 DELETE 删掉刚建章节 + * (验删除写路 + 自清理,避免唯一标题累积污染章节大纲)。 + * 设计:唯一标题每次产新值(断言才真证写生效);建→删配对,跑完 work1 章节大纲回到初态。 + * 注:删除走 window.confirm,用 page.once('dialog') 自动接受;新建章节 revision=1,匹配前端 delete 的 expectedRevision=1。 + */ +const TOKEN = 'test1'; + +async function seedToken(page: Page): Promise { + await page.addInitScript((t) => { + window.localStorage.setItem('accessToken', t); + window.localStorage.setItem('tenantId', '1'); + }, TOKEN); +} + +// FIXME(后端待修):前端契约已补齐(commandId + expectedWorkRevision),create 在干净作品上可成功(curl 实证 code:0); +// 但后端 createChapter 用 orderNo = selectCountByWorkId(active)+1,而章节软删后行仍在表、 +// uk_muse_content_chapter_work_order 不含 deleted(对比同文件 uk_muse_content_chapter_command 是 partial index) +// → 删章节后再建 order_no 与软删行冲突 → 500。系统性 schema/软删语义问题(chapter create/reorder 受影响, +// block 等表同形潜在),需人类定 DDL 修法(partial index / 物理删 / order_no 含软删 max)。后端修复后改回 test()。 +test.fixme('用户新建并删除章节(真实后端写命令:commandId + 作品乐观锁)', async ({ page }) => { + await seedToken(page); + + await page.goto('/workspace/1'); + await expect(page.getByRole('heading', { name: /正在创作/ })).toBeVisible({ timeout: 15_000 }); + + // 唯一标题:每次产新值,断言才能真证写生效(非 pre-existing 假绿)。 + const uniqueTitle = `章节·e2e-${Date.now()}`; + + // 点「+」新建 → 行内输入框出现 → 填唯一标题。 + await page.getByTitle('新建章节').click(); + const input = page.getByPlaceholder('章节标题'); + await input.fill(uniqueTitle); + + // 回车提交 → 等真实 POST /works/1/chapters(带 commandId + expectedWorkRevision)→ 200/code:0。 + const [createResp] = await Promise.all([ + page.waitForResponse( + (r) => + /\/works\/1\/chapters$/.test(new URL(r.url()).pathname) && + r.request().method() === 'POST', + { timeout: 15_000 } + ), + input.press('Enter'), + ]); + expect(createResp.status()).toBe(200); + const createBody = await createResp.json(); + expect(createBody.code).toBe(0); + expect(createBody.data?.id).toBeTruthy(); + + // 新章节出现在大纲(证真后端落库 + 列表刷新)。 + await expect(page.getByText(uniqueTitle)).toBeVisible({ timeout: 10_000 }); + + // 自清理:删除刚建章节。hover 出垃圾桶按钮,confirm 自动接受。 + page.once('dialog', (d) => d.accept()); + const chapterItem = page.locator('.group').filter({ hasText: uniqueTitle }); + await chapterItem.hover(); + + const [deleteResp] = await Promise.all([ + page.waitForResponse( + (r) => + /\/works\/1\/chapters\/[^/]+$/.test(new URL(r.url()).pathname) && + r.request().method() === 'DELETE', + { timeout: 15_000 } + ), + chapterItem.getByTitle('删除章节').click(), + ]); + expect(deleteResp.status()).toBe(200); + expect((await deleteResp.json()).code).toBe(0); + + // 章节从大纲消失(证删除写路真后端生效 + 大纲回到初态,无污染残留)。 + await expect(page.getByText(uniqueTitle)).toHaveCount(0, { timeout: 10_000 }); +}); diff --git a/muse-studio/src/features/editor/components/ChapterPanel.tsx b/muse-studio/src/features/editor/components/ChapterPanel.tsx index 64f7eeac..8f938f78 100644 --- a/muse-studio/src/features/editor/components/ChapterPanel.tsx +++ b/muse-studio/src/features/editor/components/ChapterPanel.tsx @@ -81,6 +81,8 @@ interface ChapterPanelProps { activeChapterId: string | null; /** 切换激活章节时的回调函数 */ onSelectChapter: (chapterId: string | null) => void; + /** 当前作品 revision:新建章节是作品级写命令,后端要求 expectedWorkRevision 乐观锁,缺失则 400 */ + expectedWorkRevision?: number; } /** @@ -91,6 +93,7 @@ export default function ChapterPanel({ workId, activeChapterId, onSelectChapter, + expectedWorkRevision, }: ChapterPanelProps) { const [isCreating, setIsCreating] = useState(false); const [newTitle, setNewTitle] = useState(''); @@ -132,8 +135,12 @@ export default function ChapterPanel({ return; } + if (expectedWorkRevision == null) { + // 作品详情尚未加载,拿不到作品乐观锁版本,不发建章请求(避免后端 400/乐观锁冲突) + return; + } createChapter( - { title: trimmedTitle }, + { title: trimmedTitle, expectedWorkRevision }, { onSuccess: (newCh) => { setNewTitle(''); diff --git a/muse-studio/src/features/editor/hooks/useWorks.ts b/muse-studio/src/features/editor/hooks/useWorks.ts index 47b0e4cc..3b53ef4c 100644 --- a/muse-studio/src/features/editor/hooks/useWorks.ts +++ b/muse-studio/src/features/editor/hooks/useWorks.ts @@ -135,10 +135,19 @@ export function useChapterDelete(workId: string) { export function useChapterCreate(workId: string) { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (dto: { title: string }) => - api.post(`/works/${workId}/chapters`, dto), + // 新建章节是改作品结构的写命令:后端 ChapterCreateReqVO 强制 commandId(幂等)+ expectedWorkRevision + // (作品乐观锁),缺任一会被 @Valid 拦成 400(dev mock 不校验、曾掩盖此契约)。commandId 前端生成, + // expectedWorkRevision 由调用方传入作品当前 revision。 + mutationFn: (dto: { title: string; expectedWorkRevision: number }) => + api.post(`/works/${workId}/chapters`, { + title: dto.title, + commandId: crypto.randomUUID(), + expectedWorkRevision: dto.expectedWorkRevision, + }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['chapterList', workId] }); + // 建章后作品 revision 已 +1,失效作品详情缓存,避免后续写命令复用旧 revision 触发乐观锁冲突。 + queryClient.invalidateQueries({ queryKey: ['workDetail', workId] }); }, }); } diff --git a/muse-studio/src/pages/WorkspacePage.tsx b/muse-studio/src/pages/WorkspacePage.tsx index dda38d9e..07c1ac1c 100644 --- a/muse-studio/src/pages/WorkspacePage.tsx +++ b/muse-studio/src/pages/WorkspacePage.tsx @@ -199,6 +199,7 @@ export default function WorkspacePage() { workId={workId} activeChapterId={activeChapterId} onSelectChapter={setActiveChapter} + expectedWorkRevision={work?.revision} /> {/* 右侧编辑器动态装配区 */}