fix(editor): 修新建章节缺契约字段致真后端 400(反假绿挖出)+ 标注暴露的后端 order_no 软删 bug

前端 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) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-21 13:54:34 -07:00
parent 901f8e8aec
commit ca33c8e006
4 changed files with 99 additions and 3 deletions

View File

@ -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<void> {
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 });
});

View File

@ -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('');

View File

@ -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<ChapterVO>(`/works/${workId}/chapters`, dto),
// 新建章节是改作品结构的写命令:后端 ChapterCreateReqVO 强制 commandId(幂等)+ expectedWorkRevision
// (作品乐观锁),缺任一会被 @Valid 拦成 400(dev mock 不校验、曾掩盖此契约)。commandId 前端生成,
// expectedWorkRevision 由调用方传入作品当前 revision。
mutationFn: (dto: { title: string; expectedWorkRevision: number }) =>
api.post<ChapterVO>(`/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] });
},
});
}

View File

@ -199,6 +199,7 @@ export default function WorkspacePage() {
workId={workId}
activeChapterId={activeChapterId}
onSelectChapter={setActiveChapter}
expectedWorkRevision={work?.revision}
/>
{/* 右侧编辑器动态装配区 */}