Validate chart business block datasets

This commit is contained in:
王鹏
2026-06-19 23:30:27 +08:00
parent 9a07a42d63
commit 29354508f9
2 changed files with 595 additions and 0 deletions

View File

@@ -0,0 +1,357 @@
package com.ruoyi.generator.service.front;
import java.lang.reflect.Array;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
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.BusinessBlockDefinition;
import com.ruoyi.generator.domain.front.dto.block.BusinessBlockInstance;
import com.ruoyi.generator.domain.front.dto.block.ChartDatasetConfig;
import com.ruoyi.generator.domain.front.dto.block.ChartDateRangeConfig;
import com.ruoyi.generator.domain.front.dto.block.ChartDimensionConfig;
import com.ruoyi.generator.domain.front.dto.block.ChartFilterConfig;
import com.ruoyi.generator.domain.front.dto.block.ChartMetricConfig;
import com.ruoyi.generator.domain.front.dto.block.ChartSortConfig;
public class ChartDatasetValidator
{
public static final String DATASET_SCHEMA = "single-table-aggregate-v1";
private static final Set<String> CHART_TYPES =
new HashSet<String>(Arrays.asList("metric", "line", "bar", "pie"));
private static final Set<String> AGGREGATES =
new HashSet<String>(Arrays.asList("count", "sum", "avg"));
private static final Set<String> TIME_UNITS =
new HashSet<String>(Arrays.asList("day", "week", "month"));
private static final Set<String> OPERATORS =
new HashSet<String>(Arrays.asList(
"eq", "ne", "gt", "gte", "lt", "lte", "between", "in", "is_null", "is_not_null"));
private static final Set<String> DATE_PRESETS =
new HashSet<String>(Arrays.asList(
"today", "last_7_days", "last_30_days", "this_month", "this_year", "fixed"));
private static final Set<Integer> SPANS =
new HashSet<Integer>(Arrays.asList(Integer.valueOf(12), Integer.valueOf(6), Integer.valueOf(4)));
private static final Pattern ALIAS_PATTERN = Pattern.compile("[A-Za-z][A-Za-z0-9_]{0,39}");
public void validate(BusinessBlockDefinition definition, BusinessBlockInstance instance, List<GenTable> tables)
{
if (definition == null || instance == null)
{
throw new ServiceException("图表业务块配置不存在");
}
String blockName = StringUtils.defaultIfEmpty(instance.getName(), definition.getName());
ChartDatasetConfig dataset = instance.getDataset();
if (dataset == null)
{
throw error(blockName, "数据集配置缺失");
}
String expectedSchema = StringUtils.defaultIfEmpty(definition.getDatasetSchema(), DATASET_SCHEMA);
if (!expectedSchema.equals(dataset.getSchema()))
{
throw error(blockName, "数据集协议必须为 " + expectedSchema);
}
GenTable table = requireTable(blockName, dataset.getTable(), tables);
validateSpan(definition, instance, blockName);
String chartType = StringUtils.defaultString(definition.getChartType()).toLowerCase();
if (!CHART_TYPES.contains(chartType))
{
throw error(blockName, "未知图表类型:" + chartType);
}
validateDimension(blockName, chartType, dataset.getDimension(), table);
Set<String> aliases = validateMetrics(blockName, chartType, dataset.getMetrics(), table);
validateFilters(blockName, dataset.getFilters(), table);
validateDateRange(blockName, dataset.getDateRange(), table);
validateSort(blockName, dataset.getSort(), aliases);
validateLimit(blockName, chartType, dataset);
}
private void validateSpan(BusinessBlockDefinition definition, BusinessBlockInstance instance, String blockName)
{
if (instance.getSpan() == null)
{
Integer defaultSpan = definition.getDefaultSpan();
instance.setSpan(SPANS.contains(defaultSpan) ? defaultSpan : Integer.valueOf(12));
}
if (!SPANS.contains(instance.getSpan()))
{
throw error(blockName, "宽度只能为 12、6 或 4");
}
}
private void validateDimension(String blockName, String chartType, ChartDimensionConfig dimension, GenTable table)
{
if ("metric".equals(chartType))
{
return;
}
if (dimension == null || StringUtils.isEmpty(dimension.getField()))
{
throw error(blockName, "请选择维度字段");
}
GenTableColumn column = requireColumn(blockName, table, dimension.getField());
String timeUnit = StringUtils.defaultString(dimension.getTimeUnit()).toLowerCase();
if (StringUtils.isEmpty(timeUnit))
{
return;
}
if (!TIME_UNITS.contains(timeUnit))
{
throw error(blockName, "时间粒度只能为 day、week 或 month");
}
if (!isTemporal(column))
{
throw error(blockName, "字段 " + dimension.getField() + " 不是时间类型,不能设置时间粒度");
}
}
private Set<String> validateMetrics(String blockName, String chartType, List<ChartMetricConfig> metrics,
GenTable table)
{
List<ChartMetricConfig> safeMetrics = metrics == null
? Collections.<ChartMetricConfig>emptyList() : metrics;
int size = safeMetrics.size();
if (("metric".equals(chartType) || "pie".equals(chartType)) && size != 1)
{
throw error(blockName, "指标卡和饼图必须配置 1 个指标");
}
if (("line".equals(chartType) || "bar".equals(chartType)) && (size < 1 || size > 3))
{
throw error(blockName, "折线图和柱状图必须配置 1 到 3 个指标");
}
Set<String> aliases = new HashSet<String>();
for (ChartMetricConfig metric : safeMetrics)
{
if (metric == null)
{
throw error(blockName, "指标配置不能为空");
}
String aggregate = StringUtils.defaultString(metric.getAggregate()).toLowerCase();
if (!AGGREGATES.contains(aggregate))
{
throw error(blockName, "未知聚合方式:" + aggregate);
}
GenTableColumn column = null;
if (StringUtils.isNotEmpty(metric.getField()))
{
column = requireColumn(blockName, table, metric.getField());
}
else if (!"count".equals(aggregate))
{
throw error(blockName, aggregate.toUpperCase() + " 必须选择指标字段");
}
if (("sum".equals(aggregate) || "avg".equals(aggregate)) && !isNumeric(column))
{
throw error(blockName, "指标字段 " + metric.getField() + " 不是数值类型,不能使用 "
+ aggregate.toUpperCase());
}
String alias = StringUtils.defaultString(metric.getAlias());
if (!ALIAS_PATTERN.matcher(alias).matches())
{
throw error(blockName, "指标别名必须以字母开头,且只能包含字母、数字和下划线");
}
if (!aliases.add(alias))
{
throw error(blockName, "指标别名不能重复:" + alias);
}
}
return aliases;
}
private void validateFilters(String blockName, List<ChartFilterConfig> filters, GenTable table)
{
List<ChartFilterConfig> safeFilters = filters == null
? Collections.<ChartFilterConfig>emptyList() : filters;
if (safeFilters.size() > 10)
{
throw error(blockName, "筛选条件不能超过 10 个");
}
for (ChartFilterConfig filter : safeFilters)
{
if (filter == null)
{
throw error(blockName, "筛选条件不能为空");
}
requireColumn(blockName, table, filter.getField());
String operator = StringUtils.defaultString(filter.getOperator()).toLowerCase();
if (!OPERATORS.contains(operator))
{
throw error(blockName, "未知筛选运算符:" + operator);
}
validateFilterValue(blockName, operator, filter.getValue());
}
}
private void validateFilterValue(String blockName, String operator, Object value)
{
if ("is_null".equals(operator) || "is_not_null".equals(operator))
{
return;
}
if ("between".equals(operator))
{
if (valueCount(value) != 2)
{
throw error(blockName, "between 筛选必须提供两个值");
}
return;
}
if ("in".equals(operator))
{
if (valueCount(value) < 1)
{
throw error(blockName, "in 筛选至少提供一个值");
}
return;
}
if (value == null || (value instanceof String && StringUtils.isEmpty((String) value)))
{
throw error(blockName, operator + " 筛选必须提供值");
}
}
private int valueCount(Object value)
{
if (value instanceof Collection)
{
return ((Collection<?>) value).size();
}
if (value != null && value.getClass().isArray())
{
return Array.getLength(value);
}
return value == null ? 0 : 1;
}
private void validateDateRange(String blockName, ChartDateRangeConfig dateRange, GenTable table)
{
if (dateRange == null)
{
return;
}
GenTableColumn column = requireColumn(blockName, table, dateRange.getField());
if (!isTemporal(column))
{
throw error(blockName, "日期范围字段 " + dateRange.getField() + " 不是时间类型");
}
String preset = StringUtils.defaultString(dateRange.getPreset()).toLowerCase();
if (!DATE_PRESETS.contains(preset))
{
throw error(blockName, "未知日期范围:" + preset);
}
if ("fixed".equals(preset)
&& (StringUtils.isEmpty(dateRange.getStart()) || StringUtils.isEmpty(dateRange.getEnd())))
{
throw error(blockName, "固定日期范围必须提供开始和结束日期");
}
}
private void validateSort(String blockName, ChartSortConfig sort, Set<String> aliases)
{
if (sort == null)
{
return;
}
String by = StringUtils.defaultString(sort.getBy());
if (!"dimension".equals(by) && !aliases.contains(by))
{
throw error(blockName, "排序字段必须为 dimension 或已配置的指标别名");
}
String order = StringUtils.defaultString(sort.getOrder()).toLowerCase();
if (!"asc".equals(order) && !"desc".equals(order))
{
throw error(blockName, "排序方向只能为 asc 或 desc");
}
}
private void validateLimit(String blockName, String chartType, ChartDatasetConfig dataset)
{
if (dataset.getLimit() == null)
{
dataset.setLimit(Integer.valueOf(defaultLimit(chartType)));
}
if (dataset.getLimit().intValue() < 1 || dataset.getLimit().intValue() > 500)
{
throw error(blockName, "分组上限必须在 1 到 500 之间");
}
}
private int defaultLimit(String chartType)
{
if ("metric".equals(chartType)) return 1;
if ("pie".equals(chartType)) return 10;
if ("bar".equals(chartType)) return 20;
return 100;
}
private GenTable requireTable(String blockName, String tableName, List<GenTable> tables)
{
Map<String, GenTable> tableMap = new LinkedHashMap<String, GenTable>();
for (GenTable table : tables == null ? Collections.<GenTable>emptyList() : tables)
{
if (table != null && StringUtils.isNotEmpty(table.getTableName()))
{
tableMap.put(table.getTableName(), table);
}
}
if (StringUtils.isEmpty(tableName) || !tableMap.containsKey(tableName))
{
throw error(blockName, "数据表不存在:" + StringUtils.defaultString(tableName));
}
return tableMap.get(tableName);
}
private GenTableColumn requireColumn(String blockName, GenTable table, String fieldName)
{
for (GenTableColumn column : table.getColumns() == null
? Collections.<GenTableColumn>emptyList() : table.getColumns())
{
if (column != null && (StringUtils.equals(fieldName, column.getColumnName())
|| StringUtils.equals(fieldName, column.getJavaField())))
{
return column;
}
}
throw error(blockName, "字段不存在:" + table.getTableName() + "." + StringUtils.defaultString(fieldName));
}
private boolean isNumeric(GenTableColumn column)
{
return column != null && StringUtils.equalsAnyIgnoreCase(column.getJavaType(),
"Integer", "Long", "Double", "Float", "Short", "BigDecimal");
}
private boolean isTemporal(GenTableColumn column)
{
if (column == null)
{
return false;
}
String columnType = StringUtils.defaultString(column.getColumnType()).toLowerCase();
return StringUtils.equalsAnyIgnoreCase(column.getJavaType(), "Date", "LocalDate", "LocalDateTime")
|| columnType.contains("date")
|| columnType.contains("time")
|| columnType.contains("timestamp")
|| columnType.contains("year");
}
private ServiceException error(String blockName, String message)
{
return new ServiceException("图表“" + StringUtils.defaultString(blockName) + "" + message);
}
}

