From 2459d19008a881087576dd18ed90501796a888d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=8C=E6=95=A6=E4=BC=9F?= Date: Thu, 7 May 2026 17:31:34 +0800 Subject: [PATCH] fix: make renameAsset use newName semantics --- e2e/mcp/api/assets/operation.e2e.test.ts | 38 ++++++-- src/api/assets/assets.ts | 10 ++- src/api/assets/schema.ts | 9 +- src/core/assets/manager/operation.ts | 42 ++++++--- src/core/assets/test/operation.test.ts | 108 +++++++++++++++++++++++ src/lib/assets/assets.ts | 4 +- 6 files changed, 184 insertions(+), 27 deletions(-) diff --git a/e2e/mcp/api/assets/operation.e2e.test.ts b/e2e/mcp/api/assets/operation.e2e.test.ts index 2dfc1e0b2..31e9b30d5 100644 --- a/e2e/mcp/api/assets/operation.e2e.test.ts +++ b/e2e/mcp/api/assets/operation.e2e.test.ts @@ -567,31 +567,57 @@ export class CorrectComponent extends Component { }); describe('asset-rename', () => { + test('should rename asset by newName in the same directory', async () => { + const sourceName = generateTestFileName('rename-source', 'txt'); + const targetName = generateTestFileName('rename-target', 'txt'); + const sourceUrl = `${context.testRootUrl}/${sourceName}`; + const targetPath = join(context.testRootPath, targetName); + + await context.mcpClient.callTool('assets-create-asset', { + options: { + target: sourceUrl, + content: TEST_ASSET_CONTENTS.text, + }, + }); + + const result = await context.mcpClient.callTool('assets-rename-asset', { + source: sourceUrl, + newName: targetName, + options: {}, + }); + + expect(result.code).toBe(200); + validateAssetMoved(join(context.testRootPath, sourceName), targetPath); + expect(readFileSync(targetPath, 'utf8')).toEqual(TEST_ASSET_CONTENTS.text); + }); + test('should handle renaming to existing name', async () => { - const name1 = `rename-exist-1-${generateTestId()}`; - const name2 = `rename-exist-2-${generateTestId()}`; + const name1 = generateTestFileName('rename-exist-1', 'txt'); + const name2 = generateTestFileName('rename-exist-2', 'txt'); + const sourceUrl = `${context.testRootUrl}/${name1}`; // 创建两个资源 await context.mcpClient.callTool('assets-create-asset', { options: { - target: `${context.testRootUrl}/${name1}`, + target: sourceUrl, + content: 'rename-source', }, }); await context.mcpClient.callTool('assets-create-asset', { options: { target: `${context.testRootUrl}/${name2}`, + content: 'rename-target', }, }); // 尝试重命名到已存在的名称 const result = await context.mcpClient.callTool('assets-rename-asset', { - source: `${context.testRootUrl}/${name1}`, - target: `${context.testRootUrl}/${name2}`, + source: sourceUrl, + newName: name2, options: {}, }); - // 应该失败或使用 rename 选项 expect(result.code).not.toBe(200); }); }); diff --git a/src/api/assets/assets.ts b/src/api/assets/assets.ts index 2c9ac4619..44820d72d 100644 --- a/src/api/assets/assets.ts +++ b/src/api/assets/assets.ts @@ -37,7 +37,9 @@ import { TSaveAssetResult, TRefreshDirResult, SchemaBaseName, + SchemaAssetNewName, TBaseName, + TAssetNewName, SchemaRefreshDirResult, SchemaCreateAssetByTypeOptions, TCreateAssetByTypeOptions, @@ -563,11 +565,11 @@ export class AssetsApi { */ @tool('assets-rename-asset') @title('Rename Asset') // 重命名资源 - @description('Rename the specified asset file. Supports renaming files and folders, with options to overwrite or automatically rename.') // 重命名指定的资源文件。支持重命名文件和文件夹,可选择是否覆盖或自动重命名。 + @description('Rename the specified asset in its current directory. The source can be a URL, UUID, or path. The newName parameter only changes the asset name and does not move it across directories; use moveAsset for moving. For file assets, include the extension in newName. Supports overwrite or automatic rename on conflicts.') // 在资源当前目录内重命名指定资源。source 支持 URL、UUID 或路径。newName 仅修改名称,不负责跨目录移动;如需移动请使用 moveAsset。文件资源请在 newName 中包含后缀名。支持冲突时覆盖或自动重命名。 @result(SchemaAssetInfoResult) async renameAsset( - @param(SchemaUrlOrUUIDOrPath) source: TDirOrDbPath, - @param(SchemaUrlOrUUIDOrPath) target: TDirOrDbPath, + @param(SchemaUrlOrUUIDOrPath) source: TUrlOrUUIDOrPath, + @param(SchemaAssetNewName) newName: TAssetNewName, @param(SchemaAssetRenameOptions) options: TAssetRenameOptions = {} ): Promise> { const code: HttpStatusCode = COMMON_STATUS.SUCCESS; @@ -577,7 +579,7 @@ export class AssetsApi { }; try { - ret.data = await assetManager.renameAsset(source, target, options); + ret.data = await assetManager.renameAsset(source, newName, options); } catch (e) { ret.code = COMMON_STATUS.FAIL; console.error('rename asset fail:', e instanceof Error ? e.message : String(e)); diff --git a/src/api/assets/schema.ts b/src/api/assets/schema.ts index ca97f7bb2..2ea3341a8 100644 --- a/src/api/assets/schema.ts +++ b/src/api/assets/schema.ts @@ -123,6 +123,12 @@ export const SchemaQueryAssetsOption = z.object({ export const SchemaSupportCreateType = z.enum(SUPPORT_CREATE_TYPES as any).describe('Supported asset handler types for creation'); // 支持创建的资源处理器类型 export const SchemaTargetPath = z.string().min(1).describe('Target path, asset will be created or imported to this path'); // 目标路径,资源将被创建或导入到此路径 export const SchemaBaseName = z.string().min(1).describe('Base name, asset will be created or imported to this name'); // 基础名称,资源将被创建或导入到此名称 +export const SchemaAssetNewName = z.string() + .min(1) + .refine((value) => value !== '.' && value !== '..' && !value.startsWith('db://') && !/[\\/]/.test(value), { + message: 'New name must be a single file or directory name, not a path', + }) + .describe('New asset name in the current directory. For file assets, include the file extension.'); // 新的资源名称,仅用于当前目录下重命名;文件资源需要包含后缀名 export const SchemaAssetOperationOption = z.object({ overwrite: z.boolean().optional().describe('Whether to force overwrite existing files, default false'), // 是否强制覆盖已存在的文件,默认 false rename: z.boolean().optional().describe('Whether to automatically rename conflicting files, default false'), // 是否自动重命名冲突文件,默认 false @@ -214,6 +220,7 @@ export type TDataKeys = z.infer; export type TQueryAssetsOption = z.infer | undefined; export type TSupportCreateType = z.infer; export type TTargetPath = z.infer; +export type TAssetNewName = z.infer; export type TAssetOperationOption = z.infer | undefined; export type TSourcePath = z.infer; export type TAssetData = z.infer; @@ -279,4 +286,4 @@ export const SchemaAssetConfig = z.object({ }).describe('Asset configuration information'); // 资源配置信息 export const SchemaAssetConfigMapResult = z.record(z.string(), SchemaAssetConfig).describe('Asset configuration map, key is asset handler name, value is corresponding configuration information'); // 资源配置映射表,键为资源处理器名称,值为对应的配置信息 -export type TAssetConfigMapResult = z.infer; \ No newline at end of file +export type TAssetConfigMapResult = z.infer; diff --git a/src/core/assets/manager/operation.ts b/src/core/assets/manager/operation.ts index 486381015..be4fc0dbd 100644 --- a/src/core/assets/manager/operation.ts +++ b/src/core/assets/manager/operation.ts @@ -4,7 +4,7 @@ import { refresh, reimport, queryUrl, Asset } from '@cocos/asset-db'; import { copy, move, remove, rename, existsSync } from 'fs-extra'; -import { isAbsolute, dirname, basename, join, relative, extname } from 'path'; +import { isAbsolute, dirname, join, relative, extname } from 'path'; import { IMoveOptions } from '../@types/private'; import { IAsset, CreateAssetOptions, IExportOptions, IExportData, CreateAssetByTypeOptions, ICreateMenuInfo } from '../@types/protected'; import { AssetOperationOption, AssetUserDataMap, IAssetInfo, IAssetMeta, ISupportCreateType } from '../@types/public'; @@ -51,6 +51,24 @@ class AssetOperation extends EventEmitter { return path; } + _checkRenameNewName(asset: IAsset, newName: string) { + if (!newName || newName === '.' || newName === '..') { + throw new Error('newName must be a single file or directory name'); + } + + if ( + newName.startsWith('db://') + || isAbsolute(newName) + || /[\\/]/.test(newName) + ) { + throw new Error('newName must be a single file or directory name'); + } + + if (!asset.isDirectory() && !extname(newName)) { + throw new Error('newName must include file extension'); + } + } + async saveAssetMeta(uuid: string, meta: IAssetMeta, asset?: IAsset) { // 不能为数组 if ( @@ -360,14 +378,14 @@ class AssetOperation extends EventEmitter { /** * 重命名某个资源 * @param source - * @param target + * @param newName */ - async renameAsset(source: string, target: string, option?: AssetOperationOption) { - return await assetDBManager.addTask(this._renameAsset.bind(this), [source, target, option]); + async renameAsset(source: string, newName: string, option?: AssetOperationOption) { + return await assetDBManager.addTask(this._renameAsset.bind(this), [source, newName, option]); } - private async _renameAsset(source: string, target: string, option?: AssetOperationOption) { - console.debug(`start rename asset from ${source} -> ${target}...`); + private async _renameAsset(source: string, newName: string, option?: AssetOperationOption) { + console.debug(`start rename asset from ${source} -> ${newName}...`); const asset = assetQuery.queryAsset(source); if (!asset) { throw new Error(`asset in source file ${source} not exists`); @@ -375,20 +393,16 @@ class AssetOperation extends EventEmitter { this._checkReadonly(asset); source = asset.source; this._checkExists(source); - if (target.startsWith('db://')) { - target = url2path(target); - } + this._checkRenameNewName(asset, newName); + + let target = join(dirname(source), newName); target = this._checkOverwrite(target, option); // 源地址不能被目标地址包含,也不能相等 if (target.startsWith(join(source, '/'))) { throw new Error(`${i18n.t('assets.rename_asset.fail.parent')} \nsource: ${source}\ntarget: ${target}`); } - const uri = { - basename: basename(target), - dirname: dirname(target), - }; - const temp = join(uri.dirname, '.rename_temp'); + const temp = join(dirname(target), '.rename_temp'); // 改到临时路径,然后刷新,删除原来的缓存 await rename(source + '.meta', temp + '.meta'); diff --git a/src/core/assets/test/operation.test.ts b/src/core/assets/test/operation.test.ts index 7b4b7410a..bd6e7177a 100644 --- a/src/core/assets/test/operation.test.ts +++ b/src/core/assets/test/operation.test.ts @@ -254,6 +254,114 @@ describe('测试 db 的操作接口', function () { // }); // }); + describe('rename-asset', () => { + it('使用 url 重命名普通资源时,第二个参数只接收 newName', async function () { + const sourceName = `${name}_rename-file.txt`; + const targetName = `${name}_rename-file-next.txt`; + const sourcePath = join(databasePath, sourceName); + const targetPath = join(databasePath, targetName); + + await assetManager.createAsset({ + target: sourcePath, + content: 'rename-file', + overwrite: true, + }); + + await assetManager.renameAsset(`${TestGlobalEnv.testRootUrl}/${sourceName}`, targetName); + + expect(existsSync(sourcePath)).toStrictEqual(false); + expect(existsSync(`${sourcePath}.meta`)).toStrictEqual(false); + expect(existsSync(targetPath)).toStrictEqual(true); + expect(existsSync(`${targetPath}.meta`)).toStrictEqual(true); + expect(readFileSync(targetPath, 'utf8')).toStrictEqual('rename-file'); + }); + + it('使用 uuid 重命名文件夹时,newName 保持在原目录下', async function () { + const sourceName = `${name}_rename-folder`; + const targetName = `${name}_rename-folder-next`; + const sourcePath = join(databasePath, sourceName); + const targetPath = join(databasePath, targetName); + + const asset = await assetManager.createAsset({ + target: sourcePath, + }); + + await assetManager.renameAsset(asset!.uuid, targetName); + + expect(existsSync(sourcePath)).toStrictEqual(false); + expect(existsSync(`${sourcePath}.meta`)).toStrictEqual(false); + expect(existsSync(targetPath)).toStrictEqual(true); + expect(existsSync(`${targetPath}.meta`)).toStrictEqual(true); + expect(statSync(targetPath).isDirectory()).toStrictEqual(true); + }); + + it('使用绝对路径重命名时,rename 选项会在名称冲突后自动生成新名称', async function () { + const sourceName = `${name}_rename-conflict-source.txt`; + const targetName = `${name}_rename-conflict-target.txt`; + const autoRenamedName = `${name}_rename-conflict-target-001.txt`; + const sourcePath = join(databasePath, sourceName); + const targetPath = join(databasePath, targetName); + const autoRenamedPath = join(databasePath, autoRenamedName); + + await assetManager.createAsset({ + target: sourcePath, + content: 'source-content', + overwrite: true, + }); + await assetManager.createAsset({ + target: targetPath, + content: 'target-content', + overwrite: true, + }); + + await assetManager.renameAsset(sourcePath, targetName, { + rename: true, + }); + + expect(existsSync(sourcePath)).toStrictEqual(false); + expect(existsSync(targetPath)).toStrictEqual(true); + expect(readFileSync(targetPath, 'utf8')).toStrictEqual('target-content'); + expect(existsSync(autoRenamedPath)).toStrictEqual(true); + expect(readFileSync(autoRenamedPath, 'utf8')).toStrictEqual('source-content'); + }); + + it('重命名文件时 newName 必须带后缀名', async function () { + const sourceName = `${name}_rename-no-ext.txt`; + const sourcePath = join(databasePath, sourceName); + + await assetManager.createAsset({ + target: sourcePath, + content: 'rename-no-ext', + overwrite: true, + }); + + await expect(assetManager.renameAsset(sourcePath, `${name}_rename-no-ext-next`)).rejects.toThrow( + 'newName must include file extension' + ); + + expect(existsSync(sourcePath)).toStrictEqual(true); + expect(readFileSync(sourcePath, 'utf8')).toStrictEqual('rename-no-ext'); + }); + + it('newName 包含路径分隔符时应该失败', async function () { + const sourceName = `${name}_rename-invalid.txt`; + const sourcePath = join(databasePath, sourceName); + + await assetManager.createAsset({ + target: sourcePath, + content: 'rename-invalid', + overwrite: true, + }); + + await expect(assetManager.renameAsset(sourcePath, `nested/${name}_rename-invalid-next.txt`)).rejects.toThrow( + 'newName must be a single file or directory name' + ); + + expect(existsSync(sourcePath)).toStrictEqual(true); + expect(readFileSync(sourcePath, 'utf8')).toStrictEqual('rename-invalid'); + }); + }); + describe('delete-asset', () => { describe('删除文件夹', function () { diff --git a/src/lib/assets/assets.ts b/src/lib/assets/assets.ts index 1ecc9cd05..ed06486e7 100644 --- a/src/lib/assets/assets.ts +++ b/src/lib/assets/assets.ts @@ -226,10 +226,10 @@ export async function querySortedPlugins( */ export async function renameAsset( source: string, - target: string, + newName: string, options: AssetOperationOption = {} ): Promise { - return await assetManager.renameAsset(source, target, options); + return await assetManager.renameAsset(source, newName, options); } /**