From 863e2212201d66877f81b00199f84e34281809d1 Mon Sep 17 00:00:00 2001 From: rosercode Date: Fri, 9 Oct 2026 14:29:00 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=E4=BA=A4?= =?UTF-8?q?=E6=98=93=E8=AE=B0=E5=BD=95=E5=89=8D=E5=90=8E=E7=AB=AF=E5=B7=A8?= =?UTF-8?q?=E5=9E=8B=E6=96=87=E4=BB=B6=E5=B9=B6=E4=BC=98=E5=8C=96=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E4=B8=8E=E5=AF=BC=E5=87=BA=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 TransactionServiceImpl(922 行)与 TransactionList.vue(2811 行)按职责拆分为多个协作组件,对外接口与既有行为保持兼容,并在拆分后的组件中修复批量创建 N+1 查询与导出全量加载的内存风险。 变更内容: - 后端拆分:TransactionServiceImpl 作为门面保留 TransactionService 契约,抽出 TransactionQuerySupport(查询条件构建)、TransactionVoEnricher(视图富化)、TransactionTagWriter(标签写入)、TransactionBatchService(批量操作)与 exporter.TransactionExportAssembler(导出组装) - 前端拆分:TransactionList.vue 抽出 AiRecordModal、AiSuggestModal、TransactionBatchModal 三个弹窗组件与 utils/datetime 工具,并将本地 AI 启用状态迁移到共享 useAiEnabled - 性能:batchCreate 预加载分类消除循环内 N+1 并改为批量写入标签关联;export 增加 1 万条上限保护,超限提示缩小筛选范围 --- frontend/src/components/AiRecordModal.vue | 562 +++++++++++ frontend/src/components/AiSuggestModal.vue | 235 +++++ .../src/components/TransactionBatchModal.vue | 159 +++ frontend/src/utils/datetime.ts | 30 + frontend/src/views/TransactionList.vue | 928 ++---------------- .../exporter/TransactionExportAssembler.java | 81 ++ .../service/impl/TransactionBatchService.java | 237 +++++ .../service/impl/TransactionQuerySupport.java | 141 +++ .../service/impl/TransactionServiceImpl.java | 631 ++---------- .../service/impl/TransactionTagWriter.java | 137 +++ .../service/impl/TransactionVoEnricher.java | 165 ++++ 11 files changed, 1867 insertions(+), 1439 deletions(-) create mode 100644 frontend/src/components/AiRecordModal.vue create mode 100644 frontend/src/components/AiSuggestModal.vue create mode 100644 frontend/src/components/TransactionBatchModal.vue create mode 100644 frontend/src/utils/datetime.ts create mode 100644 src/main/java/cn/rosercode/ledgerly/exporter/TransactionExportAssembler.java create mode 100644 src/main/java/cn/rosercode/ledgerly/service/impl/TransactionBatchService.java create mode 100644 src/main/java/cn/rosercode/ledgerly/service/impl/TransactionQuerySupport.java create mode 100644 src/main/java/cn/rosercode/ledgerly/service/impl/TransactionTagWriter.java create mode 100644 src/main/java/cn/rosercode/ledgerly/service/impl/TransactionVoEnricher.java diff --git a/frontend/src/components/AiRecordModal.vue b/frontend/src/components/AiRecordModal.vue new file mode 100644 index 0000000..0fe85d8 --- /dev/null +++ b/frontend/src/components/AiRecordModal.vue @@ -0,0 +1,562 @@ + + + + + diff --git a/frontend/src/components/AiSuggestModal.vue b/frontend/src/components/AiSuggestModal.vue new file mode 100644 index 0000000..0c053e8 --- /dev/null +++ b/frontend/src/components/AiSuggestModal.vue @@ -0,0 +1,235 @@ + + + diff --git a/frontend/src/components/TransactionBatchModal.vue b/frontend/src/components/TransactionBatchModal.vue new file mode 100644 index 0000000..30e1b59 --- /dev/null +++ b/frontend/src/components/TransactionBatchModal.vue @@ -0,0 +1,159 @@ + + + diff --git a/frontend/src/utils/datetime.ts b/frontend/src/utils/datetime.ts new file mode 100644 index 0000000..fa4cd13 --- /dev/null +++ b/frontend/src/utils/datetime.ts @@ -0,0 +1,30 @@ +// 日期时间格式化工具:统一交易记录页的时间展示与接口参数格式 + +/** 将时间戳格式化为查询边界(YYYY-MM-DDTHH:mm:ss),isEnd=true 时取当天 23:59:59 */ +export function formatIsoBoundary(ts: number, isEnd = false): string { + const d = new Date(ts) + const pad = (n: number) => String(n).padStart(2, '0') + const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + const time = isEnd ? '23:59:59' : '00:00:00' + return `${date}T${time}` +} + +/** 将时间戳格式化为本地日期时间(YYYY-MM-DDTHH:mm:ss),避免 UTC 转换导致时区偏移 */ +export function formatLocalDateTime(ts: number): string { + const d = new Date(ts) + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +} + +/** 将时间戳格式化为 yyyy-MM-dd */ +export function formatDateLabel(ts: number): string { + const d = new Date(ts) + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} + +/** 将时间格式化为 HH:mm:ss,用于进度日志前缀 */ +export function formatProgressTime(date: Date): string { + const pad = (value: number) => String(value).padStart(2, '0') + return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +} diff --git a/frontend/src/views/TransactionList.vue b/frontend/src/views/TransactionList.vue index 95823ea..7bb60e3 100644 --- a/frontend/src/views/TransactionList.vue +++ b/frontend/src/views/TransactionList.vue @@ -1,15 +1,15 @@ @@ -2521,177 +1913,39 @@ onBeforeUnmount(() => { @changed="fetchData" /> - - - - - - - - - - {{ batchFromSelection - ? t('transaction.batchScopeHintSelected', { count: batchTargets.length }) - : t('transaction.batchScopeHintPage', { count: batchTargets.length }) }} - - - -
- - {{ t('ai.suggestButton') }} -
-
- - - - - - - - - - - - - - - - {{ t('common.confirm') }} -
-
+ - - - {{ t('ai.applyHint') }} - - - {{ t('common.cancel') }} - - {{ t('ai.applyAll', { count: aiTotalCount }) }} - - - - + - - - - - {{ t('ai.record.hint') }} - - - {{ t('common.cancel') }} - {{ t('ai.record.parse') }} - - - - - - {{ t('ai.record.imageHint') }} - - - -
{{ t('ai.record.upload') }}
-
{{ t('ai.record.uploadTip') }}
-
{{ t('ai.record.pasteHint') }}
-
-
- - -
-
- - {{ t('ai.record.progressTitle') }} -
-
-
- - {{ item.time }} - {{ item.text }} -
-
-
- - {{ t('common.cancel') }} - - {{ t('ai.record.parseImage') }} - - -
-
-
-
- - - - - - {{ aiRecordSummary || t('ai.record.resultHint') }} - - - - {{ t('common.cancel') }} - - {{ t('ai.record.importSelected', { count: checkedAiRecordKeys.length }) }} - - - - + { :deep(.excluded-row .remark-text) { text-decoration: line-through; } -/* 图片识别进度:浅色卡片 + 步骤列表 */ -.ai-progress { - background: #f7f9fc; - border: 1px solid #eef1f6; - border-radius: 8px; - padding: 10px 12px; -} -.ai-progress-title { - display: flex; - align-items: center; - gap: 6px; - margin-bottom: 8px; - font-size: 12px; - font-weight: 600; - color: #666; -} -.ai-progress-list { - display: flex; - flex-direction: column; - gap: 6px; - max-height: 180px; - overflow-y: auto; -} -.ai-progress-item { - display: flex; - align-items: flex-start; - gap: 6px; - font-size: 13px; - line-height: 1.5; - color: #555; -} -.ai-progress-icon { - margin-top: 2px; - flex-shrink: 0; - color: #18a058; -} -/* 最新一条处理中时用蓝色旋转图标 */ -.ai-progress-item.is-running .ai-progress-icon { - color: #2080f0; - animation: ai-progress-spin 1s linear infinite; -} -.ai-progress-time { - flex-shrink: 0; - margin-top: 1px; - font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; - font-size: 12px; - color: #a0a4ab; -} -.ai-progress-text { - word-break: break-all; -} -@keyframes ai-progress-spin { - to { - transform: rotate(360deg); - } -} diff --git a/src/main/java/cn/rosercode/ledgerly/exporter/TransactionExportAssembler.java b/src/main/java/cn/rosercode/ledgerly/exporter/TransactionExportAssembler.java new file mode 100644 index 0000000..ecf2a3f --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/exporter/TransactionExportAssembler.java @@ -0,0 +1,81 @@ +package cn.rosercode.ledgerly.exporter; + +import java.time.format.DateTimeFormatter; +import java.util.Map; + +import org.springframework.stereotype.Component; + +import cn.rosercode.ledgerly.entity.Category; +import cn.rosercode.ledgerly.entity.Transaction; +import cn.rosercode.ledgerly.enums.ChannelType; +import cn.rosercode.ledgerly.enums.TransactionType; +import cn.rosercode.ledgerly.vo.TransactionExportVO; + +/** + * 交易记录导出视图组装器。 + * + *

将交易实体与关联分类转换为导出视图对象,并处理类型文案、金额格式化、 + * 渠道本地化与隐藏备注占位。

+ * + * @date 2026-10-09 10:55:53 + */ +@Component +public class TransactionExportAssembler { + + /** 备注被隐藏时在导出文件中使用的占位符 */ + private static final String REMARK_HIDDEN_PLACEHOLDER = "******"; + + /** + * 将交易实体和关联分类转换为导出视图对象。 + * + * @param t 交易实体 + * @param category 关联的分类实体(可为 null) + * @param channelNames 自定义渠道「编码 → 名称」映射 + * @return 交易导出视图对象 + */ + public TransactionExportVO toExportVO(Transaction t, Category category, Map channelNames) { + TransactionExportVO vo = new TransactionExportVO(); + Boolean include = t.getIncludeInStats() == null ? Boolean.TRUE : t.getIncludeInStats(); + if (include) { + vo.setType(TransactionType.labelOf(t.getType())); + } else { + vo.setType("不计入统计"); + } + vo.setCategoryName(category != null ? category.getName() : ""); + vo.setAmount(String.format("%.2f", t.getAmount())); + vo.setChannel(resolveChannelLabel(t.getChannel(), channelNames)); + // 备注被标记为隐藏时,导出同样以占位符替代,避免泄露 + if (Boolean.TRUE.equals(t.getRemarkHidden())) { + vo.setRemark(REMARK_HIDDEN_PLACEHOLDER); + } else { + vo.setRemark(t.getRemark() != null ? t.getRemark() : ""); + } + vo.setTransactionTime(t.getTransactionTime() != null + ? t.getTransactionTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + : ""); + vo.setIncludeInStats(include ? "是" : "否"); + return vo; + } + + /** + * 将交易渠道编码转换为中文标签,优先取用户自定义渠道名称。 + * + * @param code 交易渠道编码 + * @param channelNames 自定义渠道「编码 → 名称」映射 + * @return 中文标签 + */ + private String resolveChannelLabel(String code, Map channelNames) { + if (code == null) { + return ""; + } + String custom = channelNames.get(code); + if (custom != null) { + return custom; + } + // 兼容历史 BANK 渠道 + if ("BANK".equals(code)) { + return "银行卡"; + } + return ChannelType.labelOf(code); + } +} diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/TransactionBatchService.java b/src/main/java/cn/rosercode/ledgerly/service/impl/TransactionBatchService.java new file mode 100644 index 0000000..e52fa52 --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/service/impl/TransactionBatchService.java @@ -0,0 +1,237 @@ +package cn.rosercode.ledgerly.service.impl; + +import java.util.List; +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 lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import cn.rosercode.ledgerly.common.BusinessException; +import cn.rosercode.ledgerly.dto.BatchCategoryRequest; +import cn.rosercode.ledgerly.dto.BatchTagRequest; +import cn.rosercode.ledgerly.entity.Category; +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.TransactionMapper; +import cn.rosercode.ledgerly.mapper.TransactionTagMapper; +import cn.rosercode.ledgerly.service.TransactionGroupService; + +/** + * 交易记录批量操作组件。 + * + *

承载批量删除、批量设置分类/计入统计与批量设置标签,均按当前用户隔离。

+ * + * @date 2026-10-09 10:55:53 + */ +@Service +@Slf4j +public class TransactionBatchService { + + private final TransactionMapper transactionMapper; + private final CategoryMapper categoryMapper; + private final TransactionTagMapper transactionTagMapper; + private final TransactionGroupService transactionGroupService; + private final TransactionTagWriter tagWriter; + + public TransactionBatchService(TransactionMapper transactionMapper, + CategoryMapper categoryMapper, + TransactionTagMapper transactionTagMapper, + TransactionGroupService transactionGroupService, + TransactionTagWriter tagWriter) { + this.transactionMapper = transactionMapper; + this.categoryMapper = categoryMapper; + this.transactionTagMapper = transactionTagMapper; + this.transactionGroupService = transactionGroupService; + this.tagWriter = tagWriter; + } + + /** + * 仅删除归属当前用户的记录,先清理标签关联再批量删除,返回实际删除数。 + * + * @param userId 用户ID + * @param ids 待删除记录ID列表 + * @return 实际删除的记录数 + */ + @Transactional + public int batchDelete(Long userId, List ids) { + if (ids == null || ids.isEmpty()) { + return 0; + } + // 仅保留属于当前用户的记录ID + List ownedIds = transactionMapper.selectList(new LambdaQueryWrapper() + .select(Transaction::getId) + .eq(Transaction::getUserId, userId) + .in(Transaction::getId, ids)) + .stream() + .map(Transaction::getId) + .collect(Collectors.toList()); + if (ownedIds.isEmpty()) { + return 0; + } + // 先清理标签关联,再批量删除记录 + tagWriter.deleteByTransactionIds(ownedIds); + transactionGroupService.removeTransactionFromGroups(userId, ownedIds); + int deleted = transactionMapper.deleteByIds(ownedIds); + log.info("批量删除交易记录: userId={}, 请求={}, 实际={}", userId, ids.size(), deleted); + return deleted; + } + + /** + * 按交易类型分别批量设置分类:仅更新与所选分类类型一致、且属于当前用户的记录。 + * + * @param userId 用户ID + * @param request 批量设置请求 + * @return 实际更新的记录数 + */ + @Transactional + public int batchUpdateCategory(Long userId, BatchCategoryRequest request) { + List ids = request.getIds(); + if (ids == null || ids.isEmpty()) { + throw new BusinessException("请至少选择一条记录"); + } + if (ids.size() > 1000) { + throw new BusinessException("单次最多更新1000条记录"); + } + + // 分类与计入统计均为可选,仅在提供分类时校验归属与类型 + Category expenseCategory = request.getExpenseCategoryId() == null ? null + : requireCategory(userId, request.getExpenseCategoryId(), TransactionType.EXPENSE.name()); + Category incomeCategory = request.getIncomeCategoryId() == null ? null + : requireCategory(userId, request.getIncomeCategoryId(), TransactionType.INCOME.name()); + Boolean includeInStats = request.getIncludeInStats(); + if (expenseCategory == null && incomeCategory == null && includeInStats == null) { + throw new BusinessException("请至少选择分类或计入统计"); + } + + // 查询待更新记录,按交易类型拆分ID,避免类型不匹配的更新 + List transactions = transactionMapper.selectList(new LambdaQueryWrapper() + .select(Transaction::getId, Transaction::getType) + .eq(Transaction::getUserId, userId) + .in(Transaction::getId, ids)); + List expenseIds = transactions.stream() + .filter(t -> TransactionType.EXPENSE.name().equals(t.getType())) + .map(Transaction::getId) + .collect(Collectors.toList()); + List incomeIds = transactions.stream() + .filter(t -> TransactionType.INCOME.name().equals(t.getType())) + .map(Transaction::getId) + .collect(Collectors.toList()); + + // 按类型分组,同组内一次写入分类与计入统计,避免重复计数 + int updated = 0; + updated += batchUpdate(userId, expenseIds, expenseCategory, includeInStats); + updated += batchUpdate(userId, incomeIds, incomeCategory, includeInStats); + log.info("批量设置分类: userId={}, 请求={}, 实际={}", userId, ids.size(), updated); + return updated; + } + + /** + * 仅对属于当前用户的记录生效;先移除指定标签,再补充尚未存在的标签。 + * + * @param userId 用户ID + * @param request 批量设置标签请求 + * @return 实际处理的记录数 + */ + @Transactional + public int batchTags(Long userId, BatchTagRequest request) { + List ids = request.getIds(); + if (ids == null || ids.isEmpty()) { + throw new BusinessException("请至少选择一条记录"); + } + List addTagIds = tagWriter.validTagIds(userId, request.getAddTagIds()); + List removeTagIds = tagWriter.validTagIds(userId, request.getRemoveTagIds()); + if (addTagIds.isEmpty() && removeTagIds.isEmpty()) { + throw new BusinessException("请至少选择要添加或移除的标签"); + } + + // 仅处理属于当前用户的记录 + List validIds = transactionMapper.selectList(new LambdaQueryWrapper() + .select(Transaction::getId) + .eq(Transaction::getUserId, userId) + .in(Transaction::getId, ids)) + .stream().map(Transaction::getId).collect(Collectors.toList()); + if (validIds.isEmpty()) { + return 0; + } + + // 移除标签关联 + if (!removeTagIds.isEmpty()) { + transactionTagMapper.delete(new LambdaQueryWrapper() + .in(TransactionTag::getTransactionId, validIds) + .in(TransactionTag::getTagId, removeTagIds)); + } + // 添加标签关联,跳过已存在的组合 + if (!addTagIds.isEmpty()) { + List existing = transactionTagMapper.selectList(new LambdaQueryWrapper() + .in(TransactionTag::getTransactionId, validIds) + .in(TransactionTag::getTagId, addTagIds)); + Set existingKeys = existing.stream() + .map(rel -> rel.getTransactionId() + ":" + rel.getTagId()) + .collect(Collectors.toSet()); + for (Long transactionId : validIds) { + for (Long tagId : addTagIds) { + if (existingKeys.contains(transactionId + ":" + tagId)) { + continue; + } + TransactionTag relation = new TransactionTag(); + relation.setTransactionId(transactionId); + relation.setTagId(tagId); + transactionTagMapper.insert(relation); + } + } + } + log.info("批量设置标签: userId={}, 记录={}, 添加={}, 移除={}", + userId, validIds.size(), addTagIds.size(), removeTagIds.size()); + return validIds.size(); + } + + /** + * 对指定记录批量写入分类与计入统计标记。 + * + * @param userId 用户ID + * @param ids 待更新记录ID列表 + * @param category 目标分类,为 null 时不修改分类 + * @param includeInStats 计入统计标记,为 null 时不修改 + * @return 实际更新的记录数 + */ + private int batchUpdate(Long userId, List ids, Category category, Boolean includeInStats) { + if (ids.isEmpty() || (category == null && includeInStats == null)) { + return 0; + } + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper() + .eq(Transaction::getUserId, userId) + .in(Transaction::getId, ids); + if (category != null) { + wrapper.set(Transaction::getCategoryId, category.getId()); + } + if (includeInStats != null) { + wrapper.set(Transaction::getIncludeInStats, includeInStats); + } + return transactionMapper.update(null, wrapper); + } + + /** + * 校验分类是否存在、归属当前用户且类型匹配。 + * + * @param userId 用户ID + * @param categoryId 分类ID + * @param type 期望的分类类型 + * @return 分类实体 + */ + private Category requireCategory(Long userId, Long categoryId, String type) { + Category category = categoryMapper.selectById(categoryId); + if (category == null || !category.getUserId().equals(userId)) { + throw new BusinessException("分类不存在"); + } + if (!type.equals(category.getType())) { + throw new BusinessException("分类与交易类型不匹配"); + } + return category; + } +} diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/TransactionQuerySupport.java b/src/main/java/cn/rosercode/ledgerly/service/impl/TransactionQuerySupport.java new file mode 100644 index 0000000..a1128c1 --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/service/impl/TransactionQuerySupport.java @@ -0,0 +1,141 @@ +package cn.rosercode.ledgerly.service.impl; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.stream.Collectors; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import cn.rosercode.ledgerly.dto.TransactionQuery; +import cn.rosercode.ledgerly.entity.Transaction; +import cn.rosercode.ledgerly.service.UserPreferenceService; + +/** + * 交易记录查询条件构建组件。 + * + *

将分页查询与导出共用的筛选条件拼装逻辑抽取于此,避免两处重复维护: + * 包含收支类型、分类(含未分类)、时间范围、渠道、计入统计、金额区间、 + * 备注关键字、备注隐藏偏好与标签(含标签/无标签/未打标签)。

+ * + * @date 2026-10-09 10:55:53 + */ +@Component +public class TransactionQuerySupport { + + private final UserPreferenceService userPreferenceService; + + public TransactionQuerySupport(UserPreferenceService userPreferenceService) { + this.userPreferenceService = userPreferenceService; + } + + /** + * 根据查询参数构建交易记录的筛选条件。 + * + * @param userId 用户ID + * @param query 查询参数 + * @return 查询条件包装器 + */ + public LambdaQueryWrapper buildQueryWrapper(Long userId, TransactionQuery query) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(Transaction::getUserId, userId); + + // 按交易类型筛选(多选) + if (query.getTypes() != null && !query.getTypes().isEmpty()) { + wrapper.in(Transaction::getType, query.getTypes()); + } + // 按分类ID筛选(多选);同时勾选「未分类」时与其取并集 + boolean hasCategoryIds = query.getCategoryIds() != null && !query.getCategoryIds().isEmpty(); + boolean uncategorized = Boolean.TRUE.equals(query.getUncategorized()); + if (hasCategoryIds && uncategorized) { + wrapper.and(w -> w.in(Transaction::getCategoryId, query.getCategoryIds()) + .or().isNull(Transaction::getCategoryId)); + } else if (hasCategoryIds) { + wrapper.in(Transaction::getCategoryId, query.getCategoryIds()); + } else if (uncategorized) { + wrapper.isNull(Transaction::getCategoryId); + } + // 按起始时间筛选 + if (StringUtils.hasText(query.getStartTime())) { + wrapper.ge(Transaction::getTransactionTime, + LocalDateTime.parse(query.getStartTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + } + // 按结束时间筛选 + if (StringUtils.hasText(query.getEndTime())) { + wrapper.le(Transaction::getTransactionTime, + LocalDateTime.parse(query.getEndTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + } + // 按交易渠道筛选(多选) + if (query.getChannels() != null && !query.getChannels().isEmpty()) { + wrapper.in(Transaction::getChannel, query.getChannels()); + } + // 按是否计入统计筛选 + if (query.getIncludeInStats() != null) { + wrapper.eq(Transaction::getIncludeInStats, query.getIncludeInStats()); + } + // 按金额区间筛选 + if (query.getMinAmount() != null) { + wrapper.ge(Transaction::getAmount, query.getMinAmount()); + } + if (query.getMaxAmount() != null) { + wrapper.le(Transaction::getAmount, query.getMaxAmount()); + } + // 按备注关键字模糊匹配 + if (StringUtils.hasText(query.getRemark())) { + wrapper.like(Transaction::getRemark, query.getRemark()); + } + // 是否显示已隐藏备注的记录由用户偏好控制,默认不显示 + if (!userPreferenceService.getShowHiddenData(userId)) { + wrapper.and(w -> w.isNull(Transaction::getRemarkHidden) + .or().eq(Transaction::getRemarkHidden, false)); + } + // 按标签筛选:命中任一选中标签、有任意标签,或筛选无任何标签的记录(多条件取并集) + boolean hasTagIds = query.getTagIds() != null && !query.getTagIds().isEmpty(); + boolean untagged = Boolean.TRUE.equals(query.getUntagged()); + boolean tagged = Boolean.TRUE.equals(query.getTagged()); + if (hasTagIds || untagged || tagged) { + wrapper.and(w -> { + boolean appended = false; + 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(",")) + ")"); + appended = true; + } + if (tagged) { + if (appended) { + w.or(); + } + w.apply("EXISTS (SELECT 1 FROM t_transaction_tag tt WHERE tt.transaction_id = t_transaction.id)"); + appended = true; + } + if (untagged) { + if (appended) { + w.or(); + } + w.apply("NOT EXISTS (SELECT 1 FROM t_transaction_tag tt WHERE tt.transaction_id = t_transaction.id)"); + } + }); + } + return wrapper; + } + + /** + * 应用排序条件:默认按交易时间降序,支持按交易时间或金额升/降序。 + * + *

排序字段与方向均采用白名单,非法值回退为交易时间降序,避免注入风险。

+ * + * @param wrapper 查询条件 + * @param query 查询参数 + */ + public void applySort(LambdaQueryWrapper wrapper, TransactionQuery query) { + boolean asc = "asc".equalsIgnoreCase(query.getSortOrder()); + if ("amount".equals(query.getSortField())) { + wrapper.orderBy(true, asc, Transaction::getAmount); + } else if ("transactionTime".equals(query.getSortField())) { + wrapper.orderBy(true, asc, Transaction::getTransactionTime); + } else { + wrapper.orderByDesc(Transaction::getTransactionTime); + } + } +} diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/TransactionServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/TransactionServiceImpl.java index eef7b15..433e053 100644 --- a/src/main/java/cn/rosercode/ledgerly/service/impl/TransactionServiceImpl.java +++ b/src/main/java/cn/rosercode/ledgerly/service/impl/TransactionServiceImpl.java @@ -1,23 +1,20 @@ package cn.rosercode.ledgerly.service.impl; import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import java.util.ArrayList; -import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; 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; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; import cn.rosercode.ledgerly.common.BusinessException; import cn.rosercode.ledgerly.common.PageResult; @@ -30,72 +27,69 @@ import cn.rosercode.ledgerly.dto.TransactionRequest; import cn.rosercode.ledgerly.entity.AiImage; import cn.rosercode.ledgerly.entity.AiImageTransaction; import cn.rosercode.ledgerly.entity.Category; -import cn.rosercode.ledgerly.entity.Tag; import cn.rosercode.ledgerly.entity.Transaction; -import cn.rosercode.ledgerly.entity.TransactionGroupItem; -import cn.rosercode.ledgerly.entity.TransactionTag; -import cn.rosercode.ledgerly.enums.ChannelType; -import cn.rosercode.ledgerly.enums.TransactionType; +import cn.rosercode.ledgerly.exporter.TransactionExportAssembler; import cn.rosercode.ledgerly.mapper.AiImageMapper; import cn.rosercode.ledgerly.mapper.AiImageTransactionMapper; import cn.rosercode.ledgerly.mapper.CategoryMapper; -import cn.rosercode.ledgerly.mapper.TagMapper; -import cn.rosercode.ledgerly.mapper.TransactionGroupItemMapper; import cn.rosercode.ledgerly.mapper.TransactionMapper; -import cn.rosercode.ledgerly.mapper.TransactionTagMapper; import cn.rosercode.ledgerly.service.ChannelService; import cn.rosercode.ledgerly.service.TransactionGroupService; import cn.rosercode.ledgerly.service.TransactionService; -import cn.rosercode.ledgerly.service.UserPreferenceService; import cn.rosercode.ledgerly.util.TransactionDedupUtils; -import cn.rosercode.ledgerly.vo.TagVO; import cn.rosercode.ledgerly.vo.TransactionExportVO; import cn.rosercode.ledgerly.vo.TransactionVO; /** * 交易记录服务实现类,处理收支记录的分页查询、创建、更新和删除等业务逻辑。 + * + *

作为 {@link TransactionService} 的门面,内部委托查询构建、视图富化、标签写入、 + * 批量操作与导出组装等协作组件,保持对外接口不变。

*/ @Service @Slf4j public class TransactionServiceImpl implements TransactionService { - /** 备注被隐藏时在导出文件中使用的占位符 */ - private static final String REMARK_HIDDEN_PLACEHOLDER = "******"; + /** 导出记录数上限,超过时提示缩小筛选范围,避免全量加载导致 OOM */ + private static final int EXPORT_MAX_ROWS = 10000; private final TransactionMapper transactionMapper; private final CategoryMapper categoryMapper; - private final TagMapper tagMapper; - private final TransactionTagMapper transactionTagMapper; private final AiImageMapper aiImageMapper; private final AiImageTransactionMapper aiImageTransactionMapper; - private final TransactionGroupItemMapper transactionGroupItemMapper; private final ChannelService channelService; private final TransactionGroupService transactionGroupService; - private final UserPreferenceService userPreferenceService; private final VoConverter voConverter; + private final TransactionQuerySupport querySupport; + private final TransactionVoEnricher enricher; + private final TransactionTagWriter tagWriter; + private final TransactionBatchService batchService; + private final TransactionExportAssembler exportAssembler; public TransactionServiceImpl(TransactionMapper transactionMapper, CategoryMapper categoryMapper, - TagMapper tagMapper, - TransactionTagMapper transactionTagMapper, AiImageMapper aiImageMapper, AiImageTransactionMapper aiImageTransactionMapper, - TransactionGroupItemMapper transactionGroupItemMapper, ChannelService channelService, TransactionGroupService transactionGroupService, - UserPreferenceService userPreferenceService, - VoConverter voConverter) { + VoConverter voConverter, + TransactionQuerySupport querySupport, + TransactionVoEnricher enricher, + TransactionTagWriter tagWriter, + TransactionBatchService batchService, + TransactionExportAssembler exportAssembler) { this.transactionMapper = transactionMapper; this.categoryMapper = categoryMapper; - this.tagMapper = tagMapper; - this.transactionTagMapper = transactionTagMapper; this.aiImageMapper = aiImageMapper; this.aiImageTransactionMapper = aiImageTransactionMapper; - this.transactionGroupItemMapper = transactionGroupItemMapper; this.channelService = channelService; this.transactionGroupService = transactionGroupService; - this.userPreferenceService = userPreferenceService; this.voConverter = voConverter; + this.querySupport = querySupport; + this.enricher = enricher; + this.tagWriter = tagWriter; + this.batchService = batchService; + this.exportAssembler = exportAssembler; } /** @@ -106,113 +100,19 @@ public class TransactionServiceImpl implements TransactionService { */ @Override public PageResult page(Long userId, TransactionQuery query) { - LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); - wrapper.eq(Transaction::getUserId, userId); - - // 按交易类型筛选(多选) - if (query.getTypes() != null && !query.getTypes().isEmpty()) { - wrapper.in(Transaction::getType, query.getTypes()); - } - // 按分类ID筛选(多选);同时勾选「未分类」时与其取并集 - boolean hasCategoryIds = query.getCategoryIds() != null && !query.getCategoryIds().isEmpty(); - boolean uncategorized = Boolean.TRUE.equals(query.getUncategorized()); - if (hasCategoryIds && uncategorized) { - wrapper.and(w -> w.in(Transaction::getCategoryId, query.getCategoryIds()) - .or().isNull(Transaction::getCategoryId)); - } else if (hasCategoryIds) { - wrapper.in(Transaction::getCategoryId, query.getCategoryIds()); - } else if (uncategorized) { - wrapper.isNull(Transaction::getCategoryId); - } - // 按起始时间筛选 - if (StringUtils.hasText(query.getStartTime())) { - wrapper.ge(Transaction::getTransactionTime, - LocalDateTime.parse(query.getStartTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME)); - } - // 按结束时间筛选 - if (StringUtils.hasText(query.getEndTime())) { - wrapper.le(Transaction::getTransactionTime, - LocalDateTime.parse(query.getEndTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME)); - } - // 按交易渠道筛选(多选) - if (query.getChannels() != null && !query.getChannels().isEmpty()) { - wrapper.in(Transaction::getChannel, query.getChannels()); - } - // 按是否计入统计筛选 - if (query.getIncludeInStats() != null) { - wrapper.eq(Transaction::getIncludeInStats, query.getIncludeInStats()); - } - // 按金额区间筛选 - if (query.getMinAmount() != null) { - wrapper.ge(Transaction::getAmount, query.getMinAmount()); - } - if (query.getMaxAmount() != null) { - wrapper.le(Transaction::getAmount, query.getMaxAmount()); - } - // 按备注关键字模糊匹配 - if (StringUtils.hasText(query.getRemark())) { - wrapper.like(Transaction::getRemark, query.getRemark()); - } - // 是否显示已隐藏备注的记录由用户偏好控制,默认不显示 - if (!userPreferenceService.getShowHiddenData(userId)) { - wrapper.and(w -> w.isNull(Transaction::getRemarkHidden) - .or().eq(Transaction::getRemarkHidden, false)); - } - // 按标签筛选:命中任一选中标签、有任意标签,或筛选无任何标签的记录(多条件取并集) - boolean hasTagIds = query.getTagIds() != null && !query.getTagIds().isEmpty(); - boolean untagged = Boolean.TRUE.equals(query.getUntagged()); - boolean tagged = Boolean.TRUE.equals(query.getTagged()); - if (hasTagIds || untagged || tagged) { - wrapper.and(w -> { - boolean appended = false; - 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(",")) + ")"); - appended = true; - } - if (tagged) { - if (appended) { - w.or(); - } - w.apply("EXISTS (SELECT 1 FROM t_transaction_tag tt WHERE tt.transaction_id = t_transaction.id)"); - appended = true; - } - if (untagged) { - if (appended) { - w.or(); - } - w.apply("NOT EXISTS (SELECT 1 FROM t_transaction_tag tt WHERE tt.transaction_id = t_transaction.id)"); - } - }); - } - - // 应用排序:默认按交易时间降序,支持按交易时间/金额排序 - applySort(wrapper, query); + LambdaQueryWrapper wrapper = querySupport.buildQueryWrapper(userId, query); + querySupport.applySort(wrapper, query); Page page = new Page<>(query.getPage(), query.getSize()); Page result = transactionMapper.selectPage(page, wrapper); // 批量查询关联的分类信息,避免 N+1 查询问题 - List categoryIds = result.getRecords().stream() - .map(Transaction::getCategoryId) - .distinct() - .collect(Collectors.toList()); - - Map categoryMap = Map.of(); - if (!categoryIds.isEmpty()) { - List categories = categoryMapper.selectBatchIds(categoryIds); - categoryMap = categories.stream().collect(Collectors.toMap(Category::getId, c -> c)); - } - - // 将交易记录转换为视图对象,并填充分类名称和图标 - Map finalCategoryMap = categoryMap; + Map categoryMap = enricher.loadCategoryMap(result.getRecords()); List voList = result.getRecords().stream() - .map(t -> voConverter.toTransactionVO(t, finalCategoryMap.get(t.getCategoryId()))) + .map(t -> voConverter.toTransactionVO(t, categoryMap.get(t.getCategoryId()))) .collect(Collectors.toList()); - // 批量填充标签与来源图片 - fillTags(voList); - fillImages(voList); - fillRelationGroupCount(voList); + // 批量填充标签、来源图片与关系组数量 + enricher.enrich(voList); return new PageResult<>(voList, result.getTotal(), result.getCurrent(), result.getSize()); } @@ -242,7 +142,7 @@ public class TransactionServiceImpl implements TransactionService { transaction.setIncludeInStats(request.getIncludeInStats() == null ? Boolean.TRUE : request.getIncludeInStats()); transactionMapper.insert(transaction); // 同步标签关联 - saveTransactionTags(userId, transaction.getId(), request.getTagIds()); + tagWriter.saveTransactionTags(userId, transaction.getId(), request.getTagIds()); // 金额属敏感信息,日志仅记录类型,不记录金额 log.info("新增交易记录: userId={}, id={}, type={}", userId, transaction.getId(), transaction.getType()); } @@ -302,8 +202,18 @@ public class TransactionServiceImpl implements TransactionService { } } + // 预加载分类,避免循环内逐条查库(N+1) + Set categoryIds = records.stream() + .map(TransactionRequest::getCategoryId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map categoryMap = categoryIds.isEmpty() ? Map.of() + : categoryMapper.selectBatchIds(categoryIds).stream() + .collect(Collectors.toMap(Category::getId, c -> c)); + List createdIds = new ArrayList<>(); List createdImageIds = new ArrayList<>(); + List> createdTagIds = new ArrayList<>(); Set seen = new HashSet<>(); int skipped = 0; for (int i = 0; i < records.size(); i++) { @@ -317,7 +227,7 @@ public class TransactionServiceImpl implements TransactionService { } // 分类可选:仅在指定分类时校验归属 if (record.getCategoryId() != null) { - Category category = categoryMapper.selectById(record.getCategoryId()); + Category category = categoryMap.get(record.getCategoryId()); if (category == null || !category.getUserId().equals(userId)) { throw new BusinessException("分类不存在"); } @@ -334,11 +244,14 @@ public class TransactionServiceImpl implements TransactionService { : record.getIncludeInStats()); transaction.setRawData(record.getRawData()); transactionMapper.insert(transaction); - saveTransactionTags(userId, transaction.getId(), record.getTagIds()); createdIds.add(transaction.getId()); createdImageIds.add(recordImageIds.get(i)); + createdTagIds.add(record.getTagIds()); } + // 批量建立标签关联,避免逐条插入 + tagWriter.insertTags(userId, createdIds, createdTagIds); + // 按每条记录的来源图片建立图片关联 for (int i = 0; i < createdIds.size(); i++) { Long imageId = createdImageIds.get(i); @@ -387,7 +300,7 @@ public class TransactionServiceImpl implements TransactionService { transaction.setIncludeInStats(request.getIncludeInStats() == null ? Boolean.TRUE : request.getIncludeInStats()); transactionMapper.updateById(transaction); // 全量替换标签关联 - saveTransactionTags(userId, id, request.getTagIds()); + tagWriter.saveTransactionTags(userId, id, request.getTagIds()); log.info("更新交易记录: userId={}, id={}", userId, id); } @@ -404,8 +317,7 @@ public class TransactionServiceImpl implements TransactionService { throw new BusinessException("记录不存在"); } // 先清理标签关联,再删除记录 - transactionTagMapper.delete(new LambdaQueryWrapper() - .eq(TransactionTag::getTransactionId, id)); + tagWriter.deleteByTransactionIds(List.of(id)); transactionGroupService.removeTransactionFromGroups(userId, List.of(id)); transactionMapper.deleteById(id); log.info("删除交易记录: userId={}, id={}", userId, id); @@ -413,33 +325,10 @@ public class TransactionServiceImpl implements TransactionService { /** * {@inheritDoc} - * - * 仅删除归属当前用户的记录,先清理标签关联再批量删除,返回实际删除数。 */ @Override - @Transactional public int batchDelete(Long userId, List ids) { - if (ids == null || ids.isEmpty()) { - return 0; - } - // 仅保留属于当前用户的记录ID - List ownedIds = transactionMapper.selectList(new LambdaQueryWrapper() - .select(Transaction::getId) - .eq(Transaction::getUserId, userId) - .in(Transaction::getId, ids)) - .stream() - .map(Transaction::getId) - .collect(Collectors.toList()); - if (ownedIds.isEmpty()) { - return 0; - } - // 先清理标签关联,再批量删除记录 - transactionTagMapper.delete(new LambdaQueryWrapper() - .in(TransactionTag::getTransactionId, ownedIds)); - transactionGroupService.removeTransactionFromGroups(userId, ownedIds); - int deleted = transactionMapper.deleteByIds(ownedIds); - log.info("批量删除交易记录: userId={}, 请求={}, 实际={}", userId, ids.size(), deleted); - return deleted; + return batchService.batchDelete(userId, ids); } /** @@ -478,445 +367,39 @@ public class TransactionServiceImpl implements TransactionService { /** * {@inheritDoc} - * - * 按交易类型分别批量设置分类:仅更新与所选分类类型一致、且属于当前用户的记录。 */ @Override - @Transactional public int batchUpdateCategory(Long userId, BatchCategoryRequest request) { - List ids = request.getIds(); - if (ids == null || ids.isEmpty()) { - throw new BusinessException("请至少选择一条记录"); - } - if (ids.size() > 1000) { - throw new BusinessException("单次最多更新1000条记录"); - } - - // 分类与计入统计均为可选,仅在提供分类时校验归属与类型 - Category expenseCategory = request.getExpenseCategoryId() == null ? null - : requireCategory(userId, request.getExpenseCategoryId(), TransactionType.EXPENSE.name()); - Category incomeCategory = request.getIncomeCategoryId() == null ? null - : requireCategory(userId, request.getIncomeCategoryId(), TransactionType.INCOME.name()); - Boolean includeInStats = request.getIncludeInStats(); - if (expenseCategory == null && incomeCategory == null && includeInStats == null) { - throw new BusinessException("请至少选择分类或计入统计"); - } - - // 查询待更新记录,按交易类型拆分ID,避免类型不匹配的更新 - List transactions = transactionMapper.selectList(new LambdaQueryWrapper() - .select(Transaction::getId, Transaction::getType) - .eq(Transaction::getUserId, userId) - .in(Transaction::getId, ids)); - List expenseIds = transactions.stream() - .filter(t -> TransactionType.EXPENSE.name().equals(t.getType())) - .map(Transaction::getId) - .collect(Collectors.toList()); - List incomeIds = transactions.stream() - .filter(t -> TransactionType.INCOME.name().equals(t.getType())) - .map(Transaction::getId) - .collect(Collectors.toList()); - - // 按类型分组,同组内一次写入分类与计入统计,避免重复计数 - int updated = 0; - updated += batchUpdate(userId, expenseIds, expenseCategory, includeInStats); - updated += batchUpdate(userId, incomeIds, incomeCategory, includeInStats); - log.info("批量设置分类: userId={}, 请求={}, 实际={}", userId, ids.size(), updated); - return updated; - } - - /** - * 对指定记录批量写入分类与计入统计标记 - * - * @param userId 用户ID - * @param ids 待更新记录ID列表 - * @param category 目标分类,为 null 时不修改分类 - * @param includeInStats 计入统计标记,为 null 时不修改 - * @return 实际更新的记录数 - */ - private int batchUpdate(Long userId, List ids, Category category, Boolean includeInStats) { - if (ids.isEmpty() || (category == null && includeInStats == null)) { - return 0; - } - LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper() - .eq(Transaction::getUserId, userId) - .in(Transaction::getId, ids); - if (category != null) { - wrapper.set(Transaction::getCategoryId, category.getId()); - } - if (includeInStats != null) { - wrapper.set(Transaction::getIncludeInStats, includeInStats); - } - return transactionMapper.update(null, wrapper); + return batchService.batchUpdateCategory(userId, request); } /** * {@inheritDoc} - * - * 仅对属于当前用户的记录生效;先移除指定标签,再补充尚未存在的标签。 */ @Override - @Transactional public int batchTags(Long userId, BatchTagRequest request) { - List ids = request.getIds(); - if (ids == null || ids.isEmpty()) { - throw new BusinessException("请至少选择一条记录"); - } - List addTagIds = validTagIds(userId, request.getAddTagIds()); - List removeTagIds = validTagIds(userId, request.getRemoveTagIds()); - if (addTagIds.isEmpty() && removeTagIds.isEmpty()) { - throw new BusinessException("请至少选择要添加或移除的标签"); - } - - // 仅处理属于当前用户的记录 - List validIds = transactionMapper.selectList(new LambdaQueryWrapper() - .select(Transaction::getId) - .eq(Transaction::getUserId, userId) - .in(Transaction::getId, ids)) - .stream().map(Transaction::getId).collect(Collectors.toList()); - if (validIds.isEmpty()) { - return 0; - } - - // 移除标签关联 - if (!removeTagIds.isEmpty()) { - transactionTagMapper.delete(new LambdaQueryWrapper() - .in(TransactionTag::getTransactionId, validIds) - .in(TransactionTag::getTagId, removeTagIds)); - } - // 添加标签关联,跳过已存在的组合 - if (!addTagIds.isEmpty()) { - List existing = transactionTagMapper.selectList(new LambdaQueryWrapper() - .in(TransactionTag::getTransactionId, validIds) - .in(TransactionTag::getTagId, addTagIds)); - Set existingKeys = existing.stream() - .map(rel -> rel.getTransactionId() + ":" + rel.getTagId()) - .collect(Collectors.toSet()); - for (Long transactionId : validIds) { - for (Long tagId : addTagIds) { - if (existingKeys.contains(transactionId + ":" + tagId)) { - continue; - } - TransactionTag relation = new TransactionTag(); - relation.setTransactionId(transactionId); - relation.setTagId(tagId); - transactionTagMapper.insert(relation); - } - } - } - log.info("批量设置标签: userId={}, 记录={}, 添加={}, 移除={}", - userId, validIds.size(), addTagIds.size(), removeTagIds.size()); - return validIds.size(); - } - - /** - * 过滤出属于当前用户的标签ID - * - * @param userId 用户ID - * @param tagIds 待校验的标签ID列表 - * @return 有效的标签ID列表 - */ - private List validTagIds(Long userId, List tagIds) { - if (tagIds == null || tagIds.isEmpty()) { - return List.of(); - } - return tagMapper.selectBatchIds(tagIds).stream() - .filter(tag -> tag.getUserId().equals(userId)) - .map(Tag::getId) - .collect(Collectors.toList()); - } - - /** - * 全量替换某条交易记录的标签关联 - * - * @param userId 用户ID - * @param transactionId 交易记录ID - * @param tagIds 目标标签ID列表,为空表示清空 - */ - private void saveTransactionTags(Long userId, Long transactionId, List tagIds) { - transactionTagMapper.delete(new LambdaQueryWrapper() - .eq(TransactionTag::getTransactionId, transactionId)); - for (Long tagId : validTagIds(userId, tagIds)) { - TransactionTag relation = new TransactionTag(); - relation.setTransactionId(transactionId); - relation.setTagId(tagId); - transactionTagMapper.insert(relation); - } - } - - /** - * 批量填充交易视图对象的标签列表 - * - * @param voList 交易视图对象列表 - */ - private void fillTags(List voList) { - if (voList.isEmpty()) { - return; - } - List transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList()); - List relations = transactionTagMapper.selectList(new LambdaQueryWrapper() - .in(TransactionTag::getTransactionId, transactionIds)); - if (relations.isEmpty()) { - return; - } - List tagIds = relations.stream().map(TransactionTag::getTagId).distinct().collect(Collectors.toList()); - Map tagMap = tagMapper.selectBatchIds(tagIds).stream() - .collect(Collectors.toMap(Tag::getId, tag -> tag)); - Map> tagsByTransaction = new HashMap<>(); - for (TransactionTag relation : relations) { - Tag tag = tagMap.get(relation.getTagId()); - if (tag == null) { - continue; - } - tagsByTransaction.computeIfAbsent(relation.getTransactionId(), key -> new ArrayList<>()) - .add(voConverter.toTagVO(tag)); - } - for (TransactionVO vo : voList) { - vo.setTags(tagsByTransaction.getOrDefault(vo.getId(), List.of())); - } - } - - /** - * 批量填充交易视图对象的来源图片ID列表 - * - * @param voList 交易视图对象列表 - */ - private void fillImages(List voList) { - if (voList.isEmpty()) { - return; - } - List transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList()); - List relations = aiImageTransactionMapper.selectList( - new LambdaQueryWrapper() - .in(AiImageTransaction::getTransactionId, transactionIds)); - if (relations.isEmpty()) { - return; - } - Map> imagesByTransaction = new HashMap<>(); - for (AiImageTransaction relation : relations) { - imagesByTransaction.computeIfAbsent(relation.getTransactionId(), key -> new ArrayList<>()) - .add(relation.getImageId()); - } - for (TransactionVO vo : voList) { - vo.setImageIds(imagesByTransaction.getOrDefault(vo.getId(), List.of())); - } - } - - /** - * 批量填充交易视图对象所属的关系组数量 - * - * @param voList 交易视图对象列表 - */ - private void fillRelationGroupCount(List voList) { - if (voList.isEmpty()) { - return; - } - List transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList()); - List items = transactionGroupItemMapper.selectList( - new LambdaQueryWrapper() - .in(TransactionGroupItem::getTransactionId, transactionIds)); - Map counts = items.stream() - .collect(Collectors.groupingBy(TransactionGroupItem::getTransactionId, Collectors.counting())); - for (TransactionVO vo : voList) { - vo.setRelationGroupCount(counts.getOrDefault(vo.getId(), 0L).intValue()); - } - } - - /** - * 应用排序条件:默认按交易时间降序,支持按交易时间或金额升/降序。 - * - *

排序字段与方向均采用白名单,非法值回退为交易时间降序,避免注入风险。

- * - * @param wrapper 查询条件 - * @param query 查询参数 - */ - private void applySort(LambdaQueryWrapper wrapper, TransactionQuery query) { - boolean asc = "asc".equalsIgnoreCase(query.getSortOrder()); - if ("amount".equals(query.getSortField())) { - wrapper.orderBy(true, asc, Transaction::getAmount); - } else if ("transactionTime".equals(query.getSortField())) { - wrapper.orderBy(true, asc, Transaction::getTransactionTime); - } else { - wrapper.orderByDesc(Transaction::getTransactionTime); - } - } - - /** - * 校验分类是否存在、归属当前用户且类型匹配 - * - * @param userId 用户ID - * @param categoryId 分类ID - * @param type 期望的分类类型 - * @return 分类实体 - */ - private Category requireCategory(Long userId, Long categoryId, String type) { - Category category = categoryMapper.selectById(categoryId); - if (category == null || !category.getUserId().equals(userId)) { - throw new BusinessException("分类不存在"); - } - if (!type.equals(category.getType())) { - throw new BusinessException("分类与交易类型不匹配"); - } - return category; + return batchService.batchTags(userId, request); } /** * {@inheritDoc} * - * 根据查询条件导出交易记录,不分页,返回所有匹配的记录。 + * 根据查询条件导出交易记录,不分页;超过上限时拒绝以避免内存溢出。 */ @Override public List export(Long userId, TransactionQuery query) { - LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); - wrapper.eq(Transaction::getUserId, userId); - - // 按交易类型筛选(多选) - if (query.getTypes() != null && !query.getTypes().isEmpty()) { - wrapper.in(Transaction::getType, query.getTypes()); - } - // 按分类ID筛选(多选);同时勾选「未分类」时与其取并集 - boolean hasCategoryIds = query.getCategoryIds() != null && !query.getCategoryIds().isEmpty(); - boolean uncategorized = Boolean.TRUE.equals(query.getUncategorized()); - if (hasCategoryIds && uncategorized) { - wrapper.and(w -> w.in(Transaction::getCategoryId, query.getCategoryIds()) - .or().isNull(Transaction::getCategoryId)); - } else if (hasCategoryIds) { - wrapper.in(Transaction::getCategoryId, query.getCategoryIds()); - } else if (uncategorized) { - wrapper.isNull(Transaction::getCategoryId); - } - // 按起始时间筛选 - if (StringUtils.hasText(query.getStartTime())) { - wrapper.ge(Transaction::getTransactionTime, - LocalDateTime.parse(query.getStartTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME)); - } - // 按结束时间筛选 - if (StringUtils.hasText(query.getEndTime())) { - wrapper.le(Transaction::getTransactionTime, - LocalDateTime.parse(query.getEndTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME)); - } - // 按交易渠道筛选(多选) - if (query.getChannels() != null && !query.getChannels().isEmpty()) { - wrapper.in(Transaction::getChannel, query.getChannels()); - } - // 按是否计入统计筛选 - if (query.getIncludeInStats() != null) { - wrapper.eq(Transaction::getIncludeInStats, query.getIncludeInStats()); - } - // 按金额区间筛选 - if (query.getMinAmount() != null) { - wrapper.ge(Transaction::getAmount, query.getMinAmount()); - } - if (query.getMaxAmount() != null) { - wrapper.le(Transaction::getAmount, query.getMaxAmount()); - } - // 按备注关键字模糊匹配 - if (StringUtils.hasText(query.getRemark())) { - wrapper.like(Transaction::getRemark, query.getRemark()); - } - // 是否显示已隐藏备注的记录由用户偏好控制,默认不显示 - if (!userPreferenceService.getShowHiddenData(userId)) { - wrapper.and(w -> w.isNull(Transaction::getRemarkHidden) - .or().eq(Transaction::getRemarkHidden, false)); - } - // 按标签筛选:命中任一选中标签、有任意标签,或筛选无任何标签的记录(多条件取并集) - boolean hasTagIds = query.getTagIds() != null && !query.getTagIds().isEmpty(); - boolean untagged = Boolean.TRUE.equals(query.getUntagged()); - boolean tagged = Boolean.TRUE.equals(query.getTagged()); - if (hasTagIds || untagged || tagged) { - wrapper.and(w -> { - boolean appended = false; - 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(",")) + ")"); - appended = true; - } - if (tagged) { - if (appended) { - w.or(); - } - w.apply("EXISTS (SELECT 1 FROM t_transaction_tag tt WHERE tt.transaction_id = t_transaction.id)"); - appended = true; - } - if (untagged) { - if (appended) { - w.or(); - } - w.apply("NOT EXISTS (SELECT 1 FROM t_transaction_tag tt WHERE tt.transaction_id = t_transaction.id)"); - } - }); - } - - // 应用排序:默认按交易时间降序,支持按交易时间/金额排序 - applySort(wrapper, query); + LambdaQueryWrapper wrapper = querySupport.buildQueryWrapper(userId, query); + querySupport.applySort(wrapper, query); List transactions = transactionMapper.selectList(wrapper); - - // 批量查询关联的分类信息 - List categoryIds = transactions.stream() - .map(Transaction::getCategoryId) - .distinct() - .collect(Collectors.toList()); - - Map categoryMap = Map.of(); - if (!categoryIds.isEmpty()) { - List categories = categoryMapper.selectBatchIds(categoryIds); - categoryMap = categories.stream().collect(Collectors.toMap(Category::getId, c -> c)); + if (transactions.size() > EXPORT_MAX_ROWS) { + throw new BusinessException("导出数据量过大,请缩小筛选范围后重试(上限" + EXPORT_MAX_ROWS + "条)"); } - // 转换为导出视图对象,自定义渠道名称按当前用户解析 - Map finalCategoryMap = categoryMap; + Map categoryMap = enricher.loadCategoryMap(transactions); Map channelNames = channelService.nameMap(userId); return transactions.stream() - .map(t -> toExportVO(t, finalCategoryMap.get(t.getCategoryId()), channelNames)) + .map(t -> exportAssembler.toExportVO(t, categoryMap.get(t.getCategoryId()), channelNames)) .collect(Collectors.toList()); } - - /** - * 将交易实体和关联分类转换为导出视图对象 - * - * @param t 交易实体 - * @param category 关联的分类实体(可为 null) - * @param channelNames 自定义渠道「编码 → 名称」映射 - * @return 交易导出视图对象 - */ - private TransactionExportVO toExportVO(Transaction t, Category category, Map channelNames) { - TransactionExportVO vo = new TransactionExportVO(); - Boolean include = t.getIncludeInStats() == null ? Boolean.TRUE : t.getIncludeInStats(); - if (include) { - vo.setType(TransactionType.labelOf(t.getType())); - } else { - vo.setType("不计入统计"); - } - vo.setCategoryName(category != null ? category.getName() : ""); - vo.setAmount(String.format("%.2f", t.getAmount())); - vo.setChannel(resolveChannelLabel(t.getChannel(), channelNames)); - // 备注被标记为隐藏时,导出同样以占位符替代,避免泄露 - if (Boolean.TRUE.equals(t.getRemarkHidden())) { - vo.setRemark(REMARK_HIDDEN_PLACEHOLDER); - } else { - vo.setRemark(t.getRemark() != null ? t.getRemark() : ""); - } - vo.setTransactionTime(t.getTransactionTime() != null - ? t.getTransactionTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) - : ""); - vo.setIncludeInStats(include ? "是" : "否"); - return vo; - } - - /** - * 将交易渠道编码转换为中文标签,优先取用户自定义渠道名称 - * - * @param code 交易渠道编码 - * @param channelNames 自定义渠道「编码 → 名称」映射 - * @return 中文标签 - */ - private String resolveChannelLabel(String code, Map channelNames) { - if (code == null) return ""; - String custom = channelNames.get(code); - if (custom != null) return custom; - // 兼容历史 BANK 渠道 - if ("BANK".equals(code)) return "银行卡"; - return ChannelType.labelOf(code); - } - } diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/TransactionTagWriter.java b/src/main/java/cn/rosercode/ledgerly/service/impl/TransactionTagWriter.java new file mode 100644 index 0000000..ed4cd97 --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/service/impl/TransactionTagWriter.java @@ -0,0 +1,137 @@ +package cn.rosercode.ledgerly.service.impl; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springframework.stereotype.Service; + +import cn.rosercode.ledgerly.entity.Tag; +import cn.rosercode.ledgerly.entity.TransactionTag; +import cn.rosercode.ledgerly.mapper.TagMapper; +import cn.rosercode.ledgerly.mapper.TransactionTagMapper; + +/** + * 交易与标签关联的写入组件。 + * + *

统一处理标签归属校验、单条全量替换、批量插入与关联删除,插入统一分批。

+ * + * @date 2026-10-09 10:55:53 + */ +@Service +public class TransactionTagWriter { + + /** 批量插入的分批大小 */ + private static final int BATCH_SIZE = 500; + + private final TagMapper tagMapper; + private final TransactionTagMapper transactionTagMapper; + + public TransactionTagWriter(TagMapper tagMapper, TransactionTagMapper transactionTagMapper) { + this.tagMapper = tagMapper; + this.transactionTagMapper = transactionTagMapper; + } + + /** + * 过滤出属于当前用户的标签ID。 + * + * @param userId 用户ID + * @param tagIds 待校验的标签ID列表 + * @return 有效的标签ID列表 + */ + public List validTagIds(Long userId, List tagIds) { + if (tagIds == null || tagIds.isEmpty()) { + return List.of(); + } + return tagMapper.selectBatchIds(tagIds).stream() + .filter(tag -> tag.getUserId().equals(userId)) + .map(Tag::getId) + .collect(Collectors.toList()); + } + + /** + * 全量替换某条交易记录的标签关联。 + * + * @param userId 用户ID + * @param transactionId 交易记录ID + * @param tagIds 目标标签ID列表,为空表示清空 + */ + public void saveTransactionTags(Long userId, Long transactionId, List tagIds) { + deleteByTransactionIds(List.of(transactionId)); + List validIds = validTagIds(userId, tagIds); + if (validIds.isEmpty()) { + return; + } + List relations = validIds.stream().map(tagId -> { + TransactionTag relation = new TransactionTag(); + relation.setTransactionId(transactionId); + relation.setTagId(tagId); + return relation; + }).collect(Collectors.toList()); + insertBatch(relations); + } + + /** + * 为多条新建交易批量建立标签关联。 + * + *

标签列表按交易顺序一一对应,先统一校验标签归属再批量插入,避免逐条写库。

+ * + * @param userId 用户ID + * @param transactionIds 交易记录ID列表 + * @param tagIdsList 各交易对应的目标标签ID列表,下标与 transactionIds 对应 + */ + public void insertTags(Long userId, List transactionIds, List> tagIdsList) { + Set allTagIds = new LinkedHashSet<>(); + for (List ids : tagIdsList) { + if (ids != null) { + allTagIds.addAll(ids); + } + } + Set validIds = new LinkedHashSet<>(validTagIds(userId, new ArrayList<>(allTagIds))); + List relations = new ArrayList<>(); + for (int i = 0; i < transactionIds.size(); i++) { + List ids = tagIdsList.get(i); + if (ids == null) { + continue; + } + for (Long tagId : ids) { + if (!validIds.contains(tagId)) { + continue; + } + TransactionTag relation = new TransactionTag(); + relation.setTransactionId(transactionIds.get(i)); + relation.setTagId(tagId); + relations.add(relation); + } + } + insertBatch(relations); + } + + /** + * 按交易记录ID批量删除标签关联。 + * + * @param transactionIds 交易记录ID列表 + */ + public void deleteByTransactionIds(List transactionIds) { + if (transactionIds == null || transactionIds.isEmpty()) { + return; + } + transactionTagMapper.delete(new LambdaQueryWrapper() + .in(TransactionTag::getTransactionId, transactionIds)); + } + + /** + * 分批插入标签关联。 + * + * @param relations 待插入的关联列表 + */ + private void insertBatch(List relations) { + for (int i = 0; i < relations.size(); i += BATCH_SIZE) { + List batch = relations.subList(i, Math.min(i + BATCH_SIZE, relations.size())); + transactionTagMapper.insertBatch(batch); + } + } +} diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/TransactionVoEnricher.java b/src/main/java/cn/rosercode/ledgerly/service/impl/TransactionVoEnricher.java new file mode 100644 index 0000000..e9ed0d5 --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/service/impl/TransactionVoEnricher.java @@ -0,0 +1,165 @@ +package cn.rosercode.ledgerly.service.impl; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springframework.stereotype.Service; + +import cn.rosercode.ledgerly.converter.VoConverter; +import cn.rosercode.ledgerly.entity.AiImageTransaction; +import cn.rosercode.ledgerly.entity.Category; +import cn.rosercode.ledgerly.entity.Tag; +import cn.rosercode.ledgerly.entity.Transaction; +import cn.rosercode.ledgerly.entity.TransactionGroupItem; +import cn.rosercode.ledgerly.entity.TransactionTag; +import cn.rosercode.ledgerly.mapper.AiImageTransactionMapper; +import cn.rosercode.ledgerly.mapper.CategoryMapper; +import cn.rosercode.ledgerly.mapper.TagMapper; +import cn.rosercode.ledgerly.mapper.TransactionGroupItemMapper; +import cn.rosercode.ledgerly.mapper.TransactionTagMapper; +import cn.rosercode.ledgerly.vo.TagVO; +import cn.rosercode.ledgerly.vo.TransactionVO; + +/** + * 交易视图对象富化组件。 + * + *

为交易列表视图对象批量补充关联数据:分类映射、标签、来源图片与关系组数量, + * 均采用批量查询以避免 N+1。

+ * + * @date 2026-10-09 10:55:53 + */ +@Service +public class TransactionVoEnricher { + + private final CategoryMapper categoryMapper; + private final TagMapper tagMapper; + private final TransactionTagMapper transactionTagMapper; + private final AiImageTransactionMapper aiImageTransactionMapper; + private final TransactionGroupItemMapper transactionGroupItemMapper; + private final VoConverter voConverter; + + public TransactionVoEnricher(CategoryMapper categoryMapper, + TagMapper tagMapper, + TransactionTagMapper transactionTagMapper, + AiImageTransactionMapper aiImageTransactionMapper, + TransactionGroupItemMapper transactionGroupItemMapper, + VoConverter voConverter) { + this.categoryMapper = categoryMapper; + this.tagMapper = tagMapper; + this.transactionTagMapper = transactionTagMapper; + this.aiImageTransactionMapper = aiImageTransactionMapper; + this.transactionGroupItemMapper = transactionGroupItemMapper; + this.voConverter = voConverter; + } + + /** + * 批量加载交易关联的分类映射。 + * + * @param transactions 交易实体列表 + * @return 「分类ID → 分类实体」映射 + */ + public Map loadCategoryMap(List transactions) { + List categoryIds = transactions.stream() + .map(Transaction::getCategoryId) + .distinct() + .collect(Collectors.toList()); + if (categoryIds.isEmpty()) { + return Map.of(); + } + return categoryMapper.selectBatchIds(categoryIds).stream() + .collect(Collectors.toMap(Category::getId, c -> c)); + } + + /** + * 批量填充交易视图对象的标签、来源图片与关系组数量。 + * + * @param voList 交易视图对象列表 + */ + public void enrich(List voList) { + fillTags(voList); + fillImages(voList); + fillRelationGroupCount(voList); + } + + /** + * 批量填充交易视图对象的标签列表。 + * + * @param voList 交易视图对象列表 + */ + private void fillTags(List voList) { + if (voList.isEmpty()) { + return; + } + List transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList()); + List relations = transactionTagMapper.selectList(new LambdaQueryWrapper() + .in(TransactionTag::getTransactionId, transactionIds)); + if (relations.isEmpty()) { + return; + } + List tagIds = relations.stream().map(TransactionTag::getTagId).distinct().collect(Collectors.toList()); + Map tagMap = tagMapper.selectBatchIds(tagIds).stream() + .collect(Collectors.toMap(Tag::getId, tag -> tag)); + Map> tagsByTransaction = new HashMap<>(); + for (TransactionTag relation : relations) { + Tag tag = tagMap.get(relation.getTagId()); + if (tag == null) { + continue; + } + tagsByTransaction.computeIfAbsent(relation.getTransactionId(), key -> new ArrayList<>()) + .add(voConverter.toTagVO(tag)); + } + for (TransactionVO vo : voList) { + vo.setTags(tagsByTransaction.getOrDefault(vo.getId(), List.of())); + } + } + + /** + * 批量填充交易视图对象的来源图片ID列表。 + * + * @param voList 交易视图对象列表 + */ + private void fillImages(List voList) { + if (voList.isEmpty()) { + return; + } + List transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList()); + List relations = aiImageTransactionMapper.selectList( + new LambdaQueryWrapper() + .in(AiImageTransaction::getTransactionId, transactionIds)); + if (relations.isEmpty()) { + return; + } + Map> imagesByTransaction = new HashMap<>(); + for (AiImageTransaction relation : relations) { + imagesByTransaction.computeIfAbsent(relation.getTransactionId(), key -> new ArrayList<>()) + .add(relation.getImageId()); + } + for (TransactionVO vo : voList) { + vo.setImageIds(imagesByTransaction.getOrDefault(vo.getId(), List.of())); + } + } + + /** + * 批量填充交易视图对象所属的关系组数量。 + * + * @param voList 交易视图对象列表 + */ + private void fillRelationGroupCount(List voList) { + if (voList.isEmpty()) { + return; + } + List transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList()); + List items = transactionGroupItemMapper.selectList( + new LambdaQueryWrapper() + .in(TransactionGroupItem::getTransactionId, transactionIds)); + Map counts = items.stream() + .collect(Collectors.groupingBy(TransactionGroupItem::getTransactionId, Collectors.counting())); + for (TransactionVO vo : voList) { + vo.setRelationGroupCount(counts.getOrDefault(vo.getId(), 0L).intValue()); + } + } +}