Feature/action logs (#62)

* feat: add hasMany field for action logs

* fix: add scope to log field

* test: add cases

* test: remove console.log

* test: add expects

* refactor: use constants for hook types

* fix: missing pageInfo

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
Junyi 2021-02-01 13:32:02 +08:00 committed by GitHub
parent 134f72f6e8
commit f9c4fb9df0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 390 additions and 86 deletions

View File

@ -4,6 +4,7 @@ export default {
title: '示例', title: '示例',
name: 'examples', name: 'examples',
showInDataMenu: true, showInDataMenu: true,
logging: false,
// createdBy: true, // createdBy: true,
// updatedBy: true, // updatedBy: true,
fields: [ fields: [

View File

@ -6,5 +6,9 @@
"dependencies": { "dependencies": {
"@nocobase/database": "^0.3.0-alpha.0", "@nocobase/database": "^0.3.0-alpha.0",
"@nocobase/resourcer": "^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"
} }
} }

View File

@ -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']);
});
});
});

View File

@ -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<supertest.Response>;
list: (params?: ActionParams) => Promise<supertest.Response>;
create: (params?: ActionParams) => Promise<supertest.Response>;
update: (params?: ActionParams) => Promise<supertest.Response>;
destroy: (params?: ActionParams) => Promise<supertest.Response>;
[name: string]: (params?: ActionParams) => Promise<supertest.Response>;
}
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);
}
}
}
});
}
};
}

View File

@ -0,0 +1,15 @@
import { TableOptions } from "@nocobase/database";
export default {
name: 'comments',
fields: [
{
type: 'string',
name: 'content',
},
{
type: 'belongsTo',
name: 'post',
},
]
} as TableOptions;

View File

@ -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;

View File

