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 d09ecce0..f9eb3f80 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`(含一次旧消息键迁移)。工作台主体仍在 `App.tsx`,其余 effect 未拆完 | +| FE App.tsx | 部分。登录壳不再静态引用重模块。本地缓存写入在 `useWorkbenchPersistence`。登录后的项目/成员/会话同步在 `useAuthenticatedWorkspaceSync`。工作台主体仍在 `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 6145696f..4b08672a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -19,14 +19,14 @@ import { deleteFreeformDoc, } from './api/freeformDoc'; import { registerUploadAttachments } from './lib/uploadFreeformRegister'; -import { fetchChatSessions, upsertChatSession } from './api/chatSessions'; +import { upsertChatSession } from './api/chatSessions'; import { listProjectWorkflowDocuments } from './api/projectDocuments'; import type { DiffResult, OverlayMode } from './features/documentRevision/types'; import { documentRevisionInactiveHint, revisionToolbarMountMode, } from './features/documentRevision/revisionToolbarMount'; -import { createProject, fetchProjectLineage, fetchProjects, getProject } from './api/projectWorkflow'; +import { createProject, fetchProjectLineage, getProject } from './api/projectWorkflow'; import { addProjectMember, listProjectMembers, @@ -198,17 +198,15 @@ import { buildPrivateSessionId, } from './data/storage'; import { useChatWindow } from './hooks/useChatWindow'; +import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspaceSync'; import { useWorkbenchPersistence } from './hooks/useWorkbenchPersistence'; import { useTurnRuntime } from './hooks/useTurnRuntime'; import { useTaskRunStream } from './hooks/useTaskRunStream'; import { fetchChatTurnStatus } from './api/chatTurn'; import { createClientTurnId } from './lib/createClientTurnId'; import { mergeSessionUser } from './lib/mergeSessionUser'; -import { mapServerProjectToProjectInfo, mergeProjectLists } from './lib/projectAdapter'; -import { loginMemberSyncIds, RequestEpoch, seedViewerMembership, shareInflight } from './data/initFanout'; +import { RequestEpoch, shareInflight } from './data/initFanout'; import { - mapServerSessionToProjectSession, - mergeSessionLists, resolveSessionProjectId, } from './lib/sessionAdapter'; import { SendMessageResult } from './types/sendMessage'; @@ -476,126 +474,27 @@ export default function App() { const [capabilities, setCapabilities] = useState(() => authClient.getCapabilities()); - useEffect(() => { - authClient.getSystemAuthConfig().then(setSystemAuthConfig).catch(() => {}); - const saved = authClient.loadSavedSession(); - if (saved) { - setSession(saved); - if (saved.env) setSystemEnv(saved.env); - setCapabilities(saved.capabilities ?? []); - const match = allUsers.find((u) => u.id === saved.user.id); - setCurrentUser(mergeSessionUser(saved.user, match)); - void authClient - .fetchMe() - .then((me) => { - setCapabilities(me.capabilities ?? []); - setCurrentUser(mergeSessionUser(me.user, match)); - setSessionValidated(true); - }) - .catch(() => { - authClient.clearLocalSession(); - setSession(null); - setCapabilities([]); - setSessionValidated(false); - }); - } else { - setSessionValidated(false); - } - }, []); - - useEffect(() => { - if (!session || sessionValidated !== true) { - setProjectSyncNotice(null); - return; - } - - let cancelled = false; - void (async () => { - try { - const serverProjects = await fetchProjects(); - if (cancelled) return; - setProjects((prev) => { - const mapped = (serverProjects ?? []).map((project) => - seedViewerMembership( - mapServerProjectToProjectInfo( - project, - allUsers, - prev.find((item) => item.id === project.id) - ), - currentUser.id, - currentUser.name - ) - ); - return mergeProjectLists(prev, mapped); - }); - const syncIds = loginMemberSyncIds( - (serverProjects ?? []).map((project) => project.id), - currentProjectId - ); - await Promise.all( - syncIds.map((id) => { - const project = serverProjects.find((item) => item.id === id); - if (!project) return Promise.resolve(null); - return syncProjectMembersFromServer(project.id, project.creator_user_id); - }) - ); - setProjectSyncNotice(null); - } catch (err) { - if (cancelled) return; - console.warn('[projects] 服务端项目列表同步失败,继续使用本地缓存:', err); - setProjectSyncNotice('项目列表暂用本地缓存:无法连接服务端,新建项目可能无法在其他设备同步。'); - } - })(); - - return () => { - cancelled = true; - }; - }, [session, sessionValidated, allUsers, currentUser.id, currentUser.name, currentProjectId, syncProjectMembersFromServer]); - - useEffect(() => { - if (!projectMembersModalOpen || !currentProjectId || !session || sessionValidated !== true) return; - const project = projectsRef.current.find((item) => item.id === currentProjectId); - if (!project) return; - void syncProjectMembersFromServer(project.id, project.creatorId); - }, [projectMembersModalOpen, currentProjectId, session, sessionValidated, syncProjectMembersFromServer]); - - useEffect(() => { - if (!session || sessionValidated !== true || !currentUser.id) { - setSessionSyncNotice(null); - return; - } - - const abort = new AbortController(); - const epoch = sessionEpochRef.current.next(); - void (async () => { - try { - const serverSessions = await fetchChatSessions(currentUser.id, undefined, 100, abort.signal); - if (abort.signal.aborted || !sessionEpochRef.current.isCurrent(epoch)) return; - const knownProjects = projectsRef.current; - setSessions((prev) => { - const mapped = (serverSessions ?? []).map((item) => - mapServerSessionToProjectSession( - item, - knownProjects, - prev.find((sessionItem) => sessionItem.sessionId === item.session_id) - ) - ); - return mergeSessionLists(prev, mapped); - }); - setSessionSyncNotice(null); - } catch (err) { - if (abort.signal.aborted || !sessionEpochRef.current.isCurrent(epoch)) return; - console.warn('[sessions] 服务端会话列表同步失败,继续使用本地缓存:', err); - setSessionSyncNotice( - '会话列表暂用本地缓存:无法连接服务端,新建会话可能无法在其他设备同步。' - ); - } - })(); - - return () => { - abort.abort(); - }; - }, [session, sessionValidated, currentUser.id]); + useAuthenticatedWorkspaceSync({ + allUsers, + setCurrentUser, + setSession, + setSessionValidated, + setSystemEnv, + setSystemAuthConfig, + setCapabilities, + session, + sessionValidated, + currentUser, + currentProjectId, + syncProjectMembersFromServer, + setProjects, + setProjectSyncNotice, + projectMembersModalOpen, + projectsRef, + setSessions, + setSessionSyncNotice, + sessionEpochRef, + }); // handleLogout 定义在 turnRuntime/taskRunStream hook 之后,便于 abort 全会话在途流。 diff --git a/web/src/hooks/useAuthenticatedWorkspaceSync.test.ts b/web/src/hooks/useAuthenticatedWorkspaceSync.test.ts new file mode 100644 index 00000000..970905e4 --- /dev/null +++ b/web/src/hooks/useAuthenticatedWorkspaceSync.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('login workspace sync lives in one hook', () => { + const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); + const hook = readFileSync(new URL('./useAuthenticatedWorkspaceSync.ts', import.meta.url), 'utf8'); + assert.equal((app.match(/useAuthenticatedWorkspaceSync\(/g) || []).length, 1); + assert.equal(app.includes('fetchProjects('), false); + assert.equal(app.includes('fetchChatSessions('), false); + assert.equal(hook.includes('fetchProjects'), true); + assert.equal(hook.includes('fetchChatSessions'), true); + assert.equal(hook.includes('loginMemberSyncIds'), true); + assert.equal(hook.includes('.fetchMe()'), true); +}); diff --git a/web/src/hooks/useAuthenticatedWorkspaceSync.ts b/web/src/hooks/useAuthenticatedWorkspaceSync.ts new file mode 100644 index 00000000..f03a8c6d --- /dev/null +++ b/web/src/hooks/useAuthenticatedWorkspaceSync.ts @@ -0,0 +1,194 @@ +import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'; +import { authClient } from '../api/auth'; +import { fetchChatSessions } from '../api/chatSessions'; +import { fetchProjects } from '../api/projectWorkflow'; +import { loginMemberSyncIds, RequestEpoch, seedViewerMembership } from '../data/initFanout'; +import { mergeSessionUser } from '../lib/mergeSessionUser'; +import { mapServerProjectToProjectInfo, mergeProjectLists } from '../lib/projectAdapter'; +import { mapServerSessionToProjectSession, mergeSessionLists } from '../lib/sessionAdapter'; +import type { LoginResult, SystemAuthConfig } from '../types/auth'; +import type { UserProfile } from '../types/docgov'; +import type { ProjectInfo, ProjectSession } from '../types/workbench'; + +export interface AuthenticatedWorkspaceSyncInput { + allUsers: UserProfile[]; + setCurrentUser: Dispatch>; + setSession: Dispatch>; + setSessionValidated: Dispatch>; + setSystemEnv: Dispatch>; + setSystemAuthConfig: Dispatch>; + setCapabilities: Dispatch>; + session: LoginResult | null; + sessionValidated: boolean | null; + currentUser: UserProfile; + currentProjectId: number; + syncProjectMembersFromServer: (projectId: number, creatorId: number) => Promise; + setProjects: Dispatch>; + setProjectSyncNotice: Dispatch>; + projectMembersModalOpen: boolean; + projectsRef: MutableRefObject; + setSessions: Dispatch>; + setSessionSyncNotice: Dispatch>; + sessionEpochRef: MutableRefObject; +} + +/** + * 已登录工作台的启动同步:校验会话、拉项目、按需同步当前项目成员、拉会话列表。 + * 首次校验故意只跑一次,沿用入口组件原来的空依赖,避免用户目录更新把登录态重放一遍。 + */ +export function useAuthenticatedWorkspaceSync(input: AuthenticatedWorkspaceSyncInput): void { + const { + allUsers, + setCurrentUser, + setSession, + setSessionValidated, + setSystemEnv, + setSystemAuthConfig, + setCapabilities, + session, + sessionValidated, + currentUser, + currentProjectId, + syncProjectMembersFromServer, + setProjects, + setProjectSyncNotice, + projectMembersModalOpen, + projectsRef, + setSessions, + setSessionSyncNotice, + sessionEpochRef, + } = input; + + useEffect(() => { + authClient.getSystemAuthConfig().then(setSystemAuthConfig).catch(() => {}); + const saved = authClient.loadSavedSession(); + if (saved) { + setSession(saved); + if (saved.env) setSystemEnv(saved.env); + setCapabilities(saved.capabilities ?? []); + const match = allUsers.find((u) => u.id === saved.user.id); + setCurrentUser(mergeSessionUser(saved.user, match)); + void authClient + .fetchMe() + .then((me) => { + setCapabilities(me.capabilities ?? []); + setCurrentUser(mergeSessionUser(me.user, match)); + setSessionValidated(true); + }) + .catch(() => { + authClient.clearLocalSession(); + setSession(null); + setCapabilities([]); + setSessionValidated(false); + }); + } else { + setSessionValidated(false); + } + // 与原入口一致:只在挂载时恢复本地会话。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!session || sessionValidated !== true) { + setProjectSyncNotice(null); + return; + } + + let cancelled = false; + void (async () => { + try { + const serverProjects = await fetchProjects(); + if (cancelled) return; + setProjects((prev) => { + const mapped = (serverProjects ?? []).map((project) => + seedViewerMembership( + mapServerProjectToProjectInfo( + project, + allUsers, + prev.find((item) => item.id === project.id) + ), + currentUser.id, + currentUser.name + ) + ); + return mergeProjectLists(prev, mapped); + }); + const syncIds = loginMemberSyncIds( + (serverProjects ?? []).map((project) => project.id), + currentProjectId + ); + await Promise.all( + syncIds.map((id) => { + const project = serverProjects.find((item) => item.id === id); + if (!project) return Promise.resolve(null); + return syncProjectMembersFromServer(project.id, project.creator_user_id); + }) + ); + setProjectSyncNotice(null); + } catch (err) { + if (cancelled) return; + console.warn('[projects] 服务端项目列表同步失败,继续使用本地缓存:', err); + setProjectSyncNotice('项目列表暂用本地缓存:无法连接服务端,新建项目可能无法在其他设备同步。'); + } + })(); + + return () => { + cancelled = true; + }; + }, [ + session, + sessionValidated, + allUsers, + currentUser.id, + currentUser.name, + currentProjectId, + syncProjectMembersFromServer, + setProjects, + setProjectSyncNotice, + ]); + + useEffect(() => { + if (!projectMembersModalOpen || !currentProjectId || !session || sessionValidated !== true) return; + const project = projectsRef.current.find((item) => item.id === currentProjectId); + if (!project) return; + void syncProjectMembersFromServer(project.id, project.creatorId); + }, [projectMembersModalOpen, currentProjectId, session, sessionValidated, syncProjectMembersFromServer, projectsRef]); + + useEffect(() => { + if (!session || sessionValidated !== true || !currentUser.id) { + setSessionSyncNotice(null); + return; + } + + const abort = new AbortController(); + const epoch = sessionEpochRef.current.next(); + void (async () => { + try { + const serverSessions = await fetchChatSessions(currentUser.id, undefined, 100, abort.signal); + if (abort.signal.aborted || !sessionEpochRef.current.isCurrent(epoch)) return; + const knownProjects = projectsRef.current; + setSessions((prev) => { + const mapped = (serverSessions ?? []).map((item) => + mapServerSessionToProjectSession( + item, + knownProjects, + prev.find((sessionItem) => sessionItem.sessionId === item.session_id) + ) + ); + return mergeSessionLists(prev, mapped); + }); + setSessionSyncNotice(null); + } catch (err) { + if (abort.signal.aborted || !sessionEpochRef.current.isCurrent(epoch)) return; + console.warn('[sessions] 服务端会话列表同步失败,继续使用本地缓存:', err); + setSessionSyncNotice( + '会话列表暂用本地缓存:无法连接服务端,新建会话可能无法在其他设备同步。' + ); + } + })(); + + return () => { + abort.abort(); + }; + }, [session, sessionValidated, currentUser.id, setSessions, setSessionSyncNotice, sessionEpochRef, projectsRef]); +}