feat: update action logs plugin

This commit is contained in:
chenos 2021-09-08 14:36:18 +08:00
parent c182c29161
commit e8113f3809
10 changed files with 189 additions and 259 deletions

View File

@ -168,7 +168,7 @@ type HookType =
const hookType = this._getHookType(event); const hookType = this._getHookType(event);
if (hookType) { if (hookType) {
const state = this.hookTypes.get(hookType); const state = this.hookTypes.get(hookType);
console.log('sequelize.addHook', hookType) console.log('sequelize.addHook', event, hookType)
this.sequelize.addHook(hookType, async (...args: any[]) => { this.sequelize.addHook(hookType, async (...args: any[]) => {
let modelName: string; let modelName: string;
switch (state) { switch (state) {
@ -182,7 +182,7 @@ type HookType =
modelName = args?.[0]?.model?.name; modelName = args?.[0]?.model?.name;
break; break;
} }
console.log({ modelName, args }); // console.log({ modelName, args });
if (modelName) { if (modelName) {
await this.emitAsync(`${modelName}.${hookType}`, ...args); await this.emitAsync(`${modelName}.${hookType}`, ...args);
} }

View File

@ -1,69 +1,66 @@
import Database from '@nocobase/database';
import Application from '@nocobase/server';
import { getApp, getAPI, getAgent } from '.'; import { getApp, getAPI, getAgent } from '.';
describe('hook', () => { describe('hook', () => {
let app; let app: Application;
let anonymousAPI; let db: Database;
let userAPI; let api;
let db;
let user;
beforeEach(async () => { beforeEach(async () => {
app = await getApp(); app = await getApp();
db = app.database; db = app.database;
db.table({
anonymousAPI = getAPI(getAgent(app)); name: 'posts',
logging: true,
fields: [
{
type: 'string',
name: 'title',
},
{
type: 'string',
name: 'status',
defaultValue: 'draft',
},
]
});
await db.sync();
const User = db.getModel('users'); const User = db.getModel('users');
user = await User.create({ nickname: 'a', token: 'token1' }); const user = await User.create({ nickname: 'a', token: 'token1' });
console.log('beforeEach', user);
const userAgent = getAgent(app); const userAgent = getAgent(app);
userAgent.set('Authorization', `Bearer ${user.token}`); userAgent.set('Authorization', `Bearer ${user.token}`);
userAPI = getAPI(userAgent); api = getAPI(userAgent);
}); });
afterEach(() => db.close()); afterEach(async () => {
await db.close();
});
describe('common', () => { it('database', async () => {
it('create log', async () => { const Post = db.getModel('posts');
await userAPI.resource('posts').create({ const post = await Post.create({ title: 't1' });
values: { title: 't1' } await post.update({title: 't2'});
}); await post.destroy();
const ActionLog = db.getModel('action_logs');
const count = await ActionLog.count();
expect(count).toBe(3);
});
const Post = db.getModel('posts'); it('resource', async () => {
const p1 = await Post.findByPk(1); const response = await api.resource('posts').create({
values: { title: 't1' },
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'
});
}); });
await api.resource('posts').update({
it('logs should be scoped (no other model logs)', async () => { resourceKey: response.body.data.id,
await userAPI.resource('posts').create({ values: { title: 't2' },
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']);
}); });
await api.resource('posts').destroy({
resourceKey: response.body.data.id,
});
const ActionLog = db.getModel('action_logs');
const count = await ActionLog.count();
expect(count).toBe(3);
}); });
}); });

View File

@ -45,43 +45,20 @@ export function getDatabase() {
return new Database(config); return new Database(config);
}; };
export async function getApp() { export async function getApp(): Promise<Application> {
const app = new Application({ const app = new Application({
database: config, database: config,
resourcer: { resourcer: {
prefix: '/api', prefix: '/api',
}, },
}); });
app.resourcer.use(middlewares.associated);
app.resourcer.registerActionHandlers({ ...actions.associate, ...actions.common });
app.registerPlugin({ app.registerPlugin({
collections: path.resolve(__dirname, '../../../plugin-collections'), collections: path.resolve(__dirname, '../../../plugin-collections'),
users: path.resolve(__dirname, '../../../plugin-users'), users: path.resolve(__dirname, '../../../plugin-users'),
logs: plugin logs: plugin
}); });
await app.loadPlugins(); await app.loadPlugins();
const testTables = app.database.import({ await app.database.sync();
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; return app;
} }

View File

@ -12,6 +12,7 @@ export default {
type: 'belongsTo', type: 'belongsTo',
name: 'log', name: 'log',
target: 'action_logs', target: 'action_logs',
foreignKey: 'log_id',
}, },
{ {
type: 'jsonb', type: 'jsonb',

View File

@ -22,6 +22,7 @@ export default {
name: 'collection', name: 'collection',
target: 'collections', target: 'collections',
targetKey: 'name', targetKey: 'name',
constraints: false,
}, },
{ {
type: 'string', type: 'string',
@ -35,6 +36,7 @@ export default {
type: 'hasMany', type: 'hasMany',
name: 'changes', name: 'changes',
target: 'action_changes', target: 'action_changes',
foreignKey: 'log_id',
} }
], ],
} as TableOptions; } as TableOptions;

View File

@ -1,53 +1,52 @@
import { Field } from '@nocobase/database'; import Database, { Field, Model } from '@nocobase/database';
import { LOG_TYPE_CREATE } from '../constants'; import { LOG_TYPE_CREATE } from '../constants';
export default async function (model, options) { export async function afterCreate(model, options) {
if (!options.context) { const db = model.database as Database;
const table = db.getTable(model.constructor.name);
if (!table.getOptions('logging')) {
return; return;
} }
const { database: db } = model; const { transaction = await db.sequelize.transaction() } = options;
const { context: { state }, transaction = await db.sequelize.transaction() } = options;
const ActionLog = db.getModel('action_logs'); const ActionLog = db.getModel('action_logs');
// 创建操作记录 const currentUserId = options?.context?.state?.currentUser?.id;
const log = await ActionLog.create({ try {
type: LOG_TYPE_CREATE, const log = await ActionLog.create({
collection_name: model.constructor.name, type: LOG_TYPE_CREATE,
index: model.get(model.constructor.primaryKeyAttribute), collection_name: model.constructor.name,
created_at: model.get('created_at') index: model.get(model.constructor.primaryKeyAttribute),
}, { created_at: model.get('created_at'),
transaction user_id: currentUserId,
});
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 = [];
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.hidden && field.options.type !== 'formula') {
changes.push({
field: field.options,
after: model.get(key)
});
}
});
await log.updateAssociations({
changes
}, { }, {
transaction transaction,
hooks: false,
}); });
} const fields = db.getTable(model.constructor.name).getFields();
const fieldsList = Array.from(fields.values());
if (!options.transaction) { const changes = [];
await transaction.commit(); 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.hidden && field.options.type !== 'formula') {
changes.push({
field: field.options,
after: model.get(key)
});
}
});
await log.updateAssociations({
changes
}, {
transaction
});
}
if (!options.transaction) {
await transaction.commit();
}
} catch (error) {
if (!options.transaction) {
await transaction.rollback();
}
} }
} }

