From 22ea4c31ede66e8698ce9830c9ddda0750e9d8b2 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 19 Sep 2026 11:33:03 +0000 Subject: [PATCH] refactor(web): sync server documents from one hook Keep freeform append, workflow reconcile, and lease polling together so a failed list cannot wipe local docs. Co-authored-by: Cursor --- .../long-term/2026-09-19-audit-remainder.md | 2 +- web/src/App.tsx | 140 +------------- web/src/hooks/useServerDocumentSync.test.ts | 15 ++ web/src/hooks/useServerDocumentSync.ts | 181 ++++++++++++++++++ 4 files changed, 206 insertions(+), 132 deletions(-) create mode 100644 web/src/hooks/useServerDocumentSync.test.ts create mode 100644 web/src/hooks/useServerDocumentSync.ts diff --git a/.doc/ops/long-term/2026-09-19-audit-remainder.md b/.doc/ops/long-term/2026-09-19-audit-remainder.md index 17f854db..80cd617c 100644 --- a/.doc/ops/long-term/2026-09-19-audit-remainder.md +++ b/.doc/ops/long-term/2026-09-19-audit-remainder.md @@ -46,7 +46,7 @@ updated: 2026-09-19 | FE 缓存 | done(版本字段)。窗口缓存写入 `{schema,data}`,无 schema 的旧载荷丢弃。`window cache: 无 schema` | | FE lucide | 关闭理由:`package-lock.json` 锁定 `lucide-react@1.42.0`,来源 `registry.npmjs.org`,带 integrity。`^1.16.0` 是合法的 1.x 范围,不是来源不明的包 | | M7 | done(出站意图 + 同键重放)。`document_content_projections` 与账本同一事务写入;投影成功才销账。`TestDocRevision_PatchOrder_RetryProjectsWithoutSecondLedgerCommit`。账本库与正文库仍不是一个 SQL 事务,进程退出后靠未投影行重放,不靠客户端记忆 | -| FE App.tsx | 部分。登录壳不再静态引用重模块。本地缓存写入在 `useWorkbenchPersistence`。登录后的项目/成员/会话同步在 `useAuthenticatedWorkspaceSync`。受控文档补水与重试在 `useControlledDocumentHydration`。工作台主体仍在 `App.tsx` | +| FE App.tsx | 部分。登录壳不再静态引用重模块。本地缓存写入在 `useWorkbenchPersistence`。登录后的项目/成员/会话同步在 `useAuthenticatedWorkspaceSync`。受控文档补水与重试在 `useControlledDocumentHydration`。自由文档、工作流对账和编辑租约轮询在 `useServerDocumentSync`。工作台主体仍在 `App.tsx` | | H1-del | 关闭理由:`allowLegacyTestPrincipal` 仍被 `AllowInMemory && ContextDbHost==""` 的 dev/test 内存测试使用。删除会把这批测试变成未登录 401。保留告警,删除另开、先改测试夹具 | 仍开放:rdev P3 三样本真 PI、首次加载 U1/U6/U7、dev 容器重建与治理库 PG。公式引擎已离开入口包(`index-j0BIrjIR.js` 737.38 kB,br 170,451 B,包内无 katex),U3 仍缺浏览器瀑布,不关。P4 键名空间化已在本分支落地:新清单用带前缀的任务键,旧键与旧 doc_id 仍可读,`TestPublishedTaskKeysAreNamespacedAndDisjoint`。意图短语仍输出旧键,执行路径按别名接上。P2 规划界面已折叠长阶段、可批量纳入人工审批,并在选中 rdev 时提示 CDO 前置。U4 登录扇出收口仍有效,浏览器请求次数证据还没有。2026-09-19 在 `fix/audit-remainder-closeout` 跑过 `cd web && npm test && npm run build`:node:test 414 pass / 0 fail,vitest 32 pass;入口 `index-BFf6n8IA.js` 1,005.01 kB,br 235,285 B。CURRENT 文首 schema 52 与三套容器一致,00053 要等重建后才升。H2 批注/审计的项目成员检查在 Postgres 上走 `RequireProjectAccess`;内存授权器在无库时把任意正用户 id 视为成员,这是 `TestAuthorizeDocRevision_ReviewerAndNonMemberCannotWrite` 的隔离前提,不能改成默认拒绝。H2 的 HTTP 负例因此要在 `TEST_DATABASE_URL` 上补,本机未设置该变量。M7 的投影意图已随账本提交(goose `00053`);容器 schema 仍是 52,要等占槽重建后才升到 53。 diff --git a/web/src/App.tsx b/web/src/App.tsx index 36afbda0..f2eb69ed 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,7 +12,6 @@ import { FreeformDocumentActionBar } from './components/doc/FreeformDocumentActi import { createFreeformDoc, saveMessageToPrivate, - listFreeformDocs, fetchFreeformDoc, shareFreeformDoc, unshareFreeformDoc, @@ -87,7 +86,6 @@ import { planControlledContentInvalidation, type ControlledInvalidationPlan, } from './lib/controlledContentInvalidation'; -import { applyLeaseUpdates, sameLease } from './lib/deliverableLeaseSync'; import { createEditorHydrationGate, } from './lib/editorHydrationGate'; @@ -196,6 +194,7 @@ import { import { useChatWindow } from './hooks/useChatWindow'; import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspaceSync'; import { useControlledDocumentHydration } from './hooks/useControlledDocumentHydration'; +import { useServerDocumentSync } from './hooks/useServerDocumentSync'; import { useWorkbenchPersistence } from './hooks/useWorkbenchPersistence'; import { useTurnRuntime } from './hooks/useTurnRuntime'; import { useTaskRunStream } from './hooks/useTaskRunStream'; @@ -1188,140 +1187,19 @@ export default function App() { } }; - // 挂载或项目切换时,拉取服务端的自由文档(我的私密与项目共享)并同步至 deliverables - useEffect(() => { - if (!session || sessionValidated !== true || !currentProjectId) return; - let active = true; - const fetchDocs = async () => { - try { - const [privateSummaries, sharedSummaries] = await Promise.all([ - listFreeformDocs('my_private', currentProjectId).catch(() => []), - listFreeformDocs('project_shared', currentProjectId).catch(() => []), - ]); - if (!active) return; - const allSummaries = [...privateSummaries, ...sharedSummaries]; - if (allSummaries.length === 0) return; - - setDeliverables((prev) => { - let updated = [...prev]; - for (const s of allSummaries) { - const exists = updated.find((d) => d.docId === s.doc_id); - const category = s.space === 'my_private' ? 'private' : 'shared'; - if (!exists) { - updated.unshift({ - docId: s.doc_id, - projectId: s.project_id || currentProjectId, - taskKey: 'freeform_doc', - title: s.title, - stage: 'V0.1', - category, - creatorId: s.owner_user_id || currentUser.id, - creatorName: currentUser.name, - isFixedWorkflow: false, - isTemplateOnly: false, - lifecycleState: 'drafting', - isClaimed: true, - author: currentUser.name, - reviewer: '', - approver: '', - updatedAt: (s.updated_at || s.created_at || '').replace('T', ' ').substring(0, 16), - governanceMode: 'freeform', - space: s.space, - }); - } - } - return updated; - }); - } catch (e) { - console.warn('获取服务端自由文档列表异常:', e); - } - }; - fetchDocs(); - return () => { - active = false; - }; - }, [session, sessionValidated, currentProjectId, currentUser.id]); - - // 挂载或项目切换时,拉取服务端工作流受控文档并覆盖本地模板状态 - useEffect(() => { - if (!session || sessionValidated !== true || !currentProjectId || currentProjectId <= 0) return; - let active = true; - const syncWorkflowDocs = async () => { - try { - // 成功返回(含空数组)才对账;失败不得调用 merge,以免误清本地真实项。 - const workflowDocs = await listProjectWorkflowDocuments(currentProjectId); - if (!active) return; - setDeliverables((prev) => - mergeWorkflowDocumentsFromServer(prev, workflowDocs, currentProjectId, currentUser), - ); - applyControlledInvalidationPlan( - planControlledContentInvalidation({ - reason: 'list_sync', - candidateIds: controlledHydrateCandidateIds( - deliverablesRef.current, - currentProjectId, - ), - activeDocId: activeDocIdRef.current, - editorOpen: editorHydrationGateRef.current.isEditorOpen(), - }), - ); - } catch (e) { - console.warn('[deliverables] 工作流受控文档同步失败:', e); - } - }; - void syncWorkflowDocs(); - return () => { - active = false; - }; - }, [ + useServerDocumentSync({ session, sessionValidated, currentProjectId, - currentUser.id, - currentUser.name, + currentUser, workflowDocsEpoch, + setDeliverables, + setDocCache, + deliverablesRef, + activeDocIdRef, + editorHydrationGateRef, applyControlledInvalidationPlan, - ]); - - // 文档占用是多人共享运行态:定期从服务端同步,避免其他标签页/成员开始编辑后本页无感知。 - useEffect(() => { - if (!session || sessionValidated !== true || currentProjectId <= 0) return; - let cancelled = false; - const syncLeases = async () => { - const docs = await listProjectWorkflowDocuments(currentProjectId).catch(() => []); - if (cancelled || docs.length === 0) return; - const leases = new Map( - docs.map((doc) => [ - doc.doc_id, - doc.lease_holder_type - ? { - holderType: doc.lease_holder_type, - holderId: doc.lease_holder_id, - expiresAt: doc.lease_expires_at, - heartbeatAt: doc.lease_heartbeat_at, - } - : undefined, - ]), - ); - setDeliverables((prev) => applyLeaseUpdates(prev, leases, currentProjectId)); - setDocCache((prev) => { - let changed = false; - const next = { ...prev }; - leases.forEach((editLease, docId) => { - if (!next[docId]) return; - if (sameLease(next[docId].editLease, editLease)) return; - next[docId] = { ...next[docId], editLease }; - changed = true; - }); - return changed ? next : prev; - }); - }; - const timer = window.setInterval(() => void syncLeases(), 5000); - return () => { - cancelled = true; - window.clearInterval(timer); - }; - }, [session, sessionValidated, currentProjectId]); + }); // 模态弹窗与侧栏 const [commentingBlock, setCommentingBlock] = useState(null); diff --git a/web/src/hooks/useServerDocumentSync.test.ts b/web/src/hooks/useServerDocumentSync.test.ts new file mode 100644 index 00000000..01beca62 --- /dev/null +++ b/web/src/hooks/useServerDocumentSync.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('server document list sync lives in one hook', () => { + const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); + const hook = readFileSync(new URL('./useServerDocumentSync.ts', import.meta.url), 'utf8'); + assert.equal((app.match(/useServerDocumentSync\(/g) || []).length, 1); + assert.equal(app.includes('获取服务端自由文档列表异常'), false); + assert.equal(app.includes('工作流受控文档同步失败'), false); + assert.equal(hook.includes('listFreeformDocs'), true); + assert.equal(hook.includes('mergeWorkflowDocumentsFromServer'), true); + assert.equal(hook.includes('applyLeaseUpdates'), true); + assert.equal(hook.includes('失败不得调用 merge'), true); +}); diff --git a/web/src/hooks/useServerDocumentSync.ts b/web/src/hooks/useServerDocumentSync.ts new file mode 100644 index 00000000..5de4103e --- /dev/null +++ b/web/src/hooks/useServerDocumentSync.ts @@ -0,0 +1,181 @@ +import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'; +import { listFreeformDocs } from '../api/freeformDoc'; +import { listProjectWorkflowDocuments } from '../api/projectDocuments'; +import { applyLeaseUpdates, sameLease } from '../lib/deliverableLeaseSync'; +import { mergeWorkflowDocumentsFromServer } from '../lib/deliverableSync'; +import { + controlledHydrateCandidateIds, + planControlledContentInvalidation, + type ControlledInvalidationPlan, +} from '../lib/controlledContentInvalidation'; +import { + createEditorHydrationGate, +} from '../lib/editorHydrationGate'; +import type { LoginResult } from '../types/auth'; +import type { DocumentAST } from '../types/block'; +import type { UserProfile } from '../types/docgov'; +import type { DeliverableItem } from '../types/workbench'; + +type Gate = ReturnType; + +export interface ServerDocumentSyncInput { + session: LoginResult | null; + sessionValidated: boolean | null; + currentProjectId: number; + currentUser: UserProfile; + workflowDocsEpoch: number; + setDeliverables: Dispatch>; + setDocCache: Dispatch>>; + deliverablesRef: MutableRefObject; + activeDocIdRef: MutableRefObject; + editorHydrationGateRef: MutableRefObject; + applyControlledInvalidationPlan: (plan: ControlledInvalidationPlan) => void; +} + +export function useServerDocumentSync(input: ServerDocumentSyncInput): void { + const { + session, + sessionValidated, + currentProjectId, + currentUser, + workflowDocsEpoch, + setDeliverables, + setDocCache, + deliverablesRef, + activeDocIdRef, + editorHydrationGateRef, + applyControlledInvalidationPlan, + } = input; + + useEffect(() => { + if (!session || sessionValidated !== true || !currentProjectId) return; + let active = true; + const fetchDocs = async () => { + try { + const [privateSummaries, sharedSummaries] = await Promise.all([ + listFreeformDocs('my_private', currentProjectId).catch(() => []), + listFreeformDocs('project_shared', currentProjectId).catch(() => []), + ]); + if (!active) return; + const allSummaries = [...privateSummaries, ...sharedSummaries]; + if (allSummaries.length === 0) return; + + setDeliverables((prev) => { + let updated = [...prev]; + for (const s of allSummaries) { + const exists = updated.find((d) => d.docId === s.doc_id); + const category = s.space === 'my_private' ? 'private' : 'shared'; + if (!exists) { + updated.unshift({ + docId: s.doc_id, + projectId: s.project_id || currentProjectId, + taskKey: 'freeform_doc', + title: s.title, + stage: 'V0.1', + category, + creatorId: s.owner_user_id || currentUser.id, + creatorName: currentUser.name, + isFixedWorkflow: false, + isTemplateOnly: false, + lifecycleState: 'drafting', + isClaimed: true, + author: currentUser.name, + reviewer: '', + approver: '', + updatedAt: (s.updated_at || s.created_at || '').replace('T', ' ').substring(0, 16), + governanceMode: 'freeform', + space: s.space, + }); + } + } + return updated; + }); + } catch (e) { + console.warn('获取服务端自由文档列表异常:', e); + } + }; + fetchDocs(); + return () => { + active = false; + }; + }, [session, sessionValidated, currentProjectId, currentUser.id]); + + useEffect(() => { + if (!session || sessionValidated !== true || !currentProjectId || currentProjectId <= 0) return; + let active = true; + const syncWorkflowDocs = async () => { + try { + // 成功返回(含空数组)才对账;失败不得调用 merge,以免误清本地真实项。 + const workflowDocs = await listProjectWorkflowDocuments(currentProjectId); + if (!active) return; + setDeliverables((prev) => + mergeWorkflowDocumentsFromServer(prev, workflowDocs, currentProjectId, currentUser), + ); + applyControlledInvalidationPlan( + planControlledContentInvalidation({ + reason: 'list_sync', + candidateIds: controlledHydrateCandidateIds( + deliverablesRef.current, + currentProjectId, + ), + activeDocId: activeDocIdRef.current, + editorOpen: editorHydrationGateRef.current.isEditorOpen(), + }), + ); + } catch (e) { + console.warn('[deliverables] 工作流受控文档同步失败:', e); + } + }; + void syncWorkflowDocs(); + return () => { + active = false; + }; + }, [ + session, + sessionValidated, + currentProjectId, + currentUser.id, + currentUser.name, + workflowDocsEpoch, + applyControlledInvalidationPlan, + ]); + + useEffect(() => { + if (!session || sessionValidated !== true || currentProjectId <= 0) return; + let cancelled = false; + const syncLeases = async () => { + const docs = await listProjectWorkflowDocuments(currentProjectId).catch(() => []); + if (cancelled || docs.length === 0) return; + const leases = new Map( + docs.map((doc) => [ + doc.doc_id, + doc.lease_holder_type + ? { + holderType: doc.lease_holder_type, + holderId: doc.lease_holder_id, + expiresAt: doc.lease_expires_at, + heartbeatAt: doc.lease_heartbeat_at, + } + : undefined, + ]), + ); + setDeliverables((prev) => applyLeaseUpdates(prev, leases, currentProjectId)); + setDocCache((prev) => { + let changed = false; + const next = { ...prev }; + leases.forEach((editLease, docId) => { + if (!next[docId]) return; + if (sameLease(next[docId].editLease, editLease)) return; + next[docId] = { ...next[docId], editLease }; + changed = true; + }); + return changed ? next : prev; + }); + }; + const timer = window.setInterval(() => void syncLeases(), 5000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [session, sessionValidated, currentProjectId]); +}