fix(editor): 修删除作品缺契约字段致真后端 400(反假绿挖出,纯前端修复)

前端 useWorkDelete 此前 api.delete 不传 body,而后端 deleteWork 要 @Valid RevisionCommandReqVO
(commandId 幂等 + expectedRevision 作品乐观锁)→ 真后端删作品必 400(dev mock 不校验长期掩盖)。
又因列表 VO(GET /works)不含 revision 字段(仅 detail 有),无法直接取乐观锁版本;故 useWorkDelete
改为删除前先读 work 详情拿 revision,再带 commandId 提交删除(删除低频、额外一次 GET 可接受)。

补 work-create-delete.spec.ts(配对自清理 e2e:建作品→删作品,验 DELETE 200/code:0 + 列表无残留)。
curl 实证后端 work create→delete code:0(无 chapter 那类 order_no schema bug)。

tsc/eslint 0,vitest 79/79,全量 e2e 42/0(+1 chapter fixme)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-21 14:08:08 -07:00
parent c4d3041268
commit 59d139637a
2 changed files with 75 additions and 1 deletions

View File

@ -0,0 +1,65 @@
import { expect, test, type Page } from '@playwright/test';
/**
* + e2e(,MSW off)
*
* 前置:vite VITE_API_MOCK=false muse-server 48080
* (绿):, deleteWork @Valid RevisionCommandReqVO(commandId +
* expectedRevision ), useWorkDelete api.delete body 400(dev mock )
* DELETE /works/{id} 200/code:0create D1 ,delete (,)
* 设计:唯一标题每次产新值;, window.confirm, page.once('dialog')
*/
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);
}
test('用户新建并删除作品(真实后端写命令:commandId + 作品乐观锁)', async ({ page }) => {
await seedToken(page);
const uniqueTitle = `作品·e2e-${Date.now()}`;
await page.goto('/');
await expect(page.getByRole('heading', { name: '我的创作台' })).toBeVisible({ timeout: 15_000 });
// 新建作品(复用 D1 弹窗流:打开弹窗→Schema 下拉→填标题→确认创建)。
await page.getByRole('button', { name: /新建作品|立即创建/ }).first().click();
await expect(page.getByLabel('作品结构 Schema')).toBeVisible({ timeout: 15_000 });
await page.getByPlaceholder('请输入小说或书籍的标题,例如:星海迷途').fill(uniqueTitle);
const [createResp] = await Promise.all([
page.waitForResponse(
(r) =>
/\/works$/.test(new URL(r.url()).pathname) && r.request().method() === 'POST',
{ timeout: 15_000 }
),
page.getByRole('button', { name: '确认创建' }).click(),
]);
expect(createResp.status()).toBe(200);
expect((await createResp.json()).code).toBe(0);
// 新作品卡片出现在创作台。
await expect(page.getByText(uniqueTitle)).toBeVisible({ timeout: 10_000 });
// 自清理:删除刚建作品。hover 卡片出垃圾桶按钮,confirm 自动接受。
page.once('dialog', (d) => d.accept());
const card = page.locator('.group').filter({ hasText: uniqueTitle });
await card.hover();
const [deleteResp] = await Promise.all([
page.waitForResponse(
(r) =>
/\/works\/[^/]+$/.test(new URL(r.url()).pathname) &&
r.request().method() === 'DELETE',
{ timeout: 15_000 }
),
card.getByTitle('删除作品').click(),
]);
expect(deleteResp.status()).toBe(200);
expect((await deleteResp.json()).code).toBe(0);
// 删除后列表刷新(onSuccess 失效 works 缓存 + navigate '/'),作品卡片消失,列表无残留。
await expect(page.getByText(uniqueTitle)).toHaveCount(0, { timeout: 10_000 });
});

View File

@ -68,7 +68,16 @@ export function useWorkDelete() {
const queryClient = useQueryClient();
const navigate = useNavigate();
return useMutation({
mutationFn: (workId: string) => api.delete<boolean>(`/works/${workId}`),
// 删作品是写命令:后端 deleteWork 要 @Valid RevisionCommandReqVO(commandId 幂等 + expectedRevision 作品乐观锁),
// 此前前端不传 body→真后端必 400(dev mock 掩盖)。列表 VO(GET /works)不含 revision,故删除前先读 work
// 详情拿当前 revision,再带 commandId 提交删除(删除低频,额外一次 GET 可接受;乐观锁兜底并发改写)。
mutationFn: async (workId: string) => {
const detail = await api.get<WorkVO>(`/works/${workId}`);
return api.delete<boolean>(`/works/${workId}`, {
commandId: crypto.randomUUID(),
expectedRevision: detail.revision,
});
},
onSuccess: (_, workId) => {
// 失效作品列表,强制拉取新列表
queryClient.invalidateQueries({ queryKey: ['works'] });