feat(workflow): support api hooks (#1591)
Co-authored-by: sealday <sealday@gmail.com> Co-authored-by: Toby <2287769986@qq.com> Reviewed-on: daoyoucloud/tachybase#1591
This commit is contained in:
parent
f458d590c3
commit
970264f86e
@ -261,6 +261,64 @@ const schema: ISchema = {
|
||||
},
|
||||
},
|
||||
},
|
||||
resourceName: {
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
properties: {
|
||||
'5w93jhg5355': {
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Col',
|
||||
properties: {
|
||||
resourceName: {
|
||||
type: 'string',
|
||||
'x-component': 'CollectionField',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-collection-field': 'webhooks.resourceName',
|
||||
'x-reactions': [
|
||||
{
|
||||
dependencies: ['.type'],
|
||||
fulfill: {
|
||||
state: {
|
||||
hidden: '{{ $deps[0] !== "action" }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
'x-component-props': {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
actionName: {
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
properties: {
|
||||
'5w93jhg5355': {
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Col',
|
||||
properties: {
|
||||
actionName: {
|
||||
type: 'string',
|
||||
'x-component': 'CollectionField',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-collection-field': 'webhooks.actionName',
|
||||
'x-reactions': [
|
||||
{
|
||||
dependencies: ['.type'],
|
||||
fulfill: {
|
||||
state: {
|
||||
hidden: '{{ $deps[0] !== "action" }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
'x-component-props': {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'38fm61ehxvn': {
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
@ -611,6 +669,64 @@ const schema: ISchema = {
|
||||
},
|
||||
},
|
||||
},
|
||||
resourceName: {
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
properties: {
|
||||
'5w93jhg5355': {
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Col',
|
||||
properties: {
|
||||
resourceName: {
|
||||
type: 'string',
|
||||
'x-component': 'CollectionField',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-collection-field': 'webhooks.resourceName',
|
||||
'x-reactions': [
|
||||
{
|
||||
dependencies: ['.type'],
|
||||
fulfill: {
|
||||
state: {
|
||||
hidden: '{{ $deps[0] !== "action" }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
'x-component-props': {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
actionName: {
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
properties: {
|
||||
'5w93jhg5355': {
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Col',
|
||||
properties: {
|
||||
actionName: {
|
||||
type: 'string',
|
||||
'x-component': 'CollectionField',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-collection-field': 'webhooks.actionName',
|
||||
'x-reactions': [
|
||||
{
|
||||
dependencies: ['.type'],
|
||||
fulfill: {
|
||||
state: {
|
||||
hidden: '{{ $deps[0] !== "action" }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
'x-component-props': {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'38fm61ehxvn': {
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
|
@ -62,12 +62,35 @@ export const dispatchers: CollectionOptions = {
|
||||
enum: [
|
||||
{ label: tval('HTTP endpoint'), value: 'code' },
|
||||
{ label: tval('Scheduler'), value: 'cron' },
|
||||
{ label: tval('API action'), value: 'action' },
|
||||
],
|
||||
'x-component': 'Radio.Group',
|
||||
'x-decorator': 'FormItem',
|
||||
default: 'code',
|
||||
} as ISchema,
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'resourceName',
|
||||
interface: 'input',
|
||||
uiSchema: {
|
||||
title: tval('Resource'),
|
||||
type: 'string',
|
||||
'x-component': 'Input',
|
||||
required: true,
|
||||
} as ISchema,
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'actionName',
|
||||
interface: 'input',
|
||||
uiSchema: {
|
||||
title: tval('Action'),
|
||||
type: 'string',
|
||||
'x-component': 'Input',
|
||||
required: true,
|
||||
} as ISchema,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'code',
|
||||
@ -78,7 +101,7 @@ export const dispatchers: CollectionOptions = {
|
||||
'x-component': 'CodeMirror',
|
||||
'x-component-props': {
|
||||
defaultValue:
|
||||
'// ctx.action.params can get user query\n// ctx.action.params.values can get user body\n// ctx.body to pass your data to workflow or to client who invoke this.',
|
||||
'// ctx.action.params can get user query\n// ctx.action.params.values can get user body\n// const { changed, data, error } = await ctx.getChanged(); can get changed fields and raw data\n// ctx.body to pass your data to workflow or to client who invoke this.',
|
||||
},
|
||||
} as ISchema,
|
||||
},
|
||||
|
@ -289,5 +289,7 @@
|
||||
"Common": "Common",
|
||||
"ApprovalCarbonCopy": "Approval:CarbonCopy",
|
||||
"Approval Launch": "Approval Launch",
|
||||
"needAuthorization": "needAuthorization"
|
||||
"needAuthorization": "needAuthorization",
|
||||
"API action": "API action",
|
||||
"Resource": "Resource"
|
||||
}
|
||||
|
@ -367,5 +367,7 @@
|
||||
"CC'd to Me": "抄送我的",
|
||||
"My Pending Tasks": "我的代办",
|
||||
"Common": "通用",
|
||||
"needAuthorization": "是否携带权限校验"
|
||||
"needAuthorization": "是否携带权限校验",
|
||||
"API action": "api操作",
|
||||
"Resource": "数据表"
|
||||
}
|
||||
|
@ -298,6 +298,14 @@ export default class PluginWorkflowServer extends Plugin {
|
||||
await this.executing;
|
||||
}
|
||||
});
|
||||
|
||||
this.db.on('webhooks.afterCreate', this.removeWebhooksCache.bind(this));
|
||||
this.db.on('webhooks.afterUpdate', this.removeWebhooksCache.bind(this));
|
||||
this.db.on('webhooks.afterDestroy', this.removeWebhooksCache.bind(this));
|
||||
}
|
||||
|
||||
async removeWebhooksCache() {
|
||||
this.app.cache.del('webhooks');
|
||||
}
|
||||
|
||||
toggle(workflow: WorkflowModel, enable?: boolean) {
|
||||
|
@ -1,5 +1,8 @@
|
||||
import path from 'path';
|
||||
import { Plugin } from '@tachybase/server';
|
||||
import { Model } from '@tachybase/database';
|
||||
import Application, { Plugin } from '@tachybase/server';
|
||||
|
||||
import lodash from 'lodash';
|
||||
|
||||
import { WebhookController } from './webhooks';
|
||||
|
||||
@ -16,5 +19,41 @@ export class PluginWebhook extends Plugin {
|
||||
},
|
||||
});
|
||||
this.app.acl.allow('webhooks', ['trigger', 'test'], 'loggedIn');
|
||||
|
||||
const app = this.app as Application;
|
||||
this.app.acl.use(
|
||||
async (ctx, next) => {
|
||||
if (lodash.isEmpty(ctx.action.params)) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
const { resourceName, actionName } = ctx.action;
|
||||
const webhooksRepo = ctx.db.getRepository('webhooks');
|
||||
const actions = await app.cache.wrap(
|
||||
'webhooks',
|
||||
async () => {
|
||||
return await webhooksRepo.find({
|
||||
filter: {
|
||||
enabled: true,
|
||||
type: 'action',
|
||||
},
|
||||
});
|
||||
},
|
||||
30 * 60 * 1000,
|
||||
);
|
||||
const actionObj = {};
|
||||
const actionList = actions.filter((action) => {
|
||||
return resourceName === action['resourceName'] && actionName === action['actionName'];
|
||||
});
|
||||
for (const action of actionList) {
|
||||
actionObj[action.id] = await new WebhookController().action(ctx, action);
|
||||
}
|
||||
await next();
|
||||
for (const action of actionList) {
|
||||
await new WebhookController().triggerWorkflow(ctx, action, actionObj[action.id]);
|
||||
}
|
||||
},
|
||||
{ tag: 'api-webhook', after: 'core' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,15 @@
|
||||
import { extendCollection } from '@tachybase/database';
|
||||
|
||||
export default extendCollection({
|
||||
name: 'webhooks',
|
||||
fields: [
|
||||
{
|
||||
name: 'actionName',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
name: 'resourceName',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
});
|
@ -27,5 +27,9 @@ export default defineCollection({
|
||||
name: 'code',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'settings',
|
||||
type: 'json',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
@ -1,7 +1,160 @@
|
||||
import { Context } from '@tachybase/actions';
|
||||
import { IField } from '@tachybase/data-source-manager';
|
||||
import Database, { Model } from '@tachybase/database';
|
||||
import { PluginWorkflow } from '@tachybase/plugin-workflow';
|
||||
import { ActionParams } from '@tachybase/resourcer';
|
||||
import Application from '@tachybase/server';
|
||||
import { dayjs } from '@tachybase/utils';
|
||||
|
||||
import lodash from 'lodash';
|
||||
|
||||
function isSameBasic(val1: any, val2: any): boolean {
|
||||
if (val1 instanceof Date || val2 instanceof Date) {
|
||||
return new Date(val1).getTime() === new Date(val2).getTime();
|
||||
}
|
||||
return val1 === val2;
|
||||
}
|
||||
function getLostKey(smallOne: any, bigOne: any, path = ''): string[] {
|
||||
const lostKeys: Set<string> = new Set();
|
||||
if (typeof bigOne !== 'object' || bigOne === null) {
|
||||
if (smallOne === undefined) {
|
||||
return [path];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
const bigKeys = Object.keys(bigOne);
|
||||
for (const key of bigKeys) {
|
||||
let keyLabel = path ? `${path}.${key}` : key;
|
||||
if (Array.isArray(bigOne)) {
|
||||
keyLabel = path; // 去掉数组中的点 (e.g., 'items.0' becomes 'items')
|
||||
}
|
||||
if (smallOne?.[key] === undefined) {
|
||||
lostKeys.add(keyLabel);
|
||||
continue;
|
||||
} else {
|
||||
const result = getLostKey(smallOne?.[key], bigOne?.[key], keyLabel);
|
||||
for (const item of result) {
|
||||
lostKeys.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...lostKeys];
|
||||
}
|
||||
|
||||
// 只用得出before(数据库值)针对after(前端参数)中不同的key
|
||||
// after(前端参数)多余的部分不算在内
|
||||
function getDiffKeyExceptAfter(before: any, after: any, path = ''): string[] {
|
||||
if (after === null || after === undefined) {
|
||||
return [];
|
||||
}
|
||||
if (typeof before !== 'object' || typeof after !== 'object' || before === null || after === null) {
|
||||
// 一旦其中一个有值
|
||||
if (!isSameBasic(before, after)) {
|
||||
return [path];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (Array.isArray(before) && Array.isArray(after) && before.length != after.length) {
|
||||
return [path];
|
||||
}
|
||||
|
||||
const beforeDiffKeys: Set<string> = new Set();
|
||||
const beforeKeys = Object.keys(before);
|
||||
for (const key of beforeKeys) {
|
||||
if (after?.[key] === undefined) {
|
||||
continue;
|
||||
}
|
||||
let keyLabel = path ? `${path}.${key}` : key;
|
||||
if (Array.isArray(after)) {
|
||||
keyLabel = path; // 去掉数组中的点 (e.g., 'items.0' becomes 'items')
|
||||
}
|
||||
if (before?.[key] === undefined) {
|
||||
beforeDiffKeys.add(keyLabel);
|
||||
continue;
|
||||
} else {
|
||||
const result = getDiffKeyExceptAfter(before?.[key], after?.[key], keyLabel);
|
||||
for (const item of result) {
|
||||
beforeDiffKeys.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...beforeDiffKeys];
|
||||
}
|
||||
|
||||
/** 获得真正变动的数据库字段 */
|
||||
function getChanged(ctx: Context): () => Promise<{ changed?: string[]; data?: any; error: Error }> {
|
||||
return async function () {
|
||||
try {
|
||||
const params = lodash.cloneDeep(ctx.action.params) as ActionParams;
|
||||
// const changedKeys = new Set(Object.keys(params.values));
|
||||
const repo = ctx.db.getRepository(ctx.action.resourceName);
|
||||
const fieldsObj: Record<string, IField> = {};
|
||||
const app = ctx.app as Application;
|
||||
const c = app.mainDataSource.collectionManager.getCollection(ctx.action.resourceName);
|
||||
const fields = c.getFields();
|
||||
for (const field of fields) {
|
||||
fieldsObj[field.options.name] = field;
|
||||
}
|
||||
|
||||
let appendSet: Set<string> = new Set();
|
||||
if (params.updateAssociationValues) {
|
||||
appendSet = new Set(params.updateAssociationValues);
|
||||
}
|
||||
for (const key of appendSet) {
|
||||
if (!fieldsObj[key]) {
|
||||
continue;
|
||||
}
|
||||
const type = fieldsObj[key].options.type;
|
||||
if (type === 'virtual') {
|
||||
appendSet.delete(key);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let dataBefore = (
|
||||
await repo.findOne({
|
||||
filter: {
|
||||
id: params.filterByTk,
|
||||
},
|
||||
appends: [...appendSet],
|
||||
})
|
||||
).toJSON();
|
||||
|
||||
const lostKeys = getLostKey(dataBefore, params.values);
|
||||
for (const lostKey of lostKeys) {
|
||||
if (
|
||||
lostKey.includes('.') ||
|
||||
(fieldsObj[lostKey] !== undefined &&
|
||||
['belongsTo', 'belongsToMany', 'hasOne', 'hasMany'].includes(fieldsObj[lostKey].options.type))
|
||||
) {
|
||||
appendSet.add(lostKey);
|
||||
}
|
||||
}
|
||||
|
||||
dataBefore = (
|
||||
await repo.findOne({
|
||||
filter: {
|
||||
id: params.filterByTk,
|
||||
},
|
||||
appends: [...appendSet],
|
||||
})
|
||||
).toJSON();
|
||||
|
||||
const changed = getDiffKeyExceptAfter(dataBefore, params.values);
|
||||
return {
|
||||
error: null,
|
||||
data: dataBefore,
|
||||
changed,
|
||||
};
|
||||
} catch (err) {
|
||||
ctx.log.error(err);
|
||||
return {
|
||||
error: err.stack,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export class WebhookController {
|
||||
async getLink(ctx: Context) {
|
||||
const {
|
||||
@ -69,6 +222,46 @@ export class WebhookController {
|
||||
ctx.action.params.values = body;
|
||||
await new WebhookController().getLink(ctx);
|
||||
}
|
||||
|
||||
async action(ctx: Context, action: any) {
|
||||
const webhookCtx = {
|
||||
request: ctx.request,
|
||||
action: ctx.action,
|
||||
body: '',
|
||||
getChanged: getChanged(ctx),
|
||||
};
|
||||
try {
|
||||
await evalSimulate(action.code, {
|
||||
ctx: webhookCtx,
|
||||
lib: {
|
||||
JSON,
|
||||
Math,
|
||||
dayjs,
|
||||
},
|
||||
});
|
||||
return webhookCtx.body;
|
||||
} catch (err) {
|
||||
ctx.app.log.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async triggerWorkflow(ctx, action, body) {
|
||||
const { currentUser, currentRole } = ctx.state;
|
||||
const { model: UserModel } = ctx.db.getCollection('users');
|
||||
// 只有绑定工作流才执行
|
||||
if (!action.workflowKey) {
|
||||
return;
|
||||
}
|
||||
const userInfo = {
|
||||
user: UserModel.build(currentUser).desensitize(),
|
||||
roleName: currentRole,
|
||||
};
|
||||
const pluginWorkflow = ctx.app.getPlugin(PluginWorkflow) as PluginWorkflow;
|
||||
const wfRepo = ctx.db.getRepository('workflows');
|
||||
const wf = await wfRepo.findOne({ filter: { key: action.workflowKey, enabled: true } });
|
||||
await pluginWorkflow.trigger(wf, { data: body, ...userInfo }, { httpContext: ctx });
|
||||
}
|
||||
}
|
||||
|
||||
function run(jsCode: string, { ctx, lib }) {
|
||||
@ -78,3 +271,13 @@ function run(jsCode: string, { ctx, lib }) {
|
||||
console.log('err', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function evalSimulate(jsCode, { ctx, lib }) {
|
||||
const AsyncFunction: any = async function () {}.constructor;
|
||||
return await new AsyncFunction('$root', `with($root) { ${jsCode}; }`)({
|
||||
ctx,
|
||||
// 允许用户覆盖,这个时候可以使用 _ctx
|
||||
__ctx: ctx,
|
||||
lib,
|
||||
});
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user