diff --git a/packages/plugins/@tachybase/plugin-workflow/src/client/features/webhook/WebhookManager.tsx b/packages/plugins/@tachybase/plugin-workflow/src/client/features/webhook/WebhookManager.tsx index 4dd385709..514927365 100644 --- a/packages/plugins/@tachybase/plugin-workflow/src/client/features/webhook/WebhookManager.tsx +++ b/packages/plugins/@tachybase/plugin-workflow/src/client/features/webhook/WebhookManager.tsx @@ -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', diff --git a/packages/plugins/@tachybase/plugin-workflow/src/client/features/webhook/collections/dispatchers.tsx b/packages/plugins/@tachybase/plugin-workflow/src/client/features/webhook/collections/dispatchers.tsx index b192dbd80..cbd78b861 100644 --- a/packages/plugins/@tachybase/plugin-workflow/src/client/features/webhook/collections/dispatchers.tsx +++ b/packages/plugins/@tachybase/plugin-workflow/src/client/features/webhook/collections/dispatchers.tsx @@ -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, }, diff --git a/packages/plugins/@tachybase/plugin-workflow/src/locale/en-US.json b/packages/plugins/@tachybase/plugin-workflow/src/locale/en-US.json index e1393dca3..20b7a611e 100644 --- a/packages/plugins/@tachybase/plugin-workflow/src/locale/en-US.json +++ b/packages/plugins/@tachybase/plugin-workflow/src/locale/en-US.json @@ -289,5 +289,7 @@ "Common": "Common", "ApprovalCarbonCopy": "Approval:CarbonCopy", "Approval Launch": "Approval Launch", - "needAuthorization": "needAuthorization" + "needAuthorization": "needAuthorization", + "API action": "API action", + "Resource": "Resource" } diff --git a/packages/plugins/@tachybase/plugin-workflow/src/locale/zh-CN.json b/packages/plugins/@tachybase/plugin-workflow/src/locale/zh-CN.json index adfd70751..78aa8b693 100644 --- a/packages/plugins/@tachybase/plugin-workflow/src/locale/zh-CN.json +++ b/packages/plugins/@tachybase/plugin-workflow/src/locale/zh-CN.json @@ -367,5 +367,7 @@ "CC'd to Me": "抄送我的", "My Pending Tasks": "我的代办", "Common": "通用", - "needAuthorization": "是否携带权限校验" + "needAuthorization": "是否携带权限校验", + "API action": "api操作", + "Resource": "数据表" } diff --git a/packages/plugins/@tachybase/plugin-workflow/src/server/Plugin.ts b/packages/plugins/@tachybase/plugin-workflow/src/server/Plugin.ts index 4df843dec..c75504118 100644 --- a/packages/plugins/@tachybase/plugin-workflow/src/server/Plugin.ts +++ b/packages/plugins/@tachybase/plugin-workflow/src/server/Plugin.ts @@ -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) { diff --git a/packages/plugins/@tachybase/plugin-workflow/src/server/features/webhook/Plugin.ts b/packages/plugins/@tachybase/plugin-workflow/src/server/features/webhook/Plugin.ts index ac3c1e0fd..179c6e8fc 100644 --- a/packages/plugins/@tachybase/plugin-workflow/src/server/features/webhook/Plugin.ts +++ b/packages/plugins/@tachybase/plugin-workflow/src/server/features/webhook/Plugin.ts @@ -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' }, + ); } } diff --git a/packages/plugins/@tachybase/plugin-workflow/src/server/features/webhook/collections/apiHook.ts b/packages/plugins/@tachybase/plugin-workflow/src/server/features/webhook/collections/apiHook.ts new file mode 100644 index 000000000..dae4d559e --- /dev/null +++ b/packages/plugins/@tachybase/plugin-workflow/src/server/features/webhook/collections/apiHook.ts @@ -0,0 +1,15 @@ +import { extendCollection } from '@tachybase/database'; + +export default extendCollection({ + name: 'webhooks', + fields: [ + { + name: 'actionName', + type: 'string', + }, + { + name: 'resourceName', + type: 'text', + }, + ], +}); diff --git a/packages/plugins/@tachybase/plugin-workflow/src/server/features/webhook/collections/webhooks.ts b/packages/plugins/@tachybase/plugin-workflow/src/server/features/webhook/collections/webhooks.ts index 8e9af1bdf..99978cc64 100644 --- a/packages/plugins/@tachybase/plugin-workflow/src/server/features/webhook/collections/webhooks.ts +++ b/packages/plugins/@tachybase/plugin-workflow/src/server/features/webhook/collections/webhooks.ts @@ -27,5 +27,9 @@ export default defineCollection({ name: 'code', type: 'text', }, + { + name: 'settings', + type: 'json', + }, ], }); diff --git a/packages/plugins/@tachybase/plugin-workflow/src/server/features/webhook/webhooks.ts b/packages/plugins/@tachybase/plugin-workflow/src/server/features/webhook/webhooks.ts index 23adfa7da..7f724360c 100644 --- a/packages/plugins/@tachybase/plugin-workflow/src/server/features/webhook/webhooks.ts +++ b/packages/plugins/@tachybase/plugin-workflow/src/server/features/webhook/webhooks.ts @@ -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 = 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 = 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 = {}; + 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 = 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, + }); +}