From a29e98096a15be78e99052129310141ab0ba008c Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 19 Sep 2026 12:09:28 +0000 Subject: [PATCH] refactor(web): create projects from one hook Remember the new project id as soon as create succeeds so a later member failure retries members instead of creating a second project. Co-authored-by: Cursor --- .../long-term/2026-09-19-audit-remainder.md | 2 +- web/src/App.tsx | 199 ++------------- web/src/hooks/useCreateProjectConfirm.test.ts | 16 ++ web/src/hooks/useCreateProjectConfirm.ts | 232 ++++++++++++++++++ 4 files changed, 270 insertions(+), 179 deletions(-) create mode 100644 web/src/hooks/useCreateProjectConfirm.test.ts create mode 100644 web/src/hooks/useCreateProjectConfirm.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 f8b64330..faa51eef 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 2dba33b1..ccd8cfa5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -16,13 +16,12 @@ import { unshareFreeformDoc, deleteFreeformDoc, } from './api/freeformDoc'; -import { upsertChatSession } from './api/chatSessions'; import type { DiffResult, OverlayMode } from './features/documentRevision/types'; import { documentRevisionInactiveHint, revisionToolbarMountMode, } from './features/documentRevision/revisionToolbarMount'; -import { createProject, fetchProjectLineage, getProject } from './api/projectWorkflow'; +import { fetchProjectLineage, getProject } from './api/projectWorkflow'; import { addProjectMember, listProjectMembers, @@ -31,14 +30,9 @@ import { } from './api/projectMembers'; import { buildManagedMemberUpsertPayload, - ensureCreatedProjectWithMembers, } from './lib/createProjectMembersPersist'; import { afterProjectMemberPersisted } from './lib/afterProjectMemberPersisted'; import { - beginCreateProjectSubmit, - endCreateProjectSubmit, - resolveCreateProjectSubmitPhase, - shouldCloneMedicalTemplates, type CreateProjectSubmitPhase, } from './lib/createProjectSubmitGuard'; import { mapApiMembersToProjectMembers } from './lib/projectMemberAdapter'; @@ -74,7 +68,7 @@ import { UserProjectRightsModal } from './components/user/UserProjectRightsModal import { UserSettingsModal } from './components/user/UserSettingsModal'; import { OnDemand } from './components/lazy/OnDemand'; import { DocVisibilityModal } from './components/doc/DocVisibilityModal'; -import { CreateProjectModal, type CreateProjectConfirmData } from './components/project/CreateProjectModal'; +import { CreateProjectModal } from './components/project/CreateProjectModal'; import { ProjectMembersModal } from './components/project/ProjectMembersModal'; import { ProjectSettingsModal } from './components/project/ProjectSettingsModal'; import { fetchProjectSettings } from './api/projectSettings'; @@ -116,7 +110,6 @@ import { MOCK_USERS, } from '@demo-seeds'; import { - createProjectTemplateDeliverables, getTemplateDefinitionByTaskKey, buildTemplateASTForProject, } from './engine/templateFactory'; @@ -137,6 +130,7 @@ import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspace import { useBlockEditing } from './hooks/useBlockEditing'; import { useControlledDocumentHydration } from './hooks/useControlledDocumentHydration'; import { useDocumentLifecycleActions } from './hooks/useDocumentLifecycleActions'; +import { useCreateProjectConfirm } from './hooks/useCreateProjectConfirm'; import { useCardActions } from './hooks/useCardActions'; import { useTaskRunAttempts } from './hooks/useTaskRunAttempts'; import { useSendMessage } from './hooks/useSendMessage'; @@ -1630,6 +1624,24 @@ export default function App() { }; // 未登录拦截门禁:展示现代化医疗合规登录与注册页面 + const handleCreateProjectConfirm = useCreateProjectConfirm({ + currentUser, + createProjectInFlightRef, + createProjectBootstrapIdRef, + setCreateProjectError, + setCreateProjectSubmitting, + setCreateProjectSubmitPhase, + setCreateProjectModalOpen, + setProjects, + setSessions, + setMessages, + setDeliverables, + setDocCache, + setActiveSessionId, + setActiveDocId, + setActiveCategory, + }); + if (!session) { return ( { - if (!beginCreateProjectSubmit(createProjectInFlightRef)) { - return; - } - setCreateProjectError(null); - setCreateProjectSubmitting(true); - setCreateProjectSubmitPhase( - resolveCreateProjectSubmitPhase(createProjectBootstrapIdRef.current), - ); - - let newProjId: number; - try { - try { - const ensured = await ensureCreatedProjectWithMembers({ - bootstrapProjectId: createProjectBootstrapIdRef.current, - createProject: async () => { - const created = await createProject({ - name: data.title, - device_type: data.deviceType || undefined, - intended_use: data.productDomain === 'non_medical_software' ? undefined : data.description, - product_domain: data.productDomain, - project_type_key: data.projectTypeKey, - team_mode: data.teamMode, - execution_mode: data.executionMode, - problem_statement: data.problemStatement, - target_users: data.targetUsers, - target_outcome: data.targetOutcome, - initial_workflow_key: - data.productDomain === 'non_medical_software' ? 'software_bootstrap' : undefined, - }); - // create 刚成功即写入 bootstrap:成员失败窗口内串行重试不会再 create - createProjectBootstrapIdRef.current = created.id; - return created; - }, - creatorUserId: currentUser.id, - initialMembers: data.initialMembers, - addMember: addProjectMember, - onPhase: setCreateProjectSubmitPhase, - }); - newProjId = ensured.projectId; - createProjectBootstrapIdRef.current = ensured.projectId; - } catch (err) { - const withId = err as Error & { projectId?: number }; - const bootstrapId = - typeof withId.projectId === 'number' && withId.projectId > 0 - ? withId.projectId - : createProjectBootstrapIdRef.current; - if (bootstrapId) { - createProjectBootstrapIdRef.current = bootstrapId; - } - const message = - err instanceof Error - ? err.message - : '创建项目或同步初始成员失败,请稍后重试(不会因本地状态假成功)'; - setCreateProjectError( - bootstrapId - ? `项目已创建(#${bootstrapId}),但初始成员未全部落库:${message}。请再次确认以幂等补齐,勿关闭后当作成功。` - : message, - ); - return; - } - - const nowStr = new Date().toLocaleString(); - - // 1. 构建项目与成员元数据(服务端成员已持久化) - const members: ProjectMember[] = data.initialMembers.map((m) => ({ - userId: m.userId, - userName: m.userName, - department: m.department, - position: m.position, - role: m.role, - status: 'active', - joinedAt: nowStr, - })); - - const newProject: ProjectInfo = { - id: newProjId, - title: data.title, - deviceType: data.deviceType, - description: data.description, - creatorId: currentUser.id, - creatorName: currentUser.name, - createdAt: nowStr, - members, - }; - - // 2. 为每个选中的项目成员生成独立私聊会话 - const newSessionsToAppend: ProjectSession[] = []; - const newMessagesToAppend: ChatMessage[] = []; - let creatorSessionId = ''; - - members.forEach((m) => { - const sessId = buildPrivateSessionId(`p${newProjId}`, m.userId); - if (m.userId === currentUser.id) { - creatorSessionId = sessId; - } - newSessionsToAppend.push({ - sessionId: sessId, - userId: m.userId, - projectId: newProjId, - title: data.title, - deviceType: data.deviceType, - stage: 'V0.1', - stageLabel: 'V0.1 需求摸底', - messageCount: 1, - updatedAt: '刚刚', - }); - - newMessagesToAppend.push({ - id: `m-init-${m.userId}-${Date.now()}`, - sessionId: sessId, - role: 'system', - content: - m.userId === currentUser.id - ? `【立项成功】项目《${data.title}》已成功建立!全套研发工作流与阶段文档模板已克隆就绪,协同参谋小队已就位,进入【V0.1 需求摸底】阶段。您可以点击附件图标上传临床需求 URS,或直接输入您的产品构想。` - : `【项目协同通知】您已被项目创建人 ${currentUser.name} 邀请加入《${data.title}》,并分配法定职责为【${m.role}】。`, - timestamp: new Date().toTimeString().substring(0, 5), - }); - }); - - // 3. 仅医疗项目克隆 7 份受控模板;软件项目走服务端 catalog / docs_only - const clonedDocs = shouldCloneMedicalTemplates(data.productDomain) - ? createProjectTemplateDeliverables({ - projectId: newProjId, - projectName: data.title, - deviceType: data.deviceType, - creatorId: currentUser.id, - creatorName: currentUser.name, - }) - : { deliverables: [], docASTMap: {} }; - const clonedAsts = clonedDocs.docASTMap; - - setProjects((prev) => [newProject, ...prev]); - setSessions((prev) => [...newSessionsToAppend, ...prev]); - setMessages((prev) => [...prev, ...newMessagesToAppend]); - - void Promise.all( - newSessionsToAppend.map((sessionItem) => - upsertChatSession({ - session_id: sessionItem.sessionId, - title: sessionItem.title, - user_id: sessionItem.userId ?? currentUser.id, - project_id: sessionItem.projectId, - }) - ) - ).catch((err) => { - console.warn('[sessions] 服务端会话注册失败,其他设备可能暂不可见:', err); - }); - setDeliverables((prev) => [...prev, ...clonedDocs.deliverables]); - setDocCache((prev) => ({ ...prev, ...clonedAsts })); - - if (creatorSessionId) { - setActiveSessionId(creatorSessionId); - } - if (clonedDocs.deliverables[0]?.docId) { - setActiveDocId(clonedDocs.deliverables[0].docId); - setActiveCategory('workflow'); - } - createProjectBootstrapIdRef.current = null; - setCreateProjectModalOpen(false); - setCreateProjectError(null); - } finally { - endCreateProjectSubmit(createProjectInFlightRef); - setCreateProjectSubmitting(false); - setCreateProjectSubmitPhase(null); - } - }; - // 添加成员(持久化至服务端 project_members) const handleAddProjectMember = async (newMember: Omit) => { if (!currentProject) return; diff --git a/web/src/hooks/useCreateProjectConfirm.test.ts b/web/src/hooks/useCreateProjectConfirm.test.ts new file mode 100644 index 00000000..5c879936 --- /dev/null +++ b/web/src/hooks/useCreateProjectConfirm.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('project creation lives in one hook called before the login return', () => { + const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); + const hook = readFileSync(new URL('./useCreateProjectConfirm.ts', import.meta.url), 'utf8'); + assert.equal((app.match(/useCreateProjectConfirm\(/g) || []).length, 1); + const callAt = app.indexOf('useCreateProjectConfirm('); + const loginReturnAt = app.indexOf('if (!session)'); + assert.equal(callAt > 0 && callAt < loginReturnAt, true); + assert.equal(app.includes('ensureCreatedProjectWithMembers'), false); + assert.equal(hook.includes('create 刚成功即写入 bootstrap'), true); + assert.equal(hook.includes('shouldCloneMedicalTemplates'), true); + assert.equal(hook.includes('不会因本地状态假成功'), true); +}); diff --git a/web/src/hooks/useCreateProjectConfirm.ts b/web/src/hooks/useCreateProjectConfirm.ts new file mode 100644 index 00000000..293bc1fe --- /dev/null +++ b/web/src/hooks/useCreateProjectConfirm.ts @@ -0,0 +1,232 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; +import { upsertChatSession } from '../api/chatSessions'; +import { addProjectMember } from '../api/projectMembers'; +import { createProject } from '../api/projectWorkflow'; +import { buildPrivateSessionId } from '../data/storage'; +import { createProjectTemplateDeliverables } from '../engine/templateFactory'; +import { ensureCreatedProjectWithMembers } from '../lib/createProjectMembersPersist'; +import { + beginCreateProjectSubmit, + endCreateProjectSubmit, + resolveCreateProjectSubmitPhase, + shouldCloneMedicalTemplates, + type CreateProjectSubmitPhase, +} from '../lib/createProjectSubmitGuard'; +import type { CreateProjectConfirmData } from '../components/project/CreateProjectModal'; +import type { DocumentAST } from '../types/block'; +import type { UserProfile } from '../types/docgov'; +import type { + ChatMessage, + DeliverableCategory, + DeliverableItem, + ProjectInfo, + ProjectMember, + ProjectSession, +} from '../types/workbench'; + +export interface CreateProjectConfirmInput { + currentUser: UserProfile; + createProjectInFlightRef: MutableRefObject; + createProjectBootstrapIdRef: MutableRefObject; + setCreateProjectError: Dispatch>; + setCreateProjectSubmitting: Dispatch>; + setCreateProjectSubmitPhase: Dispatch>; + setCreateProjectModalOpen: Dispatch>; + setProjects: Dispatch>; + setSessions: Dispatch>; + setMessages: Dispatch>; + setDeliverables: Dispatch>; + setDocCache: Dispatch>>; + setActiveSessionId: Dispatch>; + setActiveDocId: Dispatch>; + setActiveCategory: Dispatch>; +} + +export function useCreateProjectConfirm(input: CreateProjectConfirmInput) { + const { + currentUser, + createProjectInFlightRef, + createProjectBootstrapIdRef, + setCreateProjectError, + setCreateProjectSubmitting, + setCreateProjectSubmitPhase, + setCreateProjectModalOpen, + setProjects, + setSessions, + setMessages, + setDeliverables, + setDocCache, + setActiveSessionId, + setActiveDocId, + setActiveCategory, + } = input; + + // 发起医疗器械新项目立项 (通过 Modal 交互收集名称、品类与初始成员) + return async (data: CreateProjectConfirmData) => { + if (!beginCreateProjectSubmit(createProjectInFlightRef)) { + return; + } + setCreateProjectError(null); + setCreateProjectSubmitting(true); + setCreateProjectSubmitPhase( + resolveCreateProjectSubmitPhase(createProjectBootstrapIdRef.current), + ); + + let newProjId: number; + try { + try { + const ensured = await ensureCreatedProjectWithMembers({ + bootstrapProjectId: createProjectBootstrapIdRef.current, + createProject: async () => { + const created = await createProject({ + name: data.title, + device_type: data.deviceType || undefined, + intended_use: data.productDomain === 'non_medical_software' ? undefined : data.description, + product_domain: data.productDomain, + project_type_key: data.projectTypeKey, + team_mode: data.teamMode, + execution_mode: data.executionMode, + problem_statement: data.problemStatement, + target_users: data.targetUsers, + target_outcome: data.targetOutcome, + initial_workflow_key: + data.productDomain === 'non_medical_software' ? 'software_bootstrap' : undefined, + }); + // create 刚成功即写入 bootstrap:成员失败窗口内串行重试不会再 create + createProjectBootstrapIdRef.current = created.id; + return created; + }, + creatorUserId: currentUser.id, + initialMembers: data.initialMembers, + addMember: addProjectMember, + onPhase: setCreateProjectSubmitPhase, + }); + newProjId = ensured.projectId; + createProjectBootstrapIdRef.current = ensured.projectId; + } catch (err) { + const withId = err as Error & { projectId?: number }; + const bootstrapId = + typeof withId.projectId === 'number' && withId.projectId > 0 + ? withId.projectId + : createProjectBootstrapIdRef.current; + if (bootstrapId) { + createProjectBootstrapIdRef.current = bootstrapId; + } + const message = + err instanceof Error + ? err.message + : '创建项目或同步初始成员失败,请稍后重试(不会因本地状态假成功)'; + setCreateProjectError( + bootstrapId + ? `项目已创建(#${bootstrapId}),但初始成员未全部落库:${message}。请再次确认以幂等补齐,勿关闭后当作成功。` + : message, + ); + return; + } + + const nowStr = new Date().toLocaleString(); + + // 1. 构建项目与成员元数据(服务端成员已持久化) + const members: ProjectMember[] = data.initialMembers.map((m) => ({ + userId: m.userId, + userName: m.userName, + department: m.department, + position: m.position, + role: m.role, + status: 'active', + joinedAt: nowStr, + })); + + const newProject: ProjectInfo = { + id: newProjId, + title: data.title, + deviceType: data.deviceType, + description: data.description, + creatorId: currentUser.id, + creatorName: currentUser.name, + createdAt: nowStr, + members, + }; + + // 2. 为每个选中的项目成员生成独立私聊会话 + const newSessionsToAppend: ProjectSession[] = []; + const newMessagesToAppend: ChatMessage[] = []; + let creatorSessionId = ''; + + members.forEach((m) => { + const sessId = buildPrivateSessionId(`p${newProjId}`, m.userId); + if (m.userId === currentUser.id) { + creatorSessionId = sessId; + } + newSessionsToAppend.push({ + sessionId: sessId, + userId: m.userId, + projectId: newProjId, + title: data.title, + deviceType: data.deviceType, + stage: 'V0.1', + stageLabel: 'V0.1 需求摸底', + messageCount: 1, + updatedAt: '刚刚', + }); + + newMessagesToAppend.push({ + id: `m-init-${m.userId}-${Date.now()}`, + sessionId: sessId, + role: 'system', + content: + m.userId === currentUser.id + ? `【立项成功】项目《${data.title}》已成功建立!全套研发工作流与阶段文档模板已克隆就绪,协同参谋小队已就位,进入【V0.1 需求摸底】阶段。您可以点击附件图标上传临床需求 URS,或直接输入您的产品构想。` + : `【项目协同通知】您已被项目创建人 ${currentUser.name} 邀请加入《${data.title}》,并分配法定职责为【${m.role}】。`, + timestamp: new Date().toTimeString().substring(0, 5), + }); + }); + + // 3. 仅医疗项目克隆 7 份受控模板;软件项目走服务端 catalog / docs_only + const clonedDocs = shouldCloneMedicalTemplates(data.productDomain) + ? createProjectTemplateDeliverables({ + projectId: newProjId, + projectName: data.title, + deviceType: data.deviceType, + creatorId: currentUser.id, + creatorName: currentUser.name, + }) + : { deliverables: [], docASTMap: {} }; + const clonedAsts = clonedDocs.docASTMap; + + setProjects((prev) => [newProject, ...prev]); + setSessions((prev) => [...newSessionsToAppend, ...prev]); + setMessages((prev) => [...prev, ...newMessagesToAppend]); + + void Promise.all( + newSessionsToAppend.map((sessionItem) => + upsertChatSession({ + session_id: sessionItem.sessionId, + title: sessionItem.title, + user_id: sessionItem.userId ?? currentUser.id, + project_id: sessionItem.projectId, + }) + ) + ).catch((err) => { + console.warn('[sessions] 服务端会话注册失败,其他设备可能暂不可见:', err); + }); + setDeliverables((prev) => [...prev, ...clonedDocs.deliverables]); + setDocCache((prev) => ({ ...prev, ...clonedAsts })); + + if (creatorSessionId) { + setActiveSessionId(creatorSessionId); + } + if (clonedDocs.deliverables[0]?.docId) { + setActiveDocId(clonedDocs.deliverables[0].docId); + setActiveCategory('workflow'); + } + createProjectBootstrapIdRef.current = null; + setCreateProjectModalOpen(false); + setCreateProjectError(null); + } finally { + endCreateProjectSubmit(createProjectInFlightRef); + setCreateProjectSubmitting(false); + setCreateProjectSubmitPhase(null); + } + }; +}