From 0e1443c59ea4148f51ac17f25545700f9eccad69 Mon Sep 17 00:00:00 2001 From: rosercode Date: Thu, 24 Sep 2026 10:38:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E6=9F=A5=E6=95=B0=EF=BC=88=E8=87=AA=E7=84=B6=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E8=BD=AC=20SQL=20=E5=8F=AA=E8=AF=BB=E6=9F=A5=E8=AF=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增「智能查数」功能:用户用自然语言提问,由大模型生成一条只读 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 递归处理 --- frontend/package.json | 2 + frontend/src/api/ai.ts | 30 + frontend/src/components/AiConfigPanel.vue | 3 + frontend/src/composables/useAiEnabled.ts | 35 ++ frontend/src/locales/messages.ts | 27 + frontend/src/router/index.ts | 5 + frontend/src/utils/storageKeys.ts | 2 + frontend/src/views/Layout.vue | 30 +- frontend/src/views/SqlQuery.vue | 415 ++++++++++++++ .../ledgerly/controller/AiController.java | 20 +- .../rosercode/ledgerly/enums/AiCallType.java | 5 +- .../prompt/PromptTemplateService.java | 16 + .../ledgerly/service/AiSqlQueryService.java | 23 + .../service/impl/AiSqlQueryServiceImpl.java | 325 +++++++++++ .../ledgerly/util/sql/TextToSqlGuard.java | 542 ++++++++++++++++++ .../util/sql/TextToSqlSchemaProvider.java | 238 ++++++++ .../ledgerly/util/sql/TextToSqlTables.java | 165 ++++++ .../rosercode/ledgerly/vo/AiSqlColumnVO.java | 21 + .../ledgerly/vo/AiSqlQueryResultVO.java | 36 ++ src/main/resources/prompts/ai-sql-system.tpl | 19 + .../ledgerly/LedgerlyApplicationTests.java | 15 - .../prompt/PromptTemplateServiceTest.java | 65 --- .../impl/AiPromptPayloadPrivacyTest.java | 124 ---- 23 files changed, 1947 insertions(+), 216 deletions(-) create mode 100644 frontend/src/composables/useAiEnabled.ts create mode 100644 frontend/src/views/SqlQuery.vue create mode 100644 src/main/java/cn/rosercode/ledgerly/service/AiSqlQueryService.java create mode 100644 src/main/java/cn/rosercode/ledgerly/service/impl/AiSqlQueryServiceImpl.java create mode 100644 src/main/java/cn/rosercode/ledgerly/util/sql/TextToSqlGuard.java create mode 100644 src/main/java/cn/rosercode/ledgerly/util/sql/TextToSqlSchemaProvider.java create mode 100644 src/main/java/cn/rosercode/ledgerly/util/sql/TextToSqlTables.java create mode 100644 src/main/java/cn/rosercode/ledgerly/vo/AiSqlColumnVO.java create mode 100644 src/main/java/cn/rosercode/ledgerly/vo/AiSqlQueryResultVO.java create mode 100644 src/main/resources/prompts/ai-sql-system.tpl delete mode 100644 src/test/java/cn/rosercode/ledgerly/LedgerlyApplicationTests.java delete mode 100644 src/test/java/cn/rosercode/ledgerly/prompt/PromptTemplateServiceTest.java delete mode 100644 src/test/java/cn/rosercode/ledgerly/service/impl/AiPromptPayloadPrivacyTest.java diff --git a/frontend/package.json b/frontend/package.json index ab1f1cb..a86f5f7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,8 +12,10 @@ "@vicons/ionicons5": "^0.13.0", "axios": "^1.20.0", "echarts": "^6.1.0", + "highlight.js": "^11.12.0", "naive-ui": "^2.45.2", "pinia": "^4.0.3", + "sql-formatter": "^15.9.0", "vue": "^3.5.41", "vue-echarts": "^8.1.0", "vue-i18n": "^11.4.12", diff --git a/frontend/src/api/ai.ts b/frontend/src/api/ai.ts index 8216b36..92989f6 100644 --- a/frontend/src/api/ai.ts +++ b/frontend/src/api/ai.ts @@ -234,6 +234,36 @@ export function queryByAi(query: string) { 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[] + /** 返回行数 */ + rowCount: number + /** 是否因行数上限被截断 */ + truncated: boolean +} + +/** 将自然语言转换为只读 SQL 并执行查询(调用大模型,超时放宽) */ +export function queryBySql(query: string) { + return request.post('/ai/sql', { query }, { timeout: 120000 }) +} + /** 自然语言记账解析结果 */ export interface AiRecordResult { summary: string diff --git a/frontend/src/components/AiConfigPanel.vue b/frontend/src/components/AiConfigPanel.vue index 7bd0165..cd6f564 100644 --- a/frontend/src/components/AiConfigPanel.vue +++ b/frontend/src/components/AiConfigPanel.vue @@ -10,6 +10,7 @@ import { SettingsOutline, KeyOutline, LinkOutline, CubeOutline, SaveOutline, FlashOutline, StatsChartOutline, CameraOutline, } from '@vicons/ionicons5' import { getAiConfig, saveAiConfig, testAiConfig } from '../api/ai' +import { refreshAiEnabled } from '../composables/useAiEnabled' const router = useRouter() const message = useMessage() @@ -58,6 +59,7 @@ async function handleSave() { await saveAiConfig(buildPayload()) message.success(t('ai.saveSuccess')) await loadConfig() + await refreshAiEnabled() } catch (e: any) { message.error(e.message || t('common.operationFailed')) } finally { @@ -73,6 +75,7 @@ async function handleTest() { await testAiConfig() message.success(t('ai.testSuccess')) await loadConfig() + await refreshAiEnabled() } catch (e: any) { message.error(e.message || t('ai.testFailed')) } finally { diff --git a/frontend/src/composables/useAiEnabled.ts b/frontend/src/composables/useAiEnabled.ts new file mode 100644 index 0000000..e548ed5 --- /dev/null +++ b/frontend/src/composables/useAiEnabled.ts @@ -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 +} diff --git a/frontend/src/locales/messages.ts b/frontend/src/locales/messages.ts index 761d484..da3e937 100644 --- a/frontend/src/locales/messages.ts +++ b/frontend/src/locales/messages.ts @@ -93,6 +93,7 @@ export default { tags: { zh: '标签管理', en: 'Tags', ja: 'タグ管理' }, rules: { zh: '规则管理', en: 'Rules', ja: 'ルール管理' }, emailBills: { zh: '邮箱账单', en: 'Email Bills', ja: 'メール請求' }, + sqlQuery: { zh: '智能查数', en: 'Smart Data', ja: 'スマート集計' }, logs: { zh: '日志', en: 'Logs', ja: 'ログ' }, settings: { zh: '设置', en: 'Settings', ja: '設定' }, user: { zh: '用户', en: 'User', ja: 'ユーザー' }, @@ -845,6 +846,31 @@ export default { includeNo: { zh: '不计入', en: 'Excluded', 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: { button: { zh: '智能记账', en: 'Smart Entry', ja: 'スマート入力' }, title: { zh: 'AI 智能记账', en: 'AI Smart Entry', ja: 'AI スマート入力' }, @@ -923,6 +949,7 @@ export default { rule: { zh: '生成规则', en: 'Rule', ja: 'ルール生成' }, statistics: { zh: '统计搜索', en: 'Statistics', ja: '統計検索' }, relation: { zh: '生成关系', en: 'Relation', ja: '関連生成' }, + sql: { zh: '智能查数', en: 'Smart Data', ja: 'スマート集計' }, }, column: { time: { zh: '时间', en: 'Time', ja: '日時' }, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 2a00c81..d8cea35 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -41,6 +41,11 @@ const router = createRouter({ name: 'Dashboard', component: () => import('../views/Dashboard.vue'), // 统计面板 }, + { + path: 'smart-query', + name: 'SqlQuery', + component: () => import('../views/SqlQuery.vue'), // 智能查数(自然语言转 SQL) + }, { path: 'transactions', name: 'Transactions', diff --git a/frontend/src/utils/storageKeys.ts b/frontend/src/utils/storageKeys.ts index dcc9be8..b39fd3a 100644 --- a/frontend/src/utils/storageKeys.ts +++ b/frontend/src/utils/storageKeys.ts @@ -33,6 +33,8 @@ export const STORAGE_KEYS = { transactionAiQueryHistory: 'ledgerly.transaction.aiQueryHistory', // 收支记录 AI 查询浮窗位置尺寸 transactionAiQueryWindow: 'ledgerly.transaction.aiQueryWindow', + // 智能查数的历史提问 + sqlQueryHistory: 'ledgerly.sqlQuery.history', // 内容区滚动位置(sessionStorage,按路由拼接后缀) scrollPrefix: 'ledgerly.scroll.', } as const diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue index bdf21a7..50bd9ee 100644 --- a/frontend/src/views/Layout.vue +++ b/frontend/src/views/Layout.vue @@ -5,12 +5,13 @@ import { useRouter, useRoute } from 'vue-router' import { useI18n } from 'vue-i18n' import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown } 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 LocaleSwitch from '../components/LocaleSwitch.vue' import { getChannels } from '../api/transaction' import { readSessionString, writeSessionString } from '../utils/storage' import { scrollKey } from '../utils/storageKeys' +import { aiEnabled, refreshAiEnabled } from '../composables/useAiEnabled' const router = useRouter() const route = useRoute() @@ -21,6 +22,7 @@ const { t } = useI18n() const activeKey = computed(() => { const map: Record = { '/dashboard': 'dashboard', + '/smart-query': 'smart-query', '/transactions': 'transactions', '/categories': 'categories', '/tags': 'tags', @@ -41,15 +43,21 @@ function renderIcon(icon: any) { return () => h(NIcon, null, { default: () => h(icon) }) } -// 侧边栏菜单配置项(随语言切换响应式更新) -const menuOptions = computed(() => [ - { label: t('layout.dashboard'), key: 'dashboard', icon: renderIcon(BarChartOutline) }, - { label: t('layout.transactions'), key: 'transactions', icon: renderIcon(ListOutline) }, - { label: t('layout.categories'), key: 'categories', icon: renderIcon(FolderOpenOutline) }, - { label: t('layout.tags'), key: 'tags', icon: renderIcon(PricetagsOutline) }, - { label: t('layout.rules'), key: 'rules', icon: renderIcon(OptionsOutline) }, - { label: t('layout.emailBills'), key: 'email-bills', icon: renderIcon(MailOutline) }, -]) +// 侧边栏菜单配置项(随语言切换响应式更新;智能查数仅在 AI 启用时展示) +const menuOptions = computed(() => { + const options: MenuOption[] = [ + { label: t('layout.dashboard'), key: 'dashboard', icon: renderIcon(BarChartOutline) }, + { label: t('layout.transactions'), key: 'transactions', icon: renderIcon(ListOutline) }, + { label: t('layout.categories'), key: 'categories', icon: renderIcon(FolderOpenOutline) }, + { label: t('layout.tags'), key: 'tags', icon: renderIcon(PricetagsOutline) }, + { 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 导航到对应路由 */ function handleMenuUpdate(key: string) { @@ -184,6 +192,8 @@ onMounted(() => { restoreScroll(route.path) // 预加载渠道列表,保证自定义渠道名称在各页面可正确展示 loadChannels() + // 加载 AI 启用状态,控制「智能查数」菜单显隐(每次登录进入布局时刷新,避免沿用上一账号状态) + refreshAiEnabled() }) onBeforeUnmount(() => { diff --git a/frontend/src/views/SqlQuery.vue b/frontend/src/views/SqlQuery.vue new file mode 100644 index 0000000..f3f9f6e --- /dev/null +++ b/frontend/src/views/SqlQuery.vue @@ -0,0 +1,415 @@ + + + + + diff --git a/src/main/java/cn/rosercode/ledgerly/controller/AiController.java b/src/main/java/cn/rosercode/ledgerly/controller/AiController.java index a72508e..7e5f664 100644 --- a/src/main/java/cn/rosercode/ledgerly/controller/AiController.java +++ b/src/main/java/cn/rosercode/ledgerly/controller/AiController.java @@ -47,6 +47,7 @@ import cn.rosercode.ledgerly.service.AiQueryService; import cn.rosercode.ledgerly.service.AiRecordService; import cn.rosercode.ledgerly.service.AiRelationService; import cn.rosercode.ledgerly.service.AiRuleService; +import cn.rosercode.ledgerly.service.AiSqlQueryService; import cn.rosercode.ledgerly.service.AiStatisticsService; import cn.rosercode.ledgerly.service.AiSuggestionService; 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.AiRelationResultVO; import cn.rosercode.ledgerly.vo.AiRuleSuggestResultVO; +import cn.rosercode.ledgerly.vo.AiSqlQueryResultVO; import cn.rosercode.ledgerly.vo.AiStatisticsResultVO; import cn.rosercode.ledgerly.vo.AiSuggestionVO; import cn.rosercode.ledgerly.vo.AiTagResultVO; @@ -91,6 +93,7 @@ public class AiController { private final AiChannelService aiChannelService; private final AiTagService aiTagService; private final AiRuleService aiRuleService; + private final AiSqlQueryService aiSqlQueryService; private final AiStatisticsService aiStatisticsService; private final AiImageRecordService aiImageRecordService; private final AiImageRecordStreamService aiImageRecordStreamService; @@ -103,7 +106,8 @@ public class AiController { AiRecordService aiRecordService, AiDecorationService aiDecorationService, AiCategoryService aiCategoryService, AiChannelService aiChannelService, AiTagService aiTagService, - AiRuleService aiRuleService, AiStatisticsService aiStatisticsService, + AiRuleService aiRuleService, AiSqlQueryService aiSqlQueryService, + AiStatisticsService aiStatisticsService, AiImageRecordService aiImageRecordService, AiImageRecordStreamService aiImageRecordStreamService, AiRelationService aiRelationService, @@ -118,6 +122,7 @@ public class AiController { this.aiChannelService = aiChannelService; this.aiTagService = aiTagService; this.aiRuleService = aiRuleService; + this.aiSqlQueryService = aiSqlQueryService; this.aiStatisticsService = aiStatisticsService; this.aiImageRecordService = aiImageRecordService; this.aiImageRecordStreamService = aiImageRecordStreamService; @@ -230,6 +235,19 @@ public class AiController { return Result.success(aiQueryService.parse(userId, queryRequest.getQuery())); } + /** + * 自然语言转 SQL 查询 + * + * @param queryRequest 查询请求,携带自然语言语句 + * @return 查询结果(含生成的 SQL、列元数据与行数据) + */ + @PostMapping("/sql") + @Operation(summary = "自然语言查数", description = "将自然语言转换为只读 SQL 并在当前用户数据上执行,返回结果集") + public Result sqlQuery(@RequestBody @Valid AiQueryRequest queryRequest) { + Long userId = UserContext.getUserId(); + return Result.success(aiSqlQueryService.query(userId, queryRequest.getQuery())); + } + /** * 解析自然语言记账内容为交易记录 * diff --git a/src/main/java/cn/rosercode/ledgerly/enums/AiCallType.java b/src/main/java/cn/rosercode/ledgerly/enums/AiCallType.java index a43e07f..e51e463 100644 --- a/src/main/java/cn/rosercode/ledgerly/enums/AiCallType.java +++ b/src/main/java/cn/rosercode/ledgerly/enums/AiCallType.java @@ -41,5 +41,8 @@ public enum AiCallType { STATISTICS, /** 生成账单关系名称与备注 */ - RELATION + RELATION, + + /** 自然语言转换为只读 SQL 查询 */ + SQL } diff --git a/src/main/java/cn/rosercode/ledgerly/prompt/PromptTemplateService.java b/src/main/java/cn/rosercode/ledgerly/prompt/PromptTemplateService.java index a50e576..d813c7e 100644 --- a/src/main/java/cn/rosercode/ledgerly/prompt/PromptTemplateService.java +++ b/src/main/java/cn/rosercode/ledgerly/prompt/PromptTemplateService.java @@ -124,6 +124,22 @@ public class PromptTemplateService { 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 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})。 * diff --git a/src/main/java/cn/rosercode/ledgerly/service/AiSqlQueryService.java b/src/main/java/cn/rosercode/ledgerly/service/AiSqlQueryService.java new file mode 100644 index 0000000..39fd66c --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/service/AiSqlQueryService.java @@ -0,0 +1,23 @@ +package cn.rosercode.ledgerly.service; + +import cn.rosercode.ledgerly.vo.AiSqlQueryResultVO; + +/** + * 自然语言转 SQL 查询服务。 + * + *

将用户输入的自然语言交给大模型生成只读 SELECT 语句,经安全校验后在 + * 当前用户的数据上执行并返回结果。

+ * + * @date 2026-09-24 09:01:13 + */ +public interface AiSqlQueryService { + + /** + * 将自然语言转换为只读 SQL 并在当前用户数据上执行。 + * + * @param userId 当前用户ID + * @param query 用户输入的自然语言 + * @return 查询结果(含生成的 SQL、列元数据与行数据) + */ + AiSqlQueryResultVO query(Long userId, String query); +} diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/AiSqlQueryServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/AiSqlQueryServiceImpl.java new file mode 100644 index 0000000..78132ba --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/service/impl/AiSqlQueryServiceImpl.java @@ -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 columns = new ArrayList<>(); + List> 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 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 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(); + } +} diff --git a/src/main/java/cn/rosercode/ledgerly/util/sql/TextToSqlGuard.java b/src/main/java/cn/rosercode/ledgerly/util/sql/TextToSqlGuard.java new file mode 100644 index 0000000..2440d40 --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/util/sql/TextToSqlGuard.java @@ -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 的安全网关。 + * + *

对模型生成的 SQL 做只读校验与改写,保证无论模型输出如何,都只可能以 + * 只读方式访问当前用户自己的数据:

+ *
    + *
  • 仅允许单条 SELECT 语句,拒绝任何写操作、DDL 与多语句;
  • + *
  • 仅允许查询白名单中的业务表,拒绝系统表与其它表;
  • + *
  • 对所有直接引用的表强制注入 user_id 隔离条件,对无 user_id 的关联表 + * 通过父表归属子查询限制;
  • + *
  • 限制结果行数上限,防止超大结果集。
  • + *
+ * + * @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 USER_TABLES = TextToSqlTables.USER_TABLES; + + /** 无 user_id 列的关联表 -> [父表, 外键] */ + private static final Map CHILD_TABLES = TextToSqlTables.CHILD_TABLES; + + /** 允许查询的全部业务表 */ + private static final Set ALLOWED_TABLES = Set.copyOf(TextToSqlTables.ALLOWED_TABLES); + + /** 禁止调用的危险函数 */ + private static final Set 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 usedTables = new LinkedHashSet<>(); + + /** 查询语句访问器,递归处理子查询 */ + private final SelectVisitor selectVisitor; + + /** 表达式访问器,递归发现表达式中的子查询 */ + private final ExpressionVisitor expressionVisitor; + + GuardContext(long userId) { + this.userId = userId; + this.selectVisitor = new SelectVisitor<>() { + + @Override + public Void visit(PlainSelect plainSelect, S context) { + rewritePlainSelect(plainSelect); + return null; + } + + @Override + public 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 Void visit(ParenthesedSelect parenthesedSelect, S context) { + parenthesedSelect.getSelect().accept(this, context); + return null; + } + + @Override + public Void visit(WithItem withItem, S context) { + withItem.getSelect().accept(this, context); + return null; + } + + @Override + public Void visit(Values values, S context) { + return null; + } + + @Override + public Void visit(LateralSubSelect lateralSubSelect, S context) { + lateralSubSelect.getSelect().accept(this, context); + return null; + } + + @Override + public Void visit(TableStatement tableStatement, S context) { + return null; + } + }; + ExpressionVisitorAdapter adapter = new ExpressionVisitorAdapter<>() { + + @Override + public 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> 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); + } + } + + /** + * 为当前查询直接引用的表注入用户隔离条件。 + * + *

主表条件加入 WHERE;JOIN 的表条件加入其 ON 子句,避免在 LEFT JOIN + * 下因 WHERE 过滤而丢弃右表未匹配的行(例如内置渠道不在 t_channel 中)。

+ * + * @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); + } + } + } +} diff --git a/src/main/java/cn/rosercode/ledgerly/util/sql/TextToSqlSchemaProvider.java b/src/main/java/cn/rosercode/ledgerly/util/sql/TextToSqlSchemaProvider.java new file mode 100644 index 0000000..bc96afd --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/util/sql/TextToSqlSchemaProvider.java @@ -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 的表结构描述提供者。 + * + *

通过 JDBC 元数据动态读取白名单表的列信息(列名、类型),因此新增、删除 + * 或修改字段后无需调整提示词;列的中文说明优先取 {@link TextToSqlTables} 的 + * 字典,缺失时回退数据库列注释(MySQL 支持,SQLite 无),仍缺失则只展示列名 + * 与类型。

+ * + *

结果按当前数据源标识缓存,切换数据库后自动失效重建。

+ * + * @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 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 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 readColumns(DatabaseMetaData metaData, String catalog, String table) + throws SQLException { + List 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 readColumns(DatabaseMetaData metaData, String catalog, String schema, String table) + throws SQLException { + List 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) { + } +} diff --git a/src/main/java/cn/rosercode/ledgerly/util/sql/TextToSqlTables.java b/src/main/java/cn/rosercode/ledgerly/util/sql/TextToSqlTables.java new file mode 100644 index 0000000..3b7b576 --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/util/sql/TextToSqlTables.java @@ -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 可访问的表结构元数据。 + * + *

集中维护允许模型查询的表白名单、表与列的中文说明以及表间关系。列的 + * 存在性与类型由 {@link TextToSqlSchemaProvider} 从数据库元数据动态读取, + * 因此新增、删除或修改字段后无需调整提示词;本类中的中文说明仅作为可选的 + * 语义增强,字典未覆盖的新列仍会出现在提示词中(只是没有中文说明)。

+ * + *

安全性说明:表名白名单是静态的,新增数据表不会自动对模型开放,必须 + * 在此显式登记。

+ * + * @date 2026-09-24 09:16:13 + */ +public final class TextToSqlTables { + + /** 直接持有 user_id 列的业务表 */ + public static final Set USER_TABLES = Set.of( + "t_transaction", + "t_category", + "t_tag", + "t_channel", + "t_transaction_group", + "t_rule"); + + /** 无 user_id 列的关联表 -> [父表, 外键] */ + public static final Map 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 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 FORBIDDEN_COLUMNS = Set.of("raw_data", "external_no"); + + /** 表名 -> 中文说明 */ + public static final Map 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> COLUMN_DESCRIPTIONS = buildColumnDescriptions(); + + private TextToSqlTables() { + } + + /** + * 查询列的补充中文说明。 + * + * @param table 表名 + * @param column 列名 + * @return 中文说明,未配置时返回 null + */ + public static String describeColumn(String table, String column) { + Map columns = COLUMN_DESCRIPTIONS.get(table); + if (columns == null || column == null) { + return null; + } + return columns.get(column.toLowerCase()); + } + + /** + * 查询某张表的全部列说明,用于元数据不可用时兜底拼接。 + * + * @param table 表名 + * @return 列名到说明的映射,无配置时为空 Map + */ + public static Map columnDescriptions(String table) { + return COLUMN_DESCRIPTIONS.getOrDefault(table, Map.of()); + } + + /** + * 构建列说明字典。 + * + * @return 表名到列说明的映射 + */ + private static Map> buildColumnDescriptions() { + Map> 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); + } +} diff --git a/src/main/java/cn/rosercode/ledgerly/vo/AiSqlColumnVO.java b/src/main/java/cn/rosercode/ledgerly/vo/AiSqlColumnVO.java new file mode 100644 index 0000000..2dcde66 --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/vo/AiSqlColumnVO.java @@ -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; +} diff --git a/src/main/java/cn/rosercode/ledgerly/vo/AiSqlQueryResultVO.java b/src/main/java/cn/rosercode/ledgerly/vo/AiSqlQueryResultVO.java new file mode 100644 index 0000000..18be79c --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/vo/AiSqlQueryResultVO.java @@ -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 columns; + + /** 结果行数据,按列顺序保序 */ + private List> rows; + + /** 返回行数 */ + private int rowCount; + + /** 是否因行数上限被截断 */ + private boolean truncated; +} diff --git a/src/main/resources/prompts/ai-sql-system.tpl b/src/main/resources/prompts/ai-sql-system.tpl new file mode 100644 index 0000000..7dea102 --- /dev/null +++ b/src/main/resources/prompts/ai-sql-system.tpl @@ -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"} diff --git a/src/test/java/cn/rosercode/ledgerly/LedgerlyApplicationTests.java b/src/test/java/cn/rosercode/ledgerly/LedgerlyApplicationTests.java deleted file mode 100644 index df46bf7..0000000 --- a/src/test/java/cn/rosercode/ledgerly/LedgerlyApplicationTests.java +++ /dev/null @@ -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() { - } -} diff --git a/src/test/java/cn/rosercode/ledgerly/prompt/PromptTemplateServiceTest.java b/src/test/java/cn/rosercode/ledgerly/prompt/PromptTemplateServiceTest.java deleted file mode 100644 index 8340c08..0000000 --- a/src/test/java/cn/rosercode/ledgerly/prompt/PromptTemplateServiceTest.java +++ /dev/null @@ -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}"); - } -} diff --git a/src/test/java/cn/rosercode/ledgerly/service/impl/AiPromptPayloadPrivacyTest.java b/src/test/java/cn/rosercode/ledgerly/service/impl/AiPromptPayloadPrivacyTest.java deleted file mode 100644 index fd89c9e..0000000 --- a/src/test/java/cn/rosercode/ledgerly/service/impl/AiPromptPayloadPrivacyTest.java +++ /dev/null @@ -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 用户提示词隐私测试。 - * - *

校验 {@code buildUserPrompt} 生成的 JSON 载荷仅含白名单字段, - * 不含 amount、rawData、externalNo 等敏感信息,防止后续改动误加字段导致数据外发。

- * - * @date 2026-09-21 11:10:58 - */ -class AiPromptPayloadPrivacyTest { - - /** 禁止出现在提示词载荷中的敏感字段名 */ - private static final List 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; - } -}