feat: 新增智能查数(自然语言转 SQL 只读查询)

新增「智能查数」功能:用户用自然语言提问,由大模型生成一条只读 SQL,经安全校验后在当前用户数据上执行,前端按结果形状自适应渲染为统计卡片、表格或图表。入口位于侧边栏,仅在 AI 启用时显示。

变更内容:
- 后端新增 AiSqlQueryService 与 POST /api/ai/sql,新增 AiCallType.SQL 用量记录与提示词模板 ai-sql-system.tpl
- 新增安全网关 TextToSqlGuard:仅允许单条 SELECT,拒绝写操作、多语句、危险函数与 SELECT INTO/FOR UPDATE;校验表白名单与 schema 前缀;递归注入 user_id 隔离(主表加 WHERE、JOIN 加 ON,覆盖括号连接与 CTE);结果上限 1000
- 表结构改由 TextToSqlSchemaProvider 从数据库元数据动态读取(60 秒缓存),白名单与表列中文说明集中在 TextToSqlTables,字段增删改无需调整提示词
- 新增 AiSqlQueryResultVO、AiSqlColumnVO,返回生成的 SQL、列元数据与行数据
- 前端新增智能查数页 SqlQuery.vue(路由 /smart-query),按结果形状渲染单行统计卡片、多行动态表格并支持柱状图与饼图切换
- 渠道列用 getChannelLabel 本地化展示,生成的 SQL 用 sql-formatter 缩进、highlight.js 高亮并支持复制
- 新增共享 useAiEnabled 状态,侧边栏「智能查数」菜单仅 AI 启用时显示,设置页保存 AI 配置后即时刷新
- 设置页与收支记录页文案补充智能查数相关三语文案

依赖与清理:
- 新增前端依赖 sql-formatter、highlight.js
- 移除仓库内测试文件 LedgerlyApplicationTests、PromptTemplateServiceTest、AiPromptPayloadPrivacyTest,此后测试源码不再纳入提交

