@gadmin2n/schematics 0.0.135 → 0.0.137

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 (95) hide show
  1. package/dist/lib/application/files/gadmin2-game-angle-demo/Dockerfile.server +2 -2
  2. package/dist/lib/application/files/gadmin2-game-angle-demo/compose-ctl.sh +1 -1
  3. package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/canvas.prisma +1 -0
  4. package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/workflow.prisma +14 -3
  5. package/dist/lib/application/files/gadmin2-game-angle-demo/config/ui/User.ts +4 -4
  6. package/dist/lib/application/files/gadmin2-game-angle-demo/docker-compose.md +2 -2
  7. package/dist/lib/application/files/gadmin2-game-angle-demo/docker-compose.yml +13 -92
  8. package/dist/lib/application/files/gadmin2-game-angle-demo/readme.md +105 -7
  9. package/dist/lib/application/files/gadmin2-game-angle-demo/server/ensure-database.js +1 -1
  10. package/dist/lib/application/files/gadmin2-game-angle-demo/server/package.json +7 -6
  11. package/dist/lib/application/files/gadmin2-game-angle-demo/server/prisma.config.ts +9 -1
  12. package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/gen-jwt.ts +85 -0
  13. package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/notify-dev-servers.js +9 -8
  14. package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/wait-for-nest-ready.js +5 -5
  15. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts +43 -2
  16. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow.seed.ts +9 -1
  17. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflows.ts +24 -0
  18. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas-export.dto.ts +27 -0
  19. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas-export.service.spec.ts +430 -0
  20. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas-export.service.ts +203 -0
  21. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas.controller.ts +36 -3
  22. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas.module.ts +2 -1
  23. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/temporalTenantConfig/temporalTenantConfig.controller.spec.ts +54 -0
  24. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/temporalTenantConfig/temporalTenantConfig.controller.ts +245 -0
  25. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/temporalTenantConfig/temporalTenantConfig.module.ts +10 -0
  26. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/temporalTenantConfig/temporalTenantConfig.service.spec.ts +366 -0
  27. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/temporalTenantConfig/temporalTenantConfig.service.ts +110 -0
  28. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-execution.dto.ts +19 -1
  29. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.ts +15 -0
  30. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.spec.ts +79 -0
  31. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +41 -19
  32. package/dist/lib/application/files/gadmin2-game-angle-demo/server/start-prod.sh +5 -5
  33. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/README.md +37 -573
  34. package/dist/lib/application/files/gadmin2-game-angle-demo/web/ecosystem.dev.config.cjs +12 -1
  35. package/dist/lib/application/files/gadmin2-game-angle-demo/web/package.json +1 -0
  36. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/layout/header.tsx +3 -2
  37. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/config/routeRegistry.tsx +39 -26
  38. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/i18n.ts +3 -1
  39. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/common.json +3 -0
  40. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/common.json +3 -0
  41. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasEditPage.tsx +23 -5
  42. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasListPage.tsx +66 -10
  43. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasPage.tsx +16 -7
  44. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasToolbar.tsx +21 -0
  45. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/canvasApi.ts +30 -0
  46. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/components/ExportModal.tsx +84 -0
  47. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/components/ImportModal.tsx +166 -0
  48. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/hooks/useCanvasHistory.ts +52 -13
  49. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/DslView.tsx +6 -0
  50. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodePropertyPanel.tsx +6 -1
  51. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/editor.tsx +17 -0
  52. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.tsx +18 -6
  53. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/temporal-tenant/index.tsx +225 -0
  54. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/utilities/lazy-with-reload.ts +31 -0
  55. package/package.json +1 -1
  56. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-dsl-validate.spec.ts +0 -220
  57. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-dsl-validate.ts +0 -132
  58. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/TODO.md +0 -152
  59. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/config/development-sql.yaml +0 -5
  60. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/docker-compose.yml +0 -25
  61. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/package.json +0 -13
  62. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/.dockerignore +0 -12
  63. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/.env +0 -7
  64. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/Dockerfile +0 -79
  65. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/GRACEFUL-DEPLOYMENT.md +0 -270
  66. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/SANDBOX.md +0 -122
  67. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/package-lock.json +0 -4285
  68. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/package.json +0 -28
  69. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/activities/code-execute.ts +0 -51
  70. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/activities/db-execute.ts +0 -85
  71. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/activities/db-query.ts +0 -35
  72. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/activities/http-request.ts +0 -54
  73. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/activities/index.ts +0 -6
  74. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/activities/reporting.ts +0 -85
  75. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/activities/send-notification.ts +0 -47
  76. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/config.ts +0 -13
  77. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/dsl/condition.ts +0 -101
  78. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/dsl/context.ts +0 -58
  79. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/dsl/graph.ts +0 -184
  80. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/dsl/helpers.ts +0 -133
  81. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/dsl/node-types.ts +0 -96
  82. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/dsl/types.ts +0 -77
  83. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/dsl/validate.ts +0 -109
  84. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/index.ts +0 -101
  85. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/outbox-poller.ts +0 -382
  86. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/tests/activities/code-execute.test.ts +0 -44
  87. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/tests/activities/http-request.test.ts +0 -87
  88. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/tests/cron-trigger-workflow.test.ts +0 -20
  89. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/tests/helpers.test.ts +0 -225
  90. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/tests/node-type-consistency.test.ts +0 -101
  91. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/tests/validate.test.ts +0 -205
  92. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/workflows/dsl-workflow.ts +0 -499
  93. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/tsconfig.json +0 -19
  94. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/vitest.config.ts +0 -8
  95. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/yarn.lock +0 -1905
