From 8de29466e1aae5f467ada9ef4c78eb54eb86f069 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 19 Sep 2026 12:11:52 +0000 Subject: [PATCH] refactor(web): persist project members from one hook Refresh the roster and workflow documents only after the member write succeeds, so a failed add does not invent a local session. Co-authored-by: Cursor --- .../long-term/2026-09-19-audit-remainder.md | 2 +- web/src/App.tsx | 131 ++------------- web/src/hooks/useProjectMemberActions.test.ts | 14 ++ web/src/hooks/useProjectMemberActions.ts | 152 ++++++++++++++++++ 4 files changed, 183 insertions(+), 116 deletions(-) create mode 100644 web/src/hooks/useProjectMemberActions.test.ts create mode 100644 web/src/hooks/useProjectMemberActions.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 faa51eef..f68b6527 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 | 部分。登录壳不再静态引用重模块。本地缓存、登录同步、文档补水、服务端文档同步、生命周期、发送消息、块编辑、对话流/任务流重试、卡片动作和立项已分别抽出。工作台壳与成员管理仍在 `App.tsx` | +| FE App.tsx | 部分。登录壳不再静态引用重模块。本地缓存、登录同步、文档补水、服务端文档同步、生命周期、发送消息、块编辑、对话流/任务流重试、卡片动作、立项和成员写操作已分别抽出。工作台壳仍在 `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 ccd8cfa5..b4e19aa9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -22,16 +22,7 @@ import { revisionToolbarMountMode, } from './features/documentRevision/revisionToolbarMount'; import { fetchProjectLineage, getProject } from './api/projectWorkflow'; -import { - addProjectMember, - listProjectMembers, - removeProjectMember, - updateProjectMember, -} from './api/projectMembers'; -import { - buildManagedMemberUpsertPayload, -} from './lib/createProjectMembersPersist'; -import { afterProjectMemberPersisted } from './lib/afterProjectMemberPersisted'; +import { listProjectMembers } from './api/projectMembers'; import { type CreateProjectSubmitPhase, } from './lib/createProjectSubmitGuard'; @@ -87,7 +78,6 @@ import { DeliverableCategory, ProjectInfo, ProjectMember, - ProjectMemberRole, } from './types/workbench'; import { UserRole, UserProfile, DocPermissionRole } from './types/docgov'; import type { DocLifecycleState } from './types/docgov'; @@ -123,7 +113,6 @@ import { loadProjects, filterProjectSessionsForUser, getProjectMembership, - buildPrivateSessionId, } from './data/storage'; import { useChatWindow } from './hooks/useChatWindow'; import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspaceSync'; @@ -131,6 +120,7 @@ import { useBlockEditing } from './hooks/useBlockEditing'; import { useControlledDocumentHydration } from './hooks/useControlledDocumentHydration'; import { useDocumentLifecycleActions } from './hooks/useDocumentLifecycleActions'; import { useCreateProjectConfirm } from './hooks/useCreateProjectConfirm'; +import { useProjectMemberActions } from './hooks/useProjectMemberActions'; import { useCardActions } from './hooks/useCardActions'; import { useTaskRunAttempts } from './hooks/useTaskRunAttempts'; import { useSendMessage } from './hooks/useSendMessage'; @@ -1642,6 +1632,20 @@ export default function App() { setActiveCategory, }); + const { + handleAddProjectMember, + handleUpdateProjectMemberRole, + handleRemoveProjectMember, + handleRestoreProjectMember, + } = useProjectMemberActions({ + currentProject, + syncProjectMembersFromServer, + setWorkflowDocsEpoch, + setSessions, + setMessages, + setProjectMembersError, + }); + if (!session) { return ( ) => { - if (!currentProject) return; - try { - await addProjectMember( - currentProject.id, - buildManagedMemberUpsertPayload( - { userId: newMember.userId, role: newMember.role }, - currentProject.creatorId, - ), - ); - await afterProjectMemberPersisted({ - syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId), - refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1), - }); - - const newSessId = buildPrivateSessionId(`p${currentProject.id}`, newMember.userId); - setSessions((prev) => { - const exists = prev.some( - (s) => s.projectId === currentProject.id && s.userId === newMember.userId - ); - if (exists) return prev; - return [ - { - sessionId: newSessId, - userId: newMember.userId, - projectId: currentProject.id, - title: currentProject.title, - deviceType: currentProject.deviceType, - stage: 'V0.1', - stageLabel: 'V0.1 需求摸底', - messageCount: 1, - updatedAt: '刚刚', - }, - ...prev, - ]; - }); - - setMessages((prev) => [ - ...prev, - { - id: `m-join-${newMember.userId}-${Date.now()}`, - sessionId: newSessId, - role: 'system', - content: `【项目协同通知】您已被项目创建人 ${currentProject.creatorName} 加入项目《${currentProject.title}》,并分配项目角色为【${newMember.role}】。`, - timestamp: new Date().toTimeString().substring(0, 5), - }, - ]); - } catch (err) { - setProjectMembersError(err instanceof Error ? err.message : '添加成员失败'); - throw err; - } - }; - - const handleUpdateProjectMemberRole = async (userId: number, newRole: ProjectMemberRole) => { - if (!currentProject) return; - try { - await updateProjectMember( - currentProject.id, - buildManagedMemberUpsertPayload({ userId, role: newRole }, currentProject.creatorId), - ); - await afterProjectMemberPersisted({ - syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId), - refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1), - }); - } catch (err) { - setProjectMembersError(err instanceof Error ? err.message : '更新成员角色失败'); - } - }; - - const handleRemoveProjectMember = async (userId: number) => { - if (!currentProject) return; - try { - await removeProjectMember(currentProject.id, userId); - await afterProjectMemberPersisted({ - syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId), - refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1), - }); - } catch (err) { - setProjectMembersError(err instanceof Error ? err.message : '移出成员失败'); - } - }; - - const handleRestoreProjectMember = async (userId: number) => { - if (!currentProject) return; - try { - const member = currentProject.members.find((m) => m.userId === userId); - await updateProjectMember( - currentProject.id, - buildManagedMemberUpsertPayload( - { userId, role: member?.role ?? 'member', status: 'active' }, - currentProject.creatorId, - ), - ); - await afterProjectMemberPersisted({ - syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId), - refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1), - }); - } catch (err) { - setProjectMembersError(err instanceof Error ? err.message : '恢复成员失败'); - } - }; - // 全屏系统管理台:与工作台并列的顶层视图,不经过 AppShell if (view === 'admin') { return ( diff --git a/web/src/hooks/useProjectMemberActions.test.ts b/web/src/hooks/useProjectMemberActions.test.ts new file mode 100644 index 00000000..a3ed812a --- /dev/null +++ b/web/src/hooks/useProjectMemberActions.test.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('project member writes live in one hook before the login return', () => { + const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); + const hook = readFileSync(new URL('./useProjectMemberActions.ts', import.meta.url), 'utf8'); + assert.equal((app.match(/useProjectMemberActions\(/g) || []).length, 1); + assert.equal(app.indexOf('useProjectMemberActions(') < app.indexOf('if (!session)'), true); + assert.equal(app.includes('afterProjectMemberPersisted'), false); + assert.equal(hook.includes('afterProjectMemberPersisted'), true); + assert.equal(hook.includes('removeProjectMember'), true); + assert.equal(hook.includes("status: 'active'"), true); +}); diff --git a/web/src/hooks/useProjectMemberActions.ts b/web/src/hooks/useProjectMemberActions.ts new file mode 100644 index 00000000..e93648a8 --- /dev/null +++ b/web/src/hooks/useProjectMemberActions.ts @@ -0,0 +1,152 @@ +import type { Dispatch, SetStateAction } from 'react'; +import { + addProjectMember, + removeProjectMember, + updateProjectMember, +} from '../api/projectMembers'; +import { buildPrivateSessionId } from '../data/storage'; +import { afterProjectMemberPersisted } from '../lib/afterProjectMemberPersisted'; +import { buildManagedMemberUpsertPayload } from '../lib/createProjectMembersPersist'; +import type { + ChatMessage, + ProjectInfo, + ProjectMember, + ProjectMemberRole, + ProjectSession, +} from '../types/workbench'; + +export interface ProjectMemberActionsInput { + currentProject: ProjectInfo | undefined; + syncProjectMembersFromServer: (projectId: number, creatorId: number) => Promise; + setWorkflowDocsEpoch: Dispatch>; + setSessions: Dispatch>; + setMessages: Dispatch>; + setProjectMembersError: Dispatch>; +} + +export interface ProjectMemberActions { + handleAddProjectMember: (newMember: Omit) => Promise; + handleUpdateProjectMemberRole: (userId: number, newRole: ProjectMemberRole) => Promise; + handleRemoveProjectMember: (userId: number) => Promise; + handleRestoreProjectMember: (userId: number) => Promise; +} + +export function useProjectMemberActions(input: ProjectMemberActionsInput): ProjectMemberActions { + const { + currentProject, + syncProjectMembersFromServer, + setWorkflowDocsEpoch, + setSessions, + setMessages, + setProjectMembersError, + } = input; + + const handleAddProjectMember = async (newMember: Omit) => { + if (!currentProject) return; + try { + await addProjectMember( + currentProject.id, + buildManagedMemberUpsertPayload( + { userId: newMember.userId, role: newMember.role }, + currentProject.creatorId, + ), + ); + await afterProjectMemberPersisted({ + syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId), + refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1), + }); + + const newSessId = buildPrivateSessionId(`p${currentProject.id}`, newMember.userId); + setSessions((prev) => { + const exists = prev.some( + (s) => s.projectId === currentProject.id && s.userId === newMember.userId, + ); + if (exists) return prev; + return [ + { + sessionId: newSessId, + userId: newMember.userId, + projectId: currentProject.id, + title: currentProject.title, + deviceType: currentProject.deviceType, + stage: 'V0.1', + stageLabel: 'V0.1 需求摸底', + messageCount: 1, + updatedAt: '刚刚', + }, + ...prev, + ]; + }); + + setMessages((prev) => [ + ...prev, + { + id: `m-join-${newMember.userId}-${Date.now()}`, + sessionId: newSessId, + role: 'system', + content: `【项目协同通知】您已被项目创建人 ${currentProject.creatorName} 加入项目《${currentProject.title}》,并分配项目角色为【${newMember.role}】。`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + } catch (err) { + setProjectMembersError(err instanceof Error ? err.message : '添加成员失败'); + throw err; + } + }; + + const handleUpdateProjectMemberRole = async (userId: number, newRole: ProjectMemberRole) => { + if (!currentProject) return; + try { + await updateProjectMember( + currentProject.id, + buildManagedMemberUpsertPayload({ userId, role: newRole }, currentProject.creatorId), + ); + await afterProjectMemberPersisted({ + syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId), + refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1), + }); + } catch (err) { + setProjectMembersError(err instanceof Error ? err.message : '更新成员角色失败'); + } + }; + + const handleRemoveProjectMember = async (userId: number) => { + if (!currentProject) return; + try { + await removeProjectMember(currentProject.id, userId); + await afterProjectMemberPersisted({ + syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId), + refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1), + }); + } catch (err) { + setProjectMembersError(err instanceof Error ? err.message : '移出成员失败'); + } + }; + + const handleRestoreProjectMember = async (userId: number) => { + if (!currentProject) return; + try { + const member = currentProject.members.find((m) => m.userId === userId); + await updateProjectMember( + currentProject.id, + buildManagedMemberUpsertPayload( + { userId, role: member?.role ?? 'member', status: 'active' }, + currentProject.creatorId, + ), + ); + await afterProjectMemberPersisted({ + syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId), + refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1), + }); + } catch (err) { + setProjectMembersError(err instanceof Error ? err.message : '恢复成员失败'); + } + }; + + return { + handleAddProjectMember, + handleUpdateProjectMemberRole, + handleRemoveProjectMember, + handleRestoreProjectMember, + }; +}