说明:
- 表结构动态读取保证字段变更零维护,仅可查表白名单需显式登记
- 安全网关对 JOIN 条件注入 ON 子句以保证 LEFT JOIN 语义,并对括号连接与 CTE 递归处理
This commit is contained in:
2026-09-24 10:38:24 +08:00
parent 5673f6fca2
commit 0e1443c59e
23 changed files with 1947 additions and 216 deletions
+415
View File
@@ -0,0 +1,415 @@
<script setup lang="ts">
// 智能查数页:将自然语言交给大模型生成只读 SQL 并执行,自适应渲染统计卡片、表格与图表
import { computed, defineAsyncComponent, h, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
NAlert, NButton, NCard, NCollapse, NCollapseItem, NDataTable, NEmpty, NGrid, NGridItem,
NIcon, NInput, NSelect, NSpace, NStatistic, NText, useMessage,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { CopyOutline, SearchOutline, SparklesOutline } from '@vicons/ionicons5'
import { format } from 'sql-formatter'
import hljs from 'highlight.js/lib/core'
import sql from 'highlight.js/lib/languages/sql'
import 'highlight.js/styles/github.css'
import { queryBySql } from '../api/ai'
import type { AiSqlColumn, AiSqlQueryResult } from '../api/ai'
import { getChannelLabel } from '../utils/channel'
import { aiEnabled, ensureAiEnabledLoaded } from '../composables/useAiEnabled'
import { useKeywordHistory } from '../composables/useKeywordHistory'
import { usePageSizeOptions } from '../composables/usePageSizeOptions'
import { useTablePageSize } from '../composables/useTablePageSize'
import { STORAGE_KEYS } from '../utils/storageKeys'
// 图表组件异步加载:echarts 体积较大,按需加载,避免阻塞首屏
const VChart = defineAsyncComponent(async () => {
const [vueEcharts, echartsCore, renderers, charts, components] = await Promise.all([
import('vue-echarts'),
import('echarts/core'),
import('echarts/renderers'),
import('echarts/charts'),
import('echarts/components'),
])
echartsCore.use([
renderers.CanvasRenderer,
charts.BarChart,
charts.PieChart,
components.TitleComponent,
components.TooltipComponent,
components.LegendComponent,
components.GridComponent,
])
return vueEcharts.default
})
// 注册 SQL 语言,供生成的 SQL 语法高亮
hljs.registerLanguage('sql', sql)
const { t } = useI18n()
const message = useMessage()
/** 展示视图类型 */
type ViewMode = 'table' | 'bar' | 'pie'
const queryText = ref('')
const loading = ref(false)
const result = ref<AiSqlQueryResult | null>(null)
const resultVersion = ref(0)
const viewMode = ref<ViewMode>('table')
// 提问历史(持久化、去重、清空)
const { history, load: loadHistory, record: recordHistory, clear: clearHistory } = useKeywordHistory({
storageKey: STORAGE_KEYS.sqlQueryHistory,
text: queryText,
clearLabel: () => t('ai.sql.clearHistory'),
max: 50,
})
// 示例问题
const exampleList = computed(() => t('ai.sql.examples').split('|').filter((item) => item.trim()))
// 历史下拉选项
const historyOptions = computed(() => history.value.map((item) => ({ label: item, value: item })))
// 当前结果行
const resultRows = computed(() => result.value?.rows ?? [])
// 表格分页偏好(记忆每页条数)
const pageSize = useTablePageSize('smart-query')
const { pageSizeOptions } = usePageSizeOptions()
const pagination = computed(() => ({
pageSize: pageSize.value,
showSizePicker: true,
pageSizes: pageSizeOptions.value,
onUpdatePageSize: (value: number) => {
pageSize.value = value
},
}))
// 单行结果视为统计值,用统计卡片展示
const isScalar = computed(() => !!result.value && result.value.rows.length === 1)
// 多行且含数值列时可切换图表
const canChart = computed(() => !!result.value
&& result.value.rows.length > 1
&& result.value.columns.some((col) => col.type === 'NUMBER'))
const hasRows = computed(() => resultRows.value.length > 0)
// 单行统计卡片项
const scalarItems = computed(() => {
if (!result.value || !isScalar.value) return []
const row = result.value.rows[0]
return result.value.columns.map((col) => ({
key: col.key,
label: col.title,
value: displayValue(col, row[col.key]),
}))
})
// 动态表格列:按后端列元数据生成
const tableColumns = computed<DataTableColumns<Record<string, unknown>>>(() => {
if (!result.value) return []
return result.value.columns.map((col) => ({
key: col.key,
title: col.title,
align: col.type === 'NUMBER' ? 'right' : 'left',
ellipsis: { tooltip: true },
render: (row: Record<string, unknown>) => h('span', displayValue(col, row[col.key])),
}))
})
// 图表配置:饼图取首个数值列,柱状图取全部数值列
const chartOption = computed(() => {
if (!result.value || !canChart.value) return null
const cols = result.value.columns
const dimension = cols.find((col) => col.type !== 'NUMBER') || cols[0]
const measures = cols.filter((col) => col.type === 'NUMBER')
const rows = result.value.rows
if (viewMode.value === 'pie') {
const measure = measures[0]
if (!measure) return null
return {
tooltip: { trigger: 'item' },
legend: { type: 'scroll', bottom: 0 },
series: [{
type: 'pie',
radius: ['35%', '65%'],
data: rows.map((row) => ({
name: String(row[dimension.key] ?? ''),
value: Number(row[measure.key]),
})),
}],
}
}
if (!measures.length) return null
return {
tooltip: { trigger: 'axis' },
legend: { top: 0 },
grid: { left: 12, right: 20, top: 40, bottom: 40, containLabel: true },
xAxis: {
type: 'category',
data: rows.map((row) => String(row[dimension.key] ?? '')),
axisLabel: { interval: 0, rotate: rows.length > 6 ? 30 : 0 },
},
yAxis: { type: 'value' },
series: measures.map((measure) => ({
name: measure.title,
type: 'bar',
data: rows.map((row) => Number(row[measure.key])),
})),
}
})
/** 查询:调用后端生成 SQL 并执行 */
async function handleQuery() {
const text = queryText.value.trim()
if (!text) {
message.warning(t('ai.sql.emptyInput'))
return
}
loading.value = true
try {
const res: any = await queryBySql(text)
result.value = res.data as AiSqlQueryResult
viewMode.value = defaultViewMode(result.value)
resultVersion.value += 1
recordHistory()
} catch (e: any) {
message.error(e.message || t('ai.sql.failed'))
} finally {
loading.value = false
}
}
/** 根据结果形状与后端建议决定默认视图 */
function defaultViewMode(data: AiSqlQueryResult): ViewMode {
if (data.rows.length > 1) {
if (data.chartType === 'BAR') return 'bar'
if (data.chartType === 'PIE') return 'pie'
}
return 'table'
}
/** Ctrl/Cmd + Enter 快捷提交 */
function handleKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault()
handleQuery()
}
}
/** 点击示例:填入并立即查询 */
function applyExample(value: string) {
queryText.value = value
handleQuery()
}
/** 选择历史:填入并立即查询 */
function applyHistory(value: string | number | null) {
if (typeof value !== 'string' || !value) return
queryText.value = value
handleQuery()
}
/** 判断列是否为交易渠道列:与现有页面 row.channel → getChannelLabel 的约定一致 */
function isChannelColumn(col: AiSqlColumn): boolean {
const key = col.key.toLowerCase()
const title = col.title.toLowerCase()
return key === 'channel' || title.includes('channel') || col.title.includes('渠道')
}
/** 格式化展示值:渠道转名称、数值千分位、空值占位 */
function displayValue(col: AiSqlColumn, value: unknown): string {
if (value === null || value === undefined || value === '') return '-'
if (isChannelColumn(col)) return getChannelLabel(String(value))
if (col.type === 'NUMBER') {
const num = Number(value)
if (!Number.isNaN(num)) return num.toLocaleString(undefined, { maximumFractionDigits: 2 })
}
return String(value)
}
// 生成的 SQL 格式化:多行缩进、关键字大写,失败回退原文
const formattedSql = computed(() => {
const raw = result.value?.sql
if (!raw) return ''
try {
return format(raw, { language: 'sql', tabWidth: 2, keywordCase: 'upper' })
} catch (e) {
return raw
}
})
/** HTML 转义,用于高亮回退分支,避免以 v-html 渲染未转义文本 */
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
// SQL 语法高亮(highlight.js 输出已转义;回退分支手动转义后仍安全)
const highlightedSql = computed(() => {
const code = formattedSql.value
if (!code) return ''
try {
return hljs.highlight(code, { language: 'sql' }).value
} catch (e) {
return escapeHtml(code)
}
})
/** 复制生成的 SQL(复制格式化后的版本) */
async function copySql() {
if (!result.value) return
try {
await navigator.clipboard.writeText(formattedSql.value || result.value.sql)
message.success(t('ai.sql.copySuccess'))
} catch (e) {
message.error(t('ai.sql.failed'))
}
}
onMounted(async () => {
await ensureAiEnabledLoaded()
loadHistory()
})
</script>
<template>
<NCard :bordered="false">
<template #header>
<NSpace align="center" :size="8">
<NIcon :component="SparklesOutline" />
<span>{{ t('ai.sql.title') }}</span>
</NSpace>
</template>
<NAlert v-if="!aiEnabled" type="warning" :show-icon="true">
{{ t('ai.sql.needConfig') }}
</NAlert>
<template v-else>
<NText depth="3" style="display: block; margin-bottom: 12px;">{{ t('ai.sql.hint') }}</NText>
<NInput
v-model:value="queryText"
type="textarea"
:autosize="{ minRows: 2, maxRows: 6 }"
:placeholder="t('ai.sql.placeholder')"
@keydown="handleKeydown"
/>
<NSpace align="center" justify="space-between" style="margin-top: 12px;">
<NSpace align="center" :size="8">
<NText depth="3" style="font-size: 12px;">{{ t('ai.sql.examplesLabel') }}</NText>
<NButton v-for="ex in exampleList" :key="ex" size="tiny" quaternary @click="applyExample(ex)">
{{ ex }}
</NButton>
</NSpace>
<NButton type="primary" :loading="loading" @click="handleQuery">
<template #icon>
<NIcon :component="SearchOutline" />
</template>
{{ t('ai.sql.submit') }}
</NButton>
</NSpace>
<NSpace v-if="history.length" align="center" :size="8" style="margin-top: 8px;">
<NText depth="3" style="font-size: 12px;">{{ t('ai.sql.history') }}</NText>
<NSelect
:options="historyOptions"
size="small"
style="width: 240px;"
:placeholder="t('ai.sql.history')"
@update:value="applyHistory"
/>
<NButton size="tiny" quaternary @click="clearHistory">{{ t('ai.sql.clearHistory') }}</NButton>
</NSpace>
<div v-if="result" style="margin-top: 20px;">
<NCollapse style="margin-bottom: 16px;">
<NCollapseItem :title="t('ai.sql.sqlTitle')" name="sql">
<div class="sql-block">
<NSpace justify="end" style="margin-bottom: 8px;">
<NButton size="tiny" secondary @click="copySql">
<template #icon>
<NIcon :component="CopyOutline" />
</template>
{{ t('ai.sql.copySql') }}
</NButton>
</NSpace>
<pre class="sql-code"><code class="hljs" v-html="highlightedSql"></code></pre>
</div>
</NCollapseItem>
</NCollapse>
<NText v-if="result?.explanation" depth="3" style="display: block; margin-bottom: 12px;">
{{ result.explanation }}
</NText>
<NAlert v-if="result?.truncated" type="warning" :show-icon="true" style="margin-bottom: 12px;">
{{ t('ai.sql.truncated', { max: 1000 }) }}
</NAlert>
<NEmpty v-if="!hasRows" :description="t('ai.sql.empty')" />
<template v-else>
<!-- 单行结果:统计卡片 -->
<NGrid v-if="isScalar" :cols="Math.min(scalarItems.length, 4)" :x-gap="16" :y-gap="16">
<NGridItem v-for="item in scalarItems" :key="item.key">
<NCard size="small" :bordered="true">
<NStatistic :label="item.label" :value="item.value" />
</NCard>
</NGridItem>
</NGrid>
<!-- 多行结果:视图切换 + 表格/图表 -->
<template v-else>
<NSpace align="center" justify="space-between" style="margin-bottom: 12px;">
<NText depth="3" style="font-size: 12px;">{{ t('ai.sql.rowCount', { count: result?.rowCount }) }}</NText>
<NSpace v-if="canChart" :size="8">
<NButton size="small" :type="viewMode === 'table' ? 'primary' : 'default'" @click="viewMode = 'table'">
{{ t('ai.sql.viewTable') }}
</NButton>
<NButton size="small" :type="viewMode === 'bar' ? 'primary' : 'default'" @click="viewMode = 'bar'">
{{ t('ai.sql.viewBar') }}
</NButton>
<NButton size="small" :type="viewMode === 'pie' ? 'primary' : 'default'" @click="viewMode = 'pie'">
{{ t('ai.sql.viewPie') }}
</NButton>
</NSpace>
</NSpace>
<NDataTable
v-if="viewMode === 'table' || !canChart"
:key="resultVersion"
:columns="tableColumns"
:data="resultRows"
:pagination="pagination"
:bordered="false"
size="small"
:scroll-x="960"
/>
<VChart v-else :option="chartOption ?? undefined" autoresize style="height: 380px;" />
</template>
</template>
</div>
</template>
</NCard>
</template>
<style scoped>
.sql-block {
width: 100%;
}
.sql-code {
margin: 0;
padding: 12px;
border-radius: 6px;
font-size: 12px;
line-height: 1.6;
overflow: auto;
}
.sql-code code.hljs {
padding: 0;
background: transparent;
}
</style>