fix(studio): 正文保存补真后端 e2e + 修 block/work revision 字段漂移(D 类反假绿)
D 类(缺口盘点最高优先):正文自动保存是写作台心脏却零 e2e、仅 MSW 掩盖。补
block-autosave.spec.ts 真打 PUT /works/{id}/blocks/{id} 暴露并修真 bug:
- block revision 漂移:WorkspacePage 裸读 activeBlock.version(真后端返 revision、无 version)
→ expectedRevision 恒为 1 → 真后端乐观锁必冲突(1041000002,curl 实证)。提 blockRevision()
到 useBlockStructure 共享、WorkspacePage 复用。
- work revision 漂移(tsc -b 增量重检暴露):WorkVO 类型无 revision、真后端 detail 返 revision,
chapter create/work delete 裸读致 TS 报错。加 workRevision() helper。
- MuseEditor saveBlock 返回 { revision }(非 openapi 标的 newVersion),L110 改 revision 优先兜底。
- market 申诉 deadline fixture 老化(纯时间推进、非代码):reviewedAt+7天过期致提交被拒
1044000027,globalSetup 刷新被驳回 request.reviewed_at 到近期。
验证:block-autosave + block-structure/chapter/work 回归 4/4 绿;全量 e2e 44/0;tsc/eslint/vitest 79/79。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d59fdab9b2
commit
a581fec913
52
muse-studio/e2e/block-autosave.spec.ts
Normal file
52
muse-studio/e2e/block-autosave.spec.ts
Normal file
@ -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<void> {
|
||||
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');
|
||||
});
|
||||
@ -138,6 +138,14 @@ async function globalSetup(): Promise<void> {
|
||||
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)"
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -107,7 +107,10 @@ export default function MuseEditor({ blockId, workId, initialContent, revision,
|
||||
};
|
||||
|
||||
const result = await api.put<SaveBlockResult>(`/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);
|
||||
|
||||
@ -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。
|
||||
*
|
||||
|
||||
@ -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<WorkVO>(`/works/${workId}`);
|
||||
return api.delete<boolean>(`/works/${workId}`, {
|
||||
commandId: crypto.randomUUID(),
|
||||
expectedRevision: detail.revision,
|
||||
expectedRevision: workRevision(detail),
|
||||
});
|
||||
},
|
||||
onSuccess: (_, workId) => {
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
|
||||
{/* 右侧编辑器动态装配区 */}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user