feat: 扩展 AI 记账能力并优化收支记录交互

- AI 能力:新增分类 Emoji 推荐、标签颜色推荐、自然语言生成分类/标签、单条记录分类推荐

- AI 批量建议:按类型+支付方式+备注去重聚合后分批调用,支持分类/标签/两者范围并记忆偏好,结果可勾选应用

- 分类/标签管理:新增 EmojiPicker 与预设色板,支持 AI 一键推荐并在列表可视化

- 收支记录:新增序号列,标签筛选支持无标签,编辑记录时允许不设置分类

- 修复:账单导入未选择默认标签时生成空 IN 列表导致的 SQL 语法错误

- AI 未启用时隐藏所有 AI 入口与弹窗
This commit is contained in:
2026-09-18 09:57:53 +08:00
parent 49c70a732d
commit a8ef91ed3d
30 changed files with 2009 additions and 132 deletions
@@ -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;
}