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:
lili 2026-06-22 07:02:13 -07:00
parent d59fdab9b2
commit a581fec913
7 changed files with 95 additions and 17 deletions

View 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');
});

View File

@ -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)"

View File

@ -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;

View File

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

View File

@ -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
*

View File

@ -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();
* 1chapter 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) => {

View File

@ -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 真实 revisionblockRevision 兼容真后端
// 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)}
/>
{/* 右侧编辑器动态装配区 */}