diff --git a/packages/plugins/@nocobase/plugin-client/src/server/__tests__/desktopRoutes.test.ts b/packages/plugins/@nocobase/plugin-client/src/server/__tests__/desktopRoutes.test.ts index 330b1141ba0..458bc394254 100644 --- a/packages/plugins/@nocobase/plugin-client/src/server/__tests__/desktopRoutes.test.ts +++ b/packages/plugins/@nocobase/plugin-client/src/server/__tests__/desktopRoutes.test.ts @@ -9,6 +9,7 @@ import Database, { Model, Repository } from '@nocobase/database'; import { createMockServer, MockServer } from '@nocobase/test'; +import CleanOrphanFlowRouteModelsMigration from '../migrations/202605121200-clean-orphan-flow-route-models'; describe('desktopRoutes:listAccessible', () => { let app: MockServer; @@ -290,3 +291,188 @@ describe('desktopRoutes', async () => { expect(rolesDesktopRoutes.length).toBe(0); }); }); + +describe('desktopRoutes flow model cleanup', () => { + let app: MockServer, db: Database, desktopRoutesRepo: Repository; + let flowRepo: any, usageRepo: any; + + const buildReferenceOptions = (templateUid: string) => ({ + use: 'ReferenceBlockModel', + stepParams: { + referenceSettings: { + useTemplate: { + templateUid, + templateName: `name-${templateUid}`, + mode: 'reference', + }, + }, + }, + }); + + const createTemplate = async (uid: string) => { + await app + .agent() + .resource('flowModelTemplates') + .create({ + values: { + uid, + name: uid, + targetUid: 'target-block', + }, + }); + }; + + const saveRouteTreeWithReferenceBlock = async (routeUid: string, referenceUid: string, templateUid: string) => { + await app + .agent() + .resource('flowModels') + .save({ + values: { + uid: routeUid, + use: 'RouteModel', + subModels: { + page: { + uid: `${routeUid}-page`, + use: 'RootPageModel', + subModels: { + grid: { + uid: referenceUid, + ...buildReferenceOptions(templateUid), + }, + }, + }, + }, + }, + }); + }; + + const createSchemaRouteRoot = async (routeUid: string) => { + await flowRepo.create({ + values: { + uid: routeUid, + name: routeUid, + schema: { + use: 'RouteModel', + }, + }, + }); + }; + + const countUsage = (filter: Record) => { + return usageRepo.count({ filter }); + }; + + beforeEach(async () => { + app = await createMockServer({ + registerActions: true, + plugins: [ + 'error-handler', + 'client', + 'field-sort', + 'acl', + 'ui-schema-storage', + 'system-settings', + 'data-source-main', + 'data-source-manager', + 'flow-engine', + 'ui-templates', + ], + }); + db = app.db; + desktopRoutesRepo = db.getRepository('desktopRoutes'); + flowRepo = db.getRepository('flowModels'); + usageRepo = db.getRepository('flowModelTemplateUsages'); + + await flowRepo.create({ + values: { + uid: 'target-block', + options: { + use: 'TargetBlock', + }, + }, + }); + }); + + afterEach(async () => { + await app.destroy(); + }); + + it('should remove child route flow model tree and template usages when deleting a parent desktop route', async () => { + await createTemplate('tpl-route-cleanup'); + const group = await desktopRoutesRepo.create({ + values: { + title: 'group', + type: 'group', + }, + }); + await desktopRoutesRepo.create({ + values: { + title: 'child page', + type: 'flowPage', + parentId: group.get('id'), + schemaUid: 'route-cleanup', + }, + }); + await saveRouteTreeWithReferenceBlock('route-cleanup', 'ref-route-cleanup', 'tpl-route-cleanup'); + + expect(await countUsage({ templateUid: 'tpl-route-cleanup', modelUid: 'ref-route-cleanup' })).toBe(1); + + await desktopRoutesRepo.destroy({ + filterByTk: group.get('id'), + }); + + expect(await flowRepo.findOne({ filter: { uid: 'route-cleanup' } })).toBeFalsy(); + expect(await flowRepo.findOne({ filter: { uid: 'route-cleanup-page' } })).toBeFalsy(); + expect(await flowRepo.findOne({ filter: { uid: 'ref-route-cleanup' } })).toBeFalsy(); + expect(await countUsage({ templateUid: 'tpl-route-cleanup', modelUid: 'ref-route-cleanup' })).toBe(0); + + const destroyTemplateResp = await app.agent().resource('flowModelTemplates').destroy({ + filterByTk: 'tpl-route-cleanup', + }); + expect(destroyTemplateResp.status).toBe(200); + }); + + it('should clean orphan RouteModel trees and stale usages in migration', async () => { + await createTemplate('tpl-orphan-route'); + await desktopRoutesRepo.create({ + values: { + title: 'active page', + type: 'flowPage', + schemaUid: 'active-route', + }, + }); + await desktopRoutesRepo.create({ + values: { + title: 'active schema page', + type: 'flowPage', + schemaUid: 'active-schema-route', + }, + }); + + await saveRouteTreeWithReferenceBlock('active-route', 'ref-active-route', 'tpl-orphan-route'); + await saveRouteTreeWithReferenceBlock('orphan-route', 'ref-orphan-route', 'tpl-orphan-route'); + await createSchemaRouteRoot('orphan-schema-route'); + await usageRepo.create({ + values: { + uid: 'usage-missing-route-model', + templateUid: 'tpl-orphan-route', + modelUid: 'missing-route-model', + }, + }); + + expect(await countUsage({ templateUid: 'tpl-orphan-route' })).toBe(3); + + const migration = new CleanOrphanFlowRouteModelsMigration({ db, app } as any); + await migration.up(); + + expect(await flowRepo.findOne({ filter: { uid: 'active-route' } })).toBeTruthy(); + expect(await flowRepo.findOne({ filter: { uid: 'active-schema-route' } })).toBeTruthy(); + expect(await flowRepo.findOne({ filter: { uid: 'ref-active-route' } })).toBeTruthy(); + expect(await flowRepo.findOne({ filter: { uid: 'orphan-route' } })).toBeFalsy(); + expect(await flowRepo.findOne({ filter: { uid: 'orphan-schema-route' } })).toBeFalsy(); + expect(await flowRepo.findOne({ filter: { uid: 'ref-orphan-route' } })).toBeFalsy(); + expect(await countUsage({ templateUid: 'tpl-orphan-route', modelUid: 'ref-orphan-route' })).toBe(0); + expect(await countUsage({ templateUid: 'tpl-orphan-route', modelUid: 'missing-route-model' })).toBe(0); + expect(await countUsage({ templateUid: 'tpl-orphan-route', modelUid: 'ref-active-route' })).toBe(1); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-client/src/server/migrations/202605121200-clean-orphan-flow-route-models.ts b/packages/plugins/@nocobase/plugin-client/src/server/migrations/202605121200-clean-orphan-flow-route-models.ts new file mode 100644 index 00000000000..7f6dad65ae7 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-client/src/server/migrations/202605121200-clean-orphan-flow-route-models.ts @@ -0,0 +1,117 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Migration } from '@nocobase/server'; +import * as _ from 'lodash'; + +const parseOptions = (value: any) => { + if (!value) return {}; + if (typeof value === 'string') { + try { + return JSON.parse(value); + } catch (error) { + return {}; + } + } + return value; +}; + +const isRouteModelOptions = (value: any) => { + const options = parseOptions(value); + return options?.use === 'RouteModel' || options?.schema?.use === 'RouteModel'; +}; + +export default class extends Migration { + on = 'afterLoad'; + + async up() { + if ( + !this.db.hasCollection('desktopRoutes') || + !this.db.hasCollection('flowModels') || + !this.db.hasCollection('flowModelTreePath') + ) { + return; + } + + await this.db.sequelize.transaction(async (transaction) => { + const desktopRoutesRepo = this.db.getRepository('desktopRoutes'); + const flowRepo = this.db.getRepository('flowModels') as any; + const treePathRepo = this.db.getRepository('flowModelTreePath'); + + const desktopRoutes = await desktopRoutesRepo.find({ fields: ['schemaUid'], transaction }); + const routeSchemaUids = new Set(desktopRoutes.map((route) => route.get('schemaUid')).filter(Boolean)); + + const flowModels = await flowRepo.find({ fields: ['uid', 'options'], transaction }); + const routeModelUids = flowModels + .filter((model) => isRouteModelOptions(model.get('options'))) + .map((model) => model.get('uid')) + .filter(Boolean); + + if (routeModelUids.length) { + const childRouteModels = new Set(); + for (const chunk of _.chunk(routeModelUids, 500)) { + const parentPaths = await treePathRepo.find({ + filter: { + descendant: { + $in: chunk, + }, + depth: 1, + }, + fields: ['descendant'], + transaction, + }); + for (const path of parentPaths) { + const descendant = path.get('descendant'); + if (descendant) { + childRouteModels.add(descendant); + } + } + } + const orphanRouteModelUids = routeModelUids.filter( + (uid) => !routeSchemaUids.has(uid) && !childRouteModels.has(uid), + ); + + for (const uid of orphanRouteModelUids) { + await flowRepo.remove(uid, { transaction }); + } + } + + if (this.db.hasCollection('flowModelTemplateUsages')) { + const usageRepo = this.db.getRepository('flowModelTemplateUsages'); + const usages = await usageRepo.find({ fields: ['modelUid'], transaction }); + const usageModelUids = _.uniq(usages.map((usage) => usage.get('modelUid')).filter(Boolean)); + + for (const chunk of _.chunk(usageModelUids, 500)) { + const existingModels = await flowRepo.find({ + fields: ['uid'], + filter: { + uid: { + $in: chunk, + }, + }, + transaction, + }); + const existingUids = new Set(existingModels.map((model) => model.get('uid')).filter(Boolean)); + const missingUids = chunk.filter((uid) => !existingUids.has(uid)); + + if (missingUids.length) { + await usageRepo.destroy({ + filter: { + modelUid: { + $in: missingUids, + }, + }, + transaction, + }); + } + } + } + }); + } +} diff --git a/packages/plugins/@nocobase/plugin-client/src/server/server.ts b/packages/plugins/@nocobase/plugin-client/src/server/server.ts index 2d388c7f78b..94d4ed1e667 100644 --- a/packages/plugins/@nocobase/plugin-client/src/server/server.ts +++ b/packages/plugins/@nocobase/plugin-client/src/server/server.ts @@ -188,15 +188,53 @@ export class PluginClientServer extends Plugin { instance.allowNewMenu === undefined ? ['admin', 'member'].includes(instance.name) : !!instance.allowNewMenu, ); }); - this.db.on('desktopRoutes.afterDestroy', async (instance: Model, { transaction }) => { - const r = this.db.getRepository('flowModels'); - if (r) { - await r.destroy({ + + const collectDesktopRouteSchemaUids = async (instance: Model, transaction?: Transaction) => { + const routeRepo = this.db.getRepository('desktopRoutes'); + const schemaUids = new Set(); + const pendingIds: Array = []; + const rootId = instance.get('id'); + const rootSchemaUid = instance.get('schemaUid'); + + if (rootSchemaUid) { + schemaUids.add(rootSchemaUid); + } + if (rootId) { + pendingIds.push(rootId); + } + + while (pendingIds.length) { + const routes = await routeRepo.find({ + fields: ['id', 'schemaUid'], filter: { - uid: instance.get('schemaUid'), + parentId: { + $in: pendingIds.splice(0, pendingIds.length), + }, }, transaction, }); + for (const route of routes) { + const schemaUid = route.get('schemaUid'); + const id = route.get('id'); + if (schemaUid) { + schemaUids.add(schemaUid); + } + if (id) { + pendingIds.push(id); + } + } + } + + return Array.from(schemaUids); + }; + + this.db.on('desktopRoutes.beforeDestroy', async (instance: Model, { transaction }) => { + const r = this.db.getRepository('flowModels') as any; + if (r?.remove) { + const schemaUids = await collectDesktopRouteSchemaUids(instance, transaction); + for (const schemaUid of schemaUids) { + await r.remove(schemaUid, { transaction }); + } } }); this.db.on('desktopRoutes.afterCreate', async (instance: Model, { transaction }) => { diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/repository.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/repository.ts index b8c61eb52a2..8dcb096f327 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/repository.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/repository.ts @@ -456,6 +456,29 @@ export class FlowModelRepository extends Repository { return; } + const descendants = (await this.database.sequelize.query( + this.sqlAdapter(`SELECT descendant FROM ${this.flowModelTreePathTableName} WHERE ancestor = :uid`), + { + type: 'SELECT', + replacements: { + uid, + }, + transaction, + }, + )) as Array<{ descendant?: string }>; + const uids = descendants.map((row) => row.descendant).filter(Boolean) as string[]; + + if (uids.length) { + await this.database.emitAsync( + `${this.collection.name}.beforeRemoveTree`, + { + rootUid: uid, + uids, + }, + options, + ); + } + await this.database.sequelize.query( this.sqlAdapter(`DELETE FROM ${this.flowModelsTableName} WHERE "uid" IN ( SELECT descendant FROM ${this.flowModelTreePathTableName} WHERE ancestor = :uid diff --git a/packages/plugins/@nocobase/plugin-ui-templates/src/server/__tests__/template-usage.test.ts b/packages/plugins/@nocobase/plugin-ui-templates/src/server/__tests__/template-usage.test.ts index cbbc92e3ca1..117151f1300 100644 --- a/packages/plugins/@nocobase/plugin-ui-templates/src/server/__tests__/template-usage.test.ts +++ b/packages/plugins/@nocobase/plugin-ui-templates/src/server/__tests__/template-usage.test.ts @@ -150,6 +150,65 @@ describe('ui templates and usages', () => { expect(destroyResp2.status).toBe(200); }); + it('should remove target tree usages when template is destroyed', async () => { + const agent = app.agent(); + await agent.resource('flowModelTemplates').create({ + values: { + uid: 'tpl-block', + name: 'Block Template', + targetUid: 'target-block', + }, + }); + + const savePopupTargetResp = await agent.resource('flowModels').save({ + values: { + uid: 'popup-template-root', + use: 'PopupActionModel', + subModels: { + content: [ + { + uid: 'popup-ref-block', + ...buildOptions('tpl-block'), + }, + ], + }, + }, + }); + expect(savePopupTargetResp.status).toBe(200); + + expect(await countUsage({ templateUid: 'tpl-block', modelUid: 'popup-ref-block' })).toBe(1); + const blockTplResp = await agent.resource('flowModelTemplates').get({ + filterByTk: 'tpl-block', + }); + expect(blockTplResp.status).toBe(200); + const blockTpl = blockTplResp.body?.data || blockTplResp.body; + expect(blockTpl?.usageCount).toBe(1); + + const popupTplResp = await agent.resource('flowModelTemplates').create({ + values: { + uid: 'tpl-popup', + name: 'Popup Template', + targetUid: 'popup-template-root', + type: 'popup', + }, + }); + expect(popupTplResp.status).toBe(200); + + const destroyResp = await agent.resource('flowModelTemplates').destroy({ + filterByTk: 'tpl-popup', + }); + expect(destroyResp.status).toBe(200); + + expect(await countUsage({ templateUid: 'tpl-block', modelUid: 'popup-ref-block' })).toBe(0); + expect(await flowRepo.findNodesById('popup-template-root', { includeAsyncNode: true })).toHaveLength(0); + const blockTplRespAfterDestroy = await agent.resource('flowModelTemplates').get({ + filterByTk: 'tpl-block', + }); + expect(blockTplRespAfterDestroy.status).toBe(200); + const blockTplAfterDestroy = blockTplRespAfterDestroy.body?.data || blockTplRespAfterDestroy.body; + expect(blockTplAfterDestroy?.usageCount).toBe(0); + }); + it('should sync template name/description to reference blocks on template update', async () => { const agent = app.agent(); await agent.resource('flowModelTemplates').create({ diff --git a/packages/plugins/@nocobase/plugin-ui-templates/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-ui-templates/src/server/plugin.ts index de436ad461f..5c37d23053e 100644 --- a/packages/plugins/@nocobase/plugin-ui-templates/src/server/plugin.ts +++ b/packages/plugins/@nocobase/plugin-ui-templates/src/server/plugin.ts @@ -121,23 +121,36 @@ export class PluginBlockReferenceServer extends Plugin { } }; - const removeUsagesByInstance = async ( - instanceUid: string, - _options: any, - { transaction }: { transaction?: any } = {}, - ) => { + const removeUsagesByInstances = async (instanceUids: string[], { transaction }: { transaction?: any } = {}) => { const usageRepo = this.db.getRepository('flowModelTemplateUsages'); - await usageRepo.destroy({ filter: { modelUid: instanceUid }, transaction }); + const uids = _.uniq(instanceUids.map(String).filter(Boolean)); + for (const chunk of _.chunk(uids, 500)) { + await usageRepo.destroy({ + filter: { + modelUid: { + $in: chunk, + }, + }, + transaction, + }); + } }; // 区块删除时自动清理 usage 记录,避免垃圾数据 this.db.on('flowModels.afterDestroy', async (instance: any, { transaction }: any = {}) => { const instanceUid = instance?.get?.('uid') || instance?.uid; if (!instanceUid) return; - const options = parseOptions(instance?.get?.('options') || instance?.options); - await removeUsagesByInstance(instanceUid, options, { transaction }); + await removeUsagesByInstances([instanceUid], { transaction }); }); + this.db.on( + 'flowModels.beforeRemoveTree', + async ({ uids }: { rootUid?: string; uids?: string[] } = {}, options: { transaction?: any } = {}) => { + if (!uids?.length) return; + await removeUsagesByInstances(uids, { transaction: options?.transaction }); + }, + ); + // 区块保存时维护 usage: // - referenceSettings.useTemplate.templateUid(且 mode!==copy) // - popupSettings.openView.popupTemplateUid + popupTemplateMode!==copy @@ -201,11 +214,7 @@ export class PluginBlockReferenceServer extends Plugin { for (const node of nodes) { const instanceUid = node?.uid; if (!instanceUid) continue; - const options = parseOptions(node?.options || {}); - if (node?.parent && !options?.parentId) { - options.parentId = node.parent; - } - await removeUsagesByInstance(instanceUid, options, { transaction: ctx.transaction }); + await removeUsagesByInstances([instanceUid], { transaction: ctx.transaction }); } return; } diff --git a/packages/plugins/@nocobase/plugin-ui-templates/src/server/resources/flowModelTemplates.ts b/packages/plugins/@nocobase/plugin-ui-templates/src/server/resources/flowModelTemplates.ts index cfbec9d0f92..f7365ccbc66 100644 --- a/packages/plugins/@nocobase/plugin-ui-templates/src/server/resources/flowModelTemplates.ts +++ b/packages/plugins/@nocobase/plugin-ui-templates/src/server/resources/flowModelTemplates.ts @@ -238,6 +238,13 @@ export default { data: { usageCount }, }); } + const templateRepo = ctx.db.getRepository('flowModelTemplates'); + const template = await templateRepo.findOne({ + filter: { uid: templateUid }, + transaction: ctx.transaction, + context: ctx, + }); + const targetUid = template?.get?.('targetUid') || template?.targetUid; await actions.destroy(ctx, next); // 兜底清理孤立 usage(正常情况下 usageCount 已为 0) await usageRepo.destroy({ @@ -246,6 +253,10 @@ export default { }, context: ctx, }); + if (targetUid) { + const flowRepo = ctx.db.getRepository('flowModels') as FlowModelRepository; + await flowRepo.remove(targetUid, { transaction: ctx.transaction }); + } }, }, };