feat: 接入 DeepSeek,新增 AI 分类建议、自然语言查询与智能记账

- 后端新增 AI 模块:DeepSeek 客户端、用户级配置表 t_ai_config、用量记录表 t_ai_usage
- 批量设置支持 AI 推荐分类与标签,并可一键应用
- 交易列表支持自然语言查询,解析为筛选条件确认后应用
- 支持自然语言记账,解析后预填新增表单
- 新增 AI 设置页:启用开关、API Key、接口地址、模型、连通性测试与用量统计
- 交易列表重排:AI 查询区、已激活筛选标签、备注与查询历史缓存
- 账单导入默认分类下拉补充分类图标
- 顶部用户入口改为下拉菜单(AI 设置 / 退出登录)
This commit is contained in:
2026-09-17 18:26:03 +08:00
parent 10fa1b5e96
commit 49c70a732d
38 changed files with 3951 additions and 27 deletions
@@ -0,0 +1,140 @@
package cn.rosercode.finance.client;
import cn.rosercode.finance.common.BusinessException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* DeepSeek 客户端,封装 Chat Completions 接口的 HTTP 调用与响应解析。
*
* @date 2026-09-17 17:52:17
*/
@Component
public class DeepSeekClient {
/** 单次请求的读取超时时间 */
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(60);
/** 连接超时时间 */
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
/** 错误信息中保留的响应体最大长度 */
private static final int ERROR_BODY_LIMIT = 300;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
/**
* 构造方法,初始化 JSON 映射器与 HTTP 客户端。
*
* @param objectMapper Jackson 对象映射器
*/
public DeepSeekClient(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.build();
}
/**
* 调用 Chat Completions 接口,返回模型输出的文本内容。
*
* @param baseUrl API 基础地址
* @param apiKey API Key
* @param model 模型名称
* @param systemPrompt 系统提示词
* @param userPrompt 用户提示词
* @return 模型输出内容与 token 用量
*/
public DeepSeekResponse chat(String baseUrl, String apiKey, String model, String systemPrompt, String userPrompt) {
// 兼容以斜杠结尾的 baseUrl
String normalized = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
String url = normalized + "/chat/completions";
// 构造请求体,强制返回 JSON 对象
Map<String, Object> body = new LinkedHashMap<>();
body.put("model", model);
body.put("temperature", 0.2);
body.put("stream", false);
body.put("response_format", Map.of("type", "json_object"));
body.put("messages", List.of(
Map.of("role", "system", "content", systemPrompt),
Map.of("role", "user", "content", userPrompt)
));
String requestBody;
try {
requestBody = objectMapper.writeValueAsString(body);
} catch (JsonProcessingException e) {
throw new BusinessException("构建 AI 请求失败: " + e.getMessage());
}
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(REQUEST_TIMEOUT)
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestBody, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response;
try {
response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
} catch (IOException e) {
throw new BusinessException("调用 DeepSeek 失败: " + e.getMessage());
} catch (InterruptedException e) {
// 恢复中断标记,避免吞掉中断信号
Thread.currentThread().interrupt();
throw new BusinessException("调用 DeepSeek 被中断");
}
if (response.statusCode() != 200) {
throw new BusinessException("DeepSeek 返回异常(" + response.statusCode() + "): " + truncate(response.body()));
}
// 解析 choices[0].message.content 与 usage 用量
try {
JsonNode root = objectMapper.readTree(response.body());
JsonNode content = root.path("choices").path(0).path("message").path("content");
if (content.isMissingNode() || content.isNull() || content.asText().isBlank()) {
throw new BusinessException("DeepSeek 返回内容为空");
}
JsonNode usage = root.path("usage");
DeepSeekResponse result = new DeepSeekResponse();
result.setContent(content.asText());
result.setPromptTokens(usage.path("prompt_tokens").asInt(0));
result.setCompletionTokens(usage.path("completion_tokens").asInt(0));
result.setTotalTokens(usage.path("total_tokens").asInt(0));
// DeepSeek 用 prompt_cache_hit_tokens 表示缓存命中的输入 token
result.setCachedTokens(usage.path("prompt_cache_hit_tokens").asInt(0));
return result;
} catch (JsonProcessingException e) {
throw new BusinessException("解析 DeepSeek 响应失败: " + e.getMessage());
}
}
/**
* 截断过长的响应体,避免错误信息过长。
*
* @param body 响应体
* @return 截断后的字符串
*/
private String truncate(String body) {
if (body == null) {
return "";
}
return body.length() <= ERROR_BODY_LIMIT ? body : body.substring(0, ERROR_BODY_LIMIT) + "...";
}
}
@@ -0,0 +1,27 @@
package cn.rosercode.finance.client;
import lombok.Data;
/**
* DeepSeek 调用结果,包含模型输出内容与 token 用量。
*
* @date 2026-09-17 18:02:08
*/
@Data
public class DeepSeekResponse {
/** 模型输出的文本内容 */
private String content;
/** 输入 token 数 */
private int promptTokens;
/** 输出 token 数 */
private int completionTokens;
/** 总 token 数 */
private int totalTokens;
/** 缓存命中的输入 token 数 */
private int cachedTokens;
}
@@ -0,0 +1,177 @@
package cn.rosercode.finance.controller;
import cn.rosercode.finance.common.Result;
import cn.rosercode.finance.dto.AiApplyRequest;
import cn.rosercode.finance.dto.AiConfigRequest;
import cn.rosercode.finance.dto.AiQueryRequest;
import cn.rosercode.finance.dto.AiRecordRequest;
import cn.rosercode.finance.dto.AiSuggestRequest;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiQueryService;
import cn.rosercode.finance.service.AiRecordService;
import cn.rosercode.finance.service.AiSuggestionService;
import cn.rosercode.finance.service.AiUsageService;
import cn.rosercode.finance.vo.AiConfigVO;
import cn.rosercode.finance.vo.AiQueryResultVO;
import cn.rosercode.finance.vo.AiRecordResultVO;
import cn.rosercode.finance.vo.AiSuggestionVO;
import cn.rosercode.finance.vo.AiUsageResultVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* AI 控制器
*
* <p>提供 AI 配置的读写、连通性测试以及交易记录分类与标签智能建议接口</p>
*
* @date 2026-09-17 17:52:40
*/
@RestController
@RequestMapping("/api/ai")
@Tag(name = "AI 智能建议", description = "DeepSeek 配置与交易分类标签建议相关接口")
public class AiController {
private final AiConfigService aiConfigService;
private final AiSuggestionService aiSuggestionService;
private final AiUsageService aiUsageService;
private final AiQueryService aiQueryService;
private final AiRecordService aiRecordService;
public AiController(AiConfigService aiConfigService, AiSuggestionService aiSuggestionService,
AiUsageService aiUsageService, AiQueryService aiQueryService,
AiRecordService aiRecordService) {
this.aiConfigService = aiConfigService;
this.aiSuggestionService = aiSuggestionService;
this.aiUsageService = aiUsageService;
this.aiQueryService = aiQueryService;
this.aiRecordService = aiRecordService;
}
/**
* 获取当前用户的 AI 配置
*
* @param request HTTP请求,从中获取用户ID
* @return AI 配置(API Key 脱敏)
*/
@GetMapping("/config")
@Operation(summary = "获取 AI 配置", description = "获取当前用户的 AI 智能建议配置,API Key 已脱敏")
public Result<AiConfigVO> getConfig(HttpServletRequest request) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(aiConfigService.getConfig(userId));
}
/**
* 保存当前用户的 AI 配置
*
* @param request HTTP请求,从中获取用户ID
* @param configRequest 配置保存请求
* @return 操作结果
*/
@PutMapping("/config")
@Operation(summary = "保存 AI 配置", description = "更新当前用户的 AI 配置,API Key 留空表示不修改")
public Result<Void> saveConfig(HttpServletRequest request, @RequestBody AiConfigRequest configRequest) {
Long userId = (Long) request.getAttribute("userId");
aiConfigService.saveConfig(userId, configRequest);
return Result.success();
}
/**
* 测试 DeepSeek 连通性
*
* @param request HTTP请求,从中获取用户ID
* @return 操作结果
*/
@PostMapping("/config/test")
@Operation(summary = "测试连接", description = "使用当前配置向 DeepSeek 发送一条测试请求")
public Result<Void> testConfig(HttpServletRequest request) {
Long userId = (Long) request.getAttribute("userId");
aiConfigService.testConnection(userId);
return Result.success();
}
/**
* 生成分类与标签建议
*
* @param request HTTP请求,从中获取用户ID
* @param suggestRequest 建议请求,携带记录ID列表
* @return 逐条建议列表
*/
@PostMapping("/suggest")
@Operation(summary = "生成 AI 建议", description = "根据交易记录内容生成分类与标签建议")
public Result<List<AiSuggestionVO>> suggest(HttpServletRequest request,
@RequestBody @Valid AiSuggestRequest suggestRequest) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(aiSuggestionService.suggest(userId, suggestRequest.getIds()));
}
/**
* 一键应用 AI 建议
*
* @param request HTTP请求,从中获取用户ID
* @param applyRequest 应用请求,携带逐条建议
* @return 实际发生变更的记录数
*/
@PostMapping("/apply")
@Operation(summary = "应用 AI 建议", description = "覆盖分类并追加标签,一键写入建议")
public Result<Integer> apply(HttpServletRequest request,
@RequestBody @Valid AiApplyRequest applyRequest) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(aiSuggestionService.apply(userId, applyRequest));
}
/**
* 获取 AI 使用情况
*
* @param request HTTP请求,从中获取用户ID
* @param limit 最近明细条数,默认 20
* @return 累计汇总与最近调用明细
*/
@GetMapping("/usage")
@Operation(summary = "获取 AI 使用情况", description = "获取当前用户 AI 调用的累计汇总与最近调用明细")
public Result<AiUsageResultVO> usage(HttpServletRequest request,
@RequestParam(defaultValue = "20") int limit) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(aiUsageService.getUsage(userId, limit));
}
/**
* 解析自然语言查询为筛选条件
*
* @param request HTTP请求,从中获取用户ID
* @param queryRequest 查询请求,携带自然语言语句
* @return 结构化筛选条件
*/
@PostMapping("/query")
@Operation(summary = "自然语言查询解析", description = "将自然语言查询解析为交易筛选条件,供用户确认后应用")
public Result<AiQueryResultVO> query(HttpServletRequest request,
@RequestBody @Valid AiQueryRequest queryRequest) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(aiQueryService.parse(userId, queryRequest.getQuery()));
}
/**
* 解析自然语言记账内容为交易记录
*
* @param request HTTP请求,从中获取用户ID
* @param recordRequest 记账请求,携带自然语言语句
* @return 结构化交易记录
*/
@PostMapping("/record")
@Operation(summary = "自然语言记账解析", description = "将自然语言记账内容解析为交易记录,供用户确认后保存")
public Result<AiRecordResultVO> record(HttpServletRequest request,
@RequestBody @Valid AiRecordRequest recordRequest) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(aiRecordService.parse(userId, recordRequest.getText()));
}
}
@@ -0,0 +1,39 @@
package cn.rosercode.finance.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
/**
* AI 建议应用请求DTO,携带逐条记录的建议分类与标签,用于一键写入。
*
* @date 2026-09-17 17:52:00
*/
@Data
public class AiApplyRequest {
/** 待应用的建议列表 */
@NotEmpty(message = "请至少提供一条建议")
@Valid
private List<Item> suggestions;
/**
* 单条建议项,描述某条交易记录的目标分类与待追加标签。
*/
@Data
public static class Item {
/** 交易记录ID */
@NotNull(message = "记录ID不能为空")
private Long transactionId;
/** 建议分类ID,为 null 表示不修改分类 */
private Long categoryId;
/** 建议追加的标签ID列表,可为空表示不追加标签 */
private List<Long> tagIds;
}
}
@@ -0,0 +1,24 @@
package cn.rosercode.finance.dto;
import lombok.Data;
/**
* AI 配置保存请求DTO,用于更新用户的 AI 智能建议配置。
*
* @date 2026-09-17 17:51:58
*/
@Data
public class AiConfigRequest {
/** 是否启用 AI 智能建议 */
private Boolean enabled;
/** DeepSeek API Key,为空或 null 时保留原值(不修改) */
private String apiKey;
/** API 基础地址,为空时使用默认值 */
private String baseUrl;
/** 模型名称,为空时使用默认值 */
private String model;
}
@@ -0,0 +1,17 @@
package cn.rosercode.finance.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* AI 自然语言查询请求DTO,携带用户输入的查询语句。
*
* @date 2026-09-17 18:13:06
*/
@Data
public class AiQueryRequest {
/** 用户输入的自然语言查询语句 */
@NotBlank(message = "请输入查询内容")
private String query;
}
@@ -0,0 +1,17 @@
package cn.rosercode.finance.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* AI 自然语言记账请求DTO,携带用户输入的记账语句。
*
* @date 2026-09-17 18:23:10
*/
@Data
public class AiRecordRequest {
/** 用户输入的自然语言记账内容 */
@NotBlank(message = "请输入记账内容")
private String text;
}
@@ -0,0 +1,19 @@
package cn.rosercode.finance.dto;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.List;
/**
* AI 建议请求DTO,携带需要分析分类与标签的交易记录ID列表。
*
* @date 2026-09-17 17:51:59
*/
@Data
public class AiSuggestRequest {
/** 待分析建议的交易记录ID列表 */
@NotEmpty(message = "请至少选择一条记录")
private List<Long> ids;
}
@@ -0,0 +1,43 @@
package cn.rosercode.finance.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* AI 配置实体类,对应数据库表 t_ai_config,用于记录用户的 AI 智能建议配置。
*
* @date 2026-09-17 17:51:57
*/
@Data
@TableName("t_ai_config")
public class AiConfig {
/** 配置ID,主键自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 所属用户ID */
private Long userId;
/** 是否启用 AI 智能建议 */
private Boolean enabled;
/** DeepSeek API Key(明文存储) */
private String apiKey;
/** API 基础地址 */
private String baseUrl;
/** 使用的模型名称 */
private String model;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
}
@@ -0,0 +1,58 @@
package cn.rosercode.finance.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* AI 使用记录实体类,对应数据库表 t_ai_usage,记录每次调用 DeepSeek 的用量与结果。
*
* @date 2026-09-17 18:02:05
*/
@Data
@TableName("t_ai_usage")
public class AiUsage {
/** 记录ID,主键自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 所属用户ID */
private Long userId;
/** 调用类型:SUGGEST-生成建议,TEST-测试连接 */
private String type;
/** 模型名称 */
private String model;
/** 本次分析的记录数 */
private Integer transactionCount;
/** 输入 token 数 */
private Integer promptTokens;
/** 输出 token 数 */
private Integer completionTokens;
/** 总 token 数 */
private Integer totalTokens;
/** 缓存命中的输入 token 数 */
private Integer cachedTokens;
/** 是否成功 */
private Boolean success;
/** 失败原因 */
private String errorMessage;
/** 调用耗时(毫秒) */
private Long durationMs;
/** 创建时间 */
private LocalDateTime createdAt;
}
@@ -0,0 +1,21 @@
package cn.rosercode.finance.enums;
/**
* AI 调用类型枚举,标识使用记录的来源。
*
* @date 2026-09-17 18:02:04
*/
public enum AiCallType {
/** 生成分类与标签建议 */
SUGGEST,
/** 测试连接 */
TEST,
/** 自然语言查询解析 */
QUERY,
/** 自然语言记账解析 */
RECORD
}
@@ -0,0 +1,14 @@
package cn.rosercode.finance.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import cn.rosercode.finance.entity.AiConfig;
import org.apache.ibatis.annotations.Mapper;
/**
* AI 配置Mapper接口,继承MyBatis-Plus的BaseMapper,提供AI配置表的基础CRUD操作。
*
* @date 2026-09-17 17:51:58
*/
@Mapper
public interface AiConfigMapper extends BaseMapper<AiConfig> {
}
@@ -0,0 +1,33 @@
package cn.rosercode.finance.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import cn.rosercode.finance.entity.AiUsage;
import cn.rosercode.finance.vo.AiUsageSummaryVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/**
* AI 使用记录Mapper接口,继承MyBatis-Plus的BaseMapper,并提供汇总统计查询。
*
* @date 2026-09-17 18:02:08
*/
@Mapper
public interface AiUsageMapper extends BaseMapper<AiUsage> {
/**
* 统计指定用户的累计使用情况(调用次数与 token 用量)。
*
* @param userId 用户ID
* @return 汇总结果,无记录时各项为 0
*/
@Select("SELECT COUNT(*) AS totalCalls, "
+ "COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) AS successCalls, "
+ "COALESCE(SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END), 0) AS failedCalls, "
+ "COALESCE(SUM(prompt_tokens), 0) AS promptTokens, "
+ "COALESCE(SUM(completion_tokens), 0) AS completionTokens, "
+ "COALESCE(SUM(total_tokens), 0) AS totalTokens, "
+ "COALESCE(SUM(cached_tokens), 0) AS cachedTokens "
+ "FROM t_ai_usage WHERE user_id = #{userId}")
AiUsageSummaryVO selectSummary(@Param("userId") Long userId);
}
@@ -0,0 +1,44 @@
package cn.rosercode.finance.service;
import cn.rosercode.finance.dto.AiConfigRequest;
import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.vo.AiConfigVO;
/**
* AI 配置服务接口,提供用户级 AI 配置的读取、保存与连通性测试。
*
* @date 2026-09-17 17:52:14
*/
public interface AiConfigService {
/**
* 获取当前用户的 AI 配置(API Key 脱敏),未配置时返回默认值。
*
* @param userId 用户ID
* @return 脱敏后的配置视图对象
*/
AiConfigVO getConfig(Long userId);
/**
* 保存当前用户的 AI 配置。
*
* @param userId 用户ID
* @param request 配置保存请求
*/
void saveConfig(Long userId, AiConfigRequest request);
/**
* 获取原始配置实体,供内部调用(含明文 API Key)。
*
* @param userId 用户ID
* @return 配置实体,不存在时返回 null
*/
AiConfig getRawConfig(Long userId);
/**
* 测试当前用户配置的 DeepSeek 连通性。
*
* @param userId 用户ID
*/
void testConnection(Long userId);
}
@@ -0,0 +1,20 @@
package cn.rosercode.finance.service;
import cn.rosercode.finance.vo.AiQueryResultVO;
/**
* AI 自然语言查询服务接口,将自然语言解析为交易筛选条件。
*
* @date 2026-09-17 18:13:18
*/
public interface AiQueryService {
/**
* 解析自然语言查询。
*
* @param userId 用户ID
* @param query 查询语句
* @return 结构化筛选条件
*/
AiQueryResultVO parse(Long userId, String query);
}
@@ -0,0 +1,20 @@
package cn.rosercode.finance.service;
import cn.rosercode.finance.vo.AiRecordResultVO;
/**
* AI 自然语言记账服务接口,将自然语言解析为一条交易记录。
*
* @date 2026-09-17 18:23:17
*/
public interface AiRecordService {
/**
* 解析自然语言记账内容。
*
* @param userId 用户ID
* @param text 记账语句
* @return 结构化交易记录
*/
AiRecordResultVO parse(Long userId, String text);
}
@@ -0,0 +1,32 @@
package cn.rosercode.finance.service;
import cn.rosercode.finance.dto.AiApplyRequest;
import cn.rosercode.finance.vo.AiSuggestionVO;
import java.util.List;
/**
* AI 建议服务接口,提供交易记录的分类与标签智能建议及应用能力。
*
* @date 2026-09-17 17:52:23
*/
public interface AiSuggestionService {
/**
* 根据交易记录内容调用 DeepSeek 生成分类与标签建议。
*
* @param userId 用户ID
* @param ids 待分析的建议记录ID列表
* @return 逐条建议列表
*/
List<AiSuggestionVO> suggest(Long userId, List<Long> ids);
/**
* 一键应用建议:覆盖分类、追加标签。
*
* @param userId 用户ID
* @param request 建议应用请求
* @return 实际发生变更的记录数
*/
int apply(Long userId, AiApplyRequest request);
}
@@ -0,0 +1,48 @@
package cn.rosercode.finance.service;
import cn.rosercode.finance.client.DeepSeekResponse;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.vo.AiUsageResultVO;
/**
* AI 使用记录服务接口,提供调用用量的记录与查询能力。
*
* @date 2026-09-17 18:02:15
*/
public interface AiUsageService {
/**
* 记录一次成功的 AI 调用。
*
* @param userId 用户ID
* @param type 调用类型
* @param model 模型名称
* @param transactionCount 本次分析的记录数
* @param response 模型返回结果(含 token 用量)
* @param durationMs 调用耗时(毫秒)
*/
void recordSuccess(Long userId, AiCallType type, String model, int transactionCount,
DeepSeekResponse response, long durationMs);
/**
* 记录一次失败的 AI 调用。
*
* @param userId 用户ID
* @param type 调用类型
* @param model 模型名称
* @param transactionCount 本次分析的记录数
* @param durationMs 调用耗时(毫秒)
* @param errorMessage 失败原因
*/
void recordFailure(Long userId, AiCallType type, String model, int transactionCount,
long durationMs, String errorMessage);
/**
* 查询指定用户的使用情况(累计汇总 + 最近明细)。
*
* @param userId 用户ID
* @param recentLimit 最近明细条数
* @return 使用情况视图对象
*/
AiUsageResultVO getUsage(Long userId, int recentLimit);
}
@@ -0,0 +1,158 @@
package cn.rosercode.finance.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import cn.rosercode.finance.client.DeepSeekClient;
import cn.rosercode.finance.client.DeepSeekResponse;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.dto.AiConfigRequest;
import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.mapper.AiConfigMapper;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiUsageService;
import cn.rosercode.finance.vo.AiConfigVO;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* AI 配置服务实现类,处理用户级 AI 配置的读写与连通性测试。
*
* @date 2026-09-17 17:52:22
*/
@Service
public class AiConfigServiceImpl implements AiConfigService {
/** 默认 API 基础地址 */
private static final String DEFAULT_BASE_URL = "https://api.deepseek.com";
/** 默认模型名称 */
private static final String DEFAULT_MODEL = "deepseek-chat";
private final AiConfigMapper aiConfigMapper;
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
public AiConfigServiceImpl(AiConfigMapper aiConfigMapper, DeepSeekClient deepSeekClient,
AiUsageService aiUsageService) {
this.aiConfigMapper = aiConfigMapper;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
}
/**
* {@inheritDoc}
*
* 未配置时返回关闭状态与默认地址、模型。
*/
@Override
public AiConfigVO getConfig(Long userId) {
AiConfig config = getRawConfig(userId);
AiConfigVO vo = new AiConfigVO();
if (config == null) {
vo.setEnabled(false);
vo.setHasApiKey(false);
vo.setBaseUrl(DEFAULT_BASE_URL);
vo.setModel(DEFAULT_MODEL);
return vo;
}
vo.setEnabled(Boolean.TRUE.equals(config.getEnabled()));
vo.setHasApiKey(config.getApiKey() != null && !config.getApiKey().isBlank());
vo.setApiKey(maskApiKey(config.getApiKey()));
vo.setBaseUrl(config.getBaseUrl());
vo.setModel(config.getModel());
return vo;
}
/**
* {@inheritDoc}
*
* API Key 为空时保留原值,地址与模型为空时回退默认值。
*/
@Override
@Transactional
public void saveConfig(Long userId, AiConfigRequest request) {
AiConfig config = getRawConfig(userId);
if (config == null) {
config = new AiConfig();
config.setUserId(userId);
}
config.setEnabled(request.getEnabled() != null ? request.getEnabled() : Boolean.FALSE);
// API Key 为空表示不修改,保留原值
if (request.getApiKey() != null && !request.getApiKey().isBlank()) {
config.setApiKey(request.getApiKey().trim());
}
config.setBaseUrl(isBlank(request.getBaseUrl()) ? DEFAULT_BASE_URL : request.getBaseUrl().trim());
config.setModel(isBlank(request.getModel()) ? DEFAULT_MODEL : request.getModel().trim());
if (config.getId() == null) {
aiConfigMapper.insert(config);
} else {
aiConfigMapper.updateById(config);
}
}
/**
* {@inheritDoc}
*/
@Override
public AiConfig getRawConfig(Long userId) {
return aiConfigMapper.selectOne(new LambdaQueryWrapper<AiConfig>()
.eq(AiConfig::getUserId, userId));
}
/**
* {@inheritDoc}
*
* 发送一条极简请求验证 Key 与地址是否可用。
*/
@Override
public void testConnection(Long userId) {
AiConfig config = getRawConfig(userId);
if (config == null || isBlank(config.getApiKey())) {
throw new BusinessException("请先配置 DeepSeek API Key");
}
long start = System.currentTimeMillis();
DeepSeekResponse response;
try {
response = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
"你是一个测试助手,请只输出 JSON。",
"请返回 {\"ok\":true}");
} catch (RuntimeException e) {
// 失败也记录一次使用,便于排查问题
aiUsageService.recordFailure(userId, AiCallType.TEST, config.getModel(), 0,
System.currentTimeMillis() - start, e.getMessage());
throw e;
}
aiUsageService.recordSuccess(userId, AiCallType.TEST, config.getModel(), 0,
response, System.currentTimeMillis() - start);
if (isBlank(response.getContent())) {
throw new BusinessException("连接成功但未返回内容");
}
}
/**
* 对 API Key 进行脱敏,仅保留前缀与后缀。
*
* @param apiKey 原始 API Key
* @return 脱敏后的字符串,未配置时返回 null
*/
private String maskApiKey(String apiKey) {
if (isBlank(apiKey)) {
return null;
}
if (apiKey.length() <= 7) {
return "****";
}
return apiKey.substring(0, 3) + "****" + apiKey.substring(apiKey.length() - 4);
}
/**
* 判断字符串是否为空白。
*
* @param value 待判断字符串
* @return 为 null 或全空白时返回 true
*/
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}
@@ -0,0 +1,436 @@
package cn.rosercode.finance.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import cn.rosercode.finance.client.DeepSeekClient;
import cn.rosercode.finance.client.DeepSeekResponse;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TagMapper;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiQueryService;
import cn.rosercode.finance.service.AiUsageService;
import cn.rosercode.finance.vo.AiQueryResultVO;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* AI 自然语言查询服务实现类,负责提示词组装、日期推算与名称映射。
*
* @date 2026-09-17 18:13:28
*/
@Service
public class AiQueryServiceImpl implements AiQueryService {
/** 日期格式 */
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
/** 日期时间格式,与前端筛选参数保持一致 */
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
/** 合法的收支类型 */
private static final List<String> VALID_TYPES = List.of("INCOME", "EXPENSE");
/** 合法的支付方式 */
private static final List<String> VALID_PAYMENTS = List.of("WECHAT", "ALIPAY", "CASH", "BANK");
private final AiConfigService aiConfigService;
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
private final ObjectMapper objectMapper;
public AiQueryServiceImpl(AiConfigService aiConfigService,
CategoryMapper categoryMapper,
TagMapper tagMapper,
DeepSeekClient deepSeekClient,
AiUsageService aiUsageService,
ObjectMapper objectMapper) {
this.aiConfigService = aiConfigService;
this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
this.objectMapper = objectMapper;
}
/**
* {@inheritDoc}
*
* 将用户分类、标签与当前日期注入提示词,解析模型输出并映射为筛选条件。
*/
@Override
public AiQueryResultVO parse(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");
}
List<Category> categories = categoryMapper.selectList(new LambdaQueryWrapper<Category>()
.eq(Category::getUserId, userId));
List<Tag> tags = tagMapper.selectList(new LambdaQueryWrapper<Tag>()
.eq(Tag::getUserId, userId));
String systemPrompt = buildSystemPrompt(categories, tags);
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.QUERY, config.getModel(), 0,
System.currentTimeMillis() - start, e.getMessage());
throw e;
}
aiUsageService.recordSuccess(userId, AiCallType.QUERY, config.getModel(), 0,
aiResponse, System.currentTimeMillis() - start);
return parseResult(aiResponse.getContent(), categories, tags);
}
/**
* 构建系统提示词,注入可用分类、标签与当前日期。
*
* @param categories 用户分类列表
* @param tags 用户标签列表
* @return 系统提示词
*/
private String buildSystemPrompt(List<Category> categories, List<Tag> tags) {
String categoryNames = categories.stream().map(Category::getName).collect(Collectors.joining("、"));
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
String today = LocalDate.now().format(DATE_FMT);
return "你是一个记账系统的查询解析助手。请把用户的自然语言查询解析为结构化筛选条件,只输出 JSON,不要输出任何多余内容。\n"
+ "可用的收支类型:INCOME(收入)、EXPENSE(支出)。\n"
+ "可用的支付方式:WECHAT(微信)、ALIPAY(支付宝)、CASH(现金)、BANK(银行卡)。\n"
+ "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
+ "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
+ "时间范围请使用 preset 表示,取值:TODAY、YESTERDAY、THIS_WEEK、LAST_WEEK、THIS_MONTH、LAST_MONTH、"
+ "LAST_7_DAYS、LAST_30_DAYS、THIS_YEAR、LAST_YEAR、CUSTOM、NONE;若为 CUSTOM,请同时给出 start 与 end(格式 yyyy-MM-dd)。\n"
+ "未提及的字段请留空(数组用 [],其余用 null)。分类和标签只能使用上面给出的名称。\n"
+ "今天是 " + today + "。\n"
+ "输出格式:{\"summary\":\"简要说明识别到的查询意图\",\"types\":[],\"categoryNames\":[],\"tagNames\":[],"
+ "\"paymentMethods\":[],\"dateRange\":{\"preset\":\"NONE\",\"start\":null,\"end\":null},\"minAmount\":null,"
+ "\"maxAmount\":null,\"remark\":null,\"includeInStats\":null,\"uncategorized\":false,\"unmatched\":[]}";
}
/**
* 解析模型返回的 JSON 并映射为筛选条件。
*
* @param content 模型输出内容
* @param categories 用户分类列表
* @param tags 用户标签列表
* @return 结构化筛选条件
*/
private AiQueryResultVO parseResult(String content, List<Category> categories, List<Tag> tags) {
JsonNode root;
try {
root = objectMapper.readTree(stripCodeFence(content));
} catch (JsonProcessingException e) {
throw new BusinessException("AI 返回内容不是有效 JSON,请重试");
}
AiQueryResultVO vo = new AiQueryResultVO();
vo.setSummary(root.path("summary").asText(""));
vo.setTypes(filterValid(root.path("types"), VALID_TYPES));
vo.setPaymentMethods(filterValid(root.path("paymentMethods"), VALID_PAYMENTS));
vo.setUncategorized(root.path("uncategorized").asBoolean(false));
JsonNode statsNode = root.path("includeInStats");
if (!statsNode.isMissingNode() && !statsNode.isNull()) {
vo.setIncludeInStats(statsNode.asBoolean());
}
vo.setMinAmount(decimalOrNull(root.path("minAmount")));
vo.setMaxAmount(decimalOrNull(root.path("maxAmount")));
String remark = root.path("remark").asText(null);
vo.setRemark(isBlank(remark) ? null : remark.trim());
// 分类名称映射为ID
List<Long> categoryIds = new ArrayList<>();
List<String> categoryNames = new ArrayList<>();
JsonNode categoryNode = root.path("categoryNames");
if (categoryNode.isArray()) {
for (JsonNode node : categoryNode) {
Category category = matchCategory(categories, node.asText(""));
if (category != null && !categoryIds.contains(category.getId())) {
categoryIds.add(category.getId());
categoryNames.add(category.getName());
}
}
}
vo.setCategoryIds(categoryIds);
vo.setCategoryNames(categoryNames);
// 标签名称映射为ID
List<Long> tagIds = new ArrayList<>();
List<String> tagNames = new ArrayList<>();
JsonNode tagNode = root.path("tagNames");
if (tagNode.isArray()) {
for (JsonNode node : tagNode) {
Tag tag = matchTag(tags, node.asText(""));
if (tag != null && !tagIds.contains(tag.getId())) {
tagIds.add(tag.getId());
tagNames.add(tag.getName());
}
}
}
vo.setTagIds(tagIds);
vo.setTagNames(tagNames);
// 时间范围推算
String[] range = resolveDateRange(root.path("dateRange"));
if (range != null) {
vo.setStartTime(range[0]);
vo.setEndTime(range[1]);
}
// 未识别关键词
List<String> unmatched = new ArrayList<>();
JsonNode unmatchedNode = root.path("unmatched");
if (unmatchedNode.isArray()) {
for (JsonNode node : unmatchedNode) {
String text = node.asText("");
if (!isBlank(text)) {
unmatched.add(text);
}
}
}
vo.setUnmatched(unmatched);
return vo;
}
/**
* 将模型给出的日期 preset 推算为绝对时间范围。
*
* @param dateRange 模型输出的 dateRange 节点
* @return 起止时间数组,无法识别时返回 null
*/
private String[] resolveDateRange(JsonNode dateRange) {
if (dateRange == null || dateRange.isMissingNode() || dateRange.isNull()) {
return null;
}
String preset = dateRange.path("preset").asText("NONE");
if (isBlank(preset) || "NONE".equalsIgnoreCase(preset)) {
return null;
}
LocalDate today = LocalDate.now();
LocalDate start;
LocalDate end;
switch (preset.toUpperCase()) {
case "TODAY":
start = today;
end = today;
break;
case "YESTERDAY":
start = today.minusDays(1);
end = start;
break;
case "THIS_WEEK":
start = today.with(DayOfWeek.MONDAY);
end = today;
break;
case "LAST_WEEK":
start = today.with(DayOfWeek.MONDAY).minusWeeks(1);
end = start.plusDays(6);
break;
case "THIS_MONTH":
start = today.withDayOfMonth(1);
end = today;
break;
case "LAST_MONTH":
start = today.withDayOfMonth(1).minusMonths(1);
end = start.withDayOfMonth(start.lengthOfMonth());
break;
case "LAST_7_DAYS":
start = today.minusDays(6);
end = today;
break;
case "LAST_30_DAYS":
start = today.minusDays(29);
end = today;
break;
case "THIS_YEAR":
start = today.withDayOfYear(1);
end = today;
break;
case "LAST_YEAR":
start = today.withDayOfYear(1).minusYears(1);
end = start.withDayOfYear(start.lengthOfYear());
break;
case "CUSTOM": {
LocalDate customStart = parseDate(dateRange.path("start").asText(null));
LocalDate customEnd = parseDate(dateRange.path("end").asText(null));
if (customStart == null && customEnd == null) {
return null;
}
start = customStart != null ? customStart : customEnd;
end = customEnd != null ? customEnd : customStart;
break;
}
default:
return null;
}
return new String[]{
start.atStartOfDay().format(DATETIME_FMT),
end.atTime(23, 59, 59).format(DATETIME_FMT)
};
}
/**
* 解析日期文本,失败返回 null。
*
* @param text 日期文本
* @return 日期对象
*/
private LocalDate parseDate(String text) {
if (isBlank(text)) {
return null;
}
try {
return LocalDate.parse(text.trim().substring(0, 10), DATE_FMT);
} catch (Exception e) {
return null;
}
}
/**
* 按名称匹配分类:优先精确匹配,其次包含匹配。
*
* @param categories 分类列表
* @param name 目标名称
* @return 匹配到的分类,未命中返回 null
*/
private Category matchCategory(List<Category> categories, String name) {
if (isBlank(name)) {
return null;
}
String target = name.trim();
for (Category category : categories) {
if (category.getName().equalsIgnoreCase(target)) {
return category;
}
}
for (Category category : categories) {
if (category.getName().contains(target) || target.contains(category.getName())) {
return category;
}
}
return null;
}
/**
* 按名称匹配标签:优先精确匹配,其次包含匹配。
*
* @param tags 标签列表
* @param name 目标名称
* @return 匹配到的标签,未命中返回 null
*/
private Tag matchTag(List<Tag> tags, String name) {
if (isBlank(name)) {
return null;
}
String target = name.trim();
for (Tag tag : tags) {
if (tag.getName().equalsIgnoreCase(target)) {
return tag;
}
}
for (Tag tag : tags) {
if (tag.getName().contains(target) || target.contains(tag.getName())) {
return tag;
}
}
return null;
}
/**
* 过滤出合法且去重的枚举值。
*
* @param node 模型输出节点
* @param valid 合法值列表
* @return 过滤后的列表
*/
private List<String> filterValid(JsonNode node, List<String> valid) {
List<String> result = new ArrayList<>();
if (node != null && node.isArray()) {
for (JsonNode item : node) {
String value = item.asText("").toUpperCase();
if (valid.contains(value) && !result.contains(value)) {
result.add(value);
}
}
}
return result;
}
/**
* 将节点转换为金额,非数字时返回 null。
*
* @param node 模型输出节点
* @return 金额
*/
private BigDecimal decimalOrNull(JsonNode node) {
if (node == null || node.isMissingNode() || node.isNull()) {
return null;
}
try {
return node.decimalValue();
} catch (Exception e) {
return null;
}
}
/**
* 去除模型可能附加的 Markdown 代码块围栏。
*
* @param content 原始内容
* @return 清理后的内容
*/
private String stripCodeFence(String content) {
if (content == null) {
return "";
}
String cleaned = content.trim();
if (cleaned.startsWith("```")) {
int firstLineEnd = cleaned.indexOf('\n');
if (firstLineEnd > 0) {
cleaned = cleaned.substring(firstLineEnd + 1);
}
if (cleaned.endsWith("```")) {
cleaned = cleaned.substring(0, cleaned.length() - 3);
}
}
return cleaned.trim();
}
/**
* 判断字符串是否为空白。
*
* @param value 待判断字符串
* @return 为 null 或全空白时返回 true
*/
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}
@@ -0,0 +1,351 @@
package cn.rosercode.finance.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import cn.rosercode.finance.client.DeepSeekClient;
import cn.rosercode.finance.client.DeepSeekResponse;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TagMapper;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiRecordService;
import cn.rosercode.finance.service.AiUsageService;
import cn.rosercode.finance.vo.AiRecordResultVO;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* AI 自然语言记账服务实现类,负责提示词组装、时间推算与名称映射。
*
* @date 2026-09-17 18:23:25
*/
@Service
public class AiRecordServiceImpl implements AiRecordService {
/** 日期时间格式,与前端表单保持一致 */
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
/** 合法的收支类型 */
private static final List<String> VALID_TYPES = List.of("INCOME", "EXPENSE");
/** 合法的支付方式 */
private static final List<String> VALID_PAYMENTS = List.of("WECHAT", "ALIPAY", "CASH", "BANK");
private final AiConfigService aiConfigService;
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
private final ObjectMapper objectMapper;
public AiRecordServiceImpl(AiConfigService aiConfigService,
CategoryMapper categoryMapper,
TagMapper tagMapper,
DeepSeekClient deepSeekClient,
AiUsageService aiUsageService,
ObjectMapper objectMapper) {
this.aiConfigService = aiConfigService;
this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
this.objectMapper = objectMapper;
}
/**
* {@inheritDoc}
*
* 将用户分类、标签与当前日期时间注入提示词,解析模型输出并映射为交易记录字段。
*/
@Override
public AiRecordResultVO parse(Long userId, String text) {
if (text == null || text.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");
}
List<Category> categories = categoryMapper.selectList(new LambdaQueryWrapper<Category>()
.eq(Category::getUserId, userId));
List<Tag> tags = tagMapper.selectList(new LambdaQueryWrapper<Tag>()
.eq(Tag::getUserId, userId));
String systemPrompt = buildSystemPrompt(categories, tags);
long start = System.currentTimeMillis();
DeepSeekResponse aiResponse;
try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
systemPrompt, text);
} catch (RuntimeException e) {
// 失败也记录一次使用,便于排查问题
aiUsageService.recordFailure(userId, AiCallType.RECORD, config.getModel(), 0,
System.currentTimeMillis() - start, e.getMessage());
throw e;
}
aiUsageService.recordSuccess(userId, AiCallType.RECORD, config.getModel(), 0,
aiResponse, System.currentTimeMillis() - start);
return parseResult(aiResponse.getContent(), categories, tags);
}
/**
* 构建系统提示词,注入可用分类、标签与当前日期时间。
*
* @param categories 用户分类列表
* @param tags 用户标签列表
* @return 系统提示词
*/
private String buildSystemPrompt(List<Category> categories, List<Tag> tags) {
String categoryNames = categories.stream().map(Category::getName).collect(Collectors.joining("、"));
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return "你是一个记账助手。请把用户的自然语言记账内容解析为一条结构化交易记录,只输出 JSON,不要输出任何多余内容。\n"
+ "可用的收支类型:INCOME(收入)、EXPENSE(支出)。\n"
+ "可用的支付方式:WECHAT(微信)、ALIPAY(支付宝)、CASH(现金)、BANK(银行卡)。\n"
+ "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
+ "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
+ "规则:1. type 默认为 EXPENSE,除非明显是收入;2. amount 为必填数字;"
+ "3. categoryNames 从可用分类中选择最合适的一个(数组,最多一个),无法判断则为空数组;"
+ "4. tagNames 从可用标签中选择(数组,可为空);5. paymentMethod 从可用支付方式中选择,无法判断为 null;"
+ "6. transactionTime 输出 yyyy-MM-ddTHH:mm:ss,未提及时间用当前时间,相对时间(如昨天、上周五)请换算;"
+ "7. includeInStats 默认 true;8. remark 给出简洁备注。\n"
+ "当前时间:" + now + "。\n"
+ "输出格式:{\"summary\":\"简要说明识别到的记账内容\",\"type\":\"EXPENSE\",\"amount\":35.5,"
+ "\"categoryNames\":[\"餐饮\"],\"tagNames\":[],\"paymentMethod\":\"ALIPAY\","
+ "\"transactionTime\":\"" + LocalDateTime.now().format(DATETIME_FMT) + "\",\"remark\":\"午餐\","
+ "\"includeInStats\":true,\"unmatched\":[]}";
}
/**
* 解析模型返回的 JSON 并映射为交易记录字段。
*
* @param content 模型输出内容
* @param categories 用户分类列表
* @param tags 用户标签列表
* @return 结构化交易记录
*/
private AiRecordResultVO parseResult(String content, List<Category> categories, List<Tag> tags) {
JsonNode root;
try {
root = objectMapper.readTree(stripCodeFence(content));
} catch (JsonProcessingException e) {
throw new BusinessException("AI 返回内容不是有效 JSON,请重试");
}
AiRecordResultVO vo = new AiRecordResultVO();
vo.setSummary(root.path("summary").asText(""));
// 类型:仅接受合法值,默认支出
String type = root.path("type").asText("EXPENSE").toUpperCase();
vo.setType(VALID_TYPES.contains(type) ? type : "EXPENSE");
// 金额
vo.setAmount(decimalOrNull(root.path("amount")));
if (vo.getAmount() == null || vo.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new BusinessException("未能识别到有效金额,请补充金额后重试");
}
// 分类:取第一个匹配项
JsonNode categoryNode = root.path("categoryNames");
if (categoryNode.isArray()) {
for (JsonNode node : categoryNode) {
Category category = matchCategory(categories, node.asText(""));
if (category != null) {
vo.setCategoryId(category.getId());
vo.setCategoryName(category.getName());
break;
}
}
}
// 标签
List<Long> tagIds = new ArrayList<>();
List<String> tagNames = new ArrayList<>();
JsonNode tagNode = root.path("tagNames");
if (tagNode.isArray()) {
for (JsonNode node : tagNode) {
Tag tag = matchTag(tags, node.asText(""));
if (tag != null && !tagIds.contains(tag.getId())) {
tagIds.add(tag.getId());
tagNames.add(tag.getName());
}
}
}
vo.setTagIds(tagIds);
vo.setTagNames(tagNames);
// 支付方式
String payment = root.path("paymentMethod").asText("").toUpperCase();
if (VALID_PAYMENTS.contains(payment)) {
vo.setPaymentMethod(payment);
}
// 交易时间
vo.setTransactionTime(resolveTransactionTime(root.path("transactionTime")));
String remark = root.path("remark").asText(null);
vo.setRemark(isBlank(remark) ? null : remark.trim());
JsonNode statsNode = root.path("includeInStats");
vo.setIncludeInStats(statsNode.isMissingNode() || statsNode.isNull() ? Boolean.TRUE : statsNode.asBoolean(true));
// 未识别关键词
List<String> unmatched = new ArrayList<>();
JsonNode unmatchedNode = root.path("unmatched");
if (unmatchedNode.isArray()) {
for (JsonNode node : unmatchedNode) {
String value = node.asText("");
if (!isBlank(value)) {
unmatched.add(value);
}
}
}
vo.setUnmatched(unmatched);
return vo;
}
/**
* 解析模型给出的交易时间,无法解析时回退当前时间。
*
* @param node 模型输出的时间节点
* @return 格式化后的时间字符串
*/
private String resolveTransactionTime(JsonNode node) {
LocalDateTime now = LocalDateTime.now();
String text = node == null || node.isMissingNode() || node.isNull() ? null : node.asText(null);
if (isBlank(text)) {
return now.format(DATETIME_FMT);
}
String value = text.trim().replace(' ', 'T');
try {
// 完整日期时间
return LocalDateTime.parse(value.substring(0, Math.min(value.length(), 19))).format(DATETIME_FMT);
} catch (Exception e) {
// 仅日期:补齐当前时分秒
try {
LocalDate date = LocalDate.parse(value.substring(0, 10));
return date.atTime(now.getHour(), now.getMinute(), now.getSecond()).format(DATETIME_FMT);
} catch (Exception ex) {
return now.format(DATETIME_FMT);
}
}
}
/**
* 按名称匹配分类:优先精确匹配,其次包含匹配。
*
* @param categories 分类列表
* @param name 目标名称
* @return 匹配到的分类,未命中返回 null
*/
private Category matchCategory(List<Category> categories, String name) {
if (isBlank(name)) {
return null;
}
String target = name.trim();
for (Category category : categories) {
if (category.getName().equalsIgnoreCase(target)) {
return category;
}
}
for (Category category : categories) {
if (category.getName().contains(target) || target.contains(category.getName())) {
return category;
}
}
return null;
}
/**
* 按名称匹配标签:优先精确匹配,其次包含匹配。
*
* @param tags 标签列表
* @param name 目标名称
* @return 匹配到的标签,未命中返回 null
*/
private Tag matchTag(List<Tag> tags, String name) {
if (isBlank(name)) {
return null;
}
String target = name.trim();
for (Tag tag : tags) {
if (tag.getName().equalsIgnoreCase(target)) {
return tag;
}
}
for (Tag tag : tags) {
if (tag.getName().contains(target) || target.contains(tag.getName())) {
return tag;
}
}
return null;
}
/**
* 将节点转换为金额,非数字时返回 null。
*
* @param node 模型输出节点
* @return 金额
*/
private BigDecimal decimalOrNull(JsonNode node) {
if (node == null || node.isMissingNode() || node.isNull()) {
return null;
}
try {
return node.decimalValue();
} catch (Exception e) {
return null;
}
}
/**
* 去除模型可能附加的 Markdown 代码块围栏。
*
* @param content 原始内容
* @return 清理后的内容
*/
private String stripCodeFence(String content) {
if (content == null) {
return "";
}
String cleaned = content.trim();
if (cleaned.startsWith("```")) {
int firstLineEnd = cleaned.indexOf('\n');
if (firstLineEnd > 0) {
cleaned = cleaned.substring(firstLineEnd + 1);
}
if (cleaned.endsWith("```")) {
cleaned = cleaned.substring(0, cleaned.length() - 3);
}
}
return cleaned.trim();
}
/**
* 判断字符串是否为空白。
*
* @param value 待判断字符串
* @return 为 null 或全空白时返回 true
*/
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}
@@ -0,0 +1,386 @@
package cn.rosercode.finance.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import cn.rosercode.finance.client.DeepSeekClient;
import cn.rosercode.finance.client.DeepSeekResponse;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.dto.AiApplyRequest;
import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.entity.Transaction;
import cn.rosercode.finance.entity.TransactionTag;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TagMapper;
import cn.rosercode.finance.mapper.TransactionMapper;
import cn.rosercode.finance.mapper.TransactionTagMapper;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiSuggestionService;
import cn.rosercode.finance.service.AiUsageService;
import cn.rosercode.finance.vo.AiSuggestionVO;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* AI 建议服务实现类,负责组装提示词、调用 DeepSeek、校验并应用建议。
*
* @date 2026-09-17 17:52:35
*/
@Service
public class AiSuggestionServiceImpl implements AiSuggestionService {
/** 单次最多分析的记录数 */
private static final int MAX_SUGGEST_SIZE = 100;
/** 原始账单数据注入提示词时的最大长度 */
private static final int RAW_DATA_LIMIT = 300;
/** 系统提示词,要求模型严格返回 JSON */
private static final String SYSTEM_PROMPT = "你是一个专业的个人记账助手。请根据每条交易记录的类型、金额、备注等信息,"
+ "从给定的分类列表和标签列表中,为该记录选择最合适的分类与标签。"
+ "要求:1. 分类必须与记录的类型一致(INCOME 只能选 INCOME 分类,EXPENSE 只能选 EXPENSE 分类);"
+ "2. 只能使用给定列表中的 ID,不要编造;3. 标签可多选,也可为空数组;"
+ "4. 只输出 JSON,不要输出任何多余内容。"
+ "输出格式:{\"suggestions\":[{\"id\":记录ID,\"categoryId\":分类ID,\"tagIds\":[标签ID],\"reason\":\"简要中文理由\"}]}";
private final AiConfigService aiConfigService;
private final TransactionMapper transactionMapper;
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
private final TransactionTagMapper transactionTagMapper;
private final DeepSeekClient deepSeekClient;
private final ObjectMapper objectMapper;
private final AiUsageService aiUsageService;
public AiSuggestionServiceImpl(AiConfigService aiConfigService,
TransactionMapper transactionMapper,
CategoryMapper categoryMapper,
TagMapper tagMapper,
TransactionTagMapper transactionTagMapper,
DeepSeekClient deepSeekClient,
ObjectMapper objectMapper,
AiUsageService aiUsageService) {
this.aiConfigService = aiConfigService;
this.transactionMapper = transactionMapper;
this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
this.transactionTagMapper = transactionTagMapper;
this.deepSeekClient = deepSeekClient;
this.objectMapper = objectMapper;
this.aiUsageService = aiUsageService;
}
/**
* {@inheritDoc}
*
* 校验配置与数量后,将分类、标签与交易记录注入提示词,调用模型并解析校验结果。
*/
@Override
public List<AiSuggestionVO> suggest(Long userId, List<Long> ids) {
if (ids == null || ids.isEmpty()) {
throw new BusinessException("请至少选择一条记录");
}
if (ids.size() > MAX_SUGGEST_SIZE) {
throw new BusinessException("单次最多分析 " + MAX_SUGGEST_SIZE + " 条记录,请缩小范围后重试");
}
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");
}
// 仅分析属于当前用户的记录
List<Transaction> transactions = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.eq(Transaction::getUserId, userId)
.in(Transaction::getId, ids));
if (transactions.isEmpty()) {
throw new BusinessException("未找到可分析的交易记录");
}
List<Category> categories = categoryMapper.selectList(new LambdaQueryWrapper<Category>()
.eq(Category::getUserId, userId));
if (categories.isEmpty()) {
throw new BusinessException("请先创建分类后再使用 AI 建议");
}
List<Tag> tags = tagMapper.selectList(new LambdaQueryWrapper<Tag>()
.eq(Tag::getUserId, userId));
String userPrompt = buildUserPrompt(categories, tags, transactions);
long start = System.currentTimeMillis();
DeepSeekResponse aiResponse;
try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
SYSTEM_PROMPT, userPrompt);
} catch (RuntimeException e) {
// 失败也记录一次使用,便于排查问题
aiUsageService.recordFailure(userId, AiCallType.SUGGEST, config.getModel(), transactions.size(),
System.currentTimeMillis() - start, e.getMessage());
throw e;
}
aiUsageService.recordSuccess(userId, AiCallType.SUGGEST, config.getModel(), transactions.size(),
aiResponse, System.currentTimeMillis() - start);
List<AiSuggestionVO> result = parseSuggestions(aiResponse.getContent(), transactions, categories, tags);
if (result.isEmpty()) {
throw new BusinessException("AI 未返回有效建议,请重试");
}
return result;
}
/**
* {@inheritDoc}
*
* 分类覆盖写入,标签在保留原有基础上追加。
*/
@Override
@Transactional
public int apply(Long userId, AiApplyRequest request) {
List<AiApplyRequest.Item> items = request.getSuggestions();
List<Long> transactionIds = items.stream()
.map(AiApplyRequest.Item::getTransactionId)
.distinct()
.collect(Collectors.toList());
// 仅处理属于当前用户的记录
Map<Long, Transaction> transactionMap = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.eq(Transaction::getUserId, userId)
.in(Transaction::getId, transactionIds))
.stream().collect(Collectors.toMap(Transaction::getId, Function.identity()));
if (transactionMap.isEmpty()) {
return 0;
}
Map<Long, Category> categoryMap = categoryMapper.selectList(new LambdaQueryWrapper<Category>()
.eq(Category::getUserId, userId))
.stream().collect(Collectors.toMap(Category::getId, Function.identity()));
Map<Long, Tag> tagMap = tagMapper.selectList(new LambdaQueryWrapper<Tag>()
.eq(Tag::getUserId, userId))
.stream().collect(Collectors.toMap(Tag::getId, Function.identity()));
int applied = 0;
for (AiApplyRequest.Item item : items) {
Transaction transaction = transactionMap.get(item.getTransactionId());
if (transaction == null) {
continue;
}
boolean changed = false;
// 分类:仅接受类型匹配且确属当前用户的分类
if (item.getCategoryId() != null) {
Category category = categoryMap.get(item.getCategoryId());
if (category != null
&& category.getType().equals(transaction.getType())
&& !category.getId().equals(transaction.getCategoryId())) {
transactionMapper.update(null, new LambdaUpdateWrapper<Transaction>()
.eq(Transaction::getId, transaction.getId())
.eq(Transaction::getUserId, userId)
.set(Transaction::getCategoryId, category.getId()));
changed = true;
}
}
// 标签:追加尚未关联的有效标签
List<Long> validTagIds = item.getTagIds() == null ? List.of() : item.getTagIds().stream()
.filter(tagMap::containsKey)
.distinct()
.collect(Collectors.toList());
if (!validTagIds.isEmpty()) {
Set<Long> existingTagIds = transactionTagMapper.selectList(new LambdaQueryWrapper<TransactionTag>()
.eq(TransactionTag::getTransactionId, transaction.getId()))
.stream().map(TransactionTag::getTagId).collect(Collectors.toSet());
for (Long tagId : validTagIds) {
if (existingTagIds.contains(tagId)) {
continue;
}
TransactionTag relation = new TransactionTag();
relation.setTransactionId(transaction.getId());
relation.setTagId(tagId);
transactionTagMapper.insert(relation);
changed = true;
}
}
if (changed) {
applied++;
}
}
return applied;
}
/**
* 构建用户提示词,将分类、标签与交易记录序列化为 JSON。
*
* @param categories 用户分类列表
* @param tags 用户标签列表
* @param transactions 待分析记录列表
* @return JSON 格式的用户提示词
*/
private String buildUserPrompt(List<Category> categories, List<Tag> tags, List<Transaction> transactions) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("categories", categories.stream().map(category -> {
Map<String, Object> node = new LinkedHashMap<>();
node.put("id", category.getId());
node.put("name", category.getName());
node.put("type", category.getType());
return node;
}).collect(Collectors.toList()));
payload.put("tags", tags.stream().map(tag -> {
Map<String, Object> node = new LinkedHashMap<>();
node.put("id", tag.getId());
node.put("name", tag.getName());
return node;
}).collect(Collectors.toList()));
payload.put("transactions", transactions.stream().map(transaction -> {
Map<String, Object> node = new LinkedHashMap<>();
node.put("id", transaction.getId());
node.put("type", transaction.getType());
node.put("amount", transaction.getAmount());
node.put("time", transaction.getTransactionTime());
node.put("paymentMethod", transaction.getPaymentMethod());
node.put("remark", transaction.getRemark());
node.put("extra", extractExtra(transaction.getRawData()));
return node;
}).collect(Collectors.toList()));
try {
return objectMapper.writeValueAsString(payload);
} catch (JsonProcessingException e) {
throw new BusinessException("构建 AI 提示词失败: " + e.getMessage());
}
}
/**
* 解析模型返回的 JSON,并校验分类与标签的合法性。
*
* @param content 模型输出内容
* @param transactions 参与分析的记录列表
* @param categories 用户分类列表
* @param tags 用户标签列表
* @return 有效建议列表
*/
private List<AiSuggestionVO> parseSuggestions(String content,
List<Transaction> transactions,
List<Category> categories,
List<Tag> tags) {
JsonNode root;
try {
root = objectMapper.readTree(stripCodeFence(content));
} catch (JsonProcessingException e) {
throw new BusinessException("AI 返回内容不是有效 JSON,请重试");
}
JsonNode array = root.path("suggestions");
if (!array.isArray()) {
throw new BusinessException("AI 返回格式不正确,请重试");
}
Map<Long, Transaction> transactionMap = transactions.stream()
.collect(Collectors.toMap(Transaction::getId, Function.identity()));
Map<Long, Category> categoryMap = categories.stream()
.collect(Collectors.toMap(Category::getId, Function.identity()));
Map<Long, Tag> tagMap = tags.stream()
.collect(Collectors.toMap(Tag::getId, Function.identity()));
List<AiSuggestionVO> result = new ArrayList<>();
for (JsonNode node : array) {
long id = node.path("id").asLong(0);
Transaction transaction = transactionMap.get(id);
if (transaction == null) {
continue;
}
AiSuggestionVO vo = new AiSuggestionVO();
vo.setTransactionId(id);
// 分类必须存在且与记录类型一致
long categoryId = node.path("categoryId").asLong(0);
Category category = categoryMap.get(categoryId);
if (category != null && category.getType().equals(transaction.getType())) {
vo.setCategoryId(category.getId());
vo.setCategoryName(category.getName());
}
// 标签过滤为属于当前用户的标签
List<Long> tagIds = new ArrayList<>();
List<String> tagNames = new ArrayList<>();
JsonNode tagNode = node.path("tagIds");
if (tagNode.isArray()) {
for (JsonNode item : tagNode) {
Tag tag = tagMap.get(item.asLong(0));
if (tag != null && !tagIds.contains(tag.getId())) {
tagIds.add(tag.getId());
tagNames.add(tag.getName());
}
}
}
vo.setTagIds(tagIds);
vo.setTagNames(tagNames);
vo.setReason(node.path("reason").asText(""));
// 至少存在一项有效建议才返回
if (vo.getCategoryId() != null || !tagIds.isEmpty()) {
result.add(vo);
}
}
return result;
}
/**
* 去除模型可能附加的 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 rawData 原始账单 JSON 字符串
* @return 截断后的摘要,为空时返回 null
*/
private String extractExtra(String rawData) {
if (isBlank(rawData)) {
return null;
}
return rawData.length() <= RAW_DATA_LIMIT ? rawData : rawData.substring(0, RAW_DATA_LIMIT);
}
/**
* 判断字符串是否为空白。
*
* @param value 待判断字符串
* @return 为 null 或全空白时返回 true
*/
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}
@@ -0,0 +1,173 @@
package cn.rosercode.finance.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import cn.rosercode.finance.client.DeepSeekResponse;
import cn.rosercode.finance.entity.AiUsage;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.mapper.AiUsageMapper;
import cn.rosercode.finance.service.AiUsageService;
import cn.rosercode.finance.vo.AiUsageResultVO;
import cn.rosercode.finance.vo.AiUsageSummaryVO;
import cn.rosercode.finance.vo.AiUsageVO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
/**
* AI 使用记录服务实现类,负责写入调用明细并汇总查询。
*
* @date 2026-09-17 18:02:23
*/
@Service
public class AiUsageServiceImpl implements AiUsageService {
private static final Logger log = LoggerFactory.getLogger(AiUsageServiceImpl.class);
/** 最近明细条数上限 */
private static final int MAX_RECENT_LIMIT = 100;
/** 失败原因最大长度 */
private static final int ERROR_MESSAGE_LIMIT = 500;
private final AiUsageMapper aiUsageMapper;
public AiUsageServiceImpl(AiUsageMapper aiUsageMapper) {
this.aiUsageMapper = aiUsageMapper;
}
/**
* {@inheritDoc}
*
* 记录写入失败仅打印告警,不影响主流程。
*/
@Override
public void recordSuccess(Long userId, AiCallType type, String model, int transactionCount,
DeepSeekResponse response, long durationMs) {
AiUsage usage = new AiUsage();
usage.setUserId(userId);
usage.setType(type.name());
usage.setModel(model);
usage.setTransactionCount(transactionCount);
usage.setPromptTokens(response != null ? response.getPromptTokens() : 0);
usage.setCompletionTokens(response != null ? response.getCompletionTokens() : 0);
usage.setTotalTokens(response != null ? response.getTotalTokens() : 0);
usage.setCachedTokens(response != null ? response.getCachedTokens() : 0);
usage.setSuccess(true);
usage.setDurationMs(durationMs);
saveQuietly(usage);
}
/**
* {@inheritDoc}
*/
@Override
public void recordFailure(Long userId, AiCallType type, String model, int transactionCount,
long durationMs, String errorMessage) {
AiUsage usage = new AiUsage();
usage.setUserId(userId);
usage.setType(type.name());
usage.setModel(model);
usage.setTransactionCount(transactionCount);
usage.setPromptTokens(0);
usage.setCompletionTokens(0);
usage.setTotalTokens(0);
usage.setCachedTokens(0);
usage.setSuccess(false);
usage.setErrorMessage(truncate(errorMessage));
usage.setDurationMs(durationMs);
saveQuietly(usage);
}
/**
* {@inheritDoc}
*/
@Override
public AiUsageResultVO getUsage(Long userId, int recentLimit) {
int limit = Math.min(Math.max(recentLimit, 1), MAX_RECENT_LIMIT);
AiUsageSummaryVO summary = aiUsageMapper.selectSummary(userId);
if (summary == null) {
summary = emptySummary();
}
List<AiUsageVO> recent = aiUsageMapper.selectList(new LambdaQueryWrapper<AiUsage>()
.eq(AiUsage::getUserId, userId)
.orderByDesc(AiUsage::getCreatedAt)
.orderByDesc(AiUsage::getId)
.last("LIMIT " + limit))
.stream().map(this::toVO).collect(Collectors.toList());
AiUsageResultVO result = new AiUsageResultVO();
result.setSummary(summary);
result.setRecent(recent);
return result;
}
/**
* 安静地保存使用记录,失败时仅打印告警。
*
* @param usage 使用记录实体
*/
private void saveQuietly(AiUsage usage) {
try {
aiUsageMapper.insert(usage);
} catch (Exception e) {
log.warn("记录 AI 使用情况失败: {}", e.getMessage());
}
}
/**
* 构造全 0 的汇总对象。
*
* @return 空的汇总对象
*/
private AiUsageSummaryVO emptySummary() {
AiUsageSummaryVO summary = new AiUsageSummaryVO();
summary.setTotalCalls(0L);
summary.setSuccessCalls(0L);
summary.setFailedCalls(0L);
summary.setPromptTokens(0L);
summary.setCompletionTokens(0L);
summary.setTotalTokens(0L);
summary.setCachedTokens(0L);
return summary;
}
/**
* 将使用记录实体转换为视图对象。
*
* @param usage 使用记录实体
* @return 使用明细视图对象
*/
private AiUsageVO toVO(AiUsage usage) {
AiUsageVO vo = new AiUsageVO();
vo.setId(usage.getId());
vo.setType(usage.getType());
vo.setModel(usage.getModel());
vo.setTransactionCount(usage.getTransactionCount());
vo.setPromptTokens(usage.getPromptTokens());
vo.setCompletionTokens(usage.getCompletionTokens());
vo.setTotalTokens(usage.getTotalTokens());
vo.setCachedTokens(usage.getCachedTokens());
vo.setSuccess(usage.getSuccess());
vo.setErrorMessage(usage.getErrorMessage());
vo.setDurationMs(usage.getDurationMs());
vo.setCreatedAt(usage.getCreatedAt());
return vo;
}
/**
* 截断过长的失败原因。
*
* @param value 原始文本
* @return 截断后的文本
*/
private String truncate(String value) {
if (value == null) {
return null;
}
return value.length() <= ERROR_MESSAGE_LIMIT ? value : value.substring(0, ERROR_MESSAGE_LIMIT);
}
}
@@ -0,0 +1,27 @@
package cn.rosercode.finance.vo;
import lombok.Data;
/**
* AI 配置视图对象,用于向前端返回配置信息(API Key 脱敏)。
*
* @date 2026-09-17 17:52:00
*/
@Data
public class AiConfigVO {
/** 是否启用 AI 智能建议 */
private Boolean enabled;
/** 脱敏后的 API Key,未配置时为 null */
private String apiKey;
/** 是否已配置 API Key */
private Boolean hasApiKey;
/** API 基础地址 */
private String baseUrl;
/** 使用的模型名称 */
private String model;
}
@@ -0,0 +1,60 @@
package cn.rosercode.finance.vo;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
* AI 自然语言查询解析结果视图对象,字段与交易查询条件对应。
*
* @date 2026-09-17 18:13:07
*/
@Data
public class AiQueryResultVO {
/** 解析摘要,用于向用户说明识别到的查询意图 */
private String summary;
/** 收支类型:INCOME / EXPENSE */
private List<String> types;
/** 命中的分类ID列表 */
private List<Long> categoryIds;
/** 命中的分类名称列表 */
private List<String> categoryNames;
/** 是否筛选未分类记录 */
private Boolean uncategorized;
/** 支付方式列表 */
private List<String> paymentMethods;
/** 开始时间(yyyy-MM-ddTHH:mm:ss) */
private String startTime;
/** 结束时间(yyyy-MM-ddTHH:mm:ss) */
private String endTime;
/** 是否计入统计 */
private Boolean includeInStats;
/** 最小金额 */
private BigDecimal minAmount;
/** 最大金额 */
private BigDecimal maxAmount;
/** 备注关键字 */
private String remark;
/** 命中的标签ID列表 */
private List<Long> tagIds;
/** 命中的标签名称列表 */
private List<String> tagNames;
/** 未能识别的关键词,供用户参考 */
private List<String> unmatched;
}
@@ -0,0 +1,51 @@
package cn.rosercode.finance.vo;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
* AI 自然语言记账解析结果视图对象,字段与交易记录表单对应。
*
* @date 2026-09-17 18:23:11
*/
@Data
public class AiRecordResultVO {
/** 解析摘要,用于向用户说明识别到的记账内容 */
private String summary;
/** 收支类型:INCOME / EXPENSE */
private String type;
/** 金额 */
private BigDecimal amount;
/** 命中的分类ID,未命中为 null */
private Long categoryId;
/** 命中的分类名称 */
private String categoryName;
/** 支付方式 */
private String paymentMethod;
/** 交易时间(yyyy-MM-ddTHH:mm:ss) */
private String transactionTime;
/** 备注 */
private String remark;
/** 是否计入统计 */
private Boolean includeInStats;
/** 命中的标签ID列表 */
private List<Long> tagIds;
/** 命中的标签名称列表 */
private List<String> tagNames;
/** 未能识别的关键词 */
private List<String> unmatched;
}
@@ -0,0 +1,32 @@
package cn.rosercode.finance.vo;
import lombok.Data;
import java.util.List;
/**
* AI 建议视图对象,描述某条交易记录的分类与标签建议。
*
* @date 2026-09-17 17:52:01
*/
@Data
public class AiSuggestionVO {
/** 交易记录ID */
private Long transactionId;
/** 建议分类ID,为 null 表示无分类建议 */
private Long categoryId;
/** 建议分类名称 */
private String categoryName;
/** 建议标签ID列表 */
private List<Long> tagIds;
/** 建议标签名称列表 */
private List<String> tagNames;
/** 建议理由 */
private String reason;
}
@@ -0,0 +1,20 @@
package cn.rosercode.finance.vo;
import lombok.Data;
import java.util.List;
/**
* AI 使用情况视图对象,包含累计汇总与最近调用明细。
*
* @date 2026-09-17 18:02:14
*/
@Data
public class AiUsageResultVO {
/** 累计汇总 */
private AiUsageSummaryVO summary;
/** 最近调用明细(按时间倒序) */
private List<AiUsageVO> recent;
}
@@ -0,0 +1,33 @@
package cn.rosercode.finance.vo;
import lombok.Data;
/**
* AI 使用汇总视图对象,展示累计调用次数与 token 用量。
*
* @date 2026-09-17 18:02:13
*/
@Data
public class AiUsageSummaryVO {
/** 累计调用次数 */
private Long totalCalls;
/** 成功次数 */
private Long successCalls;
/** 失败次数 */
private Long failedCalls;
/** 累计输入 token 数 */
private Long promptTokens;
/** 累计输出 token 数 */
private Long completionTokens;
/** 累计总 token 数 */
private Long totalTokens;
/** 累计缓存命中 token 数 */
private Long cachedTokens;
}
@@ -0,0 +1,50 @@
package cn.rosercode.finance.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* AI 使用明细视图对象,描述一次调用的用量与结果。
*
* @date 2026-09-17 18:02:13
*/
@Data
public class AiUsageVO {
/** 记录ID */
private Long id;
/** 调用类型:SUGGEST-生成建议,TEST-测试连接 */
private String type;
/** 模型名称 */
private String model;
/** 本次分析的记录数 */
private Integer transactionCount;
/** 输入 token 数 */
private Integer promptTokens;
/** 输出 token 数 */
private Integer completionTokens;
/** 总 token 数 */
private Integer totalTokens;
/** 缓存命中的输入 token 数 */
private Integer cachedTokens;
/** 是否成功 */
private Boolean success;
/** 失败原因 */
private String errorMessage;
/** 调用耗时(毫秒) */
private Long durationMs;
/** 创建时间 */
private LocalDateTime createdAt;
}
+31
View File
@@ -65,3 +65,34 @@ CREATE TABLE IF NOT EXISTS t_transaction_tag (
UNIQUE INDEX uk_transaction_tag (transaction_id, tag_id) COMMENT '交易+标签唯一索引',
INDEX idx_tag_id (tag_id) COMMENT '标签ID索引'
);
-- AI 配置表:存储每个用户的 AI 智能建议相关配置
CREATE TABLE IF NOT EXISTS t_ai_config (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '配置ID,主键自增',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用AI智能建议:1-启用,0-关闭',
api_key VARCHAR(255) COMMENT 'DeepSeek API Key(明文存储)',
base_url VARCHAR(255) NOT NULL DEFAULT 'https://api.deepseek.com' COMMENT 'API 基础地址',
model VARCHAR(50) NOT NULL DEFAULT 'deepseek-chat' COMMENT '使用的模型名称',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE INDEX uk_user_id (user_id) COMMENT '用户ID唯一索引,每个用户一条配置'
);
-- AI 使用记录表:记录每次调用 DeepSeek 的用量与结果
CREATE TABLE IF NOT EXISTS t_ai_usage (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID,主键自增',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
type VARCHAR(20) NOT NULL COMMENT '调用类型:SUGGEST-生成建议,TEST-测试连接',
model VARCHAR(50) COMMENT '模型名称',
transaction_count INT NOT NULL DEFAULT 0 COMMENT '本次分析的记录数',
prompt_tokens INT NOT NULL DEFAULT 0 COMMENT '输入 token 数',
completion_tokens INT NOT NULL DEFAULT 0 COMMENT '输出 token 数',
total_tokens INT NOT NULL DEFAULT 0 COMMENT '总 token 数',
cached_tokens INT NOT NULL DEFAULT 0 COMMENT '缓存命中的输入 token 数',
success TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否成功:1-成功,0-失败',
error_message VARCHAR(500) COMMENT '失败原因',
duration_ms BIGINT COMMENT '调用耗时(毫秒)',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引'
);