diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/ChartDatasetValidator.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/ChartDatasetValidator.java new file mode 100644 index 0000000..24010a3 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/ChartDatasetValidator.java @@ -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 CHART_TYPES = + new HashSet(Arrays.asList("metric", "line", "bar", "pie")); + private static final Set AGGREGATES = + new HashSet(Arrays.asList("count", "sum", "avg")); + private static final Set TIME_UNITS = + new HashSet(Arrays.asList("day", "week", "month")); + private static final Set OPERATORS = + new HashSet(Arrays.asList( + "eq", "ne", "gt", "gte", "lt", "lte", "between", "in", "is_null", "is_not_null")); + private static final Set DATE_PRESETS = + new HashSet(Arrays.asList( + "today", "last_7_days", "last_30_days", "this_month", "this_year", "fixed")); + private static final Set SPANS = + new HashSet(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 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 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 validateMetrics(String blockName, String chartType, List metrics, + GenTable table) + { + List safeMetrics = metrics == null + ? Collections.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 aliases = new HashSet(); + 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 filters, GenTable table) + { + List safeFilters = filters == null + ? Collections.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 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 tables) + { + Map tableMap = new LinkedHashMap(); + for (GenTable table : tables == null ? Collections.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.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); + } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/ChartDatasetValidatorTest.java b/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/ChartDatasetValidatorTest.java new file mode 100644 index 0000000..2d24706 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/ChartDatasetValidatorTest.java @@ -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 filters = new ArrayList(); + 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 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(); + } +}