fix(任务): 修复快速创建导致的重复ID
This commit is contained in:
27
apps/web/lib/entity-id.test.ts
Normal file
27
apps/web/lib/entity-id.test.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { createEntityId, dedupeEntityIds } from './entity-id';
|
||||||
|
|
||||||
|
test('createEntityId stays unique within the same millisecond', () => {
|
||||||
|
const ids = Array.from({ length: 20 }, () => createEntityId('task', 1782361373857));
|
||||||
|
|
||||||
|
assert.equal(new Set(ids).size, ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dedupeEntityIds rewrites duplicate ids while preserving the first item id', () => {
|
||||||
|
const result = dedupeEntityIds(
|
||||||
|
[
|
||||||
|
{ id: 'task-1', title: 'A' },
|
||||||
|
{ id: 'task-1', title: 'B' },
|
||||||
|
{ id: 'task-2', title: 'C' },
|
||||||
|
{ id: 'task-1', title: 'D' },
|
||||||
|
],
|
||||||
|
'task',
|
||||||
|
1782361373857,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, true);
|
||||||
|
assert.equal(result.items[0].id, 'task-1');
|
||||||
|
assert.equal(new Set(result.items.map((item) => item.id)).size, result.items.length);
|
||||||
|
});
|
||||||
43
apps/web/lib/entity-id.ts
Normal file
43
apps/web/lib/entity-id.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
let lastTimestamp = 0;
|
||||||
|
let sameTimestampSequence = 0;
|
||||||
|
|
||||||
|
export interface DedupeEntityIdsResult<T> {
|
||||||
|
items: T[];
|
||||||
|
changed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEntityId(prefix: string, timestamp = Date.now()): string {
|
||||||
|
if (timestamp === lastTimestamp) {
|
||||||
|
sameTimestampSequence += 1;
|
||||||
|
} else {
|
||||||
|
lastTimestamp = timestamp;
|
||||||
|
sameTimestampSequence = 0;
|
||||||
|
}
|
||||||
|
return `${prefix}-${timestamp}-${sameTimestampSequence.toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dedupeEntityIds<T extends { id: string }>(
|
||||||
|
items: T[],
|
||||||
|
prefix: string,
|
||||||
|
timestamp = Date.now(),
|
||||||
|
): DedupeEntityIdsResult<T> {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
const deduped = items.map((item) => {
|
||||||
|
if (item.id && !seen.has(item.id)) {
|
||||||
|
seen.add(item.id);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = true;
|
||||||
|
let nextId = createEntityId(prefix, timestamp);
|
||||||
|
while (seen.has(nextId)) {
|
||||||
|
nextId = createEntityId(prefix, timestamp);
|
||||||
|
}
|
||||||
|
seen.add(nextId);
|
||||||
|
return { ...item, id: nextId };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { items: changed ? deduped : items, changed };
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { create } from 'zustand';
|
|||||||
import type { DevTask, DevTaskStatus } from '@/lib/dev-task';
|
import type { DevTask, DevTaskStatus } from '@/lib/dev-task';
|
||||||
import { generateTaskNo, isLegacyTask } from '@/lib/dev-task';
|
import { generateTaskNo, isLegacyTask } from '@/lib/dev-task';
|
||||||
import { applyDevTaskTransition, normalizeDevTaskOnCreate } from '@/lib/dev-task-workflow';
|
import { applyDevTaskTransition, normalizeDevTaskOnCreate } from '@/lib/dev-task-workflow';
|
||||||
|
import { createEntityId, dedupeEntityIds } from '@/lib/entity-id';
|
||||||
import { loadServerData, saveServerData } from '@/lib/server-data';
|
import { loadServerData, saveServerData } from '@/lib/server-data';
|
||||||
|
|
||||||
function saveStored(items: DevTask[]) {
|
function saveStored(items: DevTask[]) {
|
||||||
@@ -16,7 +17,9 @@ async function loadStored(): Promise<DevTask[] | null> {
|
|||||||
if (Array.isArray(parsed) && parsed.some(isLegacyTask)) {
|
if (Array.isArray(parsed) && parsed.some(isLegacyTask)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return parsed;
|
const result = dedupeEntityIds(parsed, 'task');
|
||||||
|
if (result.changed) saveStored(result.items);
|
||||||
|
return result.items;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
return null;
|
return null;
|
||||||
@@ -48,7 +51,7 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const task: DevTask = normalizeDevTaskOnCreate({
|
const task: DevTask = normalizeDevTaskOnCreate({
|
||||||
...data,
|
...data,
|
||||||
id: `task-${Date.now()}`,
|
id: createEntityId('task'),
|
||||||
taskNo: generateTaskNo(list),
|
taskNo: generateTaskNo(list),
|
||||||
isBlocked: false,
|
isBlocked: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { create } from 'zustand';
|
|||||||
import type { TestCase, TestCaseStatus } from '@/lib/test-case';
|
import type { TestCase, TestCaseStatus } from '@/lib/test-case';
|
||||||
import { generateCaseNo, normalizeTestCases } from '@/lib/test-case';
|
import { generateCaseNo, normalizeTestCases } from '@/lib/test-case';
|
||||||
import { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow';
|
import { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow';
|
||||||
|
import { createEntityId, dedupeEntityIds } from '@/lib/entity-id';
|
||||||
import { loadServerData, saveServerData } from '@/lib/server-data';
|
import { loadServerData, saveServerData } from '@/lib/server-data';
|
||||||
|
|
||||||
function saveStored(items: TestCase[]) {
|
function saveStored(items: TestCase[]) {
|
||||||
@@ -33,7 +34,11 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
|||||||
|
|
||||||
fetchTestCases: async () => {
|
fetchTestCases: async () => {
|
||||||
const cached = await loadStored();
|
const cached = await loadStored();
|
||||||
if (cached) set({ testCases: normalizeTestCases(cached) });
|
if (cached) {
|
||||||
|
const result = dedupeEntityIds(normalizeTestCases(cached), 'tc');
|
||||||
|
if (result.changed) saveStored(result.items);
|
||||||
|
set({ testCases: result.items });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
createTestCase: (data) => {
|
createTestCase: (data) => {
|
||||||
@@ -41,7 +46,7 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const tc: TestCase = normalizeTestCaseOnCreate({
|
const tc: TestCase = normalizeTestCaseOnCreate({
|
||||||
...data,
|
...data,
|
||||||
id: `tc-${Date.now()}`,
|
id: createEntityId('tc'),
|
||||||
caseNo: generateCaseNo(list),
|
caseNo: generateCaseNo(list),
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
estimateHours: data.estimateHours ?? 0.5,
|
estimateHours: data.estimateHours ?? 0.5,
|
||||||
|
|||||||
Reference in New Issue
Block a user