摘要:本文手把手实现"输入一句话,自动生成图表"的智能报表系统。完整覆盖 Text2SQL 原理、Java 后端实现、前端 ECharts 动态渲染、SQL 安全防注入五大模块,附可直接运行的 Spring Boot 完整代码。关键词:Text2SQL、自然语言转SQL、Java大模型、智能报表、ECharts、Spring Boot AI。
一、Text2SQL 是什么?适合哪些场景?
Text2SQL 让用户用自然语言查询数据库,无需写 SQL:
用户输入:去年每个月的销售额是多少? 系统生成:SELECT DATE_FORMAT(order_date,'%Y-%m')as month, SUM(amount)as total FROM orders WHERE YEAR(order_date)=2024GROUP BY month ORDER BY month适用场景:
| 场景 | 传统方案 | Text2SQL 方案 |
|---|---|---|
| 运营看数 | 找 BI 工程师手写报表(1-3天) | 自然语言提问,秒级出图 |
| 管理层查询 | 依赖固定报表模板 | 灵活临时查询,自助分析 |
| 数据探查 | 需要 SQL 能力 | 业务人员自助 |
不适用场景:超复杂多表嵌套子查询(>5张表关联)、数据写入操作(只做查询)。
二、整体系统架构
前端(Vue3 + ECharts) ↕ HTTP / SSE Spring Boot 后端 ├── NL 接收层:接收用户自然语言输入 ├── Schema 注入层:从数据库读取表结构 ├── LLM 调用层:调用 DeepSeek 生成 SQL ├── SQL 安全层:白名单校验 + 危险语句拦截 ├── JDBC 执行层:执行 SQL,返回结构化数据 └── 图表推荐层:根据数据结构推荐图表类型技术栈:
- 后端:Spring Boot 3.x + MyBatis + HikariCP
- 大模型:DeepSeek V4 API(兼容 OpenAI SDK)
- 数据库:MySQL 8.0
- 前端:Vue3 + ECharts 5.x
三、数据库 Schema 注入 Prompt 的正确姿势
Schema 信息是 Text2SQL 的核心。大模型需要知道有哪些表、哪些字段、字段含义,才能生成正确的 SQL。
3.1 Schema 自动提取工具
// SchemaExtractor.java@ComponentpublicclassSchemaExtractor{@AutowiredprivateDataSourcedataSource;/** * 提取指定数据库的表结构,格式化为 Prompt 友好的文本 */publicStringextractSchema(String...tableNames)throwsSQLException{StringBuildersb=newStringBuilder();sb.append("【数据库表结构】\n");try(Connectionconn=dataSource.getConnection()){DatabaseMetaDatameta=conn.getMetaData();for(StringtableName:tableNames){sb.append("\n表名:").append(tableName).append("\n");// 获取列信息ResultSetcolumns=meta.getColumns(null,null,tableName,null);sb.append("字段列表:\n");while(columns.next()){StringcolName=columns.getString("COLUMN_NAME");StringcolType=columns.getString("TYPE_NAME");Stringremarks=columns.getString("REMARKS");// 注释sb.append(String.format(" - %s (%s)%s\n",colName,colType,remarks!=null&&!remarks.isEmpty()?" // "+remarks:""));}}}returnsb.toString();}}3.2 Schema 示例(业务含义注释很关键)
【数据库表结构】 表名:orders 字段列表: -id(BIGINT)// 订单ID - order_no(VARCHAR)// 订单编号 - user_id(BIGINT)// 用户ID - amount(DECIMAL)// 订单金额(元) - status(INT)// 订单状态:0待付款1已付款2已发货3已完成4已取消 - order_date(DATETIME)// 下单时间 - city(VARCHAR)// 下单城市 表名:products 字段列表: -id(BIGINT)// 商品ID - name(VARCHAR)// 商品名称 - category(VARCHAR)// 商品分类 - price(DECIMAL)// 售价(元)关键经验:字段的业务含义注释(如
status各值代表什么)是生成正确 SQL 的最重要因素,不要省略!
四、Java 后端实现:完整链路代码
4.1 核心 Service 层
// Text2SqlService.java@Service@Slf4jpublicclassText2SqlService{@AutowiredprivateSchemaExtractorschemaExtractor;@AutowiredprivateSqlSecurityCheckersqlSecurityChecker;@AutowiredprivateJdbcTemplatejdbcTemplate;// DeepSeek API 配置(兼容 OpenAI SDK)privatefinalOpenAiApiopenAiApi;publicText2SqlService(@Value("${deepseek.api-key}")StringapiKey){this.openAiApi=newOpenAiApi("https://api.deepseek.com",apiKey);}/** * 主流程:自然语言 → SQL → 查询结果 */publicQueryResultquery(StringnaturalLanguage)throwsException{// 1. 提取 SchemaStringschema=schemaExtractor.extractSchema("orders","products","users");// 2. 调用 LLM 生成 SQLStringsql=generateSql(naturalLanguage,schema);log.info("生成 SQL: {}",sql);// 3. 安全检查sqlSecurityChecker.check(sql);// 4. 执行查询List<Map<String,Object>>data=jdbcTemplate.queryForList(sql);// 5. 推荐图表类型StringchartType=recommendChartType(naturalLanguage,data);returnQueryResult.builder().sql(sql).data(data).chartType(chartType).build();}privateStringgenerateSql(Stringquestion,Stringschema){StringsystemPrompt=""" 你是一个专业的数据库工程师,精通 MySQL。 根据用户的自然语言问题和提供的数据库表结构,生成一条正确的 MySQL 查询 SQL。 【要求】 1. 只返回 SQL 语句本身,不要有任何解释、注释或 markdown 标记 2. SQL 必须是 SELECT 语句,严禁生成 INSERT/UPDATE/DELETE/DROP 等写操作 3. 使用标准 MySQL 语法 4. 对于时间类查询,使用 DATE_FORMAT、YEAR、MONTH 等函数 5. 涉及金额的字段保留两位小数:ROUND(amount, 2) 6. 结果集超过 1000 行时自动加 LIMIT 1000 """;StringuserPrompt=schema+"\n\n【用户问题】\n"+question;ChatCompletionRequestrequest=ChatCompletionRequest.builder().model("deepseek-chat").messages(List.of(newChatCompletionMessage(systemPrompt,Role.SYSTEM),newChatCompletionMessage(userPrompt,Role.USER))).temperature(0.1)// 低温度,确保SQL稳定.maxTokens(1024).build();ChatCompletionresponse=openAiApi.chatCompletion(request).block();StringrawSql=response.choices().get(0).message().content();// 清理可能的 markdown 代码块标记returncleanSql(rawSql);}privateStringcleanSql(Stringraw){returnraw.replaceAll("```sql","").replaceAll("```","").trim();}privateStringrecommendChartType(Stringquestion,List<Map<String,Object>>data){if(data.isEmpty())return"table";// 简单规则:根据关键词和数据结构推断Stringq=question.toLowerCase();intcols=data.get(0).size();if(q.contains("趋势")||q.contains("每月")||q.contains("每天")){return"line";// 折线图}elseif(q.contains("占比")||q.contains("比例")||q.contains("分布")){return"pie";// 饼图}elseif(cols==2){return"bar";// 柱状图}else{return"table";// 数据表格}}}4.2 SQL 安全防注入检查器
// SqlSecurityChecker.java@ComponentpublicclassSqlSecurityChecker{// 危险关键词(黑名单)privatestaticfinalList<String>DANGEROUS_KEYWORDS=Arrays.asList("INSERT","UPDATE","DELETE","DROP","TRUNCATE","ALTER","CREATE","GRANT","REVOKE","EXEC","EXECUTE","UNION","--","/*","xp_");// 允许的 SQL 起始词(白名单)privatestaticfinalList<String>ALLOWED_STARTS=Arrays.asList("SELECT","WITH");publicvoidcheck(Stringsql){if(sql==null||sql.trim().isEmpty()){thrownewIllegalArgumentException("SQL 不能为空");}StringupperSql=sql.trim().toUpperCase();// 白名单:必须以 SELECT 或 WITH 开头booleanvalidStart=ALLOWED_STARTS.stream().anyMatch(upperSql::startsWith);if(!validStart){thrownewSecurityException("只允许 SELECT 查询,拒绝执行: "+sql);}// 黑名单:检测危险关键词for(Stringkeyword:DANGEROUS_KEYWORDS){// 使用词边界匹配,避免误判(如 "selector" 误报 "SELECT")Stringpattern="\\b"+keyword+"\\b";if(upperSql.matches(".*"+pattern+".*")){thrownewSecurityException("SQL 包含危险操作: "+keyword);}}// 长度限制if(sql.length()>5000){thrownewIllegalArgumentException("SQL 过长,拒绝执行");}}}4.3 Controller 层(支持 SSE 流式)
// QueryController.java@RestController@RequestMapping("/api/query")@CrossOriginpublicclassQueryController{@AutowiredprivateText2SqlServicetext2SqlService;@PostMapping("/ask")publicResponseEntity<QueryResult>ask(@RequestBodyQueryRequestrequest){try{QueryResultresult=text2SqlService.query(request.getQuestion());returnResponseEntity.ok(result);}catch(SecurityExceptione){returnResponseEntity.badRequest().body(QueryResult.error("安全检查未通过: "+e.getMessage()));}catch(Exceptione){log.error("查询失败",e);returnResponseEntity.internalServerError().body(QueryResult.error("查询失败,请换个说法重试"));}}}// 请求/响应 DTO@Data@BuilderpublicclassQueryResult{privateStringsql;privateList<Map<String,Object>>data;privateStringchartType;privateStringerror;publicstaticQueryResulterror(Stringmsg){returnQueryResult.builder().error(msg).build();}}五、前端 ECharts 动态渲染
根据后端返回的chartType和data,前端自动选择图表类型:
// ChartRenderer.vue<template><div><divclass="query-input"><el-input v-model="question"placeholder="请输入问题,如:去年每月的销售额是多少?"@keyup.enter="query"/><el-button type="primary"@click="query":loading="loading">查询</el-button></div><!--生成的SQL展示--><div v-if="result.sql"class="sql-preview"><code>{{result.sql}}</code></div><!--动态图表--><div ref="chartRef"style="width: 100%; height: 400px;"v-show="showChart"/><!--数据表格降级--><el-table v-if="result.chartType === 'table'":data="result.data"border/></div></template><script setup>import*asechartsfrom'echarts'import{ref,onMounted,watch}from'vue'importaxiosfrom'axios'constquestion=ref('')constresult=ref({})constloading=ref(false)constchartRef=ref(null)letchartInstance=nullonMounted(()=>{chartInstance=echarts.init(chartRef.value)})constquery=async()=>{loading.value=truetry{const{data}=awaitaxios.post('/api/query/ask',{question:question.value})result.value=dataif(data.chartType!=='table'){renderChart(data)}}finally{loading.value=false}}constrenderChart=(result)=>{constrows=result.dataif(!rows||rows.length===0)returnconstkeys=Object.keys(rows[0])constxKey=keys[0]// 第一列为 X 轴(时间/类别)constyKey=keys[1]// 第二列为 Y 轴(数值)constxData=rows.map(r=>r[xKey])constyData=rows.map(r=>Number(r[yKey]))constoptionMap={bar:{xAxis:{type:'category',data:xData},yAxis:{type:'value'},series:[{type:'bar',data:yData,label:{show:true}}]},line:{xAxis:{type:'category',data:xData},yAxis:{type:'value'},series:[{type:'line',data:yData,smooth:true,areaStyle:{}}]},pie:{series:[{type:'pie',data:rows.map(r=>({name:r[xKey],value:r[yKey]})),radius:'60%'}]}}chartInstance.setOption({tooltip:{trigger:'axis'},...optionMap[result.chartType]})}</script>六、实测效果与已知局限
6.1 实测效果(测试数据库:电商订单系统)
| 测试问题 | 生成 SQL 是否正确 | 响应时间 |
|---|---|---|
| 去年每月销售额趋势 | ✅ | 2.3s |
| 各城市订单量 Top10 | ✅ | 1.8s |
| 已完成订单的平均金额 | ✅ | 1.5s |
| 最近 7 天每日新增用户数 | ✅ | 2.1s |
| 退款率最高的商品分类 | ⚠️ 需要 refund 表,提示无数据 | 1.9s |
| 每个销售员的业绩排名 | ❌(未提供 sales 表结构) | 2.0s |
准确率约 80-90%,错误主要来自:跨表查询未提供关联表 Schema。
6.2 已知局限与应对方案
| 局限 | 现象 | 应对方案 |
|---|---|---|
| 多表复杂 JOIN | SQL 逻辑错误 | 提供更详细的表关系注释 |
| 模糊时间描述 | “最近”、"近期"理解偏差 | 在 Prompt 中定义时间规范 |
| 方言/缩写 | "GMV"等业务术语不识别 | Schema 注释中解释术语 |
| SQL 注入风险 | 已通过安全检查器覆盖 | 双重防护:LLM 约束 + 白名单 |
七、生产环境优化建议
// 1. SQL 执行加超时保护jdbcTemplate.setQueryTimeout(30);// 超过 30 秒自动终止// 2. 结果集大小限制if(data.size()>10000){thrownewRuntimeException("查询结果超过1万行,请缩小查询范围");}// 3. 审计日志:记录每次 NL → SQL 的映射@EventListenerpublicvoidlogQuery(QueryEventevent){auditLogRepository.save(AuditLog.builder().question(event.getQuestion()).generatedSql(event.getSql()).executionTime(event.getDuration()).userId(event.getUserId()).build());}总结
本文完整实现了自然语言 → SQL → ECharts 图表的智能报表全链路。核心要点:
- Schema 注释的质量决定 SQL 准确率的上限,业务含义必须写清楚
- SQL 安全检查是必须的,绝不能省略白名单校验
- 图表类型推荐可以先用简单规则,后期迭代为大模型推荐
- 生产环境需要审计日志,方便追踪问题和持续优化 Prompt