From 8bdbd804f0adc5cbdb5bb4627fd642647c1e73b4 Mon Sep 17 00:00:00 2001 From: Junyi Date: Sat, 19 Dec 2020 08:45:19 +0800 Subject: [PATCH] feature: add file manager base architecture (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feature: add file manager base architecture * 修改 action 注册方式 * put upload action and middleware together * bugfix Co-authored-by: chenos --- packages/database/src/fields/field-types.ts | 4 +- packages/plugin-file-manager/package.json | 8 +- .../src/__tests__/action.test.ts | 29 ++++ .../src/__tests__/files/text.txt | 1 + .../src/__tests__/index.ts | 138 ++++++++++++++++++ .../plugin-file-manager/src/actions/upload.ts | 23 +++ .../src/collections/attachments.ts | 50 ++++++- .../src/collections/storages.ts | 38 +++++ packages/plugin-file-manager/src/constants.ts | 1 + .../plugin-file-manager/src/fields/URL.ts | 20 +++ .../plugin-file-manager/src/fields/index.ts | 1 + .../src/resources/attachments.ts | 5 - packages/plugin-file-manager/src/server.ts | 21 ++- .../src/storages/IStorage.ts | 5 + .../plugin-file-manager/src/storages/Local.ts | 13 ++ .../plugin-file-manager/src/storages/index.ts | 2 + 16 files changed, 348 insertions(+), 11 deletions(-) create mode 100644 packages/plugin-file-manager/src/__tests__/action.test.ts create mode 100644 packages/plugin-file-manager/src/__tests__/files/text.txt create mode 100644 packages/plugin-file-manager/src/__tests__/index.ts create mode 100644 packages/plugin-file-manager/src/actions/upload.ts create mode 100644 packages/plugin-file-manager/src/collections/storages.ts create mode 100644 packages/plugin-file-manager/src/constants.ts create mode 100644 packages/plugin-file-manager/src/fields/URL.ts create mode 100644 packages/plugin-file-manager/src/fields/index.ts delete mode 100644 packages/plugin-file-manager/src/resources/attachments.ts create mode 100644 packages/plugin-file-manager/src/storages/IStorage.ts create mode 100644 packages/plugin-file-manager/src/storages/Local.ts create mode 100644 packages/plugin-file-manager/src/storages/index.ts diff --git a/packages/database/src/fields/field-types.ts b/packages/database/src/fields/field-types.ts index b890d91df..6ef912251 100644 --- a/packages/database/src/fields/field-types.ts +++ b/packages/database/src/fields/field-types.ts @@ -65,7 +65,6 @@ export class Column extends Field { if (DataTypes[type]) { return DataTypes[type]; } - return DataTypes[(this.constructor).name.toUpperCase()]; } @@ -332,6 +331,9 @@ export class JSON extends Column { export class JSONB extends Column { } +export class UUID extends Column { +} + export interface HasOneAccessors { get: string; set: string; diff --git a/packages/plugin-file-manager/package.json b/packages/plugin-file-manager/package.json index ec342a79f..c95aa0520 100644 --- a/packages/plugin-file-manager/package.json +++ b/packages/plugin-file-manager/package.json @@ -4,7 +4,13 @@ "main": "lib/index.js", "license": "MIT", "dependencies": { + "@koa/multer": "^3.0.0", "@nocobase/database": "^0.3.0-alpha.0", - "@nocobase/resourcer": "^0.3.0-alpha.0" + "@nocobase/resourcer": "^0.3.0-alpha.0", + "multer": "^1.4.2" + }, + "devDependencies": { + "@nocobase/actions": "^0.3.0-alpha.0", + "@nocobase/server": "^0.3.0-alpha.0" } } diff --git a/packages/plugin-file-manager/src/__tests__/action.test.ts b/packages/plugin-file-manager/src/__tests__/action.test.ts new file mode 100644 index 000000000..54338492f --- /dev/null +++ b/packages/plugin-file-manager/src/__tests__/action.test.ts @@ -0,0 +1,29 @@ +import path from 'path'; +import { FILE_FIELD_NAME } from '../constants'; +import { getApp, getAgent } from '.'; + +describe('user fields', () => { + let app; + let agent; + let db; + + beforeEach(async () => { + app = await getApp(); + agent = getAgent(app); + db = app.database; + await db.sync({ force: true }); + }); + + afterEach(async () => { + await db.close(); + }); + + describe('', () => { + it('', async () => { + const response = await agent + .post('/api/attachments:upload') + .attach(FILE_FIELD_NAME, path.resolve(__dirname, './files/text.txt')); + console.log(response.body); + }); + }); +}); diff --git a/packages/plugin-file-manager/src/__tests__/files/text.txt b/packages/plugin-file-manager/src/__tests__/files/text.txt new file mode 100644 index 000000000..cd0875583 --- /dev/null +++ b/packages/plugin-file-manager/src/__tests__/files/text.txt @@ -0,0 +1 @@ +Hello world! diff --git a/packages/plugin-file-manager/src/__tests__/index.ts b/packages/plugin-file-manager/src/__tests__/index.ts new file mode 100644 index 000000000..ded2eb99d --- /dev/null +++ b/packages/plugin-file-manager/src/__tests__/index.ts @@ -0,0 +1,138 @@ +import path from 'path'; +import qs from 'qs'; +import supertest from 'supertest'; +import bodyParser from 'koa-bodyparser'; +import { Dialect } from 'sequelize'; +import Database from '@nocobase/database'; +import { actions, middlewares } from '@nocobase/actions'; +import { Application, middleware } from '@nocobase/server'; +import plugin from '../server'; + +function getTestKey() { + const { id } = require.main; + const key = id + .replace(`${process.env.PWD}/packages`, '') + .replace(/src\/__tests__/g, '') + .replace('.test.ts', '') + .replace(/[^\w]/g, '_') + .replace(/_+/g, '_'); + return key +} + +const config = { + username: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_DATABASE, + host: process.env.DB_HOST, + port: Number.parseInt(process.env.DB_PORT, 10), + dialect: process.env.DB_DIALECT as Dialect, + define: { + hooks: { + beforeCreate(model, options) { + + }, + }, + }, + logging: process.env.DB_LOG_SQL === 'on', + sync: { + force: true, + alter: { + drop: true, + }, + }, + hooks: { + beforeDefine(columns, model) { + model.tableName = `${getTestKey()}_${model.tableName || model.name.plural}`; + } + }, +}; + +export function getDatabase() { + return new Database(config); +}; + +export async function getApp() { + const app = new Application({ + database: config, + resourcer: { + prefix: '/api', + }, + }); + app.resourcer.use(middlewares.associated); + app.resourcer.registerActionHandlers({...actions.associate, ...actions.common}); + app.registerPlugins({ + 'collections': [path.resolve(__dirname, '../../../plugin-collections')], + 'file-manager': [plugin] + }); + await app.loadPlugins(); + await app.database.sync({ + force: true, + }); + app.use(async (ctx, next) => { + ctx.db = app.database; + await next(); + }); + app.use(bodyParser()); + app.use(middleware({ + prefix: '/api', + resourcer: app.resourcer, + database: app.database, + })); + return app; +} + +interface ActionParams { + resourceKey?: string | number; + // resourceName?: string; + // associatedName?: string; + associatedKey?: string | number; + fields?: any; + filter?: any; + values?: any; + [key: string]: any; +} + +interface Handler { + get: (params?: ActionParams) => Promise; + list: (params?: ActionParams) => Promise; + create: (params?: ActionParams) => Promise; + update: (params?: ActionParams) => Promise; + destroy: (params?: ActionParams) => Promise; + [name: string]: (params?: ActionParams) => Promise; +} + +export interface Agent { + resource: (name: string) => Handler; +} + +export function getAgent(app: Application) { + return supertest.agent(app.callback()); +} + +export function getAPI(app: Application) { + const agent = getAgent(app); + return { + resource(name: string): any { + return new Proxy({}, { + get(target, method, receiver) { + return (params: ActionParams = {}) => { + const { associatedKey, resourceKey, values = {}, ...restParams } = params; + let url = `/api/${name}`; + if (associatedKey) { + url = `/api/${name.split('.').join(`/${associatedKey}/`)}`; + } + url += `:${method as string}`; + if (resourceKey) { + url += `/${resourceKey}`; + } + if (['list', 'get'].indexOf(method as string) !== -1) { + return agent.get(`${url}?${qs.stringify(restParams)}`); + } else { + return agent.post(`${url}?${qs.stringify(restParams)}`).send(values); + } + } + } + }); + } + }; +} diff --git a/packages/plugin-file-manager/src/actions/upload.ts b/packages/plugin-file-manager/src/actions/upload.ts new file mode 100644 index 000000000..df9d70884 --- /dev/null +++ b/packages/plugin-file-manager/src/actions/upload.ts @@ -0,0 +1,23 @@ +import multer from '@koa/multer'; +import actions from '@nocobase/actions'; + +import { FILE_FIELD_NAME } from '../constants'; + +export async function middleware(ctx: actions.Context, next: actions.Next) { + const { actionName } = ctx.action.params; + + if (actionName !== 'upload') { + return next(); + } + + const options = {}; + const upload = multer(options); + return upload.single(FILE_FIELD_NAME)(ctx, next); +}; + +export async function action(ctx: actions.Context, next: actions.Next) { + const { [FILE_FIELD_NAME]: file } = ctx; + console.log(file); + ctx.body = file; + await next(); +}; diff --git a/packages/plugin-file-manager/src/collections/attachments.ts b/packages/plugin-file-manager/src/collections/attachments.ts index 4ece3afcf..ddd3bc60d 100644 --- a/packages/plugin-file-manager/src/collections/attachments.ts +++ b/packages/plugin-file-manager/src/collections/attachments.ts @@ -6,9 +6,55 @@ export default { internal: true, fields: [ { - type: 'string', - name: 'title', + comment: '唯一 ID,系统文件名', + type: 'uuid', + name: 'id', + primaryKey: true }, + { + comment: '用户文件名', + type: 'string', + name: 'name', + }, + { + comment: '扩展名(含“.”)', + type: 'string', + name: 'extname', + }, + { + comment: '文件体积(字节)', + type: 'integer', + name: 'size', + }, + { + comment: '文件类型(mimetype 前半段,通常用于预览)', + type: 'string', + name: 'type', + }, + { + type: 'string', + name: 'mimetype', + }, + { + comment: '存储引擎', + type: 'belongsTo', + name: 'storage', + }, + { + comment: '相对路径', + type: 'string', + name: 'path', + }, + { + comment: '其他文件信息(如图片的宽高)', + type: 'jsonb', + name: 'meta', + }, + { + comment: '网络访问地址', + type: 'url', + name: 'url' + } ], actions: [ { diff --git a/packages/plugin-file-manager/src/collections/storages.ts b/packages/plugin-file-manager/src/collections/storages.ts new file mode 100644 index 000000000..2ce3bb1fd --- /dev/null +++ b/packages/plugin-file-manager/src/collections/storages.ts @@ -0,0 +1,38 @@ +import { TableOptions } from '@nocobase/database'; + +export default { + name: 'storages', + title: '存储引擎', + internal: true, + fields: [ + { + comment: '标识名称,用于用户记忆', + type: 'string', + name: 'name', + }, + { + comment: '类型标识,如 local/ali-oss 等', + type: 'string', + name: 'type', + defaultValue: 'local' + }, + { + comment: '配置项', + type: 'jsonb', + name: 'options', + defaultValue: {} + }, + { + comment: '存储相对路径模板', + type: 'string', + name: 'path', + defaultValue: '' + }, + { + comment: '访问地址前缀', + type: 'string', + name: 'baseUrl', + defaultValue: '' + }, + ] +} as TableOptions; diff --git a/packages/plugin-file-manager/src/constants.ts b/packages/plugin-file-manager/src/constants.ts new file mode 100644 index 000000000..3665c6b8f --- /dev/null +++ b/packages/plugin-file-manager/src/constants.ts @@ -0,0 +1 @@ +export const FILE_FIELD_NAME = 'file'; diff --git a/packages/plugin-file-manager/src/fields/URL.ts b/packages/plugin-file-manager/src/fields/URL.ts new file mode 100644 index 000000000..1da527b10 --- /dev/null +++ b/packages/plugin-file-manager/src/fields/URL.ts @@ -0,0 +1,20 @@ +import { DataTypes } from 'sequelize'; +import { VIRTUAL, VirtualOptions, FieldContext } from '@nocobase/database'; + +export interface URLOptions extends Omit { + type: 'url'; +} + +export default class URL extends VIRTUAL { + + constructor({ type, ...options }, context: FieldContext) { + super({ + ...options, + type: 'virtual', + get() { + const storage = this.getDataValue('storage') || {}; + return `${storage.baseUrl}${this.getDataValue('path')}/${this.getDataValue('id')}${this.getDataValue('extname')}`; + } + } as VirtualOptions, context); + } +} diff --git a/packages/plugin-file-manager/src/fields/index.ts b/packages/plugin-file-manager/src/fields/index.ts new file mode 100644 index 000000000..479d0f3e8 --- /dev/null +++ b/packages/plugin-file-manager/src/fields/index.ts @@ -0,0 +1 @@ +export { default as URL } from './URL'; diff --git a/packages/plugin-file-manager/src/resources/attachments.ts b/packages/plugin-file-manager/src/resources/attachments.ts deleted file mode 100644 index d9bcc18fd..000000000 --- a/packages/plugin-file-manager/src/resources/attachments.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ResourceOptions } from '@nocobase/resourcer'; - -export default { - name: 'attachments', -} as ResourceOptions; diff --git a/packages/plugin-file-manager/src/server.ts b/packages/plugin-file-manager/src/server.ts index 16e788eff..34ccf101e 100644 --- a/packages/plugin-file-manager/src/server.ts +++ b/packages/plugin-file-manager/src/server.ts @@ -1,12 +1,29 @@ import path from 'path'; -import Database from '@nocobase/database'; +import Database, { registerFields } from '@nocobase/database'; import Resourcer from '@nocobase/resourcer'; -export default async function (options = {}) { +import * as fields from './fields'; +import { IStorage } from './storages'; +import { + action as uploadAction, + middleware as uploadMiddleware, +} from './actions/upload'; + +export interface FileManagerOptions { + storages: IStorage[] +} + +export default async function (options: FileManagerOptions) { const database: Database = this.database; const resourcer: Resourcer = this.resourcer; + registerFields(fields); + database.import({ directory: path.resolve(__dirname, 'collections'), }); + + // 暂时中间件只能通过 use 加进来 + resourcer.use(uploadMiddleware); + resourcer.registerActionHandler('upload', uploadAction); } diff --git a/packages/plugin-file-manager/src/storages/IStorage.ts b/packages/plugin-file-manager/src/storages/IStorage.ts new file mode 100644 index 000000000..78e399b7c --- /dev/null +++ b/packages/plugin-file-manager/src/storages/IStorage.ts @@ -0,0 +1,5 @@ +export default interface IStorage { + options: any; + + put: (file, data) => Promise +} diff --git a/packages/plugin-file-manager/src/storages/Local.ts b/packages/plugin-file-manager/src/storages/Local.ts new file mode 100644 index 000000000..ea1569dd4 --- /dev/null +++ b/packages/plugin-file-manager/src/storages/Local.ts @@ -0,0 +1,13 @@ +import IStorage from './IStorage'; + +export default class Local implements IStorage { + options: any; + + constructor(options: any) { + this.options = options; + } + + async put(file, data) { + + } +} diff --git a/packages/plugin-file-manager/src/storages/index.ts b/packages/plugin-file-manager/src/storages/index.ts new file mode 100644 index 000000000..7004c8d47 --- /dev/null +++ b/packages/plugin-file-manager/src/storages/index.ts @@ -0,0 +1,2 @@ +export { default as IStorage } from './IStorage'; +export { default as Local } from './Local';