refactor: 拆分交易记录前后端巨型文件并优化批量与导出性能

将 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 万条上限保护,超限提示缩小筛选范围
This commit is contained in:
2026-10-09 14:29:00 +08:00
parent 4735f0bf85
commit 863e221220
11 changed files with 1867 additions and 1439 deletions
@@ -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;
/**
* 交易记录导出视图组装器。
*
* <p>将交易实体与关联分类转换为导出视图对象,并处理类型文案、金额格式化、
* 渠道本地化与隐藏备注占位。</p>
*
* @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<String, String> 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<String, String> 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);
}
}
@@ -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;
/**
* 交易记录批量操作组件。
*
* <p>承载批量删除、批量设置分类/计入统计与批量设置标签,均按当前用户隔离。</p>
*
* @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<Long> ids) {
if (ids == null || ids.isEmpty()) {
return 0;
}
// 仅保留属于当前用户的记录ID
List<Long> ownedIds = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.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<Long> 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<Transaction> transactions = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.select(Transaction::getId, Transaction::getType)
.eq(Transaction::getUserId, userId)
.in(Transaction::getId, ids));
List<Long> expenseIds = transactions.stream()
.filter(t -> TransactionType.EXPENSE.name().equals(t.getType()))
.map(Transaction::getId)
.collect(Collectors.toList());
List<Long> 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<Long> ids = request.getIds();
if (ids == null || ids.isEmpty()) {
throw new BusinessException("请至少选择一条记录");
}
List<Long> addTagIds = tagWriter.validTagIds(userId, request.getAddTagIds());
List<Long> removeTagIds = tagWriter.validTagIds(userId, request.getRemoveTagIds());
if (addTagIds.isEmpty() && removeTagIds.isEmpty()) {
throw new BusinessException("请至少选择要添加或移除的标签");
}
// 仅处理属于当前用户的记录
List<Long> validIds = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.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<TransactionTag>()
.in(TransactionTag::getTransactionId, validIds)
.in(TransactionTag::getTagId, removeTagIds));
}
// 添加标签关联,跳过已存在的组合
if (!addTagIds.isEmpty()) {
List<TransactionTag> existing = transactionTagMapper.selectList(new LambdaQueryWrapper<TransactionTag>()
.in(TransactionTag::getTransactionId, validIds)
.in(TransactionTag::getTagId, addTagIds));
Set<String> 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<Long> ids, Category category, Boolean includeInStats) {
if (ids.isEmpty() || (category == null && includeInStats == null)) {
return 0;
}
LambdaUpdateWrapper<Transaction> wrapper = new LambdaUpdateWrapper<Transaction>()
.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;
}
}
@@ -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;
/**
* 交易记录查询条件构建组件。
*
* <p>将分页查询与导出共用的筛选条件拼装逻辑抽取于此,避免两处重复维护:
* 包含收支类型、分类(含未分类)、时间范围、渠道、计入统计、金额区间、
* 备注关键字、备注隐藏偏好与标签(含标签/无标签/未打标签)。</p>
*
* @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<Transaction> buildQueryWrapper(Long userId, TransactionQuery query) {
LambdaQueryWrapper<Transaction> 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;
}
/**
* 应用排序条件:默认按交易时间降序,支持按交易时间或金额升/降序。
*
* <p>排序字段与方向均采用白名单,非法值回退为交易时间降序,避免注入风险。</p>
*
* @param wrapper 查询条件
* @param query 查询参数
*/
public void applySort(LambdaQueryWrapper<Transaction> 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);
}
}
}
@@ -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;
/**
* 交易记录服务实现类,处理收支记录的分页查询、创建、更新和删除等业务逻辑。
*
* <p>作为 {@link TransactionService} 的门面,内部委托查询构建、视图富化、标签写入、
* 批量操作与导出组装等协作组件,保持对外接口不变。</p>
*/
@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<TransactionVO> page(Long userId, TransactionQuery query) {
LambdaQueryWrapper<Transaction> 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<Transaction> wrapper = querySupport.buildQueryWrapper(userId, query);
querySupport.applySort(wrapper, query);
Page<Transaction> page = new Page<>(query.getPage(), query.getSize());
Page<Transaction> result = transactionMapper.selectPage(page, wrapper);
// 批量查询关联的分类信息,避免 N+1 查询问题
List<Long> categoryIds = result.getRecords().stream()
.map(Transaction::getCategoryId)
.distinct()
.collect(Collectors.toList());
Map<Long, Category> categoryMap = Map.of();
if (!categoryIds.isEmpty()) {
List<Category> categories = categoryMapper.selectBatchIds(categoryIds);
categoryMap = categories.stream().collect(Collectors.toMap(Category::getId, c -> c));
}
// 将交易记录转换为视图对象,并填充分类名称和图标
Map<Long, Category> finalCategoryMap = categoryMap;
Map<Long, Category> categoryMap = enricher.loadCategoryMap(result.getRecords());
List<TransactionVO> 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<Long> categoryIds = records.stream()
.map(TransactionRequest::getCategoryId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, Category> categoryMap = categoryIds.isEmpty() ? Map.of()
: categoryMapper.selectBatchIds(categoryIds).stream()
.collect(Collectors.toMap(Category::getId, c -> c));
List<Long> createdIds = new ArrayList<>();
List<Long> createdImageIds = new ArrayList<>();
List<List<Long>> createdTagIds = new ArrayList<>();
Set<String> 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<TransactionTag>()
.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<Long> ids) {
if (ids == null || ids.isEmpty()) {
return 0;
}
// 仅保留属于当前用户的记录ID
List<Long> ownedIds = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.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<TransactionTag>()
.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<Long> 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<Transaction> transactions = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.select(Transaction::getId, Transaction::getType)
.eq(Transaction::getUserId, userId)
.in(Transaction::getId, ids));
List<Long> expenseIds = transactions.stream()
.filter(t -> TransactionType.EXPENSE.name().equals(t.getType()))
.map(Transaction::getId)
.collect(Collectors.toList());
List<Long> 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<Long> ids, Category category, Boolean includeInStats) {
if (ids.isEmpty() || (category == null && includeInStats == null)) {
return 0;
}
LambdaUpdateWrapper<Transaction> wrapper = new LambdaUpdateWrapper<Transaction>()
.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<Long> ids = request.getIds();
if (ids == null || ids.isEmpty()) {
throw new BusinessException("请至少选择一条记录");
}
List<Long> addTagIds = validTagIds(userId, request.getAddTagIds());
List<Long> removeTagIds = validTagIds(userId, request.getRemoveTagIds());
if (addTagIds.isEmpty() && removeTagIds.isEmpty()) {
throw new BusinessException("请至少选择要添加或移除的标签");
}
// 仅处理属于当前用户的记录
List<Long> validIds = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.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<TransactionTag>()
.in(TransactionTag::getTransactionId, validIds)
.in(TransactionTag::getTagId, removeTagIds));
}
// 添加标签关联,跳过已存在的组合
if (!addTagIds.isEmpty()) {
List<TransactionTag> existing = transactionTagMapper.selectList(new LambdaQueryWrapper<TransactionTag>()
.in(TransactionTag::getTransactionId, validIds)
.in(TransactionTag::getTagId, addTagIds));
Set<String> 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<Long> validTagIds(Long userId, List<Long> 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<Long> tagIds) {
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
.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<TransactionVO> voList) {
if (voList.isEmpty()) {
return;
}
List<Long> transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList());
List<TransactionTag> relations = transactionTagMapper.selectList(new LambdaQueryWrapper<TransactionTag>()
.in(TransactionTag::getTransactionId, transactionIds));
if (relations.isEmpty()) {
return;
}
List<Long> tagIds = relations.stream().map(TransactionTag::getTagId).distinct().collect(Collectors.toList());
Map<Long, Tag> tagMap = tagMapper.selectBatchIds(tagIds).stream()
.collect(Collectors.toMap(Tag::getId, tag -> tag));
Map<Long, List<TagVO>> 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<TransactionVO> voList) {
if (voList.isEmpty()) {
return;
}
List<Long> transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList());
List<AiImageTransaction> relations = aiImageTransactionMapper.selectList(
new LambdaQueryWrapper<AiImageTransaction>()
.in(AiImageTransaction::getTransactionId, transactionIds));
if (relations.isEmpty()) {
return;
}
Map<Long, List<Long>> 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<TransactionVO> voList) {
if (voList.isEmpty()) {
return;
}
List<Long> transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList());
List<TransactionGroupItem> items = transactionGroupItemMapper.selectList(
new LambdaQueryWrapper<TransactionGroupItem>()
.in(TransactionGroupItem::getTransactionId, transactionIds));
Map<Long, Long> counts = items.stream()
.collect(Collectors.groupingBy(TransactionGroupItem::getTransactionId, Collectors.counting()));
for (TransactionVO vo : voList) {
vo.setRelationGroupCount(counts.getOrDefault(vo.getId(), 0L).intValue());
}
}
/**
* 应用排序条件:默认按交易时间降序,支持按交易时间或金额升/降序。
*
* <p>排序字段与方向均采用白名单,非法值回退为交易时间降序,避免注入风险。</p>
*
* @param wrapper 查询条件
* @param query 查询参数
*/
private void applySort(LambdaQueryWrapper<Transaction> 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<TransactionExportVO> export(Long userId, TransactionQuery query) {
LambdaQueryWrapper<Transaction> 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<Transaction> wrapper = querySupport.buildQueryWrapper(userId, query);
querySupport.applySort(wrapper, query);
List<Transaction> transactions = transactionMapper.selectList(wrapper);
// 批量查询关联的分类信息
List<Long> categoryIds = transactions.stream()
.map(Transaction::getCategoryId)
.distinct()
.collect(Collectors.toList());
Map<Long, Category> categoryMap = Map.of();
if (!categoryIds.isEmpty()) {
List<Category> 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<Long, Category> finalCategoryMap = categoryMap;
Map<Long, Category> categoryMap = enricher.loadCategoryMap(transactions);
Map<String, String> 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<String, String> 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<String, String> 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);
}
}
@@ -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;
/**
* 交易与标签关联的写入组件。
*
* <p>统一处理标签归属校验、单条全量替换、批量插入与关联删除,插入统一分批。</p>
*
* @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<Long> validTagIds(Long userId, List<Long> 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<Long> tagIds) {
deleteByTransactionIds(List.of(transactionId));
List<Long> validIds = validTagIds(userId, tagIds);
if (validIds.isEmpty()) {
return;
}
List<TransactionTag> relations = validIds.stream().map(tagId -> {
TransactionTag relation = new TransactionTag();
relation.setTransactionId(transactionId);
relation.setTagId(tagId);
return relation;
}).collect(Collectors.toList());
insertBatch(relations);
}
/**
* 为多条新建交易批量建立标签关联。
*
* <p>标签列表按交易顺序一一对应,先统一校验标签归属再批量插入,避免逐条写库。</p>
*
* @param userId 用户ID
* @param transactionIds 交易记录ID列表
* @param tagIdsList 各交易对应的目标标签ID列表,下标与 transactionIds 对应
*/
public void insertTags(Long userId, List<Long> transactionIds, List<List<Long>> tagIdsList) {
Set<Long> allTagIds = new LinkedHashSet<>();
for (List<Long> ids : tagIdsList) {
if (ids != null) {
allTagIds.addAll(ids);
}
}
Set<Long> validIds = new LinkedHashSet<>(validTagIds(userId, new ArrayList<>(allTagIds)));
List<TransactionTag> relations = new ArrayList<>();
for (int i = 0; i < transactionIds.size(); i++) {
List<Long> 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<Long> transactionIds) {
if (transactionIds == null || transactionIds.isEmpty()) {
return;
}
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
.in(TransactionTag::getTransactionId, transactionIds));
}
/**
* 分批插入标签关联。
*
* @param relations 待插入的关联列表
*/
private void insertBatch(List<TransactionTag> relations) {
for (int i = 0; i < relations.size(); i += BATCH_SIZE) {
List<TransactionTag> batch = relations.subList(i, Math.min(i + BATCH_SIZE, relations.size()));
transactionTagMapper.insertBatch(batch);
}
}
}
@@ -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;
/**
* 交易视图对象富化组件。
*
* <p>为交易列表视图对象批量补充关联数据:分类映射、标签、来源图片与关系组数量,
* 均采用批量查询以避免 N+1。</p>
*
* @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<Long, Category> loadCategoryMap(List<Transaction> transactions) {
List<Long> 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<TransactionVO> voList) {
fillTags(voList);
fillImages(voList);
fillRelationGroupCount(voList);
}
/**
* 批量填充交易视图对象的标签列表。
*
* @param voList 交易视图对象列表
*/
private void fillTags(List<TransactionVO> voList) {
if (voList.isEmpty()) {
return;
}
List<Long> transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList());
List<TransactionTag> relations = transactionTagMapper.selectList(new LambdaQueryWrapper<TransactionTag>()
.in(TransactionTag::getTransactionId, transactionIds));
if (relations.isEmpty()) {
return;
}
List<Long> tagIds = relations.stream().map(TransactionTag::getTagId).distinct().collect(Collectors.toList());
Map<Long, Tag> tagMap = tagMapper.selectBatchIds(tagIds).stream()
.collect(Collectors.toMap(Tag::getId, tag -> tag));
Map<Long, List<TagVO>> 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<TransactionVO> voList) {
if (voList.isEmpty()) {
return;
}
List<Long> transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList());
List<AiImageTransaction> relations = aiImageTransactionMapper.selectList(
new LambdaQueryWrapper<AiImageTransaction>()
.in(AiImageTransaction::getTransactionId, transactionIds));
if (relations.isEmpty()) {
return;
}
Map<Long, List<Long>> 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<TransactionVO> voList) {
if (voList.isEmpty()) {
return;
}
List<Long> transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList());
List<TransactionGroupItem> items = transactionGroupItemMapper.selectList(
new LambdaQueryWrapper<TransactionGroupItem>()
.in(TransactionGroupItem::getTransactionId, transactionIds));
Map<Long, Long> counts = items.stream()
.collect(Collectors.groupingBy(TransactionGroupItem::getTransactionId, Collectors.counting()));
for (TransactionVO vo : voList) {
vo.setRelationGroupCount(counts.getOrDefault(vo.getId(), 0L).intValue());
}
}
}