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:
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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: '日時' },
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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[]>(() => {
|
||||||
{ label: t('layout.dashboard'), key: 'dashboard', icon: renderIcon(BarChartOutline) },
|
const options: MenuOption[] = [
|
||||||
{ label: t('layout.transactions'), key: 'transactions', icon: renderIcon(ListOutline) },
|
{ label: t('layout.dashboard'), key: 'dashboard', icon: renderIcon(BarChartOutline) },
|
||||||
{ label: t('layout.categories'), key: 'categories', icon: renderIcon(FolderOpenOutline) },
|
{ label: t('layout.transactions'), key: 'transactions', icon: renderIcon(ListOutline) },
|
||||||
{ label: t('layout.tags'), key: 'tags', icon: renderIcon(PricetagsOutline) },
|
{ label: t('layout.categories'), key: 'categories', icon: renderIcon(FolderOpenOutline) },
|
||||||
{ label: t('layout.rules'), key: 'rules', icon: renderIcon(OptionsOutline) },
|
{ label: t('layout.tags'), key: 'tags', icon: renderIcon(PricetagsOutline) },
|
||||||
{ label: t('layout.emailBills'), key: 'email-bills', icon: renderIcon(MailOutline) },
|
{ label: t('layout.rules'), key: 'rules', icon: renderIcon(OptionsOutline) },
|
||||||
])
|
{ 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(() => {
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user