feat: 收支记录页支持基于勾选账单 AI 生成规则
在收支记录页批量操作区新增「AI 生成规则」入口,可基于勾选的账单调用 AI 归纳分类与标签规则,在弹窗中预览、勾选创建,并可选择同时将这些规则应用到所选账单,复用既有规则生成能力并补充本地匹配应用接口。 变更内容: - 后端新增 `POST /api/ai/rule/apply-transactions`(`AiRuleService.applyToTransactions`):按传入规则对指定交易做本地匹配,命中则覆盖分类、追加标签,必要时新建分类与标签,复用 `AiSuggestionService.apply` 落库。 - 新增 DTO `AiRuleTransactionApplyRequest`,复用 `AiRuleApplyRequest.Item` 结构承载规则定义。 - 前端 `api/ai.ts` 抽出 `AiRuleApplyItem` 类型并新增 `applyRulesToTransactions`。 - 新增组件 `AiRuleSuggestModal.vue`:展示规则建议、勾选创建,并提供「同时应用到当前勾选的 N 条账单」开关。 - `TransactionList.vue` 批量操作区新增「AI 生成规则」按钮(受 AI 启用状态控制),勾选账单后一步生成并预览规则。 - `locales/messages.ts` 补充相关中文、英文、日文文案。 说明: - `apply-transactions` 为纯本地匹配、不调用大模型,不外发任何敏感数据;`suggest` 仍遵循白名单只发送类型、备注、渠道与分类标签名称。
This commit is contained in:
@@ -32,6 +32,7 @@ import cn.rosercode.ledgerly.dto.AiRecordRequest;
|
||||
import cn.rosercode.ledgerly.dto.AiRelationRequest;
|
||||
import cn.rosercode.ledgerly.dto.AiRuleApplyRequest;
|
||||
import cn.rosercode.ledgerly.dto.AiRuleSuggestRequest;
|
||||
import cn.rosercode.ledgerly.dto.AiRuleTransactionApplyRequest;
|
||||
import cn.rosercode.ledgerly.dto.AiSuggestOneRequest;
|
||||
import cn.rosercode.ledgerly.dto.AiSuggestRequest;
|
||||
import cn.rosercode.ledgerly.dto.AiTagRequest;
|
||||
@@ -480,6 +481,19 @@ public class AiController {
|
||||
return Result.success(aiRuleService.apply(userId, request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将规则匹配并应用到指定交易记录
|
||||
*
|
||||
* @param request 目标交易与规则定义
|
||||
* @return 实际发生变更的记录数量
|
||||
*/
|
||||
@PostMapping("/rule/apply-transactions")
|
||||
@Operation(summary = "应用 AI 规则到交易", description = "按传入的规则匹配并应用到指定交易,命中则覆盖分类、追加标签")
|
||||
public Result<Integer> applyRulesToTransactions(@RequestBody @Valid AiRuleTransactionApplyRequest request) {
|
||||
Long userId = UserContext.getUserId();
|
||||
return Result.success(aiRuleService.applyToTransactions(userId, request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将自然语言统计查询解析为统计面板参数
|
||||
*
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package cn.rosercode.ledgerly.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* AI 规则应用到交易记录的请求,携带目标交易与用于匹配的规则定义。
|
||||
*
|
||||
* @date 2026-10-13 20:22:47
|
||||
*/
|
||||
@Data
|
||||
public class AiRuleTransactionApplyRequest {
|
||||
|
||||
/** 待应用的目标交易记录ID列表,不能为空 */
|
||||
@NotEmpty(message = "请至少选择一条记录")
|
||||
private List<Long> transactionIds;
|
||||
|
||||
/** 用于匹配并应用的规则列表,不能为空 */
|
||||
@NotEmpty(message = "请至少选择一条规则")
|
||||
@Valid
|
||||
private List<AiRuleApplyRequest.Item> rules;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package cn.rosercode.ledgerly.service;
|
||||
|
||||
import cn.rosercode.ledgerly.dto.AiRuleApplyRequest;
|
||||
import cn.rosercode.ledgerly.dto.AiRuleSuggestRequest;
|
||||
import cn.rosercode.ledgerly.dto.AiRuleTransactionApplyRequest;
|
||||
import cn.rosercode.ledgerly.vo.AiRuleSuggestResultVO;
|
||||
|
||||
/**
|
||||
@@ -28,4 +29,16 @@ public interface AiRuleService {
|
||||
* @return 实际创建的规则数量
|
||||
*/
|
||||
int apply(Long userId, AiRuleApplyRequest request);
|
||||
|
||||
/**
|
||||
* 将规则匹配并应用到指定交易记录。
|
||||
*
|
||||
* <p>纯本地匹配,不调用大模型:命中规则时覆盖分类并追加标签,必要时新建分类与标签。
|
||||
* 规则的关键词匹配交易的备注(包含、忽略大小写),并遵循类型与金额区间条件。</p>
|
||||
*
|
||||
* @param userId 当前用户ID
|
||||
* @param request 目标交易与规则定义
|
||||
* @return 实际发生变更的记录数
|
||||
*/
|
||||
int applyToTransactions(Long userId, AiRuleTransactionApplyRequest request);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cn.rosercode.ledgerly.service.impl;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
@@ -24,8 +25,10 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import cn.rosercode.ledgerly.client.DeepSeekClient;
|
||||
import cn.rosercode.ledgerly.client.DeepSeekResponse;
|
||||
import cn.rosercode.ledgerly.common.BusinessException;
|
||||
import cn.rosercode.ledgerly.dto.AiApplyRequest;
|
||||
import cn.rosercode.ledgerly.dto.AiRuleApplyRequest;
|
||||
import cn.rosercode.ledgerly.dto.AiRuleSuggestRequest;
|
||||
import cn.rosercode.ledgerly.dto.AiRuleTransactionApplyRequest;
|
||||
import cn.rosercode.ledgerly.dto.RuleRequest;
|
||||
import cn.rosercode.ledgerly.entity.AiConfig;
|
||||
import cn.rosercode.ledgerly.entity.Category;
|
||||
@@ -41,6 +44,7 @@ import cn.rosercode.ledgerly.mapper.TransactionTagMapper;
|
||||
import cn.rosercode.ledgerly.prompt.PromptTemplateService;
|
||||
import cn.rosercode.ledgerly.service.AiConfigService;
|
||||
import cn.rosercode.ledgerly.service.AiRuleService;
|
||||
import cn.rosercode.ledgerly.service.AiSuggestionService;
|
||||
import cn.rosercode.ledgerly.service.AiUsageService;
|
||||
import cn.rosercode.ledgerly.service.RuleService;
|
||||
import cn.rosercode.ledgerly.vo.AiNewTagVO;
|
||||
@@ -91,6 +95,7 @@ public class AiRuleServiceImpl implements AiRuleService {
|
||||
private final TransactionTagMapper transactionTagMapper;
|
||||
private final RuleService ruleService;
|
||||
private final PromptTemplateService promptTemplateService;
|
||||
private final AiSuggestionService aiSuggestionService;
|
||||
|
||||
public AiRuleServiceImpl(AiConfigService aiConfigService,
|
||||
DeepSeekClient deepSeekClient,
|
||||
@@ -101,7 +106,8 @@ public class AiRuleServiceImpl implements AiRuleService {
|
||||
TagMapper tagMapper,
|
||||
TransactionTagMapper transactionTagMapper,
|
||||
RuleService ruleService,
|
||||
PromptTemplateService promptTemplateService) {
|
||||
PromptTemplateService promptTemplateService,
|
||||
AiSuggestionService aiSuggestionService) {
|
||||
this.aiConfigService = aiConfigService;
|
||||
this.deepSeekClient = deepSeekClient;
|
||||
this.aiUsageService = aiUsageService;
|
||||
@@ -112,6 +118,7 @@ public class AiRuleServiceImpl implements AiRuleService {
|
||||
this.transactionTagMapper = transactionTagMapper;
|
||||
this.ruleService = ruleService;
|
||||
this.promptTemplateService = promptTemplateService;
|
||||
this.aiSuggestionService = aiSuggestionService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -439,6 +446,136 @@ public class AiRuleServiceImpl implements AiRuleService {
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 纯本地匹配,不调用大模型:逐条交易按类型、关键词与金额区间匹配规则,
|
||||
* 命中后聚合分类与标签,委托 {@link AiSuggestionService} 完成落库。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public int applyToTransactions(Long userId, AiRuleTransactionApplyRequest request) {
|
||||
List<Long> transactionIds = request.getTransactionIds().stream()
|
||||
.distinct().collect(Collectors.toList());
|
||||
// 仅处理属于当前用户的记录,且保持请求顺序
|
||||
Map<Long, Transaction> transactionMap = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
|
||||
.eq(Transaction::getUserId, userId)
|
||||
.in(Transaction::getId, transactionIds))
|
||||
.stream().collect(Collectors.toMap(Transaction::getId, Function.identity()));
|
||||
if (transactionMap.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
List<AiRuleApplyRequest.Item> rules = request.getRules();
|
||||
List<AiApplyRequest.Item> applyItems = new ArrayList<>();
|
||||
for (Long transactionId : transactionIds) {
|
||||
Transaction transaction = transactionMap.get(transactionId);
|
||||
if (transaction == null) {
|
||||
continue;
|
||||
}
|
||||
AiApplyRequest.Item item = buildApplyItem(transaction, rules);
|
||||
if (item != null) {
|
||||
applyItems.add(item);
|
||||
}
|
||||
}
|
||||
if (applyItems.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
AiApplyRequest applyRequest = new AiApplyRequest();
|
||||
applyRequest.setSuggestions(applyItems);
|
||||
int applied = aiSuggestionService.apply(userId, applyRequest);
|
||||
log.info("AI 规则应用到交易完成: userId={}, 目标交易={}, 命中={}, 实际应用={}", userId,
|
||||
transactionIds.size(), applyItems.size(), applied);
|
||||
return applied;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按规则匹配单条交易并聚合目标分类与标签。
|
||||
*
|
||||
* @param transaction 目标交易
|
||||
* @param rules 规则列表
|
||||
* @return 应用项,无任何命中或可应用内容时返回 null
|
||||
*/
|
||||
private AiApplyRequest.Item buildApplyItem(Transaction transaction, List<AiRuleApplyRequest.Item> rules) {
|
||||
String text = safe(transaction.getRemark()).toLowerCase();
|
||||
Long categoryId = null;
|
||||
String newCategoryName = null;
|
||||
String newCategoryIcon = null;
|
||||
List<Long> tagIds = new ArrayList<>();
|
||||
List<AiNewTagVO> newTags = new ArrayList<>();
|
||||
Set<String> newTagNames = new HashSet<>();
|
||||
boolean matched = false;
|
||||
for (AiRuleApplyRequest.Item rule : rules) {
|
||||
if (rule == null || !matchesRule(rule, transaction, text)) {
|
||||
continue;
|
||||
}
|
||||
matched = true;
|
||||
// 分类取首个命中规则:优先现有分类,其次新建分类
|
||||
if (categoryId == null && isBlank(newCategoryName)) {
|
||||
if (rule.getCategoryId() != null) {
|
||||
categoryId = rule.getCategoryId();
|
||||
} else if (!isBlank(rule.getNewCategoryName())) {
|
||||
newCategoryName = rule.getNewCategoryName().trim();
|
||||
newCategoryIcon = rule.getNewCategoryIcon();
|
||||
}
|
||||
}
|
||||
// 标签取所有命中规则的并集
|
||||
if (rule.getTagIds() != null) {
|
||||
for (Long tagId : rule.getTagIds()) {
|
||||
if (tagId != null && !tagIds.contains(tagId)) {
|
||||
tagIds.add(tagId);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rule.getNewTags() != null) {
|
||||
for (AiNewTagVO newTag : rule.getNewTags()) {
|
||||
if (newTag == null || isBlank(newTag.getName()) || !newTagNames.add(newTag.getName().trim())) {
|
||||
continue;
|
||||
}
|
||||
newTags.add(newTag);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matched || (categoryId == null && isBlank(newCategoryName) && tagIds.isEmpty() && newTags.isEmpty())) {
|
||||
return null;
|
||||
}
|
||||
AiApplyRequest.Item item = new AiApplyRequest.Item();
|
||||
item.setTransactionId(transaction.getId());
|
||||
item.setCategoryId(categoryId);
|
||||
item.setNewCategoryName(newCategoryName);
|
||||
item.setNewCategoryIcon(newCategoryIcon);
|
||||
item.setTagIds(tagIds);
|
||||
item.setNewTags(newTags);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断单条规则是否命中交易:类型限定、关键词包含与金额区间为「与」关系。
|
||||
*
|
||||
* @param rule 规则
|
||||
* @param transaction 交易
|
||||
* @param text 交易的匹配文本(备注转小写)
|
||||
* @return 命中返回 true
|
||||
*/
|
||||
private boolean matchesRule(AiRuleApplyRequest.Item rule, Transaction transaction, String text) {
|
||||
String type = validType(rule.getType());
|
||||
if (type != null && !type.equals(transaction.getType())) {
|
||||
return false;
|
||||
}
|
||||
if (!isBlank(rule.getKeyword()) && !text.contains(rule.getKeyword().trim().toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
BigDecimal amount = transaction.getAmount();
|
||||
if (rule.getMinAmount() != null && (amount == null || amount.compareTo(rule.getMinAmount()) < 0)) {
|
||||
return false;
|
||||
}
|
||||
if (rule.getMaxAmount() != null && (amount == null || amount.compareTo(rule.getMaxAmount()) > 0)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建用户提示词,将可用分类、标签与已打标签记录序列化为 JSON。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user