@gadmin2n/schematics 0.0.152 → 0.0.153

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.
Files changed (43) hide show
  1. package/dist/lib/application/files/gadmin2-game-angle-demo/CODEBUDDY.local.md +8 -0
  2. package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/workflow.prisma +1 -0
  3. package/dist/lib/application/files/gadmin2-game-angle-demo/server/.seed-timestamp +1 -0
  4. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/index.ts +2 -1
  5. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/permission.seed.ts +23 -44
  6. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts +25 -4
  7. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow.seed.ts +74 -64
  8. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/temporal.service.ts +0 -7
  9. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-cancellation.spec.ts +17 -0
  10. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.spec.ts +58 -3
  11. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.ts +66 -6
  12. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-execution-idempotency.spec.ts +61 -149
  13. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-execution.dto.ts +1 -1
  14. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-runtime.controller.spec.ts +1 -1
  15. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-runtime.controller.ts +1 -1
  16. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle.service.spec.ts +2 -2
  17. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.spec.ts +38 -0
  18. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.ts +18 -7
  19. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.dto.ts +1 -0
  20. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +131 -242
  21. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/config/routeRegistry.test.ts +7 -5
  22. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/config/routeRegistry.tsx +6 -36
  23. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/hooks/useDynamicResources.tsx +0 -4
  24. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/workflow.json +29 -7
  25. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/workflow.json +29 -7
  26. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/people-groups/index.tsx +20 -17
  27. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/person-directory/index.tsx +38 -35
  28. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/detail.tsx +6 -1
  29. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/index.tsx +24 -22
  30. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/EnhancedFlowRenderer.tsx +0 -11
  31. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/StatusCards.tsx +24 -0
  32. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/index.tsx +20 -18
  33. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/index.tsx +7 -0
  34. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.tsx +28 -79
  35. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instances.tsx +5 -0
  36. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/components/NodeInstanceForm.tsx +0 -1
  37. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/DbExecuteConfigEditor.tsx +227 -97
  38. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SimplifiedNodeEditors.test.tsx +62 -0
  39. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/settings/index.tsx +73 -0
  40. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/temporal-tenant/index.tsx +21 -46
  41. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/types.ts +2 -0
  42. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/newNodeConfig.ts +1 -0
  43. package/package.json +1 -1
