fix(studio): 修新建章节双提交 + work 乐观锁版本兜底两处 bug(studio e2e 真连揪出)

studio e2e chapter-create-delete 偶发 code=1041000002,根因两个独立前端 bug:①ChapterPanel 双提交:input onKeyDown(Enter→submitCreate)+onBlur=submitCreate,Enter 提交成功后 setIsCreating(false)卸载 input→onBlur 二次触发,isCreatingPending(异步)+disabled 滞后挡不住→重复 POST(第 2 个同 reqRev→500)。修:submitCreate 加同步 useRef 守卫(提交中早返+置位+onSettled 复位),只挡同次重入、不挡首次/失焦补提交。②WorkspacePage expectedWorkRevision 兜底:原 workRevision(work)??1,work 详情异步未加载时兜成 1、但真实 revision 已 >1→乐观锁冲突。改传真实 revision(未加载 undefined→ChapterPanel 既有 ==null 早返挡过早提交)。

实证:单次新建 POST 修前 2(第 1 成功+第 2 1041000002)→修后 6 轮恒 1;chapter-create-delete 连跑 8 次全绿。相邻写路 spec 不回归、tsc 0 错、editor 单测 33/33。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-25 13:47:28 -07:00
parent 80106c2d40
commit 443a1f3414
2 changed files with 29 additions and 5 deletions

View File

@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useRef } from 'react';
import { useChapterList, useChapterCreate, useChapterDelete } from '../hooks/useWorks';
import type { ChapterVO } from '@/types/openapi';
@ -81,8 +81,12 @@ interface ChapterPanelProps {
activeChapterId: string | null;
/** 切换激活章节时的回调函数 */
onSelectChapter: (chapterId: string | null) => void;
/** 当前作品 revision:新建章节是作品级写命令,后端要求 expectedWorkRevision 乐观锁,缺失则 400 */
expectedWorkRevision?: number;
/**
* revision:新建章节是作品级写命令, expectedWorkRevision , 400
* , undefined( 1, revision>1 );
* submitCreate null/undefined , work
*/
expectedWorkRevision?: number | undefined;
}
/**
@ -97,6 +101,11 @@ export default function ChapterPanel({
}: ChapterPanelProps) {
const [isCreating, setIsCreating] = useState(false);
const [newTitle, setNewTitle] = useState('');
// 同步提交守卫:isCreatingPending(React Query isPending)是异步 state、disabled 滞后,挡不住同一事件循环内的二次重入。
// 触发链:回车 submitCreate(第 1 次建章,reqRev=N)→ onSuccess 卸载 input / 焦点变化触发 onBlur → submitCreate(第 2 次)。
// 此时 newTitle 未清、expectedWorkRevision 仍是旧值 N,而 work 已被第 1 次推进到 N+1 → 第 2 次乐观锁冲突 500(1041000002)。
// useRef 在提交瞬间同步置位,挡住进行中提交的重入;只挡"同一次提交",不挡首次提交与失焦补提交的正常用途。
const submittingRef = useRef(false);
// 1. 数据流 Hooks
const { data: chapters = [], isLoading } = useChapterList(workId);
@ -129,6 +138,11 @@ export default function ChapterPanel({
// 3. 提交行内新建章节表单
const submitCreate = () => {
// 同步守卫:同一次提交进行中时,挡住 onBlur 等触发的二次重入(避免复用旧 expectedWorkRevision 致乐观锁冲突 500)。
if (submittingRef.current) {
return;
}
const trimmedTitle = newTitle.trim();
if (!trimmedTitle) {
setIsCreating(false);
@ -139,6 +153,8 @@ export default function ChapterPanel({
// 作品详情尚未加载,拿不到作品乐观锁版本,不发建章请求(避免后端 400/乐观锁冲突)
return;
}
// 置位须在 createChapter 调用之前:阻断同一事件循环内 onBlur 的重入。
submittingRef.current = true;
createChapter(
{ title: trimmedTitle, expectedWorkRevision },
{
@ -150,6 +166,10 @@ export default function ChapterPanel({
onSelectChapter(newCh.id);
}
},
// 无论成功失败都复位守卫,放行下一次正常新建(成功后 input 已卸载;失败时用户可重试)。
onSettled: () => {
submittingRef.current = false;
},
}
);
};

View File

@ -3,7 +3,7 @@ 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, workRevision } from '@/features/editor/hooks/useWorks';
import { useChapterList, useChapterDetail } 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';
@ -201,7 +201,11 @@ export default function WorkspacePage() {
workId={workId}
activeChapterId={activeChapterId}
onSelectChapter={setActiveChapter}
expectedWorkRevision={workRevision(work)}
// 传作品真实 revision(未加载时为 undefined,不可兜底 1):workRevision 的 ?? 1 兜底用于 work delete 路径,
// 但建章乐观锁不能拿假版本——work 详情(含 revision)异步加载,而 "正在创作:" 标题静态恒显、
// 用户/测试可在 work 未到位时就回车建章。若兜底 1,真实 work revision 已是 N(>1)→ 后端乐观锁冲突 1041000002(偶发红)。
// 传 undefined 则交给 ChapterPanel 既有 `expectedWorkRevision == null` 早返门禁,挡住 work 未加载时的过早提交。
expectedWorkRevision={(work as { revision?: number } | undefined)?.revision}
/>
{/* 右侧编辑器动态装配区 */}