diff --git a/frontend/src/api/ai.ts b/frontend/src/api/ai.ts index c405398..47421a5 100644 --- a/frontend/src/api/ai.ts +++ b/frontend/src/api/ai.ts @@ -205,6 +205,11 @@ export function applyAiRules(rules: AiRuleApplyItem[]) { return request.post('/ai/rule/apply', { rules }) } +/** 校验编辑后的规则是否与已有规则或批内其它规则重复,返回逐条重复标记 */ +export function checkRuleDuplicates(rules: AiRuleApplyItem[]) { + return request.post('/ai/rule/check-duplicates', { rules }) +} + /** 将规则按类型/关键词/金额匹配并应用到指定交易,命中则覆盖分类、追加标签 */ export function applyRulesToTransactions(transactionIds: number[], rules: AiRuleApplyItem[]) { return request.post('/ai/rule/apply-transactions', { transactionIds, rules }) diff --git a/frontend/src/api/rule.ts b/frontend/src/api/rule.ts index fd24700..0595131 100644 --- a/frontend/src/api/rule.ts +++ b/frontend/src/api/rule.ts @@ -82,3 +82,46 @@ export function batchDeleteRules(ids: number[]) { export function toggleRuleEnabled(id: number, enabled: boolean) { return request.put(`/rule/${id}/enabled`, { enabled }) } + +/** 规则命中的账单(用于应用预览) */ +export interface RuleMatchedTransaction { + id: number + transactionTime: string | null + type: string + amount: number + channel: string + remark: string | null + currentCategoryId: number | null + currentCategoryName: string | null + currentTagNames: string[] + targetCategoryId: number | null + targetCategoryName: string | null + targetTagNames: string[] + matchedRuleNames: string[] +} + +/** 规则应用预览结果 */ +export interface RuleApplyPreview { + matchedCount: number + categoryChangeCount: number + tagChangeCount: number + truncated: boolean + transactions: RuleMatchedTransaction[] +} + +/** 规则应用结果统计 */ +export interface RuleApplyResult { + matchedCount: number + updatedCount: number + tagAddedCount: number +} + +/** 预览所选规则命中的历史账单(分类与标签目标) */ +export function getRuleApplyPreview(ruleIds: number[]) { + return request.post('/rule/apply-preview', { ruleIds }) +} + +/** 一键将所选规则的分类与标签应用到其命中的历史账单 */ +export function applyRulesToMatchedTransactions(ruleIds: number[]) { + return request.post('/rule/apply', { ruleIds }) +} diff --git a/frontend/src/components/AiRuleEditorTable.vue b/frontend/src/components/AiRuleEditorTable.vue new file mode 100644 index 0000000..cc8da6c --- /dev/null +++ b/frontend/src/components/AiRuleEditorTable.vue @@ -0,0 +1,413 @@ + + + diff --git a/frontend/src/components/AiRuleSuggestModal.vue b/frontend/src/components/AiRuleSuggestModal.vue index d4c1b47..2ec386b 100644 --- a/frontend/src/components/AiRuleSuggestModal.vue +++ b/frontend/src/components/AiRuleSuggestModal.vue @@ -1,19 +1,34 @@ + + + diff --git a/frontend/src/views/TransactionList.vue b/frontend/src/views/TransactionList.vue index c55792b..98e3293 100644 --- a/frontend/src/views/TransactionList.vue +++ b/frontend/src/views/TransactionList.vue @@ -326,6 +326,9 @@ const categoryFilterOptions = computed(() => [ // 标签筛选项 const tagFilterOptions = computed(() => allTags.value.map((tag) => ({ label: tag.name, value: tag.id }))) +// AI 规则编辑用的标签选项(与筛选一致,供规则弹窗编辑分类与标签) +const ruleTagOptions = computed(() => allTags.value.map((tag) => ({ label: tag.name, value: tag.id }))) + // 标签筛选下拉项:额外提供「无标签」选项(仅筛选使用,编辑/批量选择不包含) const tagFilterSelectOptions = computed(() => [ { label: t('transaction.untagged'), value: 0 }, @@ -2297,6 +2300,8 @@ onMounted(() => { :suggestions="aiRuleSuggestions" :target-count="checkedTransactionIds.length" :applying="aiRuleApplying" + :categories="categories" + :tags="ruleTagOptions" @submit="handleAiRuleSubmit" /> diff --git a/src/main/java/cn/rosercode/ledgerly/controller/AiController.java b/src/main/java/cn/rosercode/ledgerly/controller/AiController.java index 2e7863f..e2de4b4 100644 --- a/src/main/java/cn/rosercode/ledgerly/controller/AiController.java +++ b/src/main/java/cn/rosercode/ledgerly/controller/AiController.java @@ -31,6 +31,7 @@ import cn.rosercode.ledgerly.dto.AiQueryRequest; import cn.rosercode.ledgerly.dto.AiRecordRequest; import cn.rosercode.ledgerly.dto.AiRelationRequest; import cn.rosercode.ledgerly.dto.AiRuleApplyRequest; +import cn.rosercode.ledgerly.dto.AiRuleDuplicateCheckRequest; import cn.rosercode.ledgerly.dto.AiRuleSuggestRequest; import cn.rosercode.ledgerly.dto.AiRuleTransactionApplyRequest; import cn.rosercode.ledgerly.dto.AiSuggestOneRequest; @@ -494,6 +495,19 @@ public class AiController { return Result.success(aiRuleService.applyToTransactions(userId, request)); } + /** + * 校验编辑后的规则是否与已有规则或批内其它规则重复 + * + * @param request 待校验的规则定义列表 + * @return 与入参同序的重复标记列表 + */ + @PostMapping("/rule/check-duplicates") + @Operation(summary = "AI 规则去重校验", description = "校验编辑后的规则是否与已有规则或批内其它规则重复,返回逐条重复标记") + public Result> checkRuleDuplicates(@RequestBody @Valid AiRuleDuplicateCheckRequest request) { + Long userId = UserContext.getUserId(); + return Result.success(aiRuleService.checkDuplicates(userId, request.getRules())); + } + /** * 将自然语言统计查询解析为统计面板参数 * diff --git a/src/main/java/cn/rosercode/ledgerly/controller/RuleController.java b/src/main/java/cn/rosercode/ledgerly/controller/RuleController.java index 7174377..e40900b 100644 --- a/src/main/java/cn/rosercode/ledgerly/controller/RuleController.java +++ b/src/main/java/cn/rosercode/ledgerly/controller/RuleController.java @@ -19,10 +19,13 @@ import org.springframework.web.bind.annotation.RestController; import cn.rosercode.ledgerly.common.PageResult; import cn.rosercode.ledgerly.common.Result; import cn.rosercode.ledgerly.dto.BatchDeleteRequest; +import cn.rosercode.ledgerly.dto.RuleApplyRequest; import cn.rosercode.ledgerly.dto.RuleQuery; import cn.rosercode.ledgerly.dto.RuleRequest; import cn.rosercode.ledgerly.service.RuleService; import cn.rosercode.ledgerly.util.UserContext; +import cn.rosercode.ledgerly.vo.RuleApplyPreviewVO; +import cn.rosercode.ledgerly.vo.RuleApplyResultVO; import cn.rosercode.ledgerly.vo.RuleVO; /** @@ -141,6 +144,32 @@ public class RuleController { return Result.success(); } + /** + * 预览所选规则命中的历史账单 + * + * @param body 待应用的规则ID列表 + * @return 命中账单统计与明细 + */ + @PostMapping("/apply-preview") + @Operation(summary = "预览规则匹配账单", description = "按所选规则条件匹配当前用户的历史账单,返回将应用的目标分类与标签") + public Result applyPreview(@RequestBody @Valid RuleApplyRequest body) { + Long userId = UserContext.getUserId(); + return Result.success(ruleService.previewApply(userId, body.getRuleIds())); + } + + /** + * 一键将所选规则的分类与标签应用到其命中的历史账单 + * + * @param body 待应用的规则ID列表 + * @return 命中与变更统计 + */ + @PostMapping("/apply") + @Operation(summary = "应用规则到匹配账单", description = "将所选规则的分类与标签应用到其命中的历史账单,分类覆盖、标签追加") + public Result apply(@RequestBody @Valid RuleApplyRequest body) { + Long userId = UserContext.getUserId(); + return Result.success(ruleService.applyToMatchedTransactions(userId, body.getRuleIds())); + } + /** * 启用状态请求体 */ diff --git a/src/main/java/cn/rosercode/ledgerly/dto/AiRuleDuplicateCheckRequest.java b/src/main/java/cn/rosercode/ledgerly/dto/AiRuleDuplicateCheckRequest.java new file mode 100644 index 0000000..e195884 --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/dto/AiRuleDuplicateCheckRequest.java @@ -0,0 +1,23 @@ +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 22:40:47 + */ +@Data +public class AiRuleDuplicateCheckRequest { + + /** 待校验的规则列表,不能为空 */ + @NotEmpty(message = "请至少提供一条规则") + @Valid + private List rules; +} diff --git a/src/main/java/cn/rosercode/ledgerly/dto/RuleApplyRequest.java b/src/main/java/cn/rosercode/ledgerly/dto/RuleApplyRequest.java new file mode 100644 index 0000000..d9f4a22 --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/dto/RuleApplyRequest.java @@ -0,0 +1,21 @@ +package cn.rosercode.ledgerly.dto; + +import java.util.List; + +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +/** + * 规则应用请求,携带待应用的规则ID列表。 + * + *

用于预览或一键将所选规则的分类与标签应用到其命中的历史账单。

+ * + * @date 2026-10-13 22:09:05 + */ +@Data +public class RuleApplyRequest { + + /** 待应用的规则ID列表,不能为空 */ + @NotEmpty(message = "请至少选择一条规则") + private List ruleIds; +} diff --git a/src/main/java/cn/rosercode/ledgerly/service/AiRuleService.java b/src/main/java/cn/rosercode/ledgerly/service/AiRuleService.java index fed70b4..90ef48b 100644 --- a/src/main/java/cn/rosercode/ledgerly/service/AiRuleService.java +++ b/src/main/java/cn/rosercode/ledgerly/service/AiRuleService.java @@ -1,5 +1,7 @@ package cn.rosercode.ledgerly.service; +import java.util.List; + import cn.rosercode.ledgerly.dto.AiRuleApplyRequest; import cn.rosercode.ledgerly.dto.AiRuleSuggestRequest; import cn.rosercode.ledgerly.dto.AiRuleTransactionApplyRequest; @@ -41,4 +43,15 @@ public interface AiRuleService { * @return 实际发生变更的记录数 */ int applyToTransactions(Long userId, AiRuleTransactionApplyRequest request); + + /** + * 校验编辑后的规则是否与已有规则或批内其它规则重复。 + * + *

纯本地判定,不调用大模型:签名一致即视为重复,批内重复保留首条。

+ * + * @param userId 当前用户ID + * @param items 待校验的规则定义列表 + * @return 与入参同序的重复标记列表 + */ + List checkDuplicates(Long userId, List items); } diff --git a/src/main/java/cn/rosercode/ledgerly/service/RuleService.java b/src/main/java/cn/rosercode/ledgerly/service/RuleService.java index 3c56fff..049b82e 100644 --- a/src/main/java/cn/rosercode/ledgerly/service/RuleService.java +++ b/src/main/java/cn/rosercode/ledgerly/service/RuleService.java @@ -5,6 +5,8 @@ import java.util.List; import cn.rosercode.ledgerly.common.PageResult; import cn.rosercode.ledgerly.dto.RuleQuery; import cn.rosercode.ledgerly.dto.RuleRequest; +import cn.rosercode.ledgerly.vo.RuleApplyPreviewVO; +import cn.rosercode.ledgerly.vo.RuleApplyResultVO; import cn.rosercode.ledgerly.vo.RuleVO; /** @@ -87,4 +89,27 @@ public interface RuleService { * @return 与入参一一对应的匹配结果列表 */ List matchBatch(Long userId, List 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; +}