feat: 智能查数支持提问历史持久化与体验优化

为智能查数补充提问历史的后端持久化(含创建时间、可清空),并完善前端历史展示、会话缓存与若干体验问题。

变更内容:
- 新增 t_ai_sql_history 表(MySQL/SQLite 双脚本)与 AiSqlHistory 实体、Mapper、Service、VO,记录提问内容与创建时间
- AiSqlQueryServiceImpl 在查询成功后写入提问历史,新增 GET/DELETE /api/ai/sql/history 接口,支持按创建时间倒序查询与清空
- 前端历史改为后端持久化:卡片式列表、展示创建时间、搜索过滤、一键复制提问、点击回填重查与清空
- 新增会话缓存(sessionStorage):切走再回或刷新后恢复上次输入、结果与视图模式,结果超过 500 行时不缓存结果,登出时清除

体验修复:
- 修复柱状图与饼图切换时旧 series/坐标轴残留(VChart 采用 notMerge)
- 修复进入 /smart-query 时短暂误显示「AI 未启用」(新增 aiEnabledLoaded 加载态)
- 引入 JetBrains Mono 字体并修复生成的 SQL 字体未生效(内部 code 元素继承父级字体)

依赖:
- 新增前端依赖 @fontsource/jetbrains-mono

说明:
- 历史表由 SchemaInitializer 启动时幂等创建,无需手工执行 SQL
This commit is contained in:
2026-09-24 12:30:00 +08:00
parent 49d16bec82
commit feb56c8a8d
18 changed files with 527 additions and 39 deletions
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@fontsource/jetbrains-mono": "^5.3.0",
"@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",
+17
View File
@@ -264,6 +264,23 @@ export function queryBySql(query: string) {
return request.post('/ai/sql', { query }, { timeout: 120000 }) return request.post('/ai/sql', { query }, { timeout: 120000 })
} }
/** 智能查数历史记录 */
export interface AiSqlHistoryItem {
id: number
query: string
createdAt: string
}
/** 获取智能查数历史(按创建时间倒序) */
export function getSqlHistory(limit = 100) {
return request.get('/ai/sql/history', { params: { limit } })
}
/** 清空智能查数历史 */
export function clearSqlHistory() {
return request.delete('/ai/sql/history')
}
/** 自然语言记账解析结果 */ /** 自然语言记账解析结果 */
export interface AiRecordResult { export interface AiRecordResult {
summary: string summary: string
+5 -5
View File
@@ -5,8 +5,8 @@ import { getAiConfig } from '../api/ai'
/** 当前用户 AI 是否启用;读取失败一律按未启用处理 */ /** 当前用户 AI 是否启用;读取失败一律按未启用处理 */
export const aiEnabled = ref(false) export const aiEnabled = ref(false)
/** 是否已成功加载过一次,避免重复请求 */ /** AI 启用状态是否已确定(首次读取完成前为 false,用于避免误显示「未启用」) */
let loaded = false export const aiEnabledLoaded = ref(false)
/** /**
* 从后端刷新 AI 启用状态。 * 从后端刷新 AI 启用状态。
@@ -19,17 +19,17 @@ export async function refreshAiEnabled() {
} catch (e) { } catch (e) {
aiEnabled.value = false aiEnabled.value = false
} }
loaded = true aiEnabledLoaded.value = true
} }
/** 确保至少加载过一次 AI 启用状态(已加载则不重复请求) */ /** 确保至少加载过一次 AI 启用状态(已加载则不重复请求) */
export async function ensureAiEnabledLoaded() { export async function ensureAiEnabledLoaded() {
if (loaded) return if (aiEnabledLoaded.value) return
await refreshAiEnabled() await refreshAiEnabled()
} }
/** 重置为未启用(退出登录等场景使用) */ /** 重置为未启用(退出登录等场景使用) */
export function resetAiEnabled() { export function resetAiEnabled() {
aiEnabled.value = false aiEnabled.value = false
loaded = false aiEnabledLoaded.value = false
} }
+3
View File
@@ -883,6 +883,9 @@ export default {
examplesLabel: { zh: '示例', en: 'Examples', ja: '例' }, examplesLabel: { zh: '示例', en: 'Examples', ja: '例' },
history: { zh: '历史提问', en: 'History', ja: '履歴' }, history: { zh: '历史提问', en: 'History', ja: '履歴' },
clearHistory: { zh: '清空历史', en: 'Clear history', ja: '履歴をクリア' }, clearHistory: { zh: '清空历史', en: 'Clear history', ja: '履歴をクリア' },
copyQuestion: { zh: '复制提问', en: 'Copy question', ja: '質問をコピー' },
historySearch: { zh: '搜索历史提问', en: 'Search history', ja: '履歴を検索' },
historyNoMatch: { zh: '无匹配结果', en: 'No matching history', ja: '一致する履歴がありません' },
needConfig: { zh: 'AI 未启用,请先在「设置」中开启 AI。', en: 'AI is not enabled. Please enable it in Settings first.', ja: 'AI が有効になっていません。「設定」で有効にしてください。' }, needConfig: { zh: 'AI 未启用,请先在「设置」中开启 AI。', en: 'AI is not enabled. Please enable it in Settings first.', ja: 'AI が有効になっていません。「設定」で有効にしてください。' },
}, },
record: { record: {
+4 -1
View File
@@ -2,7 +2,8 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { getUserInfo } from '../api/user' import { getUserInfo } from '../api/user'
import { readString, removeKey, writeString } from '../utils/storage' import { readString, removeKey, removeSessionKey, writeString } from '../utils/storage'
import { STORAGE_KEYS } from '../utils/storageKeys'
/** 登录令牌存储键(认证凭据,不属于界面偏好,不纳入 ledgerly. 前缀) */ /** 登录令牌存储键(认证凭据,不属于界面偏好,不纳入 ledgerly. 前缀) */
const TOKEN_KEY = 'token' const TOKEN_KEY = 'token'
@@ -41,6 +42,8 @@ export const useUserStore = defineStore('user', () => {
nickname.value = '' nickname.value = ''
userId.value = null userId.value = null
removeKey(TOKEN_KEY) removeKey(TOKEN_KEY)
// 清除智能查数上一次查询会话,避免同标签切换账号后看到上一用户的数据
removeSessionKey(STORAGE_KEYS.sqlQuerySession)
} }
return { token, nickname, userId, setToken, setUserInfo, fetchUserInfo, logout } return { token, nickname, userId, setToken, setUserInfo, fetchUserInfo, logout }
+28
View File
@@ -64,6 +64,34 @@ export function writeSessionString(key: string, value: string): void {
} }
} }
/** 读取 sessionStorage JSON,解析失败或读取异常时回退 fallback */
export function readSessionJson<T>(key: string, fallback: T): T {
try {
const raw = sessionStorage.getItem(key)
return raw ? (JSON.parse(raw) as T) : fallback
} catch (e) {
return fallback
}
}
/** 写入 sessionStorage JSON,忽略存储异常 */
export function writeSessionJson(key: string, value: unknown): void {
try {
sessionStorage.setItem(key, JSON.stringify(value))
} catch (e) {
// 忽略写入失败
}
}
/** 删除 sessionStorage 键,忽略存储异常 */
export function removeSessionKey(key: string): void {
try {
sessionStorage.removeItem(key)
} catch (e) {
// 忽略删除失败
}
}
/** /**
* 迁移历史存储键:新键不存在且旧键存在时,将旧值搬运到新键并删除旧键。 * 迁移历史存储键:新键不存在且旧键存在时,将旧值搬运到新键并删除旧键。
* *
+2 -2
View File
@@ -33,8 +33,8 @@ export const STORAGE_KEYS = {
transactionAiQueryHistory: 'ledgerly.transaction.aiQueryHistory', transactionAiQueryHistory: 'ledgerly.transaction.aiQueryHistory',
// 收支记录 AI 查询浮窗位置尺寸 // 收支记录 AI 查询浮窗位置尺寸
transactionAiQueryWindow: 'ledgerly.transaction.aiQueryWindow', transactionAiQueryWindow: 'ledgerly.transaction.aiQueryWindow',
// 智能查数的历史提问 // 智能查数上一次查询会话(sessionStorage,含输入与结果缓存)
sqlQueryHistory: 'ledgerly.sqlQuery.history', sqlQuerySession: 'ledgerly.sqlQuery.session',
// 内容区滚动位置(sessionStorage,按路由拼接后缀) // 内容区滚动位置(sessionStorage,按路由拼接后缀)
scrollPrefix: 'ledgerly.scroll.', scrollPrefix: 'ledgerly.scroll.',
} as const } as const
+205 -27
View File
@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
// 智能查数页:将自然语言交给大模型生成只读 SQL 并执行,自适应渲染统计卡片、表格与图表 // 智能查数页:将自然语言交给大模型生成只读 SQL 并执行,自适应渲染统计卡片、表格与图表
import { computed, defineAsyncComponent, h, onMounted, ref } from 'vue' import { computed, defineAsyncComponent, h, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { import {
NAlert, NButton, NCard, NCollapse, NCollapseItem, NDataTable, NEmpty, NGrid, NGridItem, NAlert, NButton, NCard, NCollapse, NCollapseItem, NDataTable, NEmpty, NGrid, NGridItem,
NIcon, NInput, NSelect, NSpace, NStatistic, NText, useMessage, NIcon, NInput, NSpin, NSpace, NStatistic, NText, useMessage,
} from 'naive-ui' } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui' import type { DataTableColumns } from 'naive-ui'
import { CopyOutline, SearchOutline, SparklesOutline } from '@vicons/ionicons5' import { CopyOutline, SearchOutline, SparklesOutline } from '@vicons/ionicons5'
@@ -12,14 +12,17 @@ import { format } from 'sql-formatter'
import hljs from 'highlight.js/lib/core' import hljs from 'highlight.js/lib/core'
import sql from 'highlight.js/lib/languages/sql' import sql from 'highlight.js/lib/languages/sql'
import 'highlight.js/styles/github.css' import 'highlight.js/styles/github.css'
import { queryBySql } from '../api/ai' import '@fontsource/jetbrains-mono/400.css'
import type { AiSqlColumn, AiSqlQueryResult } from '../api/ai' import '@fontsource/jetbrains-mono/700.css'
import { queryBySql, getSqlHistory, clearSqlHistory } from '../api/ai'
import type { AiSqlColumn, AiSqlHistoryItem, AiSqlQueryResult } from '../api/ai'
import { getChannelLabel } from '../utils/channel' import { getChannelLabel } from '../utils/channel'
import { aiEnabled, ensureAiEnabledLoaded } from '../composables/useAiEnabled' import { aiEnabled, aiEnabledLoaded, ensureAiEnabledLoaded } from '../composables/useAiEnabled'
import { useKeywordHistory } from '../composables/useKeywordHistory'
import { usePageSizeOptions } from '../composables/usePageSizeOptions' import { usePageSizeOptions } from '../composables/usePageSizeOptions'
import { useTablePageSize } from '../composables/useTablePageSize' import { useTablePageSize } from '../composables/useTablePageSize'
import { readSessionJson, writeSessionJson, removeSessionKey } from '../utils/storage'
import { STORAGE_KEYS } from '../utils/storageKeys' import { STORAGE_KEYS } from '../utils/storageKeys'
import { useUserStore } from '../store/user'
// 图表组件异步加载:echarts 体积较大,按需加载,避免阻塞首屏 // 图表组件异步加载:echarts 体积较大,按需加载,避免阻塞首屏
const VChart = defineAsyncComponent(async () => { const VChart = defineAsyncComponent(async () => {
@@ -47,28 +50,61 @@ hljs.registerLanguage('sql', sql)
const { t } = useI18n() const { t } = useI18n()
const message = useMessage() const message = useMessage()
const userStore = useUserStore()
/** 展示视图类型 */ /** 展示视图类型 */
type ViewMode = 'table' | 'bar' | 'pie' type ViewMode = 'table' | 'bar' | 'pie'
/** 上一次查询的会话缓存结构(sessionStorage) */
interface SqlQuerySession {
userId: number | null
query: string
result: AiSqlQueryResult | null
viewMode: ViewMode
}
/** 可缓存的最大结果行数,超过时只缓存输入与视图 */
const MAX_CACHED_ROWS = 500
const queryText = ref('') const queryText = ref('')
const loading = ref(false) const loading = ref(false)
const result = ref<AiSqlQueryResult | null>(null) const result = ref<AiSqlQueryResult | null>(null)
const resultVersion = ref(0) const resultVersion = ref(0)
const viewMode = ref<ViewMode>('table') const viewMode = ref<ViewMode>('table')
// 提问历史(持久化、去重、清空) // 提问历史(后端持久化,含创建时间)
const { history, load: loadHistory, record: recordHistory, clear: clearHistory } = useKeywordHistory({ const history = ref<AiSqlHistoryItem[]>([])
storageKey: STORAGE_KEYS.sqlQueryHistory,
text: queryText, /** 从后端加载提问历史 */
clearLabel: () => t('ai.sql.clearHistory'), async function loadHistory() {
max: 50, try {
const res: any = await getSqlHistory(100)
history.value = res.data || []
} catch (e) {
// 静默处理,历史加载失败不影响查询
}
}
/** 清空后端的提问历史 */
async function clearHistory() {
try {
await clearSqlHistory()
history.value = []
} catch (e: any) {
message.error(e.message || t('ai.sql.failed'))
}
}
// 历史搜索关键字与过滤结果(按提问内容忽略大小写匹配)
const historyKeyword = ref('')
const filteredHistory = computed(() => {
const keyword = historyKeyword.value.trim().toLowerCase()
if (!keyword) return history.value
return history.value.filter((item) => item.query.toLowerCase().includes(keyword))
}) })
// 示例问题 // 示例问题
const exampleList = computed(() => t('ai.sql.examples').split('|').filter((item) => item.trim())) 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 resultRows = computed(() => result.value?.rows ?? [])
@@ -115,6 +151,9 @@ const tableColumns = computed<DataTableColumns<Record<string, unknown>>>(() => {
})) }))
}) })
// 图表更新选项:完全替换 option,避免柱状图与饼图切换时残留旧的 series/坐标轴
const chartUpdateOptions = { notMerge: true }
// 图表配置:饼图取首个数值列,柱状图取全部数值列 // 图表配置:饼图取首个数值列,柱状图取全部数值列
const chartOption = computed(() => { const chartOption = computed(() => {
if (!result.value || !canChart.value) return null if (!result.value || !canChart.value) return null
@@ -170,7 +209,7 @@ async function handleQuery() {
result.value = res.data as AiSqlQueryResult result.value = res.data as AiSqlQueryResult
viewMode.value = defaultViewMode(result.value) viewMode.value = defaultViewMode(result.value)
resultVersion.value += 1 resultVersion.value += 1
recordHistory() loadHistory()
} catch (e: any) { } catch (e: any) {
message.error(e.message || t('ai.sql.failed')) message.error(e.message || t('ai.sql.failed'))
} finally { } finally {
@@ -202,12 +241,28 @@ function applyExample(value: string) {
} }
/** 选择历史:填入并立即查询 */ /** 选择历史:填入并立即查询 */
function applyHistory(value: string | number | null) { function applyHistory(value: string) {
if (typeof value !== 'string' || !value) return if (!value) return
queryText.value = value queryText.value = value
handleQuery() handleQuery()
} }
/** 复制历史提问到剪贴板 */
async function copyQuestion(text: string) {
try {
await navigator.clipboard.writeText(text)
message.success(t('ai.sql.copySuccess'))
} catch (e) {
message.error(t('ai.sql.failed'))
}
}
/** 格式化历史创建时间:后端返回本地时间字符串,直接展示到分钟 */
function formatHistoryTime(value: string): string {
if (!value) return ''
return value.replace('T', ' ').slice(0, 16)
}
/** 判断列是否为交易渠道列:与现有页面 row.channel → getChannelLabel 的约定一致 */ /** 判断列是否为交易渠道列:与现有页面 row.channel → getChannelLabel 的约定一致 */
function isChannelColumn(col: AiSqlColumn): boolean { function isChannelColumn(col: AiSqlColumn): boolean {
const key = col.key.toLowerCase() const key = col.key.toLowerCase()
@@ -267,7 +322,49 @@ async function copySql() {
} }
} }
/** 恢复上一次查询会话(sessionStorage,切换路由/刷新后不丢) */
function restoreSession() {
const session = readSessionJson<SqlQuerySession | null>(STORAGE_KEYS.sqlQuerySession, null)
if (!session || typeof session !== 'object') return
// 用户不一致时忽略并清除(userStore.userId 未加载时按同用户处理)
if (session.userId != null && userStore.userId != null && session.userId !== userStore.userId) {
removeSessionKey(STORAGE_KEYS.sqlQuerySession)
return
}
if (typeof session.query === 'string') {
queryText.value = session.query
}
if (session.viewMode === 'table' || session.viewMode === 'bar' || session.viewMode === 'pie') {
viewMode.value = session.viewMode
}
const cached = session.result
if (cached && Array.isArray(cached.columns) && Array.isArray(cached.rows)) {
result.value = cached
resultVersion.value += 1
}
}
/** 持久化当前查询会话;结果行数过大时只缓存输入与视图 */
function persistSession() {
const payload: SqlQuerySession = {
userId: userStore.userId,
query: queryText.value,
viewMode: viewMode.value,
result: result.value && result.value.rowCount <= MAX_CACHED_ROWS ? result.value : null,
}
writeSessionJson(STORAGE_KEYS.sqlQuerySession, payload)
}
// 输入、结果与视图变化时持久化(输入做防抖,避免频繁写入)
let persistTimer = 0
watch(queryText, () => {
if (persistTimer) window.clearTimeout(persistTimer)
persistTimer = window.setTimeout(persistSession, 300)
})
watch([result, viewMode], persistSession)
onMounted(async () => { onMounted(async () => {
restoreSession()
await ensureAiEnabledLoaded() await ensureAiEnabledLoaded()
loadHistory() loadHistory()
}) })
@@ -282,7 +379,9 @@ onMounted(async () => {
</NSpace> </NSpace>
</template> </template>
<NAlert v-if="!aiEnabled" type="warning" :show-icon="true"> <NSpin v-if="!aiEnabledLoaded" size="small" class="ai-loading" />
<NAlert v-else-if="!aiEnabled" type="warning" :show-icon="true">
{{ t('ai.sql.needConfig') }} {{ t('ai.sql.needConfig') }}
</NAlert> </NAlert>
@@ -312,18 +411,51 @@ onMounted(async () => {
</NButton> </NButton>
</NSpace> </NSpace>
<NSpace v-if="history.length" align="center" :size="8" style="margin-top: 8px;"> <NCollapse v-if="history.length" style="margin-top: 12px;">
<NText depth="3" style="font-size: 12px;">{{ t('ai.sql.history') }}</NText> <NCollapseItem :title="`${t('ai.sql.history')} (${history.length})`" name="history">
<NSelect <NSpace align="center" justify="space-between" :size="8" style="margin-bottom: 12px;">
:options="historyOptions" <NInput
v-model:value="historyKeyword"
size="small" size="small"
style="width: 240px;" clearable
:placeholder="t('ai.sql.history')" :placeholder="t('ai.sql.historySearch')"
@update:value="applyHistory" style="max-width: 260px;"
/> >
<template #prefix>
<NIcon :component="SearchOutline" />
</template>
</NInput>
<NButton size="tiny" quaternary @click="clearHistory">{{ t('ai.sql.clearHistory') }}</NButton> <NButton size="tiny" quaternary @click="clearHistory">{{ t('ai.sql.clearHistory') }}</NButton>
</NSpace> </NSpace>
<NEmpty
v-if="!filteredHistory.length"
size="small"
:description="t('ai.sql.historyNoMatch')"
/>
<div v-else class="history-list">
<div
v-for="item in filteredHistory"
:key="item.id"
class="history-item"
@click="applyHistory(item.query)"
>
<div class="history-item__main">
<span class="history-item__query">{{ item.query }}</span>
<span class="history-item__time">{{ formatHistoryTime(item.createdAt) }}</span>
</div>
<NButton size="tiny" quaternary @click.stop="copyQuestion(item.query)">
<template #icon>
<NIcon :component="CopyOutline" />
</template>
{{ t('ai.sql.copyQuestion') }}
</NButton>
</div>
</div>
</NCollapseItem>
</NCollapse>
<div v-if="result" style="margin-top: 20px;"> <div v-if="result" style="margin-top: 20px;">
<NCollapse style="margin-bottom: 16px;"> <NCollapse style="margin-bottom: 16px;">
<NCollapseItem :title="t('ai.sql.sqlTitle')" name="sql"> <NCollapseItem :title="t('ai.sql.sqlTitle')" name="sql">
@@ -388,7 +520,7 @@ onMounted(async () => {
size="small" size="small"
:scroll-x="960" :scroll-x="960"
/> />
<VChart v-else :option="chartOption ?? undefined" autoresize style="height: 380px;" /> <VChart v-else :option="chartOption ?? undefined" :update-options="chartUpdateOptions" autoresize style="height: 380px;" />
</template> </template>
</template> </template>
</div> </div>
@@ -407,9 +539,55 @@ onMounted(async () => {
font-size: 12px; font-size: 12px;
line-height: 1.6; line-height: 1.6;
overflow: auto; overflow: auto;
font-family: 'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace;
} }
.sql-code code.hljs { .sql-code code.hljs {
padding: 0; padding: 0;
background: transparent; background: transparent;
font-family: inherit;
}
.ai-loading {
display: flex;
justify-content: center;
padding: 24px 0;
}
.history-list {
max-height: 280px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.history-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fafafa;
cursor: pointer;
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
}
.history-item:hover {
border-color: #18a058;
background: #f0f9f3;
box-shadow: 0 1px 4px rgba(24, 160, 88, 0.12);
}
.history-item__main {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.history-item__query {
color: #333;
word-break: break-all;
}
.history-item__time {
margin-top: 2px;
font-size: 12px;
color: #9aa0a6;
} }
</style> </style>
@@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -47,6 +48,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.AiSqlHistoryService;
import cn.rosercode.ledgerly.service.AiSqlQueryService; 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;
@@ -62,6 +64,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.AiSqlHistoryVO;
import cn.rosercode.ledgerly.vo.AiSqlQueryResultVO; 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;
@@ -94,6 +97,7 @@ public class AiController {
private final AiTagService aiTagService; private final AiTagService aiTagService;
private final AiRuleService aiRuleService; private final AiRuleService aiRuleService;
private final AiSqlQueryService aiSqlQueryService; private final AiSqlQueryService aiSqlQueryService;
private final AiSqlHistoryService aiSqlHistoryService;
private final AiStatisticsService aiStatisticsService; private final AiStatisticsService aiStatisticsService;
private final AiImageRecordService aiImageRecordService; private final AiImageRecordService aiImageRecordService;
private final AiImageRecordStreamService aiImageRecordStreamService; private final AiImageRecordStreamService aiImageRecordStreamService;
@@ -107,6 +111,7 @@ public class AiController {
AiCategoryService aiCategoryService, AiChannelService aiChannelService, AiCategoryService aiCategoryService, AiChannelService aiChannelService,
AiTagService aiTagService, AiTagService aiTagService,
AiRuleService aiRuleService, AiSqlQueryService aiSqlQueryService, AiRuleService aiRuleService, AiSqlQueryService aiSqlQueryService,
AiSqlHistoryService aiSqlHistoryService,
AiStatisticsService aiStatisticsService, AiStatisticsService aiStatisticsService,
AiImageRecordService aiImageRecordService, AiImageRecordService aiImageRecordService,
AiImageRecordStreamService aiImageRecordStreamService, AiImageRecordStreamService aiImageRecordStreamService,
@@ -123,6 +128,7 @@ public class AiController {
this.aiTagService = aiTagService; this.aiTagService = aiTagService;
this.aiRuleService = aiRuleService; this.aiRuleService = aiRuleService;
this.aiSqlQueryService = aiSqlQueryService; this.aiSqlQueryService = aiSqlQueryService;
this.aiSqlHistoryService = aiSqlHistoryService;
this.aiStatisticsService = aiStatisticsService; this.aiStatisticsService = aiStatisticsService;
this.aiImageRecordService = aiImageRecordService; this.aiImageRecordService = aiImageRecordService;
this.aiImageRecordStreamService = aiImageRecordStreamService; this.aiImageRecordStreamService = aiImageRecordStreamService;
@@ -248,6 +254,31 @@ public class AiController {
return Result.success(aiSqlQueryService.query(userId, queryRequest.getQuery())); return Result.success(aiSqlQueryService.query(userId, queryRequest.getQuery()));
} }
/**
* 获取智能查数历史
*
* @param limit 返回条数,默认 100
* @return 提问历史列表(按创建时间倒序)
*/
@GetMapping("/sql/history")
@Operation(summary = "获取智能查数历史", description = "按创建时间倒序返回当前用户的自然语言提问历史")
public Result<List<AiSqlHistoryVO>> sqlHistory(@RequestParam(defaultValue = "100") int limit) {
Long userId = UserContext.getUserId();
return Result.success(aiSqlHistoryService.list(userId, limit));
}
/**
* 清空智能查数历史
*
* @return 删除的历史条数
*/
@DeleteMapping("/sql/history")
@Operation(summary = "清空智能查数历史", description = "删除当前用户的全部智能查数提问历史")
public Result<Integer> clearSqlHistory() {
Long userId = UserContext.getUserId();
return Result.success(aiSqlHistoryService.clear(userId));
}
/** /**
* 解析自然语言记账内容为交易记录 * 解析自然语言记账内容为交易记录
* *
@@ -0,0 +1,31 @@
package cn.rosercode.ledgerly.entity;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* AI 智能查数历史实体类,对应数据库表 t_ai_sql_history,记录用户的自然语言提问。
*
* @date 2026-09-24 12:18:26
*/
@Data
@TableName("t_ai_sql_history")
public class AiSqlHistory {
/** 记录ID,主键自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 创建时间 */
private LocalDateTime createdAt;
/** 所属用户ID */
private Long userId;
/** 用户的自然语言提问 */
private String queryText;
}
@@ -0,0 +1,15 @@
package cn.rosercode.ledgerly.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import cn.rosercode.ledgerly.entity.AiSqlHistory;
/**
* AI 智能查数历史数据访问接口。
*
* @date 2026-09-24 12:18:26
*/
@Mapper
public interface AiSqlHistoryMapper extends BaseMapper<AiSqlHistory> {
}
@@ -0,0 +1,38 @@
package cn.rosercode.ledgerly.service;
import java.util.List;
import cn.rosercode.ledgerly.vo.AiSqlHistoryVO;
/**
* AI 智能查数历史服务,提供提问历史的记录、查询与清空。
*
* @date 2026-09-24 12:18:26
*/
public interface AiSqlHistoryService {
/**
* 记录一条提问历史。
*
* @param userId 用户ID
* @param query 用户的自然语言提问
*/
void save(Long userId, String query);
/**
* 查询当前用户最近的提问历史(按创建时间倒序)。
*
* @param userId 用户ID
* @param limit 返回条数
* @return 历史列表
*/
List<AiSqlHistoryVO> list(Long userId, int limit);
/**
* 清空当前用户的全部提问历史。
*
* @param userId 用户ID
* @return 删除条数
*/
int clear(Long userId);
}
@@ -0,0 +1,98 @@
package cn.rosercode.ledgerly.service.impl;
import java.util.ArrayList;
import java.util.List;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import cn.rosercode.ledgerly.entity.AiSqlHistory;
import cn.rosercode.ledgerly.mapper.AiSqlHistoryMapper;
import cn.rosercode.ledgerly.service.AiSqlHistoryService;
import cn.rosercode.ledgerly.vo.AiSqlHistoryVO;
/**
* AI 智能查数历史服务实现类。
*
* @date 2026-09-24 12:18:26
*/
@Slf4j
@Service
public class AiSqlHistoryServiceImpl implements AiSqlHistoryService {
/** 查询条数上限 */
private static final int MAX_LIMIT = 200;
/** 提问内容最大长度,与数据库列保持一致 */
private static final int QUERY_TEXT_LIMIT = 500;
private final AiSqlHistoryMapper aiSqlHistoryMapper;
public AiSqlHistoryServiceImpl(AiSqlHistoryMapper aiSqlHistoryMapper) {
this.aiSqlHistoryMapper = aiSqlHistoryMapper;
}
/**
* {@inheritDoc}
*
* 写入失败仅打印告警,不影响主流程。
*/
@Override
public void save(Long userId, String query) {
if (query == null || query.isBlank()) {
return;
}
AiSqlHistory history = new AiSqlHistory();
history.setUserId(userId);
history.setQueryText(truncate(query.trim()));
try {
aiSqlHistoryMapper.insert(history);
} catch (RuntimeException e) {
log.warn("保存智能查数历史失败: userId={}, 原因={}", userId, e.getMessage());
}
}
/**
* {@inheritDoc}
*/
@Override
public List<AiSqlHistoryVO> list(Long userId, int limit) {
int size = Math.min(Math.max(limit, 1), MAX_LIMIT);
List<AiSqlHistory> rows = aiSqlHistoryMapper.selectList(new LambdaQueryWrapper<AiSqlHistory>()
.eq(AiSqlHistory::getUserId, userId)
.orderByDesc(AiSqlHistory::getCreatedAt)
.orderByDesc(AiSqlHistory::getId)
.last("LIMIT " + size));
List<AiSqlHistoryVO> result = new ArrayList<>();
for (AiSqlHistory row : rows) {
AiSqlHistoryVO vo = new AiSqlHistoryVO();
vo.setId(row.getId());
vo.setQuery(row.getQueryText());
vo.setCreatedAt(row.getCreatedAt());
result.add(vo);
}
return result;
}
/**
* {@inheritDoc}
*/
@Override
public int clear(Long userId) {
int deleted = aiSqlHistoryMapper.delete(new LambdaQueryWrapper<AiSqlHistory>()
.eq(AiSqlHistory::getUserId, userId));
log.info("清空智能查数历史: userId={}, 删除={}条", userId, deleted);
return deleted;
}
/**
* 截断提问文本,避免超出数据库列长度。
*
* @param value 原始文本
* @return 截断后的文本
*/
private String truncate(String value) {
return value.length() > QUERY_TEXT_LIMIT ? value.substring(0, QUERY_TEXT_LIMIT) : value;
}
}
@@ -32,6 +32,7 @@ import cn.rosercode.ledgerly.entity.AiConfig;
import cn.rosercode.ledgerly.enums.AiCallType; import cn.rosercode.ledgerly.enums.AiCallType;
import cn.rosercode.ledgerly.prompt.PromptTemplateService; import cn.rosercode.ledgerly.prompt.PromptTemplateService;
import cn.rosercode.ledgerly.service.AiConfigService; import cn.rosercode.ledgerly.service.AiConfigService;
import cn.rosercode.ledgerly.service.AiSqlHistoryService;
import cn.rosercode.ledgerly.service.AiSqlQueryService; import cn.rosercode.ledgerly.service.AiSqlQueryService;
import cn.rosercode.ledgerly.service.AiUsageService; import cn.rosercode.ledgerly.service.AiUsageService;
import cn.rosercode.ledgerly.util.sql.TextToSqlGuard; import cn.rosercode.ledgerly.util.sql.TextToSqlGuard;
@@ -63,6 +64,7 @@ public class AiSqlQueryServiceImpl implements AiSqlQueryService {
private final AiConfigService aiConfigService; private final AiConfigService aiConfigService;
private final DeepSeekClient deepSeekClient; private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService; private final AiUsageService aiUsageService;
private final AiSqlHistoryService aiSqlHistoryService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final PromptTemplateService promptTemplateService; private final PromptTemplateService promptTemplateService;
private final TextToSqlSchemaProvider schemaProvider; private final TextToSqlSchemaProvider schemaProvider;
@@ -71,6 +73,7 @@ public class AiSqlQueryServiceImpl implements AiSqlQueryService {
public AiSqlQueryServiceImpl(AiConfigService aiConfigService, public AiSqlQueryServiceImpl(AiConfigService aiConfigService,
DeepSeekClient deepSeekClient, DeepSeekClient deepSeekClient,
AiUsageService aiUsageService, AiUsageService aiUsageService,
AiSqlHistoryService aiSqlHistoryService,
ObjectMapper objectMapper, ObjectMapper objectMapper,
PromptTemplateService promptTemplateService, PromptTemplateService promptTemplateService,
TextToSqlSchemaProvider schemaProvider, TextToSqlSchemaProvider schemaProvider,
@@ -78,6 +81,7 @@ public class AiSqlQueryServiceImpl implements AiSqlQueryService {
this.aiConfigService = aiConfigService; this.aiConfigService = aiConfigService;
this.deepSeekClient = deepSeekClient; this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService; this.aiUsageService = aiUsageService;
this.aiSqlHistoryService = aiSqlHistoryService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.promptTemplateService = promptTemplateService; this.promptTemplateService = promptTemplateService;
this.schemaProvider = schemaProvider; this.schemaProvider = schemaProvider;
@@ -132,6 +136,7 @@ public class AiSqlQueryServiceImpl implements AiSqlQueryService {
result.setChartType(chartType); result.setChartType(chartType);
log.info("AI SQL 查询完成: userId={}, 耗时={}ms, 行数={}, 截断={}", log.info("AI SQL 查询完成: userId={}, 耗时={}ms, 行数={}, 截断={}",
userId, System.currentTimeMillis() - start, result.getRowCount(), result.isTruncated()); userId, System.currentTimeMillis() - start, result.getRowCount(), result.isTruncated());
aiSqlHistoryService.save(userId, query);
return result; return result;
} }
@@ -1,6 +1,5 @@
package cn.rosercode.ledgerly.util.sql; package cn.rosercode.ledgerly.util.sql;
import java.util.ArrayList;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@@ -0,0 +1,23 @@
package cn.rosercode.ledgerly.vo;
import java.time.LocalDateTime;
import lombok.Data;
/**
* AI 智能查数历史视图对象。
*
* @date 2026-09-24 12:18:26
*/
@Data
public class AiSqlHistoryVO {
/** 记录ID */
private Long id;
/** 提问内容 */
private String query;
/** 创建时间 */
private LocalDateTime createdAt;
}
+9
View File
@@ -122,6 +122,15 @@ CREATE TABLE IF NOT EXISTS t_ai_usage (
INDEX idx_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引' INDEX idx_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引'
); );
-- AI 智能查数历史表:记录用户的自然语言提问,用于历史回看与复制
CREATE TABLE IF NOT EXISTS t_ai_sql_history (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID,主键自增',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
query_text VARCHAR(500) NOT NULL COMMENT '用户的自然语言提问',
INDEX idx_ai_sql_history_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引'
);
-- AI 记账图片表:保存用户上传的账单/支付截图及其解析结果摘要 -- AI 记账图片表:保存用户上传的账单/支付截图及其解析结果摘要
CREATE TABLE IF NOT EXISTS t_ai_image ( CREATE TABLE IF NOT EXISTS t_ai_image (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '图片ID,主键自增', id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '图片ID,主键自增',
+9
View File
@@ -122,6 +122,15 @@ CREATE TABLE IF NOT EXISTS t_ai_usage (
);; );;
CREATE INDEX IF NOT EXISTS idx_ai_usage_user_created ON t_ai_usage (user_id, created_at);; CREATE INDEX IF NOT EXISTS idx_ai_usage_user_created ON t_ai_usage (user_id, created_at);;
-- AI 智能查数历史表:记录用户的自然语言提问,用于历史回看与复制
CREATE TABLE IF NOT EXISTS t_ai_sql_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
user_id INTEGER NOT NULL,
query_text VARCHAR(500) NOT NULL
);;
CREATE INDEX IF NOT EXISTS idx_ai_sql_history_user_created ON t_ai_sql_history (user_id, created_at);;
-- AI 记账图片表:保存用户上传的账单/支付截图及其解析结果摘要 -- AI 记账图片表:保存用户上传的账单/支付截图及其解析结果摘要
CREATE TABLE IF NOT EXISTS t_ai_image ( CREATE TABLE IF NOT EXISTS t_ai_image (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,