View File

@ -1,49 +1,48 @@
import { Field } from '@nocobase/database'; import Database, { Field } from '@nocobase/database';
import { LOG_TYPE_DESTROY } from '../constants'; import { LOG_TYPE_DESTROY } from '../constants';
export default async function (model, options) { export async function afterDestroy(model, options) {
if (!options.context) { const db = model.database as Database;
const table = db.getTable(model.constructor.name);
if (!table.getOptions('logging')) {
return; return;
} }
const { database: db } = model; const { transaction = await db.sequelize.transaction() } = options;
const { context: { state }, transaction = await db.sequelize.transaction() } = options;
const ActionLog = db.getModel('action_logs'); const ActionLog = db.getModel('action_logs');
// 创建操作记录 const currentUserId = options?.context?.state?.currentUser?.id;
const log = await ActionLog.create({ try {
// user_id: state.currentUser ? state.currentUser.id : null, const log = await ActionLog.create({
type: LOG_TYPE_DESTROY, type: LOG_TYPE_DESTROY,
collection_name: model.constructor.name, collection_name: model.constructor.name,
index: model.get(model.constructor.primaryKeyAttribute), index: model.get(model.constructor.primaryKeyAttribute),
// created_at: model.get('created_at') user_id: currentUserId,
}, { }, {
transaction transaction,
}); hooks: false,
if (state.currentUser) { });
await log.updateAssociations({ user: state.currentUser.id }, { const fields = db.getTable(model.constructor.name).getFields();
const fieldsList = Array.from(fields.values());
const changes = [];
Object.keys(model.get()).forEach((key: string) => {
const field = fields.get(key) || fieldsList.find((item: Field) => item.options.field === key);
if (field) {
changes.push({
field: field.options,
before: model.get(key)
});
}
});
await log.updateAssociations({
changes
}, {
transaction transaction
}); });
} if (!options.transaction) {
await transaction.commit();
const fields = db.getTable(model.constructor.name).getFields(); }
const fieldsList = Array.from(fields.values()); } catch (error) {
const changes = []; if (!options.transaction) {
Object.keys(model.get()).forEach((key: string) => { await transaction.rollback();
const field = fields.get(key) || fieldsList.find((item: Field) => item.options.field === key);
if (field) {
changes.push({
field: field.options,
before: model.get(key)
});
} }
});
await log.updateAssociations({
changes
}, {
transaction
});
if (!options.transaction) {
await transaction.commit();
} }
} }

