diff --git a/packages/app/src/api/migrations/collections/example.ts b/packages/app/src/api/migrations/collections/example.ts index d36044a90..6a23c648b 100644 --- a/packages/app/src/api/migrations/collections/example.ts +++ b/packages/app/src/api/migrations/collections/example.ts @@ -4,6 +4,7 @@ export default { title: '示例', name: 'examples', showInDataMenu: true, + logging: false, // createdBy: true, // updatedBy: true, fields: [ diff --git a/packages/plugin-action-logs/package.json b/packages/plugin-action-logs/package.json index babc448a2..f35a83250 100644 --- a/packages/plugin-action-logs/package.json +++ b/packages/plugin-action-logs/package.json @@ -6,5 +6,9 @@ "dependencies": { "@nocobase/database": "^0.3.0-alpha.0", "@nocobase/resourcer": "^0.3.0-alpha.0" + }, + "devDependencies": { + "@nocobase/server": "^0.3.0-alpha.0", + "@nocobase/actions": "^0.3.0-alpha.0" } } diff --git a/packages/plugin-action-logs/src/__tests__/hook.test.ts b/packages/plugin-action-logs/src/__tests__/hook.test.ts new file mode 100644 index 000000000..d233087a0 --- /dev/null +++ b/packages/plugin-action-logs/src/__tests__/hook.test.ts @@ -0,0 +1,69 @@ +import { getApp, getAPI, getAgent } from '.'; + +describe('hook', () => { + let app; + let anonymousAPI; + let userAPI; + let db; + let user; + + beforeEach(async () => { + app = await getApp(); + db = app.database; + + anonymousAPI = getAPI(getAgent(app)); + + const User = db.getModel('users'); + user = await User.create({ nickname: 'a', token: 'token1' }); + + const userAgent = getAgent(app); + userAgent.set('Authorization', `Bearer ${user.token}`); + userAPI = getAPI(userAgent); + }); + + afterEach(() => db.close()); + + describe('common', () => { + it('create log', async () => { + await userAPI.resource('posts').create({ + values: { title: 't1' } + }); + + const Post = db.getModel('posts'); + const p1 = await Post.findByPk(1); + + const logs = await p1.getAction_logs(); + + expect(logs.length).toBe(1); + expect(logs[0].get()).toMatchObject({ + type: 'create', + index: p1.id, + user_id: user.id, + collection_name: 'posts' + }); + }); + + it('logs should be scoped (no other model logs)', async () => { + await userAPI.resource('posts').create({ + values: { title: 't1' } + }); + await userAPI.resource('posts').update({ + resourceKey: '1', + values: { title: 't11' } + }); + await userAPI.resource('posts').create({ + values: { title: 't2' } + }); + await userAPI.resource('comments').create({ + values: { content: 'c1' } + }); + + const Post = db.getModel('posts'); + const p1 = await Post.findByPk(1); + + const logs = await p1.getAction_logs(); + expect(logs.length).toBe(2); + expect(logs.map(item => item.collection_name)).toEqual(['posts', 'posts']); + }); + }); +}); diff --git a/packages/plugin-action-logs/src/__tests__/index.ts b/packages/plugin-action-logs/src/__tests__/index.ts new file mode 100644 index 000000000..8e23901ce --- /dev/null +++ b/packages/plugin-action-logs/src/__tests__/index.ts @@ -0,0 +1,145 @@ +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 } from '@nocobase/server'; +import middleware from '@nocobase/server/src/middleware'; +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, + 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.registerPlugin({ + collections: path.resolve(__dirname, '../../../plugin-collections'), + users: path.resolve(__dirname, '../../../plugin-users'), + logs: plugin + }); + await app.loadPlugins(); + const testTables = app.database.import({ + directory: path.resolve(__dirname, './tables') + }); + try { + await app.database.sync(); + } catch(err) { + console.error(err); + } + + for (const table of testTables.values()) { + // TODO(bug): 由于每个用例结束后不会清理用于测试的数据表,导致再次创建和更新 + // 创建和更新里面仍会再次创建 fields,导致创建相关的数据重复,数据库报错。 + await app.database.getModel('collections').import(table.getOptions(), { update: true, migrate: false }); + } + + app.context.db = app.database; + 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(agent) { + return { + resource(name: string): any { + return new Proxy({}, { + get(target, method: string, receiver) { + return (params: ActionParams = {}) => { + const { associatedKey, resourceKey, values = {}, filePath, ...restParams } = params; + let url = `/api/${name}`; + if (associatedKey) { + url = `/api/${name.split('.').join(`/${associatedKey}/`)}`; + } + url += `:${method as string}`; + if (resourceKey) { + url += `/${resourceKey}`; + } + + switch (method) { + case 'list': + case 'get': + return agent.get(`${url}?${qs.stringify(restParams)}`); + + default: + return agent.post(`${url}?${qs.stringify(restParams)}`).send(values); + } + } + } + }); + } + }; +} diff --git a/packages/plugin-action-logs/src/__tests__/tables/comments.ts b/packages/plugin-action-logs/src/__tests__/tables/comments.ts new file mode 100644 index 000000000..6f752ff1b --- /dev/null +++ b/packages/plugin-action-logs/src/__tests__/tables/comments.ts @@ -0,0 +1,15 @@ +import { TableOptions } from "@nocobase/database"; + +export default { + name: 'comments', + fields: [ + { + type: 'string', + name: 'content', + }, + { + type: 'belongsTo', + name: 'post', + }, + ] +} as TableOptions; diff --git a/packages/plugin-action-logs/src/__tests__/tables/posts.ts b/packages/plugin-action-logs/src/__tests__/tables/posts.ts new file mode 100644 index 000000000..85c5f340f --- /dev/null +++ b/packages/plugin-action-logs/src/__tests__/tables/posts.ts @@ -0,0 +1,22 @@ +import { TableOptions } from "@nocobase/database"; + +export default { + name: 'posts', + // 目前默认就带了 + // createdBy: true, + fields: [ + { + type: 'string', + name: 'title', + }, + { + type: 'string', + name: 'status', + defaultValue: 'draft', + }, + { + type: 'hasMany', + name: 'comments', + } + ] +} as TableOptions; diff --git a/packages/plugin-action-logs/src/collections/action_changes.ts b/packages/plugin-action-logs/src/collections/action_changes.ts index a30a33cf7..2c48d9d24 100644 --- a/packages/plugin-action-logs/src/collections/action_changes.ts +++ b/packages/plugin-action-logs/src/collections/action_changes.ts @@ -9,6 +9,7 @@ export default { updatedBy: false, createdAt: false, updatedAt: false, + logging: false, fields: [ { interface: 'linkTo', @@ -45,6 +46,13 @@ export default { }, } ], + actions: [ + { + type: 'list', + name: 'list', + title: '查看' + } + ], views: [ { type: 'table', diff --git a/packages/plugin-action-logs/src/collections/action_logs.ts b/packages/plugin-action-logs/src/collections/action_logs.ts index ad6e4cb64..7ff83205c 100644 --- a/packages/plugin-action-logs/src/collections/action_logs.ts +++ b/packages/plugin-action-logs/src/collections/action_logs.ts @@ -1,4 +1,5 @@ import { TableOptions } from '@nocobase/database'; +import { LOG_TYPE_CREATE, LOG_TYPE_UPDATE, LOG_TYPE_DESTROY } from '../constants'; export default { name: 'action_logs', @@ -8,6 +9,7 @@ export default { createdBy: false, updatedBy: false, updatedAt: false, + logging: false, fields: [ { interface: 'createdAt', @@ -54,9 +56,9 @@ export default { title: '操作类型', filterable: true, dataSource: [ - { value: 'create', label: '新增' }, - { value: 'update', label: '更新' }, - { value: 'destroy', label: '删除' }, + { value: LOG_TYPE_CREATE, label: '新增' }, + { value: LOG_TYPE_UPDATE, label: '更新' }, + { value: LOG_TYPE_DESTROY, label: '删除' }, ], component: { showInTable: true, @@ -83,6 +85,11 @@ export default { } ], actions: [ + { + type: 'filter', + name: 'filter', + title: '筛选' + }, { type: 'list', name: 'list', diff --git a/packages/plugin-action-logs/src/collections/collections.ts b/packages/plugin-action-logs/src/collections/collections.ts new file mode 100644 index 000000000..25cf0dae9 --- /dev/null +++ b/packages/plugin-action-logs/src/collections/collections.ts @@ -0,0 +1,7 @@ +import { extend } from '@nocobase/database'; + +// TODO(bug): collections 表创建关联字段有问题 +export default extend({ + name: 'collections', + logging: false +}); diff --git a/packages/plugin-action-logs/src/constants.ts b/packages/plugin-action-logs/src/constants.ts new file mode 100644 index 000000000..0cf75c724 --- /dev/null +++ b/packages/plugin-action-logs/src/constants.ts @@ -0,0 +1,3 @@ +export const LOG_TYPE_CREATE = 'create'; +export const LOG_TYPE_UPDATE = 'update'; +export const LOG_TYPE_DESTROY = 'destroy'; diff --git a/packages/plugin-action-logs/src/hooks/after-create.ts b/packages/plugin-action-logs/src/hooks/after-create.ts index 6e6b0cb2a..dfee2a48a 100644 --- a/packages/plugin-action-logs/src/hooks/after-create.ts +++ b/packages/plugin-action-logs/src/hooks/after-create.ts @@ -1,52 +1,51 @@ import { Field } from '@nocobase/database'; +import { LOG_TYPE_CREATE } from '../constants'; export default async function(model, options) { if (!options.context) { return; } const { database: db } = model; - const { context, transaction = await db.sequelize.transaction() } = options; - const { - state, - action: { - params: { - actionName, - resourceName, - } - } - } = context; + const { context: { state }, transaction = await db.sequelize.transaction() } = options; const ActionLog = db.getModel('action_logs'); // 创建操作记录 const log = await ActionLog.create({ - type: actionName, + type: LOG_TYPE_CREATE, collection_name: model.constructor.name, index: model.get(model.constructor.primaryKeyAttribute), created_at: model.get('created_at') }, { transaction }); + if (state.currentUser) { + // TODO(bug): state.currentUser 不是 belongsTo field 的 target 实例 + // Sequelize 会另外创建一个 Model 的继承类,直传 instance 因为无法匹配类会当做 id 造成类型错误 + // await log.setUser(state.currentUser, { transaction }); + await log.updateAssociations({ user: state.currentUser.id }, { + transaction + }); + } const fields = db.getTable(model.constructor.name).getFields(); const fieldsList = Array.from(fields.values()); const changes = []; - model.changed().forEach((key: string) => { - const field = fields.get(key) || fieldsList.find((item: Field) => item.options.field === key); - if (field) { - changes.push({ - field: field.options, - after: model.get(key) - }); - } - }); - // TODO(bug): state.currentUser 不是 belongsTo field 的 target 实例 - // Sequelize 会另外创建一个 Model 的继承类,无法直传 instance - // await log.setUser(state.currentUser, { transaction }); - await log.updateAssociations({ - ...(state.currentUser ? { user: state.currentUser.id } : {}), - changes - }, { - transaction - }); + const changed = model.changed(); + if (changed) { + changed.forEach((key: string) => { + const field = fields.get(key) || fieldsList.find((item: Field) => item.options.field === key); + if (field) { + changes.push({ + field: field.options, + after: model.get(key) + }); + } + }); + await log.updateAssociations({ + changes + }, { + transaction + }); + } if (!options.transaction) { await transaction.commit(); diff --git a/packages/plugin-action-logs/src/hooks/after-destroy.ts b/packages/plugin-action-logs/src/hooks/after-destroy.ts index 938984ac7..5623ded85 100644 --- a/packages/plugin-action-logs/src/hooks/after-destroy.ts +++ b/packages/plugin-action-logs/src/hooks/after-destroy.ts @@ -1,31 +1,28 @@ import { Field } from '@nocobase/database'; +import { LOG_TYPE_DESTROY } from '../constants'; export default async function(model, options) { if (!options.context) { return; } const { database: db } = model; - const { context, transaction = await db.sequelize.transaction() } = options; - const { - state, - action: { - params: { - actionName, - resourceName, - } - } - } = context; + const { context: { state }, transaction = await db.sequelize.transaction() } = options; const ActionLog = db.getModel('action_logs'); // 创建操作记录 const log = await ActionLog.create({ // user_id: state.currentUser ? state.currentUser.id : null, - type: actionName, + type: LOG_TYPE_DESTROY, collection_name: model.constructor.name, index: model.get(model.constructor.primaryKeyAttribute), // created_at: model.get('created_at') }, { transaction }); + if (state.currentUser) { + await log.updateAssociations({ user: state.currentUser.id }, { + transaction + }); + } const fields = db.getTable(model.constructor.name).getFields(); const fieldsList = Array.from(fields.values()); @@ -41,7 +38,6 @@ export default async function(model, options) { }); await log.updateAssociations({ - ...(state.currentUser ? { user: state.currentUser.id } : {}), changes }, { transaction diff --git a/packages/plugin-action-logs/src/hooks/after-update.ts b/packages/plugin-action-logs/src/hooks/after-update.ts index b02ad7231..15140dbe0 100644 --- a/packages/plugin-action-logs/src/hooks/after-update.ts +++ b/packages/plugin-action-logs/src/hooks/after-update.ts @@ -1,58 +1,50 @@ import { Field } from '@nocobase/database'; +import { LOG_TYPE_UPDATE } from '../constants'; export default async function(model, options) { if (!options.context) { return; } const { database: db } = model; - const { context, transaction = await db.sequelize.transaction() } = options; - const { - state, - action: { - params: { - actionName, - resourceName, - } - } - } = context; + const { context: { state }, transaction = await db.sequelize.transaction() } = options; const ActionLog = db.getModel('action_logs'); const fields = db.getTable(model.constructor.name).getFields(); const fieldsList = Array.from(fields.values()); const changes = []; + const changed = model.changed(); + if (changed) { + changed.forEach((key: string) => { + const field = fields.get(key) || fieldsList.find((item: Field) => item.options.field === key); + if (field && field.options.type !== 'formula') { + changes.push({ + field: field.options, + after: model.get(key), + before: model.previous(key) + }); + } + }); - model.changed().forEach((key: string) => { - const field = fields.get(key) || fieldsList.find((item: Field) => item.options.field === key); - if (field && field.options.type !== 'formula') { - changes.push({ - field: field.options, - after: model.get(key), - before: model.previous(key) + if (changes.length) { + // 创建操作记录 + const log = await ActionLog.create({ + type: LOG_TYPE_UPDATE, + collection_name: model.constructor.name, + index: model.get(model.constructor.primaryKeyAttribute), + created_at: model.get('updated_at') + }, { + transaction + }); + + await log.updateAssociations({ + ...(state.currentUser ? { user: state.currentUser.id } : {}), + changes + }, { + transaction }); } - }); - - if (changes.length === 0) { - return; } - // 创建操作记录 - const log = await ActionLog.create({ - type: actionName, - collection_name: model.constructor.name, - index: model.get(model.constructor.primaryKeyAttribute), - created_at: model.get('updated_at') - }, { - transaction - }); - - await log.updateAssociations({ - ...(state.currentUser ? { user: state.currentUser.id } : {}), - changes - }, { - transaction - }); - if (!options.transaction) { await transaction.commit(); } diff --git a/packages/plugin-action-logs/src/server.ts b/packages/plugin-action-logs/src/server.ts index 470ec6350..c6f4458fe 100644 --- a/packages/plugin-action-logs/src/server.ts +++ b/packages/plugin-action-logs/src/server.ts @@ -1,4 +1,5 @@ import path from 'path'; +import { Op } from 'sequelize'; import { addAll } from './hooks'; @@ -10,11 +11,46 @@ export default async function() { }); // 为所有的表都加上日志的 hooks - database.addHook('afterTableInit', function (table) { - if (['action_logs', 'action_changes'].includes(table.options.name)) { + database.addHook('afterTableInit', (table) => { + if (table.options.logging === false) { return; } - const Model = database.getModel(table.options.name); - addAll(Model); + addAll(database.getModel(table.options.name)); + }); + + const Collection = database.getModel('collections'); + Collection.addHook('afterCreate', async (model, options) => { + if (model.get('logging') === false) { + return; + } + + const { transaction = await model.sequelize.transaction() } = options; + + const exists = await model.countFields({ + where: { + type: { [Op.iLike]: 'hasMany' }, + name: 'action_logs' + }, + transaction + }); + + if (!exists) { + await model.createField({ + interface: 'linkTo', + type: 'hasMany', + name: 'action_logs', + target: 'action_logs', + title: '数据动态', + foreignKey: 'index', + scope: { + collection_name: model.get('name') + }, + constraints: false + }, { transaction }); + } + + if (!options.transaction) { + await transaction.commit(); + } }); }