@ -9,6 +9,7 @@ export default {
updatedBy: false, updatedBy: false,
createdAt: false, createdAt: false,
updatedAt: false, updatedAt: false,
logging: false,
fields: [ fields: [
{ {
interface: 'linkTo', interface: 'linkTo',
@ -45,6 +46,13 @@ export default {
}, },
} }
], ],
actions: [
{
type: 'list',
name: 'list',
title: '查看'
}
],
views: [ views: [
{ {
type: 'table', type: 'table',

View File

@ -1,4 +1,5 @@
import { TableOptions } from '@nocobase/database'; import { TableOptions } from '@nocobase/database';
import { LOG_TYPE_CREATE, LOG_TYPE_UPDATE, LOG_TYPE_DESTROY } from '../constants';
export default { export default {
name: 'action_logs', name: 'action_logs',
@ -8,6 +9,7 @@ export default {
createdBy: false, createdBy: false,
updatedBy: false, updatedBy: false,
updatedAt: false, updatedAt: false,
logging: false,
fields: [ fields: [
{ {
interface: 'createdAt', interface: 'createdAt',
@ -54,9 +56,9 @@ export default {
title: '操作类型', title: '操作类型',
filterable: true, filterable: true,
dataSource: [ dataSource: [
{ value: 'create', label: '新增' }, { value: LOG_TYPE_CREATE, label: '新增' },
{ value: 'update', label: '更新' }, { value: LOG_TYPE_UPDATE, label: '更新' },
{ value: 'destroy', label: '删除' }, { value: LOG_TYPE_DESTROY, label: '删除' },
], ],
component: { component: {
showInTable: true, showInTable: true,
@ -83,6 +85,11 @@ export default {
} }
], ],
actions: [ actions: [
{
type: 'filter',
name: 'filter',
title: '筛选'
},
{ {
type: 'list', type: 'list',
name: 'list', name: 'list',

View File

@ -0,0 +1,7 @@
import { extend } from '@nocobase/database';
// TODO(bug): collections 表创建关联字段有问题
export default extend({
name: 'collections',
logging: false
});

View File

@ -0,0 +1,3 @@
export const LOG_TYPE_CREATE = 'create';
export const LOG_TYPE_UPDATE = 'update';
export const LOG_TYPE_DESTROY = 'destroy';

View File

@ -1,35 +1,37 @@
import { Field } from '@nocobase/database'; import { Field } from '@nocobase/database';
import { LOG_TYPE_CREATE } from '../constants';
export default async function(model, options) { export default async function(model, options) {
if (!options.context) { if (!options.context) {
return; return;
} }
const { database: db } = model; const { database: db } = model;
const { context, transaction = await db.sequelize.transaction() } = options; const { context: { state }, transaction = await db.sequelize.transaction() } = options;
const {
state,
action: {
params: {
actionName,
resourceName,
}
}
} = context;
const ActionLog = db.getModel('action_logs'); const ActionLog = db.getModel('action_logs');
// 创建操作记录 // 创建操作记录
const log = await ActionLog.create({ const log = await ActionLog.create({
type: actionName, type: LOG_TYPE_CREATE,
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') created_at: model.get('created_at')
}, { }, {
transaction 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 fields = db.getTable(model.constructor.name).getFields();
const fieldsList = Array.from(fields.values()); const fieldsList = Array.from(fields.values());
const changes = []; const changes = [];
model.changed().forEach((key: string) => { const changed = model.changed();
if (changed) {
changed.forEach((key: string) => {
const field = fields.get(key) || fieldsList.find((item: Field) => item.options.field === key); const field = fields.get(key) || fieldsList.find((item: Field) => item.options.field === key);
if (field) { if (field) {
changes.push({ changes.push({
@ -38,15 +40,12 @@ export default async function(model, options) {
}); });
} }
}); });
// TODO(bug): state.currentUser 不是 belongsTo field 的 target 实例
// Sequelize 会另外创建一个 Model 的继承类,无法直传 instance
// await log.setUser(state.currentUser, { transaction });
await log.updateAssociations({ await log.updateAssociations({
...(state.currentUser ? { user: state.currentUser.id } : {}),
changes changes
}, { }, {
transaction transaction
}); });
}
if (!options.transaction) { if (!options.transaction) {
await transaction.commit(); await transaction.commit();

View File

@ -1,31 +1,28 @@
import { Field } from '@nocobase/database'; import { Field } from '@nocobase/database';
import { LOG_TYPE_DESTROY } from '../constants';
export default async function(model, options) { export default async function(model, options) {
if (!options.context) { if (!options.context) {
return; return;
} }
const { database: db } = model; const { database: db } = model;
const { context, transaction = await db.sequelize.transaction() } = options; const { context: { state }, transaction = await db.sequelize.transaction() } = options;
const {
state,
action: {
params: {
actionName,
resourceName,
}
}
} = context;
const ActionLog = db.getModel('action_logs'); const ActionLog = db.getModel('action_logs');
// 创建操作记录 // 创建操作记录
const log = await ActionLog.create({ const log = await ActionLog.create({
// user_id: state.currentUser ? state.currentUser.id : null, // user_id: state.currentUser ? state.currentUser.id : null,
type: actionName, 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') // created_at: model.get('created_at')
}, { }, {
transaction transaction
}); });
if (state.currentUser) {
await log.updateAssociations({ user: state.currentUser.id }, {
transaction
});
}
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());
@ -41,7 +38,6 @@ export default async function(model, options) {
}); });
await log.updateAssociations({ await log.updateAssociations({
...(state.currentUser ? { user: state.currentUser.id } : {}),
changes changes
}, { }, {
transaction transaction

View File

@ -1,27 +1,20 @@
import { Field } from '@nocobase/database'; import { Field } from '@nocobase/database';
import { LOG_TYPE_UPDATE } from '../constants';
export default async function(model, options) { export default async function(model, options) {
if (!options.context) { if (!options.context) {
return; return;
} }
const { database: db } = model; const { database: db } = model;
const { context, transaction = await db.sequelize.transaction() } = options; const { context: { state }, transaction = await db.sequelize.transaction() } = options;
const {
state,
action: {
params: {
actionName,
resourceName,
}
}
} = context;
const ActionLog = db.getModel('action_logs'); const ActionLog = db.getModel('action_logs');
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();
model.changed().forEach((key: string) => { if (changed) {
changed.forEach((key: string) => {
const field = fields.get(key) || fieldsList.find((item: Field) => item.options.field === key); const field = fields.get(key) || fieldsList.find((item: Field) => item.options.field === key);
if (field && field.options.type !== 'formula') { if (field && field.options.type !== 'formula') {
changes.push({ changes.push({
@ -32,13 +25,10 @@ export default async function(model, options) {
} }
}); });
if (changes.length === 0) { if (changes.length) {
return;
}
// 创建操作记录 // 创建操作记录
const log = await ActionLog.create({ const log = await ActionLog.create({
type: actionName, type: LOG_TYPE_UPDATE,
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('updated_at') created_at: model.get('updated_at')
@ -52,6 +42,8 @@ export default async function(model, options) {
}, { }, {
transaction transaction
}); });
}
}
if (!options.transaction) { if (!options.transaction) {
await transaction.commit(); await transaction.commit();

View File

@ -1,4 +1,5 @@
import path from 'path'; import path from 'path';
import { Op } from 'sequelize';
import { addAll } from './hooks'; import { addAll } from './hooks';
@ -10,11 +11,46 @@ export default async function() {
}); });
// 为所有的表都加上日志的 hooks // 为所有的表都加上日志的 hooks
database.addHook('afterTableInit', function (table) { database.addHook('afterTableInit', (table) => {
if (['action_logs', 'action_changes'].includes(table.options.name)) { if (table.options.logging === false) {
return; return;
} }
const Model = database.getModel(table.options.name); addAll(database.getModel(table.options.name));
addAll(Model); });
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();
}
}); });
} }