View File

@ -1,51 +1,57 @@
import { Field } from '@nocobase/database'; import Database, { Field } from '@nocobase/database';
import { LOG_TYPE_UPDATE } from '../constants'; import { LOG_TYPE_UPDATE } from '../constants';
export default async function (model, options) { export async function afterUpdate(model, options) {
if (!options.context) { const db = model.database as Database;
const table = db.getTable(model.constructor.name);
if (!table.getOptions('logging')) {
return; return;
} }
const { database: db } = model; const changed = model.changed();
const { context: { state }, transaction = await db.sequelize.transaction() } = options; if (!changed) {
return;
}
const { transaction = await db.sequelize.transaction() } = options;
const ActionLog = db.getModel('action_logs'); const ActionLog = db.getModel('action_logs');
const currentUserId = options?.context?.state?.currentUser?.id;
const fields = db.getTable(model.constructor.name).getFields(); const fields = db.getTable(model.constructor.name).getFields();
const fieldsList = Array.from(fields.values()); const fieldsList = Array.from(fields.values());
const changes = []; const changes = [];
const changed = model.changed(); changed.forEach((key: string) => {
if (changed) { const field = fields.get(key) || fieldsList.find((item: Field) => item.options.field === key);
changed.forEach((key: string) => { if (field && !field.options.hidden && field.options.type !== 'formula') {
const field = fields.get(key) || fieldsList.find((item: Field) => item.options.field === key); changes.push({
if (field && !field.options.hidden && field.options.type !== 'formula') { field: field.options,
changes.push({ after: model.get(key),
field: field.options, before: model.previous(key)
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) {
return;
} }
try {
if (!options.transaction) { const log = await ActionLog.create({
await transaction.commit(); type: LOG_TYPE_UPDATE,
collection_name: model.constructor.name,
index: model.get(model.constructor.primaryKeyAttribute),
created_at: model.get('updated_at'),
user_id: currentUserId,
}, {
transaction,
hooks: false,
});
await log.updateAssociations({
changes
}, {
transaction
});
if (!options.transaction) {
await transaction.commit();
}
} catch (error) {
if (!options.transaction) {
await transaction.rollback();
}
} }
} }

View File

@ -1,10 +1,3 @@
import afterCreate from './after-create'; export * from './after-create';
import afterUpdate from './after-update'; export * from './after-update';
import afterDestroy from './after-destroy'; export * from './after-destroy';
export function addAll(Model) {
Model.addHook('afterCreate', afterCreate);
// Model.addHook('afterBulkCreate', hooks.afterBulkCreate);
Model.addHook('afterUpdate', afterUpdate);
Model.addHook('afterDestroy', afterDestroy);
}

View File

@ -1,57 +1,13 @@
import path from 'path'; import path from 'path';
import { Op } from 'sequelize'; import Application from '@nocobase/server';
import { afterCreate, afterUpdate, afterDestroy } from './hooks';
import { addAll } from './hooks'; export default async function (this: Application) {
export default async function() {
const { database } = this; const { database } = this;
database.import({ database.import({
directory: path.resolve(__dirname, 'collections'), directory: path.resolve(__dirname, 'collections'),
}); });
database.on('afterCreate', afterCreate);
// 为所有的表都加上日志的 hooks database.on('afterUpdate', afterUpdate);
database.addHook('afterTableInit', (table) => { database.on('afterDestroy', afterDestroy);
if (!table.options.logging) {
return;
}
addAll(database.getModel(table.options.name));
});
// const Collection = database.getModel('collections');
// Collection.addHook('afterCreate', async (model, options) => {
// if (!model.get('logging')) {
// return;
// }
// const { transaction = await model.sequelize.transaction() } = options;
// const exists = await model.countFields({
// where: {
// dataType: { [Op.iLike]: 'hasMany' },
// name: 'action_logs'
// },
// transaction
// });
// if (!exists) {
// await model.createSystemField({
// interface: 'linkTo',
// dataType: 'hasMany',
// name: 'action_logs',
// target: 'action_logs',
// title: '数据动态',
// foreignKey: 'index',
// state: 0,
// scope: {
// collection_name: model.get('name')
// },
// constraints: false
// }, { transaction });
// }
// if (!options.transaction) {
// await transaction.commit();
// }
// });
} }