From c6558bc757a622c9f860ead8dcde5b5cc4710499 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 12:25:22 +0000 Subject: [PATCH] fix(web): persist initial project members after createProject Create Project no longer treats local member lists as success: non-creator initialMembers are upserted via the authorized members API, with bootstrap project id retained for idempotent retry when persistence partially fails. Co-authored-by: Cursor --- web/src/App.tsx | 43 ++++-- .../lib/createProjectMembersPersist.test.ts | 125 ++++++++++++++++++ web/src/lib/createProjectMembersPersist.ts | 90 +++++++++++++ 3 files changed, 250 insertions(+), 8 deletions(-) create mode 100644 web/src/lib/createProjectMembersPersist.test.ts create mode 100644 web/src/lib/createProjectMembersPersist.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 1c26535d..aa26f0c5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -36,6 +36,7 @@ import { removeProjectMember, updateProjectMember, } from './api/projectMembers'; +import { ensureCreatedProjectWithMembers } from './lib/createProjectMembersPersist'; import { mapApiMembersToProjectMembers, toApiProjectRole, @@ -603,6 +604,8 @@ export default function App() { const [createProjectModalOpen, setCreateProjectModalOpen] = useState(false); const [createProjectError, setCreateProjectError] = useState(null); + /** createProject 已成功但成员持久化失败时保留,避免再次确认重复建项 */ + const [createProjectBootstrapId, setCreateProjectBootstrapId] = useState(null); const [projectSyncNotice, setProjectSyncNotice] = useState(null); const [sessionSyncNotice, setSessionSyncNotice] = useState(null); const [projectMembersModalOpen, setProjectMembersModalOpen] = useState(false); @@ -3417,21 +3420,44 @@ export default function App() { let newProjId: number; try { - const created = await createProject({ - name: data.title, - device_type: data.deviceType, - intended_use: data.description, + const ensured = await ensureCreatedProjectWithMembers({ + bootstrapProjectId: createProjectBootstrapId, + createProject: () => + createProject({ + name: data.title, + device_type: data.deviceType, + intended_use: data.description, + }), + creatorUserId: currentUser.id, + initialMembers: data.initialMembers, + addMember: addProjectMember, }); - newProjId = created.id; + newProjId = ensured.projectId; + setCreateProjectBootstrapId(ensured.projectId); } catch (err) { - const message = err instanceof Error ? err.message : '创建项目失败,请稍后重试'; - setCreateProjectError(message); + const withId = err as Error & { projectId?: number }; + const bootstrapId = + typeof withId.projectId === 'number' && withId.projectId > 0 + ? withId.projectId + : createProjectBootstrapId; + if (bootstrapId) { + setCreateProjectBootstrapId(bootstrapId); + } + const message = + err instanceof Error + ? err.message + : '创建项目或同步初始成员失败,请稍后重试(不会因本地状态假成功)'; + setCreateProjectError( + bootstrapId + ? `项目已创建(#${bootstrapId}),但初始成员未全部落库:${message}。请再次确认以幂等补齐,勿关闭后当作成功。` + : message, + ); return; } const nowStr = new Date().toLocaleString(); - // 1. 构建项目与成员元数据 + // 1. 构建项目与成员元数据(服务端成员已持久化) const members: ProjectMember[] = data.initialMembers.map((m) => ({ userId: m.userId, userName: m.userName, @@ -3520,6 +3546,7 @@ export default function App() { } setActiveDocId(clonedDocs[0].docId); setActiveCategory('workflow'); + setCreateProjectBootstrapId(null); setCreateProjectModalOpen(false); setCreateProjectError(null); }; diff --git a/web/src/lib/createProjectMembersPersist.test.ts b/web/src/lib/createProjectMembersPersist.test.ts new file mode 100644 index 00000000..7bede034 --- /dev/null +++ b/web/src/lib/createProjectMembersPersist.test.ts @@ -0,0 +1,125 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildInitialMemberUpsertPayload, + ensureCreatedProjectWithMembers, + persistInitialProjectMembers, + selectNonCreatorInitialMembers, +} from './createProjectMembersPersist'; + +test('selectNonCreatorInitialMembers skips creator only', () => { + const members = selectNonCreatorInitialMembers( + [ + { userId: 86, userName: 'A', role: 'creator' }, + { userId: 99, userName: 'B', role: 'reviewer' }, + ], + 86, + ); + assert.deepEqual( + members.map((m) => m.userId), + [99], + ); +}); + +test('buildInitialMemberUpsertPayload maps reviewer UI role to member API role', () => { + assert.deepEqual(buildInitialMemberUpsertPayload({ userId: 99, userName: 'B', role: 'reviewer' }, 86), { + user_id: 99, + project_role: 'member', + }); + assert.deepEqual(buildInitialMemberUpsertPayload({ userId: 99, userName: 'B', role: 'viewer' }, 86), { + user_id: 99, + project_role: 'viewer', + }); +}); + +test('persistInitialProjectMembers awaits each add and stops on failure', async () => { + const calls: number[] = []; + await assert.rejects( + async () => + persistInitialProjectMembers({ + projectId: 6071, + creatorUserId: 86, + initialMembers: [ + { userId: 86, userName: 'A', role: 'creator' }, + { userId: 2, userName: 'B', role: 'reviewer' }, + { userId: 3, userName: 'C', role: 'approver' }, + ], + addMember: async (_projectId, payload) => { + calls.push(payload.user_id); + if (payload.user_id === 3) { + throw new Error('add failed'); + } + }, + }), + /add failed/, + ); + assert.deepEqual(calls, [2, 3]); +}); + +test('ensureCreatedProjectWithMembers skips recreate on bootstrap id and retries members', async () => { + let createCalls = 0; + const added: number[] = []; + const first = await ensureCreatedProjectWithMembers({ + bootstrapProjectId: null, + createProject: async () => { + createCalls += 1; + return { id: 6071 }; + }, + creatorUserId: 86, + initialMembers: [ + { userId: 86, userName: 'A', role: 'creator' }, + { userId: 99, userName: 'B', role: 'reviewer' }, + ], + addMember: async (_pid, payload) => { + added.push(payload.user_id); + }, + }); + assert.equal(first.projectId, 6071); + assert.equal(first.createdNow, true); + assert.deepEqual(first.persistedUserIds, [99]); + + let failOnce = true; + await assert.rejects( + async () => + ensureCreatedProjectWithMembers({ + bootstrapProjectId: 6071, + createProject: async () => { + createCalls += 1; + return { id: 9999 }; + }, + creatorUserId: 86, + initialMembers: [ + { userId: 86, userName: 'A', role: 'creator' }, + { userId: 99, userName: 'B', role: 'reviewer' }, + ], + addMember: async () => { + if (failOnce) { + failOnce = false; + throw new Error('transient'); + } + }, + }), + /transient/, + ); + assert.equal(createCalls, 1, 'must not recreate project on member retry'); + + const retry = await ensureCreatedProjectWithMembers({ + bootstrapProjectId: 6071, + createProject: async () => { + createCalls += 1; + return { id: 9999 }; + }, + creatorUserId: 86, + initialMembers: [ + { userId: 86, userName: 'A', role: 'creator' }, + { userId: 99, userName: 'B', role: 'reviewer' }, + ], + addMember: async (_pid, payload) => { + added.push(payload.user_id); + }, + }); + assert.equal(retry.createdNow, false); + assert.equal(retry.projectId, 6071); + assert.equal(createCalls, 1); + assert.ok(added.filter((id) => id === 99).length >= 2); +}); diff --git a/web/src/lib/createProjectMembersPersist.ts b/web/src/lib/createProjectMembersPersist.ts new file mode 100644 index 00000000..4d9ce815 --- /dev/null +++ b/web/src/lib/createProjectMembersPersist.ts @@ -0,0 +1,90 @@ +/** + * 立项成功后将非创建者初始成员持久化到受权 project_members API。 + * 禁止仅写本地 allUsers/sessions 造成「页面勾选成功、库表无成员」假成功。 + */ + +import type { UpsertProjectMemberPayload } from '../api/projectMembers'; +import { toApiProjectRole } from './projectMemberAdapter'; +import type { ProjectMemberRole } from '../types/workbench'; + +export type InitialProjectMemberInput = { + userId: number; + userName: string; + department?: string; + position?: string; + role: ProjectMemberRole; +}; + +export function selectNonCreatorInitialMembers( + initialMembers: InitialProjectMemberInput[], + creatorUserId: number, +): InitialProjectMemberInput[] { + return initialMembers.filter((m) => m.userId !== creatorUserId); +} + +export function buildInitialMemberUpsertPayload( + member: InitialProjectMemberInput, + creatorUserId: number, +): UpsertProjectMemberPayload { + return { + user_id: member.userId, + project_role: toApiProjectRole(member.role, member.userId, creatorUserId), + }; +} + +export type AddProjectMemberFn = ( + projectId: number, + payload: UpsertProjectMemberPayload, +) => Promise; + +/** + * 逐一 upsert 非创建者;任一失败抛错(调用方保留已创建 projectId 以便重试幂等)。 + * Upsert 语义下重复确认不会因已存在成员而假失败。 + */ +export async function persistInitialProjectMembers(params: { + projectId: number; + creatorUserId: number; + initialMembers: InitialProjectMemberInput[]; + addMember: AddProjectMemberFn; +}): Promise<{ persistedUserIds: number[] }> { + const targets = selectNonCreatorInitialMembers(params.initialMembers, params.creatorUserId); + const persistedUserIds: number[] = []; + for (const member of targets) { + const payload = buildInitialMemberUpsertPayload(member, params.creatorUserId); + await params.addMember(params.projectId, payload); + persistedUserIds.push(member.userId); + } + return { persistedUserIds }; +} + +/** + * 若已有 bootstrapProjectId(上次 create 成功但成员失败),跳过再次 createProject,仅补成员。 + */ +export async function ensureCreatedProjectWithMembers(params: { + bootstrapProjectId: number | null; + createProject: () => Promise<{ id: number }>; + creatorUserId: number; + initialMembers: InitialProjectMemberInput[]; + addMember: AddProjectMemberFn; +}): Promise<{ projectId: number; createdNow: boolean; persistedUserIds: number[] }> { + let projectId = params.bootstrapProjectId; + let createdNow = false; + if (projectId == null || projectId <= 0) { + const created = await params.createProject(); + projectId = created.id; + createdNow = true; + } + try { + const { persistedUserIds } = await persistInitialProjectMembers({ + projectId, + creatorUserId: params.creatorUserId, + initialMembers: params.initialMembers, + addMember: params.addMember, + }); + return { projectId, createdNow, persistedUserIds }; + } catch (err) { + const wrapped = err instanceof Error ? err : new Error(String(err)); + (wrapped as Error & { projectId?: number }).projectId = projectId; + throw wrapped; + } +}