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
+2
View File
@@ -12,8 +12,10 @@
"@vicons/ionicons5": "^0.13.0", "@vicons/ionicons5": "^0.13.0",
"axios": "^1.20.0", "axios": "^1.20.0",
"echarts": "^6.1.0", "echarts": "^6.1.0",
"highlight.js": "^11.12.0",
"naive-ui": "^2.45.2", "naive-ui": "^2.45.2",
"pinia": "^4.0.3", "pinia": "^4.0.3",
"sql-formatter": "^15.9.0",
"vue": "^3.5.41", "vue": "^3.5.41",
"vue-echarts": "^8.1.0", "vue-echarts": "^8.1.0",
"vue-i18n": "^11.4.12", "vue-i18n": "^11.4.12",
+30
View File
@@ -234,6 +234,36 @@ export function queryByAi(query: string) {
return request.post('/ai/query', { query }, { timeout: 120000 }) return request.post('/ai/query', { query }, { timeout: 120000 })
} }
/** 智能查数结果的列元数据 */
export interface AiSqlColumn {
key: string
title: string
type: 'NUMBER' | 'STRING' | 'DATE' | 'DATETIME' | 'BOOLEAN'
}
/** 智能查数结果 */
export interface AiSqlQueryResult {
/** 生成的 SQL(经安全校验后的最终语句) */
sql: string
/** 查询意图说明 */
explanation: string
/** 建议的展示类型 */
chartType: 'SCALAR' | 'TABLE' | 'BAR' | 'PIE'
/** 结果列元数据 */
columns: AiSqlColumn[]
/** 结果行数据 */
rows: Record<string, unknown>[]
/** 返回行数 */
rowCount: number
/** 是否因行数上限被截断 */
truncated: boolean
}
/** 将自然语言转换为只读 SQL 并执行查询(调用大模型,超时放宽) */
export function queryBySql(query: string) {
return request.post('/ai/sql', { query }, { timeout: 120000 })
}
/** 自然语言记账解析结果 */ /** 自然语言记账解析结果 */
export interface AiRecordResult { export interface AiRecordResult {
summary: string summary: string
@@ -10,6 +10,7 @@ import {
SettingsOutline, KeyOutline, LinkOutline, CubeOutline, SaveOutline, FlashOutline, StatsChartOutline, CameraOutline, SettingsOutline, KeyOutline, LinkOutline, CubeOutline, SaveOutline, FlashOutline, StatsChartOutline, CameraOutline,
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
import { getAiConfig, saveAiConfig, testAiConfig } from '../api/ai' import { getAiConfig, saveAiConfig, testAiConfig } from '../api/ai'
import { refreshAiEnabled } from '../composables/useAiEnabled'
const router = useRouter() const router = useRouter()
const message = useMessage() const message = useMessage()
@@ -58,6 +59,7 @@ async function handleSave() {
await saveAiConfig(buildPayload()) await saveAiConfig(buildPayload())
message.success(t('ai.saveSuccess')) message.success(t('ai.saveSuccess'))
await loadConfig() await loadConfig()
await refreshAiEnabled()
} catch (e: any) { } catch (e: any) {
message.error(e.message || t('common.operationFailed')) message.error(e.message || t('common.operationFailed'))
} finally { } finally {
@@ -73,6 +75,7 @@ async function handleTest() {
await testAiConfig() await testAiConfig()
message.success(t('ai.testSuccess')) message.success(t('ai.testSuccess'))
await loadConfig() await loadConfig()
await refreshAiEnabled()
} catch (e: any) { } catch (e: any) {
message.error(e.message || t('ai.testFailed')) message.error(e.message || t('ai.testFailed'))
} finally { } finally {
+35
View File
@@ -0,0 +1,35 @@
// AI 启用状态共享 composable:全局单例,供侧边栏菜单与页面统一控制 AI 相关元素显隐
import { ref } from 'vue'
import { getAiConfig } from '../api/ai'
/** 当前用户 AI 是否启用;读取失败一律按未启用处理 */
export const aiEnabled = ref(false)
/** 是否已成功加载过一次,避免重复请求 */
let loaded = false
/**
* 从后端刷新 AI 启用状态。
* 设置页保存 AI 配置后调用,可让侧边栏菜单即时显隐。
*/
export async function refreshAiEnabled() {
try {
const res: any = await getAiConfig()
aiEnabled.value = !!res?.data?.enabled
} catch (e) {
aiEnabled.value = false
}
loaded = true
}
/** 确保至少加载过一次 AI 启用状态(已加载则不重复请求) */
export async function ensureAiEnabledLoaded() {
if (loaded) return
await refreshAiEnabled()
}
/** 重置为未启用(退出登录等场景使用) */
export function resetAiEnabled() {
aiEnabled.value = false
loaded = false
}
+27
View File
@@ -93,6 +93,7 @@ export default {
tags: { zh: '标签管理', en: 'Tags', ja: 'タグ管理' }, tags: { zh: '标签管理', en: 'Tags', ja: 'タグ管理' },
rules: { zh: '规则管理', en: 'Rules', ja: 'ルール管理' }, rules: { zh: '规则管理', en: 'Rules', ja: 'ルール管理' },
emailBills: { zh: '邮箱账单', en: 'Email Bills', ja: 'メール請求' }, emailBills: { zh: '邮箱账单', en: 'Email Bills', ja: 'メール請求' },
sqlQuery: { zh: '智能查数', en: 'Smart Data', ja: 'スマート集計' },
logs: { zh: '日志', en: 'Logs', ja: 'ログ' }, logs: { zh: '日志', en: 'Logs', ja: 'ログ' },
settings: { zh: '设置', en: 'Settings', ja: '設定' }, settings: { zh: '设置', en: 'Settings', ja: '設定' },
user: { zh: '用户', en: 'User', ja: 'ユーザー' }, user: { zh: '用户', en: 'User', ja: 'ユーザー' },
@@ -845,6 +846,31 @@ export default {
includeNo: { zh: '不计入', en: 'Excluded', ja: '含めない' }, includeNo: { zh: '不计入', en: 'Excluded', ja: '含めない' },
unmatched: { zh: '未识别', en: 'Unmatched', ja: '未認識' }, unmatched: { zh: '未识别', en: 'Unmatched', ja: '未認識' },
}, },
sql: {
entry: { zh: '智能查数', en: 'Smart Data', ja: 'スマート集計' },
title: { zh: '智能查数', en: 'Smart Data Query', ja: 'スマート集計' },
hint: { zh: '用自然语言描述你想查的内容,AI 会生成只读查询并展示结果;只能查询你本人的数据。', en: 'Describe what you want to query; AI generates a read-only query and shows the result. Only your own data can be queried.', ja: '知りたい内容を自然言語で説明すると、AI が読み取り専用クエリを生成して結果を表示します。自分のデータのみ参照できます。' },
placeholder: { zh: '例如:上个月一共花了多少钱', en: 'e.g. How much did I spend last month in total?', ja: '例:先月の支出合計はいくら?' },
examples: { zh: '上个月一共花了多少钱|按分类统计今年的支出|今年收入最高的一个月|各渠道的支出占比|本月支出最高的5笔', en: 'How much did I spend last month?|Expenses by category this year|Month with highest income this year|Spending share by channel|Top 5 expenses this month', ja: '先月の支出合計|今年のカテゴリ別支出|今年の収入が最も多い月|チャネル別支出の割合|今月の支出トップ5' },
submit: { zh: '查询', en: 'Query', ja: '検索' },
submitting: { zh: '正在查询,请稍候…', en: 'Querying, please wait…', ja: '検索中です。しばらくお待ちください…' },
emptyInput: { zh: '请输入查询内容', en: 'Please enter a query', ja: '検索内容を入力してください' },
failed: { zh: '智能查数失败', en: 'Smart data query failed', ja: 'スマート集計に失敗しました' },
resultTitle: { zh: '查询结果', en: 'Result', ja: '結果' },
sqlTitle: { zh: '生成的 SQL', en: 'Generated SQL', ja: '生成された SQL' },
copySql: { zh: '复制 SQL', en: 'Copy SQL', ja: 'SQL をコピー' },
copySuccess: { zh: '已复制', en: 'Copied', ja: 'コピーしました' },
empty: { zh: '查询没有返回结果', en: 'The query returned no result', ja: '結果がありません' },
rowCount: { zh: '共 {count} 行', en: '{count} row(s)', ja: '{count} 行' },
truncated: { zh: '结果超过 {max} 行,仅展示前 {max} 行', en: 'More than {max} rows; only the first {max} are shown', ja: '{max} 行を超えるため、先頭 {max} 行のみ表示します' },
viewTable: { zh: '表格', en: 'Table', ja: 'テーブル' },
viewBar: { zh: '柱状图', en: 'Bar chart', ja: '棒グラフ' },
viewPie: { zh: '饼图', en: 'Pie chart', ja: '円グラフ' },
examplesLabel: { zh: '示例', en: 'Examples', ja: '例' },
history: { zh: '历史提问', en: 'History', ja: '履歴' },
clearHistory: { zh: '清空历史', en: 'Clear history', ja: '履歴をクリア' },
needConfig: { zh: 'AI 未启用,请先在「设置」中开启 AI。', en: 'AI is not enabled. Please enable it in Settings first.', ja: 'AI が有効になっていません。「設定」で有効にしてください。' },
},
record: { record: {
button: { zh: '智能记账', en: 'Smart Entry', ja: 'スマート入力' }, button: { zh: '智能记账', en: 'Smart Entry', ja: 'スマート入力' },
title: { zh: 'AI 智能记账', en: 'AI Smart Entry', ja: 'AI スマート入力' }, title: { zh: 'AI 智能记账', en: 'AI Smart Entry', ja: 'AI スマート入力' },
@@ -923,6 +949,7 @@ export default {
rule: { zh: '生成规则', en: 'Rule', ja: 'ルール生成' }, rule: { zh: '生成规则', en: 'Rule', ja: 'ルール生成' },
statistics: { zh: '统计搜索', en: 'Statistics', ja: '統計検索' }, statistics: { zh: '统计搜索', en: 'Statistics', ja: '統計検索' },
relation: { zh: '生成关系', en: 'Relation', ja: '関連生成' }, relation: { zh: '生成关系', en: 'Relation', ja: '関連生成' },
sql: { zh: '智能查数', en: 'Smart Data', ja: 'スマート集計' },
}, },
column: { column: {
time: { zh: '时间', en: 'Time', ja: '日時' }, time: { zh: '时间', en: 'Time', ja: '日時' },
+5
View File
@@ -41,6 +41,11 @@ const router = createRouter({
name: 'Dashboard', name: 'Dashboard',
component: () => import('../views/Dashboard.vue'), // 统计面板 component: () => import('../views/Dashboard.vue'), // 统计面板
}, },
{
path: 'smart-query',
name: 'SqlQuery',
component: () => import('../views/SqlQuery.vue'), // 智能查数(自然语言转 SQL)
},
{ {
path: 'transactions', path: 'transactions',
name: 'Transactions', name: 'Transactions',
+2
View File
@@ -33,6 +33,8 @@ export const STORAGE_KEYS = {
transactionAiQueryHistory: 'ledgerly.transaction.aiQueryHistory', transactionAiQueryHistory: 'ledgerly.transaction.aiQueryHistory',
// 收支记录 AI 查询浮窗位置尺寸 // 收支记录 AI 查询浮窗位置尺寸
transactionAiQueryWindow: 'ledgerly.transaction.aiQueryWindow', transactionAiQueryWindow: 'ledgerly.transaction.aiQueryWindow',
// 智能查数的历史提问
sqlQueryHistory: 'ledgerly.sqlQuery.history',
// 内容区滚动位置(sessionStorage,按路由拼接后缀) // 内容区滚动位置(sessionStorage,按路由拼接后缀)
scrollPrefix: 'ledgerly.scroll.', scrollPrefix: 'ledgerly.scroll.',
} as const } as const
+14 -4
View File
@@ -5,12 +5,13 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown } from 'naive-ui' import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown } from 'naive-ui'
import type { MenuOption } from 'naive-ui' import type { MenuOption } from 'naive-ui'
import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, OptionsOutline, MailOutline, DocumentTextOutline, SettingsOutline, LogOutOutline, ChevronDownOutline, WalletOutline } from '@vicons/ionicons5' import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, OptionsOutline, MailOutline, DocumentTextOutline, SettingsOutline, LogOutOutline, ChevronDownOutline, WalletOutline, SparklesOutline } from '@vicons/ionicons5'
import { useUserStore } from '../store/user' import { useUserStore } from '../store/user'
import LocaleSwitch from '../components/LocaleSwitch.vue' import LocaleSwitch from '../components/LocaleSwitch.vue'
import { getChannels } from '../api/transaction' import { getChannels } from '../api/transaction'
import { readSessionString, writeSessionString } from '../utils/storage' import { readSessionString, writeSessionString } from '../utils/storage'
import { scrollKey } from '../utils/storageKeys' import { scrollKey } from '../utils/storageKeys'
import { aiEnabled, refreshAiEnabled } from '../composables/useAiEnabled'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -21,6 +22,7 @@ const { t } = useI18n()
const activeKey = computed(() => { const activeKey = computed(() => {
const map: Record<string, string> = { const map: Record<string, string> = {
'/dashboard': 'dashboard', '/dashboard': 'dashboard',
'/smart-query': 'smart-query',
'/transactions': 'transactions', '/transactions': 'transactions',
'/categories': 'categories', '/categories': 'categories',
'/tags': 'tags', '/tags': 'tags',
@@ -41,15 +43,21 @@ function renderIcon(icon: any) {
return () => h(NIcon, null, { default: () => h(icon) }) return () => h(NIcon, null, { default: () => h(icon) })
} }
// 侧边栏菜单配置项(随语言切换响应式更新) // 侧边栏菜单配置项(随语言切换响应式更新;智能查数仅在 AI 启用时展示)
const menuOptions = computed<MenuOption[]>(() => [ const menuOptions = computed<MenuOption[]>(() => {
const options: MenuOption[] = [
{ label: t('layout.dashboard'), key: 'dashboard', icon: renderIcon(BarChartOutline) }, { label: t('layout.dashboard'), key: 'dashboard', icon: renderIcon(BarChartOutline) },
{ label: t('layout.transactions'), key: 'transactions', icon: renderIcon(ListOutline) }, { label: t('layout.transactions'), key: 'transactions', icon: renderIcon(ListOutline) },
{ label: t('layout.categories'), key: 'categories', icon: renderIcon(FolderOpenOutline) }, { label: t('layout.categories'), key: 'categories', icon: renderIcon(FolderOpenOutline) },
{ label: t('layout.tags'), key: 'tags', icon: renderIcon(PricetagsOutline) }, { label: t('layout.tags'), key: 'tags', icon: renderIcon(PricetagsOutline) },
{ label: t('layout.rules'), key: 'rules', icon: renderIcon(OptionsOutline) }, { label: t('layout.rules'), key: 'rules', icon: renderIcon(OptionsOutline) },
{ label: t('layout.emailBills'), key: 'email-bills', icon: renderIcon(MailOutline) }, { label: t('layout.emailBills'), key: 'email-bills', icon: renderIcon(MailOutline) },
]) ]
if (aiEnabled.value) {
options.splice(1, 0, { label: t('layout.sqlQuery'), key: 'smart-query', icon: renderIcon(SparklesOutline) })
}
return options
})
/** 菜单点击事件:根据 key 导航到对应路由 */ /** 菜单点击事件:根据 key 导航到对应路由 */
function handleMenuUpdate(key: string) { function handleMenuUpdate(key: string) {
@@ -184,6 +192,8 @@ onMounted(() => {
restoreScroll(route.path) restoreScroll(route.path)
// 预加载渠道列表,保证自定义渠道名称在各页面可正确展示 // 预加载渠道列表,保证自定义渠道名称在各页面可正确展示
loadChannels() loadChannels()
// 加载 AI 启用状态,控制「智能查数」菜单显隐(每次登录进入布局时刷新,避免沿用上一账号状态)
refreshAiEnabled()
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
+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>
@@ -47,6 +47,7 @@ import cn.rosercode.ledgerly.service.AiQueryService;
import cn.rosercode.ledgerly.service.AiRecordService; import cn.rosercode.ledgerly.service.AiRecordService;
import cn.rosercode.ledgerly.service.AiRelationService; import cn.rosercode.ledgerly.service.AiRelationService;
import cn.rosercode.ledgerly.service.AiRuleService; import cn.rosercode.ledgerly.service.AiRuleService;
import cn.rosercode.ledgerly.service.AiSqlQueryService;
import cn.rosercode.ledgerly.service.AiStatisticsService; import cn.rosercode.ledgerly.service.AiStatisticsService;
import cn.rosercode.ledgerly.service.AiSuggestionService; import cn.rosercode.ledgerly.service.AiSuggestionService;
import cn.rosercode.ledgerly.service.AiTagService; import cn.rosercode.ledgerly.service.AiTagService;
@@ -61,6 +62,7 @@ import cn.rosercode.ledgerly.vo.AiRecordImageResultVO;
import cn.rosercode.ledgerly.vo.AiRecordResultVO; import cn.rosercode.ledgerly.vo.AiRecordResultVO;
import cn.rosercode.ledgerly.vo.AiRelationResultVO; import cn.rosercode.ledgerly.vo.AiRelationResultVO;
import cn.rosercode.ledgerly.vo.AiRuleSuggestResultVO; import cn.rosercode.ledgerly.vo.AiRuleSuggestResultVO;
import cn.rosercode.ledgerly.vo.AiSqlQueryResultVO;
import cn.rosercode.ledgerly.vo.AiStatisticsResultVO; import cn.rosercode.ledgerly.vo.AiStatisticsResultVO;
import cn.rosercode.ledgerly.vo.AiSuggestionVO; import cn.rosercode.ledgerly.vo.AiSuggestionVO;
import cn.rosercode.ledgerly.vo.AiTagResultVO; import cn.rosercode.ledgerly.vo.AiTagResultVO;
@@ -91,6 +93,7 @@ public class AiController {
private final AiChannelService aiChannelService; private final AiChannelService aiChannelService;
private final AiTagService aiTagService; private final AiTagService aiTagService;
private final AiRuleService aiRuleService; private final AiRuleService aiRuleService;
private final AiSqlQueryService aiSqlQueryService;
private final AiStatisticsService aiStatisticsService; private final AiStatisticsService aiStatisticsService;
private final AiImageRecordService aiImageRecordService; private final AiImageRecordService aiImageRecordService;
private final AiImageRecordStreamService aiImageRecordStreamService; private final AiImageRecordStreamService aiImageRecordStreamService;
@@ -103,7 +106,8 @@ public class AiController {
AiRecordService aiRecordService, AiDecorationService aiDecorationService, AiRecordService aiRecordService, AiDecorationService aiDecorationService,
AiCategoryService aiCategoryService, AiChannelService aiChannelService, AiCategoryService aiCategoryService, AiChannelService aiChannelService,
AiTagService aiTagService, AiTagService aiTagService,
AiRuleService aiRuleService, AiStatisticsService aiStatisticsService, AiRuleService aiRuleService, AiSqlQueryService aiSqlQueryService,
AiStatisticsService aiStatisticsService,
AiImageRecordService aiImageRecordService, AiImageRecordService aiImageRecordService,
AiImageRecordStreamService aiImageRecordStreamService, AiImageRecordStreamService aiImageRecordStreamService,
AiRelationService aiRelationService, AiRelationService aiRelationService,
@@ -118,6 +122,7 @@ public class AiController {
this.aiChannelService = aiChannelService; this.aiChannelService = aiChannelService;
this.aiTagService = aiTagService; this.aiTagService = aiTagService;
this.aiRuleService = aiRuleService; this.aiRuleService = aiRuleService;
this.aiSqlQueryService = aiSqlQueryService;
this.aiStatisticsService = aiStatisticsService; this.aiStatisticsService = aiStatisticsService;
this.aiImageRecordService = aiImageRecordService; this.aiImageRecordService = aiImageRecordService;
this.aiImageRecordStreamService = aiImageRecordStreamService; this.aiImageRecordStreamService = aiImageRecordStreamService;
@@ -230,6 +235,19 @@ public class AiController {
return Result.success(aiQueryService.parse(userId, queryRequest.getQuery())); return Result.success(aiQueryService.parse(userId, queryRequest.getQuery()));
} }
/**
* 自然语言转 SQL 查询
*
* @param queryRequest 查询请求,携带自然语言语句
* @return 查询结果(含生成的 SQL、列元数据与行数据)
*/
@PostMapping("/sql")
@Operation(summary = "自然语言查数", description = "将自然语言转换为只读 SQL 并在当前用户数据上执行,返回结果集")
public Result<AiSqlQueryResultVO> sqlQuery(@RequestBody @Valid AiQueryRequest queryRequest) {
Long userId = UserContext.getUserId();
return Result.success(aiSqlQueryService.query(userId, queryRequest.getQuery()));
}
/** /**
* 解析自然语言记账内容为交易记录 * 解析自然语言记账内容为交易记录
* *
@@ -41,5 +41,8 @@ public enum AiCallType {
STATISTICS, STATISTICS,
/** 生成账单关系名称与备注 */ /** 生成账单关系名称与备注 */
RELATION RELATION,
/** 自然语言转换为只读 SQL 查询 */
SQL
} }
@@ -124,6 +124,22 @@ public class PromptTemplateService {
return render("ai-statistics-system", variables); return render("ai-statistics-system", variables);
} }
/**
* 渲染自然语言转 SQL 的系统提示词(模板 {@code ai-sql-system})。
*
* @param dialect 当前数据库方言(MySQL / SQLite)
* @param today 当天日期(yyyy-MM-dd)
* @param schema 动态读取的表结构描述
* @return 系统提示词
*/
public String textToSqlSystemPrompt(String dialect, String today, String schema) {
Map<String, Object> variables = new LinkedHashMap<>();
variables.put("dialect", dialect);
variables.put("today", today);
variables.put("schema", schema);
return render("ai-sql-system", variables);
}
/** /**
* 渲染生成分类的系统提示词(模板 {@code ai-category-system})。 * 渲染生成分类的系统提示词(模板 {@code ai-category-system})。
* *
@@ -0,0 +1,23 @@
package cn.rosercode.ledgerly.service;
import cn.rosercode.ledgerly.vo.AiSqlQueryResultVO;
/**
* 自然语言转 SQL 查询服务。
*
* <p>将用户输入的自然语言交给大模型生成只读 SELECT 语句,经安全校验后在
* 当前用户的数据上执行并返回结果。</p>
*
* @date 2026-09-24 09:01:13
*/
public interface AiSqlQueryService {
/**
* 将自然语言转换为只读 SQL 并在当前用户数据上执行。
*
* @param userId 当前用户ID
* @param query 用户输入的自然语言
* @return 查询结果(含生成的 SQL、列元数据与行数据)
*/
AiSqlQueryResultVO query(Long userId, String query);
}
@@ -0,0 +1,325 @@
package cn.rosercode.ledgerly.service.impl;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.sql.Types;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import javax.sql.DataSource;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import cn.rosercode.ledgerly.client.DeepSeekClient;
import cn.rosercode.ledgerly.client.DeepSeekResponse;
import cn.rosercode.ledgerly.common.BusinessException;
import cn.rosercode.ledgerly.config.datasource.DataSourceContextHolder;
import cn.rosercode.ledgerly.config.datasource.DataSourceType;
import cn.rosercode.ledgerly.entity.AiConfig;
import cn.rosercode.ledgerly.enums.AiCallType;
import cn.rosercode.ledgerly.prompt.PromptTemplateService;
import cn.rosercode.ledgerly.service.AiConfigService;
import cn.rosercode.ledgerly.service.AiSqlQueryService;
import cn.rosercode.ledgerly.service.AiUsageService;
import cn.rosercode.ledgerly.util.sql.TextToSqlGuard;
import cn.rosercode.ledgerly.util.sql.TextToSqlSchemaProvider;
import cn.rosercode.ledgerly.vo.AiSqlColumnVO;
import cn.rosercode.ledgerly.vo.AiSqlQueryResultVO;
/**
* 自然语言转 SQL 查询服务实现类,负责提示词组装、模型调用与只读执行。
*
* @date 2026-09-24 09:01:13
*/
@Slf4j
@Service
public class AiSqlQueryServiceImpl implements AiSqlQueryService {
/** 日期格式 */
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
/** 日期时间格式 */
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/** 时间格式 */
private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("HH:mm:ss");
/** 查询超时时间(秒) */
private static final int QUERY_TIMEOUT_SECONDS = 15;
private final AiConfigService aiConfigService;
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
private final ObjectMapper objectMapper;
private final PromptTemplateService promptTemplateService;
private final TextToSqlSchemaProvider schemaProvider;
private final DataSource dataSource;
public AiSqlQueryServiceImpl(AiConfigService aiConfigService,
DeepSeekClient deepSeekClient,
AiUsageService aiUsageService,
ObjectMapper objectMapper,
PromptTemplateService promptTemplateService,
TextToSqlSchemaProvider schemaProvider,
DataSource dataSource) {
this.aiConfigService = aiConfigService;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
this.objectMapper = objectMapper;
this.promptTemplateService = promptTemplateService;
this.schemaProvider = schemaProvider;
this.dataSource = dataSource;
}
/**
* {@inheritDoc}
*
* 构造只读 SQL 的提示词并调用模型,解析结果后经安全网关校验并在只读连接上执行。
*/
@Override
public AiSqlQueryResultVO query(Long userId, String query) {
if (query == null || query.isBlank()) {
throw new BusinessException("请输入查询内容");
}
AiConfig config = aiConfigService.getRawConfig(userId);
if (config == null || !Boolean.TRUE.equals(config.getEnabled())) {
throw new BusinessException("AI 功能未启用,请先在 AI 设置中开启");
}
if (isBlank(config.getApiKey())) {
throw new BusinessException("请先配置 DeepSeek API Key");
}
String systemPrompt = promptTemplateService.textToSqlSystemPrompt(
resolveDialect(), LocalDate.now().format(DATE_FMT), schemaProvider.describe());
long start = System.currentTimeMillis();
DeepSeekResponse aiResponse;
try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
systemPrompt, query);
} catch (RuntimeException e) {
aiUsageService.recordFailure(userId, AiCallType.SQL, config.getModel(), 0,
System.currentTimeMillis() - start, e.getMessage());
throw e;
}
aiUsageService.recordSuccess(userId, AiCallType.SQL, config.getModel(), 0,
aiResponse, System.currentTimeMillis() - start);
JsonNode root = parseResponse(aiResponse.getContent());
String sql = root.path("sql").asText("");
if (isBlank(sql)) {
throw new BusinessException("AI 未能生成查询语句,请换个问法重试");
}
String explanation = root.path("explanation").asText("");
String chartType = normalizeChartType(root.path("chartType").asText(""));
TextToSqlGuard.GuardedSql guarded = TextToSqlGuard.guard(sql, userId);
AiSqlQueryResultVO result = execute(guarded, userId);
result.setSql(guarded.sql());
result.setExplanation(explanation);
result.setChartType(chartType);
log.info("AI SQL 查询完成: userId={}, 耗时={}ms, 行数={}, 截断={}",
userId, System.currentTimeMillis() - start, result.getRowCount(), result.isTruncated());
return result;
}
/**
* 在只读连接上执行经安全网关处理后的 SQL,并组装列元数据与行数据。
*
* @param guarded 校验改写后的 SQL
* @param userId 当前用户ID,用于日志
* @return 查询结果
*/
private AiSqlQueryResultVO execute(TextToSqlGuard.GuardedSql guarded, Long userId) {
List<AiSqlColumnVO> columns = new ArrayList<>();
List<Map<String, Object>> rows = new ArrayList<>();
try (Connection connection = dataSource.getConnection()) {
setReadOnlyQuietly(connection);
try (PreparedStatement statement = connection.prepareStatement(guarded.sql())) {
statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
try (ResultSet resultSet = statement.executeQuery()) {
readColumns(resultSet.getMetaData(), columns);
while (resultSet.next()) {
Map<String, Object> row = new LinkedHashMap<>();
for (int i = 1; i <= columns.size(); i++) {
row.put(columns.get(i - 1).getKey(), normalizeValue(resultSet.getObject(i)));
}
rows.add(row);
}
}
}
} catch (SQLException e) {
log.warn("AI SQL 执行失败: userId={}, 原因={}", userId, e.getMessage());
throw new BusinessException("查询执行失败,请换个问法重试");
}
AiSqlQueryResultVO result = new AiSqlQueryResultVO();
result.setColumns(columns);
result.setRows(rows);
result.setRowCount(rows.size());
result.setTruncated(guarded.truncated() && rows.size() >= TextToSqlGuard.MAX_ROWS);
return result;
}
/**
* 读取结果集元数据,生成列定义。
*
* @param metaData 结果集元数据
* @param columns 列定义输出
* @throws SQLException 读取元数据失败
*/
private void readColumns(ResultSetMetaData metaData, List<AiSqlColumnVO> columns) throws SQLException {
int columnCount = metaData.getColumnCount();
for (int i = 1; i <= columnCount; i++) {
String label = metaData.getColumnLabel(i);
if (isBlank(label)) {
label = metaData.getColumnName(i);
}
AiSqlColumnVO column = new AiSqlColumnVO();
column.setKey(label);
column.setTitle(label);
column.setType(mapColumnType(metaData.getColumnType(i)));
columns.add(column);
}
}
/**
* 将 JDBC 类型映射为前端可识别的类型名。
*
* @param sqlType JDBC 类型
* @return 类型名:NUMBER / STRING / DATE / DATETIME / BOOLEAN
*/
private String mapColumnType(int sqlType) {
return switch (sqlType) {
case Types.INTEGER, Types.BIGINT, Types.SMALLINT, Types.TINYINT,
Types.DECIMAL, Types.NUMERIC, Types.FLOAT, Types.DOUBLE, Types.REAL -> "NUMBER";
case Types.DATE -> "DATE";
case Types.TIMESTAMP, Types.TIMESTAMP_WITH_TIMEZONE, Types.TIME, Types.TIME_WITH_TIMEZONE -> "DATETIME";
case Types.BOOLEAN, Types.BIT -> "BOOLEAN";
default -> "STRING";
};
}
/**
* 将结果值转换为便于前端展示的形式。
*
* @param value 原始值
* @return 转换后的值
*/
private Object normalizeValue(Object value) {
if (value instanceof byte[] bytes) {
return Base64.getEncoder().encodeToString(bytes);
}
if (value instanceof java.sql.Timestamp timestamp) {
return timestamp.toLocalDateTime().format(DATETIME_FMT);
}
if (value instanceof java.sql.Date date) {
return date.toLocalDate().format(DATE_FMT);
}
if (value instanceof java.sql.Time time) {
return time.toLocalTime().format(TIME_FMT);
}
if (value instanceof java.util.Date date) {
return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime().format(DATETIME_FMT);
}
return value;
}
/**
* 解析模型返回的 JSON 内容。
*
* @param content 模型输出
* @return JSON 根节点
*/
private JsonNode parseResponse(String content) {
try {
return objectMapper.readTree(stripCodeFence(content));
} catch (JsonProcessingException e) {
throw new BusinessException("AI 返回内容不是有效 JSON,请重试");
}
}
/**
* 归一化模型建议的展示类型。
*
* @param value 模型输出
* @return SCALAR / TABLE / BAR / PIE
*/
private String normalizeChartType(String value) {
if (value == null) {
return "TABLE";
}
String upper = value.trim().toUpperCase(Locale.ROOT);
return switch (upper) {
case "SCALAR", "BAR", "PIE", "TABLE" -> upper;
default -> "TABLE";
};
}
/**
* 解析当前数据库方言。
*
* @return MySQL / SQLite
*/
private String resolveDialect() {
return DataSourceType.SQLITE.equals(DataSourceContextHolder.getType()) ? "SQLite" : "MySQL";
}
/**
* 尝试将连接设为只读,失败时忽略(SQL 已在校验阶段限制为只读)。
*
* @param connection 数据库连接
*/
private void setReadOnlyQuietly(Connection connection) {
try {
connection.setReadOnly(true);
} catch (SQLException e) {
log.debug("设置只读连接失败(忽略): {}", e.getMessage());
}
}
/**
* 去除模型可能附加的 Markdown 代码块围栏。
*
* @param content 原始内容
* @return 清理后的内容
*/
private String stripCodeFence(String content) {
if (content == null) {
return "";
}
String cleaned = content.trim();
if (cleaned.startsWith("```")) {
int firstLineEnd = cleaned.indexOf('\n');
if (firstLineEnd > 0) {
cleaned = cleaned.substring(firstLineEnd + 1);
}
if (cleaned.endsWith("```")) {
cleaned = cleaned.substring(0, cleaned.length() - 3);
}
}
return cleaned.trim();
}
/**
* 判断字符串是否为空白。
*
* @param value 待判断字符串
* @return 为 null 或全空白时返回 true
*/
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}
@@ -0,0 +1,542 @@
package cn.rosercode.ledgerly.util.sql;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.ExpressionVisitor;
import net.sf.jsqlparser.expression.ExpressionVisitorAdapter;
import net.sf.jsqlparser.expression.Function;
import net.sf.jsqlparser.expression.LongValue;
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
import net.sf.jsqlparser.expression.operators.relational.EqualsTo;
import net.sf.jsqlparser.expression.operators.relational.InExpression;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.schema.Column;
import net.sf.jsqlparser.schema.Table;
import net.sf.jsqlparser.statement.Statements;
import net.sf.jsqlparser.statement.select.FromItem;
import net.sf.jsqlparser.statement.select.Join;
import net.sf.jsqlparser.statement.select.LateralSubSelect;
import net.sf.jsqlparser.statement.select.Limit;
import net.sf.jsqlparser.statement.select.OrderByElement;
import net.sf.jsqlparser.statement.select.ParenthesedFromItem;
import net.sf.jsqlparser.statement.select.ParenthesedSelect;
import net.sf.jsqlparser.statement.select.PlainSelect;
import net.sf.jsqlparser.statement.select.Select;
import net.sf.jsqlparser.statement.select.SelectItem;
import net.sf.jsqlparser.statement.select.SelectVisitor;
import net.sf.jsqlparser.statement.select.SetOperationList;
import net.sf.jsqlparser.statement.select.TableStatement;
import net.sf.jsqlparser.statement.select.Values;
import net.sf.jsqlparser.statement.select.WithItem;
import cn.rosercode.ledgerly.common.BusinessException;
/**
* 自然语言转 SQL 的安全网关。
*
* <p>对模型生成的 SQL 做只读校验与改写,保证无论模型输出如何,都只可能以
* 只读方式访问当前用户自己的数据:</p>
* <ul>
* <li>仅允许单条 SELECT 语句,拒绝任何写操作、DDL 与多语句;</li>
* <li>仅允许查询白名单中的业务表,拒绝系统表与其它表;</li>
* <li>对所有直接引用的表强制注入 user_id 隔离条件,对无 user_id 的关联表
* 通过父表归属子查询限制;</li>
* <li>限制结果行数上限,防止超大结果集。</li>
* </ul>
*
* @date 2026-09-24 09:01:13
*/
public final class TextToSqlGuard {
/** 单次查询返回的最大行数 */
public static final int MAX_ROWS = 1000;
/** 直接持有 user_id 列的业务表,集中维护于 {@link TextToSqlTables} */
private static final Set<String> USER_TABLES = TextToSqlTables.USER_TABLES;
/** 无 user_id 列的关联表 -> [父表, 外键] */
private static final Map<String, String[]> CHILD_TABLES = TextToSqlTables.CHILD_TABLES;
/** 允许查询的全部业务表 */
private static final Set<String> ALLOWED_TABLES = Set.copyOf(TextToSqlTables.ALLOWED_TABLES);
/** 禁止调用的危险函数 */
private static final Set<String> FORBIDDEN_FUNCTIONS = Set.of(
"SLEEP",
"BENCHMARK",
"LOAD_FILE",
"LOAD_EXTENSION",
"SYS_EVAL",
"PG_SLEEP");
/** 禁止出现的危险子句 */
private static final Pattern FORBIDDEN_OUTPUT = Pattern.compile(
"(?i)\\binto\\s+(outfile|dumpfile)\\b");
private TextToSqlGuard() {
}
/**
* 校验并改写模型生成的 SQL。
*
* @param rawSql 模型生成的原始 SQL
* @param userId 当前用户ID
* @return 校验改写后的 SQL 与截断标记
*/
public static GuardedSql guard(String rawSql, long userId) {
if (rawSql == null || rawSql.isBlank()) {
throw new BusinessException("AI 未生成有效的查询语句,请换个问法重试");
}
String trimmed = rawSql.trim();
Statements statements;
try {
statements = CCJSqlParserUtil.parseStatements(trimmed);
} catch (Exception e) {
throw new BusinessException("AI 生成的语句无法解析,请换个问法重试");
}
if (statements.size() != 1 || !(statements.get(0) instanceof Select select)) {
throw new BusinessException("只允许执行单条查询语句");
}
if (FORBIDDEN_OUTPUT.matcher(trimmed).find()) {
throw new BusinessException("查询中包含不被允许的语句");
}
GuardContext context = new GuardContext(userId);
try {
select.accept(context.selectVisitor, null);
} catch (GuardRejectedException e) {
throw new BusinessException(e.getMessage());
}
for (String table : context.usedTables) {
if (!ALLOWED_TABLES.contains(table)) {
throw new BusinessException("查询包含不允许访问的数据表:" + table);
}
}
boolean truncated = applyRowLimit(select);
return new GuardedSql(select.toString(), truncated);
}
/**
* 对查询根节点应用行数上限。
*
* @param select 查询语句
* @return 是否对原始行数进行了收敛(可能截断)
*/
private static boolean applyRowLimit(Select select) {
Limit limit;
if (select instanceof PlainSelect plainSelect) {
limit = plainSelect.getLimit();
} else if (select instanceof SetOperationList setOperationList) {
limit = setOperationList.getLimit();
} else if (select instanceof ParenthesedSelect parenthesedSelect) {
return applyRowLimit(parenthesedSelect.getSelect());
} else {
return false;
}
if (limit == null || limit.getRowCount() == null) {
select.setLimit(new Limit().withRowCount(new LongValue(MAX_ROWS)));
return true;
}
Expression rowCount = limit.getRowCount();
if (rowCount instanceof LongValue longValue) {
if (longValue.getValue() > MAX_ROWS) {
limit.setRowCount(new LongValue(MAX_ROWS));
return true;
}
return false;
}
limit.setRowCount(new LongValue(MAX_ROWS));
return true;
}
/**
* 归一化表名,去除 schema 前缀、引号并转为小写。
*
* @param tableName 原始表名
* @return 归一化后的表名
*/
private static String normalize(String tableName) {
String name = tableName;
int index = name.lastIndexOf('.');
if (index >= 0) {
name = name.substring(index + 1);
}
return name.replace("`", "").replace("\"", "").toLowerCase(Locale.ROOT);
}
/**
* 取函数名最后一段。
*
* @param functionName 函数名
* @return 最后一段名称
*/
private static String lastSegment(String functionName) {
String name = functionName == null ? "" : functionName;
int index = name.lastIndexOf('.');
return index >= 0 ? name.substring(index + 1) : name;
}
/**
* 安全网关处理结果。
*
* @param sql 校验改写后的 SQL
* @param truncated 是否因行数上限可能被截断
*/
public record GuardedSql(String sql, boolean truncated) {
}
/**
* 校验过程中的拒绝异常,便于统一转换为业务异常。
*/
private static final class GuardRejectedException extends RuntimeException {
private static final long serialVersionUID = 1L;
GuardRejectedException(String message) {
super(message);
}
}
/**
* SQL 遍历与改写上下文。
*/
private static final class GuardContext {
/** 当前用户ID,用于注入隔离条件 */
private final long userId;
/** 遍历中收集到的全部表名 */
private final Set<String> usedTables = new LinkedHashSet<>();
/** 查询语句访问器,递归处理子查询 */
private final SelectVisitor<Void> selectVisitor;
/** 表达式访问器,递归发现表达式中的子查询 */
private final ExpressionVisitor<Void> expressionVisitor;
GuardContext(long userId) {
this.userId = userId;
this.selectVisitor = new SelectVisitor<>() {
@Override
public <S> Void visit(PlainSelect plainSelect, S context) {
rewritePlainSelect(plainSelect);
return null;
}
@Override
public <S> Void visit(SetOperationList setOperationList, S context) {
rewriteWithItems(setOperationList.getWithItemsList());
if (setOperationList.getSelects() != null) {
for (Select select : setOperationList.getSelects()) {
select.accept(this, context);
}
}
return null;
}
@Override
public <S> Void visit(ParenthesedSelect parenthesedSelect, S context) {
parenthesedSelect.getSelect().accept(this, context);
return null;
}
@Override
public <S> Void visit(WithItem<?> withItem, S context) {
withItem.getSelect().accept(this, context);
return null;
}
@Override
public <S> Void visit(Values values, S context) {
return null;
}
@Override
public <S> Void visit(LateralSubSelect lateralSubSelect, S context) {
lateralSubSelect.getSelect().accept(this, context);
return null;
}
@Override
public <S> Void visit(TableStatement tableStatement, S context) {
return null;
}
};
ExpressionVisitorAdapter<Void> adapter = new ExpressionVisitorAdapter<>() {
@Override
public <S> Void visit(Function function, S context) {
String name = lastSegment(function.getName()).toUpperCase(Locale.ROOT);
if (FORBIDDEN_FUNCTIONS.contains(name)) {
throw new GuardRejectedException("查询中包含不被允许的函数:" + function.getName());
}
return super.visit(function, context);
}
};
adapter.setSelectVisitor(this.selectVisitor);
this.expressionVisitor = adapter;
}
/**
* 处理单个 PlainSelect:递归子查询并注入用户隔离条件。
*
* @param plainSelect 待处理的查询
*/
private void rewritePlainSelect(PlainSelect plainSelect) {
rewriteWithItems(plainSelect.getWithItemsList());
if (plainSelect.getIntoTables() != null && !plainSelect.getIntoTables().isEmpty()) {
throw new GuardRejectedException("不支持 SELECT ... INTO 语句");
}
if (plainSelect.getForClause() != null) {
throw new GuardRejectedException("不支持锁定查询");
}
rewriteFromItem(plainSelect.getFromItem());
if (plainSelect.getJoins() != null) {
for (Join join : plainSelect.getJoins()) {
rewriteFromItem(join.getRightItem());
if (join.getOnExpressions() != null) {
for (Expression expression : join.getOnExpressions()) {
acceptExpression(expression);
}
}
}
}
if (plainSelect.getSelectItems() != null) {
for (SelectItem<?> item : plainSelect.getSelectItems()) {
acceptExpression(item.getExpression());
}
}
acceptExpression(plainSelect.getWhere());
acceptExpression(plainSelect.getHaving());
if (plainSelect.getGroupBy() != null
&& plainSelect.getGroupBy().getGroupByExpressionList() != null) {
for (Object item : plainSelect.getGroupBy().getGroupByExpressionList()) {
if (item instanceof Expression expression) {
acceptExpression(expression);
}
}
}
if (plainSelect.getOrderByElements() != null) {
for (OrderByElement element : plainSelect.getOrderByElements()) {
acceptExpression(element.getExpression());
}
}
injectOwnTables(plainSelect);
}
/**
* 递归处理 CTE(WITH 子句),保证其中的表同样通过白名单校验并注入隔离条件。
*
* @param withItems CTE 列表
*/
private void rewriteWithItems(List<WithItem<?>> withItems) {
if (withItems == null) {
return;
}
for (WithItem<?> withItem : withItems) {
withItem.getSelect().accept(selectVisitor, null);
}
}
/**
* 递归处理 FROM 项,收集表名并进入子查询。
*
* @param fromItem FROM 项
*/
private void rewriteFromItem(FromItem fromItem) {
if (fromItem instanceof ParenthesedSelect parenthesedSelect) {
parenthesedSelect.getSelect().accept(selectVisitor, null);
} else if (fromItem instanceof ParenthesedFromItem parenthesedFromItem) {
rewriteFromItem(parenthesedFromItem.getFromItem());
if (parenthesedFromItem.getJoins() != null) {
for (Join join : parenthesedFromItem.getJoins()) {
rewriteFromItem(join.getRightItem());
if (join.getOnExpressions() != null) {
for (Expression expression : join.getOnExpressions()) {
acceptExpression(expression);
}
}
}
}
} else if (fromItem instanceof Table table) {
collectTable(table);
}
}
/**
* 为当前查询直接引用的表注入用户隔离条件。
*
* <p>主表条件加入 WHERE;JOIN 的表条件加入其 ON 子句,避免在 LEFT JOIN
* 下因 WHERE 过滤而丢弃右表未匹配的行(例如内置渠道不在 t_channel 中)。</p>
*
* @param plainSelect 当前查询
*/
private void injectOwnTables(PlainSelect plainSelect) {
injectFromItem(plainSelect, plainSelect.getFromItem());
if (plainSelect.getJoins() != null) {
for (Join join : plainSelect.getJoins()) {
injectJoinRightItem(plainSelect, join);
}
}
}
/**
* 递归为 FROM 项注入隔离条件,覆盖括号包裹的连接(ParenthesedFromItem)。
*
* @param plainSelect 当前查询
* @param fromItem FROM 项,可能为 Table 或 ParenthesedFromItem
*/
private void injectFromItem(PlainSelect plainSelect, FromItem fromItem) {
if (fromItem instanceof Table table) {
collectTable(table);
Expression condition = buildCondition(table);
if (condition != null) {
addWhereCondition(plainSelect, condition);
}
} else if (fromItem instanceof ParenthesedFromItem parenthesedFromItem) {
injectFromItem(plainSelect, parenthesedFromItem.getFromItem());
if (parenthesedFromItem.getJoins() != null) {
for (Join join : parenthesedFromItem.getJoins()) {
injectJoinRightItem(plainSelect, join);
}
}
}
}
/**
* 为 JOIN 右项注入隔离条件,覆盖括号包裹的连接。
*
* @param plainSelect 当前查询
* @param join 连接
*/
private void injectJoinRightItem(PlainSelect plainSelect, Join join) {
FromItem rightItem = join.getRightItem();
if (rightItem instanceof Table table) {
collectTable(table);
Expression condition = buildCondition(table);
if (condition != null) {
addJoinCondition(plainSelect, join, condition);
}
} else if (rightItem instanceof ParenthesedFromItem parenthesedFromItem) {
injectFromItem(plainSelect, parenthesedFromItem.getFromItem());
if (parenthesedFromItem.getJoins() != null) {
for (Join inner : parenthesedFromItem.getJoins()) {
injectJoinRightItem(plainSelect, inner);
}
}
}
}
/**
* 收集表名并校验 schema 前缀,拒绝显式指定非 main schema 的表引用,避免跨库访问同名表。
*
* @param table 表
*/
private void collectTable(Table table) {
String schema = table.getSchemaName();
if (schema != null && !schema.isBlank() && !"main".equalsIgnoreCase(schema)) {
throw new GuardRejectedException("查询中包含不允许访问的表:" + table.getFullyQualifiedName());
}
usedTables.add(normalize(table.getName()));
}
/**
* 构建表的用户隔离条件,未知表返回 null。
*
* @param table 表
* @return 隔离条件,无需注入时返回 null
*/
private Expression buildCondition(Table table) {
String name = normalize(table.getName());
if (USER_TABLES.contains(name)) {
return new EqualsTo(referenceColumn(table, "user_id"), new LongValue(userId));
}
if (CHILD_TABLES.containsKey(name)) {
return buildChildCondition(table, CHILD_TABLES.get(name));
}
return null;
}
/**
* 为无 user_id 的关联表构建父表归属条件。
*
* @param table 关联表
* @param relation 父表与外键
* @return 归属条件
*/
private Expression buildChildCondition(Table table, String[] relation) {
PlainSelect subQuery = new PlainSelect();
subQuery.addSelectItems(new SelectItem<>(new Column("id")));
subQuery.setFromItem(new Table(relation[0]));
subQuery.setWhere(new EqualsTo(new Column("user_id"), new LongValue(userId)));
ParenthesedSelect subSelect = new ParenthesedSelect();
subSelect.setSelect(subQuery);
return new InExpression(referenceColumn(table, relation[1]), subSelect);
}
/**
* 将条件加入 JOIN 的 ON 子句;USING 或无 ON 的连接退回 WHERE。
*
* @param plainSelect 当前查询
* @param join 连接
* @param condition 条件
*/
private void addJoinCondition(PlainSelect plainSelect, Join join, Expression condition) {
if ((join.getUsingColumns() != null && !join.getUsingColumns().isEmpty())
|| join.getOnExpressions() == null || join.getOnExpressions().isEmpty()) {
addWhereCondition(plainSelect, condition);
return;
}
Expression combined = null;
for (Expression expression : join.getOnExpressions()) {
combined = combined == null ? expression : new AndExpression(combined, expression);
}
join.setOnExpressions(List.of(new AndExpression(combined, condition)));
}
/**
* 构造带表引用或别名的列。
*
* @param table 表
* @param columnName 列名
* @return 列表达式
*/
private Column referenceColumn(Table table, String columnName) {
String reference = table.getAlias() != null ? table.getAlias().getName() : table.getName();
return new Column(new Table(reference), columnName);
}
/**
* 向查询的 WHERE 追加 AND 条件。
*
* @param plainSelect 查询
* @param condition 条件
*/
private void addWhereCondition(PlainSelect plainSelect, Expression condition) {
if (plainSelect.getWhere() == null) {
plainSelect.setWhere(condition);
} else {
plainSelect.setWhere(new AndExpression(plainSelect.getWhere(), condition));
}
}
/**
* 用表达式访问器递归处理表达式,发现其中的子查询。
*
* @param expression 表达式
*/
private void acceptExpression(Expression expression) {
if (expression != null) {
expression.accept(expressionVisitor, null);
}
}
}
}
@@ -0,0 +1,238 @@
package cn.rosercode.ledgerly.util.sql;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import javax.sql.DataSource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import cn.rosercode.ledgerly.config.datasource.DataSourceContextHolder;
/**
* 自然语言转 SQL 的表结构描述提供者。
*
* <p>通过 JDBC 元数据动态读取白名单表的列信息(列名、类型),因此新增、删除
* 或修改字段后无需调整提示词;列的中文说明优先取 {@link TextToSqlTables} 的
* 字典,缺失时回退数据库列注释(MySQL 支持,SQLite 无),仍缺失则只展示列名
* 与类型。</p>
*
* <p>结果按当前数据源标识缓存,切换数据库后自动失效重建。</p>
*
* @date 2026-09-24 09:16:13
*/
@Slf4j
@Component
public class TextToSqlSchemaProvider {
/** 表结构缓存有效期(毫秒):期间内不重复读取数据库元数据 */
private static final long CACHE_TTL_MILLIS = 60_000L;
private final DataSource dataSource;
/** 缓存对应的数据源标识 */
private volatile String cachedKey;
/** 缓存的表结构描述 */
private volatile String cachedSchema;
/** 缓存生成时间戳 */
private volatile long cachedAt;
public TextToSqlSchemaProvider(DataSource dataSource) {
this.dataSource = dataSource;
}
/**
* 生成供提示词使用的表结构描述。
*
* @return 表结构文本
*/
public String describe() {
String key = DataSourceContextHolder.getKey();
String schema = cachedSchema;
if (schema != null && key != null && key.equals(cachedKey)
&& System.currentTimeMillis() - cachedAt < CACHE_TTL_MILLIS) {
return schema;
}
String built = build();
cachedSchema = built;
cachedKey = key;
cachedAt = System.currentTimeMillis();
return built;
}
/**
* 读取全部白名单表的列并拼装描述。
*
* @return 表结构文本
*/
private String build() {
StringBuilder builder = new StringBuilder();
try (Connection connection = dataSource.getConnection()) {
DatabaseMetaData metaData = connection.getMetaData();
String catalog = connection.getCatalog();
for (String table : TextToSqlTables.ALLOWED_TABLES) {
appendTable(builder, table, readColumns(metaData, catalog, table));
}
builder.append(TextToSqlTables.RELATIONSHIPS);
return builder.toString();
} catch (SQLException e) {
log.warn("读取数据库表结构失败,回退为字典说明: {}", e.getMessage());
return buildFromDictionary();
}
}
/**
* 追加单张表的描述,元数据缺失时回退字典。
*
* @param builder 输出
* @param table 表名
* @param columns 列元数据
*/
private void appendTable(StringBuilder builder, String table, List<ColumnMeta> columns) {
if (columns.isEmpty()) {
appendFromDictionary(builder, table);
return;
}
builder.append(table).append(' ').append(TextToSqlTables.TABLE_DESCRIPTIONS.getOrDefault(table, ""))
.append(":\n");
for (ColumnMeta column : columns) {
if (column.name() == null) {
continue;
}
if (TextToSqlTables.FORBIDDEN_COLUMNS.contains(column.name().toLowerCase(Locale.ROOT))) {
continue;
}
builder.append("- ").append(column.name());
if (notBlank(column.type())) {
builder.append(' ').append(column.type());
}
String description = resolveDescription(table, column);
if (notBlank(description)) {
builder.append(' ').append(description);
}
builder.append('\n');
}
builder.append('\n');
}
/**
* 用字典追加单张表的描述。
*
* @param builder 输出
* @param table 表名
*/
private void appendFromDictionary(StringBuilder builder, String table) {
builder.append(table).append(' ').append(TextToSqlTables.TABLE_DESCRIPTIONS.getOrDefault(table, ""))
.append(":\n");
for (Map.Entry<String, String> entry : TextToSqlTables.columnDescriptions(table).entrySet()) {
builder.append("- ").append(entry.getKey());
if (notBlank(entry.getValue())) {
builder.append(' ').append(entry.getValue());
}
builder.append('\n');
}
builder.append('\n');
}
/**
* 完全基于字典构建描述,作为元数据不可用时的兜底。
*
* @return 表结构文本
*/
private String buildFromDictionary() {
StringBuilder builder = new StringBuilder();
for (String table : TextToSqlTables.ALLOWED_TABLES) {
appendFromDictionary(builder, table);
}
builder.append(TextToSqlTables.RELATIONSHIPS);
return builder.toString();
}
/**
* 读取单张表的列元数据,兼容 catalog 与表名大小写差异。
*
* @param metaData 数据库元数据
* @param catalog 当前 catalog
* @param table 表名
* @return 列元数据列表
* @throws SQLException 读取失败
*/
private List<ColumnMeta> readColumns(DatabaseMetaData metaData, String catalog, String table)
throws SQLException {
List<ColumnMeta> columns = readColumns(metaData, catalog, null, table);
if (columns.isEmpty()) {
columns = readColumns(metaData, null, null, table);
}
if (columns.isEmpty()) {
columns = readColumns(metaData, null, null, table.toUpperCase(Locale.ROOT));
}
return columns;
}
/**
* 按指定 catalog/schema 读取列元数据。
*
* @param metaData 数据库元数据
* @param catalog catalog
* @param schema schema
* @param table 表名
* @return 列元数据列表
* @throws SQLException 读取失败
*/
private List<ColumnMeta> readColumns(DatabaseMetaData metaData, String catalog, String schema, String table)
throws SQLException {
List<ColumnMeta> columns = new ArrayList<>();
try (ResultSet resultSet = metaData.getColumns(catalog, schema, table, "%")) {
while (resultSet.next()) {
columns.add(new ColumnMeta(
resultSet.getString("COLUMN_NAME"),
resultSet.getString("TYPE_NAME"),
resultSet.getString("REMARKS")));
}
}
return columns;
}
/**
* 解析列的中文说明:字典优先,其次数据库列注释。
*
* @param table 表名
* @param column 列元数据
* @return 中文说明,可能为空
*/
private String resolveDescription(String table, ColumnMeta column) {
String description = TextToSqlTables.describeColumn(table, column.name());
if (notBlank(description)) {
return description;
}
return column.remarks();
}
/**
* 判断字符串是否非空。
*
* @param value 字符串
* @return 非空返回 true
*/
private boolean notBlank(String value) {
return value != null && !value.isBlank();
}
/**
* 列元数据。
*
* @param name 列名
* @param type 类型名
* @param remarks 列注释
*/
private record ColumnMeta(String name, String type, String remarks) {
}
}
@@ -0,0 +1,165 @@
package cn.rosercode.ledgerly.util.sql;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 自然语言转 SQL 可访问的表结构元数据。
*
* <p>集中维护允许模型查询的表白名单、表与列的中文说明以及表间关系。列的
* 存在性与类型由 {@link TextToSqlSchemaProvider} 从数据库元数据动态读取,
* 因此新增、删除或修改字段后无需调整提示词;本类中的中文说明仅作为可选的
* 语义增强,字典未覆盖的新列仍会出现在提示词中(只是没有中文说明)。</p>
*
* <p>安全性说明:表名白名单是静态的,新增数据表不会自动对模型开放,必须
* 在此显式登记。</p>
*
* @date 2026-09-24 09:16:13
*/
public final class TextToSqlTables {
/** 直接持有 user_id 列的业务表 */
public static final Set<String> USER_TABLES = Set.of(
"t_transaction",
"t_category",
"t_tag",
"t_channel",
"t_transaction_group",
"t_rule");
/** 无 user_id 列的关联表 -> [父表, 外键] */
public static final Map<String, String[]> CHILD_TABLES = Map.of(
"t_transaction_tag", new String[]{"t_transaction", "transaction_id"},
"t_transaction_group_item", new String[]{"t_transaction_group", "group_id"},
"t_rule_tag", new String[]{"t_rule", "rule_id"});
/** 允许模型访问的表,顺序决定提示词中的展示顺序 */
public static final List<String> ALLOWED_TABLES = List.of(
"t_transaction",
"t_category",
"t_tag",
"t_transaction_tag",
"t_channel",
"t_transaction_group",
"t_transaction_group_item",
"t_rule",
"t_rule_tag");
/** 禁止出现在提示词与查询中的列(隐私与数据安全考虑) */
public static final Set<String> FORBIDDEN_COLUMNS = Set.of("raw_data", "external_no");
/** 表名 -> 中文说明 */
public static final Map<String, String> TABLE_DESCRIPTIONS = Map.of(
"t_transaction", "收支记录(每行一笔)",
"t_category", "分类",
"t_tag", "标签",
"t_transaction_tag", "交易与标签的关联",
"t_channel", "用户自定义交易渠道",
"t_transaction_group", "账单关系分组",
"t_transaction_group_item", "账单关系分组的成员",
"t_rule", "分类标签规则",
"t_rule_tag", "规则与标签的关联");
/** 表关系说明,供模型理解连表方式 */
public static final String RELATIONSHIPS = """
表关系:t_transaction.category_id = t_category.id;t_transaction_tag.transaction_id = t_transaction.id 且 t_transaction_tag.tag_id = t_tag.id;t_transaction_group_item.group_id = t_transaction_group.id 且 t_transaction_group_item.transaction_id = t_transaction.id;t_rule_tag.rule_id = t_rule.id 且 t_rule_tag.tag_id = t_tag.id。""";
/** 表名 -> (列名 -> 中文说明),作为可选的语义增强 */
private static final Map<String, Map<String, String>> COLUMN_DESCRIPTIONS = buildColumnDescriptions();
private TextToSqlTables() {
}
/**
* 查询列的补充中文说明。
*
* @param table 表名
* @param column 列名
* @return 中文说明,未配置时返回 null
*/
public static String describeColumn(String table, String column) {
Map<String, String> columns = COLUMN_DESCRIPTIONS.get(table);
if (columns == null || column == null) {
return null;
}
return columns.get(column.toLowerCase());
}
/**
* 查询某张表的全部列说明,用于元数据不可用时兜底拼接。
*
* @param table 表名
* @return 列名到说明的映射,无配置时为空 Map
*/
public static Map<String, String> columnDescriptions(String table) {
return COLUMN_DESCRIPTIONS.getOrDefault(table, Map.of());
}
/**
* 构建列说明字典。
*
* @return 表名到列说明的映射
*/
private static Map<String, Map<String, String>> buildColumnDescriptions() {
Map<String, Map<String, String>> descriptions = new LinkedHashMap<>();
descriptions.put("t_transaction", Map.ofEntries(
Map.entry("id", "主键"),
Map.entry("category_id", "分类ID,关联 t_category.id,可为空(表示未分类)"),
Map.entry("type", "收支类型:INCOME 收入 / EXPENSE 支出"),
Map.entry("amount", "金额,正数"),
Map.entry("channel", "交易渠道编码(见 t_channel.code)"),
Map.entry("remark", "备注"),
Map.entry("remark_hidden", "备注是否隐藏,1 为隐藏"),
Map.entry("transaction_time", "交易发生时间"),
Map.entry("include_in_stats", "是否计入统计,1 为计入"),
Map.entry("created_at", "创建时间"),
Map.entry("updated_at", "更新时间")));
descriptions.put("t_category", Map.of(
"id", "主键",
"name", "名称",
"type", "收支类型:INCOME / EXPENSE",
"icon", "图标",
"sort_order", "排序"));
descriptions.put("t_tag", Map.of(
"id", "主键",
"name", "名称",
"color", "颜色",
"sort_order", "排序"));
descriptions.put("t_transaction_tag", Map.of(
"id", "主键",
"transaction_id", "交易ID,关联 t_transaction.id",
"tag_id", "标签ID,关联 t_tag.id"));
descriptions.put("t_channel", Map.of(
"id", "主键",
"code", "渠道编码,交易记录按此关联",
"name", "渠道名称",
"sort_order", "排序"));
descriptions.put("t_transaction_group", Map.of(
"id", "主键",
"type", "关系类型:PARENT_CHILD 父子 / SIBLING 同级",
"name", "名称",
"remark", "备注"));
descriptions.put("t_transaction_group_item", Map.of(
"id", "主键",
"group_id", "分组ID,关联 t_transaction_group.id",
"transaction_id", "交易ID,关联 t_transaction.id",
"role", "成员角色:PARENT/CHILD/MEMBER",
"sort_order", "排序"));
descriptions.put("t_rule", Map.of(
"id", "主键",
"name", "名称",
"enabled", "是否启用,1 为启用",
"type", "收支类型",
"keyword", "关键词",
"min_amount", "最小金额",
"max_amount", "最大金额",
"category_id", "分类ID"));
descriptions.put("t_rule_tag", Map.of(
"id", "主键",
"rule_id", "规则ID,关联 t_rule.id",
"tag_id", "标签ID,关联 t_tag.id"));
return Map.copyOf(descriptions);
}
}
@@ -0,0 +1,21 @@
package cn.rosercode.ledgerly.vo;
import lombok.Data;
/**
* 自然语言转 SQL 查询结果的列视图对象。
*
* @date 2026-09-24 09:01:13
*/
@Data
public class AiSqlColumnVO {
/** 列键,与行数据中的字段名一致 */
private String key;
/** 列标题(取自 SQL 列别名) */
private String title;
/** 列值类型:NUMBER / STRING / DATE / DATETIME / BOOLEAN */
private String type;
}
@@ -0,0 +1,36 @@
package cn.rosercode.ledgerly.vo;
import java.util.List;
import java.util.Map;
import lombok.Data;
/**
* 自然语言转 SQL 查询结果视图对象,承载生成的 SQL、列元数据与行数据。
*
* @date 2026-09-24 09:01:13
*/
@Data
public class AiSqlQueryResultVO {
/** 生成的 SQL 语句(经安全校验与改写后的最终语句) */
private String sql;
/** 查询意图说明 */
private String explanation;
/** 建议的展示类型:SCALAR / TABLE / BAR / PIE */
private String chartType;
/** 结果列元数据 */
private List<AiSqlColumnVO> columns;
/** 结果行数据,按列顺序保序 */
private List<Map<String, Object>> rows;
/** 返回行数 */
private int rowCount;
/** 是否因行数上限被截断 */
private boolean truncated;
}
@@ -0,0 +1,19 @@
你是一个记账系统的 SQL 生成助手。请根据用户的自然语言,生成一条只读的 SELECT 查询语句,只输出 JSON,不要输出任何多余内容。
当前数据库方言:[(${dialect})]。今天是 [(${today})]。
要求:
1. 只能生成单条 SELECT 查询,禁止生成 INSERT、UPDATE、DELETE、DROP、ALTER 等任何写操作,禁止多语句,禁止 SELECT ... INTO 与 FOR UPDATE。
2. 不需要也不要自己添加 user_id 条件,系统会自动把查询限制为当前登录用户的数据。
3. 只能使用下面列出的表与列,禁止查询任何其它表(包括系统表);禁止查询 raw_data、external_no 列。
4. 允许 JOIN 连表、GROUP BY 聚合、子查询、聚合函数(SUM/COUNT/AVG/MAX/MIN)与日期函数。
5. 日期函数必须使用当前方言支持的写法:SQLite 使用 strftime/date,例如 strftime('%Y-%m', transaction_time) 与 date('now','localtime');MySQL 使用 DATE_FORMAT/DATE/CURDATE,例如 DATE_FORMAT(transaction_time,'%Y-%m')。
6. 请为每个查询列起一个有意义的中文别名(AS),金额结果请保留两位小数。
7. chartType 取值:结果为单个聚合值(如「上个月花了多少钱」)用 SCALAR;按维度分组的多行结果(如「按分类统计支出」)用 BAR 或 PIE;明细列表用 TABLE。
8. explanation 用一句中文说明查询意图。
9. 查询渠道时直接选择 t_transaction.channel(渠道编码,内置如 ALIPAY/WECHAT/MEITUAN/TAOBAO/JD/CMB/BOCOM/CASH,或用户自定义编码),前端会显示为渠道名称;不要为了取渠道名称去 JOIN t_channel,它仅存储用户自定义渠道。
10. 若用户要求修改、删除、插入数据,或问题与记账数据无关、无法用上述表回答,请将 sql 置为空字符串 "",并在 explanation 中用一句中文说明系统仅支持查询及原因,禁止编造无意义的查询。
数据表结构(由数据库实时读取,字段可能随版本变化,请以列出的实际列名为准;SQLite 的 DATETIME 列以 ISO8601 字符串存储,形如 2026-09-01T12:00:00):
[(${schema})]
输出格式:{"sql":"生成的 SELECT 语句","explanation":"查询意图说明","chartType":"SCALAR"}
@@ -1,15 +0,0 @@
package cn.rosercode.ledgerly;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
/**
* 应用启动测试
*/
@SpringBootTest
class LedgerlyApplicationTests {
@Test
void contextLoads() {
}
}
@@ -1,65 +0,0 @@
package cn.rosercode.ledgerly.prompt;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.thymeleaf.templatemode.TemplateMode;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
/**
* 提示词模板渲染测试,验证各模板可正常加载、变量替换与 scope 条件分支。
*
* @date 2026-09-21 11:01:25
*/
class PromptTemplateServiceTest {
private PromptTemplateService promptTemplateService;
@BeforeEach
void setUp() {
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
resolver.setPrefix("prompts/");
resolver.setSuffix(".tpl");
resolver.setTemplateMode(TemplateMode.TEXT);
resolver.setCharacterEncoding("UTF-8");
SpringTemplateEngine engine = new SpringTemplateEngine();
engine.setTemplateResolver(resolver);
promptTemplateService = new PromptTemplateService(engine);
}
@Test
void renderRecordSystemPrompt() {
String result = promptTemplateService.recordSystemPrompt(
"INCOME(收入)、EXPENSE(支出)",
"ALIPAY(支付宝)",
"餐饮",
"无",
"2026-09-21 11:00:00",
"2026-09-21T11:00:00");
assertThat(result).contains("可用的收支类型:INCOME(收入)、EXPENSE(支出)。");
assertThat(result).contains("可用分类:餐饮。");
assertThat(result).contains("可用标签:无。");
assertThat(result).contains("当前时间:2026-09-21 11:00:00。");
assertThat(result).contains("\"transactionTime\":\"2026-09-21T11:00:00\"");
}
@Test
void renderSuggestionScopeSuffix() {
String both = promptTemplateService.suggestionNewSystemPrompt("BOTH");
assertThat(both).doesNotContain("注意:本次只需给出");
String category = promptTemplateService.suggestionNewSystemPrompt("CATEGORY");
assertThat(category).contains("注意:本次只需给出分类建议,tagIds 必须返回空数组,newTags 必须返回空数组。");
String tag = promptTemplateService.suggestionNewSystemPrompt("TAG");
assertThat(tag).contains("注意:本次只需给出标签建议,categoryId 必须返回 null,newCategory 必须返回 null。");
}
@Test
void renderStaticPrompts() {
assertThat(promptTemplateService.tagSystemPrompt()).contains("你是一个个人记账应用的标签助手。");
assertThat(promptTemplateService.ruleSystemPrompt()).contains("你是一个个人记账应用的规则生成助手。");
assertThat(promptTemplateService.configTestSystemPrompt()).contains("你是一个测试助手,请只输出 JSON。");
assertThat(promptTemplateService.configTestUserPrompt()).contains("请返回 {\"ok\":true}");
}
}
@@ -1,124 +0,0 @@
package cn.rosercode.ledgerly.service.impl;
import static org.assertj.core.api.Assertions.assertThat;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import cn.rosercode.ledgerly.entity.Category;
import cn.rosercode.ledgerly.entity.Tag;
import cn.rosercode.ledgerly.entity.Transaction;
/**
* AI 用户提示词隐私测试。
*
* <p>校验 {@code buildUserPrompt} 生成的 JSON 载荷仅含白名单字段,
* 不含 amount、rawData、externalNo 等敏感信息,防止后续改动误加字段导致数据外发。</p>
*
* @date 2026-09-21 11:10:58
*/
class AiPromptPayloadPrivacyTest {
/** 禁止出现在提示词载荷中的敏感字段名 */
private static final List<String> SENSITIVE_FIELDS = List.of("amount", "rawData", "externalNo");
/**
* AI 建议的批量用户提示词不得包含敏感字段。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
void suggestionUserPromptExcludesSensitiveFields() throws Exception {
AiSuggestionServiceImpl service = new AiSuggestionServiceImpl(
null, null, null, null, null, null, new ObjectMapper().findAndRegisterModules(), null, null);
Method method = AiSuggestionServiceImpl.class.getDeclaredMethod(
"buildUserPrompt", List.class, List.class, List.class);
method.setAccessible(true);
String json = (String) method.invoke(service,
List.of(category()), List.of(tag()), List.of(transaction()));
assertExcludesSensitiveFields(json);
assertThat(json).contains("remark").contains("午餐");
}
/**
* AI 规则生成的用户提示词不得包含敏感字段。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
void ruleUserPromptExcludesSensitiveFields() throws Exception {
AiRuleServiceImpl service = new AiRuleServiceImpl(
null, null, null, new ObjectMapper().findAndRegisterModules(), null, null, null, null, null, null);
Method method = AiRuleServiceImpl.class.getDeclaredMethod(
"buildUserPrompt", List.class, List.class, List.class, Map.class);
method.setAccessible(true);
String json = (String) method.invoke(service, List.of(category()), List.of(tag()),
List.of(transaction()), Map.of(1L, List.of("报销")));
assertExcludesSensitiveFields(json);
assertThat(json).contains("remark").contains("午餐");
}
/**
* 断言载荷不含任何敏感字段名。
*
* @param json 提示词载荷
*/
private void assertExcludesSensitiveFields(String json) {
for (String field : SENSITIVE_FIELDS) {
assertThat(json).doesNotContain(field);
}
}
/**
* 构造用于测试的分类。
*
* @return 分类实体
*/
private Category category() {
Category category = new Category();
category.setId(1L);
category.setName("餐饮");
category.setType("EXPENSE");
return category;
}
/**
* 构造用于测试的标签。
*
* @return 标签实体
*/
private Tag tag() {
Tag tag = new Tag();
tag.setId(1L);
tag.setName("报销");
return tag;
}
/**
* 构造用于测试的交易记录,并刻意写入敏感字段以验证其不会外发。
*
* @return 交易记录实体
*/
private Transaction transaction() {
Transaction transaction = new Transaction();
transaction.setId(1L);
transaction.setType("EXPENSE");
transaction.setChannel("ALIPAY");
transaction.setRemark("午餐");
transaction.setTransactionTime(LocalDateTime.now());
transaction.setAmount(new BigDecimal("35.50"));
transaction.setExternalNo("2026092100001");
transaction.setRawData("{\"secret\":\"raw\"}");
return transaction;
}
}