@@ -0,0 +1,430 @@
1
+ import { NotFoundException } from '@nestjs/common';
2
+ import { Test, TestingModule } from '@nestjs/testing';
3
+ import { PrismaService } from 'nestjs-prisma';
4
+ import { CanvasExportService } from './canvas-export.service';
5
+
6
+ describe('CanvasExportService', () => {
7
+ let service: CanvasExportService;
8
+ let prisma: any;
9
+
10
+ beforeEach(async () => {
11
+ prisma = {
12
+ canvas: {
13
+ findUnique: jest.fn(),
14
+ findFirst: jest.fn(),
15
+ upsert: jest.fn(),
16
+ },
17
+ savedQuery: {
18
+ findMany: jest.fn(),
19
+ findFirst: jest.fn(),
20
+ create: jest.fn(),
21
+ },
22
+ page: {
23
+ findFirst: jest.fn(),
24
+ delete: jest.fn(),
25
+ },
26
+ rolePages: {
27
+ deleteMany: jest.fn(),
28
+ },
29
+ $transaction: jest.fn(async (cb: any) =>
30
+ cb({
31
+ canvas: prisma.canvas,
32
+ savedQuery: prisma.savedQuery,
33
+ page: prisma.page,
34
+ rolePages: prisma.rolePages,
35
+ }),
36
+ ),
37
+ };
38
+
39
+ const module: TestingModule = await Test.createTestingModule({
40
+ providers: [
41
+ CanvasExportService,
42
+ { provide: PrismaService, useValue: prisma },
43
+ ],
44
+ }).compile();
45
+
46
+ service = module.get<CanvasExportService>(CanvasExportService);
47
+ });
48
+
49
+ describe('generateExportToken', () => {
50
+ it('throws NotFoundException when canvas does not exist', async () => {
51
+ prisma.canvas.findUnique.mockResolvedValueOnce(null);
52
+
53
+ await expect(
54
+ service.generateExportToken('nonexistent-id', 'https://example.com'),
55
+ ).rejects.toThrow(NotFoundException);
56
+ });
57
+
58
+ it('returns a URL containing the canvas code and a token', async () => {
59
+ prisma.canvas.findUnique.mockResolvedValueOnce({ code: 'cv_abc123' });
60
+
61
+ const result = await service.generateExportToken(
62
+ 'canvas-id-1',
63
+ 'https://example.com/api',
64
+ );
65
+
66
+ expect(result.url).toContain(
67
+ 'https://example.com/api/canvas/export/cv_abc123?token=',
68
+ );
69
+ expect(prisma.canvas.findUnique).toHaveBeenCalledWith({
70
+ where: { id: 'canvas-id-1' },
71
+ select: { code: true },
72
+ });
73
+ });
74
+ });
75
+
76
+ describe('getExportPayload', () => {
77
+ const CODE = 'cv_abc123';
78
+ let validToken: string;
79
+
80
+ beforeEach(() => {
81
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
82
+ const jwt = require('jsonwebtoken');
83
+ validToken = jwt.sign(
84
+ { canvasCode: CODE },
85
+ process.env.TAIHU_ODC_APP_TOKEN || 'gadmin-default-secret',
86
+ { expiresIn: 1800 },
87
+ );
88
+ });
89
+
90
+ it('throws UnauthorizedException for invalid token', async () => {
91
+ await expect(
92
+ service.getExportPayload(CODE, 'garbage-token'),
93
+ ).rejects.toThrow('Export token is invalid or expired');
94
+ });
95
+
96
+ it('throws UnauthorizedException when token code mismatches', async () => {
97
+ await expect(
98
+ service.getExportPayload('different-code', validToken),
99
+ ).rejects.toThrow('Token does not match this canvas');
100
+ });
101
+
102
+ it('throws NotFoundException when canvas does not exist', async () => {
103
+ prisma.canvas.findUnique.mockResolvedValueOnce(null);
104
+
105
+ await expect(service.getExportPayload(CODE, validToken)).rejects.toThrow(
106
+ 'Canvas not found',
107
+ );
108
+ });
109
+
110
+ it('extracts referenced SavedQuery ids from item code and includes them in payload', async () => {
111
+ prisma.canvas.findUnique.mockResolvedValueOnce({
112
+ code: CODE,
113
+ name: 'My Dashboard',
114
+ items: [
115
+ {
116
+ id: 'item1',
117
+ componentType: 'Table',
118
+ code: "fetch('/api/canvas/saved-query/42/run')",
119
+ },
120
+ {
121
+ id: 'item2',
122
+ componentType: 'BarChart',
123
+ code: "fetch('/api/canvas/saved-query/7/run')",
124
+ },
125
+ ],
126
+ layout: [{ i: 'item1', x: 0, y: 0, w: 4, h: 4 }],
127
+ });
128
+ prisma.savedQuery.findMany.mockResolvedValueOnce([
129
+ { id: 42, sql: 'SELECT * FROM t_a', description: 'desc a' },
130
+ { id: 7, sql: 'SELECT * FROM t_b', description: 'desc b' },
131
+ ]);
132
+
133
+ const result = await service.getExportPayload(CODE, validToken);
134
+
135
+ expect(prisma.savedQuery.findMany).toHaveBeenCalledWith({
136
+ where: { id: { in: [42, 7] } },
137
+ });
138
+ expect(result.canvas.code).toBe(CODE);
139
+ expect(result.canvas.name).toBe('My Dashboard');
140
+ expect(result.savedQueries).toEqual([
141
+ { sourceId: '42', sql: 'SELECT * FROM t_a', description: 'desc a' },
142
+ { sourceId: '7', sql: 'SELECT * FROM t_b', description: 'desc b' },
143
+ ]);
144
+ });
145
+
146
+ it('returns empty savedQueries when no items reference saved-query', async () => {
147
+ prisma.canvas.findUnique.mockResolvedValueOnce({
148
+ code: CODE,
149
+ name: 'Empty Dashboard',
150
+ items: [
151
+ { id: 'item1', componentType: 'NumCard', code: '<div>static</div>' },
152
+ ],
153
+ layout: [],
154
+ });
155
+
156
+ const result = await service.getExportPayload(CODE, validToken);
157
+
158
+ expect(prisma.savedQuery.findMany).not.toHaveBeenCalled();
159
+ expect(result.savedQueries).toEqual([]);
160
+ });
161
+ });
162
+
163
+ describe('importPreview', () => {
164
+ it('throws BadRequestException when payload has no canvas code', async () => {
165
+ await expect(
166
+ service.importPreview({
167
+ version: 1,
168
+ exportedAt: '',
169
+ canvas: {} as any,
170
+ savedQueries: [],
171
+ }),
172
+ ).rejects.toThrow('Invalid payload: missing canvas code');
173
+ });
174
+
175
+ it('returns current=null when no existing canvas matches the code', async () => {
176
+ prisma.canvas.findFirst.mockResolvedValueOnce(null);
177
+
178
+ const payload = {
179
+ version: 1,
180
+ exportedAt: '2026-01-01T00:00:00.000Z',
181
+ canvas: { code: 'cv_new', name: 'New', items: [], layout: [] },
182
+ savedQueries: [],
183
+ };
184
+
185
+ const result = await service.importPreview(payload);
186
+
187
+ expect(result.current).toBeNull();
188
+ expect(result.incoming).toEqual(payload);
189
+ });
190
+
191
+ it('returns current with existing canvas data when code matches', async () => {
192
+ prisma.canvas.findFirst.mockResolvedValueOnce({
193
+ code: 'cv_existing',
194
+ name: 'Old Name',
195
+ items: [{ id: 'x', componentType: 'Table', code: 'old' }],
196
+ layout: [{ i: 'x', x: 0, y: 0, w: 1, h: 1 }],
197
+ });
198
+
199
+ const payload = {
200
+ version: 1,
201
+ exportedAt: '2026-01-01T00:00:00.000Z',
202
+ canvas: {
203
+ code: 'cv_existing',
204
+ name: 'New Name',
205
+ items: [],
206
+ layout: [],
207
+ },
208
+ savedQueries: [],
209
+ };
210
+
211
+ const result = await service.importPreview(payload);
212
+
213
+ expect(result.current).toEqual({
214
+ canvas: {
215
+ code: 'cv_existing',
216
+ name: 'Old Name',
217
+ items: [{ id: 'x', componentType: 'Table', code: 'old' }],
218
+ layout: [{ i: 'x', x: 0, y: 0, w: 1, h: 1 }],
219
+ },
220
+ });
221
+ expect(result.incoming).toEqual(payload);
222
+ });
223
+ });
224
+
225
+ describe('importConfirm', () => {
226
+ it('throws BadRequestException when payload has no canvas code', async () => {
227
+ await expect(
228
+ service.importConfirm(
229
+ { version: 1, exportedAt: '', canvas: {} as any, savedQueries: [] },
230
+ 'user-1',
231
+ ),
232
+ ).rejects.toThrow('Invalid payload: missing canvas code');
233
+ });
234
+
235
+ it('creates a new canvas owned by the importing user when code does not exist', async () => {
236
+ prisma.canvas.findFirst.mockResolvedValueOnce(null); // 检查是否已存在(发布清理判断)
237
+ prisma.canvas.upsert.mockResolvedValueOnce({
238
+ id: 'new-canvas-id',
239
+ name: 'Imported Dashboard',
240
+ });
241
+
242
+ const payload = {
243
+ version: 1,
244
+ exportedAt: '2026-01-01T00:00:00.000Z',
245
+ canvas: {
246
+ code: 'cv_new',
247
+ name: 'Imported Dashboard',
248
+ items: [
249
+ { id: 'x', componentType: 'NumCard', code: '<div>static</div>' },
250
+ ],
251
+ layout: [],
252
+ },
253
+ savedQueries: [],
254
+ };
255
+
256
+ const result = await service.importConfirm(payload, 'user-1');
257
+
258
+ expect(prisma.canvas.upsert).toHaveBeenCalledWith({
259
+ where: { code: 'cv_new' },
260
+ create: {
261
+ code: 'cv_new',
262
+ name: 'Imported Dashboard',
263
+ userId: 'user-1',
264
+ items: payload.canvas.items,
265
+ layout: [],
266
+ },
267
+ update: {
268
+ name: 'Imported Dashboard',
269
+ items: payload.canvas.items,
270
+ layout: [],
271
+ },
272
+ });
273
+ expect(result).toEqual({
274
+ success: true,
275
+ canvasId: 'new-canvas-id',
276
+ canvasName: 'Imported Dashboard',
277
+ });
278
+ });
279
+
280
+ it('reuses existing SavedQuery id when sql text matches, and rewrites item code', async () => {
281
+ prisma.canvas.findFirst.mockResolvedValueOnce(null);
282
+ prisma.savedQuery.findFirst.mockResolvedValueOnce({
283
+ id: 99,
284
+ sql: 'SELECT 1',
285
+ description: 'd',
286
+ });
287
+ prisma.canvas.upsert.mockResolvedValueOnce({ id: 'cid', name: 'N' });
288
+
289
+ const payload = {
290
+ version: 1,
291
+ exportedAt: '',
292
+ canvas: {
293
+ code: 'cv_x',
294
+ name: 'N',
295
+ items: [
296
+ {
297
+ id: 'i1',
298
+ componentType: 'Table',
299
+ code: "fetch('/api/canvas/saved-query/42/run')",
300
+ },
301
+ ],
302
+ layout: [],
303
+ },
304
+ savedQueries: [{ sourceId: '42', sql: 'SELECT 1', description: 'd' }],
305
+ };
306
+
307
+ await service.importConfirm(payload, 'user-1');
308
+
309
+ expect(prisma.savedQuery.findFirst).toHaveBeenCalledWith({
310
+ where: { sql: 'SELECT 1' },
311
+ });
312
+ expect(prisma.savedQuery.create).not.toHaveBeenCalled();
313
+ expect(prisma.canvas.upsert).toHaveBeenCalledWith(
314
+ expect.objectContaining({
315
+ create: expect.objectContaining({
316
+ items: [
317
+ {
318
+ id: 'i1',
319
+ componentType: 'Table',
320
+ code: "fetch('/api/canvas/saved-query/99/run')",
321
+ },
322
+ ],
323
+ }),
324
+ }),
325
+ );
326
+ });
327
+
328
+ it('creates a new SavedQuery when no matching sql exists in target env', async () => {
329
+ prisma.canvas.findFirst.mockResolvedValueOnce(null);
330
+ prisma.savedQuery.findFirst.mockResolvedValueOnce(null);
331
+ prisma.savedQuery.create.mockResolvedValueOnce({
332
+ id: 100,
333
+ sql: 'SELECT 2',
334
+ description: 'd2',
335
+ });
336
+ prisma.canvas.upsert.mockResolvedValueOnce({ id: 'cid', name: 'N' });
337
+
338
+ const payload = {
339
+ version: 1,
340
+ exportedAt: '',
341
+ canvas: {
342
+ code: 'cv_y',
343
+ name: 'N',
344
+ items: [
345
+ {
346
+ id: 'i1',
347
+ componentType: 'Table',
348
+ code: "fetch('/api/canvas/saved-query/7/run')",
349
+ },
350
+ ],
351
+ layout: [],
352
+ },
353
+ savedQueries: [{ sourceId: '7', sql: 'SELECT 2', description: 'd2' }],
354
+ };
355
+
356
+ await service.importConfirm(payload, 'user-1');
357
+
358
+ expect(prisma.savedQuery.create).toHaveBeenCalledWith({
359
+ data: { sql: 'SELECT 2', description: 'd2' },
360
+ });
361
+ expect(prisma.canvas.upsert).toHaveBeenCalledWith(
362
+ expect.objectContaining({
363
+ create: expect.objectContaining({
364
+ items: [
365
+ {
366
+ id: 'i1',
367
+ componentType: 'Table',
368
+ code: "fetch('/api/canvas/saved-query/100/run')",
369
+ },
370
+ ],
371
+ }),
372
+ }),
373
+ );
374
+ });
375
+
376
+ it('cleans up Page and RolePages when overwriting an already-published canvas', async () => {
377
+ prisma.canvas.findFirst.mockResolvedValueOnce({
378
+ id: 'existing-id',
379
+ code: 'cv_pub',
380
+ publishedPageCode: 'my-page-slug',
381
+ });
382
+ prisma.page.findFirst.mockResolvedValueOnce({ id: 5 });
383
+ prisma.canvas.upsert.mockResolvedValueOnce({
384
+ id: 'existing-id',
385
+ name: 'N',
386
+ });
387
+
388
+ const payload = {
389
+ version: 1,
390
+ exportedAt: '',
391
+ canvas: { code: 'cv_pub', name: 'N', items: [], layout: [] },
392
+ savedQueries: [],
393
+ };
394
+
395
+ await service.importConfirm(payload, 'user-1');
396
+
397
+ expect(prisma.page.findFirst).toHaveBeenCalledWith({
398
+ where: { code: 'my-page-slug' },
399
+ });
400
+ expect(prisma.rolePages.deleteMany).toHaveBeenCalledWith({
401
+ where: { pageId: 5 },
402
+ });
403
+ expect(prisma.page.delete).toHaveBeenCalledWith({ where: { id: 5 } });
404
+ });
405
+
406
+ it('does not touch Page/RolePages when overwriting a canvas that is not published', async () => {
407
+ prisma.canvas.findFirst.mockResolvedValueOnce({
408
+ id: 'existing-id',
409
+ code: 'cv_unpub',
410
+ publishedPageCode: null,
411
+ });
412
+ prisma.canvas.upsert.mockResolvedValueOnce({
413
+ id: 'existing-id',
414
+ name: 'N',
415
+ });
416
+
417
+ const payload = {
418
+ version: 1,
419
+ exportedAt: '',
420
+ canvas: { code: 'cv_unpub', name: 'N', items: [], layout: [] },
421
+ savedQueries: [],
422
+ };
423
+
424
+ await service.importConfirm(payload, 'user-1');
425
+
426
+ expect(prisma.page.findFirst).not.toHaveBeenCalled();
427
+ expect(prisma.rolePages.deleteMany).not.toHaveBeenCalled();
428
+ });
429
+ });
430
+ });
@@ -0,0 +1,203 @@
1
+ import {
2
+ BadRequestException,
3
+ Injectable,
4
+ NotFoundException,
5
+ UnauthorizedException,
6
+ } from '@nestjs/common';
7
+ import * as jwt from 'jsonwebtoken';
8
+ import { PrismaService } from 'nestjs-prisma';
9
+ import { ExportPayload, ImportPreviewResponse } from './canvas-export.dto';
10
+
11
+ const EXPORT_TOKEN_SECRET =
12
+ process.env.TAIHU_ODC_APP_TOKEN || 'gadmin-default-secret';
13
+ const EXPORT_TOKEN_EXPIRY = 30 * 60; // 30 minutes in seconds
14
+
15
+ // 匹配 Canvas Item 代码里硬编码的 saved-query 调用,如 fetch('/api/canvas/saved-query/42/run')
16
+ const SAVED_QUERY_ID_PATTERN = /\/canvas\/saved-query\/(\d+)\/run/g;
17
+
18
+ interface ExportTokenPayload {
19
+ canvasCode: string;
20
+ }
21
+
22
+ @Injectable()
23
+ export class CanvasExportService {
24
+ constructor(private prisma: PrismaService) {}
25
+
26
+ /** Generate a JWT export token for a canvas */
27
+ async generateExportToken(
28
+ canvasId: string,
29
+ baseUrl: string,
30
+ ): Promise<{ url: string }> {
31
+ const canvas = await this.prisma.canvas.findUnique({
32
+ where: { id: canvasId },
33
+ select: { code: true },
34
+ });
35
+ if (!canvas) throw new NotFoundException('Canvas not found');
36
+
37
+ const token = jwt.sign(
38
+ { canvasCode: canvas.code } as ExportTokenPayload,
39
+ EXPORT_TOKEN_SECRET,
40
+ { expiresIn: EXPORT_TOKEN_EXPIRY },
41
+ );
42
+
43
+ const url = `${baseUrl}/canvas/export/${canvas.code}?token=${token}`;
44
+ return { url };
45
+ }
46
+
47
+ /** 从 canvas items 的 code 字符串里提取所有引用到的 SavedQuery id */
48
+ private extractSavedQueryIds(items: any[]): number[] {
49
+ const ids = new Set<number>();
50
+ for (const item of items ?? []) {
51
+ const code = item?.code ?? '';
52
+ let match: RegExpExecArray | null;
53
+ const pattern = new RegExp(SAVED_QUERY_ID_PATTERN);
54
+ while ((match = pattern.exec(code)) !== null) {
55
+ ids.add(Number(match[1]));
56
+ }
57
+ }
58
+ return Array.from(ids);
59
+ }
60
+
61
+ /** Verify export token and return the full export payload */
62
+ async getExportPayload(code: string, token: string): Promise<ExportPayload> {
63
+ let decoded: ExportTokenPayload;
64
+ try {
65
+ decoded = jwt.verify(token, EXPORT_TOKEN_SECRET) as ExportTokenPayload;
66
+ } catch {
67
+ throw new UnauthorizedException('Export token is invalid or expired');
68
+ }
69
+
70
+ if (decoded.canvasCode !== code) {
71
+ throw new UnauthorizedException('Token does not match this canvas');
72
+ }
73
+
74
+ const canvas = await this.prisma.canvas.findUnique({
75
+ where: { code },
76
+ });
77
+ if (!canvas) throw new NotFoundException('Canvas not found');
78
+
79
+ const items = (canvas.items as any[]) ?? [];
80
+ const savedQueryIds = this.extractSavedQueryIds(items);
81
+
82
+ const savedQueries =
83
+ savedQueryIds.length > 0
84
+ ? await this.prisma.savedQuery.findMany({
85
+ where: { id: { in: savedQueryIds } },
86
+ })
87
+ : [];
88
+
89
+ return {
90
+ version: 1,
91
+ exportedAt: new Date().toISOString(),
92
+ canvas: {
93
+ code: canvas.code,
94
+ name: canvas.name,
95
+ items,
96
+ layout: (canvas.layout as any[]) ?? [],
97
+ },
98
+ savedQueries: savedQueries.map((sq) => ({
99
+ sourceId: String(sq.id),
100
+ sql: sq.sql,
101
+ description: sq.description,
102
+ })),
103
+ };
104
+ }
105
+
106
+ /** Preview import — return current state vs incoming for diff */
107
+ async importPreview(payload: ExportPayload): Promise<ImportPreviewResponse> {
108
+ if (!payload?.canvas?.code) {
109
+ throw new BadRequestException('Invalid payload: missing canvas code');
110
+ }
111
+
112
+ const existing = await this.prisma.canvas.findFirst({
113
+ where: { code: payload.canvas.code },
114
+ });
115
+
116
+ const current: Partial<ExportPayload> | null = existing
117
+ ? {
118
+ canvas: {
119
+ code: existing.code,
120
+ name: existing.name,
121
+ items: (existing.items as any[]) ?? [],
122
+ layout: (existing.layout as any[]) ?? [],
123
+ },
124
+ }
125
+ : null;
126
+
127
+ return { current, incoming: payload };
128
+ }
129
+
130
+ /** Confirm import — execute upsert with SavedQuery id remapping in a transaction */
131
+ async importConfirm(
132
+ payload: ExportPayload,
133
+ userId: string,
134
+ ): Promise<{ success: boolean; canvasId: string; canvasName: string }> {
135
+ if (!payload?.canvas?.code) {
136
+ throw new BadRequestException('Invalid payload: missing canvas code');
137
+ }
138
+
139
+ return this.prisma.$transaction(async (tx) => {
140
+ // 1. 若目标环境已存在同 code 的已发布 Canvas,先清理旧的 Page/RolePages
141
+ const existing = await tx.canvas.findFirst({
142
+ where: { code: payload.canvas.code },
143
+ });
144
+ if (existing?.publishedPageCode) {
145
+ const page = await tx.page.findFirst({
146
+ where: { code: existing.publishedPageCode },
147
+ });
148
+ if (page) {
149
+ await tx.rolePages.deleteMany({ where: { pageId: page.id } });
150
+ await tx.page.delete({ where: { id: page.id } });
151
+ }
152
+ }
153
+
154
+ // 2. 按 sql 文本 upsert SavedQuery,建立 sourceId → newId 映射
155
+ const idMap = new Map<string, string>();
156
+ for (const sq of payload.savedQueries) {
157
+ let record = await tx.savedQuery.findFirst({ where: { sql: sq.sql } });
158
+ if (!record) {
159
+ record = await tx.savedQuery.create({
160
+ data: { sql: sq.sql, description: sq.description },
161
+ });
162
+ }
163
+ idMap.set(sq.sourceId, String(record.id));
164
+ }
165
+
166
+ // 3. 深拷贝 items,重写引用到的 saved-query id
167
+ const items = JSON.parse(JSON.stringify(payload.canvas.items ?? []));
168
+ for (const item of items) {
169
+ if (typeof item.code !== 'string') continue;
170
+ item.code = item.code.replace(
171
+ /\/canvas\/saved-query\/(\d+)\/run/g,
172
+ (match: string, oldId: string) => {
173
+ const newId = idMap.get(oldId);
174
+ return newId ? `/canvas/saved-query/${newId}/run` : match;
175
+ },
176
+ );
177
+ }
178
+
179
+ // 4. 按 code upsert Canvas(发布状态不参与导入,始终不带 publishedPageCode/publishedAt)
180
+ const canvas = await tx.canvas.upsert({
181
+ where: { code: payload.canvas.code },
182
+ create: {
183
+ code: payload.canvas.code,
184
+ name: payload.canvas.name,
185
+ userId,
186
+ items,
187
+ layout: payload.canvas.layout ?? [],
188
+ },
189
+ update: {
190
+ name: payload.canvas.name,
191
+ items,
192
+ layout: payload.canvas.layout ?? [],
193
+ },
194
+ });
195
+
196
+ return {
197
+ success: true,
198
+ canvasId: canvas.id,
199
+ canvasName: canvas.name,
200
+ };
201
+ });
202
+ }
203
+ }
@@ -8,12 +8,15 @@ import {
8
8
  ParseIntPipe,
9
9
  Post,
10
10
  Put,
11
+ Query,
11
12
  Req,
12
- UseGuards,
13
+ UnauthorizedException,
13
14
  } from '@nestjs/common';
