在普通数据表中有附件字段,删除数据无法一并删除对应附件

有大佬知道在用普通数据表需要创建一个附件字段,但是发行删除数据时,上传的附件无法一并删除,但是看到管理员说可以文件表,但是我尝试后使用文件表后确实可以删除数据时一并删除图片,但是文件表又无普通数据表的相应功能,想问问有大佬知道有啥好的解决办法吗?

在普通数据表上添加关联字段 和文件表关联。

这样的话,普通数据表还能正常显示图片吗

可以的

想问下,你说的关联是添加一个外键还是用个直接把两个表用sql或者视图连在一起? :flushed:

添加一个关系字段

还是有点疑惑,感觉理论懂但是不知道实际如何操作。我看官方操作手册也没有一个示例 :sob:

我在nocobase中没找到在哪里添加?
请问实在普通表里创建个关系字段去关联文件表吗?


我点开后发行无法用普通表里的普通字段去绑定外键
因为我看在普通表创建附件字段并没有关联的选项。

是的,创建关联字段比如一对一去关联文件表

因为可能存在多张图片,我在普通表设置了一个外键字段并且和相应文件表建立了一对多的关系同时设置删除方式为 CASCADE,但是我在普通表上传图片后尝试删除记录,对应文件表中和普通表已无记录,但是上传的图片却还是没有被删除,而直接在文件表中操作上传图片并删除却可以一同删除本地上传图片


目前对文件的删除仅支持在对文件表管理时删除文件表的记录,其他删除关联关系并不会删除文件。

了解,后续版本排版会增加同步删除照片的功能吗

后续会优化这块逻辑。

删除附件时的操作

要完全删除一个附件,需要删除三条记录:

  1. 物理文件:storage/uploads/xxx.png

  2. 中间表记录:DELETE FROM “PostgreSQLt_oba6ni1mlda” WHERE “f_y3vqptdd1k4” = 6258;

  3. 附件表记录:DELETE FROM attachments WHERE id = 6258;

字段名说明

  • f_oyynn1sp5q0:主表外键(f_ + 随机字符串)

  • f_y3vqptdd1k4:附件表外键(f_ + 随机字符串)

这些字段名在创建附件字段时自动生成,存储在字段配置的 foreignKey 和 otherKey 属性中。

所以,你的系统确实有两个表存储附件信息:

  • attachments:存储附件元数据(全局共享)

  • PostgreSQLt_oba6ni1mlda:存储关联关系(你的集合专用)

前端渲染时,通过 JOIN 这两个表来获取完整的附件信息。
这可能对你有帮助

实现自动删除的推荐设置:
改用关系字段关联文件表,在业务表中添加与文件表的关系字段,关系类型推荐使用:
One to one (has one)
One to many
设置该关系字段的 ON DELETE 为 CASCADE

取消文件保留选项
进入文件表 → 文件存储器设置
取消勾选 “Keep file in storage when destroy record”
这样,当关联的业务记录被删除时,会自动级联删除文件表中的记录,同时删除实际存储的文件。

D:\Nocobase\nocobase-2.0.0-alpha.41\packages\plugins@nocobase\plugin-file-manager\src\server\server.ts将它改为

/**

  • This file is part of the NocoBase (R) project.
  • Copyright (c) 2020-2024 NocoBase Co., Ltd.
  • Authors: NocoBase Team.
  • This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
  • For more information, please refer to: https://www.nocobase.com/agreement.
    */

import fs from ‘fs’;
import { basename } from ‘path’;
import match from ‘mime-match’;

import { Collection, Model, Transactionable } from ‘@nocobase/database’;
import { Plugin } from ‘@nocobase/server’;
import { Registry } from ‘@nocobase/utils’;
import { Readable } from ‘stream’;
import { STORAGE_TYPE_ALI_OSS, STORAGE_TYPE_LOCAL, STORAGE_TYPE_S3, STORAGE_TYPE_TX_COS } from ‘…/constants’;
import initActions from ‘./actions’;
import { AttachmentInterface } from ‘./interfaces/attachment-interface’;
import { AttachmentModel, StorageClassType, StorageModel } from ‘./storages’;
import StorageTypeAliOss from ‘./storages/ali-oss’;
import StorageTypeLocal from ‘./storages/local’;
import StorageTypeS3 from ‘./storages/s3’;
import StorageTypeTxCos from ‘./storages/tx-cos’;
import { encodeURL } from ‘./utils’;

export type * from ‘./storages’;