@@ -0,0 +1,8 @@
1
+ ## 与 Gadmin2 模板的关系
2
+
3
+ 本项目(gadmin-test)是 gadmin2 模板的**实例项目**,用于日常迭代新功能。迭代完成后需将新特性合并回 gadmin2 模板。
4
+
5
+ | | 路径 |
6
+ |------|------|
7
+ | 实例(本项目) | `/Users/kavenma/Documents/work/code/oit/test/gadmin-test` |
8
+ | 模板(上游) | `/Users/kavenma/Documents/work/code/common/gadmin2/schematics/src/lib/application/files/gadmin2-game-angle-demo` |
@@ -9,6 +9,7 @@ enum WorkflowStatus {
9
9
  enum WorkflowInstanceStatus {
10
10
  PENDING
11
11
  RUNNING
12
+ WAITING_RETRY
12
13
  COMPLETED
13
14
  FAILED
14
15
  CANCELLED
@@ -0,0 +1 @@
1
+ 2026-09-18T04:59:46.662Z
@@ -31,12 +31,13 @@ export async function main() {
31
31
 
32
32
  await seedBootstrap(prisma);
33
33
 
34
+ // Workflow 先建父菜单与「功能设置」页(含旧 4 页面的合并迁移),permission 随后。
35
+ await seedWorkflowModule(prisma);
34
36
  await seedPermissionModule(prisma);
35
37
  await seedAgendaModule(prisma);
36
38
  await seedAuditModule(prisma);
37
39
  await seedCanvasModule(prisma);
38
40
  await seedGameModule(prisma);
39
- await seedWorkflowModule(prisma);
40
41
 
41
42
  await seedDataMngtPages(prisma);
42
43
  }
@@ -12,8 +12,9 @@ import {
12
12
  *
13
13
  * 职责(仅本模块自家):
14
14
  * 1. permission 父分组 page (顶级 admin/administration → permission)
15
- * 2. 7 个 leaf page: permission_readme / person_directory / user /
16
- * people_group / role / page / resource
15
+ * 2. 5 个 leaf page,均挂在 permission 下
16
+ * (原 person_directory / people_group 已并入 Workflow 的「功能设置」页,
17
+ * 由 workflow.seed.ts 声明并迁移)
17
18
  * 3. permission 模块所需 resource 及页面绑定
18
19
  * 4. PageResource 绑定(page ↔ resource,多对多)
19
20
  * 5. SYSTEM_ADMIN 对上述全部 page + resource 的全量授权
@@ -54,32 +55,35 @@ export async function seedPermissionModule(
54
55
  zhName: '菜单指引',
55
56
  enName: 'Menu Guide',
56
57
  sortOrder: 1005,
57
- },
58
- {
59
- code: 'person_directory',
60
- zhName: '人员目录',
61
- enName: 'People Directory',
62
- sortOrder: 1010,
58
+ parentId: permissionPage.id,
63
59
  },
64
60
  {
65
61
  code: 'user',
66
- zhName: '访问授权',
67
- enName: 'Access Grants',
62
+ zhName: '用户管理',
63
+ enName: 'User Mgnt',
68
64
  sortOrder: 1020,
65
+ parentId: permissionPage.id,
66
+ },
67
+ {
68
+ code: 'role',
69
+ zhName: '角色管理',
70
+ enName: 'Role Mgnt',
71
+ sortOrder: 1040,
72
+ parentId: permissionPage.id,
69
73
  },
70
74
  {
71
- code: 'people_group',
72
- zhName: '人员组',
73
- enName: 'People Groups',
74
- sortOrder: 1030,
75
+ code: 'page',
76
+ zhName: '页面管理',
77
+ enName: 'Page Mgnt',
78
+ sortOrder: 1050,
79
+ parentId: permissionPage.id,
75
80
  },
76
- { code: 'role', zhName: '角色管理', enName: 'Role Mgnt', sortOrder: 1040 },
77
- { code: 'page', zhName: '页面管理', enName: 'Page Mgnt', sortOrder: 1050 },
78
81
  {
79
82
  code: 'resource',
80
83
  zhName: '资源管理',
81
84
  enName: 'Resource Management',
82
85
  sortOrder: 1060,
86
+ parentId: permissionPage.id,
83
87
  },
84
88
  ];
85
89
 
@@ -95,13 +99,13 @@ export async function seedPermissionModule(
95
99
  zhName: def.zhName,
96
100
  enName: def.enName,
97
101
  sortOrder: def.sortOrder,
98
- parentId: permissionPage.id,
102
+ parentId: def.parentId,
99
103
  },
100
104
  {
101
105
  zhName: def.zhName,
102
106
  enName: def.enName,
103
107
  sortOrder: def.sortOrder,
104
- parentId: permissionPage.id,
108
+ parentId: def.parentId,
105
109
  },
106
110
  );
107
111
  pagesByCode.set(p.code, p.id);
@@ -114,32 +118,7 @@ export async function seedPermissionModule(
114
118
  description: string;
115
119
  pageCode: string;
116
120
  }[] = [
117
- {
118
- code: 'user',
119
- description: 'People directory',
120
- pageCode: 'person_directory',
121
- },
122
- {
123
- code: 'staffDirectory',
124
- description: 'Search the shared staff directory',
125
- pageCode: 'person_directory',
126
- },
127
- { code: 'user', description: 'Access grants', pageCode: 'user' },
128
- {
129
- code: 'userGroup:read',
130
- description: 'Read reusable people groups',
131
- pageCode: 'people_group',
132
- },
133
- {
134
- code: 'userGroup:create',
135
- description: 'Maintain reusable people groups',
136
- pageCode: 'people_group',
137
- },
138
- {
139
- code: 'user',
140
- description: 'Read people for group membership',
141
- pageCode: 'people_group',
142
- },
121
+ { code: 'user', description: 'User management', pageCode: 'user' },
143
122
  { code: 'role', description: 'Role management', pageCode: 'role' },
144
123
  {
145
124
  code: 'rolePages',
@@ -452,12 +452,17 @@ const NODE_DEFINITIONS: BaseNodeTypeSeed[] = [
452
452
  {
453
453
  type: 'db_execute',
454
454
  category: 'ACTION',
455
- label: '数据库 Function',
455
+ label: '数据库操作',
456
456
  icon: 'ConsoleSqlOutlined',
457
- description: '调用当前 GAdmin 数据库 workflow_api 中的 Function',
457
+ description: '执行 Raw SQL 或调用当前 GAdmin 数据库中的 Function',
458
458
  configSchema: {
459
459
  type: 'object',
460
460
  properties: {
461
+ mode: {
462
+ type: 'string',
463
+ enum: ['FUNCTION', 'SQL'],
464
+ description: '执行方式',
465
+ },
461
466
  functionRef: {
462
467
  type: 'object',
463
468
  properties: {
@@ -473,6 +478,19 @@ const NODE_DEFINITIONS: BaseNodeTypeSeed[] = [
473
478
  items: PARAMETER_BINDING_SCHEMA,
474
479
  description: '按 Function 参数顺序提供的值',
475
480
  },
481
+ allowWrite: {
482
+ type: 'boolean',
483
+ description: 'Raw SQL 是否允许新增、更新或删除数据',
484
+ },
485
+ sql: {
486
+ type: 'string',
487
+ description: '要执行的单条 SQL',
488
+ },
489
+ parameters: {
490
+ type: 'array',
491
+ items: PARAMETER_BINDING_SCHEMA,
492
+ description: 'Raw SQL 参数占位符对应的值',
493
+ },
476
494
  timeoutSeconds: {
477
495
  type: 'integer',
478
496
  minimum: 1,
@@ -480,7 +498,6 @@ const NODE_DEFINITIONS: BaseNodeTypeSeed[] = [
480
498
  description: '执行超时时间(秒)',
481
499
  },
482
500
  },
483
- required: ['functionRef', 'argumentBindings'],
484
501
  additionalProperties: false,
485
502
  },
486
503
  inputSchema: { description: '上一个节点的完整结果' },
@@ -488,6 +505,10 @@ const NODE_DEFINITIONS: BaseNodeTypeSeed[] = [
488
505
  type: 'object',
489
506
  properties: {
490
507
  result: { description: 'Function 返回值' },
508
+ rows: { type: 'array' },
509
+ rowCount: { type: 'number' },
510
+ affectedRows: { type: 'number' },
511
+ insertedId: { type: 'string' },
491
512
  },
492
513
  },
493
514
  outputs: [],
@@ -1316,7 +1337,7 @@ const dedicatedEditorNodes: NodeTypeSeed[] = [
1316
1337
  type === 'event_trigger'
1317
1338
  ? 6
1318
1339
  : type === 'db_execute'
1319
- ? 5
1340
+ ? 6
1320
1341
  : type === 'parallel'
1321
1342
  ? 6
1322
1343
  : ['if_else', 'switch'].includes(type)
@@ -67,38 +67,22 @@ export async function seedWorkflowModule(prisma: PrismaClient): Promise<void> {
67
67
  resourceCode: 'workflowNodeInstance',
68
68
  resourceDescription: 'Workflow node instance management',
69
69
  },
70
- {
71
- code: 'workflow_connections',
72
- zhName: '连接管理',
73
- enName: 'Connections',
74
- sortOrder: 3,
75
- resourceCode: 'integrationConnection',
76
- resourceDescription: 'Reusable external system connections',
77
- },
78
- {
79
- code: 'workflow_temporal_tenant',
80
- zhName: '服务注册',
81
- enName: 'Service Registration',
82
- sortOrder: 4,
83
- resourceCode: 'temporalTenantConfig',
84
- resourceDescription: 'Temporal tenant config management',
85
- },
86
- {
87
- code: 'workflow_event_catalog',
88
- zhName: '业务事件',
89
- enName: 'Business Events',
90
- sortOrder: 5,
91
- resourceCode: 'workflow',
92
- resourceDescription: 'Read registered Workflow business events',
93
- },
94
70
  {
95
71
  code: 'approval_tasks',
96
72
  zhName: '审批任务',
97
73
  enName: 'Approval Tasks',
98
- sortOrder: 6,
74
+ sortOrder: 3,
99
75
  resourceCode: 'humanApproval',
100
76
  resourceDescription: 'Read and decide assigned Human Approval tasks',
101
77
  },
78
+ {
79
+ code: 'workflow_settings',
80
+ zhName: '功能设置',
81
+ enName: 'Feature Settings',
82
+ sortOrder: 6,
83
+ resourceCode: 'integrationConnection',
84
+ resourceDescription: 'Reusable external system connections',
85
+ },
102
86
  ];
103
87
 
104
88
  const allPages = [{ id: workflowPage.id, code: workflowPage.code }];
@@ -114,7 +98,7 @@ export async function seedWorkflowModule(prisma: PrismaClient): Promise<void> {
114
98
  sortOrder: def.sortOrder,
115
99
  parentId: workflowPage.id,
116
100
  },
117
- def.code === 'approval_tasks'
101
+ def.code === 'approval_tasks' || def.code === 'workflow_settings'
118
102
  ? { parentId: workflowPage.id, sortOrder: def.sortOrder }
119
103
  : {},
120
104
  );
@@ -129,18 +113,6 @@ export async function seedWorkflowModule(prisma: PrismaClient): Promise<void> {
129
113
  );
130
114
  console.log(`[workflow] PageResource: ${def.code} ↔ ${def.resourceCode}`);
131
115
 
132
- if (def.code === 'workflow_event_catalog') {
133
- await bindResourceToPage(
134
- prisma,
135
- p.id,
136
- 'workflowEventRegistration',
137
- 'Register database-backed Workflow business events',
138
- );
139
- console.log(
140
- '[workflow] PageResource: workflow_event_catalog ↔ workflowEventRegistration',
141
- );
142
- }
143
-
144
116
  if (def.code === 'workflow_list') {
145
117
  await bindResourceToPage(
146
118
  prisma,
@@ -151,6 +123,26 @@ export async function seedWorkflowModule(prisma: PrismaClient): Promise<void> {
151
123
  console.log('[workflow] PageResource: workflow_list ↔ workflowExecution');
152
124
  }
153
125
 
126
+ // 功能设置合并了原服务注册/连接管理/业务事件/人员目录/人员组 5 个页面的全部资源
127
+ if (def.code === 'workflow_settings') {
128
+ const mergedResources = [
129
+ ['temporalTenantConfig', 'Temporal tenant config management'],
130
+ ['workflow', 'Read registered Workflow business events'],
131
+ [
132
+ 'workflowEventRegistration',
133
+ 'Register database-backed Workflow business events',
134
+ ],
135
+ ['user', 'People directory'],
136
+ ['staffDirectory', 'Search the shared staff directory'],
137
+ ['userGroup:read', 'Read reusable people groups'],
138
+ ['userGroup:create', 'Maintain reusable people groups'],
139
+ ] as const;
140
+ for (const [code, description] of mergedResources) {
141
+ await bindResourceToPage(prisma, p.id, code, description);
142
+ console.log(`[workflow] PageResource: ${def.code} ↔ ${code}`);
143
+ }
144
+ }
145
+
154
146
  // Provider actions live inside the existing Node Instance page. Resolve is
155
147
  // seeded now for the later publish/runtime call path; it has no HTTP route yet.
156
148
  if (def.code === 'workflow_node_instance') {
@@ -197,35 +189,53 @@ export async function seedWorkflowModule(prisma: PrismaClient): Promise<void> {
197
189
  }
198
190
  }
199
191
 
200
- // Connection management is part of creating and maintaining reusable Node
201
- // Templates. Keep its page access aligned with the existing Node Template
202
- // page instead of limiting it to SYSTEM_ADMIN on installations that already
203
- // granted workflow authoring to other roles.
204
- const nodeTemplatePage = allPages.find(
205
- (page) => page.code === 'workflow_node_instance',
206
- );
207
- const connectionsPage = allPages.find(
208
- (page) => page.code === 'workflow_connections',
192
+ // ── 旧页面合并迁移 ─────────────────────────────────────────────────────────
193
+ // 「功能设置」(workflow_settings) 取代了服务注册/连接管理/业务事件/人员目录/
194
+ // 人员组 5 个旧页面:已授权旧页面的角色改授新页面以保留访问,随后删除旧 page
195
+ // 及其 RolePages/PageResource 关联(RoleResource 保留——资源仍绑定在新页面上)。
196
+ // 旧页面已删除的环境重跑 seed 时本块为 no-op。
197
+ const settingsPage = allPages.find(
198
+ (page) => page.code === 'workflow_settings',
209
199
  );
210
- if (nodeTemplatePage && connectionsPage) {
211
- const authorRoles = await prisma.rolePages.findMany({
212
- where: { pageId: nodeTemplatePage.id },
213
- select: { roleId: true },
214
- });
215
- const connectionResourceIds = await getPageResourceIds(
200
+ if (settingsPage) {
201
+ const settingsResourceIds = await getPageResourceIds(
216
202
  prisma,
217
- connectionsPage.id,
203
+ settingsPage.id,
218
204
  );
219
- for (const { roleId } of authorRoles) {
220
- await grantRoleAccess(
221
- prisma,
222
- roleId,
223
- connectionsPage.id,
224
- connectionResourceIds,
225
- );
226
- console.log(
227
- `[workflow] Grant: role#${roleId} → workflow_connections (inherits Node Template access)`,
228
- );
205
+ const legacyPages = await prisma.page.findMany({
206
+ where: {
207
+ code: {
208
+ in: [
209
+ 'workflow_temporal_tenant',
210
+ 'workflow_connections',
211
+ 'workflow_event_catalog',
212
+ 'person_directory',
213
+ 'people_group',
214
+ ],
215
+ },
216
+ },
217
+ select: { id: true, code: true },
218
+ });
219
+ for (const legacy of legacyPages) {
220
+ const grants = await prisma.rolePages.findMany({
221
+ where: { pageId: legacy.id },
222
+ select: { roleId: true },
223
+ });
224
+ for (const { roleId } of grants) {
225
+ await grantRoleAccess(
226
+ prisma,
227
+ roleId,
228
+ settingsPage.id,
229
+ settingsResourceIds,
230
+ );
231
+ console.log(
232
+ `[workflow] Migrate: role#${roleId} ${legacy.code} → workflow_settings`,
233
+ );
234
+ }
235
+ await prisma.rolePages.deleteMany({ where: { pageId: legacy.id } });
236
+ await prisma.pageResource.deleteMany({ where: { pageId: legacy.id } });
237
+ await prisma.page.delete({ where: { id: legacy.id } });
238
+ console.log(`[workflow] Migrate: removed legacy page ${legacy.code}`);
229
239
  }
230
240
  }
231
241
 
@@ -36,12 +36,6 @@ export type CronScheduleStatus =
36
36
  | 'MISSING'
37
37
  | 'UNAVAILABLE';
38
38
 
39
- export interface WorkflowRetryCheckpoint {
40
- startNodeId: string;
41
- completedNodeIds: string[];
42
- outputs: Record<string, unknown>;
43
- }
44
-
45
39
  @Injectable()
46
40
  export class TemporalService implements OnModuleInit, OnModuleDestroy {
47
41
  private connection: Connection;
@@ -82,7 +76,6 @@ export class TemporalService implements OnModuleInit, OnModuleDestroy {
82
76
  versionId: number;
83
77
  dsl: any;
84
78
  context: Record<string, any>;
85
- retryCheckpoint?: WorkflowRetryCheckpoint;
86
79
  }): Promise<{ temporalRunId: string }> {
87
80
  if (!this.client) {
88
81
  throw new Error('Temporal client not connected');
@@ -167,6 +167,23 @@ describe('Workflow cancellation', () => {
167
167
  expect(temporal.cancelWorkflow).not.toHaveBeenCalled();
168
168
  });
169
169
 
170
+ it('allows cancellation while the Workflow is waiting for a node retry', async () => {
171
+ const { runtime, tx } = setup({ status: 'WAITING_RETRY' });
172
+
173
+ await expect(
174
+ runtime.cancelExecution({
175
+ tx: tx as any,
176
+ instanceId: BigInt(11),
177
+ requestedBy: 'admin-user',
178
+ }),
179
+ ).resolves.toEqual({ instanceId: '11', status: 'CANCELLED' });
180
+ expect(tx.workflowInstance.update).toHaveBeenCalledWith(
181
+ expect.objectContaining({
182
+ data: expect.objectContaining({ status: 'CANCELLED' }),
183
+ }),
184
+ );
185
+ });
186
+
170
187
  it('rejects cancellation after a blocking approval has resolved', async () => {
171
188
  const { runtime, temporal, tx } = setup({
172
189
  cancellationBlockedAt: new Date('2026-08-20T01:00:00.000Z'),
@@ -949,7 +949,7 @@ describe('WorkflowCompilerService', () => {
949
949
  ).resolves.toBeUndefined();
950
950
  });
951
951
 
952
- it('rejects legacy raw SQL database configuration', async () => {
952
+ it('accepts a safe Raw SQL database configuration', async () => {
953
953
  const prisma = {
954
954
  workflowNodeType: {
955
955
  findMany: jest.fn().mockResolvedValue([
@@ -982,7 +982,62 @@ describe('WorkflowCompilerService', () => {
982
982
  type: 'db_execute',
983
983
  config: {
984
984
  allowWrite: true,
985
- sql: 'UPDATE users SET active = false',
985
+ sql: 'UPDATE users SET active = false WHERE id = $1',
986
+ parameters: [{ source: 'INPUT', path: 'id' }],
987
+ },
988
+ },
989
+ ],
990
+ edges: [],
991
+ },
992
+ { userId: 'author' },
993
+ ),
994
+ ).resolves.toMatchObject({
995
+ nodes: [
996
+ expect.objectContaining({
997
+ id: 'database-1',
998
+ config: expect.objectContaining({
999
+ sql: 'UPDATE users SET active = false WHERE id = $1',
1000
+ }),
1001
+ }),
1002
+ ],
1003
+ });
1004
+ });
1005
+
1006
+ it('rejects unsafe Raw SQL database configuration', async () => {
1007
+ const prisma = {
1008
+ workflowNodeType: {
1009
+ findMany: jest.fn().mockResolvedValue([
1010
+ {
1011
+ id: BigInt(10),
1012
+ type: 'db_execute',
1013
+ runtimeKey: 'db_execute',
1014
+ instancePolicy: 'INLINE_ONLY',
1015
+ schemaVersion: 6,
1016
+ configSchema: { type: 'object' },
1017
+ uiSchema: {},
1018
+ },
1019
+ ]),
1020
+ },
1021
+ workflowNodeInstance: { findMany: jest.fn().mockResolvedValue([]) },
1022
+ };
1023
+ const service = new WorkflowCompilerService(
1024
+ prisma as any,
1025
+ { validateOrThrow: jest.fn() } as any,
1026
+ { assertUiSchemaSupported: jest.fn() } as any,
1027
+ { validateSubjects: jest.fn() } as any,
1028
+ );
1029
+
1030
+ await expect(
1031
+ service.compile(
1032
+ {
1033
+ nodes: [
1034
+ {
1035
+ id: 'database-1',
1036
+ type: 'db_execute',
1037
+ config: {
1038
+ mode: 'SQL',
1039
+ allowWrite: true,
1040
+ sql: 'DROP TABLE users',
986
1041
  },
987
1042
  },
988
1043
  ],
@@ -992,7 +1047,7 @@ describe('WorkflowCompilerService', () => {
992
1047
  ),
993
1048
  ).rejects.toMatchObject({
994
1049
  response: {
995
- message: '数据库节点不再支持 SQL,请选择 workflow_api 中的 Function',
1050
+ message: '数据库操作只允许单条查询、新增、更新或删除语句',
996
1051
  nodeId: 'database-1',
997
1052
  },
998
1053
  });
@@ -240,12 +240,27 @@ export class WorkflowCompilerService {
240
240
  }
241
241
  if (nodeType.runtimeKey === 'db_execute') {
242
242
  const databaseConfig = config as Record<string, unknown>;
243
- if ('sql' in databaseConfig || 'allowWrite' in databaseConfig)
244
- this.fail(
245
- 'NODE_TYPE_MISMATCH',
246
- node,
247
- '数据库节点不再支持 SQL,请选择 workflow_api 中的 Function',
248
- );
243
+ const databaseMode = databaseExecutionMode(databaseConfig);
244
+ if (!databaseMode)
245
+ this.fail('NODE_TYPE_MISMATCH', node, '数据库节点的执行方式无效');
246
+ if (databaseMode === 'SQL') {
247
+ const allowWrite = databaseConfig.allowWrite === true;
248
+ const sql = String(databaseConfig.sql ?? '');
249
+ if (!isDatabaseSqlAllowed(sql, allowWrite))
250
+ this.fail(
251
+ 'NODE_TYPE_MISMATCH',
252
+ node,
253
+ allowWrite
254
+ ? '数据库操作只允许单条查询、新增、更新或删除语句'
255
+ : '当前数据库操作节点只允许单条只读查询',
256
+ );
257
+ compiledNodes.push({
258
+ ...clone(node),
259
+ config,
260
+ runtimeKey: nodeType.runtimeKey || nodeType.type,
261
+ });
262
+ continue;
263
+ }
249
264
  const functionRef = asObject(databaseConfig.functionRef);
250
265
  const argumentBindings = Array.isArray(databaseConfig.argumentBindings)
251
266
  ? databaseConfig.argumentBindings
@@ -503,6 +518,51 @@ function clone<T>(value: T): T {
503
518
  return JSON.parse(JSON.stringify(value)) as T;
504
519
  }
505
520
 
521
+ function databaseExecutionMode(
522
+ config: Record<string, unknown>,
523
+ ): 'FUNCTION' | 'SQL' | null {
524
+ if (config.mode === 'FUNCTION' || config.mode === 'SQL') return config.mode;
525
+ if (config.mode !== undefined) return null;
526
+ return 'sql' in config || 'allowWrite' in config || 'parameters' in config
527
+ ? 'SQL'
528
+ : 'FUNCTION';
529
+ }
530
+
531
+ function isSingleReadOnlySql(sql: string): boolean {
532
+ const normalized = normalizeSqlForPolicy(sql);
533
+ if (!/^(select|with)\b/i.test(normalized)) return false;
534
+ return !/\b(insert|update|delete|merge|call|copy|create|alter|drop|truncate|grant|revoke|vacuum|refresh)\b/i.test(
535
+ normalized,
536
+ );
537
+ }
538
+
539
+ function isDatabaseSqlAllowed(sql: string, allowWrite: boolean): boolean {
540
+ const normalized = normalizeSqlForPolicy(sql);
541
+ const writableCte =
542
+ /^with\b/i.test(normalized) &&
543
+ /\b(insert|update|delete)\b/i.test(normalized) &&
544
+ !/\b(merge|call|copy|create|alter|drop|truncate|grant|revoke|vacuum|refresh)\b/i.test(
545
+ normalized,
546
+ );
547
+ return (
548
+ isSingleReadOnlySql(sql) ||
549
+ (allowWrite &&
550
+ (/^(insert|update|delete)\b/i.test(normalized) || writableCte))
551
+ );
552
+ }
553
+
554
+ function normalizeSqlForPolicy(sql: string): string {
555
+ const withoutContent = sql
556
+ .replace(/\$(\w*)\$[\s\S]*?\$\1\$/g, ' ')
557
+ .replace(/'(?:''|[^'])*'/g, ' ')
558
+ .replace(/"(?:""|[^"])*"/g, ' ')
559
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
560
+ .replace(/--[^\r\n]*/g, ' ')
561
+ .trim();
562
+ const withoutTrailingSemicolon = withoutContent.replace(/;\s*$/, '').trim();
563
+ return withoutTrailingSemicolon.includes(';') ? '' : withoutTrailingSemicolon;
564
+ }
565
+
506
566
  function asObject(value: unknown): Record<string, unknown> {
507
567
  return value && typeof value === 'object' && !Array.isArray(value)
508
568
  ? (value as Record<string, unknown>)