Commit 0ec6a52b by DigHuang Committed by GitHub

refactor(skill): remove duplicate name constraint for skills and folders (#6995)

* refactor(skill): remove duplicate name constraint for skills and folders

* clean script
parent ffc8ef45
......@@ -5,7 +5,6 @@ export enum SkillErrEnum {
unExist = 'skillUnExist',
unAuthSkill = 'unAuthSkill',
canNotEditAdminPermission = 'canNotEditAdminPermission',
skillNameExists = 'skillNameExists',
invalidSkillName = 'invalidSkillName',
invalidDescription = 'invalidDescription',
invalidCategory = 'invalidCategory',
......@@ -37,11 +36,6 @@ const skillErrList = [
message: i18nT('common:code_error.skill_error.can_not_edit_admin_permission')
},
{
statusText: SkillErrEnum.skillNameExists,
message: i18nT('common:code_error.skill_error.name_exists'),
httpStatus: 409
},
{
statusText: SkillErrEnum.invalidSkillName,
message: i18nT('common:code_error.skill_error.invalid_name'),
httpStatus: 400
......
......@@ -4,7 +4,6 @@ import type { ClientSession } from '../../../../common/mongo';
import { mongoSessionRun } from '../../../../common/mongo/sessionRun';
import { getLogger, LogCategories } from '../../../../common/logger';
import { MongoAgentSkills } from '../model/schema';
import { checkSkillNameExists } from './query';
const logger = getLogger(LogCategories.MODULE.AGENT_SKILLS.CREATION);
......@@ -78,11 +77,6 @@ export async function createSkillFolder(
): Promise<AgentSkillSchemaType> {
const { name, description, parentId, teamId, tmbId } = data;
const nameExists = await checkSkillNameExists(name, teamId, parentId || null);
if (nameExists) {
throw new Error('Folder name already exists in this directory');
}
const folder = new MongoAgentSkills({
type: AgentSkillTypeEnum.folder,
source: AgentSkillSourceEnum.personal,
......
......@@ -7,7 +7,6 @@ import { removeSkillPackageTTL, uploadSkillPackage } from '../package';
import { MongoAgentSkills } from '../model/schema';
import { createVersion } from '../version';
import { updateCurrentVersion } from './update';
import { checkSkillNameExists } from './query';
/**
* Import skill from a validated package.
......@@ -27,11 +26,6 @@ export async function importSkill(
): Promise<string> {
const { skill } = packageData;
const nameExists = await checkSkillNameExists(skill.name, teamId, parentId || null);
if (nameExists) {
throw SkillErrEnum.skillNameExists;
}
const newSkill = new MongoAgentSkills({
parentId: parentId || null,
type: AgentSkillTypeEnum.skill,
......
......@@ -36,28 +36,3 @@ export async function canModifySkill(skillId: string, tmbId: string): Promise<bo
return skill.tmbId?.toString() === tmbId;
}
/**
* Check if skill/folder name already exists in the same parent folder.
*/
export async function checkSkillNameExists(
name: string,
teamId: string,
parentId: string | null,
excludeId?: string
): Promise<boolean> {
const query: Record<string, any> = {
name,
teamId,
parentId: parentId || null,
deleteTime: null,
source: AgentSkillSourceEnum.personal
};
if (excludeId) {
query._id = { $ne: excludeId };
}
const count = await MongoAgentSkills.countDocuments(query);
return count > 0;
}
......@@ -119,14 +119,6 @@ try {
AgentSkillsSchema.index({ category: 1 });
// 文件夹树查询。
AgentSkillsSchema.index({ teamId: 1, parentId: 1, deleteTime: 1 });
// 同一父目录下的个人 skill/folder 不允许重名,软删除数据不参与唯一约束。
AgentSkillsSchema.index(
{ teamId: 1, parentId: 1, name: 1, deleteTime: 1 },
{
unique: true,
partialFilterExpression: { deleteTime: null, source: AgentSkillSourceEnum.personal }
}
);
} catch (error) {
console.log('AgentSkill index error:', error);
}
......
......@@ -8,7 +8,6 @@ import {
deleteSkill,
getSkillById,
canModifySkill,
checkSkillNameExists,
importSkill
} from '@fastgpt/service/core/ai/skill/manage';
import { MongoAgentSkillsVersion } from '@fastgpt/service/core/ai/skill/version/schema';
......@@ -339,44 +338,6 @@ describe('AgentSkill Controller', () => {
});
});
// ==================== Check Name Exists ====================
describe('checkSkillNameExists', () => {
it('should return true for existing name', async () => {
const skillData = {
name: 'Existing Name',
description: 'A test skill',
category: [],
teamId: testTeamId,
tmbId: testTmbId
};
await createSkill(skillData);
const exists = await checkSkillNameExists('Existing Name', testTeamId, null);
expect(exists).toBe(true);
});
it('should return false for non-existing name', async () => {
const exists = await checkSkillNameExists('Non-Existing Name', testTeamId, null);
expect(exists).toBe(false);
});
it('should return false when excluding current skill', async () => {
const skillData = {
name: 'Unique Name',
description: 'A test skill',
category: [],
teamId: testTeamId,
tmbId: testTmbId
};
const skillId = await createSkill(skillData);
const exists = await checkSkillNameExists('Unique Name', testTeamId, skillId);
expect(exists).toBe(false);
});
});
// ==================== Import Skill ====================
describe('importSkill', () => {
it('should import skill from package', async () => {
......@@ -401,7 +362,7 @@ describe('AgentSkill Controller', () => {
expect(skill?.source).toBe(AgentSkillSourceEnum.personal);
});
it('should throw error when importing duplicate name', async () => {
it('should allow importing duplicate name without error', async () => {
const packageData = {
skill: {
name: 'Duplicate Import',
......@@ -413,12 +374,14 @@ describe('AgentSkill Controller', () => {
const mockZipBuffer = Buffer.from('mock zip content');
// First import
await importSkill(packageData, testTeamId, testTmbId, mockZipBuffer);
const firstSkillId = await importSkill(packageData, testTeamId, testTmbId, mockZipBuffer);
// Second import should fail
await expect(importSkill(packageData, testTeamId, testTmbId, mockZipBuffer)).rejects.toThrow(
'skillNameExists'
);
// Second import should succeed with a different ID
const secondSkillId = await importSkill(packageData, testTeamId, testTmbId, mockZipBuffer);
expect(firstSkillId).toBeDefined();
expect(secondSkillId).toBeDefined();
expect(firstSkillId).not.toBe(secondSkillId);
});
});
......
import { NextAPI } from '@/service/middleware/entry';
import { AppFolderTypeList } from '@fastgpt/global/core/app/constants';
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { extractAppResourceRefsFromNodes } from '@fastgpt/service/core/app/resourceRefs';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { MongoAppVersion } from '@fastgpt/service/core/app/version/schema';
import { authCert } from '@fastgpt/service/support/permission/auth/common';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import { getLogger } from '@fastgpt/service/common/logger';
import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { Types } from '@fastgpt/service/common/mongo';
import { z } from 'zod';
const logger = getLogger(['backfillAppResourceRefs']);
const DEFAULT_BACKFILL_START_TIME = new Date('2026-05-24T00:00:00.000+08:00');
const QuerySchema = z.object({
dryRun: z
.enum(['true', 'false'])
.optional()
.default('false')
.transform((value) => value === 'true'),
batchSize: z.coerce.number().int().min(1).max(2000).optional().default(500),
startTime: z.coerce.date().optional().default(DEFAULT_BACKFILL_START_TIME)
});
type MigrationStats = {
matched: number;
updated: number;
};
type ResponseType = {
message: string;
dryRun: boolean;
batchSize: number;
startTime: string;
versions: MigrationStats;
apps: MigrationStats;
};
/**
* 回填指定时间后的 app_versions.resourceRefs,保证新版 Skill 发布后的版本记录具备统一索引。
*/
async function backfillVersionResourceRefs({
dryRun,
batchSize,
startTime
}: {
dryRun: boolean;
batchSize: number;
startTime: Date;
}): Promise<MigrationStats> {
let lastId: string | undefined;
let scanned = 0;
let matched = 0;
let updated = 0;
while (true) {
const versions = await MongoAppVersion.find(
{
...(lastId ? { _id: { $gt: new Types.ObjectId(lastId) } } : {}),
time: { $gte: startTime }
},
'_id nodes'
)
.sort({ _id: 1 })
.limit(batchSize)
.lean();
if (versions.length === 0) break;
scanned += versions.length;
lastId = String(versions[versions.length - 1]._id);
const operations = versions.map((version) => ({
updateOne: {
filter: { _id: version._id },
update: {
$set: {
resourceRefs: extractAppResourceRefsFromNodes(version.nodes)
}
}
}
}));
matched += operations.length;
if (!dryRun && operations.length > 0) {
const result = await MongoAppVersion.bulkWrite(operations, { ordered: false });
updated += result.modifiedCount + result.upsertedCount;
}
logger.info('[version resource refs] backfill progress', {
scanned,
matched,
updated: dryRun ? 0 : updated,
lastId,
startTime
});
}
return { matched, updated: dryRun ? 0 : updated };
}
/**
* 按指定时间后的最新已发布版本刷新 apps.resourceRefs 缓存。
* 草稿和自动保存不会影响缓存,避免 Skill 关联统计偏离线上发布态。
*/
async function backfillAppResourceRefs({
dryRun,
batchSize,
startTime
}: {
dryRun: boolean;
batchSize: number;
startTime: Date;
}): Promise<MigrationStats> {
let lastId: string | undefined;
let scanned = 0;
let matched = 0;
let updated = 0;
while (true) {
const apps = await MongoApp.find(
{
...(lastId ? { _id: { $gt: new Types.ObjectId(lastId) } } : {}),
type: { $nin: AppFolderTypeList },
deleteTime: null,
updateTime: { $gte: startTime }
},
'_id'
)
.sort({ _id: 1 })
.limit(batchSize)
.lean();
if (apps.length === 0) break;
scanned += apps.length;
lastId = String(apps[apps.length - 1]._id);
const latestPublishedVersions = await MongoAppVersion.aggregate<{
_id: Types.ObjectId;
nodes?: StoreNodeItemType[];
}>([
{
$match: {
appId: { $in: apps.map((app) => new Types.ObjectId(String(app._id))) },
isPublish: true,
time: { $gte: startTime }
}
},
{
$sort: {
appId: 1,
time: -1,
_id: -1
}
},
{
$group: {
_id: '$appId',
nodes: { $first: '$nodes' }
}
}
]);
const operations = latestPublishedVersions.map((version) => ({
updateOne: {
filter: { _id: version._id },
update: {
$set: {
resourceRefs: extractAppResourceRefsFromNodes(version.nodes)
}
}
}
}));
matched += operations.length;
if (!dryRun && operations.length > 0) {
const result = await MongoApp.bulkWrite(operations, { ordered: false });
updated += result.modifiedCount + result.upsertedCount;
}
logger.info('[app resource refs] backfill progress', {
scanned,
matched,
updated: dryRun ? 0 : updated,
lastId,
startTime
});
}
return { matched, updated: dryRun ? 0 : updated };
}
async function handler(
req: ApiRequestProps<undefined, z.input<typeof QuerySchema>>
): Promise<ResponseType> {
await authCert({ req, authRoot: true });
const { dryRun, batchSize, startTime } = parseApiInput({
req,
querySchema: QuerySchema
}).query;
logger.info('Start app resource refs backfill', { dryRun, batchSize, startTime });
const versions = await backfillVersionResourceRefs({ dryRun, batchSize, startTime });
const apps = await backfillAppResourceRefs({ dryRun, batchSize, startTime });
logger.info('Finish app resource refs backfill', {
dryRun,
batchSize,
startTime,
versions,
apps
});
return {
message: 'Completed app resource refs backfill',
dryRun,
batchSize,
startTime: startTime.toISOString(),
versions,
apps
};
}
export default NextAPI(handler);
import { NextAPI } from '@/service/middleware/entry';
import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun';
import {
createSkill,
checkSkillNameExists,
updateSkillCreationFailed
} from '@fastgpt/service/core/ai/skill/manage';
import { createSkill, updateSkillCreationFailed } from '@fastgpt/service/core/ai/skill/manage';
import { addAgentSkillCreateJob } from '@fastgpt/service/core/ai/skill/manage/creation';
import {
CreateSkillBodySchema,
......@@ -89,11 +85,6 @@ async function handler(req: ApiRequestProps<CreateSkillBody>): Promise<CreateSki
if (category.length > 0 && category.some((c) => !validCategories.includes(c))) {
return Promise.reject(SkillErrEnum.invalidCategory);
}
// Display name comes from the create modal and remains user-facing.
const nameExists = await checkSkillNameExists(requestedName, teamId, parentId || null);
if (nameExists) {
return Promise.reject(SkillErrEnum.skillNameExists);
}
// Create a visible pending skill first. The slow package generation is handled by BullMQ.
// E11000 from concurrent duplicate creation propagates as-is (409-like conflict)
......
......@@ -2,11 +2,7 @@ import { NextAPI } from '@/service/middleware/entry';
import { authSkill } from '@fastgpt/service/support/permission/skill/auth';
import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun';
import {
updateSkill,
checkSkillNameExists,
updateParentFoldersUpdateTime
} from '@fastgpt/service/core/ai/skill/manage';
import { updateSkill, updateParentFoldersUpdateTime } from '@fastgpt/service/core/ai/skill/manage';
import { MongoAgentSkills } from '@fastgpt/service/core/ai/skill/model/schema';
import {
ManagePermissionVal,
......@@ -115,15 +111,6 @@ async function handler(req: ApiRequestProps<UpdateSkillBody>) {
if (name.length > 50) {
return Promise.reject(SkillErrEnum.invalidSkillName);
}
const nameExists = await checkSkillNameExists(
name.trim(),
teamId,
skill.parentId ?? null,
skillId
);
if (nameExists) {
return Promise.reject(SkillErrEnum.skillNameExists);
}
}
if (description !== undefined && description.length > 500) {
......
import { describe, expect, it } from 'vitest';
import handler from '@/pages/api/admin/backfillAppResourceRefs';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { NodeInputKeyEnum, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { MongoAppVersion } from '@fastgpt/service/core/app/version/schema';
import { getUser } from '@test/datas/users';
import { Call } from '@test/utils/request';
const startTime = new Date('2026-05-24T00:00:00.000+08:00');
const beforeStartTime = new Date('2026-05-23T23:59:59.999+08:00');
const afterStartTime = new Date('2026-05-24T12:00:00.000+08:00');
const createSkillNode = (skillId: string): StoreNodeItemType =>
({
nodeId: `node-${skillId}`,
name: 'Agent',
inputs: [
{
key: NodeInputKeyEnum.skills,
renderTypeList: [FlowNodeInputTypeEnum.selectSkill],
valueType: WorkflowIOValueTypeEnum.arrayObject,
value: [{ skillId }]
}
],
outputs: []
}) as StoreNodeItemType;
describe('GET /api/admin/backfillAppResourceRefs', () => {
it('只回填 2026-05-24 之后的版本资源索引和最新发布应用缓存', async () => {
const user = await getUser(`backfill-app-resource-refs-${getNanoid(6)}`);
const oldSkillId = `old-skill-${getNanoid(6)}`;
const newSkillId = `new-skill-${getNanoid(6)}`;
const draftSkillId = `draft-skill-${getNanoid(6)}`;
const [oldApp, newApp, draftOnlyApp] = await MongoApp.create([
{
name: 'Old App',
type: AppTypeEnum.chatAgent,
teamId: user.teamId,
tmbId: user.tmbId,
modules: [createSkillNode(oldSkillId)],
edges: [],
chatConfig: {},
updateTime: afterStartTime,
resourceRefs: { skillIds: ['stale-old-app'] }
},
{
name: 'New App',
type: AppTypeEnum.chatAgent,
teamId: user.teamId,
tmbId: user.tmbId,
modules: [createSkillNode(newSkillId)],
edges: [],
chatConfig: {},
updateTime: afterStartTime,
resourceRefs: { skillIds: ['stale-new-app'] }
},
{
name: 'Draft Only App',
type: AppTypeEnum.chatAgent,
teamId: user.teamId,
tmbId: user.tmbId,
modules: [createSkillNode(draftSkillId)],
edges: [],
chatConfig: {},
updateTime: afterStartTime,
resourceRefs: { skillIds: ['stale-draft-app'] }
}
]);
const [oldVersion, newVersion, draftVersion] = await MongoAppVersion.create([
{
appId: oldApp._id,
tmbId: user.tmbId,
nodes: [createSkillNode(oldSkillId)],
edges: [],
chatConfig: {},
isPublish: true,
versionName: 'old publish',
time: beforeStartTime,
resourceRefs: { skillIds: ['stale-old-version'] }
},
{
appId: newApp._id,
tmbId: user.tmbId,
nodes: [createSkillNode(newSkillId)],
edges: [],
chatConfig: {},
isPublish: true,
versionName: 'new publish',
time: afterStartTime,
resourceRefs: { skillIds: ['stale-new-version'] }
},
{
appId: draftOnlyApp._id,
tmbId: user.tmbId,
nodes: [createSkillNode(draftSkillId)],
edges: [],
chatConfig: {},
isPublish: false,
versionName: 'draft save',
time: afterStartTime,
resourceRefs: { skillIds: ['stale-draft-version'] }
}
]);
const res = await Call(handler, {
auth: { ...user, isRoot: true },
query: {
dryRun: 'false',
batchSize: '1'
}
});
expect(res.code).toBe(200);
expect(res.data.startTime).toBe(startTime.toISOString());
expect(res.data.versions.matched).toBe(2);
expect(res.data.apps.matched).toBe(1);
const [updatedOldVersion, updatedNewVersion, updatedDraftVersion] = await Promise.all([
MongoAppVersion.findById(oldVersion._id).lean(),
MongoAppVersion.findById(newVersion._id).lean(),
MongoAppVersion.findById(draftVersion._id).lean()
]);
expect(updatedOldVersion?.resourceRefs?.skillIds).toEqual(['stale-old-version']);
expect(updatedNewVersion?.resourceRefs?.skillIds).toEqual([newSkillId]);
expect(updatedDraftVersion?.resourceRefs?.skillIds).toEqual([draftSkillId]);
const [updatedOldApp, updatedNewApp, updatedDraftOnlyApp] = await Promise.all([
MongoApp.findById(oldApp._id).lean(),
MongoApp.findById(newApp._id).lean(),
MongoApp.findById(draftOnlyApp._id).lean()
]);
expect(updatedOldApp?.resourceRefs?.skillIds).toEqual(['stale-old-app']);
expect(updatedNewApp?.resourceRefs?.skillIds).toEqual([newSkillId]);
expect(updatedDraftOnlyApp?.resourceRefs?.skillIds).toEqual(['stale-draft-app']);
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment