diff --git a/muse-studio/e2e/block-autosave.spec.ts b/muse-studio/e2e/block-autosave.spec.ts new file mode 100644 index 00000000..3cd745a0 --- /dev/null +++ b/muse-studio/e2e/block-autosave.spec.ts @@ -0,0 +1,52 @@ +import { expect, test, type Page } from '@playwright/test'; + +/** + * 正文自动保存写路 e2e(真实后端,MSW off)——写作台心脏,此前零 e2e 真验证(D 类反假绿)。 + * + * 反假绿背景:正文保存 PUT /works/{workId}/blocks/{id} 是作品工作台最关键写命令,但此前无任何 e2e 真打, + * 仅 MSW(不校验乐观锁)掩盖、"全量 e2e 全绿"绿在 mock 上。盘点 + curl 暴露真 bug: + * WorkspacePage 此前裸读 `activeBlock?.version`,而真后端 BlockRespVO 返回 `revision`(字段漂移,无 version), + * → expectedRevision 恒回退为 1 → 真后端乐观锁必冲突 `code:1041000002 Block版本冲突`(正文从未真保存成功)。 + * 修复:WorkspacePage 复用 blockRevision()(revision 优先、兼容 mock version),MuseEditor 读返回 revision。 + * 本测打真实 PUT 验 200/code:0 + 返回 revision——证乐观锁通过、正文真落库。 + * + * 设计:进 work1 chapter1 → 编辑器(tiptap)加载首 block → 打字触发 debounce 2s 自动保存 → 等真实 PUT。 + * 用 blockRevision 动态读当前 revision(不硬编码),故 block1 revision 每轮递增无害、无需 globalSetup 复位 content。 + * 注:与 accept-suggestion(work3/block3)、block-structure(只删多余 block)隔离,互不污染。 + */ +const TOKEN = 'test1'; + +async function seedToken(page: Page): Promise { + await page.addInitScript((t) => { + window.localStorage.setItem('accessToken', t); + window.localStorage.setItem('tenantId', '1'); + }, TOKEN); +} + +test('用户编辑正文触发自动保存(真实后端写命令:乐观锁 expectedRevision 读真实 revision)', async ({ page }) => { + await seedToken(page); + + await page.goto('/workspace/1'); + await expect(page.getByRole('heading', { name: /正在创作/ })).toBeVisible({ timeout: 15_000 }); + + // tiptap 编辑器(contenteditable)加载——chapter1 首 block 激活后可编辑。 + const editor = page.locator('[contenteditable="true"]').first(); + await expect(editor).toBeVisible({ timeout: 15_000 }); + + // 点编辑器聚焦 → 打字,触发 onUpdate → debouncedAPISave(2000ms) → PUT /works/1/blocks/{id}。 + await editor.click(); + await page.keyboard.type(`正文自动保存 e2e ${Date.now()}`); + + // 等真实自动保存 PUT(debounce 2s 后发出)→ 验 200 + code:0 + 返回 revision(乐观锁通过、真落库)。 + const saveResp = await page.waitForResponse( + (r) => + /\/works\/1\/blocks\/[^/]+$/.test(new URL(r.url()).pathname) && + r.request().method() === 'PUT', + { timeout: 15_000 } + ); + expect(saveResp.status()).toBe(200); + const body = await saveResp.json(); + // 修复前此处必为 1041000002(Block版本冲突,expectedRevision 恒为 1);修复后 code:0。 + expect(body.code).toBe(0); + expect(typeof body.data?.revision).toBe('number'); +}); diff --git a/muse-studio/e2e/global-setup.ts b/muse-studio/e2e/global-setup.ts index d43b8118..1e1e3405 100644 --- a/muse-studio/e2e/global-setup.ts +++ b/muse-studio/e2e/global-setup.ts @@ -138,6 +138,14 @@ async function globalSetup(): Promise { console.warn('[e2e globalSetup] ⚠️ 未找到"待确认演练"安全事件(account-security-ack 基础种子缺失)'); } + // 5a) market 申诉提交:刷新被驳回 publish_request 的 reviewed_at 到近期,使申诉 deadline(reviewedAt + 后端 + // APPEAL_DEADLINE_DAYS=7 天)未过。否则种子 request 随日期推进必然过期,「发起申诉」用例提交被后端拒 + // MARKET_APPEAL_DEADLINE_EXPIRED(1044000027)——此 fixture 老化与代码无关、纯时间推进所致 + // (见 MarketAppealServiceImpl.requireWithinDeadline / reviewRejectionRelation 取 request.reviewedAt)。 + await client.query( + "UPDATE muse_market_publish_request SET reviewed_at = NOW() WHERE tenant_id=1 AND status IN ('rejected','compliance_blocked')" + ); + // 5) market 申诉补充材料:把当前用户最早的一条申诉置 supplementing(canSupplement→展示「补充材料」)。 const ap = await client.query( "UPDATE muse_market_appeal SET status='supplementing' WHERE tenant_id=1 AND id=(SELECT min(id) FROM muse_market_appeal WHERE tenant_id=1 AND user_id=1)" diff --git a/muse-studio/src/features/editor/components/BlockStructureBar.tsx b/muse-studio/src/features/editor/components/BlockStructureBar.tsx index 22941bf6..111a689f 100644 --- a/muse-studio/src/features/editor/components/BlockStructureBar.tsx +++ b/muse-studio/src/features/editor/components/BlockStructureBar.tsx @@ -1,4 +1,4 @@ -import { useSplitBlock, useMergeBlock } from '../hooks/useBlockStructure'; +import { useSplitBlock, useMergeBlock, blockRevision } from '../hooks/useBlockStructure'; import type { BlockVO } from '@/types/openapi'; /** 从正文提取纯文本(用于摘要 + 计算分割中点);兼容 Tiptap JSON 与纯文本 content(基础种子可能是纯文本)。 */ @@ -19,16 +19,6 @@ function extractText(content: string): string { } } -/** - * 取 Block 乐观锁版本。 - * OpenAPI Block schema 标 `version`,但真后端 BlockRespVO 实际返回 `revision`(字段名漂移); - * 故 revision 优先,兼容 mock 的 version,兜底 1。 - */ -function blockRevision(block: BlockVO): number { - const b = block as { revision?: number; version?: number }; - return b.revision ?? b.version ?? 1; -} - interface BlockStructureBarProps { workId: string; chapterId: string; diff --git a/muse-studio/src/features/editor/components/MuseEditor.tsx b/muse-studio/src/features/editor/components/MuseEditor.tsx index 487d5843..69ebd25f 100644 --- a/muse-studio/src/features/editor/components/MuseEditor.tsx +++ b/muse-studio/src/features/editor/components/MuseEditor.tsx @@ -107,7 +107,10 @@ export default function MuseEditor({ blockId, workId, initialContent, revision, }; const result = await api.put(`/works/${workId}/blocks/${id}`, payload); - revisionRef.current = result?.newVersion ?? expectedRevision + 1; + // 真后端 saveBlock 返回 { revision }(curl 实证),openapi 类型却标 newVersion(字段漂移,同 blockRevision); + // revision 优先、兼容 mock newVersion、兜底 expectedRevision+1,避免连续保存乐观锁版本滞后→冲突。 + const saved = result as { revision?: number; newVersion?: number } | undefined; + revisionRef.current = saved?.revision ?? saved?.newVersion ?? expectedRevision + 1; // 提交成功后移除本地临时影子层备份,释放 IndexedDB 空间 await removeBlockDraft(id); diff --git a/muse-studio/src/features/editor/hooks/useBlockStructure.ts b/muse-studio/src/features/editor/hooks/useBlockStructure.ts index d930ddfb..b287402b 100644 --- a/muse-studio/src/features/editor/hooks/useBlockStructure.ts +++ b/muse-studio/src/features/editor/hooks/useBlockStructure.ts @@ -2,6 +2,19 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from '@/api/client'; import type { BlockVO, SplitBlockRequest, MergeBlockRequest } from '@/types/openapi'; +/** + * 取 Block 乐观锁版本。 + * OpenAPI Block schema 标 `version`,但真后端 BlockRespVO 实际返回 `revision`(字段名漂移); + * 故 revision 优先,兼容 mock 的 version,兜底 1。 + * WorkspacePage(正文自动保存)与 BlockStructureBar(分割/合并)共用——读错字段会让 expectedRevision 恒为 1、 + * 真后端乐观锁必冲突(dev mock 不校验故长期假绿)。 + */ +export function blockRevision(block: BlockVO | undefined): number { + if (!block) return 1; + const b = block as { revision?: number; version?: number }; + return b.revision ?? b.version ?? 1; +} + /** * Block 结构编辑(分割/合并)的 mutation hooks。 * diff --git a/muse-studio/src/features/editor/hooks/useWorks.ts b/muse-studio/src/features/editor/hooks/useWorks.ts index d898f0d3..b538c9fa 100644 --- a/muse-studio/src/features/editor/hooks/useWorks.ts +++ b/muse-studio/src/features/editor/hooks/useWorks.ts @@ -3,6 +3,16 @@ import { useNavigate } from 'react-router-dom'; import { api } from '@/api/client'; import type { WorkVO, ChapterVO, ChapterDetail } from '@/types/openapi'; +/** + * 取 Work 乐观锁版本。 + * 同 blockRevision:OpenAPI Work schema(列表 VO)无 revision,但真后端作品详情 GET /works/{id} 返回 revision(字段漂移); + * 故用宽松类型安全读取、兜底 1。chapter create(expectedWorkRevision)与 work delete(expectedRevision)共用, + * 避免裸读 work.revision 触发 TS 类型缺失(运行时有值、list VO 类型无此字段)。 + */ +export function workRevision(work: WorkVO | undefined | null): number { + return (work as { revision?: number } | null | undefined)?.revision ?? 1; +} + /** * 获取当前登录用户的所有小说作品列表 * queryKey 绑定为 ['works'] @@ -75,7 +85,7 @@ export function useWorkDelete() { const detail = await api.get(`/works/${workId}`); return api.delete(`/works/${workId}`, { commandId: crypto.randomUUID(), - expectedRevision: detail.revision, + expectedRevision: workRevision(detail), }); }, onSuccess: (_, workId) => { diff --git a/muse-studio/src/pages/WorkspacePage.tsx b/muse-studio/src/pages/WorkspacePage.tsx index 07c1ac1c..e57b42b5 100644 --- a/muse-studio/src/pages/WorkspacePage.tsx +++ b/muse-studio/src/pages/WorkspacePage.tsx @@ -3,8 +3,9 @@ import { useParams, useNavigate } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { api } from '@/api/client'; import { useEditorStore } from '@/stores/editorStore'; -import { useChapterList, useChapterDetail } from '@/features/editor/hooks/useWorks'; +import { useChapterList, useChapterDetail, workRevision } from '@/features/editor/hooks/useWorks'; import { useAcceptSuggestion } from '@/features/editor/hooks/useAcceptSuggestion'; +import { blockRevision } from '@/features/editor/hooks/useBlockStructure'; import { removeBlockDraft } from '@/lib/indexed-db'; import ChapterPanel from '../features/editor/components/ChapterPanel'; import MuseEditor from '../features/editor/components/MuseEditor'; @@ -82,8 +83,9 @@ export default function WorkspacePage() { setRevisionOverride(null); } - // 当前 Block 的有效 revision:优先本地覆盖值,否则取查询返回的 version。 - const effectiveRevision = revisionOverride ?? activeBlock?.version ?? 1; + // 当前 Block 的有效 revision:优先本地覆盖值,否则取 block 真实 revision(blockRevision 兼容真后端 + // revision/mock version 字段漂移)。此前裸读 activeBlock?.version → 真后端无此字段 → 恒为 1 → 正文保存乐观锁必冲突。 + const effectiveRevision = revisionOverride ?? blockRevision(activeBlock); // 采纳候选 mutation:固定绑定当前作品与当前激活 Block。 const acceptSuggestion = useAcceptSuggestion(workId || '', activeBlock?.id || ''); @@ -199,7 +201,7 @@ export default function WorkspacePage() { workId={workId} activeChapterId={activeChapterId} onSelectChapter={setActiveChapter} - expectedWorkRevision={work?.revision} + expectedWorkRevision={workRevision(work)} /> {/* 右侧编辑器动态装配区 */}