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:
parent
134f72f6e8
commit
f9c4fb9df0
@ -4,6 +4,7 @@ export default {
|
||||
title: '示例',
|
||||
name: 'examples',
|
||||
showInDataMenu: true,
|
||||
logging: false,
|
||||
// createdBy: true,
|
||||
// updatedBy: true,
|
||||
fields: [
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
69
packages/plugin-action-logs/src/__tests__/hook.test.ts
Normal file
69
packages/plugin-action-logs/src/__tests__/hook.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
145
packages/plugin-action-logs/src/__tests__/index.ts
Normal file
145
packages/plugin-action-logs/src/__tests__/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
15
packages/plugin-action-logs/src/__tests__/tables/comments.ts
Normal file
15
packages/plugin-action-logs/src/__tests__/tables/comments.ts
Normal 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;
|
22
packages/plugin-action-logs/src/__tests__/tables/posts.ts
Normal file
22
packages/plugin-action-logs/src/__tests__/tables/posts.ts
Normal 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;
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -0,0 +1,7 @@
|
||||
import { extend } from '@nocobase/database';
|
||||
|
||||
// TODO(bug): collections 表创建关联字段有问题
|
||||
export default extend({
|
||||
name: 'collections',
|
||||
logging: false
|
||||
});
|
3
packages/plugin-action-logs/src/constants.ts
Normal file
3
packages/plugin-action-logs/src/constants.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const LOG_TYPE_CREATE = 'create';
|
||||
export const LOG_TYPE_UPDATE = 'update';
|
||||
export const LOG_TYPE_DESTROY = 'destroy';
|
@ -1,35 +1,37 @@
|
||||
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 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({
|
||||
@ -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({
|
||||
...(state.currentUser ? { user: state.currentUser.id } : {}),
|
||||
changes
|
||||
}, {
|
||||
transaction
|
||||
});
|
||||
}
|
||||
|
||||
if (!options.transaction) {
|
||||
await transaction.commit();
|
||||
|
@ -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
|
||||
|
@ -1,27 +1,20 @@
|
||||
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 = [];
|
||||
|
||||
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);
|
||||
if (field && field.options.type !== 'formula') {
|
||||
changes.push({
|
||||
@ -32,13 +25,10 @@ export default async function(model, options) {
|
||||
}
|
||||
});
|
||||
|
||||
if (changes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (changes.length) {
|
||||
// 创建操作记录
|
||||
const log = await ActionLog.create({
|
||||
type: actionName,
|
||||
type: LOG_TYPE_UPDATE,
|
||||
collection_name: model.constructor.name,
|
||||
index: model.get(model.constructor.primaryKeyAttribute),
|
||||
created_at: model.get('updated_at')
|
||||
@ -52,6 +42,8 @@ export default async function(model, options) {
|
||||
}, {
|
||||
transaction
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.transaction) {
|
||||
await transaction.commit();
|
||||
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user