feat: 扩展 AI 记账能力并优化收支记录交互
- AI 能力:新增分类 Emoji 推荐、标签颜色推荐、自然语言生成分类/标签、单条记录分类推荐 - AI 批量建议:按类型+支付方式+备注去重聚合后分批调用,支持分类/标签/两者范围并记忆偏好,结果可勾选应用 - 分类/标签管理:新增 EmojiPicker 与预设色板,支持 AI 一键推荐并在列表可视化 - 收支记录:新增序号列,标签筛选支持无标签,编辑记录时允许不设置分类 - 修复:账单导入未选择默认标签时生成空 IN 列表导致的 SQL 语法错误 - AI 未启用时隐藏所有 AI 入口与弹窗
This commit is contained in:
@@ -2,19 +2,28 @@ package cn.rosercode.finance.controller;
|
||||
|
||||
import cn.rosercode.finance.common.Result;
|
||||
import cn.rosercode.finance.dto.AiApplyRequest;
|
||||
import cn.rosercode.finance.dto.AiCategoryRequest;
|
||||
import cn.rosercode.finance.dto.AiConfigRequest;
|
||||
import cn.rosercode.finance.dto.AiDecorateRequest;
|
||||
import cn.rosercode.finance.dto.AiQueryRequest;
|
||||
import cn.rosercode.finance.dto.AiRecordRequest;
|
||||
import cn.rosercode.finance.dto.AiSuggestOneRequest;
|
||||
import cn.rosercode.finance.dto.AiSuggestRequest;
|
||||
import cn.rosercode.finance.dto.AiTagRequest;
|
||||
import cn.rosercode.finance.service.AiCategoryService;
|
||||
import cn.rosercode.finance.service.AiConfigService;
|
||||
import cn.rosercode.finance.service.AiDecorationService;
|
||||
import cn.rosercode.finance.service.AiQueryService;
|
||||
import cn.rosercode.finance.service.AiRecordService;
|
||||
import cn.rosercode.finance.service.AiSuggestionService;
|
||||
import cn.rosercode.finance.service.AiTagService;
|
||||
import cn.rosercode.finance.service.AiUsageService;
|
||||
import cn.rosercode.finance.vo.AiCategoryResultVO;
|
||||
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.AiTagResultVO;
|
||||
import cn.rosercode.finance.vo.AiUsageResultVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -47,15 +56,22 @@ public class AiController {
|
||||
private final AiUsageService aiUsageService;
|
||||
private final AiQueryService aiQueryService;
|
||||
private final AiRecordService aiRecordService;
|
||||
private final AiDecorationService aiDecorationService;
|
||||
private final AiCategoryService aiCategoryService;
|
||||
private final AiTagService aiTagService;
|
||||
|
||||
public AiController(AiConfigService aiConfigService, AiSuggestionService aiSuggestionService,
|
||||
AiUsageService aiUsageService, AiQueryService aiQueryService,
|
||||
AiRecordService aiRecordService) {
|
||||
AiRecordService aiRecordService, AiDecorationService aiDecorationService,
|
||||
AiCategoryService aiCategoryService, AiTagService aiTagService) {
|
||||
this.aiConfigService = aiConfigService;
|
||||
this.aiSuggestionService = aiSuggestionService;
|
||||
this.aiUsageService = aiUsageService;
|
||||
this.aiQueryService = aiQueryService;
|
||||
this.aiRecordService = aiRecordService;
|
||||
this.aiDecorationService = aiDecorationService;
|
||||
this.aiCategoryService = aiCategoryService;
|
||||
this.aiTagService = aiTagService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,7 +128,22 @@ public class AiController {
|
||||
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()));
|
||||
return Result.success(aiSuggestionService.suggest(userId, suggestRequest.getIds(), suggestRequest.getScope()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据表单当前内容推荐分类与标签
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param suggestOneRequest 建议请求,携带表单当前内容
|
||||
* @return 单条建议结果
|
||||
*/
|
||||
@PostMapping("/suggest-one")
|
||||
@Operation(summary = "单条记录 AI 建议", description = "根据表单当前内容推荐分类与标签,供新增或编辑时参考")
|
||||
public Result<AiSuggestionVO> suggestOne(HttpServletRequest request,
|
||||
@RequestBody @Valid AiSuggestOneRequest suggestOneRequest) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(aiSuggestionService.suggestOne(userId, suggestOneRequest));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,4 +205,64 @@ public class AiController {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(aiRecordService.parse(userId, recordRequest.getText()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据自然语言描述生成分类
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param categoryRequest 生成请求,携带分类用途描述
|
||||
* @return 分类建议
|
||||
*/
|
||||
@PostMapping("/category")
|
||||
@Operation(summary = "AI 生成分类", description = "根据自然语言描述生成分类名称、类型与图标")
|
||||
public Result<AiCategoryResultVO> generateCategory(HttpServletRequest request,
|
||||
@RequestBody @Valid AiCategoryRequest categoryRequest) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(aiCategoryService.parse(userId, categoryRequest.getText()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据自然语言描述生成标签
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param tagRequest 生成请求,携带标签用途描述
|
||||
* @return 标签建议
|
||||
*/
|
||||
@PostMapping("/tag")
|
||||
@Operation(summary = "AI 生成标签", description = "根据自然语言描述生成标签名称与颜色")
|
||||
public Result<AiTagResultVO> generateTag(HttpServletRequest request,
|
||||
@RequestBody @Valid AiTagRequest tagRequest) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(aiTagService.parse(userId, tagRequest.getText()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分类名称推荐 Emoji 图标
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param decorateRequest 建议请求,携带分类名称与收支类型
|
||||
* @return 推荐的 Emoji
|
||||
*/
|
||||
@PostMapping("/emoji")
|
||||
@Operation(summary = "AI 推荐分类图标", description = "根据分类名称与收支类型推荐一个 Emoji")
|
||||
public Result<String> suggestEmoji(HttpServletRequest request,
|
||||
@RequestBody @Valid AiDecorateRequest decorateRequest) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(aiDecorationService.suggestEmoji(userId, decorateRequest.getName(), decorateRequest.getType()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据标签名称推荐颜色
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param decorateRequest 建议请求,携带标签名称
|
||||
* @return 推荐的十六进制颜色
|
||||
*/
|
||||
@PostMapping("/color")
|
||||
@Operation(summary = "AI 推荐标签颜色", description = "根据标签名称推荐一个十六进制颜色")
|
||||
public Result<String> suggestColor(HttpServletRequest request,
|
||||
@RequestBody @Valid AiDecorateRequest decorateRequest) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(aiDecorationService.suggestColor(userId, decorateRequest.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package cn.rosercode.finance.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* AI 生成分类请求 DTO,携带用户对分类用途的自然语言描述。
|
||||
*
|
||||
* @date 2026-09-18 09:29:33
|
||||
*/
|
||||
@Data
|
||||
public class AiCategoryRequest {
|
||||
|
||||
/** 分类用途的自然语言描述 */
|
||||
@NotBlank(message = "请输入分类描述")
|
||||
private String text;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package cn.rosercode.finance.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 分类图标与标签配色建议请求 DTO,携带用于生成建议的名称与收支类型。
|
||||
*
|
||||
* @date 2026-09-18 09:00:44
|
||||
*/
|
||||
@Data
|
||||
public class AiDecorateRequest {
|
||||
|
||||
/** 分类或标签名称 */
|
||||
@NotBlank(message = "名称不能为空")
|
||||
private String name;
|
||||
|
||||
/** 收支类型,仅分类图标建议使用(INCOME/EXPENSE) */
|
||||
private String type;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package cn.rosercode.finance.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 单条记录 AI 建议请求 DTO,携带表单当前内容用于生成分类与标签建议。
|
||||
*
|
||||
* @date 2026-09-18 09:21:48
|
||||
*/
|
||||
@Data
|
||||
public class AiSuggestOneRequest {
|
||||
|
||||
/** 交易类型(INCOME/EXPENSE) */
|
||||
@NotBlank(message = "交易类型不能为空")
|
||||
private String type;
|
||||
|
||||
/** 交易金额 */
|
||||
private BigDecimal amount;
|
||||
|
||||
/** 支付方式 */
|
||||
private String paymentMethod;
|
||||
|
||||
/** 备注 */
|
||||
private String remark;
|
||||
}
|
||||
@@ -16,4 +16,7 @@ public class AiSuggestRequest {
|
||||
/** 待分析建议的交易记录ID列表 */
|
||||
@NotEmpty(message = "请至少选择一条记录")
|
||||
private List<Long> ids;
|
||||
|
||||
/** 建议范围:BOTH-分类与标签(默认),CATEGORY-仅分类,TAG-仅标签 */
|
||||
private String scope;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package cn.rosercode.finance.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* AI 生成标签请求 DTO,携带用户对标签用途的自然语言描述。
|
||||
*
|
||||
* @date 2026-09-18 09:39:25
|
||||
*/
|
||||
@Data
|
||||
public class AiTagRequest {
|
||||
|
||||
/** 标签用途的自然语言描述 */
|
||||
@NotBlank(message = "请输入标签描述")
|
||||
private String text;
|
||||
}
|
||||
@@ -55,4 +55,7 @@ public class TransactionQuery {
|
||||
|
||||
/** 标签ID筛选,支持多选 */
|
||||
private List<Long> tagIds;
|
||||
|
||||
/** 是否仅筛选无标签记录;与 tagIds 同时存在时取并集 */
|
||||
private Boolean untagged;
|
||||
}
|
||||
|
||||
@@ -15,8 +15,7 @@ import java.util.List;
|
||||
@Data
|
||||
public class TransactionRequest {
|
||||
|
||||
/** 分类ID,不能为空 */
|
||||
@NotNull(message = "分类不能为空")
|
||||
/** 分类ID,可选;为空表示未分类 */
|
||||
private Long categoryId;
|
||||
|
||||
/** 交易类型(收入/支出),不能为空 */
|
||||
|
||||
@@ -17,5 +17,14 @@ public enum AiCallType {
|
||||
QUERY,
|
||||
|
||||
/** 自然语言记账解析 */
|
||||
RECORD
|
||||
RECORD,
|
||||
|
||||
/** 分类图标与标签配色建议 */
|
||||
DECORATE,
|
||||
|
||||
/** 自然语言生成分类 */
|
||||
CATEGORY,
|
||||
|
||||
/** 自然语言生成标签 */
|
||||
TAG
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package cn.rosercode.finance.service;
|
||||
|
||||
import cn.rosercode.finance.vo.AiCategoryResultVO;
|
||||
|
||||
/**
|
||||
* AI 生成分类服务接口,将自然语言描述解析为分类建议。
|
||||
*
|
||||
* @date 2026-09-18 09:29:33
|
||||
*/
|
||||
public interface AiCategoryService {
|
||||
|
||||
/**
|
||||
* 根据自然语言描述生成分类建议。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param text 分类用途描述
|
||||
* @return 分类建议
|
||||
*/
|
||||
AiCategoryResultVO parse(Long userId, String text);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package cn.rosercode.finance.service;
|
||||
|
||||
/**
|
||||
* AI 装饰建议服务接口,根据分类/标签名称生成图标与配色建议。
|
||||
*
|
||||
* @date 2026-09-18 09:00:44
|
||||
*/
|
||||
public interface AiDecorationService {
|
||||
|
||||
/**
|
||||
* 根据分类名称与收支类型推荐一个 Emoji 图标。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param name 分类名称
|
||||
* @param type 收支类型(INCOME/EXPENSE),可为空
|
||||
* @return 推荐的 Emoji
|
||||
*/
|
||||
String suggestEmoji(Long userId, String name, String type);
|
||||
|
||||
/**
|
||||
* 根据标签名称推荐一个十六进制颜色。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param name 标签名称
|
||||
* @return 推荐的颜色(如 #18a058)
|
||||
*/
|
||||
String suggestColor(Long userId, String name);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package cn.rosercode.finance.service;
|
||||
|
||||
import cn.rosercode.finance.dto.AiApplyRequest;
|
||||
import cn.rosercode.finance.dto.AiSuggestOneRequest;
|
||||
import cn.rosercode.finance.vo.AiSuggestionVO;
|
||||
|
||||
import java.util.List;
|
||||
@@ -17,9 +18,19 @@ public interface AiSuggestionService {
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param ids 待分析的建议记录ID列表
|
||||
* @param scope 建议范围:BOTH-分类与标签,CATEGORY-仅分类,TAG-仅标签
|
||||
* @return 逐条建议列表
|
||||
*/
|
||||
List<AiSuggestionVO> suggest(Long userId, List<Long> ids);
|
||||
List<AiSuggestionVO> suggest(Long userId, List<Long> ids, String scope);
|
||||
|
||||
/**
|
||||
* 根据表单当前内容(尚未保存的记录)生成分类与标签建议。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param request 单条记录内容
|
||||
* @return 建议结果,无有效建议时返回 null
|
||||
*/
|
||||
AiSuggestionVO suggestOne(Long userId, AiSuggestOneRequest request);
|
||||
|
||||
/**
|
||||
* 一键应用建议:覆盖分类、追加标签。
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package cn.rosercode.finance.service;
|
||||
|
||||
import cn.rosercode.finance.vo.AiTagResultVO;
|
||||
|
||||
/**
|
||||
* AI 生成标签服务接口,将自然语言描述解析为标签建议。
|
||||
*
|
||||
* @date 2026-09-18 09:39:25
|
||||
*/
|
||||
public interface AiTagService {
|
||||
|
||||
/**
|
||||
* 根据自然语言描述生成标签建议。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param text 标签用途描述
|
||||
* @return 标签建议
|
||||
*/
|
||||
AiTagResultVO parse(Long userId, String text);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package cn.rosercode.finance.service.impl;
|
||||
|
||||
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.enums.AiCallType;
|
||||
import cn.rosercode.finance.service.AiCategoryService;
|
||||
import cn.rosercode.finance.service.AiConfigService;
|
||||
import cn.rosercode.finance.service.AiUsageService;
|
||||
import cn.rosercode.finance.vo.AiCategoryResultVO;
|
||||
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.util.List;
|
||||
|
||||
/**
|
||||
* AI 生成分类服务实现类,根据自然语言描述生成分类名称、类型与图标。
|
||||
*
|
||||
* @date 2026-09-18 09:29:33
|
||||
*/
|
||||
@Service
|
||||
public class AiCategoryServiceImpl implements AiCategoryService {
|
||||
|
||||
/** 分类名称最大长度 */
|
||||
private static final int NAME_MAX_LENGTH = 20;
|
||||
|
||||
/** 图标最大长度(按 UTF-16 字符数限制) */
|
||||
private static final int ICON_MAX_LENGTH = 16;
|
||||
|
||||
/** 合法的收支类型 */
|
||||
private static final List<String> VALID_TYPES = List.of("INCOME", "EXPENSE");
|
||||
|
||||
/** 系统提示词,要求模型严格返回 JSON */
|
||||
private static final String SYSTEM_PROMPT = "你是一个个人记账应用的分类助手。"
|
||||
+ "请根据用户对分类用途的自然语言描述,生成一个简洁的记账分类。"
|
||||
+ "要求:1. name 为 2-6 个汉字的分类名称;2. type 只能是 INCOME(收入)或 EXPENSE(支出);"
|
||||
+ "3. icon 为与该分类最贴切的一个 Emoji;4. 只输出 JSON,不要输出任何多余内容。"
|
||||
+ "输出格式:{\"name\":\"交通\",\"type\":\"EXPENSE\",\"icon\":\"🚌\",\"reason\":\"简要中文理由\"}";
|
||||
|
||||
private final AiConfigService aiConfigService;
|
||||
private final DeepSeekClient deepSeekClient;
|
||||
private final AiUsageService aiUsageService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public AiCategoryServiceImpl(AiConfigService aiConfigService,
|
||||
DeepSeekClient deepSeekClient,
|
||||
AiUsageService aiUsageService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.aiConfigService = aiConfigService;
|
||||
this.deepSeekClient = deepSeekClient;
|
||||
this.aiUsageService = aiUsageService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 将用户描述直接作为提示词内容,解析模型返回的分类名称、类型与图标。
|
||||
*/
|
||||
@Override
|
||||
public AiCategoryResultVO parse(Long userId, String text) {
|
||||
if (text == null || text.isBlank()) {
|
||||
throw new BusinessException("请输入分类描述");
|
||||
}
|
||||
AiConfig config = requireConfig(userId);
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
DeepSeekResponse aiResponse;
|
||||
try {
|
||||
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
|
||||
SYSTEM_PROMPT, text.trim());
|
||||
} catch (RuntimeException e) {
|
||||
// 失败也记录一次使用,便于排查问题
|
||||
aiUsageService.recordFailure(userId, AiCallType.CATEGORY, config.getModel(), 0,
|
||||
System.currentTimeMillis() - start, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
aiUsageService.recordSuccess(userId, AiCallType.CATEGORY, config.getModel(), 0,
|
||||
aiResponse, System.currentTimeMillis() - start);
|
||||
|
||||
return parseResult(aiResponse.getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模型返回的 JSON 并校验字段合法性。
|
||||
*
|
||||
* @param content 模型输出内容
|
||||
* @return 分类建议
|
||||
*/
|
||||
private AiCategoryResultVO parseResult(String content) {
|
||||
JsonNode root;
|
||||
try {
|
||||
root = objectMapper.readTree(stripCodeFence(content));
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new BusinessException("AI 返回内容不是有效 JSON,请重试");
|
||||
}
|
||||
|
||||
String name = root.path("name").asText("").trim();
|
||||
if (isBlank(name) || name.length() > NAME_MAX_LENGTH) {
|
||||
throw new BusinessException("AI 未返回有效的分类名称,请重试");
|
||||
}
|
||||
|
||||
// 类型非法时回退为支出
|
||||
String type = root.path("type").asText("EXPENSE").trim().toUpperCase();
|
||||
if (!VALID_TYPES.contains(type)) {
|
||||
type = "EXPENSE";
|
||||
}
|
||||
|
||||
String icon = root.path("icon").asText("").trim();
|
||||
if (icon.length() > ICON_MAX_LENGTH) {
|
||||
icon = "";
|
||||
}
|
||||
|
||||
AiCategoryResultVO vo = new AiCategoryResultVO();
|
||||
vo.setName(name);
|
||||
vo.setType(type);
|
||||
vo.setIcon(icon);
|
||||
vo.setReason(root.path("reason").asText(""));
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取并校验当前用户的 AI 配置。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 有效的 AI 配置
|
||||
*/
|
||||
private AiConfig requireConfig(Long userId) {
|
||||
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");
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 去除模型可能附加的 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,202 @@
|
||||
package cn.rosercode.finance.service.impl;
|
||||
|
||||
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.enums.AiCallType;
|
||||
import cn.rosercode.finance.service.AiConfigService;
|
||||
import cn.rosercode.finance.service.AiDecorationService;
|
||||
import cn.rosercode.finance.service.AiUsageService;
|
||||
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.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* AI 装饰建议服务实现类,根据分类/标签名称调用模型生成 Emoji 与配色建议。
|
||||
*
|
||||
* @date 2026-09-18 09:00:44
|
||||
*/
|
||||
@Service
|
||||
public class AiDecorationServiceImpl implements AiDecorationService {
|
||||
|
||||
/** Emoji 建议提示词 */
|
||||
private static final String EMOJI_SYSTEM_PROMPT = "你是一个个人记账应用的图标助手。"
|
||||
+ "请根据给定的分类名称与收支类型,从常用 Emoji 中挑选一个最贴切、辨识度高的作为分类图标。"
|
||||
+ "只输出 JSON,不要输出任何多余内容。输出格式:{\"emoji\":\"🍜\"}";
|
||||
|
||||
/** 颜色建议提示词 */
|
||||
private static final String COLOR_SYSTEM_PROMPT = "你是一个个人记账应用的配色助手。"
|
||||
+ "请根据标签名称的语义,挑选一个高饱和、适合作为标签背景色且搭配白色文字清晰可读的十六进制颜色。"
|
||||
+ "只输出 JSON,不要输出任何多余内容。输出格式:{\"color\":\"#18a058\"}";
|
||||
|
||||
/** Emoji 最大长度(按 UTF-16 字符数限制,兼容组合 Emoji) */
|
||||
private static final int EMOJI_MAX_LENGTH = 16;
|
||||
|
||||
/** 十六进制颜色校验 */
|
||||
private static final Pattern COLOR_PATTERN = Pattern.compile("^#[0-9a-fA-F]{6}$");
|
||||
|
||||
private final AiConfigService aiConfigService;
|
||||
private final DeepSeekClient deepSeekClient;
|
||||
private final AiUsageService aiUsageService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public AiDecorationServiceImpl(AiConfigService aiConfigService,
|
||||
DeepSeekClient deepSeekClient,
|
||||
AiUsageService aiUsageService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.aiConfigService = aiConfigService;
|
||||
this.deepSeekClient = deepSeekClient;
|
||||
this.aiUsageService = aiUsageService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 将分类名称与收支类型注入提示词,解析模型返回的 Emoji 并校验长度。
|
||||
*/
|
||||
@Override
|
||||
public String suggestEmoji(Long userId, String name, String type) {
|
||||
String trimmed = requireName(name);
|
||||
AiConfig config = requireConfig(userId);
|
||||
String userPrompt = "分类名称:" + trimmed + ";收支类型:" + typeLabel(type);
|
||||
JsonNode root = chatAndParse(userId, config, EMOJI_SYSTEM_PROMPT, userPrompt);
|
||||
String emoji = root.path("emoji").asText("").trim();
|
||||
if (isBlank(emoji) || emoji.length() > EMOJI_MAX_LENGTH) {
|
||||
throw new BusinessException("AI 未返回有效的 Emoji,请重试");
|
||||
}
|
||||
return emoji;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 将标签名称注入提示词,解析模型返回的颜色并校验十六进制格式。
|
||||
*/
|
||||
@Override
|
||||
public String suggestColor(Long userId, String name) {
|
||||
String trimmed = requireName(name);
|
||||
AiConfig config = requireConfig(userId);
|
||||
String userPrompt = "标签名称:" + trimmed;
|
||||
JsonNode root = chatAndParse(userId, config, COLOR_SYSTEM_PROMPT, userPrompt);
|
||||
String color = root.path("color").asText("").trim();
|
||||
if (!COLOR_PATTERN.matcher(color).matches()) {
|
||||
throw new BusinessException("AI 未返回有效的颜色,请重试");
|
||||
}
|
||||
return color.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用模型并解析返回的 JSON,同时记录使用情况。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param config AI 配置
|
||||
* @param systemPrompt 系统提示词
|
||||
* @param userPrompt 用户提示词
|
||||
* @return 解析后的 JSON 根节点
|
||||
*/
|
||||
private JsonNode chatAndParse(Long userId, AiConfig config, String systemPrompt, String userPrompt) {
|
||||
long start = System.currentTimeMillis();
|
||||
DeepSeekResponse aiResponse;
|
||||
try {
|
||||
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
|
||||
systemPrompt, userPrompt);
|
||||
} catch (RuntimeException e) {
|
||||
// 失败也记录一次使用,便于排查问题
|
||||
aiUsageService.recordFailure(userId, AiCallType.DECORATE, config.getModel(), 0,
|
||||
System.currentTimeMillis() - start, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
aiUsageService.recordSuccess(userId, AiCallType.DECORATE, config.getModel(), 0,
|
||||
aiResponse, System.currentTimeMillis() - start);
|
||||
|
||||
try {
|
||||
return objectMapper.readTree(stripCodeFence(aiResponse.getContent()));
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new BusinessException("AI 返回内容不是有效 JSON,请重试");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取并校验当前用户的 AI 配置。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 有效的 AI 配置
|
||||
*/
|
||||
private AiConfig requireConfig(Long userId) {
|
||||
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");
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验名称非空。
|
||||
*
|
||||
* @param name 名称
|
||||
* @return 去除首尾空白后的名称
|
||||
*/
|
||||
private String requireName(String name) {
|
||||
if (isBlank(name)) {
|
||||
throw new BusinessException("请先输入名称");
|
||||
}
|
||||
return name.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将收支类型转换为中文描述。
|
||||
*
|
||||
* @param type 收支类型
|
||||
* @return 中文描述
|
||||
*/
|
||||
private String typeLabel(String type) {
|
||||
if ("INCOME".equalsIgnoreCase(type)) {
|
||||
return "收入";
|
||||
}
|
||||
if ("EXPENSE".equalsIgnoreCase(type)) {
|
||||
return "支出";
|
||||
}
|
||||
return "未指定";
|
||||
}
|
||||
|
||||
/**
|
||||
* 去除模型可能附加的 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();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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.dto.AiSuggestOneRequest;
|
||||
import cn.rosercode.finance.entity.AiConfig;
|
||||
import cn.rosercode.finance.entity.Category;
|
||||
import cn.rosercode.finance.entity.Tag;
|
||||
@@ -26,10 +27,12 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -42,9 +45,12 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
public class AiSuggestionServiceImpl implements AiSuggestionService {
|
||||
|
||||
/** 单次最多分析的记录数 */
|
||||
/** 单批最多分析的记录数 */
|
||||
private static final int MAX_SUGGEST_SIZE = 100;
|
||||
|
||||
/** 单次请求最多分析的记录总数(与前端最大页大小一致) */
|
||||
private static final int MAX_TOTAL_SIZE = 500;
|
||||
|
||||
/** 原始账单数据注入提示词时的最大长度 */
|
||||
private static final int RAW_DATA_LIMIT = 300;
|
||||
|
||||
@@ -86,28 +92,38 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 校验配置与数量后,将分类、标签与交易记录注入提示词,调用模型并解析校验结果。
|
||||
* 先按「类型 + 支付方式 + 备注」聚合重复记录,仅将每组代表送入模型,
|
||||
* 再把建议套用到整组记录;代表数超过单批上限时自动分批调用并汇总结果。
|
||||
*/
|
||||
@Override
|
||||
public List<AiSuggestionVO> suggest(Long userId, List<Long> ids) {
|
||||
public List<AiSuggestionVO> suggest(Long userId, List<Long> ids, String scope) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
throw new BusinessException("请至少选择一条记录");
|
||||
}
|
||||
if (ids.size() > MAX_SUGGEST_SIZE) {
|
||||
throw new BusinessException("单次最多分析 " + MAX_SUGGEST_SIZE + " 条记录,请缩小范围后重试");
|
||||
// 建议范围:默认分类与标签都给出
|
||||
String normalizedScope = normalizeScope(scope);
|
||||
String systemPrompt = buildSystemPrompt(normalizedScope);
|
||||
List<Long> distinctIds = ids.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
if (distinctIds.isEmpty()) {
|
||||
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");
|
||||
if (distinctIds.size() > MAX_TOTAL_SIZE) {
|
||||
throw new BusinessException("单次最多分析 " + MAX_TOTAL_SIZE + " 条记录,请缩小范围后重试");
|
||||
}
|
||||
AiConfig config = requireConfig(userId);
|
||||
|
||||
// 仅分析属于当前用户的记录
|
||||
List<Transaction> transactions = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
|
||||
.eq(Transaction::getUserId, userId)
|
||||
.in(Transaction::getId, ids));
|
||||
// 仅分析属于当前用户的记录,并按请求顺序排列
|
||||
Map<Long, Transaction> transactionMap = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
|
||||
.eq(Transaction::getUserId, userId)
|
||||
.in(Transaction::getId, distinctIds))
|
||||
.stream().collect(Collectors.toMap(Transaction::getId, Function.identity()));
|
||||
List<Transaction> transactions = distinctIds.stream()
|
||||
.map(transactionMap::get)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
if (transactions.isEmpty()) {
|
||||
throw new BusinessException("未找到可分析的交易记录");
|
||||
}
|
||||
@@ -120,26 +136,258 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
|
||||
List<Tag> tags = tagMapper.selectList(new LambdaQueryWrapper<Tag>()
|
||||
.eq(Tag::getUserId, userId));
|
||||
|
||||
String userPrompt = buildUserPrompt(categories, tags, transactions);
|
||||
// 按类型、支付方式与备注聚合,重复记录只保留一条代表参与分析
|
||||
List<List<Transaction>> groups = groupTransactions(transactions);
|
||||
|
||||
List<AiSuggestionVO> result = new ArrayList<>();
|
||||
// 代表记录仍可能超过单批上限,按批调用并汇总
|
||||
for (List<List<Transaction>> batch : partition(groups, MAX_SUGGEST_SIZE)) {
|
||||
List<Transaction> representatives = batch.stream()
|
||||
.map(group -> group.get(0))
|
||||
.collect(Collectors.toList());
|
||||
String userPrompt = buildUserPrompt(categories, tags, representatives);
|
||||
long start = System.currentTimeMillis();
|
||||
DeepSeekResponse aiResponse;
|
||||
try {
|
||||
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
|
||||
systemPrompt, userPrompt);
|
||||
} catch (RuntimeException e) {
|
||||
// 失败也记录一次使用,便于排查问题
|
||||
aiUsageService.recordFailure(userId, AiCallType.SUGGEST, config.getModel(), representatives.size(),
|
||||
System.currentTimeMillis() - start, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
aiUsageService.recordSuccess(userId, AiCallType.SUGGEST, config.getModel(), representatives.size(),
|
||||
aiResponse, System.currentTimeMillis() - start);
|
||||
|
||||
List<AiSuggestionVO> batchResult = parseSuggestions(aiResponse.getContent(), representatives, categories,
|
||||
tags, normalizedScope);
|
||||
// 将代表建议套用到同组的所有记录
|
||||
Map<Long, AiSuggestionVO> byRepresentative = batchResult.stream()
|
||||
.collect(Collectors.toMap(AiSuggestionVO::getTransactionId, Function.identity(), (a, b) -> a));
|
||||
for (List<Transaction> group : batch) {
|
||||
AiSuggestionVO suggestion = byRepresentative.get(group.get(0).getId());
|
||||
if (suggestion == null) {
|
||||
continue;
|
||||
}
|
||||
List<Long> groupIds = group.stream().map(Transaction::getId).collect(Collectors.toList());
|
||||
suggestion.setTransactionIds(groupIds);
|
||||
suggestion.setCount(groupIds.size());
|
||||
result.add(suggestion);
|
||||
}
|
||||
}
|
||||
if (result.isEmpty()) {
|
||||
throw new BusinessException("AI 未返回有效建议,请重试");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 以表单当前内容构造一条未落库的临时记录,仅请求一条建议。
|
||||
*/
|
||||
@Override
|
||||
public AiSuggestionVO suggestOne(Long userId, AiSuggestOneRequest request) {
|
||||
if (!"INCOME".equals(request.getType()) && !"EXPENSE".equals(request.getType())) {
|
||||
throw new BusinessException("交易类型无效");
|
||||
}
|
||||
AiConfig config = requireConfig(userId);
|
||||
|
||||
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));
|
||||
|
||||
// 构造临时记录用于分析,ID 固定为 0,不落库
|
||||
Transaction transaction = new Transaction();
|
||||
transaction.setId(0L);
|
||||
transaction.setUserId(userId);
|
||||
transaction.setType(request.getType());
|
||||
transaction.setAmount(request.getAmount());
|
||||
transaction.setPaymentMethod(request.getPaymentMethod());
|
||||
transaction.setRemark(request.getRemark());
|
||||
List<Transaction> single = List.of(transaction);
|
||||
|
||||
String userPrompt = buildUserPrompt(categories, tags, single);
|
||||
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(),
|
||||
aiUsageService.recordFailure(userId, AiCallType.SUGGEST, config.getModel(), 1,
|
||||
System.currentTimeMillis() - start, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
aiUsageService.recordSuccess(userId, AiCallType.SUGGEST, config.getModel(), transactions.size(),
|
||||
aiUsageService.recordSuccess(userId, AiCallType.SUGGEST, config.getModel(), 1,
|
||||
aiResponse, System.currentTimeMillis() - start);
|
||||
|
||||
List<AiSuggestionVO> result = parseSuggestions(aiResponse.getContent(), transactions, categories, tags);
|
||||
if (result.isEmpty()) {
|
||||
throw new BusinessException("AI 未返回有效建议,请重试");
|
||||
return parseSingleSuggestion(aiResponse.getContent(), transaction, categories, tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模型针对单条记录返回的建议,不依赖模型回传的记录ID。
|
||||
*
|
||||
* @param content 模型输出内容
|
||||
* @param transaction 临时记录
|
||||
* @param categories 用户分类列表
|
||||
* @param tags 用户标签列表
|
||||
* @return 建议结果,无有效建议时返回 null
|
||||
*/
|
||||
private AiSuggestionVO parseSingleSuggestion(String content,
|
||||
Transaction transaction,
|
||||
List<Category> categories,
|
||||
List<Tag> tags) {
|
||||
JsonNode root;
|
||||
try {
|
||||
root = objectMapper.readTree(stripCodeFence(content));
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new BusinessException("AI 返回内容不是有效 JSON,请重试");
|
||||
}
|
||||
return result;
|
||||
|
||||
// 兼容模型返回数组或单个对象两种形式
|
||||
JsonNode node = null;
|
||||
JsonNode array = root.path("suggestions");
|
||||
if (array.isArray() && !array.isEmpty()) {
|
||||
node = array.get(0);
|
||||
} else if (root.has("categoryId") || root.has("tagIds")) {
|
||||
node = root;
|
||||
}
|
||||
if (node == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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()));
|
||||
|
||||
AiSuggestionVO vo = new AiSuggestionVO();
|
||||
vo.setTransactionId(transaction.getId());
|
||||
|
||||
// 分类必须存在且与记录类型一致
|
||||
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()) {
|
||||
return null;
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化建议范围,非法值回退为 BOTH。
|
||||
*
|
||||
* @param scope 原始范围
|
||||
* @return CATEGORY / TAG / BOTH
|
||||
*/
|
||||
private String normalizeScope(String scope) {
|
||||
if ("CATEGORY".equalsIgnoreCase(scope)) {
|
||||
return "CATEGORY";
|
||||
}
|
||||
if ("TAG".equalsIgnoreCase(scope)) {
|
||||
return "TAG";
|
||||
}
|
||||
return "BOTH";
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据建议范围构建系统提示词。
|
||||
*
|
||||
* @param scope 建议范围
|
||||
* @return 系统提示词
|
||||
*/
|
||||
private String buildSystemPrompt(String scope) {
|
||||
if ("CATEGORY".equals(scope)) {
|
||||
return SYSTEM_PROMPT + "注意:本次只需给出分类建议,tagIds 必须返回空数组。";
|
||||
}
|
||||
if ("TAG".equals(scope)) {
|
||||
return SYSTEM_PROMPT + "注意:本次只需给出标签建议,categoryId 必须返回 null。";
|
||||
}
|
||||
return SYSTEM_PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取并校验当前用户的 AI 配置。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 有效的 AI 配置
|
||||
*/
|
||||
private AiConfig requireConfig(Long userId) {
|
||||
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");
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按「类型 + 支付方式 + 备注」聚合交易记录,并保持原有顺序。
|
||||
*
|
||||
* <p>备注为空的记录不参与聚合,各自独立成组,避免无关记录被归为一类。</p>
|
||||
*
|
||||
* @param transactions 待分析记录列表
|
||||
* @return 分组后的记录列表,每组至少一条
|
||||
*/
|
||||
private List<List<Transaction>> groupTransactions(List<Transaction> transactions) {
|
||||
Map<String, List<Transaction>> grouped = new LinkedHashMap<>();
|
||||
for (Transaction transaction : transactions) {
|
||||
String key;
|
||||
if (isBlank(transaction.getRemark())) {
|
||||
// 备注为空时以记录ID作为唯一键,保证独立成组
|
||||
key = "__single__" + transaction.getId();
|
||||
} else {
|
||||
key = transaction.getType() + "|"
|
||||
+ (transaction.getPaymentMethod() == null ? "" : transaction.getPaymentMethod())
|
||||
+ "|" + transaction.getRemark();
|
||||
}
|
||||
grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(transaction);
|
||||
}
|
||||
return new ArrayList<>(grouped.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将列表按固定大小切分为多个子列表。
|
||||
*
|
||||
* @param list 原始列表
|
||||
* @param size 每个子列表的最大长度
|
||||
* @param <T> 元素类型
|
||||
* @return 切分后的子列表集合
|
||||
*/
|
||||
private <T> List<List<T>> partition(List<T> list, int size) {
|
||||
List<List<T>> parts = new ArrayList<>();
|
||||
for (int i = 0; i < list.size(); i += size) {
|
||||
parts.add(list.subList(i, Math.min(i + size, list.size())));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -276,7 +524,11 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
|
||||
private List<AiSuggestionVO> parseSuggestions(String content,
|
||||
List<Transaction> transactions,
|
||||
List<Category> categories,
|
||||
List<Tag> tags) {
|
||||
List<Tag> tags,
|
||||
String scope) {
|
||||
// 仅解析用户选择的范围
|
||||
boolean wantCategory = !"TAG".equals(scope);
|
||||
boolean wantTag = !"CATEGORY".equals(scope);
|
||||
JsonNode root;
|
||||
try {
|
||||
root = objectMapper.readTree(stripCodeFence(content));
|
||||
@@ -305,24 +557,28 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
|
||||
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());
|
||||
// 分类必须存在且与记录类型一致(仅标签建议时跳过)
|
||||
if (wantCategory) {
|
||||
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());
|
||||
if (wantTag) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package cn.rosercode.finance.service.impl;
|
||||
|
||||
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.enums.AiCallType;
|
||||
import cn.rosercode.finance.service.AiConfigService;
|
||||
import cn.rosercode.finance.service.AiTagService;
|
||||
import cn.rosercode.finance.service.AiUsageService;
|
||||
import cn.rosercode.finance.vo.AiTagResultVO;
|
||||
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.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* AI 生成标签服务实现类,根据自然语言描述生成标签名称与颜色。
|
||||
*
|
||||
* @date 2026-09-18 09:39:25
|
||||
*/
|
||||
@Service
|
||||
public class AiTagServiceImpl implements AiTagService {
|
||||
|
||||
/** 标签名称最大长度 */
|
||||
private static final int NAME_MAX_LENGTH = 20;
|
||||
|
||||
/** 默认颜色,AI 未返回合法颜色时使用 */
|
||||
private static final String DEFAULT_COLOR = "#2080f0";
|
||||
|
||||
/** 十六进制颜色校验 */
|
||||
private static final Pattern COLOR_PATTERN = Pattern.compile("^#[0-9a-fA-F]{6}$");
|
||||
|
||||
/** 系统提示词,要求模型严格返回 JSON */
|
||||
private static final String SYSTEM_PROMPT = "你是一个个人记账应用的标签助手。"
|
||||
+ "请根据用户对标签用途的自然语言描述,生成一个简洁的记账标签。"
|
||||
+ "要求:1. name 为 2-6 个汉字的标签名称;"
|
||||
+ "2. color 为高饱和、适合作为标签背景色且搭配白色文字清晰可读的十六进制颜色;"
|
||||
+ "3. 只输出 JSON,不要输出任何多余内容。"
|
||||
+ "输出格式:{\"name\":\"报销\",\"color\":\"#2f80ed\",\"reason\":\"简要中文理由\"}";
|
||||
|
||||
private final AiConfigService aiConfigService;
|
||||
private final DeepSeekClient deepSeekClient;
|
||||
private final AiUsageService aiUsageService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public AiTagServiceImpl(AiConfigService aiConfigService,
|
||||
DeepSeekClient deepSeekClient,
|
||||
AiUsageService aiUsageService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.aiConfigService = aiConfigService;
|
||||
this.deepSeekClient = deepSeekClient;
|
||||
this.aiUsageService = aiUsageService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 将用户描述直接作为提示词内容,解析模型返回的标签名称与颜色。
|
||||
*/
|
||||
@Override
|
||||
public AiTagResultVO parse(Long userId, String text) {
|
||||
if (text == null || text.isBlank()) {
|
||||
throw new BusinessException("请输入标签描述");
|
||||
}
|
||||
AiConfig config = requireConfig(userId);
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
DeepSeekResponse aiResponse;
|
||||
try {
|
||||
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
|
||||
SYSTEM_PROMPT, text.trim());
|
||||
} catch (RuntimeException e) {
|
||||
// 失败也记录一次使用,便于排查问题
|
||||
aiUsageService.recordFailure(userId, AiCallType.TAG, config.getModel(), 0,
|
||||
System.currentTimeMillis() - start, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
aiUsageService.recordSuccess(userId, AiCallType.TAG, config.getModel(), 0,
|
||||
aiResponse, System.currentTimeMillis() - start);
|
||||
|
||||
return parseResult(aiResponse.getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模型返回的 JSON 并校验字段合法性。
|
||||
*
|
||||
* @param content 模型输出内容
|
||||
* @return 标签建议
|
||||
*/
|
||||
private AiTagResultVO parseResult(String content) {
|
||||
JsonNode root;
|
||||
try {
|
||||
root = objectMapper.readTree(stripCodeFence(content));
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new BusinessException("AI 返回内容不是有效 JSON,请重试");
|
||||
}
|
||||
|
||||
String name = root.path("name").asText("").trim();
|
||||
if (isBlank(name) || name.length() > NAME_MAX_LENGTH) {
|
||||
throw new BusinessException("AI 未返回有效的标签名称,请重试");
|
||||
}
|
||||
|
||||
// 颜色非法时回退默认色
|
||||
String color = root.path("color").asText("").trim();
|
||||
if (!COLOR_PATTERN.matcher(color).matches()) {
|
||||
color = DEFAULT_COLOR;
|
||||
}
|
||||
|
||||
AiTagResultVO vo = new AiTagResultVO();
|
||||
vo.setName(name);
|
||||
vo.setColor(color.toLowerCase());
|
||||
vo.setReason(root.path("reason").asText(""));
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取并校验当前用户的 AI 配置。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 有效的 AI 配置
|
||||
*/
|
||||
private AiConfig requireConfig(Long userId) {
|
||||
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");
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 去除模型可能附加的 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();
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
: requireCategory(userId, request.getIncomeCategoryId(), "INCOME");
|
||||
|
||||
// 默认标签:仅保留属于当前用户的标签
|
||||
List<Long> tagIds = request.getTagIds() == null ? List.of()
|
||||
List<Long> tagIds = request.getTagIds() == null || request.getTagIds().isEmpty() ? List.of()
|
||||
: tagMapper.selectBatchIds(request.getTagIds()).stream()
|
||||
.filter(tag -> tag.getUserId().equals(userId))
|
||||
.map(Tag::getId)
|
||||
|
||||
@@ -113,10 +113,22 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
if (StringUtils.hasText(query.getRemark())) {
|
||||
wrapper.like(Transaction::getRemark, query.getRemark());
|
||||
}
|
||||
// 按标签筛选:命中任一选中标签的记录
|
||||
if (query.getTagIds() != null && !query.getTagIds().isEmpty()) {
|
||||
wrapper.inSql(Transaction::getId, "SELECT transaction_id FROM t_transaction_tag WHERE tag_id IN ("
|
||||
+ query.getTagIds().stream().map(String::valueOf).collect(Collectors.joining(",")) + ")");
|
||||
// 按标签筛选:命中任一选中标签,或筛选无任何标签的记录
|
||||
boolean hasTagIds = query.getTagIds() != null && !query.getTagIds().isEmpty();
|
||||
boolean untagged = Boolean.TRUE.equals(query.getUntagged());
|
||||
if (hasTagIds || untagged) {
|
||||
wrapper.and(w -> {
|
||||
if (hasTagIds) {
|
||||
w.inSql(Transaction::getId, "SELECT transaction_id FROM t_transaction_tag WHERE tag_id IN ("
|
||||
+ query.getTagIds().stream().map(String::valueOf).collect(Collectors.joining(",")) + ")");
|
||||
}
|
||||
if (untagged) {
|
||||
if (hasTagIds) {
|
||||
w.or();
|
||||
}
|
||||
w.apply("NOT EXISTS (SELECT 1 FROM t_transaction_tag tt WHERE tt.transaction_id = t_transaction.id)");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 应用排序:默认按交易时间降序,支持按交易时间/金额排序
|
||||
@@ -190,10 +202,12 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
throw new BusinessException("记录不存在");
|
||||
}
|
||||
|
||||
// 校验分类是否存在且属于当前用户
|
||||
Category category = categoryMapper.selectById(request.getCategoryId());
|
||||
if (category == null || !category.getUserId().equals(userId)) {
|
||||
throw new BusinessException("分类不存在");
|
||||
// 分类可选:仅在指定分类时校验其归属,未指定则保持未分类
|
||||
if (request.getCategoryId() != null) {
|
||||
Category category = categoryMapper.selectById(request.getCategoryId());
|
||||
if (category == null || !category.getUserId().equals(userId)) {
|
||||
throw new BusinessException("分类不存在");
|
||||
}
|
||||
}
|
||||
|
||||
transaction.setCategoryId(request.getCategoryId());
|
||||
@@ -587,10 +601,22 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
if (StringUtils.hasText(query.getRemark())) {
|
||||
wrapper.like(Transaction::getRemark, query.getRemark());
|
||||
}
|
||||
// 按标签筛选:命中任一选中标签的记录
|
||||
if (query.getTagIds() != null && !query.getTagIds().isEmpty()) {
|
||||
wrapper.inSql(Transaction::getId, "SELECT transaction_id FROM t_transaction_tag WHERE tag_id IN ("
|
||||
+ query.getTagIds().stream().map(String::valueOf).collect(Collectors.joining(",")) + ")");
|
||||
// 按标签筛选:命中任一选中标签,或筛选无任何标签的记录
|
||||
boolean hasTagIds = query.getTagIds() != null && !query.getTagIds().isEmpty();
|
||||
boolean untagged = Boolean.TRUE.equals(query.getUntagged());
|
||||
if (hasTagIds || untagged) {
|
||||
wrapper.and(w -> {
|
||||
if (hasTagIds) {
|
||||
w.inSql(Transaction::getId, "SELECT transaction_id FROM t_transaction_tag WHERE tag_id IN ("
|
||||
+ query.getTagIds().stream().map(String::valueOf).collect(Collectors.joining(",")) + ")");
|
||||
}
|
||||
if (untagged) {
|
||||
if (hasTagIds) {
|
||||
w.or();
|
||||
}
|
||||
w.apply("NOT EXISTS (SELECT 1 FROM t_transaction_tag tt WHERE tt.transaction_id = t_transaction.id)");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 应用排序:默认按交易时间降序,支持按交易时间/金额排序
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package cn.rosercode.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* AI 生成分类结果视图对象,字段与分类表单对应。
|
||||
*
|
||||
* @date 2026-09-18 09:29:33
|
||||
*/
|
||||
@Data
|
||||
public class AiCategoryResultVO {
|
||||
|
||||
/** 建议的分类名称 */
|
||||
private String name;
|
||||
|
||||
/** 建议的收支类型:INCOME / EXPENSE */
|
||||
private String type;
|
||||
|
||||
/** 建议的分类图标(Emoji) */
|
||||
private String icon;
|
||||
|
||||
/** 生成理由,用于向用户说明 */
|
||||
private String reason;
|
||||
}
|
||||
@@ -12,9 +12,15 @@ import java.util.List;
|
||||
@Data
|
||||
public class AiSuggestionVO {
|
||||
|
||||
/** 交易记录ID */
|
||||
/** 代表交易记录ID */
|
||||
private Long transactionId;
|
||||
|
||||
/** 该建议适用的全部记录ID(按类型、备注、支付方式聚合后的一组记录) */
|
||||
private List<Long> transactionIds;
|
||||
|
||||
/** 命中的记录数,等于 transactionIds 的大小 */
|
||||
private Integer count;
|
||||
|
||||
/** 建议分类ID,为 null 表示无分类建议 */
|
||||
private Long categoryId;
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package cn.rosercode.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* AI 生成标签结果视图对象,字段与标签表单对应。
|
||||
*
|
||||
* @date 2026-09-18 09:39:25
|
||||
*/
|
||||
@Data
|
||||
public class AiTagResultVO {
|
||||
|
||||
/** 建议的标签名称 */
|
||||
private String name;
|
||||
|
||||
/** 建议的标签颜色(十六进制) */
|
||||
private String color;
|
||||
|
||||
/** 生成理由,用于向用户说明 */
|
||||
private String reason;
|
||||
}
|
||||
Reference in New Issue
Block a user