第2225篇:报表自动生成系统——从数据到可视化图表到文字分析的AI流水线
2026/4/30大约 10 分钟
第2225篇:报表自动生成系统——从数据到可视化图表到文字分析的AI流水线
适读人群:做BI报表、数据分析自动化的工程师 | 阅读时长:约17分钟 | 核心价值:构建完整的AI驱动报表生成流水线,将数据自动转化为可视化图表和文字分析
我司财务部每月月底都有一个"噩梦"——要生成一份包含几十个图表、几千字分析的月度经营报告。三个分析师要花两天时间,汇总数据、画图表、写分析文字、做PPT。
这两天是真的辛苦:重复性操作占80%,真正需要人脑判断的分析可能只有20%。
然后我们做了一套报表自动生成系统:给定数据源和报告模板,系统自动拉取数据、生成图表、用AI写分析文字,最终输出完整报告。
现在两个小时就能出初稿,分析师只需要复核和补充人工洞见。
这篇文章把这套系统的工程实现讲透。
系统架构全景
数据获取与处理层
/**
* 多数据源数据聚合器
* 统一从数据库、API、文件等来源获取报表数据
*/
@Service
@Slf4j
public class ReportDataAggregator {
@Autowired
private DataSourceRegistry dataSourceRegistry;
@Autowired
private QueryExecutor queryExecutor;
/**
* 根据报告定义,获取所有需要的数据
*/
public ReportDataContext fetchReportData(ReportDefinition definition,
ReportPeriod period) {
ReportDataContext context = new ReportDataContext();
context.setPeriod(period);
context.setReportType(definition.getReportType());
List<CompletableFuture<Void>> fetchTasks = new ArrayList<>();
for (DataQuery dataQuery : definition.getDataQueries()) {
CompletableFuture<Void> task = CompletableFuture.runAsync(() -> {
try {
long start = System.currentTimeMillis();
DataTable result = executeQuery(dataQuery, period);
context.putDataTable(dataQuery.getQueryId(), result);
log.debug("数据查询完成: queryId={}, rows={}, elapsed={}ms",
dataQuery.getQueryId(), result.getRowCount(),
System.currentTimeMillis() - start);
} catch (Exception e) {
log.error("数据查询失败: queryId={}", dataQuery.getQueryId(), e);
context.recordQueryError(dataQuery.getQueryId(), e.getMessage());
}
});
fetchTasks.add(task);
}
// 等待所有查询完成
CompletableFuture.allOf(fetchTasks.toArray(new CompletableFuture[0]))
.orTimeout(60, TimeUnit.SECONDS)
.join();
// 计算衍生指标
computeDerivedMetrics(context, definition.getDerivedMetrics());
return context;
}
private DataTable executeQuery(DataQuery dataQuery, ReportPeriod period) {
// 替换时间参数
String sql = dataQuery.getSqlTemplate()
.replace("${start_date}", period.getStartDate().toString())
.replace("${end_date}", period.getEndDate().toString());
DataSource ds = dataSourceRegistry.getDataSource(dataQuery.getDataSourceId());
return queryExecutor.execute(ds, sql);
}
/**
* 计算衍生指标
* 如:同比增长率 = (本期 - 上期) / 上期 * 100%
*/
private void computeDerivedMetrics(ReportDataContext context,
List<DerivedMetricDefinition> definitions) {
for (DerivedMetricDefinition def : definitions) {
try {
Object value = evaluateExpression(def.getExpression(), context);
context.putMetric(def.getMetricId(), value);
} catch (Exception e) {
log.warn("衍生指标计算失败: metricId={}", def.getMetricId(), e);
}
}
}
private Object evaluateExpression(String expression, ReportDataContext context) {
// 简单表达式求值(实际可用 SpEL 或 JEXL)
// 例如:${revenue_current} / ${revenue_previous} - 1
return null; // 简化
}
}图表配置生成器
/**
* AI驱动的图表配置生成器
* 根据数据特征自动选择合适的图表类型并生成配置
*/
@Service
@Slf4j
public class ChartConfigGenerator {
@Autowired
private OpenAiClient openAiClient;
/**
* 根据数据自动生成 ECharts 图表配置
*/
public String generateEchartsConfig(DataTable dataTable, ChartContext chartContext) {
// 1. 分析数据特征
DataCharacteristics chars = analyzeDataCharacteristics(dataTable);
// 2. 如果有明确的图表类型指定,直接用;否则让AI推荐
String chartType = chartContext.getPreferredChartType() != null ?
chartContext.getPreferredChartType() :
recommendChartType(chars, chartContext.getAnalysisGoal());
// 3. 生成图表配置
return switch (chartType) {
case "line" -> generateLineChartConfig(dataTable, chartContext);
case "bar" -> generateBarChartConfig(dataTable, chartContext);
case "pie" -> generatePieChartConfig(dataTable, chartContext);
case "scatter" -> generateScatterChartConfig(dataTable, chartContext);
default -> generateWithAiAssistance(dataTable, chartContext, chartType);
};
}
/**
* 生成折线图配置(趋势展示)
*/
private String generateLineChartConfig(DataTable data, ChartContext ctx) {
List<String> xData = data.getColumnValues(ctx.getXAxisColumn());
List<Map<String, Object>> seriesList = new ArrayList<>();
for (String yCol : ctx.getYAxisColumns()) {
List<Number> yData = data.getNumericColumnValues(yCol);
Map<String, Object> series = new LinkedHashMap<>();
series.put("name", ctx.getColumnAlias(yCol, yCol));
series.put("type", "line");
series.put("data", yData);
series.put("smooth", true);
series.put("symbolSize", 6);
seriesList.add(series);
}
Map<String, Object> option = new LinkedHashMap<>();
option.put("title", Map.of("text", ctx.getChartTitle(), "textStyle",
Map.of("fontSize", 14, "fontWeight", "bold")));
option.put("tooltip", Map.of("trigger", "axis"));
option.put("legend", Map.of("bottom", 0));
option.put("xAxis", Map.of("type", "category", "data", xData));
option.put("yAxis", Map.of("type", "value", "name", ctx.getYAxisLabel()));
option.put("series", seriesList);
option.put("color", List.of("#5470c6", "#91cc75", "#fac858", "#ee6666"));
try {
return new ObjectMapper().writeValueAsString(option);
} catch (JsonProcessingException e) {
throw new ChartGenerationException("图表配置序列化失败", e);
}
}
/**
* 对于复杂图表,用AI辅助生成配置
*/
private String generateWithAiAssistance(DataTable data, ChartContext ctx,
String chartType) {
String dataSummary = buildDataSummary(data);
String prompt = String.format("""
请为以下数据生成一个 ECharts 图表配置(JSON格式)。
图表要求:
- 图表类型:%s
- 标题:%s
- 数据描述:%s
ECharts配置要求:
- 包含完整的option对象
- 颜色方案:专业商务风格
- 包含tooltip和legend
- 数值格式:大数字用万/亿单位
只输出JSON,不要说明文字。
""", chartType, ctx.getChartTitle(), dataSummary);
String config = openAiClient.chat(prompt);
return config.replaceAll("```json\\s*", "").replaceAll("```\\s*", "").trim();
}
private String recommendChartType(DataCharacteristics chars, String goal) {
if (chars.isTimeSeries()) return "line";
if (chars.isComparison() && chars.getCategoryCount() <= 10) return "bar";
if (chars.isProportional()) return "pie";
if (chars.isTwoNumericVariables()) return "scatter";
return "bar"; // 默认柱状图
}
private DataCharacteristics analyzeDataCharacteristics(DataTable data) {
return DataCharacteristics.builder()
.isTimeSeries(data.hasDateColumn())
.isComparison(data.getCategoryColumnCount() > 0)
.categoryCount(data.getMaxCategoryCount())
.isProportional(data.getNumericColumnCount() == 1 && data.getRowCount() <= 8)
.isTwoNumericVariables(data.getNumericColumnCount() == 2)
.build();
}
private String buildDataSummary(DataTable data) {
return String.format("共%d行数据,列:%s",
data.getRowCount(),
String.join(", ", data.getColumnNames()));
}
private String generateBarChartConfig(DataTable data, ChartContext ctx) { return "{}"; }
private String generatePieChartConfig(DataTable data, ChartContext ctx) { return "{}"; }
private String generateScatterChartConfig(DataTable data, ChartContext ctx) { return "{}"; }
}图表渲染:将配置转为图片
/**
* ECharts 图表渲染服务
* 使用 Headless Chrome 渲染 ECharts 图表为图片
*/
@Service
@Slf4j
public class ChartRenderService {
@Value("${chart.render.chrome.path:/usr/bin/google-chrome}")
private String chromePath;
@Value("${chart.render.timeout-seconds:30}")
private int renderTimeoutSeconds;
@Autowired
private ChartTemplateEngine templateEngine;
/**
* 渲染图表为 PNG 图片
*/
public byte[] renderChartToPng(String echartsConfig, int width, int height) {
// 1. 生成包含 ECharts 的 HTML
String html = templateEngine.buildChartHtml(echartsConfig, width, height);
// 2. 写临时HTML文件
Path tempDir = null;
try {
tempDir = Files.createTempDirectory("chart-render-");
Path htmlFile = tempDir.resolve("chart.html");
Path outputFile = tempDir.resolve("chart.png");
Files.writeString(htmlFile, html);
// 3. 调用 Headless Chrome 渲染截图
ProcessBuilder pb = new ProcessBuilder(
chromePath,
"--headless",
"--no-sandbox",
"--disable-gpu",
"--disable-dev-shm-usage",
"--window-size=" + width + "," + height,
"--screenshot=" + outputFile.toAbsolutePath(),
"--default-background-color=ffffff",
"file://" + htmlFile.toAbsolutePath()
);
pb.redirectErrorStream(true);
Process process = pb.start();
boolean finished = process.waitFor(renderTimeoutSeconds, TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
throw new ChartRenderException("图表渲染超时");
}
if (process.exitValue() != 0) {
String error = new String(process.getInputStream().readAllBytes());
throw new ChartRenderException("Chrome渲染失败: " + error);
}
// 4. 读取生成的图片
return Files.readAllBytes(outputFile);
} catch (IOException | InterruptedException e) {
throw new ChartRenderException("图表渲染失败", e);
} finally {
// 清理临时文件
if (tempDir != null) {
try {
Files.walk(tempDir)
.sorted(Comparator.reverseOrder())
.forEach(p -> {
try { Files.delete(p); } catch (IOException ignored) {}
});
} catch (IOException ignored) {}
}
}
}
/**
* 批量渲染多个图表(并行)
*/
public Map<String, byte[]> renderChartsBatch(Map<String, String> chartConfigs,
int width, int height) {
Map<String, CompletableFuture<byte[]>> futures = new HashMap<>();
for (Map.Entry<String, String> entry : chartConfigs.entrySet()) {
String chartId = entry.getKey();
String config = entry.getValue();
futures.put(chartId, CompletableFuture.supplyAsync(() ->
renderChartToPng(config, width, height)));
}
Map<String, byte[]> results = new HashMap<>();
for (Map.Entry<String, CompletableFuture<byte[]>> entry : futures.entrySet()) {
try {
results.put(entry.getKey(), entry.getValue().get(60, TimeUnit.SECONDS));
} catch (Exception e) {
log.error("图表渲染失败: chartId={}", entry.getKey(), e);
// 渲染失败时放入占位图
results.put(entry.getKey(), generateErrorPlaceholder());
}
}
return results;
}
private byte[] generateErrorPlaceholder() {
// 生成一张写着"图表渲染失败"的占位图
return new byte[0]; // 简化
}
}AI文字分析生成
/**
* AI驱动的报告文字分析生成器
* 将数据和图表转化为有深度的文字分析
*/
@Service
@Slf4j
public class ReportTextAnalyzer {
@Autowired
private OpenAiClient openAiClient;
/**
* 为单个图表生成分析文字
*/
public String generateChartAnalysis(byte[] chartImageBytes,
DataTable underlyingData,
ChartContext chartContext,
ReportPeriod period) {
String base64Chart = Base64.getEncoder().encodeToString(chartImageBytes);
// 准备数据摘要(关键数值)
String dataSummary = buildDataSummaryForAnalysis(underlyingData, chartContext);
String prompt = String.format("""
请为这张%s图表撰写分析文字,用于月度经营报告。
图表标题:%s
分析期间:%s至%s
关键数据:
%s
分析要求:
1. 描述主要趋势或规律(1-2句)
2. 指出最显著的变化或异常(1-2句)
3. 给出简要的原因判断或启示(1句)
风格要求:
- 简洁专业,150-250字
- 使用具体数字
- 避免废话,每句话都要有信息量
- 不要以"本图表显示"开头
""",
chartContext.getChartType(),
chartContext.getChartTitle(),
period.getStartDate(),
period.getEndDate(),
dataSummary);
return openAiClient.chatMultimodal(prompt, base64Chart, "image/png",
ChatOptions.builder().temperature(0.3).maxTokens(400).build());
}
/**
* 生成报告总结(基于所有图表分析)
*/
public String generateReportSummary(List<String> chartAnalyses,
ReportDataContext dataContext,
ReportPeriod period) {
StringBuilder allAnalyses = new StringBuilder();
for (int i = 0; i < chartAnalyses.size(); i++) {
allAnalyses.append(String.format("【分析%d】\n%s\n\n", i + 1, chartAnalyses.get(i)));
}
// 核心指标摘要
String keyMetrics = buildKeyMetricsSummary(dataContext);
String prompt = String.format("""
基于以下各模块的数据分析,为%s至%s经营报告撰写执行摘要(500-800字)。
核心指标:
%s
各模块分析:
%s
摘要要求:
1. 开头点明本期整体经营情况(正面/负面/平稳)
2. 梳理3-5个核心亮点和问题
3. 结尾给出下期重点关注方向
写作风格:
- 领导层报告风格,简明决策导向
- 数据支撑,有洞见不只有陈述
- 不要用"本报告"等套话开头
""",
period.getStartDate(), period.getEndDate(),
keyMetrics, allAnalyses.toString());
return openAiClient.chat(prompt,
ChatOptions.builder().temperature(0.4).maxTokens(1200).build());
}
private String buildDataSummaryForAnalysis(DataTable data, ChartContext ctx) {
StringBuilder sb = new StringBuilder();
// 计算关键统计量
for (String col : ctx.getYAxisColumns()) {
List<Number> values = data.getNumericColumnValues(col);
if (!values.isEmpty()) {
OptionalDouble max = values.stream().mapToDouble(Number::doubleValue).max();
OptionalDouble min = values.stream().mapToDouble(Number::doubleValue).min();
double last = values.get(values.size() - 1).doubleValue();
double prev = values.size() > 1 ?
values.get(values.size() - 2).doubleValue() : last;
double change = prev != 0 ? (last - prev) / prev * 100 : 0;
sb.append(ctx.getColumnAlias(col, col)).append(": ")
.append(String.format("最新值=%.2f, 环比%.1f%%, 最大=%.2f, 最小=%.2f\n",
last, change, max.orElse(0), min.orElse(0)));
}
}
return sb.toString();
}
private String buildKeyMetricsSummary(ReportDataContext ctx) {
return ctx.getMetrics().entrySet().stream()
.map(e -> e.getKey() + ": " + e.getValue())
.collect(Collectors.joining("\n"));
}
}报告组装:生成PDF输出
/**
* 报告 PDF 组装器
* 将图表图片、文字分析、数据表格组装为 PDF 报告
*/
@Service
@Slf4j
public class ReportPdfAssembler {
/**
* 组装完整 PDF 报告
* 使用 iText 7 生成 PDF
*/
public byte[] assemblePdf(ReportAssemblyContext context) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (PdfWriter writer = new PdfWriter(baos);
PdfDocument pdf = new PdfDocument(writer);
Document document = new Document(pdf, PageSize.A4)) {
document.setMargins(40, 40, 40, 40);
// 1. 封面
addCoverPage(document, context);
// 2. 执行摘要
document.add(new AreaBreak(AreaBreakType.NEXT_PAGE));
addSectionTitle(document, "执行摘要");
addParagraphText(document, context.getExecutiveSummary());
// 3. 各章节内容(图表 + 分析文字)
for (ReportSection section : context.getSections()) {
document.add(new AreaBreak(AreaBreakType.NEXT_PAGE));
addSectionTitle(document, section.getTitle());
// 图表
if (section.getChartImageBytes() != null) {
addChart(document, section.getChartImageBytes(), section.getChartCaption());
}
// 分析文字
if (section.getAnalysisText() != null) {
addParagraphText(document, section.getAnalysisText());
}
// 数据表格(可选)
if (section.getDataTable() != null) {
addDataTable(document, section.getDataTable());
}
}
// 4. 附录
if (!context.getAppendixTables().isEmpty()) {
document.add(new AreaBreak(AreaBreakType.NEXT_PAGE));
addSectionTitle(document, "附录:详细数据");
for (DataTable appendixTable : context.getAppendixTables()) {
addDataTable(document, appendixTable);
}
}
} catch (Exception e) {
throw new ReportGenerationException("PDF生成失败", e);
}
return baos.toByteArray();
}
private void addCoverPage(Document doc, ReportAssemblyContext ctx) throws Exception {
// 标题
Paragraph title = new Paragraph(ctx.getReportTitle())
.setFontSize(24)
.setBold()
.setTextAlignment(TextAlignment.CENTER)
.setMarginTop(200);
doc.add(title);
// 期间
doc.add(new Paragraph(ctx.getPeriodDescription())
.setFontSize(16)
.setTextAlignment(TextAlignment.CENTER)
.setMarginTop(20));
// 生成时间
doc.add(new Paragraph("生成时间:" +
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH:mm")))
.setFontSize(10)
.setTextAlignment(TextAlignment.CENTER)
.setMarginTop(400)
.setFontColor(ColorConstants.GRAY));
}
private void addChart(Document doc, byte[] chartBytes,
String caption) throws Exception {
Image chart = new Image(ImageDataFactory.create(chartBytes));
chart.setWidth(UnitValue.createPercentValue(90));
chart.setHorizontalAlignment(HorizontalAlignment.CENTER);
doc.add(chart);
if (caption != null) {
doc.add(new Paragraph(caption)
.setFontSize(9)
.setTextAlignment(TextAlignment.CENTER)
.setFontColor(ColorConstants.GRAY)
.setMarginBottom(10));
}
}
private void addSectionTitle(Document doc, String title) {
doc.add(new Paragraph(title)
.setFontSize(16)
.setBold()
.setMarginBottom(10)
.setBorderBottom(new SolidBorder(1)));
}
private void addParagraphText(Document doc, String text) {
if (text == null || text.isEmpty()) return;
doc.add(new Paragraph(text)
.setFontSize(11)
.setTextAlignment(TextAlignment.JUSTIFIED)
.setMarginBottom(15)
.setLeading(1.5f));
}
private void addDataTable(Document doc, DataTable dataTable) throws Exception {
Table table = new Table(UnitValue.createPercentArray(dataTable.getColumnCount()))
.setWidth(UnitValue.createPercentValue(100))
.setMarginBottom(15);
// 表头
for (String header : dataTable.getColumnNames()) {
table.addHeaderCell(new Cell()
.add(new Paragraph(header).setBold().setFontSize(9))
.setBackgroundColor(new DeviceRgb(66, 133, 244))
.setFontColor(ColorConstants.WHITE)
.setPadding(4));
}
// 数据行
boolean alternating = false;
for (List<String> row : dataTable.getStringRows()) {
for (String cell : row) {
table.addCell(new Cell()
.add(new Paragraph(cell != null ? cell : "").setFontSize(9))
.setBackgroundColor(alternating ?
new DeviceRgb(245, 245, 245) : ColorConstants.WHITE)
.setPadding(4));
}
alternating = !alternating;
}
doc.add(table);
}
}端到端流程编排
/**
* 报告生成流程编排器
* 协调各子系统,完成从请求到PDF的完整流程
*/
@Service
@Slf4j
public class ReportGenerationOrchestrator {
@Autowired
private ReportDataAggregator dataAggregator;
@Autowired
private ChartConfigGenerator chartConfigGenerator;
@Autowired
private ChartRenderService chartRenderService;
@Autowired
private ReportTextAnalyzer textAnalyzer;
@Autowired
private ReportPdfAssembler pdfAssembler;
public GenerationResult generate(ReportRequest request) {
long totalStart = System.currentTimeMillis();
log.info("开始生成报告: type={}, period={}", request.getReportType(), request.getPeriod());
// 1. 获取数据(并行)
ReportDataContext dataContext = dataAggregator.fetchReportData(
request.getDefinition(), request.getPeriod());
// 2. 生成所有图表(并行)
Map<String, String> chartConfigs = new HashMap<>();
for (ReportSection sectionDef : request.getDefinition().getSections()) {
if (sectionDef.hasChart()) {
DataTable data = dataContext.getDataTable(sectionDef.getDataQueryId());
String config = chartConfigGenerator.generateEchartsConfig(
data, sectionDef.getChartContext());
chartConfigs.put(sectionDef.getSectionId(), config);
}
}
Map<String, byte[]> chartImages = chartRenderService.renderChartsBatch(
chartConfigs, 800, 400);
// 3. 生成文字分析(并行,因为每个section独立)
List<CompletableFuture<String>> analysisFutures = new ArrayList<>();
List<ReportSection> sections = request.getDefinition().getSections();
for (ReportSection sectionDef : sections) {
byte[] chartImage = chartImages.get(sectionDef.getSectionId());
DataTable data = dataContext.getDataTable(sectionDef.getDataQueryId());
analysisFutures.add(CompletableFuture.supplyAsync(() ->
textAnalyzer.generateChartAnalysis(
chartImage, data, sectionDef.getChartContext(), request.getPeriod())));
}
List<String> analyses = analysisFutures.stream()
.map(f -> {
try {
return f.get(60, TimeUnit.SECONDS);
} catch (Exception e) {
return "分析生成失败: " + e.getMessage();
}
})
.collect(Collectors.toList());
// 4. 生成执行摘要
String summary = textAnalyzer.generateReportSummary(analyses, dataContext, request.getPeriod());
// 5. 组装PDF
ReportAssemblyContext assemblyCtx = buildAssemblyContext(
request, sections, chartImages, analyses, summary, dataContext);
byte[] pdfBytes = pdfAssembler.assemblePdf(assemblyCtx);
long totalElapsed = System.currentTimeMillis() - totalStart;
log.info("报告生成完成: type={}, elapsed={}ms, pdfSize={}KB",
request.getReportType(), totalElapsed, pdfBytes.length / 1024);
return GenerationResult.success(pdfBytes, totalElapsed);
}
private ReportAssemblyContext buildAssemblyContext(ReportRequest request,
List<ReportSection> sections,
Map<String, byte[]> chartImages,
List<String> analyses,
String summary,
ReportDataContext dataContext) {
List<ReportSection> assemblySections = new ArrayList<>();
for (int i = 0; i < sections.size(); i++) {
ReportSection sectionDef = sections.get(i);
assemblySections.add(ReportSection.builder()
.title(sectionDef.getTitle())
.chartImageBytes(chartImages.get(sectionDef.getSectionId()))
.analysisText(i < analyses.size() ? analyses.get(i) : null)
.dataTable(dataContext.getDataTable(sectionDef.getDataQueryId()))
.build());
}
return ReportAssemblyContext.builder()
.reportTitle(request.getDefinition().getTitle() + " - " +
request.getPeriod().getDisplayName())
.periodDescription(request.getPeriod().toString())
.executiveSummary(summary)
.sections(assemblySections)
.build();
}
}实践效果
我们实际部署后的数据:
- 月度报告生成时间:从2天 → 2小时(初稿)
- 分析师只需复核和补充约20%的内容
- 报告一致性显著提升(不再有"不同人写的风格完全不同"的问题)
- 数据准确率:自动生成部分 99%+(数字直接从数据库来,不经过人工誊写)
最关键的收益是分析师可以把节省的时间用于更深度的洞见,而不是机械性的数据搬运。