const DEFAULT_STORAGE_TYPE = STORAGE_TYPE_LOCAL;

class FileDeleteError extends Error {
data: Model;

constructor(message: string, data: Model) {
super(message);
this.name = ‘FileDeleteError’;
this.data = data;
}
}

export type FileRecordOptions = {
collectionName: string;
filePath: string;
storageName?: string;
values?: any;
} & Transactionable;

export type UploadFileOptions = {
filePath: string;
storageName?: string;
documentRoot?: string;
};

export class PluginFileManagerServer extends Plugin {
storageTypes = new Registry();
storagesCache = new Map<number | string, StorageModel>();

afterDestroy = async (record: Model, options) => {
const { collection } = record.constructor as typeof Model;
if (collection?.options?.template !== ‘file’ && collection.name !== ‘attachments’) {
return;
}

const recordId = record.get('id');
const storageId = record.get('storageId');

// 调试:输出所有字段以便排查
const allFields = record.get();
console.log(`[file-manager] Record fields:`, Object.keys(allFields));
console.log(`[file-manager] Record dataValues:`, (record as any).dataValues ? Object.keys((record as any).dataValues) : 'no dataValues');
console.log(`[file-manager] Record field values:`, {
  'record.get("filename")': record.get('filename'),
  'record.get("file_name")': record.get('file_name'),
  'record.get("path")': record.get('path'),
  'dataValues.filename': (record as any).dataValues?.filename,
  'dataValues.file_name': (record as any).dataValues?.file_name,
  'dataValues.path': (record as any).dataValues?.path,
});

// 尝试多种方式获取 filename,包括直接访问 dataValues
let filename = record.get('filename') || record.get('file_name');
let filePath = record.get('path');

// 如果 get() 返回 undefined,尝试直接访问 dataValues
if (!filename && (record as any).dataValues) {
  filename = (record as any).dataValues.filename || (record as any).dataValues.file_name;
}
if (!filePath && (record as any).dataValues) {
  filePath = (record as any).dataValues.path;
}

// 最后尝试直接访问属性
if (!filename) {
  filename = (record as any).filename || (record as any).file_name;
}
if (!filePath) {
  filePath = (record as any).path;
}

// 添加调试日志
console.log(`[file-manager] afterDestroy triggered for record:`, {
  collectionName: collection?.name,
  recordId,
  storageId,
  filename,
  filePath,
  template: collection?.options?.template,
});

if (!storageId) {
  console.warn(`[file-manager] Record ${recordId} has no storageId, skipping file deletion`);
  return;
}

// 如果存储缓存为空,尝试重新加载
if (this.storagesCache.size === 0) {
  console.warn(`[file-manager] Storage cache is empty, attempting to reload...`);
  await this.loadStorages();
}

const storage = this.storagesCache.get(storageId);
if (!storage) {
  console.error(`[file-manager] Storage ${storageId} not found in cache. Available storages:`,
    Array.from(this.storagesCache.keys()));
  // 尝试重新加载存储配置
  await this.loadStorages();
  const retryStorage = this.storagesCache.get(storageId);
  if (!retryStorage) {
    console.error(`[file-manager] Storage ${storageId} still not found after reload`);
    return;
  }
  // 使用重新加载的存储配置
  const Type = this.storageTypes.get(retryStorage.type);
  if (!Type) {
    console.error(`[file-manager] Storage type "${retryStorage.type}" not registered`);
    return;
  }
  try {
    const storageConfig = new Type(retryStorage);
    const result = await storageConfig.delete([record as unknown as AttachmentModel]);
    const [count, undeleted] = result;
    if (count > 0) {
      console.log(`[file-manager] Successfully deleted ${count} file(s) for record ${recordId}`);
    } else if (undeleted.length > 0) {
      console.warn(`[file-manager] Failed to delete file for record ${recordId}:`, {
        recordId,
        filename,
        path: filePath,
        undeletedCount: undeleted.length,
      });
    }
  } catch (error) {
    console.error(`[file-manager] Error deleting file for record ${recordId}:`, error);
  }
  return;
}

if (storage?.paranoid) {
  console.log(`[file-manager] Storage ${storageId} is in paranoid mode, skipping file deletion`);
  return;
}

const Type = this.storageTypes.get(storage.type);
if (!Type) {
  console.error(`[file-manager] Storage type "${storage.type}" not registered for storage ${storageId}`);
  return;
}

try {
  const storageConfig = new Type(storage);
  console.log(`[file-manager] Attempting to delete file:`, {
    recordId,
    filename,
    path: filePath,
    storageType: storage.type,
    storageName: storage.name,
  });

  const result = await storageConfig.delete([record as unknown as AttachmentModel]);
  const [count, undeleted] = result;

  if (count > 0) {
    console.log(`[file-manager] Successfully deleted ${count} file(s) for record ${recordId}`);
  } else if (undeleted.length > 0) {
    console.warn(`[file-manager] Failed to delete file for record ${recordId}:`, {
      recordId,
      filename,
      path: filePath,
      undeletedCount: undeleted.length,
    });
  } else {
    console.warn(`[file-manager] No files were deleted for record ${recordId} (count: ${count}, undeleted: ${undeleted.length})`);
  }
} catch (error) {
  console.error(`[file-manager] Error in afterDestroy hook for file deletion:`, {
    recordId,
    filename,
    path: filePath,
    error: error.message,
    stack: error.stack,
  });
}

};

registerStorageType(type: string, Type: StorageClassType) {
this.storageTypes.register(type, Type);
}

async createFileRecord(options: FileRecordOptions) {
const { values, storageName, collectionName, filePath, transaction } = options;
const collection = this.db.getCollection(collectionName);
if (!collection) {
throw new Error(collection does not exist);
}
const collectionRepository = this.db.getRepository(collectionName);
const name = storageName || collection.options.storage;
const data = await this.uploadFile({ storageName: name, filePath });
return await collectionRepository.create({ values: { …data, …values }, transaction });
}

parseStorage(instance) {
return this.app.environment.renderJsonTemplate(instance.toJSON());
}

async uploadFile(options: UploadFileOptions) {
const { storageName, filePath, documentRoot } = options;

if (!this.storagesCache.size) {
  await this.loadStorages();
}
const storages = Array.from(this.storagesCache.values());
const storage = storages.find((item) => item.name === storageName) || storages.find((item) => item.default);

if (!storage) {
  throw new Error('[file-manager] no linked or default storage provided');
}

const fileStream = fs.createReadStream(filePath);

if (documentRoot) {
  storage.options['documentRoot'] = documentRoot;
}

const StorageType = this.storageTypes.get(storage.type);
const storageInstance = new StorageType(storage);

if (!storageInstance) {
  throw new Error(`[file-manager] storage type "${storage.type}" is not defined`);
}

const engine = storageInstance.make();

const file = {
  originalname: basename(filePath),
  path: filePath,
  stream: fileStream,
} as any;

await new Promise((resolve, reject) => {
  engine._handleFile({} as any, file, (error, info) => {
    if (error) {
      reject(error);
    }
    Object.assign(file, info);
    resolve(info);
  });
});

return storageInstance.getFileData(file, {});

}

async loadStorages(options?: { transaction: any }) {
const repository = this.db.getRepository(‘storages’);
const storages = await repository.find({
transaction: options?.transaction,
});
this.storagesCache = new Map();
for (const storage of storages) {
this.storagesCache.set(storage.get(‘id’), this.parseStorage(storage));
}
}

async install() {
const defaultStorageType = this.storageTypes.get(DEFAULT_STORAGE_TYPE);

if (defaultStorageType) {
  const Storage = this.db.getCollection('storages');
  if (
    await Storage.repository.findOne({
      filter: {
        name: defaultStorageType.defaults().name,
      },
    })
  ) {
    return;
  }
  await Storage.repository.create({
    values: {
      ...defaultStorageType.defaults(),
      type: DEFAULT_STORAGE_TYPE,
      default: true,
    },
  });
}

}

async handleSyncMessage(message) {
if (message.type === ‘reloadStorages’) {
await this.loadStorages();
}
}

async beforeLoad() {
this.db.registerModels({ FileModel: Model });
this.db.on(‘beforeDefineCollection’, (options) => {
if (options.template === ‘file’) {
options.model = ‘FileModel’;
}
});
this.db.on(‘afterDefineCollection’, (collection: Collection) => {
if (collection.options.template !== ‘file’) {
return;
}
collection.model.beforeUpdate((model) => {
if (!model.changed(‘url’) || !model.changed(‘preview’)) {
return;
}
model.set(‘url’, model.previous(‘url’));
model.set(‘preview’, model.previous(‘preview’));
model.changed(‘url’, false);
model.changed(‘preview’, false);
});
});
this.app.on(‘afterStart’, async () => {
await this.loadStorages();
});
}

async load() {
this.db.on(‘afterDestroy’, this.afterDestroy);

this.storageTypes.register(STORAGE_TYPE_LOCAL, StorageTypeLocal);
this.storageTypes.register(STORAGE_TYPE_ALI_OSS, StorageTypeAliOss);
this.storageTypes.register(STORAGE_TYPE_S3, StorageTypeS3);
this.storageTypes.register(STORAGE_TYPE_TX_COS, StorageTypeTxCos);

const Storage = this.db.getModel('storages');
Storage.afterSave(async (m, { transaction }) => {
  await this.loadStorages({ transaction });
  this.sendSyncMessage({ type: 'reloadStorages' }, { transaction });
});
Storage.afterDestroy(async (m, { transaction }) => {
  for (const collection of this.db.collections.values()) {
    if (collection?.options?.template === 'file' && collection?.options?.storage === m.name) {
      throw new Error(
        this.t(
          `The storage "${m.name}" is in use in collection "${collection.name}" and cannot be deleted.`,
        ) as any,
      );
    }
  }
  await this.loadStorages({ transaction });
  this.sendSyncMessage({ type: 'reloadStorages' }, { transaction });
});

this.db.on('afterDefineCollection', (collection: Collection) => {
  const { template } = collection.options;
  if (template === 'file') {
    collection.setField('storageId', {
      type: 'bigInt',
      createOnly: true,
      visible: true,
      index: true,
    });
  }
});

this.app.acl.registerSnippet({
  name: `pm.${this.name}.storages`,
  actions: ['storages:*'],
});

initActions(this);

this.app.acl.allow('attachments', ['upload', 'create'], 'loggedIn');
this.app.acl.allow('storages', 'getBasicInfo', 'loggedIn');
this.app.acl.allow('storages', 'check', 'loggedIn');

this.app.acl.appendStrategyResource('attachments');

// this.app.resourcer.use(uploadMiddleware);
// this.app.resourcer.use(createAction);
// this.app.resourcer.registerActionHandler('upload', uploadAction);

const defaultStorageName = this.storageTypes.get(DEFAULT_STORAGE_TYPE).defaults().name;

this.app.acl.addFixedParams('storages', 'destroy', () => {
  return {
    filter: { 'name.$ne': defaultStorageName },
  };
});

const ownMerger = () => {
  return {
    filter: {
      createdById: '{{ctx.state.currentUser.id}}',
    },
  };
};

this.app.acl.addFixedParams('attachments', 'update', ownMerger);
this.app.acl.addFixedParams('attachments', 'create', ownMerger);
this.app.acl.addFixedParams('attachments', 'destroy', ownMerger);

this.app.db.interfaceManager.registerInterfaceType('attachment', AttachmentInterface);

this.db.on('afterFind', async (instances) => {
  if (!instances) {
    return;
  }
  const records = Array.isArray(instances) ? instances : [instances];
  const name = records[0]?.constructor?.name;
  if (name) {
    const collection = this.db.getCollection(name);
    if (collection?.name === 'attachments' || collection?.options?.template === 'file') {
      for (const record of records) {
        const url = await this.getFileURL(record);
        const previewUrl = await this.getFileURL(record, true);
        record.set('url', url);
        record.set('preview', previewUrl);
        record.dataValues.preview = previewUrl; // 强制添加preview,在附件字段时,通过set设置无效
      }
    }
  }
});

}

async getFileURL(file: AttachmentModel, preview = false) {
if (!file.storageId) {
return encodeURL(file.url);
}
const storage = this.storagesCache.get(file.storageId);
if (!storage) {
return encodeURL(file.url);
}
const storageType = this.storageTypes.get(storage.type);
return new storageType(storage).getFileURL(
file,
Boolean(file.mimetype && match(file.mimetype, ‘image/*’) && preview && storage.options.thumbnailRule),
);
}
async isPublicAccessStorage(storageName) {
const storageRepository = this.db.getRepository(‘storages’);
const storages = await storageRepository.findOne({
filter: { default: true },
});
let storage;
if (!storageName) {
storage = storages;
} else {
storage = await storageRepository.findOne({
filter: {
name: storageName,
},
});
}
storage = this.parseStorage(storage);
if ([‘local’, ‘ali-oss’, ‘s3’, ‘tx-cos’].includes(storage.type)) {
return true;
}
return !!storage.options?.public;
}
async getFileStream(file: AttachmentModel): Promise<{ stream: Readable; contentType?: string }> {
if (!file.storageId) {
throw new Error(‘File storageId not found’);
}
const storage = this.storagesCache.get(file.storageId);
if (!storage) {
throw new Error(‘[file-manager] no linked or default storage provided’);
}

const StorageType = this.storageTypes.get(storage.type);
if (!StorageType) {
  throw new Error(`[file-manager] storage type "${storage.type}" is not defined`);
}
const storageInstance = new StorageType(storage);

if (!storageInstance) {
  throw new Error(`[file-manager] storage type "${storage.type}" is not defined`);
}

return storageInstance.getFileStream(file);

}
}

export default PluginFileManagerServer;

将D:\Nocobase\nocobase-2.0.0-alpha.41\packages\plugins@nocobase\plugin-file-manager\src\server\storages\local.ts
改为
/**

  • This file is part of the NocoBase (R) project.
  • Copyright (c) 2020-2024 NocoBase Co., Ltd.
  • Authors: NocoBase Team.
  • This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
  • For more information, please refer to: https://www.nocobase.com/agreement.
    */

import { isURL } from ‘@nocobase/utils’;
import fsSync from ‘fs’;
import fs from ‘fs/promises’;
import multer from ‘multer’;
import path from ‘path’;
import type { Readable } from ‘stream’;
import urlJoin from ‘url-join’;
import { AttachmentModel, StorageType } from ‘.’;
import { FILE_SIZE_LIMIT_DEFAULT, STORAGE_TYPE_LOCAL } from ‘…/…/constants’;
import { getFilename } from ‘…/utils’;

const DEFAULT_BASE_URL = ‘/storage/uploads’;

function getDocumentRoot(storage): string {
const { documentRoot = process.env.LOCAL_STORAGE_DEST || ‘storage/uploads’ } = storage.options || {};
// TODO(feature): 后面考虑以字符串模板的方式使用,可注入 req/action 相关变量,以便于区分文件夹
return path.resolve(path.isAbsolute(documentRoot) ? documentRoot : path.join(process.cwd(), documentRoot));
}

export default class extends StorageType {
static defaults() {
return {
title: ‘Local storage’,
type: STORAGE_TYPE_LOCAL,
name: local,
baseUrl: DEFAULT_BASE_URL,
options: {
documentRoot: ‘storage/uploads’,
},
path: ‘’,
rules: {
size: FILE_SIZE_LIMIT_DEFAULT,
},
};
}

make() {
return multer.diskStorage({
destination: (req, file, cb) => {
const destPath = path.join(getDocumentRoot(this.storage), this.storage.path || ‘’);
const mkdirp = require(‘mkdirp’);
mkdirp(destPath, (err: Error | null) => cb(err, destPath));
},
filename: getFilename,
});
}
async delete(records: AttachmentModel): Promise<[number, AttachmentModel]> {
const documentRoot = getDocumentRoot(this.storage);
console.log([file-manager:local] Starting file deletion:, {
documentRoot,
storageId: this.storage.id,
storageName: this.storage.name,
recordCount: records.length,
});

let count = 0;
const undeleted = [];
await records.reduce(
  (promise, record) =>
    promise.then(async () => {
      // 从 Sequelize 模型中正确获取字段值
      // 尝试多种方式获取数据:dataValues、get() 方法、直接属性访问
      const recordAny = record as any;
      const recordData = recordAny.dataValues || record;
      const recordId = recordData.id || recordAny.id;

      // 尝试多种字段名,兼容不同的字段定义
      // 优先从 dataValues 获取,然后尝试 get 方法,最后直接访问
      let filename = recordData.filename || recordData.file_name;
      if (!filename && typeof recordAny.get === 'function') {
        filename = recordAny.get('filename') || recordAny.get('file_name');
      }
      if (!filename) {
        filename = recordAny.filename || recordAny.file_name;
      }

      let recordPath = recordData.path || '';
      if (!recordPath && typeof recordAny.get === 'function') {
        recordPath = recordAny.get('path') || '';
      }
      if (!recordPath) {
        recordPath = recordAny.path || '';
      }

      // 获取 URL 和 preview 字段
      let url = recordData.url;
      if (!url && typeof recordAny.get === 'function') {
        url = recordAny.get('url');
      }
      if (!url) {
        url = recordAny.url;
      }

      let preview = recordData.preview;
      if (!preview && typeof recordAny.get === 'function') {
        preview = recordAny.get('preview');
      }
      if (!preview) {
        preview = recordAny.preview;
      }

      // 如果 filename 不存在,尝试从 url 或 preview 中提取
      if (!filename) {
        const sourceUrl = url || preview;

        if (sourceUrl && typeof sourceUrl === 'string') {
          // 从 URL 中提取文件名
          // 例如: /storage/uploads/path/filename.ext 或 /storage/uploads/filename.ext
          // 排除只是目录路径的情况(如 /storage/uploads)
          const normalizedUrl = sourceUrl.replace(/^\/storage\/uploads\/?/, '').replace(/^\//, '');
          if (normalizedUrl && normalizedUrl !== 'storage/uploads' && normalizedUrl.length > 0) {
            const parts = normalizedUrl.split('/').filter(p => p && p.trim());
            if (parts.length > 0) {
              const lastPart = parts[parts.length - 1];
              // 确保最后一部分看起来像文件名(有扩展名或至少有几个字符)
              if (lastPart && lastPart.length > 0 && (lastPart.includes('.') || lastPart.length >= 3)) {
                filename = lastPart;
                // 如果有路径部分,提取出来
                if (parts.length > 1) {
                  recordPath = parts.slice(0, -1).join('/');
                }
                console.log(`[file-manager:local] Extracted filename from URL:`, {
                  sourceUrl,
                  filename,
                  recordPath,
                });
              }
            }
          }
        }
      }

      // 检查必要的字段是否存在
      if (!filename || filename === 'null' || filename.trim() === '') {
        console.warn(`[file-manager:local] File record missing valid filename, skipping file deletion:`, {
          recordId,
          file_name: recordData.file_name,
          filename: recordData.filename,
          path: recordData.path,
          url,
          preview,
          extractedFilename: filename,
          note: 'This record appears to be incomplete or the file was not uploaded successfully',
        });
        count += 1; // 视为成功,因为记录本身可能无效或文件不存在
        return;
      }

      // 构建文件路径,处理 path 可能为空的情况
      let filePath = path.join(documentRoot, recordPath, filename);

      console.log(`[file-manager:local] Attempting to delete file:`, {
        extractedFilename: filename,
        recordFilename: recordData.filename,
        recordFileName: recordData.file_name,
        recordPath,
        fullPath: filePath,
        documentRoot,
        recordId,
      });

      try {
        // 检查文件是否存在
        let fileExists = false;
        try {
          await fs.access(filePath);
          fileExists = true;
          console.log(`[file-manager:local] File exists, proceeding with deletion: ${filePath}`);
        } catch (accessError) {
          if (accessError.code === 'ENOENT') {
            // 文件不存在,尝试查找可能的变体(如果文件名不匹配)
            // 例如:如果数据库中是原始文件名,但实际文件有后缀
            const extname = path.extname(filename);
            const basenameWithoutExt = path.basename(filename, extname);

            // 如果文件名看起来没有后缀(没有 -xxxxx 格式),尝试查找带后缀的文件
            if (!basenameWithoutExt.match(/-\w{6}$/)) {
              console.log(`[file-manager:local] Filename doesn't have suffix pattern, searching for variants...`);

              try {
                // 列出目录中的所有文件,查找匹配的文件
                const dirPath = path.join(documentRoot, recordPath);
                const files = await fs.readdir(dirPath);

                // 查找以 basename 开头且以 extname 结尾的文件
                const matchingFile = files.find(f => {
                  const fExt = path.extname(f);
                  const fBase = path.basename(f, fExt);
                  return fExt === extname && fBase.startsWith(basenameWithoutExt);
                });

                if (matchingFile) {
                  filePath = path.join(dirPath, matchingFile);
                  console.log(`[file-manager:local] Found matching file with different name: ${matchingFile}`);
                  fileExists = true;
                }
              } catch (dirError) {
                console.warn(`[file-manager:local] Could not search directory for file variants:`, dirError.message);
              }
            }

            if (!fileExists) {
              console.warn(`[file-manager:local] File does not exist, treating as deleted: ${filePath}`);
              count += 1;
              return;
            }
          } else {
            throw accessError;
          }
        }

        if (fileExists) {
          await fs.unlink(filePath);
          console.log(`[file-manager:local] Successfully deleted file: ${filePath}`);
          count += 1;
        }
      } catch (ex) {
        if (ex.code === 'ENOENT') {
          // 文件不存在,视为成功删除(可能已经被手动删除)
          console.warn(`[file-manager:local] File not found, treating as deleted: ${filePath}`);
          count += 1;
        } else {
          console.error(`[file-manager:local] Failed to delete file ${filePath}:`, {
            error: ex.message,
            code: ex.code,
            stack: ex.stack,
          });
          undeleted.push(record);
        }
      }
    }),
  Promise.resolve(),
);

console.log(`[file-manager:local] File deletion completed:`, {
  totalRecords: records.length,
  deletedCount: count,
  undeletedCount: undeleted.length,
});

return [count, undeleted];

}
async getFileURL(file: AttachmentModel, preview = false) {
const url = await super.getFileURL(file, preview);
if (isURL(url)) {
return url;
}
return urlJoin(process.env.APP_PUBLIC_PATH, url);
}

async getFileStream(file: AttachmentModel): Promise<{ stream: Readable; contentType?: string }> {
// compatible with windows path
const filePath = path.join(process.cwd(), ‘storage’, ‘uploads’, file.path || ‘’, file.filename);
if (await fs.stat(filePath)) {
return {
stream: fsSync.createReadStream(filePath),
contentType: file.mimetype,
};
}
throw new Error(File not found: ${filePath});
}
}

将D:\Nocobase\nocobase-2.0.0-alpha.41\packages\plugins@nocobase\plugin-file-manager\src\server\actions\attachments.ts改为/**

  • This file is part of the NocoBase (R) project.
  • Copyright (c) 2020-2024 NocoBase Co., Ltd.
  • Authors: NocoBase Team.
  • This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
  • For more information, please refer to: https://www.nocobase.com/agreement.
    */

import { PassThrough } from ‘stream’;
import match from ‘mime-match’;
import mime from ‘mime-types’;

import { Context, Next } from ‘@nocobase/actions’;
import { koaMulter as multer } from ‘@nocobase/utils’;

import Plugin from ‘…’;
import { FILE_FIELD_NAME, FILE_SIZE_LIMIT_DEFAULT, FILE_SIZE_LIMIT_MIN, LIMIT_FILES } from ‘…/…/constants’;
import { StorageClassType, StorageType } from ‘…/storages’;

function makeMulterStorage(storage: StorageType) {
const innerStorage = storage.make();

return {
_handleFile(req, file, cb) {
const pattern = storage.storage.rules.mimetype;
const peekSize = 4100;
const originalStream = file.stream;
const passThrough = new PassThrough();
const proxyFile = { …file, stream: passThrough };
let detectedMime = null;

  const finalCallback = (err, result) => {
    if (err) {
      return cb(err);
    }
    if (detectedMime && result) {
      result.mimetype = detectedMime;
    }
    cb(null, result);
  };

  innerStorage._handleFile(req, proxyFile, finalCallback);

  const chunks = [];
  let bytesRead = 0;
  let validationTriggered = false;

  const cleanup = () => {
    originalStream.removeListener('data', onData);
    originalStream.removeListener('end', onEnd);
    originalStream.removeListener('error', onError);
  };

  const onData = (chunk) => {
    if (validationTriggered) return;
    chunks.push(chunk);
    bytesRead += chunk.length;
    if (bytesRead >= peekSize) {
      validationTriggered = true;
      cleanup();
      originalStream.pause(); // Pause before async validation
      validate(Buffer.concat(chunks));
    }
  };

  const onEnd = () => {
    if (!validationTriggered) {
      validationTriggered = true;
      cleanup();
      validate(Buffer.concat(chunks));
    }
  };

  const onError = (err) => {
    cleanup();
    passThrough.destroy(err);
  };

  const validate = async (header) => {
    try {
      const { fileTypeFromBuffer } = await import('file-type');
      const type = await fileTypeFromBuffer(new Uint8Array(header));

      if (type) {
        detectedMime = type.mime;
      } else {
        const fromFilename = mime.lookup(file.originalname);
        if (fromFilename) {
          detectedMime = fromFilename;
        }
      }

      if (!detectedMime || (pattern !== '*' && !pattern.toString().split(',').some(match(detectedMime)))) {
        const err = new Error('Mime type not allowed by storage rule');
        err.name = 'MulterError';
        originalStream.destroy();
        passThrough.destroy();
        return cb(err);
      }

      // Validation passed. Now, write the header and pipe the rest.
      passThrough.write(header, (writeErr) => {
        if (writeErr) {
          originalStream.destroy();
          passThrough.destroy();
          return cb(writeErr);
        }

        if (originalStream.readableEnded) {
          passThrough.end();
        } else {
          originalStream.pipe(passThrough);
          originalStream.resume();
        }
      });
    } catch (err) {
      originalStream.destroy();
      passThrough.destroy();
      return cb(err);
    }
  };

  originalStream.on('data', onData);
  originalStream.on('end', onEnd);
  originalStream.on('error', onError);
},

_removeFile(req, file, cb) {
  innerStorage._removeFile(req, file, cb);
},

};
}

async function multipart(ctx: Context, next: Next) {
const { storage } = ctx;
if (!storage) {
ctx.logger.error(‘[file-manager] no linked or default storage provided’);
return ctx.throw(500);
}

const StorageClass = ctx.app.pm.get(Plugin).storageTypes.get(storage.type) as StorageClassType;
if (!StorageClass) {
ctx.logger.error([file-manager] storage type "${storage.type}" is not defined);
return ctx.throw(500);
}
const storageInstance = new StorageClass(storage);

const multerOptions = {
// fileFilter: getFileFilter(storageInstance),
limits: {
// 每次只允许提交一个文件
files: LIMIT_FILES,
},
storage: storage.rules?.mimetype ? makeMulterStorage(storageInstance) : storageInstance.make(),
};
multerOptions.limits[‘fileSize’] = Math.max(FILE_SIZE_LIMIT_MIN, storage.rules.size ?? FILE_SIZE_LIMIT_DEFAULT);

const upload = multer(multerOptions).single(FILE_FIELD_NAME);
try {
// NOTE: empty next and invoke after success
await upload(ctx, () => {});
} catch (err) {
if (err.name === ‘MulterError’) {
return ctx.throw(400, err);
}
ctx.logger.error(err);
return ctx.throw(500, err);
}

const { [FILE_FIELD_NAME]: file } = ctx;
if (!file) {
return ctx.throw(400, ‘file validation failed’);
}

// 调试:输出 multer 返回的 file 对象结构
console.log([file-manager] Multer file object after upload:, {
filename: file.filename,
originalname: file.originalname,
path: file.path,
size: file.size,
mimetype: file.mimetype,
allFields: Object.keys(file),
});

const values = storageInstance.getFileData(file, ctx.request.body);

// 调试:输出 getFileData 返回的数据
console.log([file-manager] getFileData returned values:, values);

// 兼容性:如果集合使用 file_name 字段,同时设置 file_name
// 检查集合字段定义,看是否有 file_name 字段
const collection = ctx.db.getCollection(ctx.action.resourceName);
const hasFileNameField = collection?.fields?.has(‘file_name’);
const hasFilenameField = collection?.fields?.has(‘filename’);

// 如果集合有 file_name 字段,同时设置 file_name(兼容自定义字段名)
const valuesWithFileName = { …values } as any;
if (hasFileNameField && values.filename) {
valuesWithFileName.file_name = values.filename;
console.log([file-manager] Also setting file_name field for compatibility:, valuesWithFileName.file_name);
}

ctx.action.mergeParams({
values: {
…valuesWithFileName,
storage: { id: storage.id },
},
});

await next();
}

export async function createMiddleware(ctx: Context, next: Next) {
const { resourceName, actionName } = ctx.action;
const { attachmentField } = ctx.action.params;
const collection = ctx.db.getCollection(resourceName);

if (collection?.options?.template !== ‘file’ || ![‘upload’, ‘create’].includes(actionName)) {
return next();
}

const storageName =
resourceName === ‘attachments’
? ctx.db.getFieldByPath(attachmentField)?.options?.storage
: collection.options.storage;
// const StorageRepo = ctx.db.getRepository(‘storages’);
// const storage = await StorageRepo.findOne({ filter: storageName ? { name: storageName } : { default: true } });
const plugin = ctx.app.pm.get(Plugin) as Plugin;
const storage = Array.from(plugin.storagesCache.values()).find((storage) =>
storageName ? storage.name === storageName : storage.default,
);
if (!storage) {
ctx.logger.error([file-manager] no storage found);
return ctx.throw(500);
}
ctx.storage = storage;

if (ctx?.request.is(‘multipart/*’)) {
await multipart(ctx, next);
} else {
// 非 multipart 请求:检查是否提供了必要的文件信息
const values = ctx.action.params.values || {};
const hasFileInfo = values.filename || values.file_name;

if (!hasFileInfo) {
  ctx.logger.warn(`[file-manager] Creating file record without file upload or file info. This may result in an incomplete record.`, {
    resourceName,
    actionName,
    providedFields: Object.keys(values),
  });
}

ctx.action.mergeParams({
  values: {
    storage: { id: storage.id },
    ...values, // 保留用户提供的其他字段
  },
});
await next();

}
}