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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 12:25:22 +00:00
co-authored by Cursor
parent cc394c815c
commit c6558bc757
3 changed files with 250 additions and 8 deletions
+35 -8
View File
@@ -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<string | null>(null);
/** createProject 已成功但成员持久化失败时保留,避免再次确认重复建项 */
const [createProjectBootstrapId, setCreateProjectBootstrapId] = useState<number | null>(null);
const [projectSyncNotice, setProjectSyncNotice] = useState<string | null>(null);
const [sessionSyncNotice, setSessionSyncNotice] = useState<string | null>(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);
};
@@ -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);
});
@@ -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<void>;
/**
* 逐一 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;
}
}