View File

@@ -0,0 +1,238 @@
package com.ruoyi.generator.service.front;
import static org.junit.Assert.assertTrue;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
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.ChartDatasetConfig;
import com.ruoyi.generator.domain.front.dto.block.ChartDimensionConfig;
import com.ruoyi.generator.domain.front.dto.block.ChartFilterConfig;
import com.ruoyi.generator.domain.front.dto.block.ChartMetricConfig;
import com.ruoyi.generator.domain.front.dto.block.ChartSortConfig;
public class ChartDatasetValidatorTest
{
private final ChartDatasetValidator validator = new ChartDatasetValidator();
@Test
public void chartValidationAcceptsSingleTableTimeSeries()
{
validator.validate(lineChartDefinition(), validLineChartInstance(), chartTables());
}
@Test
public void chartValidationRejectsSumOnTextField()
{
BusinessBlockInstance instance = validLineChartInstance();
instance.getDataset().getMetrics().get(0).setField("status");
ServiceException exception = expectServiceException(() ->
validator.validate(lineChartDefinition(), instance, chartTables()));
assertTrue(exception.getMessage().contains("SUM"));
assertTrue(exception.getMessage().contains("status"));
}
@Test
public void chartValidationRejectsInvalidSpan()
{
BusinessBlockInstance instance = validLineChartInstance();
instance.setSpan(Integer.valueOf(5));
ServiceException exception = expectServiceException(() ->
validator.validate(lineChartDefinition(), instance, chartTables()));
assertTrue(exception.getMessage().contains("宽度"));
}
@Test
public void chartValidationRejectsMoreThanThreeMetrics()
{
BusinessBlockInstance instance = validLineChartInstance();
instance.getDataset().setMetrics(Arrays.asList(
metric("amount", "sum", "metric1"),
metric("amount", "avg", "metric2"),
metric("id", "count", "metric3"),
metric("amount", "sum", "metric4")));
ServiceException exception = expectServiceException(() ->
validator.validate(lineChartDefinition(), instance, chartTables()));
assertTrue(exception.getMessage().contains("1 到 3"));
}
@Test
public void chartValidationRejectsMoreThanTenFilters()
{
BusinessBlockInstance instance = validLineChartInstance();
List<ChartFilterConfig> filters = new ArrayList<ChartFilterConfig>();
for (int i = 0; i < 11; i++)
{
filters.add(filter("status", "eq", "PAID"));
}
instance.getDataset().setFilters(filters);
ServiceException exception = expectServiceException(() ->
validator.validate(lineChartDefinition(), instance, chartTables()));
assertTrue(exception.getMessage().contains("10"));
}
@Test
public void chartValidationRejectsInvalidTimeUnit()
{
BusinessBlockInstance instance = validLineChartInstance();
instance.getDataset().getDimension().setTimeUnit("quarter");
ServiceException exception = expectServiceException(() ->
validator.validate(lineChartDefinition(), instance, chartTables()));
assertTrue(exception.getMessage().contains("时间粒度"));
}
@Test
public void chartValidationRejectsUnknownFilterOperator()
{
BusinessBlockInstance instance = validLineChartInstance();
instance.getDataset().setFilters(Collections.singletonList(filter("status", "contains_sql", "PAID")));
ServiceException exception = expectServiceException(() ->
validator.validate(lineChartDefinition(), instance, chartTables()));
assertTrue(exception.getMessage().contains("运算符"));
}
@Test
public void chartValidationRejectsDuplicateMetricAliases()
{
BusinessBlockInstance instance = validLineChartInstance();
instance.getDataset().setMetrics(Arrays.asList(
metric("amount", "sum", "metric1"),
metric("amount", "avg", "metric1")));
ServiceException exception = expectServiceException(() ->
validator.validate(lineChartDefinition(), instance, chartTables()));
assertTrue(exception.getMessage().contains("别名"));
}
@Test
public void chartValidationRejectsLimitAboveFiveHundred()
{
BusinessBlockInstance instance = validLineChartInstance();
instance.getDataset().setLimit(Integer.valueOf(501));
ServiceException exception = expectServiceException(() ->
validator.validate(lineChartDefinition(), instance, chartTables()));
assertTrue(exception.getMessage().contains("500"));
}
private BusinessBlockDefinition lineChartDefinition()
{
BusinessBlockDefinition definition = new BusinessBlockDefinition();
definition.setCode("admin_line_chart");
definition.setName("折线图");
definition.setKind("chart");
definition.setChartType("line");
definition.setDatasetSchema("single-table-aggregate-v1");
definition.setSurfaces(Collections.singletonList("admin"));
definition.setPlacements(Arrays.asList("embedded", "page"));
definition.setDefaultSpan(Integer.valueOf(12));
return definition;
}
private BusinessBlockInstance validLineChartInstance()
{
BusinessBlockInstance instance = new BusinessBlockInstance();
instance.setId("admin_line_chart_001");
instance.setBlockCode("admin_line_chart");
instance.setName("近 30 天销售趋势");
instance.setSpan(Integer.valueOf(6));
ChartDimensionConfig dimension = new ChartDimensionConfig();
dimension.setField("create_time");
dimension.setTimeUnit("day");
ChartDatasetConfig dataset = new ChartDatasetConfig();
dataset.setSchema("single-table-aggregate-v1");
dataset.setTable("shop_order");
dataset.setDimension(dimension);
dataset.setMetrics(Collections.singletonList(metric("amount", "sum", "salesAmount")));
dataset.setFilters(Collections.singletonList(filter("status", "eq", "PAID")));
ChartSortConfig sort = new ChartSortConfig();
sort.setBy("dimension");
sort.setOrder("asc");
dataset.setSort(sort);
dataset.setLimit(Integer.valueOf(100));
instance.setDataset(dataset);
return instance;
}
private ChartMetricConfig metric(String field, String aggregate, String alias)
{
ChartMetricConfig metric = new ChartMetricConfig();
metric.setField(field);
metric.setAggregate(aggregate);
metric.setAlias(alias);
metric.setLabel(alias);
return metric;
}
private ChartFilterConfig filter(String field, String operator, Object value)
{
ChartFilterConfig filter = new ChartFilterConfig();
filter.setField(field);
filter.setOperator(operator);
filter.setValue(value);
return filter;
}
private List<GenTable> chartTables()
{
GenTable table = new GenTable();
table.setTableName("shop_order");
table.setColumns(Arrays.asList(
typedColumn("id", "id", "Long", "bigint"),
typedColumn("amount", "amount", "BigDecimal", "decimal(10,2)"),
typedColumn("status", "status", "String", "varchar(32)"),
typedColumn("create_time", "createTime", "Date", "datetime")));
return Collections.singletonList(table);
}
private GenTableColumn typedColumn(String columnName, String javaField, String javaType, String columnType)
{
GenTableColumn column = new GenTableColumn();
column.setColumnName(columnName);
column.setJavaField(javaField);
column.setJavaType(javaType);
column.setColumnType(columnType);
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();
}
}