contexts);
+
+ /**
+ * 预览所选规则命中的历史账单及其将应用的目标分类与标签。
+ *
+ * 按规则条件(类型 + 关键词 + 金额区间)匹配当前用户的全部交易,
+ * 分类取命中规则中优先级最高且类型一致者,标签取命中规则标签并集。
+ *
+ * @param userId 当前用户ID
+ * @param ruleIds 目标规则ID列表
+ * @return 命中账单统计与明细
+ */
+ RuleApplyPreviewVO previewApply(Long userId, List ruleIds);
+
+ /**
+ * 将所选规则的分类与标签一键应用到其命中的历史账单。
+ *
+ * 分类覆盖写入(仅在类型一致时),标签在保留原有基础上追加,全程本地匹配不调用大模型。
+ *
+ * @param userId 当前用户ID
+ * @param ruleIds 目标规则ID列表
+ * @return 命中与变更统计
+ */
+ RuleApplyResultVO applyToMatchedTransactions(Long userId, List ruleIds);
}
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 5320cdc..beda37a 100644
--- a/src/main/java/cn/rosercode/ledgerly/service/impl/AiRuleServiceImpl.java
+++ b/src/main/java/cn/rosercode/ledgerly/service/impl/AiRuleServiceImpl.java
@@ -224,10 +224,29 @@ public class AiRuleServiceImpl implements AiRuleService {
* @return 规则签名
*/
private String signatureOf(AiRuleSuggestionVO rule, List categories, List tags) {
- List newTagNames = rule.getNewTags() == null ? null
- : rule.getNewTags().stream().map(AiNewTagVO::getName).collect(Collectors.toList());
- return buildSignature(rule.getType(), rule.getKeyword(), rule.getCategoryId(),
- rule.getNewCategoryName(), rule.getTagIds(), newTagNames, categories, tags);
+ return signatureOfFields(rule.getType(), rule.getKeyword(), rule.getCategoryId(),
+ rule.getNewCategoryName(), rule.getTagIds(), rule.getNewTags(), categories, tags);
+ }
+
+ /**
+ * 由规则定义字段计算签名,newCategory/newTags 先按名称解析到已有分类/标签 ID。
+ *
+ * @param type 收支类型
+ * @param keyword 关键词
+ * @param categoryId 分类ID
+ * @param newCategoryName 新建分类名称
+ * @param tagIds 标签ID列表
+ * @param newTags 新建标签列表
+ * @param categories 用户分类列表
+ * @param tags 用户标签列表
+ * @return 规则签名
+ */
+ private String signatureOfFields(String type, String keyword, Long categoryId, String newCategoryName,
+ List tagIds, List newTags,
+ List categories, List tags) {
+ List newTagNames = newTags == null ? null
+ : newTags.stream().map(AiNewTagVO::getName).collect(Collectors.toList());
+ return buildSignature(type, keyword, categoryId, newCategoryName, tagIds, newTagNames, categories, tags);
}
/**
@@ -490,6 +509,32 @@ public class AiRuleServiceImpl implements AiRuleService {
return applied;
}
+ /**
+ * {@inheritDoc}
+ *
+ * 复用规则签名逻辑,逐条判定是否与已有规则或批内先前规则重复(批内保留首条)。
+ */
+ @Override
+ public List checkDuplicates(Long userId, List items) {
+ List categories = categoryMapper.selectList(new LambdaQueryWrapper()
+ .eq(Category::getUserId, userId));
+ List tags = tagMapper.selectList(new LambdaQueryWrapper()
+ .eq(Tag::getUserId, userId));
+ Set existing = loadExistingRuleSignatures(userId);
+ Set seen = new HashSet<>();
+ List result = new ArrayList<>();
+ for (AiRuleApplyRequest.Item item : items) {
+ if (item == null) {
+ result.add(Boolean.FALSE);
+ continue;
+ }
+ String signature = signatureOfFields(item.getType(), item.getKeyword(), item.getCategoryId(),
+ item.getNewCategoryName(), item.getTagIds(), item.getNewTags(), categories, tags);
+ result.add(existing.contains(signature) || !seen.add(signature));
+ }
+ return result;
+ }
+
/**
* 按规则匹配单条交易并聚合目标分类与标签。
*
diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/RuleServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/RuleServiceImpl.java
index f6b9f94..4060f5a 100644
--- a/src/main/java/cn/rosercode/ledgerly/service/impl/RuleServiceImpl.java
+++ b/src/main/java/cn/rosercode/ledgerly/service/impl/RuleServiceImpl.java
@@ -1,6 +1,7 @@
package cn.rosercode.ledgerly.service.impl;
import java.util.ArrayList;
+import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
@@ -10,6 +11,7 @@ import java.util.Set;
import java.util.stream.Collectors;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -25,14 +27,21 @@ import cn.rosercode.ledgerly.entity.Category;
import cn.rosercode.ledgerly.entity.Rule;
import cn.rosercode.ledgerly.entity.RuleTag;
import cn.rosercode.ledgerly.entity.Tag;
+import cn.rosercode.ledgerly.entity.Transaction;
+import cn.rosercode.ledgerly.entity.TransactionTag;
import cn.rosercode.ledgerly.enums.TransactionType;
import cn.rosercode.ledgerly.mapper.CategoryMapper;
import cn.rosercode.ledgerly.mapper.RuleMapper;
import cn.rosercode.ledgerly.mapper.RuleTagMapper;
import cn.rosercode.ledgerly.mapper.TagMapper;
+import cn.rosercode.ledgerly.mapper.TransactionMapper;
+import cn.rosercode.ledgerly.mapper.TransactionTagMapper;
import cn.rosercode.ledgerly.service.RuleMatchContext;
import cn.rosercode.ledgerly.service.RuleMatchResult;
import cn.rosercode.ledgerly.service.RuleService;
+import cn.rosercode.ledgerly.vo.RuleApplyPreviewVO;
+import cn.rosercode.ledgerly.vo.RuleApplyResultVO;
+import cn.rosercode.ledgerly.vo.RuleMatchedTransactionVO;
import cn.rosercode.ledgerly.vo.RuleVO;
import cn.rosercode.ledgerly.vo.TagVO;
@@ -48,21 +57,30 @@ import cn.rosercode.ledgerly.vo.TagVO;
@Slf4j
public class RuleServiceImpl implements RuleService {
+ /** 预览命中账单明细的最大展示条数 */
+ private static final int PREVIEW_MAX_ROWS = 500;
+
private final RuleMapper ruleMapper;
private final RuleTagMapper ruleTagMapper;
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
+ private final TransactionMapper transactionMapper;
+ private final TransactionTagMapper transactionTagMapper;
private final VoConverter voConverter;
public RuleServiceImpl(RuleMapper ruleMapper,
RuleTagMapper ruleTagMapper,
CategoryMapper categoryMapper,
TagMapper tagMapper,
+ TransactionMapper transactionMapper,
+ TransactionTagMapper transactionTagMapper,
VoConverter voConverter) {
this.ruleMapper = ruleMapper;
this.ruleTagMapper = ruleTagMapper;
this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
+ this.transactionMapper = transactionMapper;
+ this.transactionTagMapper = transactionTagMapper;
this.voConverter = voConverter;
}
@@ -330,6 +348,299 @@ public class RuleServiceImpl implements RuleService {
return results;
}
+ /**
+ * {@inheritDoc}
+ *
+ * 按规则条件匹配当前用户全部交易,分类取命中规则中优先级最高且类型一致者,
+ * 标签取命中规则标签并集;明细最多返回 {@value #PREVIEW_MAX_ROWS} 条。
+ */
+ @Override
+ public RuleApplyPreviewVO previewApply(Long userId, List ruleIds) {
+ List plans = computeMatchPlans(userId, ruleIds);
+ RuleApplyPreviewVO vo = new RuleApplyPreviewVO();
+ vo.setMatchedCount(plans.size());
+ vo.setTruncated(plans.size() > PREVIEW_MAX_ROWS);
+
+ Map categoryMap = loadUserCategories(userId);
+ Map tagMap = loadUserTags(userId);
+ Map> existingTagIdsByTransaction = loadTransactionTagIds(
+ plans.stream().map(plan -> plan.transaction.getId()).collect(Collectors.toList()));
+
+ int categoryChangeCount = 0;
+ int tagChangeCount = 0;
+ List rows = new ArrayList<>();
+ for (MatchPlan plan : plans) {
+ Transaction transaction = plan.transaction;
+ Set existingTagIds = existingTagIdsByTransaction.getOrDefault(transaction.getId(), Set.of());
+ boolean categoryChanged = plan.targetCategoryId != null
+ && !plan.targetCategoryId.equals(transaction.getCategoryId());
+ boolean tagChanged = plan.targetTagIds.stream().anyMatch(tagId -> !existingTagIds.contains(tagId));
+ if (categoryChanged) {
+ categoryChangeCount++;
+ }
+ if (tagChanged) {
+ tagChangeCount++;
+ }
+ if (rows.size() < PREVIEW_MAX_ROWS) {
+ rows.add(toMatchedVo(plan, existingTagIds, categoryMap, tagMap));
+ }
+ }
+ vo.setCategoryChangeCount(categoryChangeCount);
+ vo.setTagChangeCount(tagChangeCount);
+ vo.setTransactions(rows);
+ return vo;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * 分类覆盖写入(仅类型一致时),标签在保留原有基础上追加,全程本地匹配。
+ */
+ @Override
+ @Transactional
+ public RuleApplyResultVO applyToMatchedTransactions(Long userId, List ruleIds) {
+ List plans = computeMatchPlans(userId, ruleIds);
+ RuleApplyResultVO result = new RuleApplyResultVO();
+ result.setMatchedCount(plans.size());
+ if (plans.isEmpty()) {
+ return result;
+ }
+ Map> existingTagIdsByTransaction = loadTransactionTagIds(
+ plans.stream().map(plan -> plan.transaction.getId()).collect(Collectors.toList()));
+
+ int updated = 0;
+ int tagAdded = 0;
+ for (MatchPlan plan : plans) {
+ Transaction transaction = plan.transaction;
+ boolean changed = false;
+ // 分类覆盖写入:目标分类类型已在匹配阶段与交易类型校验一致
+ if (plan.targetCategoryId != null && !plan.targetCategoryId.equals(transaction.getCategoryId())) {
+ transactionMapper.update(null, new LambdaUpdateWrapper()
+ .eq(Transaction::getId, transaction.getId())
+ .eq(Transaction::getUserId, userId)
+ .set(Transaction::getCategoryId, plan.targetCategoryId));
+ changed = true;
+ }
+ // 标签追加:仅插入尚未关联的标签
+ Set existingTagIds = existingTagIdsByTransaction.getOrDefault(transaction.getId(), Set.of());
+ for (Long tagId : plan.targetTagIds) {
+ if (existingTagIds.contains(tagId)) {
+ continue;
+ }
+ TransactionTag relation = new TransactionTag();
+ relation.setTransactionId(transaction.getId());
+ relation.setTagId(tagId);
+ transactionTagMapper.insert(relation);
+ tagAdded++;
+ changed = true;
+ }
+ if (changed) {
+ updated++;
+ }
+ }
+ result.setUpdatedCount(updated);
+ result.setTagAddedCount(tagAdded);
+ log.info("规则应用到匹配账单完成: userId={}, 规则数={}, 命中={}, 变更={}, 新增标签={}",
+ userId, ruleIds.size(), plans.size(), updated, tagAdded);
+ return result;
+ }
+
+ /**
+ * 计算所选规则对当前用户全部交易的匹配计划。
+ *
+ * @param userId 当前用户ID
+ * @param ruleIds 目标规则ID列表
+ * @return 命中账单的匹配计划列表,按交易时间倒序
+ */
+ private List computeMatchPlans(Long userId, List ruleIds) {
+ List plans = new ArrayList<>();
+ if (ruleIds == null || ruleIds.isEmpty()) {
+ return plans;
+ }
+ // 仅处理归属当前用户的选中规则,按优先级排序
+ List distinctIds = ruleIds.stream()
+ .filter(Objects::nonNull)
+ .distinct()
+ .collect(Collectors.toList());
+ if (distinctIds.isEmpty()) {
+ return plans;
+ }
+ List rules = ruleMapper.selectList(new LambdaQueryWrapper()
+ .eq(Rule::getUserId, userId)
+ .in(Rule::getId, distinctIds)
+ .orderByAsc(Rule::getSortOrder)
+ .orderByAsc(Rule::getId));
+ if (rules.isEmpty()) {
+ return plans;
+ }
+
+ // 预加载规则标签关联,按规则分组
+ List selectedRuleIds = rules.stream().map(Rule::getId).collect(Collectors.toList());
+ Map> tagIdsByRule = new HashMap<>();
+ List relations = ruleTagMapper.selectList(new LambdaQueryWrapper()
+ .in(RuleTag::getRuleId, selectedRuleIds));
+ for (RuleTag relation : relations) {
+ tagIdsByRule.computeIfAbsent(relation.getRuleId(), key -> new ArrayList<>())
+ .add(relation.getTagId());
+ }
+ // 预加载用户分类,用于目标分类的类型一致性校验
+ Map categoryMap = loadUserCategories(userId);
+
+ // 仅加载匹配所需字段,避免读取 rawData 等敏感数据
+ List transactions = transactionMapper.selectList(new LambdaQueryWrapper()
+ .select(Transaction::getId, Transaction::getType, Transaction::getAmount,
+ Transaction::getCategoryId, Transaction::getRemark,
+ Transaction::getTransactionTime, Transaction::getChannel)
+ .eq(Transaction::getUserId, userId));
+ transactions.sort(Comparator.comparing(Transaction::getTransactionTime,
+ Comparator.nullsLast(Comparator.reverseOrder())));
+
+ for (Transaction transaction : transactions) {
+ String text = safe(transaction.getRemark()).toLowerCase();
+ List matchedRuleNames = new ArrayList<>();
+ Long targetCategoryId = null;
+ Set targetTagIds = new LinkedHashSet<>();
+ for (Rule rule : rules) {
+ // 收支类型限制
+ if (StringUtils.hasText(rule.getType()) && !rule.getType().equals(transaction.getType())) {
+ continue;
+ }
+ // 关键词包含匹配(已落库账单的备注包含对方与商品信息)
+ if (StringUtils.hasText(rule.getKeyword())
+ && !text.contains(rule.getKeyword().toLowerCase())) {
+ continue;
+ }
+ // 金额区间
+ if (rule.getMinAmount() != null
+ && (transaction.getAmount() == null
+ || transaction.getAmount().compareTo(rule.getMinAmount()) < 0)) {
+ continue;
+ }
+ if (rule.getMaxAmount() != null
+ && (transaction.getAmount() == null
+ || transaction.getAmount().compareTo(rule.getMaxAmount()) > 0)) {
+ continue;
+ }
+ matchedRuleNames.add(rule.getName());
+ // 分类取首个命中且类型一致的规则
+ if (targetCategoryId == null && rule.getCategoryId() != null) {
+ Category category = categoryMap.get(rule.getCategoryId());
+ if (category != null
+ && (transaction.getType() == null || transaction.getType().equals(category.getType()))) {
+ targetCategoryId = rule.getCategoryId();
+ }
+ }
+ targetTagIds.addAll(tagIdsByRule.getOrDefault(rule.getId(), List.of()));
+ }
+ if (matchedRuleNames.isEmpty()) {
+ continue;
+ }
+ MatchPlan plan = new MatchPlan();
+ plan.transaction = transaction;
+ plan.matchedRuleNames = matchedRuleNames;
+ plan.targetCategoryId = targetCategoryId;
+ plan.targetTagIds = targetTagIds;
+ plans.add(plan);
+ }
+ return plans;
+ }
+
+ /**
+ * 组装单条命中账单的预览视图对象。
+ *
+ * @param plan 匹配计划
+ * @param existingTagIds 账单当前标签ID集合
+ * @param categoryMap 用户分类映射
+ * @param tagMap 用户标签映射
+ * @return 命中账单视图对象
+ */
+ private RuleMatchedTransactionVO toMatchedVo(MatchPlan plan, Set existingTagIds,
+ Map categoryMap, Map tagMap) {
+ Transaction transaction = plan.transaction;
+ RuleMatchedTransactionVO vo = new RuleMatchedTransactionVO();
+ vo.setId(transaction.getId());
+ vo.setTransactionTime(transaction.getTransactionTime());
+ vo.setType(transaction.getType());
+ vo.setAmount(transaction.getAmount());
+ vo.setChannel(transaction.getChannel());
+ vo.setRemark(transaction.getRemark());
+ vo.setCurrentCategoryId(transaction.getCategoryId());
+ Category currentCategory = transaction.getCategoryId() == null
+ ? null : categoryMap.get(transaction.getCategoryId());
+ vo.setCurrentCategoryName(currentCategory == null ? null : currentCategory.getName());
+ vo.setCurrentTagNames(existingTagIds.stream()
+ .map(tagMap::get).filter(Objects::nonNull).map(Tag::getName).collect(Collectors.toList()));
+ vo.setTargetCategoryId(plan.targetCategoryId);
+ Category targetCategory = plan.targetCategoryId == null ? null : categoryMap.get(plan.targetCategoryId);
+ vo.setTargetCategoryName(targetCategory == null ? null : targetCategory.getName());
+ vo.setTargetTagNames(plan.targetTagIds.stream()
+ .map(tagMap::get).filter(Objects::nonNull).map(Tag::getName).collect(Collectors.toList()));
+ vo.setMatchedRuleNames(plan.matchedRuleNames);
+ return vo;
+ }
+
+ /**
+ * 加载当前用户的分类映射。
+ *
+ * @param userId 当前用户ID
+ * @return 分类ID到分类的映射
+ */
+ private Map loadUserCategories(Long userId) {
+ return categoryMapper.selectList(new LambdaQueryWrapper()
+ .eq(Category::getUserId, userId))
+ .stream().collect(Collectors.toMap(Category::getId, category -> category));
+ }
+
+ /**
+ * 加载当前用户的标签映射。
+ *
+ * @param userId 当前用户ID
+ * @return 标签ID到标签的映射
+ */
+ private Map loadUserTags(Long userId) {
+ return tagMapper.selectList(new LambdaQueryWrapper()
+ .eq(Tag::getUserId, userId))
+ .stream().collect(Collectors.toMap(Tag::getId, tag -> tag));
+ }
+
+ /**
+ * 批量加载交易的现有标签关联。
+ *
+ * @param transactionIds 交易ID列表
+ * @return 交易ID到标签ID集合的映射
+ */
+ private Map> loadTransactionTagIds(List transactionIds) {
+ Map> result = new HashMap<>();
+ if (transactionIds.isEmpty()) {
+ return result;
+ }
+ List relations = transactionTagMapper.selectList(new LambdaQueryWrapper()
+ .in(TransactionTag::getTransactionId, transactionIds));
+ for (TransactionTag relation : relations) {
+ result.computeIfAbsent(relation.getTransactionId(), key -> new LinkedHashSet<>())
+ .add(relation.getTagId());
+ }
+ return result;
+ }
+
+ /**
+ * 单条账单的匹配计划,承载命中规则与将应用的目标分类、标签。
+ */
+ private static class MatchPlan {
+
+ /** 命中的交易 */
+ private Transaction transaction;
+
+ /** 命中的规则名称列表 */
+ private List matchedRuleNames;
+
+ /** 将覆盖写入的目标分类ID,null 表示不修改分类 */
+ private Long targetCategoryId;
+
+ /** 将追加的目标标签ID集合 */
+ private Set targetTagIds;
+ }
+
/**
* 将请求字段写入规则实体,统一处理默认值。
*
diff --git a/src/main/java/cn/rosercode/ledgerly/vo/RuleApplyPreviewVO.java b/src/main/java/cn/rosercode/ledgerly/vo/RuleApplyPreviewVO.java
new file mode 100644
index 0000000..caa60f5
--- /dev/null
+++ b/src/main/java/cn/rosercode/ledgerly/vo/RuleApplyPreviewVO.java
@@ -0,0 +1,29 @@
+package cn.rosercode.ledgerly.vo;
+
+import java.util.List;
+
+import lombok.Data;
+
+/**
+ * 规则应用预览视图对象,汇总所选规则命中账单的统计信息与明细。
+ *
+ * @date 2026-10-13 22:09:05
+ */
+@Data
+public class RuleApplyPreviewVO {
+
+ /** 命中的账单总数 */
+ private int matchedCount;
+
+ /** 将会被覆盖分类的账单数 */
+ private int categoryChangeCount;
+
+ /** 将会新增标签关联的账单数 */
+ private int tagChangeCount;
+
+ /** 命中明细是否因超出展示上限被截断 */
+ private boolean truncated;
+
+ /** 命中账单明细(最多展示上限条) */
+ private List transactions;
+}
diff --git a/src/main/java/cn/rosercode/ledgerly/vo/RuleApplyResultVO.java b/src/main/java/cn/rosercode/ledgerly/vo/RuleApplyResultVO.java
new file mode 100644
index 0000000..869d842
--- /dev/null
+++ b/src/main/java/cn/rosercode/ledgerly/vo/RuleApplyResultVO.java
@@ -0,0 +1,21 @@
+package cn.rosercode.ledgerly.vo;
+
+import lombok.Data;
+
+/**
+ * 规则应用结果视图对象,返回一键应用规则到匹配账单后的变更统计。
+ *
+ * @date 2026-10-13 22:09:05
+ */
+@Data
+public class RuleApplyResultVO {
+
+ /** 命中的账单总数 */
+ private int matchedCount;
+
+ /** 实际发生变更(分类或标签)的账单数 */
+ private int updatedCount;
+
+ /** 新增的标签关联数 */
+ private int tagAddedCount;
+}
diff --git a/src/main/java/cn/rosercode/ledgerly/vo/RuleMatchedTransactionVO.java b/src/main/java/cn/rosercode/ledgerly/vo/RuleMatchedTransactionVO.java
new file mode 100644
index 0000000..951dd30
--- /dev/null
+++ b/src/main/java/cn/rosercode/ledgerly/vo/RuleMatchedTransactionVO.java
@@ -0,0 +1,57 @@
+package cn.rosercode.ledgerly.vo;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+import lombok.Data;
+
+/**
+ * 规则命中的交易视图对象,用于预览一键应用规则时的目标账单。
+ *
+ * 同时给出账单当前分类/标签与应用后将写入的目标分类/标签,便于用户确认。
+ *
+ * @date 2026-10-13 22:09:05
+ */
+@Data
+public class RuleMatchedTransactionVO {
+
+ /** 交易ID */
+ private Long id;
+
+ /** 交易时间 */
+ private LocalDateTime transactionTime;
+
+ /** 收支类型:INCOME-收入,EXPENSE-支出 */
+ private String type;
+
+ /** 交易金额 */
+ private BigDecimal amount;
+
+ /** 交易渠道编码 */
+ private String channel;
+
+ /** 备注 */
+ private String remark;
+
+ /** 当前分类ID */
+ private Long currentCategoryId;
+
+ /** 当前分类名称 */
+ private String currentCategoryName;
+
+ /** 当前标签名称列表 */
+ private List currentTagNames;
+
+ /** 应用后的目标分类ID,null 表示不修改分类 */
+ private Long targetCategoryId;
+
+ /** 应用后的目标分类名称 */
+ private String targetCategoryName;
+
+ /** 应用后追加的目标标签名称列表 */
+ private List targetTagNames;
+
+ /** 命中的规则名称列表,按优先级排列 */
+ private List matchedRuleNames;
+}