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:
2026-09-17 13:19:59 +08:00
parent 733ed4bb86
commit 1df6e23510
35 changed files with 3016 additions and 1620 deletions
@@ -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"))
: "");