14
15
  import { ApiTags } from '@nestjs/swagger';
15
16
  import { PrismaService } from 'nestjs-prisma';
16
- import AuthGuard, { AllowUnauthorizedRequest } from '../../lib/auth.guard';
17
+ import { AllowUnauthorizedRequest } from '../../lib/auth.guard';
18
+ import { ImportPayloadDto } from './canvas-export.dto';
19
+ import { CanvasExportService } from './canvas-export.service';
17
20
  import {
18
21
  CreateCanvasDto,
19
22
  CreateSavedQueryDto,
@@ -23,12 +26,12 @@ import {
23
26
  import { CanvasService } from './canvas.service';
24
27
 
25
28
  @ApiTags('canvas')
26
- @UseGuards(AuthGuard)
27
29
  @Controller('canvas')
28
30
  export class CanvasController {
29
31
  constructor(
30
32
  private readonly canvasService: CanvasService,
31
33
  private readonly prisma: PrismaService,
34
+ private readonly canvasExportService: CanvasExportService,
32
35
  ) {}
33
36
 
34
37
  @Get()
@@ -98,6 +101,36 @@ export class CanvasController {
98
101
  return this.canvasService.remove(id, req.user.userid);
99
102
  }
100
103
 
104
+ // ─── Export/Import Endpoints ──────────────────────────────────────────────────
105
+
106
+ @Post(':id/export-token')
107
+ async generateExportToken(@Param('id') id: string, @Req() req) {
108
+ const baseUrl = `${req.protocol}://${req.get('host')}/api`;
109
+ return this.canvasExportService.generateExportToken(id, baseUrl);
110
+ }
111
+
112
+ @Get('export/:code')
113
+ @AllowUnauthorizedRequest()
114
+ async getExportPayload(
115
+ @Param('code') code: string,
116
+ @Query('token') token: string,
117
+ ) {
118
+ if (!token) {
119
+ throw new UnauthorizedException('Export token is required');
120
+ }
121
+ return this.canvasExportService.getExportPayload(code, token);
122
+ }
123
+
124
+ @Post('import-preview')
125
+ async importPreview(@Body() dto: ImportPayloadDto) {
126
+ return this.canvasExportService.importPreview(dto.payload);
127
+ }
128
+
129
+ @Post('import-confirm')
130
+ async importConfirm(@Body() dto: ImportPayloadDto, @Req() req) {
131
+ return this.canvasExportService.importConfirm(dto.payload, req.user.userid);
132
+ }
133
+
101
134
  @Post(':id/publish')
102
135
  publish(@Req() req, @Param('id') id: string, @Body() dto: PublishCanvasDto) {
103
136
  return this.canvasService.publish(id, req.user.userid, dto);