diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/BusinessBlockDefinition.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/BusinessBlockDefinition.java new file mode 100644 index 0000000..4fe9563 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/BusinessBlockDefinition.java @@ -0,0 +1,50 @@ +package com.ruoyi.generator.domain.front.dto.block; + +import java.util.List; + +public class BusinessBlockDefinition +{ + private String code; + private String name; + private String category; + private String description; + private String placement; + private String kind; + private List surfaces; + private List placements; + private String chartType; + private String datasetSchema; + private Integer defaultSpan; + private Integer version; + private List configs; + private List templates; + + public String getCode() { return code; } + public void setCode(String code) { this.code = code; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getCategory() { return category; } + public void setCategory(String category) { this.category = category; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public String getPlacement() { return placement; } + public void setPlacement(String placement) { this.placement = placement; } + public String getKind() { return kind; } + public void setKind(String kind) { this.kind = kind; } + public List getSurfaces() { return surfaces; } + public void setSurfaces(List surfaces) { this.surfaces = surfaces; } + public List getPlacements() { return placements; } + public void setPlacements(List placements) { this.placements = placements; } + public String getChartType() { return chartType; } + public void setChartType(String chartType) { this.chartType = chartType; } + public String getDatasetSchema() { return datasetSchema; } + public void setDatasetSchema(String datasetSchema) { this.datasetSchema = datasetSchema; } + public Integer getDefaultSpan() { return defaultSpan; } + public void setDefaultSpan(Integer defaultSpan) { this.defaultSpan = defaultSpan; } + public Integer getVersion() { return version; } + public void setVersion(Integer version) { this.version = version; } + public List getConfigs() { return configs; } + public void setConfigs(List configs) { this.configs = configs; } + public List getTemplates() { return templates; } + public void setTemplates(List templates) { this.templates = templates; } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/BusinessBlockRegistryService.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/BusinessBlockRegistryService.java new file mode 100644 index 0000000..350d848 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/BusinessBlockRegistryService.java @@ -0,0 +1,346 @@ +package com.ruoyi.generator.service.front; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.alibaba.fastjson2.JSON; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.generator.domain.GenTable; +import com.ruoyi.generator.domain.GenTableColumn; +import com.ruoyi.generator.domain.front.dto.block.BusinessBlockConfigField; +import com.ruoyi.generator.domain.front.dto.block.BusinessBlockDefinition; +import com.ruoyi.generator.domain.front.dto.block.BusinessBlockInstance; +import com.ruoyi.generator.domain.front.dto.block.PageBusinessBlockLayout; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.stereotype.Service; + +@Service +public class BusinessBlockRegistryService +{ + public static final String BUSINESS_BLOCK_CANVAS = "business-blocks-v1"; + public static final String PLACEMENT_PAGE = "page"; + public static final String PLACEMENT_EMBEDDED = "embedded"; + + private volatile List cachedDefinitions; + + public List listDefinitions() + { + if (cachedDefinitions == null) + { + cachedDefinitions = loadDefinitions(); + } + return cachedDefinitions; + } + + public List listDefinitions(String surface) + { + String normalizedSurface = "admin".equals(surface) ? "admin" : "frontend"; + List definitions = new ArrayList(); + for (BusinessBlockDefinition definition : listDefinitions()) + { + if (definition != null && definition.getSurfaces().contains(normalizedSurface)) + { + definitions.add(definition); + } + } + return Collections.unmodifiableList(definitions); + } + + public BusinessBlockDefinition findDefinition(String code) + { + for (BusinessBlockDefinition definition : listDefinitions()) + { + if (definition != null && StringUtils.equals(definition.getCode(), code)) + { + return definition; + } + } + throw new ServiceException("未知业务块:" + code); + } + + public PageBusinessBlockLayout parseLayout(String layoutJson) + { + if (StringUtils.isEmpty(layoutJson)) + { + PageBusinessBlockLayout layout = new PageBusinessBlockLayout(); + layout.setCanvas(BUSINESS_BLOCK_CANVAS); + layout.setBlocks(Collections.emptyList()); + return layout; + } + try + { + PageBusinessBlockLayout layout = JSON.parseObject(layoutJson, PageBusinessBlockLayout.class); + if (layout.getBlocks() == null) + { + layout.setBlocks(Collections.emptyList()); + } + return layout; + } + catch (RuntimeException e) + { + throw new ServiceException("业务块布局JSON格式错误"); + } + } + + public void validateLayout(String layoutJson, List tables) + { + PageBusinessBlockLayout layout = parseLayout(layoutJson); + if (!BUSINESS_BLOCK_CANVAS.equals(layout.getCanvas())) + { + return; + } + for (BusinessBlockInstance instance : layout.getBlocks()) + { + if (instance == null) + { + continue; + } + BusinessBlockDefinition definition = findDefinition(instance.getBlockCode()); + validateInstanceConfig(definition, instance, tables); + } + } + + public Map> parseEmbeddedBlockZones(String layoutJson) + { + Map> zones = new LinkedHashMap>(); + zones.put("top", new ArrayList()); + zones.put("bottom", new ArrayList()); + if (StringUtils.isEmpty(layoutJson)) + { + return zones; + } + try + { + @SuppressWarnings("unchecked") + Map layout = JSON.parseObject(layoutJson, Map.class); + if (layout == null) + { + return zones; + } + Object embeddedBlocks = layout.get("embeddedBlocks"); + if (!(embeddedBlocks instanceof Map)) + { + return zones; + } + @SuppressWarnings("unchecked") + Map embeddedMap = (Map) embeddedBlocks; + for (Map.Entry entry : embeddedMap.entrySet()) + { + String zone = String.valueOf(entry.getKey()); + zones.put(zone, parseBlockList(entry.getValue())); + } + return zones; + } + catch (RuntimeException e) + { + throw new ServiceException("嵌入业务块JSON格式错误"); + } + } + + public void validateEmbeddedBlocks(String layoutJson, List tables) + { + Map> zones = parseEmbeddedBlockZones(layoutJson); + for (List blocks : zones.values()) + { + if (blocks == null) + { + continue; + } + for (BusinessBlockInstance instance : blocks) + { + if (instance == null || StringUtils.isEmpty(instance.getBlockCode())) + { + continue; + } + BusinessBlockDefinition definition = findDefinition(instance.getBlockCode()); + if (!isEmbeddedDefinition(definition)) + { + throw new ServiceException("业务块只能放在业务块页面:" + instance.getBlockCode()); + } + validateInstanceConfig(definition, instance, tables); + } + } + } + + public boolean isEmbeddedDefinition(BusinessBlockDefinition definition) + { + return supportsPlacement(definition, PLACEMENT_EMBEDDED); + } + + public boolean supportsPlacement(BusinessBlockDefinition definition, String placement) + { + return definition != null && definition.getPlacements() != null + && definition.getPlacements().contains(placement); + } + + public void validateInstanceConfig(BusinessBlockDefinition definition, BusinessBlockInstance instance, + List tables) + { + if (definition == null) + { + throw new ServiceException("未知业务块:" + (instance == null ? "" : instance.getBlockCode())); + } + Map config = instance == null || instance.getConfig() == null + ? Collections.emptyMap() : instance.getConfig(); + Map tableMap = buildTableMap(tables); + List fields = definition.getConfigs() == null + ? Collections.emptyList() : definition.getConfigs(); + for (BusinessBlockConfigField field : fields) + { + String value = StringUtils.defaultString(config.get(field.getKey())); + if (Boolean.TRUE.equals(field.getRequired()) && StringUtils.isEmpty(value)) + { + throw new ServiceException("业务块配置缺失:" + field.getKey()); + } + if (StringUtils.isEmpty(value)) + { + continue; + } + if ("table".equals(field.getType())) + { + requireTable(tableMap, value); + } + if ("field".equals(field.getType())) + { + String tableName = config.get(field.getTable()); + GenTable table = requireTable(tableMap, tableName); + requireField(table, value); + } + } + } + + private List loadDefinitions() + { + try + { + Resource[] resources = new PathMatchingResourcePatternResolver() + .getResources("classpath*:business-blocks/*/block.json"); + List definitions = new ArrayList(); + for (Resource resource : resources) + { + BusinessBlockDefinition definition = JSON.parseObject(readResource(resource), BusinessBlockDefinition.class); + if (definition != null && StringUtils.isNotEmpty(definition.getCode())) + { + definitions.add(normalizeDefinition(definition)); + } + } + Collections.sort(definitions, new Comparator() + { + @Override + public int compare(BusinessBlockDefinition left, BusinessBlockDefinition right) + { + return StringUtils.defaultString(left.getCode()).compareTo(StringUtils.defaultString(right.getCode())); + } + }); + return Collections.unmodifiableList(definitions); + } + catch (IOException e) + { + throw new ServiceException("读取业务块定义失败"); + } + } + + private BusinessBlockDefinition normalizeDefinition(BusinessBlockDefinition definition) + { + if (StringUtils.isEmpty(definition.getKind())) + { + definition.setKind("business"); + } + if (definition.getSurfaces() == null || definition.getSurfaces().isEmpty()) + { + definition.setSurfaces(Collections.singletonList("frontend")); + } + if (definition.getPlacements() == null || definition.getPlacements().isEmpty()) + { + String placement = StringUtils.defaultIfEmpty(definition.getPlacement(), PLACEMENT_PAGE); + definition.setPlacements(Collections.singletonList(placement)); + } + if (definition.getDefaultSpan() == null) + { + definition.setDefaultSpan(Integer.valueOf(12)); + } + return definition; + } + + private List parseBlockList(Object value) + { + if (!(value instanceof List)) + { + return new ArrayList(); + } + return JSON.parseArray(JSON.toJSONString(value), BusinessBlockInstance.class); + } + + private String readResource(Resource resource) throws IOException + { + InputStream inputStream = resource.getInputStream(); + try + { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int length; + while ((length = inputStream.read(buffer)) != -1) + { + outputStream.write(buffer, 0, length); + } + return new String(outputStream.toByteArray(), StandardCharsets.UTF_8); + } + finally + { + inputStream.close(); + } + } + + private Map buildTableMap(List tables) + { + Map tableMap = new LinkedHashMap(); + if (tables == null) + { + return tableMap; + } + for (GenTable table : tables) + { + if (table != null && StringUtils.isNotEmpty(table.getTableName())) + { + tableMap.put(table.getTableName(), table); + } + } + return tableMap; + } + + private GenTable requireTable(Map tableMap, String tableName) + { + if (StringUtils.isEmpty(tableName) || !tableMap.containsKey(tableName)) + { + throw new ServiceException("业务块表不存在:" + StringUtils.defaultString(tableName)); + } + return tableMap.get(tableName); + } + + private void requireField(GenTable table, String fieldName) + { + List columns = table.getColumns() == null + ? Collections.emptyList() : table.getColumns(); + for (GenTableColumn column : columns) + { + if (column == null) + { + continue; + } + if (StringUtils.equals(fieldName, column.getColumnName()) || StringUtils.equals(fieldName, column.getJavaField())) + { + return; + } + } + throw new ServiceException("业务块字段不存在:" + table.getTableName() + "." + fieldName); + } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/BusinessBlockRegistryServiceTest.java b/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/BusinessBlockRegistryServiceTest.java new file mode 100644 index 0000000..f7aba1d --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/BusinessBlockRegistryServiceTest.java @@ -0,0 +1,246 @@ +package com.ruoyi.generator.service.front; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import org.junit.Test; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.generator.domain.GenTable; +import com.ruoyi.generator.domain.GenTableColumn; +import com.ruoyi.generator.domain.front.dto.block.BusinessBlockDefinition; +import com.ruoyi.generator.domain.front.dto.block.BusinessBlockInstance; +import com.ruoyi.generator.domain.front.dto.block.PageBusinessBlockLayout; + +public class BusinessBlockRegistryServiceTest +{ + private final BusinessBlockRegistryService service = new BusinessBlockRegistryService(); + + @Test + public void listDefinitionsReturnsBundledCartBlock() + { + BusinessBlockDefinition definition = service.findDefinition("cart"); + + assertNotNull(definition); + assertEquals("cart", definition.getCode()); + assertTrue(definition.getConfigs().size() >= 6); + } + + @Test + public void listDefinitionsReturnsBundledBusinessBlocks() + { + assertEquals("notice", service.findDefinition("notice").getCode()); + assertEquals("carousel", service.findDefinition("carousel").getCode()); + assertEquals("order", service.findDefinition("order").getCode()); + assertEquals("master_detail", service.findDefinition("master_detail").getCode()); + } + + @Test + public void bundledDefinitionsDeclarePageOrEmbeddedPlacement() + { + assertEquals("embedded", service.findDefinition("notice").getPlacement()); + assertEquals("embedded", service.findDefinition("carousel").getPlacement()); + assertEquals("page", service.findDefinition("cart").getPlacement()); + assertEquals("page", service.findDefinition("order").getPlacement()); + assertEquals("page", service.findDefinition("master_detail").getPlacement()); + } + + @Test + public void legacyDefinitionsDefaultToFrontendBusinessBlocks() + { + BusinessBlockDefinition definition = service.findDefinition("cart"); + + assertEquals("business", definition.getKind()); + assertEquals(Collections.singletonList("frontend"), definition.getSurfaces()); + assertEquals(Collections.singletonList("page"), definition.getPlacements()); + } + + @Test + public void listDefinitionsFiltersBySurface() + { + for (BusinessBlockDefinition definition : service.listDefinitions("frontend")) + { + assertTrue(definition.getSurfaces().contains("frontend")); + } + for (BusinessBlockDefinition definition : service.listDefinitions("admin")) + { + assertTrue(definition.getSurfaces().contains("admin")); + } + } + + @Test + public void parseLayoutReadsBusinessBlockInstances() + { + PageBusinessBlockLayout layout = service.parseLayout("{\"canvas\":\"business-blocks-v1\",\"blocks\":[{\"id\":\"cart_001\",\"blockCode\":\"cart\",\"config\":{\"cartTable\":\"shop_cart\"}}]}"); + + assertEquals("business-blocks-v1", layout.getCanvas()); + assertEquals(1, layout.getBlocks().size()); + assertEquals("cart_001", layout.getBlocks().get(0).getId()); + assertEquals("cart", layout.getBlocks().get(0).getBlockCode()); + } + + @Test + public void validateInstanceConfigRejectsMissingRequiredConfig() + { + BusinessBlockDefinition definition = service.findDefinition("cart"); + BusinessBlockInstance instance = new BusinessBlockInstance(); + instance.setBlockCode("cart"); + instance.setConfig(Collections.singletonMap("productTable", "shop_product")); + + ServiceException exception = expectServiceException(new ThrowingRunnable() + { + @Override + public void run() + { + service.validateInstanceConfig(definition, instance, tables()); + } + }); + + assertTrue(exception.getMessage().contains("cartTable")); + } + + @Test + public void validateInstanceConfigRejectsUnknownTable() + { + BusinessBlockDefinition definition = service.findDefinition("cart"); + BusinessBlockInstance instance = validCartInstance(); + instance.getConfig().put("cartTable", "missing_cart"); + + ServiceException exception = expectServiceException(new ThrowingRunnable() + { + @Override + public void run() + { + service.validateInstanceConfig(definition, instance, tables()); + } + }); + + assertTrue(exception.getMessage().contains("missing_cart")); + } + + @Test + public void validateInstanceConfigRejectsUnknownField() + { + BusinessBlockDefinition definition = service.findDefinition("cart"); + BusinessBlockInstance instance = validCartInstance(); + instance.getConfig().put("productName", "missing_name"); + + ServiceException exception = expectServiceException(new ThrowingRunnable() + { + @Override + public void run() + { + service.validateInstanceConfig(definition, instance, tables()); + } + }); + + assertTrue(exception.getMessage().contains("missing_name")); + } + + @Test + public void validateLayoutIgnoresNonBusinessBlockLayout() + { + service.validateLayout("{\"canvas\":\"frontend-list-v1\",\"regions\":[]}", tables()); + } + + @Test + public void validateEmbeddedBlocksAcceptsEmbeddedDefinitions() + { + service.validateEmbeddedBlocks("{\"canvas\":\"frontend-list-v1\",\"regions\":[],\"embeddedBlocks\":{\"top\":[" + + "{\"id\":\"notice_001\",\"blockCode\":\"notice\",\"config\":{" + + "\"noticeTable\":\"sys_notice\",\"title\":\"noticeTitle\",\"content\":\"noticeContent\"}}" + + "]}}", contentTables()); + } + + @Test + public void validateEmbeddedBlocksRejectsPageOnlyDefinitions() + { + ServiceException exception = expectServiceException(new ThrowingRunnable() + { + @Override + public void run() + { + service.validateEmbeddedBlocks("{\"canvas\":\"frontend-list-v1\",\"regions\":[],\"embeddedBlocks\":{\"top\":[" + + "{\"id\":\"cart_001\",\"blockCode\":\"cart\",\"config\":{" + + "\"cartTable\":\"shop_cart\",\"productTable\":\"shop_product\",\"cartProductId\":\"product_id\"," + + "\"productId\":\"id\",\"productName\":\"name\",\"price\":\"price\",\"quantity\":\"quantity\"," + + "\"userId\":\"user_id\"}}" + + "]}}", contentTables()); + } + }); + + assertTrue(exception.getMessage().contains("cart")); + } + + private BusinessBlockInstance validCartInstance() + { + BusinessBlockInstance instance = new BusinessBlockInstance(); + instance.setId("cart_001"); + instance.setBlockCode("cart"); + java.util.Map config = new java.util.LinkedHashMap(); + config.put("cartTable", "shop_cart"); + config.put("productTable", "shop_product"); + config.put("cartProductId", "product_id"); + config.put("productId", "id"); + config.put("productName", "name"); + config.put("price", "price"); + config.put("quantity", "quantity"); + config.put("userId", "user_id"); + instance.setConfig(config); + return instance; + } + + private java.util.List tables() + { + return Arrays.asList( + table("shop_cart", column("id", "id"), column("product_id", "productId"), column("quantity", "quantity"), column("user_id", "userId")), + table("shop_product", column("id", "id"), column("name", "name"), column("price", "price"), column("stock", "stock"))); + } + + private java.util.List contentTables() + { + return Arrays.asList( + table("sys_notice", column("id", "id"), column("notice_title", "noticeTitle"), + column("notice_content", "noticeContent")), + table("shop_cart", column("id", "id"), column("product_id", "productId"), + column("quantity", "quantity"), column("user_id", "userId")), + table("shop_product", column("id", "id"), column("name", "name"), + column("price", "price"))); + } + + private GenTable table(String tableName, GenTableColumn... columns) + { + GenTable table = new GenTable(); + table.setTableName(tableName); + table.setColumns(Arrays.asList(columns)); + return table; + } + + private GenTableColumn column(String columnName, String javaField) + { + GenTableColumn column = new GenTableColumn(); + column.setColumnName(columnName); + column.setJavaField(javaField); + return column; + } + + private ServiceException expectServiceException(ThrowingRunnable runnable) + { + try + { + runnable.run(); + } + catch (ServiceException e) + { + return e; + } + throw new AssertionError("Expected ServiceException"); + } + + private interface ThrowingRunnable + { + void run(); + } +}