feat: 账单导入、统计维度扩展与收支记录增强
- 账单导入:新增支付宝账单解析/导入接口,按 external_no 去重;前端拖拽上传 + 预览确认弹窗 - 分类可选:category_id 允许为空,支持「未分类」筛选,新增按类型一键批量设置分类 - 备注隐藏:新增 remark_hidden 与切换接口,列表默认显示 ****** 并可单格查看/取消隐藏,导出同步隐藏 - 统计扩展:新增 /statistics/summary、/statistics/group,支持日/周/月/季/年/支付方式/分类/类型维度与时间范围过滤 - 收支记录:金额区间过滤、金额/交易时间列排序、分页快速跳转、本页收支结余汇总、交易时间列前置并合并创建时间提示 - 用户偏好:统计面板与列表的筛选条件持久化到 localStorage,进入自动还原 - 其他:新增默认分类种子脚本(含美容美发)、commons-csv 依赖、上传大小限制 10MB、Vite 监听 0.0.0.0
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
package com.example.finance.service;
|
||||
|
||||
import com.example.finance.dto.BillImportRequest;
|
||||
import com.example.finance.enums.BillType;
|
||||
import com.example.finance.vo.BillImportResultVO;
|
||||
import com.example.finance.vo.BillParseResultVO;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 账单导入服务,负责「先解析、后导入」两阶段的业务逻辑。
|
||||
*
|
||||
* @date 2026-09-17 09:50:10
|
||||
*/
|
||||
public interface BillImportService {
|
||||
|
||||
/**
|
||||
* 解析账单文件,返回预览数据(不落库),并标记与系统已有记录重复的行。
|
||||
*
|
||||
* @param userId 当前用户ID
|
||||
* @param billType 账单类型
|
||||
* @param content 账单文件字节内容
|
||||
* @return 解析结果
|
||||
* @throws IOException 文件解析失败
|
||||
*/
|
||||
BillParseResultVO parse(Long userId, BillType billType, byte[] content) throws IOException;
|
||||
|
||||
/**
|
||||
* 导入用户勾选的账单行,按外部交易号去重并写入交易记录。
|
||||
*
|
||||
* @param userId 当前用户ID
|
||||
* @param request 导入请求
|
||||
* @return 导入结果统计
|
||||
*/
|
||||
BillImportResultVO confirm(Long userId, BillImportRequest request);
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package com.example.finance.service;
|
||||
|
||||
import com.example.finance.dto.StatisticsQuery;
|
||||
import com.example.finance.dto.StatisticsRangeQuery;
|
||||
import com.example.finance.enums.StatisticsDimension;
|
||||
import com.example.finance.vo.CategoryStatisticsVO;
|
||||
import com.example.finance.vo.MonthlyStatisticsVO;
|
||||
import com.example.finance.vo.StatisticsGroupVO;
|
||||
import com.example.finance.vo.StatisticsSummaryVO;
|
||||
import com.example.finance.vo.TrendStatisticsVO;
|
||||
|
||||
import java.util.List;
|
||||
@@ -38,4 +42,23 @@ public interface StatisticsService {
|
||||
* @return 按日排列的趋势统计列表
|
||||
*/
|
||||
List<TrendStatisticsVO> getTrendStatistics(Long userId, StatisticsQuery query);
|
||||
|
||||
/**
|
||||
* 获取指定时间范围内的统计概览(收支汇总、日均、最大单笔、平均每笔)
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param query 时间范围查询条件
|
||||
* @return 统计概览
|
||||
*/
|
||||
StatisticsSummaryVO getSummary(Long userId, StatisticsRangeQuery query);
|
||||
|
||||
/**
|
||||
* 按指定维度对时间范围内的交易进行分组统计
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param query 时间范围查询条件
|
||||
* @param dimension 统计维度
|
||||
* @return 分组统计列表
|
||||
*/
|
||||
List<StatisticsGroupVO> getGroup(Long userId, StatisticsRangeQuery query, StatisticsDimension dimension);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.example.finance.service;
|
||||
|
||||
import com.example.finance.common.PageResult;
|
||||
import com.example.finance.dto.BatchCategoryRequest;
|
||||
import com.example.finance.dto.TransactionQuery;
|
||||
import com.example.finance.dto.TransactionRequest;
|
||||
import com.example.finance.vo.TransactionExportVO;
|
||||
@@ -64,4 +65,22 @@ public interface TransactionService {
|
||||
* @param includeInStats 新的标记值
|
||||
*/
|
||||
void toggleStats(Long userId, Long id, Boolean includeInStats);
|
||||
|
||||
/**
|
||||
* 切换单条交易记录的备注隐藏标记
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param id 交易记录ID
|
||||
* @param remarkHidden 新的标记值
|
||||
*/
|
||||
void toggleRemarkHidden(Long userId, Long id, Boolean remarkHidden);
|
||||
|
||||
/**
|
||||
* 批量设置交易记录的分类(用于对当前表格展示的数据一键归类)
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param request 批量设置请求(记录ID列表、支出/收入默认分类)
|
||||
* @return 实际更新的记录数
|
||||
*/
|
||||
int batchUpdateCategory(Long userId, BatchCategoryRequest request);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package com.example.finance.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.example.finance.common.BusinessException;
|
||||
import com.example.finance.dto.BillImportRequest;
|
||||
import com.example.finance.entity.Category;
|
||||
import com.example.finance.entity.Transaction;
|
||||
import com.example.finance.enums.BillType;
|
||||
import com.example.finance.importer.BillParser;
|
||||
import com.example.finance.importer.ParsedBillRow;
|
||||
import com.example.finance.mapper.CategoryMapper;
|
||||
import com.example.finance.mapper.TransactionMapper;
|
||||
import com.example.finance.service.BillImportService;
|
||||
import com.example.finance.vo.BillImportResultVO;
|
||||
import com.example.finance.vo.BillParseResultVO;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 账单导入服务实现类。
|
||||
*
|
||||
* <p>解析阶段按账单类型分发到对应的 {@link BillParser},并标记重复行;
|
||||
* 导入阶段校验分类归属、按外部交易号去重后逐条写入交易记录。</p>
|
||||
*
|
||||
* @date 2026-09-17 09:50:17
|
||||
*/
|
||||
@Service
|
||||
public class BillImportServiceImpl implements BillImportService {
|
||||
|
||||
private final Map<BillType, BillParser> parserMap = new EnumMap<>(BillType.class);
|
||||
private final TransactionMapper transactionMapper;
|
||||
private final CategoryMapper categoryMapper;
|
||||
|
||||
public BillImportServiceImpl(List<BillParser> parsers,
|
||||
TransactionMapper transactionMapper,
|
||||
CategoryMapper categoryMapper) {
|
||||
// 按账单类型注册解析器,便于扩展新账单来源
|
||||
for (BillParser parser : parsers) {
|
||||
parserMap.put(parser.getBillType(), parser);
|
||||
}
|
||||
this.transactionMapper = transactionMapper;
|
||||
this.categoryMapper = categoryMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 解析后批量查询已存在的交易号,标记重复行并取消其默认勾选。
|
||||
*/
|
||||
@Override
|
||||
public BillParseResultVO parse(Long userId, BillType billType, byte[] content) throws IOException {
|
||||
BillParser parser = parserMap.get(billType);
|
||||
if (parser == null) {
|
||||
throw new BusinessException("暂不支持的账单类型:" + billType);
|
||||
}
|
||||
|
||||
List<ParsedBillRow> rows = parser.parse(content);
|
||||
|
||||
// 批量查询已存在的交易号,用于标记重复
|
||||
List<String> externalNos = rows.stream()
|
||||
.map(ParsedBillRow::getExternalNo)
|
||||
.filter(StringUtils::hasText)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
Set<String> existing = findExistingExternalNos(userId, externalNos);
|
||||
for (ParsedBillRow row : rows) {
|
||||
if (StringUtils.hasText(row.getExternalNo()) && existing.contains(row.getExternalNo())) {
|
||||
row.setDuplicate(true);
|
||||
row.setSelected(false);
|
||||
}
|
||||
}
|
||||
|
||||
BillParseResultVO vo = new BillParseResultVO();
|
||||
vo.setBillType(billType.name());
|
||||
vo.setTotal(rows.size());
|
||||
vo.setRows(rows);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 逐条校验并插入,单条失败不影响其余记录,最终返回成功/跳过/失败统计。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public BillImportResultVO confirm(Long userId, BillImportRequest request) {
|
||||
List<BillImportRequest.Item> items = request.getItems();
|
||||
if (items == null || items.isEmpty()) {
|
||||
throw new BusinessException("请至少勾选一条记录");
|
||||
}
|
||||
|
||||
// 分类为可选:仅在用户选择了默认分类时才校验其归属与类型
|
||||
Category expenseCategory = request.getExpenseCategoryId() == null ? null
|
||||
: requireCategory(userId, request.getExpenseCategoryId(), "EXPENSE");
|
||||
Category incomeCategory = request.getIncomeCategoryId() == null ? null
|
||||
: requireCategory(userId, request.getIncomeCategoryId(), "INCOME");
|
||||
|
||||
// 已存在交易号 + 本批次已插入交易号,共同用于去重
|
||||
Set<String> seen = findExistingExternalNos(userId, items.stream()
|
||||
.map(BillImportRequest.Item::getExternalNo)
|
||||
.filter(StringUtils::hasText)
|
||||
.distinct()
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
int imported = 0;
|
||||
int skipped = 0;
|
||||
int failed = 0;
|
||||
List<String> messages = new ArrayList<>();
|
||||
|
||||
for (BillImportRequest.Item item : items) {
|
||||
String label = StringUtils.hasText(item.getExternalNo()) ? item.getExternalNo() : "未知交易号";
|
||||
try {
|
||||
// 基础字段校验
|
||||
if (!"INCOME".equals(item.getType()) && !"EXPENSE".equals(item.getType())) {
|
||||
failed++;
|
||||
messages.add(label + ":交易类型无效");
|
||||
continue;
|
||||
}
|
||||
if (item.getTransactionTime() == null) {
|
||||
failed++;
|
||||
messages.add(label + ":交易时间为空");
|
||||
continue;
|
||||
}
|
||||
if (item.getAmount() == null || item.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
|
||||
failed++;
|
||||
messages.add(label + ":金额无效");
|
||||
continue;
|
||||
}
|
||||
// 去重:同一用户下交易号唯一
|
||||
if (StringUtils.hasText(item.getExternalNo()) && seen.contains(item.getExternalNo())) {
|
||||
skipped++;
|
||||
messages.add(label + ":已存在,跳过");
|
||||
continue;
|
||||
}
|
||||
|
||||
Category category = "INCOME".equals(item.getType()) ? incomeCategory : expenseCategory;
|
||||
Transaction transaction = new Transaction();
|
||||
transaction.setUserId(userId);
|
||||
// 未选择默认分类时置空,表示未分类
|
||||
transaction.setCategoryId(category != null ? category.getId() : null);
|
||||
transaction.setType(item.getType());
|
||||
transaction.setAmount(item.getAmount());
|
||||
transaction.setPaymentMethod("ALIPAY");
|
||||
transaction.setRemark(truncate(buildRemark(item.getCounterparty(), item.getProduct()), 255));
|
||||
transaction.setExternalNo(item.getExternalNo());
|
||||
transaction.setTransactionTime(item.getTransactionTime());
|
||||
transaction.setIncludeInStats(item.getIncludeInStats() == null ? Boolean.TRUE : item.getIncludeInStats());
|
||||
transactionMapper.insert(transaction);
|
||||
|
||||
if (StringUtils.hasText(item.getExternalNo())) {
|
||||
seen.add(item.getExternalNo());
|
||||
}
|
||||
imported++;
|
||||
} catch (RuntimeException e) {
|
||||
failed++;
|
||||
messages.add(label + ":导入失败," + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
BillImportResultVO result = new BillImportResultVO();
|
||||
result.setTotal(items.size());
|
||||
result.setImported(imported);
|
||||
result.setSkipped(skipped);
|
||||
result.setFailed(failed);
|
||||
result.setMessages(messages);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定交易号中已存在的记录
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param externalNos 交易号列表
|
||||
* @return 已存在的交易号集合
|
||||
*/
|
||||
private Set<String> findExistingExternalNos(Long userId, List<String> externalNos) {
|
||||
Set<String> existing = new HashSet<>();
|
||||
if (externalNos.isEmpty()) {
|
||||
return existing;
|
||||
}
|
||||
List<Transaction> list = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
|
||||
.select(Transaction::getExternalNo)
|
||||
.eq(Transaction::getUserId, userId)
|
||||
.in(Transaction::getExternalNo, externalNos));
|
||||
for (Transaction t : list) {
|
||||
if (t.getExternalNo() != null) {
|
||||
existing.add(t.getExternalNo());
|
||||
}
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验分类是否存在、归属当前用户且类型匹配
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param categoryId 分类ID
|
||||
* @param type 期望的分类类型
|
||||
* @return 分类实体
|
||||
*/
|
||||
private Category requireCategory(Long userId, Long categoryId, String type) {
|
||||
if (categoryId == null) {
|
||||
throw new BusinessException("请选择" + ("INCOME".equals(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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据交易对方与商品名称构造备注
|
||||
*
|
||||
* @param counterparty 交易对方
|
||||
* @param product 商品名称
|
||||
* @return 备注文本
|
||||
*/
|
||||
private String buildRemark(String counterparty, String product) {
|
||||
String c = counterparty == null ? "" : counterparty.trim();
|
||||
String p = product == null ? "" : product.trim();
|
||||
if (!c.isEmpty() && !p.isEmpty() && !c.equals(p)) {
|
||||
return c + " · " + p;
|
||||
}
|
||||
return !c.isEmpty() ? c : p;
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断字符串以适配数据库字段长度
|
||||
*
|
||||
* @param value 原字符串
|
||||
* @param maxLength 最大长度
|
||||
* @return 截断后的字符串
|
||||
*/
|
||||
private String truncate(String value, int maxLength) {
|
||||
if (value == null || value.length() <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
return value.substring(0, maxLength);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,22 @@
|
||||
package com.example.finance.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.example.finance.common.BusinessException;
|
||||
import com.example.finance.dto.StatisticsQuery;
|
||||
import com.example.finance.dto.StatisticsRangeQuery;
|
||||
import com.example.finance.entity.Category;
|
||||
import com.example.finance.entity.Transaction;
|
||||
import com.example.finance.enums.StatisticsDimension;
|
||||
import com.example.finance.mapper.CategoryMapper;
|
||||
import com.example.finance.mapper.TransactionMapper;
|
||||
import com.example.finance.service.StatisticsService;
|
||||
import com.example.finance.vo.CategoryStatisticsVO;
|
||||
import com.example.finance.vo.MonthlyStatisticsVO;
|
||||
import com.example.finance.vo.StatisticsGroupVO;
|
||||
import com.example.finance.vo.StatisticsSummaryVO;
|
||||
import com.example.finance.vo.TrendStatisticsVO;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
@@ -18,14 +24,19 @@ import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.YearMonth;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 统计服务实现类,提供月度收支汇总、分类占比分析和每日收支趋势统计等业务逻辑。
|
||||
* 统计服务实现类,提供月度收支汇总、分类占比分析、趋势统计、概览指标及通用分组统计。
|
||||
*
|
||||
* <p>所有统计均基于 SQL 聚合(MyBatis-Plus {@code selectMaps})完成,避免将整段区间的
|
||||
* 交易记录加载到内存;仅统计计入统计({@code include_in_stats = 1 或 NULL})的记录。</p>
|
||||
*/
|
||||
@Service
|
||||
public class StatisticsServiceImpl implements StatisticsService {
|
||||
@@ -41,149 +52,427 @@ public class StatisticsServiceImpl implements StatisticsService {
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 遍历指定月份的所有交易记录,分别累加收入和支出,计算结余和交易笔数。
|
||||
* 复用概览统计,返回该月的收入、支出、结余与笔数。
|
||||
*/
|
||||
@Override
|
||||
public MonthlyStatisticsVO getMonthlyStatistics(Long userId, StatisticsQuery query) {
|
||||
List<Transaction> transactions = getMonthTransactions(userId, query);
|
||||
|
||||
StatisticsSummaryVO summary = getSummary(userId, monthToRange(query.getYear(), query.getMonth()));
|
||||
MonthlyStatisticsVO vo = new MonthlyStatisticsVO();
|
||||
BigDecimal totalIncome = BigDecimal.ZERO;
|
||||
BigDecimal totalExpense = BigDecimal.ZERO;
|
||||
|
||||
// 按交易类型分别累加收入和支出
|
||||
for (Transaction t : transactions) {
|
||||
if ("INCOME".equals(t.getType())) {
|
||||
totalIncome = totalIncome.add(t.getAmount());
|
||||
} else {
|
||||
totalExpense = totalExpense.add(t.getAmount());
|
||||
}
|
||||
}
|
||||
|
||||
vo.setTotalIncome(totalIncome);
|
||||
vo.setTotalExpense(totalExpense);
|
||||
vo.setBalance(totalIncome.subtract(totalExpense));
|
||||
vo.setTransactionCount(transactions.size());
|
||||
vo.setTotalIncome(summary.getTotalIncome());
|
||||
vo.setTotalExpense(summary.getTotalExpense());
|
||||
vo.setBalance(summary.getBalance());
|
||||
vo.setTransactionCount(summary.getTransactionCount());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 按分类ID分组统计交易金额、笔数,并计算各分类占总金额的百分比,结果按金额降序排列。
|
||||
* 按分类维度聚合该月数据,并填充分类名称、图标与类型。
|
||||
*/
|
||||
@Override
|
||||
public List<CategoryStatisticsVO> getCategoryStatistics(Long userId, StatisticsQuery query) {
|
||||
List<Transaction> transactions = getMonthTransactions(userId, query);
|
||||
|
||||
// 按分类ID分组
|
||||
Map<Long, List<Transaction>> grouped = transactions.stream()
|
||||
.collect(Collectors.groupingBy(Transaction::getCategoryId));
|
||||
|
||||
// 批量查询分类信息
|
||||
List<Long> categoryIds = new ArrayList<>(grouped.keySet());
|
||||
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));
|
||||
}
|
||||
|
||||
// 计算总金额,用于后续计算百分比
|
||||
BigDecimal totalAmount = transactions.stream()
|
||||
.map(Transaction::getAmount)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
// 构建每个分类的统计结果
|
||||
Map<Long, Category> finalCategoryMap = categoryMap;
|
||||
return grouped.entrySet().stream()
|
||||
.map(entry -> {
|
||||
List<Transaction> list = entry.getValue();
|
||||
Category category = finalCategoryMap.get(entry.getKey());
|
||||
BigDecimal amount = list.stream()
|
||||
.map(Transaction::getAmount)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
CategoryStatisticsVO vo = new CategoryStatisticsVO();
|
||||
vo.setCategoryId(entry.getKey());
|
||||
vo.setCategoryName(category != null ? category.getName() : "未知");
|
||||
vo.setCategoryIcon(category != null ? category.getIcon() : "");
|
||||
vo.setType(category != null ? category.getType() : "");
|
||||
vo.setAmount(amount);
|
||||
vo.setCount(list.size());
|
||||
// 计算占比百分比,保留两位小数,四舍五入
|
||||
if (totalAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||
vo.setPercentage(amount.multiply(BigDecimal.valueOf(100))
|
||||
.divide(totalAmount, 2, RoundingMode.HALF_UP));
|
||||
} else {
|
||||
vo.setPercentage(BigDecimal.ZERO);
|
||||
}
|
||||
return vo;
|
||||
})
|
||||
// 按金额降序排列
|
||||
.sorted((a, b) -> b.getAmount().compareTo(a.getAmount()))
|
||||
.collect(Collectors.toList());
|
||||
List<StatisticsGroupVO> groups = getGroup(userId, monthToRange(query.getYear(), query.getMonth()),
|
||||
StatisticsDimension.CATEGORY);
|
||||
return groups.stream().map(g -> {
|
||||
CategoryStatisticsVO vo = new CategoryStatisticsVO();
|
||||
vo.setCategoryId(parseLong(g.getKey()));
|
||||
vo.setCategoryName(g.getName());
|
||||
vo.setCategoryIcon(g.getIcon());
|
||||
vo.setType(g.getType());
|
||||
vo.setAmount(g.getTotal());
|
||||
vo.setCount(g.getCount());
|
||||
vo.setPercentage(g.getPercentage());
|
||||
return vo;
|
||||
}).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 统计指定月份每天的收入和支出,返回按日期排列的趋势数据列表。
|
||||
* 按日聚合该月数据,并补全当月无交易的日期(金额为 0)。
|
||||
*/
|
||||
@Override
|
||||
public List<TrendStatisticsVO> getTrendStatistics(Long userId, StatisticsQuery query) {
|
||||
YearMonth yearMonth = YearMonth.of(query.getYear(), query.getMonth());
|
||||
int daysInMonth = yearMonth.lengthOfMonth();
|
||||
List<StatisticsGroupVO> groups = getGroup(userId, monthToRange(query.getYear(), query.getMonth()),
|
||||
StatisticsDimension.DAY);
|
||||
Map<String, StatisticsGroupVO> byDate = groups.stream()
|
||||
.filter(g -> g.getKey() != null)
|
||||
.collect(Collectors.toMap(StatisticsGroupVO::getKey, g -> g, (a, b) -> a));
|
||||
|
||||
// 初始化每日收入和支出为零
|
||||
Map<Integer, BigDecimal> incomeMap = new HashMap<>();
|
||||
Map<Integer, BigDecimal> expenseMap = new HashMap<>();
|
||||
for (int i = 1; i <= daysInMonth; i++) {
|
||||
incomeMap.put(i, BigDecimal.ZERO);
|
||||
expenseMap.put(i, BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
// 累加每日交易金额
|
||||
List<Transaction> transactions = getMonthTransactions(userId, query);
|
||||
for (Transaction t : transactions) {
|
||||
int day = t.getTransactionTime().getDayOfMonth();
|
||||
if ("INCOME".equals(t.getType())) {
|
||||
incomeMap.merge(day, t.getAmount(), BigDecimal::add);
|
||||
} else {
|
||||
expenseMap.merge(day, t.getAmount(), BigDecimal::add);
|
||||
}
|
||||
}
|
||||
|
||||
// 组装每日趋势数据
|
||||
List<TrendStatisticsVO> result = new ArrayList<>();
|
||||
for (int i = 1; i <= daysInMonth; i++) {
|
||||
int days = yearMonth.lengthOfMonth();
|
||||
for (int day = 1; day <= days; day++) {
|
||||
String label = String.format("%d-%02d-%02d", query.getYear(), query.getMonth(), day);
|
||||
StatisticsGroupVO group = byDate.get(label);
|
||||
TrendStatisticsVO vo = new TrendStatisticsVO();
|
||||
vo.setDate(String.format("%d-%02d-%02d", query.getYear(), query.getMonth(), i));
|
||||
vo.setIncome(incomeMap.get(i));
|
||||
vo.setExpense(expenseMap.get(i));
|
||||
vo.setDate(label);
|
||||
vo.setIncome(group != null ? group.getIncome() : BigDecimal.ZERO);
|
||||
vo.setExpense(group != null ? group.getExpense() : BigDecimal.ZERO);
|
||||
result.add(vo);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定月份内用户的所有交易记录(仅包含计入统计的记录)
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 一条聚合查询取收支总额与笔数,另查最大单笔支出,并计算日均与平均每笔。
|
||||
*/
|
||||
@Override
|
||||
public StatisticsSummaryVO getSummary(Long userId, StatisticsRangeQuery query) {
|
||||
QueryWrapper<Transaction> wrapper = baseWrapper(userId, query);
|
||||
wrapper.select(
|
||||
"COALESCE(SUM(CASE WHEN type = 'INCOME' THEN amount ELSE 0 END), 0) AS totalIncome",
|
||||
"COALESCE(SUM(CASE WHEN type = 'EXPENSE' THEN amount ELSE 0 END), 0) AS totalExpense",
|
||||
"COUNT(*) AS totalCount",
|
||||
"COALESCE(SUM(CASE WHEN type = 'INCOME' THEN 1 ELSE 0 END), 0) AS incomeCount",
|
||||
"COALESCE(SUM(CASE WHEN type = 'EXPENSE' THEN 1 ELSE 0 END), 0) AS expenseCount");
|
||||
List<Map<String, Object>> rows = transactionMapper.selectMaps(wrapper);
|
||||
Map<String, Object> row = rows.isEmpty() ? Map.of() : rows.get(0);
|
||||
|
||||
BigDecimal totalIncome = toBigDecimal(row.get("totalIncome"));
|
||||
BigDecimal totalExpense = toBigDecimal(row.get("totalExpense"));
|
||||
int expenseCount = toInt(row.get("expenseCount"));
|
||||
|
||||
// 最大单笔支出
|
||||
QueryWrapper<Transaction> maxWrapper = baseWrapper(userId, query);
|
||||
maxWrapper.eq("type", "EXPENSE").orderByDesc("amount").last("LIMIT 1");
|
||||
Transaction maxExpense = transactionMapper.selectOne(maxWrapper);
|
||||
|
||||
int dayCount = resolveDayCount(userId, query);
|
||||
|
||||
StatisticsSummaryVO vo = new StatisticsSummaryVO();
|
||||
vo.setTotalIncome(totalIncome);
|
||||
vo.setTotalExpense(totalExpense);
|
||||
vo.setBalance(totalIncome.subtract(totalExpense));
|
||||
vo.setTransactionCount(toInt(row.get("totalCount")));
|
||||
vo.setDayCount(dayCount);
|
||||
vo.setAvgDailyIncome(divide(totalIncome, dayCount));
|
||||
vo.setAvgDailyExpense(divide(totalExpense, dayCount));
|
||||
vo.setAvgExpenseAmount(divide(totalExpense, expenseCount));
|
||||
if (maxExpense != null) {
|
||||
vo.setMaxExpenseAmount(maxExpense.getAmount());
|
||||
vo.setMaxExpenseTime(maxExpense.getTransactionTime());
|
||||
vo.setMaxExpenseRemark(maxExpense.getRemark());
|
||||
} else {
|
||||
vo.setMaxExpenseAmount(BigDecimal.ZERO);
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 按维度拼接分组表达式做 SQL 聚合,再在内存中解析名称、图标并计算占比与排序。
|
||||
*/
|
||||
@Override
|
||||
public List<StatisticsGroupVO> getGroup(Long userId, StatisticsRangeQuery query, StatisticsDimension dimension) {
|
||||
String expr = groupExpression(dimension);
|
||||
QueryWrapper<Transaction> wrapper = baseWrapper(userId, query);
|
||||
wrapper.select(expr + " AS grp",
|
||||
"COALESCE(SUM(CASE WHEN type = 'INCOME' THEN amount ELSE 0 END), 0) AS income",
|
||||
"COALESCE(SUM(CASE WHEN type = 'EXPENSE' THEN amount ELSE 0 END), 0) AS expense",
|
||||
"COUNT(*) AS cnt")
|
||||
.groupBy(expr);
|
||||
if (dimension.isTime()) {
|
||||
wrapper.orderByAsc(expr);
|
||||
}
|
||||
List<Map<String, Object>> rows = transactionMapper.selectMaps(wrapper);
|
||||
|
||||
// 分类维度需批量查询分类信息
|
||||
Map<Long, Category> categoryMap = Map.of();
|
||||
if (dimension == StatisticsDimension.CATEGORY) {
|
||||
List<Long> categoryIds = rows.stream()
|
||||
.map(r -> parseLong(stringValue(r.get("grp"))))
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
if (!categoryIds.isEmpty()) {
|
||||
categoryMap = categoryMapper.selectBatchIds(categoryIds).stream()
|
||||
.collect(Collectors.toMap(Category::getId, c -> c));
|
||||
}
|
||||
}
|
||||
|
||||
List<StatisticsGroupVO> result = new ArrayList<>();
|
||||
BigDecimal grandTotal = BigDecimal.ZERO;
|
||||
for (Map<String, Object> r : rows) {
|
||||
String key = stringValue(r.get("grp"));
|
||||
BigDecimal income = toBigDecimal(r.get("income"));
|
||||
BigDecimal expense = toBigDecimal(r.get("expense"));
|
||||
BigDecimal total = income.add(expense);
|
||||
|
||||
StatisticsGroupVO vo = new StatisticsGroupVO();
|
||||
vo.setKey(key);
|
||||
vo.setIncome(income);
|
||||
vo.setExpense(expense);
|
||||
vo.setTotal(total);
|
||||
vo.setCount(toInt(r.get("cnt")));
|
||||
fillGroupMeta(vo, dimension, key, categoryMap, income, expense);
|
||||
result.add(vo);
|
||||
grandTotal = grandTotal.add(total);
|
||||
}
|
||||
|
||||
// 非时间维度按金额降序并计算占比
|
||||
if (!dimension.isTime()) {
|
||||
for (StatisticsGroupVO vo : result) {
|
||||
vo.setPercentage(grandTotal.compareTo(BigDecimal.ZERO) > 0
|
||||
? vo.getTotal().multiply(BigDecimal.valueOf(100)).divide(grandTotal, 2, RoundingMode.HALF_UP)
|
||||
: BigDecimal.ZERO);
|
||||
}
|
||||
result.sort((a, b) -> b.getTotal().compareTo(a.getTotal()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造带用户、时间区间、过滤条件与计入统计条件的查询包装器
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param query 统计查询条件(年份、月份)
|
||||
* @return 该月份的交易记录列表
|
||||
* @param query 统计查询条件(时间范围及可选过滤条件)
|
||||
* @return 查询包装器
|
||||
*/
|
||||
private List<Transaction> getMonthTransactions(Long userId, StatisticsQuery query) {
|
||||
YearMonth yearMonth = YearMonth.of(query.getYear(), query.getMonth());
|
||||
// 计算月份的起止时间
|
||||
LocalDateTime startTime = yearMonth.atDay(1).atStartOfDay();
|
||||
LocalDateTime endTime = yearMonth.atEndOfMonth().atTime(LocalTime.MAX);
|
||||
private QueryWrapper<Transaction> baseWrapper(Long userId, StatisticsRangeQuery query) {
|
||||
QueryWrapper<Transaction> wrapper = new QueryWrapper<>();
|
||||
wrapper.eq("user_id", userId);
|
||||
|
||||
return transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
|
||||
.eq(Transaction::getUserId, userId)
|
||||
.ge(Transaction::getTransactionTime, startTime)
|
||||
.le(Transaction::getTransactionTime, endTime)
|
||||
// 仅统计计入统计的记录(排除借款、账户间转账等)
|
||||
.and(w -> w.eq(Transaction::getIncludeInStats, true)
|
||||
.or().isNull(Transaction::getIncludeInStats)));
|
||||
if (query != null) {
|
||||
LocalDateTime start = parseTime(query.getStartTime(), false);
|
||||
LocalDateTime end = parseTime(query.getEndTime(), true);
|
||||
if (start != null) {
|
||||
wrapper.ge("transaction_time", start);
|
||||
}
|
||||
if (end != null) {
|
||||
wrapper.le("transaction_time", end);
|
||||
}
|
||||
// 交易类型筛选(多选)
|
||||
if (query.getTypes() != null && !query.getTypes().isEmpty()) {
|
||||
wrapper.in("type", query.getTypes());
|
||||
}
|
||||
// 分类筛选(多选);同时勾选「未分类」时与其取并集
|
||||
boolean hasCategoryIds = query.getCategoryIds() != null && !query.getCategoryIds().isEmpty();
|
||||
boolean uncategorized = Boolean.TRUE.equals(query.getUncategorized());
|
||||
if (hasCategoryIds && uncategorized) {
|
||||
wrapper.and(w -> w.in("category_id", query.getCategoryIds()).or().isNull("category_id"));
|
||||
} else if (hasCategoryIds) {
|
||||
wrapper.in("category_id", query.getCategoryIds());
|
||||
} else if (uncategorized) {
|
||||
wrapper.isNull("category_id");
|
||||
}
|
||||
// 支付方式筛选(多选)
|
||||
if (query.getPaymentMethods() != null && !query.getPaymentMethods().isEmpty()) {
|
||||
wrapper.in("payment_method", query.getPaymentMethods());
|
||||
}
|
||||
// 金额区间筛选
|
||||
if (query.getMinAmount() != null) {
|
||||
wrapper.ge("amount", query.getMinAmount());
|
||||
}
|
||||
if (query.getMaxAmount() != null) {
|
||||
wrapper.le("amount", query.getMaxAmount());
|
||||
}
|
||||
// 备注关键字模糊匹配
|
||||
if (StringUtils.hasText(query.getRemark())) {
|
||||
wrapper.like("remark", query.getRemark());
|
||||
}
|
||||
}
|
||||
|
||||
// 仅统计计入统计的记录(排除借款、账户间转账等)
|
||||
wrapper.and(w -> w.eq("include_in_stats", true).or().isNull("include_in_stats"));
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算统计区间天数:优先按起止日期计算,否则统计有交易的天数
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param query 时间范围查询条件
|
||||
* @return 天数
|
||||
*/
|
||||
private int resolveDayCount(Long userId, StatisticsRangeQuery query) {
|
||||
LocalDateTime start = parseTime(query == null ? null : query.getStartTime(), false);
|
||||
LocalDateTime end = parseTime(query == null ? null : query.getEndTime(), true);
|
||||
if (start != null && end != null) {
|
||||
long days = ChronoUnit.DAYS.between(start.toLocalDate(), end.toLocalDate()) + 1;
|
||||
return (int) Math.max(days, 1);
|
||||
}
|
||||
QueryWrapper<Transaction> wrapper = baseWrapper(userId, query);
|
||||
wrapper.select("COUNT(DISTINCT DATE(transaction_time)) AS dayCount");
|
||||
List<Map<String, Object>> rows = transactionMapper.selectMaps(wrapper);
|
||||
return rows.isEmpty() ? 0 : toInt(rows.get(0).get("dayCount"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充分组结果的显示名称、图标与类型
|
||||
*
|
||||
* @param vo 分组结果
|
||||
* @param dimension 统计维度
|
||||
* @param key 分组键
|
||||
* @param categoryMap 分类映射
|
||||
* @param income 收入金额
|
||||
* @param expense 支出金额
|
||||
*/
|
||||
private void fillGroupMeta(StatisticsGroupVO vo, StatisticsDimension dimension, String key,
|
||||
Map<Long, Category> categoryMap, BigDecimal income, BigDecimal expense) {
|
||||
switch (dimension) {
|
||||
case CATEGORY -> {
|
||||
Category category = categoryMap.get(parseLong(key));
|
||||
vo.setName(category != null ? category.getName() : "未分类");
|
||||
vo.setIcon(category != null ? category.getIcon() : "");
|
||||
vo.setType(category != null ? category.getType()
|
||||
: (income.compareTo(expense) >= 0 ? "INCOME" : "EXPENSE"));
|
||||
}
|
||||
case PAYMENT_METHOD -> vo.setName(paymentLabel(key));
|
||||
case TYPE -> {
|
||||
vo.setName("INCOME".equals(key) ? "收入" : "支出");
|
||||
vo.setType(key);
|
||||
}
|
||||
default -> vo.setName(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据维度返回 SQL 分组表达式
|
||||
*
|
||||
* @param dimension 统计维度
|
||||
* @return 分组表达式
|
||||
*/
|
||||
private String groupExpression(StatisticsDimension dimension) {
|
||||
return switch (dimension) {
|
||||
case DAY -> "DATE_FORMAT(transaction_time, '%Y-%m-%d')";
|
||||
case WEEK -> "DATE_FORMAT(transaction_time, '%x-W%v')";
|
||||
case MONTH -> "DATE_FORMAT(transaction_time, '%Y-%m')";
|
||||
case QUARTER -> "CONCAT(YEAR(transaction_time), '-Q', QUARTER(transaction_time))";
|
||||
case YEAR -> "DATE_FORMAT(transaction_time, '%Y')";
|
||||
case PAYMENT_METHOD -> "payment_method";
|
||||
case CATEGORY -> "category_id";
|
||||
case TYPE -> "type";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将年月转换为时间范围查询条件
|
||||
*
|
||||
* @param year 年份
|
||||
* @param month 月份
|
||||
* @return 时间范围
|
||||
*/
|
||||
private StatisticsRangeQuery monthToRange(Integer year, Integer month) {
|
||||
YearMonth yearMonth = YearMonth.of(year, month);
|
||||
StatisticsRangeQuery range = new StatisticsRangeQuery();
|
||||
range.setStartTime(yearMonth.atDay(1).atStartOfDay().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
range.setEndTime(yearMonth.atEndOfMonth().atTime(LocalTime.MAX).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
return range;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析时间字符串,兼容日期与日期时间格式
|
||||
*
|
||||
* @param value 时间字符串
|
||||
* @param endOfDay 仅日期时是否取当天末尾
|
||||
* @return 解析结果,空值返回 null
|
||||
*/
|
||||
private LocalDateTime parseTime(String value, boolean endOfDay) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (value.length() == 10) {
|
||||
LocalDate date = LocalDate.parse(value);
|
||||
return endOfDay ? date.atTime(LocalTime.MAX) : date.atStartOfDay();
|
||||
}
|
||||
return LocalDateTime.parse(value, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("时间格式错误:" + value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将支付方式编码转换为中文名称
|
||||
*
|
||||
* @param code 支付方式编码
|
||||
* @return 中文名称
|
||||
*/
|
||||
private String paymentLabel(String code) {
|
||||
if (!StringUtils.hasText(code)) {
|
||||
return "未填写";
|
||||
}
|
||||
return switch (code) {
|
||||
case "WECHAT" -> "微信";
|
||||
case "ALIPAY" -> "支付宝";
|
||||
case "CASH" -> "现金";
|
||||
case "BANK" -> "银行卡";
|
||||
default -> code;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 字符串转 Long,空值或非法值返回 null
|
||||
*
|
||||
* @param value 字符串
|
||||
* @return Long 值
|
||||
*/
|
||||
private Long parseLong(String value) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(value).longValue();
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将查询结果值转换为字符串
|
||||
*
|
||||
* @param value 结果值
|
||||
* @return 字符串,空值返回 null
|
||||
*/
|
||||
private String stringValue(Object value) {
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将查询结果值转换为 BigDecimal
|
||||
*
|
||||
* @param value 结果值
|
||||
* @return BigDecimal,空值返回 0
|
||||
*/
|
||||
private BigDecimal toBigDecimal(Object value) {
|
||||
if (value == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
if (value instanceof BigDecimal decimal) {
|
||||
return decimal;
|
||||
}
|
||||
return new BigDecimal(value.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将查询结果值转换为 int
|
||||
*
|
||||
* @param value 结果值
|
||||
* @return int 值,空值返回 0
|
||||
*/
|
||||
private int toInt(Object value) {
|
||||
if (value == null) {
|
||||
return 0;
|
||||
}
|
||||
return ((Number) value).intValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全除法,保留两位小数
|
||||
*
|
||||
* @param dividend 被除数
|
||||
* @param divisor 除数
|
||||
* @return 商,除数为 0 时返回 0
|
||||
*/
|
||||
private BigDecimal divide(BigDecimal dividend, int divisor) {
|
||||
if (divisor <= 0) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
return dividend.divide(BigDecimal.valueOf(divisor), 2, RoundingMode.HALF_UP);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.example.finance.service.impl;
|
||||
|
||||
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 com.example.finance.common.BusinessException;
|
||||
import com.example.finance.common.PageResult;
|
||||
import com.example.finance.dto.BatchCategoryRequest;
|
||||
import com.example.finance.dto.TransactionQuery;
|
||||
import com.example.finance.dto.TransactionRequest;
|
||||
import com.example.finance.entity.Category;
|
||||
@@ -29,6 +31,9 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
public class TransactionServiceImpl implements TransactionService {
|
||||
|
||||
/** 备注被隐藏时在导出文件中使用的占位符 */
|
||||
private static final String REMARK_HIDDEN_PLACEHOLDER = "******";
|
||||
|
||||
private final TransactionMapper transactionMapper;
|
||||
private final CategoryMapper categoryMapper;
|
||||
|
||||
@@ -52,9 +57,16 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
if (query.getTypes() != null && !query.getTypes().isEmpty()) {
|
||||
wrapper.in(Transaction::getType, query.getTypes());
|
||||
}
|
||||
// 按分类ID筛选(多选)
|
||||
if (query.getCategoryIds() != null && !query.getCategoryIds().isEmpty()) {
|
||||
// 按分类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())) {
|
||||
@@ -74,6 +86,17 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
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());
|
||||
}
|
||||
|
||||
// 按交易时间降序排列
|
||||
wrapper.orderByDesc(Transaction::getTransactionTime);
|
||||
@@ -189,6 +212,96 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
transactionMapper.updateById(transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 切换单条交易记录的备注隐藏标记,用于表格中直接设置。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void toggleRemarkHidden(Long userId, Long id, Boolean remarkHidden) {
|
||||
Transaction transaction = transactionMapper.selectById(id);
|
||||
if (transaction == null || !transaction.getUserId().equals(userId)) {
|
||||
throw new BusinessException("记录不存在");
|
||||
}
|
||||
transaction.setRemarkHidden(remarkHidden == null ? Boolean.FALSE : remarkHidden);
|
||||
transactionMapper.updateById(transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@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(), "EXPENSE");
|
||||
Category incomeCategory = request.getIncomeCategoryId() == null ? null
|
||||
: requireCategory(userId, request.getIncomeCategoryId(), "INCOME");
|
||||
if (expenseCategory == null && incomeCategory == 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 -> "EXPENSE".equals(t.getType()))
|
||||
.map(Transaction::getId)
|
||||
.collect(Collectors.toList());
|
||||
List<Long> incomeIds = transactions.stream()
|
||||
.filter(t -> "INCOME".equals(t.getType()))
|
||||
.map(Transaction::getId)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
int updated = 0;
|
||||
if (expenseCategory != null && !expenseIds.isEmpty()) {
|
||||
updated += transactionMapper.update(null, new LambdaUpdateWrapper<Transaction>()
|
||||
.set(Transaction::getCategoryId, expenseCategory.getId())
|
||||
.eq(Transaction::getUserId, userId)
|
||||
.in(Transaction::getId, expenseIds));
|
||||
}
|
||||
if (incomeCategory != null && !incomeIds.isEmpty()) {
|
||||
updated += transactionMapper.update(null, new LambdaUpdateWrapper<Transaction>()
|
||||
.set(Transaction::getCategoryId, incomeCategory.getId())
|
||||
.eq(Transaction::getUserId, userId)
|
||||
.in(Transaction::getId, incomeIds));
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验分类是否存在、归属当前用户且类型匹配
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将交易实体和关联分类转换为视图对象
|
||||
*
|
||||
@@ -206,6 +319,7 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
vo.setAmount(t.getAmount());
|
||||
vo.setPaymentMethod(t.getPaymentMethod());
|
||||
vo.setRemark(t.getRemark());
|
||||
vo.setRemarkHidden(t.getRemarkHidden() == null ? Boolean.FALSE : t.getRemarkHidden());
|
||||
vo.setTransactionTime(t.getTransactionTime());
|
||||
vo.setIncludeInStats(t.getIncludeInStats() == null ? Boolean.TRUE : t.getIncludeInStats());
|
||||
vo.setCreatedAt(t.getCreatedAt());
|
||||
@@ -226,9 +340,16 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
if (query.getTypes() != null && !query.getTypes().isEmpty()) {
|
||||
wrapper.in(Transaction::getType, query.getTypes());
|
||||
}
|
||||
// 按分类ID筛选(多选)
|
||||
if (query.getCategoryIds() != null && !query.getCategoryIds().isEmpty()) {
|
||||
// 按分类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())) {
|
||||
@@ -248,6 +369,17 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
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());
|
||||
}
|
||||
|
||||
// 按交易时间降序排列
|
||||
wrapper.orderByDesc(Transaction::getTransactionTime);
|
||||
@@ -291,7 +423,12 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
vo.setCategoryName(category != null ? category.getName() : "");
|
||||
vo.setAmount(String.format("%.2f", t.getAmount()));
|
||||
vo.setPaymentMethod(getPaymentMethodLabel(t.getPaymentMethod()));
|
||||
vo.setRemark(t.getRemark() != null ? t.getRemark() : "");
|
||||
// 备注被标记为隐藏时,导出同样以占位符替代,避免泄露
|
||||
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"))
|
||||
: "");
|
||||
|
||||
Reference in New Issue
Block a user