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:
@@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
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.AiRelationService;
|
||||
import cn.rosercode.ledgerly.service.AiRuleService;
|
||||
import cn.rosercode.ledgerly.service.AiSqlHistoryService;
|
||||
import cn.rosercode.ledgerly.service.AiSqlQueryService;
|
||||
import cn.rosercode.ledgerly.service.AiStatisticsService;
|
||||
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.AiRelationResultVO;
|
||||
import cn.rosercode.ledgerly.vo.AiRuleSuggestResultVO;
|
||||
import cn.rosercode.ledgerly.vo.AiSqlHistoryVO;
|
||||
import cn.rosercode.ledgerly.vo.AiSqlQueryResultVO;
|
||||
import cn.rosercode.ledgerly.vo.AiStatisticsResultVO;
|
||||
import cn.rosercode.ledgerly.vo.AiSuggestionVO;
|
||||
@@ -94,6 +97,7 @@ public class AiController {
|
||||
private final AiTagService aiTagService;
|
||||
private final AiRuleService aiRuleService;
|
||||
private final AiSqlQueryService aiSqlQueryService;
|
||||
private final AiSqlHistoryService aiSqlHistoryService;
|
||||
private final AiStatisticsService aiStatisticsService;
|
||||
private final AiImageRecordService aiImageRecordService;
|
||||
private final AiImageRecordStreamService aiImageRecordStreamService;
|
||||
@@ -107,6 +111,7 @@ public class AiController {
|
||||
AiCategoryService aiCategoryService, AiChannelService aiChannelService,
|
||||
AiTagService aiTagService,
|
||||
AiRuleService aiRuleService, AiSqlQueryService aiSqlQueryService,
|
||||
AiSqlHistoryService aiSqlHistoryService,
|
||||
AiStatisticsService aiStatisticsService,
|
||||
AiImageRecordService aiImageRecordService,
|
||||
AiImageRecordStreamService aiImageRecordStreamService,
|
||||
@@ -123,6 +128,7 @@ public class AiController {
|
||||
this.aiTagService = aiTagService;
|
||||
this.aiRuleService = aiRuleService;
|
||||
this.aiSqlQueryService = aiSqlQueryService;
|
||||
this.aiSqlHistoryService = aiSqlHistoryService;
|
||||
this.aiStatisticsService = aiStatisticsService;
|
||||
this.aiImageRecordService = aiImageRecordService;
|
||||
this.aiImageRecordStreamService = aiImageRecordStreamService;
|
||||
@@ -248,6 +254,31 @@ public class AiController {
|
||||
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.prompt.PromptTemplateService;
|
||||
import cn.rosercode.ledgerly.service.AiConfigService;
|
||||
import cn.rosercode.ledgerly.service.AiSqlHistoryService;
|
||||
import cn.rosercode.ledgerly.service.AiSqlQueryService;
|
||||
import cn.rosercode.ledgerly.service.AiUsageService;
|
||||
import cn.rosercode.ledgerly.util.sql.TextToSqlGuard;
|
||||
@@ -63,6 +64,7 @@ public class AiSqlQueryServiceImpl implements AiSqlQueryService {
|
||||
private final AiConfigService aiConfigService;
|
||||
private final DeepSeekClient deepSeekClient;
|
||||
private final AiUsageService aiUsageService;
|
||||
private final AiSqlHistoryService aiSqlHistoryService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final PromptTemplateService promptTemplateService;
|
||||
private final TextToSqlSchemaProvider schemaProvider;
|
||||
@@ -71,6 +73,7 @@ public class AiSqlQueryServiceImpl implements AiSqlQueryService {
|
||||
public AiSqlQueryServiceImpl(AiConfigService aiConfigService,
|
||||
DeepSeekClient deepSeekClient,
|
||||
AiUsageService aiUsageService,
|
||||
AiSqlHistoryService aiSqlHistoryService,
|
||||
ObjectMapper objectMapper,
|
||||
PromptTemplateService promptTemplateService,
|
||||
TextToSqlSchemaProvider schemaProvider,
|
||||
@@ -78,6 +81,7 @@ public class AiSqlQueryServiceImpl implements AiSqlQueryService {
|
||||
this.aiConfigService = aiConfigService;
|
||||
this.deepSeekClient = deepSeekClient;
|
||||
this.aiUsageService = aiUsageService;
|
||||
this.aiSqlHistoryService = aiSqlHistoryService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.promptTemplateService = promptTemplateService;
|
||||
this.schemaProvider = schemaProvider;
|
||||
@@ -132,6 +136,7 @@ public class AiSqlQueryServiceImpl implements AiSqlQueryService {
|
||||
result.setChartType(chartType);
|
||||
log.info("AI SQL 查询完成: userId={}, 耗时={}ms, 行数={}, 截断={}",
|
||||
userId, System.currentTimeMillis() - start, result.getRowCount(), result.isTruncated());
|
||||
aiSqlHistoryService.save(userId, query);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package cn.rosercode.ledgerly.util.sql;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
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;
|
||||
}
|
||||
@@ -122,6 +122,15 @@ CREATE TABLE IF NOT EXISTS t_ai_usage (
|
||||
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 记账图片表:保存用户上传的账单/支付截图及其解析结果摘要
|
||||
CREATE TABLE IF NOT EXISTS t_ai_image (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '图片ID,主键自增',
|
||||
|
||||
@@ -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);;
|
||||
|
||||
-- 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 记账图片表:保存用户上传的账单/支付截图及其解析结果摘要
|
||||
CREATE TABLE IF NOT EXISTS t_ai_image (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
Reference in New Issue
Block a user