rules;
+}
diff --git a/src/main/java/cn/rosercode/ledgerly/service/AiRuleService.java b/src/main/java/cn/rosercode/ledgerly/service/AiRuleService.java
index d14fd6a..fed70b4 100644
--- a/src/main/java/cn/rosercode/ledgerly/service/AiRuleService.java
+++ b/src/main/java/cn/rosercode/ledgerly/service/AiRuleService.java
@@ -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);
+
+ /**
+ * 将规则匹配并应用到指定交易记录。
+ *
+ * 纯本地匹配,不调用大模型:命中规则时覆盖分类并追加标签,必要时新建分类与标签。
+ * 规则的关键词匹配交易的备注(包含、忽略大小写),并遵循类型与金额区间条件。
+ *
+ * @param userId 当前用户ID
+ * @param request 目标交易与规则定义
+ * @return 实际发生变更的记录数
+ */
+ int applyToTransactions(Long userId, AiRuleTransactionApplyRequest request);
}
diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/AiRuleServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/AiRuleServiceImpl.java
index d687151..5320cdc 100644
--- a/src/main/java/cn/rosercode/ledgerly/service/impl/AiRuleServiceImpl.java
+++ b/src/main/java/cn/rosercode/ledgerly/service/impl/AiRuleServiceImpl.java
@@ -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 transactionIds = request.getTransactionIds().stream()
+ .distinct().collect(Collectors.toList());
+ // 仅处理属于当前用户的记录,且保持请求顺序
+ Map transactionMap = transactionMapper.selectList(new LambdaQueryWrapper()
+ .eq(Transaction::getUserId, userId)
+ .in(Transaction::getId, transactionIds))
+ .stream().collect(Collectors.toMap(Transaction::getId, Function.identity()));
+ if (transactionMap.isEmpty()) {
+ return 0;
+ }
+
+ List rules = request.getRules();
+ List 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 rules) {
+ String text = safe(transaction.getRemark()).toLowerCase();
+ Long categoryId = null;
+ String newCategoryName = null;
+ String newCategoryIcon = null;
+ List tagIds = new ArrayList<>();
+ List newTags = new ArrayList<>();
+ Set 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。
*