Extend business block definition protocol

This commit is contained in:
王鹏
2026-06-19 23:20:43 +08:00
parent bd0a6a2cf4
commit da872fa133
3 changed files with 642 additions and 0 deletions

View File

@@ -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<String> surfaces;
private List<String> placements;
private String chartType;
private String datasetSchema;
private Integer defaultSpan;
private Integer version;
private List<BusinessBlockConfigField> configs;
private List<BusinessBlockTemplateFile> 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<String> getSurfaces() { return surfaces; }
public void setSurfaces(List<String> surfaces) { this.surfaces = surfaces; }
public List<String> getPlacements() { return placements; }
public void setPlacements(List<String> 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<BusinessBlockConfigField> getConfigs() { return configs; }
public void setConfigs(List<BusinessBlockConfigField> configs) { this.configs = configs; }
public List<BusinessBlockTemplateFile> getTemplates() { return templates; }
public void setTemplates(List<BusinessBlockTemplateFile> templates) { this.templates = templates; }
}

View File

@@ -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<BusinessBlockDefinition> cachedDefinitions;
public List<BusinessBlockDefinition> listDefinitions()
{
if (cachedDefinitions == null)
{
cachedDefinitions = loadDefinitions();
}
return cachedDefinitions;
}
public List<BusinessBlockDefinition> listDefinitions(String surface)
{
String normalizedSurface = "admin".equals(surface) ? "admin" : "frontend";
List<BusinessBlockDefinition> definitions = new ArrayList<BusinessBlockDefinition>();
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.<BusinessBlockInstance>emptyList());
return layout;
}
try
{
PageBusinessBlockLayout layout = JSON.parseObject(layoutJson, PageBusinessBlockLayout.class);
if (layout.getBlocks() == null)
{
layout.setBlocks(Collections.<BusinessBlockInstance>emptyList());
}
return layout;
}
catch (RuntimeException e)
{
throw new ServiceException("业务块布局JSON格式错误");
}
}
public void validateLayout(String layoutJson, List<GenTable> 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<String, List<BusinessBlockInstance>> parseEmbeddedBlockZones(String layoutJson)
{
Map<String, List<BusinessBlockInstance>> zones = new LinkedHashMap<String, List<BusinessBlockInstance>>();
zones.put("top", new ArrayList<BusinessBlockInstance>());
zones.put("bottom", new ArrayList<BusinessBlockInstance>());
if (StringUtils.isEmpty(layoutJson))
{
return zones;
}
try
{
@SuppressWarnings("unchecked")
Map<String, Object> 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<Object, Object> embeddedMap = (Map<Object, Object>) embeddedBlocks;
for (Map.Entry<Object, Object> 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<GenTable> tables)
{
Map<String, List<BusinessBlockInstance>> zones = parseEmbeddedBlockZones(layoutJson);
for (List<BusinessBlockInstance> 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<GenTable> tables)
{
if (definition == null)
{
throw new ServiceException("未知业务块:" + (instance == null ? "" : instance.getBlockCode()));
}
Map<String, String> config = instance == null || instance.getConfig() == null
? Collections.<String, String>emptyMap() : instance.getConfig();
Map<String, GenTable> tableMap = buildTableMap(tables);
List<BusinessBlockConfigField> fields = definition.getConfigs() == null
? Collections.<BusinessBlockConfigField>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<BusinessBlockDefinition> loadDefinitions()
{
try
{
Resource[] resources = new PathMatchingResourcePatternResolver()
.getResources("classpath*:business-blocks/*/block.json");
List<BusinessBlockDefinition> definitions = new ArrayList<BusinessBlockDefinition>();
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<BusinessBlockDefinition>()
{
@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<BusinessBlockInstance> parseBlockList(Object value)
{
if (!(value instanceof List))
{
return new ArrayList<BusinessBlockInstance>();
}
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<String, GenTable> buildTableMap(List<GenTable> tables)
{
Map<String, GenTable> tableMap = new LinkedHashMap<String, GenTable>();
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<String, GenTable> 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<GenTableColumn> columns = table.getColumns() == null
? Collections.<GenTableColumn>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);
}
}

View File

@@ -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<String, String> config = new java.util.LinkedHashMap<String, String>();
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<GenTable> 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<GenTable> 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();
}
}