@steedos-labs/plugin-workflow 3.0.100 → 3.0.102
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/main/default/client/flow2_render.client.js +1 -1
- package/main/default/routes/api_workflow_instance_return.router.js +13 -2
- package/main/default/test/test_return_after_skip.js +226 -0
- package/main/default/test/test_webhook_error_message.js +37 -0
- package/package.json +4 -2
- package/src/webhook_error.js +36 -0
- package/src/webhook_queue.js +5 -3
|
@@ -44,12 +44,23 @@ router.post('/api/workflow/v2/instance/return', requireAuthentication, async fun
|
|
|
44
44
|
throw new Error("不符合退回条件:当前为第一步,无法退回");
|
|
45
45
|
}
|
|
46
46
|
flow = await UUFlowManager.getFlow(ins.flow);
|
|
47
|
-
|
|
47
|
+
last_trace = _.last(ins.traces);
|
|
48
|
+
// 滑步处理:如果上一步是被跳过的(skipped),沿 previous_trace_ids 向前回溯,
|
|
49
|
+
// 退回到用户实际处理过的那个 trace(与取回 api_workflow_retrieve.router.js 的回溯逻辑一致),
|
|
50
|
+
// 否则 skipped approves 会被下方 new_inbox_users 过滤条件排除,导致"未找到下一步处理人"
|
|
51
|
+
var pre_trace_id = last_trace.previous_trace_ids && last_trace.previous_trace_ids[0];
|
|
52
|
+
pre_trace = (pre_trace_id && _.find(ins.traces, function (t) { return t._id === pre_trace_id; })) || ins.traces[ins.traces.length - 2];
|
|
53
|
+
while (pre_trace && pre_trace.judge === 'skipped' && pre_trace.previous_trace_ids && pre_trace.previous_trace_ids.length > 0) {
|
|
54
|
+
const skipped_prev_id = pre_trace.previous_trace_ids[0];
|
|
55
|
+
pre_trace = _.find(ins.traces, function (t) { return t._id === skipped_prev_id; });
|
|
56
|
+
}
|
|
57
|
+
if (!pre_trace) {
|
|
58
|
+
throw new Error("不符合退回条件:未找到可退回的上一步骤");
|
|
59
|
+
}
|
|
48
60
|
pre_step = await UUFlowManager.getStep(ins, flow, pre_trace.step);
|
|
49
61
|
if (pre_step.step_type === "counterSign") {
|
|
50
62
|
throw new Error("不符合退回条件:上一步为会签步骤,不允许退回");
|
|
51
63
|
}
|
|
52
|
-
last_trace = _.last(ins.traces);
|
|
53
64
|
current_step = await UUFlowManager.getStep(ins, flow, last_trace.step);
|
|
54
65
|
if (current_step.step_type !== "submit" && current_step.step_type !== "sign" && current_step.step_type !== "counterSign") {
|
|
55
66
|
throw new Error("不符合退回条件:当前步骤类型(" + current_step.step_type + ")不支持退回操作");
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 回归测试:滑步(skip processed)之后点退回,不能再报
|
|
3
|
+
* 500「未找到下一步处理人,退回失败」。
|
|
4
|
+
*
|
|
5
|
+
* 背景(线上现象):
|
|
6
|
+
* - 滑步把被跳过 trace 及其所有 approves 的 judge 写成 'skipped'
|
|
7
|
+
* (uuflow_manager.handleSkipProcessed)。
|
|
8
|
+
* - 退回路由取上一条 trace 的 approves 筛选退回对象,过滤条件只认
|
|
9
|
+
* judge ∈ (空/submitted/approved/rejected),'skipped' 被排除 →
|
|
10
|
+
* 候选人为空 → throw「未找到下一步处理人,退回失败」→ 500。
|
|
11
|
+
*
|
|
12
|
+
* 期望(修复后):
|
|
13
|
+
* - 与取回(api_workflow_retrieve.router.js)一致:沿 previous_trace_ids
|
|
14
|
+
* 回溯跳过所有 judge='skipped' 的 trace,退回到用户实际处理过的步骤。
|
|
15
|
+
* - 会签拦截检查作用于回溯后的真实目标步骤。
|
|
16
|
+
* - 正常(无滑步)退回行为不变。
|
|
17
|
+
*
|
|
18
|
+
* 用法:
|
|
19
|
+
* node main/default/test/test_return_after_skip.js
|
|
20
|
+
* 或 npm run test:return-after-skip
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const assert = require('assert');
|
|
24
|
+
const Module = require('module');
|
|
25
|
+
|
|
26
|
+
// ---- 伪造无法在独立环境 resolve 的外部依赖(express / @steedos/*) ----
|
|
27
|
+
// 路由文件加载时即 require 这些包;用 _resolveFilename 钩子把它们指到
|
|
28
|
+
// 预先塞进 require.cache 的假模块上。
|
|
29
|
+
const capturedRoutes = {};
|
|
30
|
+
const objectqlUpdates = [];
|
|
31
|
+
const fakeModules = {
|
|
32
|
+
'express': {
|
|
33
|
+
Router: function () {
|
|
34
|
+
return {
|
|
35
|
+
post: function (path) {
|
|
36
|
+
// (path, requireAuthentication, handler) → 取最后一个参数为业务 handler
|
|
37
|
+
capturedRoutes[path] = arguments[arguments.length - 1];
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
'@steedos/auth': {
|
|
43
|
+
requireAuthentication: function (req, res, next) { next(); },
|
|
44
|
+
},
|
|
45
|
+
'@steedos/objectql': {
|
|
46
|
+
getObject: function (name) {
|
|
47
|
+
return {
|
|
48
|
+
update: async function (id, doc) { objectqlUpdates.push({ name, id, doc }); return doc; },
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
const origResolve = Module._resolveFilename;
|
|
54
|
+
Module._resolveFilename = function (request) {
|
|
55
|
+
if (fakeModules[request]) return 'fake:' + request;
|
|
56
|
+
return origResolve.apply(this, arguments);
|
|
57
|
+
};
|
|
58
|
+
Object.keys(fakeModules).forEach(function (name) {
|
|
59
|
+
require.cache['fake:' + name] = {
|
|
60
|
+
id: 'fake:' + name,
|
|
61
|
+
filename: 'fake:' + name,
|
|
62
|
+
loaded: true,
|
|
63
|
+
exports: fakeModules[name],
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ---- 伪造插件内部依赖 ----
|
|
68
|
+
let instanceUpdateCalls = [];
|
|
69
|
+
const dummyInstancesCollection = {
|
|
70
|
+
updateOne: async function (selector, modifier) {
|
|
71
|
+
instanceUpdateCalls.push({ selector: selector, modifier: modifier });
|
|
72
|
+
return { modifiedCount: 1 };
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
const dummyUsersCollection = {
|
|
76
|
+
findOne: async function (selector) { return { _id: selector._id, name: 'name_of_' + selector._id }; },
|
|
77
|
+
};
|
|
78
|
+
function seedCache(relPath, exportsObj) {
|
|
79
|
+
const p = require.resolve(relPath);
|
|
80
|
+
require.cache[p] = { id: p, filename: p, loaded: true, exports: exportsObj };
|
|
81
|
+
}
|
|
82
|
+
seedCache('../utils/collection.js', {
|
|
83
|
+
getCollection: async function (name) {
|
|
84
|
+
return name === 'instances' ? dummyInstancesCollection : dummyUsersCollection;
|
|
85
|
+
},
|
|
86
|
+
_makeNewID: (function () { let n = 0; return function () { return 'new_id_' + (++n); }; })(),
|
|
87
|
+
});
|
|
88
|
+
seedCache('../manager/push_manager.js', {
|
|
89
|
+
send_message_to_specifyUser: async function () { },
|
|
90
|
+
send_instance_notification: async function () { },
|
|
91
|
+
triggerWebhook: async function () { },
|
|
92
|
+
});
|
|
93
|
+
seedCache('../manager/instance_tasks_manager.js', {
|
|
94
|
+
update_instance_tasks: async function () { },
|
|
95
|
+
insert_instance_tasks: async function () { },
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
let fakeInstance = null;
|
|
99
|
+
let stepTypes = {};
|
|
100
|
+
seedCache('../manager/uuflow_manager.js', {
|
|
101
|
+
getInstance: async function () { return fakeInstance; },
|
|
102
|
+
getFlow: async function () { return { _id: 'flow1' }; },
|
|
103
|
+
getStep: function (ins, flow, stepId) {
|
|
104
|
+
return { _id: stepId, name: 'name_' + stepId, step_type: stepTypes[stepId] || 'sign' };
|
|
105
|
+
},
|
|
106
|
+
getApproveValues: async function (values) { return values || {}; },
|
|
107
|
+
getDueDate: async function () { return null; },
|
|
108
|
+
getAgent: async function () { return null; },
|
|
109
|
+
getSpaceUser: async function () { return {}; },
|
|
110
|
+
getSpaceUserOrgInfo: async function () {
|
|
111
|
+
return { organization: 'org1', organization_name: 'org', organization_fullname: 'org' };
|
|
112
|
+
},
|
|
113
|
+
setRemindInfo: async function () { },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
require('../routes/api_workflow_instance_return.router.js');
|
|
117
|
+
const handler = capturedRoutes['/api/workflow/v2/instance/return'];
|
|
118
|
+
assert.ok(handler, '未捕获到退回路由 handler');
|
|
119
|
+
|
|
120
|
+
// ---- 构造实例:submit → sign(已审批) → sign(被滑步跳过) → sign(当前待审) ----
|
|
121
|
+
function buildInstance(opts) {
|
|
122
|
+
opts = opts || {};
|
|
123
|
+
const traces = [
|
|
124
|
+
{
|
|
125
|
+
_id: 't1', step: 'step_start', name: '填写申请单', is_finished: true, judge: 'submitted',
|
|
126
|
+
approves: [{ _id: 'a1', user: 'user_a', handler: 'user_a', type: 'draft', judge: 'submitted' }],
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
_id: 't2', step: 'step_real', name: '实际审批步骤', is_finished: true, judge: 'approved',
|
|
130
|
+
previous_trace_ids: ['t1'],
|
|
131
|
+
approves: [{ _id: 'a2', user: 'user_a', handler: 'user_a', judge: 'approved' }],
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
_id: 't3', step: 'step_skipped', name: '被滑步跳过的步骤', is_finished: true, judge: 'skipped',
|
|
135
|
+
previous_trace_ids: ['t2'],
|
|
136
|
+
approves: [{ _id: 'a3', user: 'user_a', handler: 'user_a', judge: 'skipped' }],
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
_id: 't4', step: 'step_current', name: '当前步骤', is_finished: false,
|
|
140
|
+
previous_trace_ids: ['t3'],
|
|
141
|
+
approves: [{ _id: 'a4', user: 'user_c', handler: 'user_c', is_finished: false, start_date: new Date() }],
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
if (opts.noSkip) {
|
|
145
|
+
traces.splice(2, 1); // 去掉滑步 trace,t4 直接跟在 t2 后
|
|
146
|
+
traces[2].previous_trace_ids = ['t2'];
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
_id: 'ins1', space: 'space1', state: 'pending', applicant: 'user_a', submitter: 'user_a',
|
|
150
|
+
flow: 'flow1', form: 'form1', form_version: 'fv1',
|
|
151
|
+
inbox_users: ['user_c'], outbox_users: ['user_a'], cc_users: [],
|
|
152
|
+
values: {}, current_step_name: '当前步骤',
|
|
153
|
+
traces: traces,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function callReturn() {
|
|
158
|
+
instanceUpdateCalls = [];
|
|
159
|
+
objectqlUpdates.length = 0;
|
|
160
|
+
const req = {
|
|
161
|
+
user: { userId: 'user_c' },
|
|
162
|
+
body: { approve: { _id: 'a4', instance: 'ins1', trace: 't4', values: {} }, reason: '退回测试' },
|
|
163
|
+
};
|
|
164
|
+
const res = {
|
|
165
|
+
statusCode: null, body: null,
|
|
166
|
+
status: function (c) { this.statusCode = c; return this; },
|
|
167
|
+
send: function (b) { this.body = b; return this; },
|
|
168
|
+
};
|
|
169
|
+
await handler(req, res);
|
|
170
|
+
return res;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let passed = 0;
|
|
174
|
+
let failed = 0;
|
|
175
|
+
async function run(name, fn) {
|
|
176
|
+
try {
|
|
177
|
+
await fn();
|
|
178
|
+
console.log(` ✓ ${name}`);
|
|
179
|
+
passed++;
|
|
180
|
+
} catch (e) {
|
|
181
|
+
console.log(` ✗ ${name}`);
|
|
182
|
+
console.log(` ${e.message}`);
|
|
183
|
+
failed++;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
(async () => {
|
|
188
|
+
console.log('退回接口滑步回溯(return after skip)');
|
|
189
|
+
|
|
190
|
+
await run('上一步被滑步跳过 → 回溯退回到实际处理过的步骤,不报 500', async () => {
|
|
191
|
+
stepTypes = {};
|
|
192
|
+
fakeInstance = buildInstance();
|
|
193
|
+
const res = await callReturn();
|
|
194
|
+
assert.strictEqual(res.statusCode, 200, '应返回 200,实际 ' + res.statusCode + ':' + (res.body && res.body.msg));
|
|
195
|
+
assert.strictEqual(res.body.status, 0);
|
|
196
|
+
// 新 trace 落在实际处理过的 step_real,而不是被跳过的 step_skipped
|
|
197
|
+
const pushCall = instanceUpdateCalls.find(c => c.modifier.$push && c.modifier.$push.traces);
|
|
198
|
+
assert.ok(pushCall, '未创建退回目标新 trace');
|
|
199
|
+
const newTrace = pushCall.modifier.$push.traces;
|
|
200
|
+
assert.strictEqual(newTrace.step, 'step_real');
|
|
201
|
+
assert.strictEqual(newTrace.approves[0].user, 'user_a');
|
|
202
|
+
// 退回后的待办人是实际处理人
|
|
203
|
+
const setCall = instanceUpdateCalls.find(c => c.modifier.$set && c.modifier.$set.inbox_users);
|
|
204
|
+
assert.deepStrictEqual(setCall.modifier.$set.inbox_users, ['user_a']);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await run('会签拦截作用于回溯后的真实目标步骤', async () => {
|
|
208
|
+
stepTypes = { step_real: 'counterSign' };
|
|
209
|
+
fakeInstance = buildInstance();
|
|
210
|
+
const res = await callReturn();
|
|
211
|
+
assert.strictEqual(res.statusCode, 500);
|
|
212
|
+
assert.ok(/会签/.test(res.body.msg), '应报会签不允许退回,实际:' + res.body.msg);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await run('正常(无滑步)退回行为不变', async () => {
|
|
216
|
+
stepTypes = {};
|
|
217
|
+
fakeInstance = buildInstance({ noSkip: true });
|
|
218
|
+
const res = await callReturn();
|
|
219
|
+
assert.strictEqual(res.statusCode, 200, '应返回 200,实际 ' + res.statusCode + ':' + (res.body && res.body.msg));
|
|
220
|
+
const pushCall = instanceUpdateCalls.find(c => c.modifier.$push && c.modifier.$push.traces);
|
|
221
|
+
assert.strictEqual(pushCall.modifier.$push.traces.step, 'step_real');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
225
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
226
|
+
})();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const { getWebhookErrorMessage } = require('../../../src/webhook_error');
|
|
3
|
+
|
|
4
|
+
console.log('[webhook-error-message] running tests...');
|
|
5
|
+
|
|
6
|
+
assert.strictEqual(
|
|
7
|
+
getWebhookErrorMessage({
|
|
8
|
+
message: 'Request failed with status code 500',
|
|
9
|
+
response: { data: 'name is required' }
|
|
10
|
+
}),
|
|
11
|
+
'name is required'
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
assert.strictEqual(
|
|
15
|
+
getWebhookErrorMessage({ response: { data: { errcode: 400, message: '参数错误' } } }),
|
|
16
|
+
'{"errcode":400,"message":"参数错误"}'
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
assert.strictEqual(
|
|
20
|
+
getWebhookErrorMessage(new Error('connect ECONNREFUSED')),
|
|
21
|
+
'connect ECONNREFUSED'
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const circularResponse = {};
|
|
25
|
+
circularResponse.self = circularResponse;
|
|
26
|
+
assert.strictEqual(
|
|
27
|
+
getWebhookErrorMessage({
|
|
28
|
+
message: 'Request failed with circular response data',
|
|
29
|
+
response: { data: circularResponse }
|
|
30
|
+
}),
|
|
31
|
+
'Request failed with circular response data'
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
assert.strictEqual(getWebhookErrorMessage('network error'), 'network error');
|
|
35
|
+
assert.strictEqual(getWebhookErrorMessage(null), 'Unknown webhook error');
|
|
36
|
+
|
|
37
|
+
console.log('[webhook-error-message] all tests passed');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steedos-labs/plugin-workflow",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.102",
|
|
4
4
|
"main": "package.service.js",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"files": [
|
|
@@ -26,8 +26,10 @@
|
|
|
26
26
|
"test:formula-compat": "node main/default/test/test_formula_compat.js",
|
|
27
27
|
"test:approve-values": "node main/default/test/test_getApproveValues.js",
|
|
28
28
|
"test:skip-handler-error": "node main/default/test/test_handleSkipProcessed_handlerError.js",
|
|
29
|
+
"test:return-after-skip": "node main/default/test/test_return_after_skip.js",
|
|
29
30
|
"test:flow-validator": "node main/default/test/test_flow_validator.js",
|
|
30
|
-
"test:ajax-error-message": "node main/default/test/test_ajax_error_message.js"
|
|
31
|
+
"test:ajax-error-message": "node main/default/test/test_ajax_error_message.js",
|
|
32
|
+
"test:webhook-error-message": "node main/default/test/test_webhook_error_message.js"
|
|
31
33
|
},
|
|
32
34
|
"dependencies": {
|
|
33
35
|
"graphql-parse-resolve-info": "^4.12.3",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Convert Axios and generic errors to a MongoDB-safe string while preserving
|
|
3
|
+
* the downstream response body whenever one is available.
|
|
4
|
+
*/
|
|
5
|
+
const getWebhookErrorMessage = function (error) {
|
|
6
|
+
const responseData = error && error.response && error.response.data;
|
|
7
|
+
|
|
8
|
+
if (responseData !== undefined && responseData !== null && responseData !== '') {
|
|
9
|
+
if (typeof responseData === 'string') {
|
|
10
|
+
return responseData;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const serializedResponse = JSON.stringify(responseData);
|
|
15
|
+
if (typeof serializedResponse === 'string') {
|
|
16
|
+
return serializedResponse;
|
|
17
|
+
}
|
|
18
|
+
} catch (stringifyError) {
|
|
19
|
+
// Fall through to the regular error message for circular response data.
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (error && error.message) {
|
|
24
|
+
return String(error.message);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (responseData !== undefined && responseData !== null) {
|
|
28
|
+
return String(responseData);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return error ? String(error) : 'Unknown webhook error';
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
module.exports = {
|
|
35
|
+
getWebhookErrorMessage
|
|
36
|
+
};
|
package/src/webhook_queue.js
CHANGED
|
@@ -10,6 +10,7 @@ const _ = require("lodash");
|
|
|
10
10
|
const axios = require('axios');
|
|
11
11
|
|
|
12
12
|
const { getCollection } = require("../main/default/utils/collection");
|
|
13
|
+
const { getWebhookErrorMessage } = require('./webhook_error');
|
|
13
14
|
|
|
14
15
|
const WebhookQueue = {};
|
|
15
16
|
|
|
@@ -123,13 +124,14 @@ WebhookQueue.Configure = async function (options) {
|
|
|
123
124
|
|
|
124
125
|
}
|
|
125
126
|
} catch (error) {
|
|
126
|
-
|
|
127
|
+
const errMsg = getWebhookErrorMessage(error);
|
|
128
|
+
console.error('WebhookQueue: Error while sending:', errMsg);
|
|
127
129
|
await WebhookQueue.collection.updateOne({
|
|
128
130
|
_id: webhook._id
|
|
129
131
|
}, {
|
|
130
132
|
$set: {
|
|
131
133
|
// error message
|
|
132
|
-
errMsg:
|
|
134
|
+
errMsg: errMsg
|
|
133
135
|
}
|
|
134
136
|
});
|
|
135
137
|
}
|
|
@@ -280,4 +282,4 @@ WebhookQueue.Configure = async function (options) {
|
|
|
280
282
|
|
|
281
283
|
};
|
|
282
284
|
|
|
283
|
-
module.exports = WebhookQueue;
|
|
285
|
+
module.exports = WebhookQueue;
|