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 f9eb3f80..17f854db 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`。工作台主体仍在 `App.tsx` | +| FE App.tsx | 部分。登录壳不再静态引用重模块。本地缓存写入在 `useWorkbenchPersistence`。登录后的项目/成员/会话同步在 `useAuthenticatedWorkspaceSync`。受控文档补水与重试在 `useControlledDocumentHydration`。工作台主体仍在 `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 4b08672a..36afbda0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -75,12 +75,10 @@ import { isBlockedGovernanceStatus, } from './lib/blockedGovernanceCopy'; import { - buildControlledLoadErrorSkeleton, buildControlledLoadingSkeleton, canonicalDocIdForTask, controlledHydrateFingerprint, hydrateControlledDocumentContent, - resolveCanonicalDocId, shouldLoadControlledContent, } from './lib/controlledDocHydrate'; import { @@ -90,10 +88,8 @@ import { type ControlledInvalidationPlan, } from './lib/controlledContentInvalidation'; import { applyLeaseUpdates, sameLease } from './lib/deliverableLeaseSync'; -import { ControlledContentRequestError } from './api/projectDocuments'; import { createEditorHydrationGate, - type HydrationToken, } from './lib/editorHydrationGate'; import { decideActiveDocSwitch, @@ -199,6 +195,7 @@ import { } from './data/storage'; import { useChatWindow } from './hooks/useChatWindow'; import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspaceSync'; +import { useControlledDocumentHydration } from './hooks/useControlledDocumentHydration'; import { useWorkbenchPersistence } from './hooks/useWorkbenchPersistence'; import { useTurnRuntime } from './hooks/useTurnRuntime'; import { useTaskRunStream } from './hooks/useTaskRunStream'; @@ -805,139 +802,23 @@ export default function App() { }); }, [docCache, activeDocId, deliverables, projects, visibleDeliverables, controlledContentReady, currentUser]); - // legacy doc-p{projectId}-{taskKey} → 服务端 doc_id 映射 - useEffect(() => { - const canonicalId = resolveCanonicalDocId(activeDocId, deliverables); - if (canonicalId === activeDocId) return; - setActiveDocId(canonicalId); - setDocCache((prev) => { - if (!prev[activeDocId]) return prev; - const next = { ...prev }; - delete next[activeDocId]; - return next; - }); - }, [deliverables, activeDocId]); - - const applyHydratedDocument = useCallback( - ( - token: HydrationToken, - ast: DocumentAST | null | undefined, - docId: string, - opts?: { refreshToolbar?: boolean }, - ) => { - if (!ast) return false; - if (!editorHydrationGateRef.current.decide(token).apply) return false; - setDocCache((prev) => ({ ...prev, [docId]: ast })); - setControlledContentReady((prev) => ({ ...prev, [docId]: true })); - setDocLoadErrors((prev) => ({ ...prev, [docId]: false })); - setDocLoadErrorInfo((prev) => { - if (!prev[docId]) return prev; - const next = { ...prev }; - delete next[docId]; - return next; - }); - if (opts?.refreshToolbar !== false) { - setDocRevRefreshKey((k) => k + 1); - } - return true; - }, - [], - ); - - // 受控非模板文档:挂载/切换/同步后自动拉取服务端正文。 - // hydrate 会按 head_block_map 对齐块 ID;编辑弹窗打开期间仍不得发起新 GET, - // 已在途响应也不得覆盖编辑基线或 PATCH 成功后的新正文。 - useEffect(() => { - if (!session || sessionValidated !== true) return; - const target = deliverables.find((d) => d.docId === activeDocId); - if (!target || !shouldLoadControlledContent(target)) return; - const token = editorHydrationGateRef.current.beginHydration(activeDocId); - if (!token) return; - - let cancelled = false; - setDocLoadErrors((prev) => { - const next = { ...prev }; - delete next[activeDocId]; - return next; - }); - void hydrateControlledDocumentContent(activeDocId, target) - .then((ast) => { - if (cancelled) return; - applyHydratedDocument(token, ast, activeDocId); - }) - .catch((err) => { - console.warn('自动拉取受控文档正文失败:', err); - if (cancelled) return; - if (!editorHydrationGateRef.current.decide(token).apply) return; - setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: true })); - setDocLoadErrorInfo((prev) => ({ - ...prev, - [activeDocId]: { - code: err instanceof ControlledContentRequestError ? err.code : undefined, - message: err instanceof Error ? err.message : String(err), - correlationId: - err instanceof ControlledContentRequestError ? err.correlationId : undefined, - }, - })); - setDocCache((prev) => ({ - ...prev, - [activeDocId]: buildControlledLoadErrorSkeleton(target), - })); - }); - - return () => { - cancelled = true; - }; - }, [session, sessionValidated, activeDocId, activeHydrateFingerprint, applyHydratedDocument, hydrateResumeKey]); - - const retryControlledDocLoad = useCallback(() => { - const target = deliverables.find((d) => d.docId === activeDocId); - if (!target || !shouldLoadControlledContent(target)) return; - const token = editorHydrationGateRef.current.beginHydration(activeDocId); - if (!token) return; - - setDocLoadErrors((prev) => { - const next = { ...prev }; - delete next[activeDocId]; - return next; - }); - setControlledContentReady((prev) => ({ ...prev, [activeDocId]: false })); - setDocCache((prev) => ({ - ...prev, - [activeDocId]: buildControlledLoadingSkeleton(target), - })); - - void hydrateControlledDocumentContent(activeDocId, target) - .then((ast) => { - applyHydratedDocument(token, ast, activeDocId); - }) - .catch((err) => { - console.warn('重试拉取受控文档正文失败:', err); - if (!editorHydrationGateRef.current.decide(token).apply) return; - setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: true })); - setDocLoadErrorInfo((prev) => ({ - ...prev, - [activeDocId]: { - code: err instanceof ControlledContentRequestError ? err.code : undefined, - message: err instanceof Error ? err.message : String(err), - correlationId: - err instanceof ControlledContentRequestError ? err.correlationId : undefined, - }, - })); - setDocCache((prev) => ({ - ...prev, - [activeDocId]: buildControlledLoadErrorSkeleton(target), - })); - }); - }, [activeDocId, deliverables, applyHydratedDocument]); - - // 自愈后自动回写入缓存,保障后续操作直接命中 - useEffect(() => { - if (!docAST || docCache[docAST.docId]) return; - const target = deliverables.find((d) => d.docId === docAST.docId); - if (target && shouldLoadControlledContent(target)) return; - setDocCache((prev) => ({ ...prev, [docAST.docId]: docAST })); - }, [docAST, docCache, deliverables]); + const { applyHydratedDocument, retryControlledDocLoad } = useControlledDocumentHydration({ + session, + sessionValidated, + activeDocId, + setActiveDocId, + deliverables, + docCache, + setDocCache, + setControlledContentReady, + setDocLoadErrors, + setDocLoadErrorInfo, + setDocRevRefreshKey, + editorHydrationGateRef, + activeHydrateFingerprint, + hydrateResumeKey, + docAST, + }); // 根据权威 user id 计算当前文档职责;未知身份 fail-closed 为 viewer const currentDocRole: UserRole = resolveCurrentDocRole({ diff --git a/web/src/hooks/useControlledDocumentHydration.test.ts b/web/src/hooks/useControlledDocumentHydration.test.ts new file mode 100644 index 00000000..9d0a8f56 --- /dev/null +++ b/web/src/hooks/useControlledDocumentHydration.test.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('controlled document hydration lives in one hook', () => { + const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); + const hook = readFileSync(new URL('./useControlledDocumentHydration.ts', import.meta.url), 'utf8'); + assert.equal((app.match(/useControlledDocumentHydration\(/g) || []).length, 1); + assert.equal(app.includes('自动拉取受控文档正文失败'), false); + assert.equal(app.includes('重试拉取受控文档正文失败'), false); + assert.equal(hook.includes('beginHydration'), true); + assert.equal(hook.includes('hydrateControlledDocumentContent'), true); + assert.equal(hook.includes('decide(token).apply'), true); +}); diff --git a/web/src/hooks/useControlledDocumentHydration.ts b/web/src/hooks/useControlledDocumentHydration.ts new file mode 100644 index 00000000..dbd7b3be --- /dev/null +++ b/web/src/hooks/useControlledDocumentHydration.ts @@ -0,0 +1,202 @@ +import { useCallback, useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'; +import { ControlledContentRequestError } from '../api/projectDocuments'; +import { + buildControlledLoadErrorSkeleton, + buildControlledLoadingSkeleton, + hydrateControlledDocumentContent, + resolveCanonicalDocId, + shouldLoadControlledContent, +} from '../lib/controlledDocHydrate'; +import { + createEditorHydrationGate, + type HydrationToken, +} from '../lib/editorHydrationGate'; +import type { LoginResult } from '../types/auth'; +import type { DocumentAST } from '../types/block'; +import type { DeliverableItem } from '../types/workbench'; + +export type DocLoadErrorInfo = { code?: string; message: string; correlationId?: string }; + +type Gate = ReturnType; + +export interface ControlledDocumentHydrationInput { + session: LoginResult | null; + sessionValidated: boolean | null; + activeDocId: string; + setActiveDocId: Dispatch>; + deliverables: DeliverableItem[]; + docCache: Record; + setDocCache: Dispatch>>; + setControlledContentReady: Dispatch>>; + setDocLoadErrors: Dispatch>>; + setDocLoadErrorInfo: Dispatch>>; + setDocRevRefreshKey: Dispatch>; + editorHydrationGateRef: MutableRefObject; + activeHydrateFingerprint: string; + hydrateResumeKey: number; + docAST: DocumentAST; +} + +export interface ControlledDocumentHydration { + applyHydratedDocument: ( + token: HydrationToken, + ast: DocumentAST | null | undefined, + docId: string, + opts?: { refreshToolbar?: boolean }, + ) => boolean; + retryControlledDocLoad: () => void; +} + +function loadErrorInfo(err: unknown): DocLoadErrorInfo { + return { + code: err instanceof ControlledContentRequestError ? err.code : undefined, + message: err instanceof Error ? err.message : String(err), + correlationId: err instanceof ControlledContentRequestError ? err.correlationId : undefined, + }; +} + +export function useControlledDocumentHydration( + input: ControlledDocumentHydrationInput, +): ControlledDocumentHydration { + const { + session, + sessionValidated, + activeDocId, + setActiveDocId, + deliverables, + docCache, + setDocCache, + setControlledContentReady, + setDocLoadErrors, + setDocLoadErrorInfo, + setDocRevRefreshKey, + editorHydrationGateRef, + activeHydrateFingerprint, + hydrateResumeKey, + docAST, + } = input; + + useEffect(() => { + const canonicalId = resolveCanonicalDocId(activeDocId, deliverables); + if (canonicalId === activeDocId) return; + setActiveDocId(canonicalId); + setDocCache((prev) => { + if (!prev[activeDocId]) return prev; + const next = { ...prev }; + delete next[activeDocId]; + return next; + }); + }, [deliverables, activeDocId, setActiveDocId, setDocCache]); + + const applyHydratedDocument = useCallback( + ( + token: HydrationToken, + ast: DocumentAST | null | undefined, + docId: string, + opts?: { refreshToolbar?: boolean }, + ) => { + if (!ast) return false; + if (!editorHydrationGateRef.current.decide(token).apply) return false; + setDocCache((prev) => ({ ...prev, [docId]: ast })); + setControlledContentReady((prev) => ({ ...prev, [docId]: true })); + setDocLoadErrors((prev) => ({ ...prev, [docId]: false })); + setDocLoadErrorInfo((prev) => { + if (!prev[docId]) return prev; + const next = { ...prev }; + delete next[docId]; + return next; + }); + if (opts?.refreshToolbar !== false) { + setDocRevRefreshKey((k) => k + 1); + } + return true; + }, + [editorHydrationGateRef, setControlledContentReady, setDocCache, setDocLoadErrorInfo, setDocLoadErrors, setDocRevRefreshKey], + ); + + useEffect(() => { + if (!session || sessionValidated !== true) return; + const target = deliverables.find((d) => d.docId === activeDocId); + if (!target || !shouldLoadControlledContent(target)) return; + const token = editorHydrationGateRef.current.beginHydration(activeDocId); + if (!token) return; + + let cancelled = false; + setDocLoadErrors((prev) => { + const next = { ...prev }; + delete next[activeDocId]; + return next; + }); + void hydrateControlledDocumentContent(activeDocId, target) + .then((ast) => { + if (cancelled) return; + applyHydratedDocument(token, ast, activeDocId); + }) + .catch((err) => { + console.warn('自动拉取受控文档正文失败:', err); + if (cancelled) return; + if (!editorHydrationGateRef.current.decide(token).apply) return; + setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: true })); + setDocLoadErrorInfo((prev) => ({ ...prev, [activeDocId]: loadErrorInfo(err) })); + setDocCache((prev) => ({ + ...prev, + [activeDocId]: buildControlledLoadErrorSkeleton(target), + })); + }); + + return () => { + cancelled = true; + }; + }, [session, sessionValidated, activeDocId, activeHydrateFingerprint, applyHydratedDocument, hydrateResumeKey]); + + const retryControlledDocLoad = useCallback(() => { + const target = deliverables.find((d) => d.docId === activeDocId); + if (!target || !shouldLoadControlledContent(target)) return; + const token = editorHydrationGateRef.current.beginHydration(activeDocId); + if (!token) return; + + setDocLoadErrors((prev) => { + const next = { ...prev }; + delete next[activeDocId]; + return next; + }); + setControlledContentReady((prev) => ({ ...prev, [activeDocId]: false })); + setDocCache((prev) => ({ + ...prev, + [activeDocId]: buildControlledLoadingSkeleton(target), + })); + + void hydrateControlledDocumentContent(activeDocId, target) + .then((ast) => { + applyHydratedDocument(token, ast, activeDocId); + }) + .catch((err) => { + console.warn('重试拉取受控文档正文失败:', err); + if (!editorHydrationGateRef.current.decide(token).apply) return; + setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: true })); + setDocLoadErrorInfo((prev) => ({ ...prev, [activeDocId]: loadErrorInfo(err) })); + setDocCache((prev) => ({ + ...prev, + [activeDocId]: buildControlledLoadErrorSkeleton(target), + })); + }); + }, [ + activeDocId, + deliverables, + applyHydratedDocument, + editorHydrationGateRef, + setControlledContentReady, + setDocCache, + setDocLoadErrorInfo, + setDocLoadErrors, + ]); + + useEffect(() => { + if (!docAST || docCache[docAST.docId]) return; + const target = deliverables.find((d) => d.docId === docAST.docId); + if (target && shouldLoadControlledContent(target)) return; + setDocCache((prev) => ({ ...prev, [docAST.docId]: docAST })); + }, [docAST, docCache, deliverables, setDocCache]); + + return { applyHydratedDocument, retryControlledDocLoad }; +}