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,91 @@
|
||||
package com.example.finance.controller;
|
||||
|
||||
import com.example.finance.common.BusinessException;
|
||||
import com.example.finance.common.Result;
|
||||
import com.example.finance.dto.BillImportRequest;
|
||||
import com.example.finance.enums.BillType;
|
||||
import com.example.finance.service.BillImportService;
|
||||
import com.example.finance.vo.BillImportResultVO;
|
||||
import com.example.finance.vo.BillParseResultVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 账单导入控制器
|
||||
*
|
||||
* <p>提供「先解析、后导入」两阶段接口:解析返回预览数据,用户勾选后再确认导入。</p>
|
||||
*
|
||||
* @date 2026-09-17 09:50:21
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/transaction/import")
|
||||
@Tag(name = "账单导入", description = "第三方账单(如支付宝)解析与导入接口")
|
||||
public class BillImportController {
|
||||
|
||||
private final BillImportService billImportService;
|
||||
|
||||
public BillImportController(BillImportService billImportService) {
|
||||
this.billImportService = billImportService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析账单文件(不落库)
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param file 账单文件
|
||||
* @param billType 账单类型,如 ALIPAY
|
||||
* @return 解析预览结果
|
||||
* @throws IOException 文件读取失败
|
||||
*/
|
||||
@PostMapping("/parse")
|
||||
@Operation(summary = "解析账单文件", description = "解析账单文件并返回预览数据,不写入数据库")
|
||||
public Result<BillParseResultVO> parse(HttpServletRequest request,
|
||||
@Parameter(description = "账单文件") @RequestParam("file") MultipartFile file,
|
||||
@Parameter(description = "账单类型,如 ALIPAY") @RequestParam("billType") String billType) throws IOException {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BusinessException("请选择账单文件");
|
||||
}
|
||||
return Result.success(billImportService.parse(userId, resolveBillType(billType), file.getBytes()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认导入用户勾选的账单行
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param body 导入请求(默认分类与勾选行)
|
||||
* @return 导入结果统计
|
||||
*/
|
||||
@PostMapping("/confirm")
|
||||
@Operation(summary = "确认导入账单", description = "将用户勾选的账单行写入交易记录,按交易号去重")
|
||||
public Result<BillImportResultVO> confirm(HttpServletRequest request,
|
||||
@RequestBody @Valid BillImportRequest body) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(billImportService.confirm(userId, body));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将账单类型字符串转换为枚举
|
||||
*
|
||||
* @param billType 账单类型字符串
|
||||
* @return 账单类型枚举
|
||||
*/
|
||||
private BillType resolveBillType(String billType) {
|
||||
try {
|
||||
return BillType.valueOf(billType);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new BusinessException("不支持的账单类型:" + billType);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,13 @@ package com.example.finance.controller;
|
||||
|
||||
import com.example.finance.common.Result;
|
||||
import com.example.finance.dto.StatisticsQuery;
|
||||
import com.example.finance.dto.StatisticsRangeQuery;
|
||||
import com.example.finance.enums.StatisticsDimension;
|
||||
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 io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
@@ -13,6 +17,7 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
@@ -77,4 +82,36 @@ public class StatisticsController {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(statisticsService.getTrendStatistics(userId, query));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定时间范围的统计概览
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param query 时间范围查询参数
|
||||
* @return 统计概览
|
||||
*/
|
||||
@GetMapping("/summary")
|
||||
@Operation(summary = "获取统计概览", description = "获取当前用户指定时间范围的收支汇总、日均、最大单笔与平均每笔")
|
||||
public Result<StatisticsSummaryVO> summary(HttpServletRequest request,
|
||||
@Parameter(description = "时间范围查询参数") StatisticsRangeQuery query) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(statisticsService.getSummary(userId, query));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按维度获取分组统计数据
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param dimension 统计维度(day/week/month/quarter/year/paymentMethod/category/type)
|
||||
* @param query 时间范围查询参数
|
||||
* @return 分组统计列表
|
||||
*/
|
||||
@GetMapping("/group")
|
||||
@Operation(summary = "获取分组统计数据", description = "按指定维度对时间范围内的交易进行分组统计")
|
||||
public Result<List<StatisticsGroupVO>> group(HttpServletRequest request,
|
||||
@Parameter(description = "统计维度") @RequestParam("dimension") String dimension,
|
||||
@Parameter(description = "时间范围查询参数") StatisticsRangeQuery query) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(statisticsService.getGroup(userId, query, StatisticsDimension.from(dimension)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.example.finance.controller;
|
||||
import com.alibaba.excel.EasyExcel;
|
||||
import com.example.finance.common.PageResult;
|
||||
import com.example.finance.common.Result;
|
||||
import com.example.finance.dto.BatchCategoryRequest;
|
||||
import com.example.finance.dto.TransactionQuery;
|
||||
import com.example.finance.dto.TransactionRequest;
|
||||
import com.example.finance.service.TransactionService;
|
||||
@@ -130,6 +131,40 @@ public class TransactionController {
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换交易记录的备注隐藏标记
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param id 交易记录ID
|
||||
* @param body 请求体,包含 remarkHidden 字段
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PutMapping("/{id}/remark-hidden")
|
||||
@Operation(summary = "切换备注隐藏标记", description = "切换交易记录的备注是否隐藏(用于表格中单元格按钮)")
|
||||
public Result<Void> toggleRemarkHidden(HttpServletRequest request,
|
||||
@Parameter(description = "交易记录ID") @PathVariable Long id,
|
||||
@RequestBody Map<String, Boolean> body) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
Boolean remarkHidden = body != null ? body.get("remarkHidden") : null;
|
||||
transactionService.toggleRemarkHidden(userId, id, remarkHidden);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置交易记录的分类
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param body 批量设置请求参数
|
||||
* @return 实际更新的记录数
|
||||
*/
|
||||
@PutMapping("/batch-category")
|
||||
@Operation(summary = "批量设置分类", description = "将当前表格展示的多条交易记录按类型一键归入所选分类")
|
||||
public Result<Integer> batchUpdateCategory(HttpServletRequest request,
|
||||
@RequestBody @Valid BatchCategoryRequest body) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(transactionService.batchUpdateCategory(userId, body));
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出交易记录为 Excel 文件
|
||||
*
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.example.finance.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 批量设置分类请求 DTO,用于将当前表格展示的多条交易记录一键归入分类。
|
||||
*
|
||||
* @date 2026-09-17 10:24:18
|
||||
*/
|
||||
@Data
|
||||
public class BatchCategoryRequest {
|
||||
|
||||
/** 待更新的交易记录ID列表 */
|
||||
@NotEmpty(message = "请至少选择一条记录")
|
||||
private List<Long> ids;
|
||||
|
||||
/** 支出默认分类ID,为空表示不更新支出记录 */
|
||||
private Long expenseCategoryId;
|
||||
|
||||
/** 收入默认分类ID,为空表示不更新收入记录 */
|
||||
private Long incomeCategoryId;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.example.finance.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 账单导入请求 DTO,用于「后导入」阶段接收前端回传的勾选行。
|
||||
*
|
||||
* @date 2026-09-17 09:50:04
|
||||
*/
|
||||
@Data
|
||||
public class BillImportRequest {
|
||||
|
||||
/** 账单类型,如 ALIPAY */
|
||||
@NotBlank(message = "账单类型不能为空")
|
||||
private String billType;
|
||||
|
||||
/** 支出默认分类ID,适用于所有支出类型行 */
|
||||
private Long expenseCategoryId;
|
||||
|
||||
/** 收入默认分类ID,适用于所有收入类型行 */
|
||||
private Long incomeCategoryId;
|
||||
|
||||
/** 勾选待导入的账单行列表 */
|
||||
private List<Item> items;
|
||||
|
||||
/**
|
||||
* 单条待导入账单行
|
||||
*/
|
||||
@Data
|
||||
public static class Item {
|
||||
|
||||
/** 外部账单交易号,用于去重 */
|
||||
private String externalNo;
|
||||
|
||||
/** 交易时间 */
|
||||
private LocalDateTime transactionTime;
|
||||
|
||||
/** 交易类型:INCOME-收入,EXPENSE-支出 */
|
||||
private String type;
|
||||
|
||||
/** 交易金额 */
|
||||
private BigDecimal amount;
|
||||
|
||||
/** 交易对方 */
|
||||
private String counterparty;
|
||||
|
||||
/** 商品名称 */
|
||||
private String product;
|
||||
|
||||
/** 是否计入统计 */
|
||||
private Boolean includeInStats;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.example.finance.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 统计查询 DTO,用于按时间区间及可选过滤条件进行统计。
|
||||
*
|
||||
* @date 2026-09-17 10:50:32
|
||||
*/
|
||||
@Data
|
||||
public class StatisticsRangeQuery {
|
||||
|
||||
/** 起始时间,ISO 格式(yyyy-MM-dd 或 yyyy-MM-ddTHH:mm:ss),可为空 */
|
||||
private String startTime;
|
||||
|
||||
/** 结束时间,ISO 格式(yyyy-MM-dd 或 yyyy-MM-ddTHH:mm:ss),可为空 */
|
||||
private String endTime;
|
||||
|
||||
/** 交易类型筛选(INCOME/EXPENSE),支持多选 */
|
||||
private List<String> types;
|
||||
|
||||
/** 分类ID筛选,支持多选 */
|
||||
private List<Long> categoryIds;
|
||||
|
||||
/** 是否仅筛选未分类记录;与 categoryIds 同时存在时取并集 */
|
||||
private Boolean uncategorized;
|
||||
|
||||
/** 支付方式筛选(WECHAT/ALIPAY/CASH/BANK),支持多选 */
|
||||
private List<String> paymentMethods;
|
||||
|
||||
/** 最小金额(含),可选 */
|
||||
private BigDecimal minAmount;
|
||||
|
||||
/** 最大金额(含),可选 */
|
||||
private BigDecimal maxAmount;
|
||||
|
||||
/** 备注关键字,模糊匹配 */
|
||||
private String remark;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.example.finance.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -22,6 +23,9 @@ public class TransactionQuery {
|
||||
/** 分类ID筛选,支持多选 */
|
||||
private List<Long> categoryIds;
|
||||
|
||||
/** 是否仅筛选未分类记录;与 categoryIds 同时存在时取并集 */
|
||||
private Boolean uncategorized;
|
||||
|
||||
/** 查询起始时间 */
|
||||
private String startTime;
|
||||
|
||||
@@ -33,4 +37,13 @@ public class TransactionQuery {
|
||||
|
||||
/** 是否计入统计筛选:null-全部,true-仅计入,false-仅不计入 */
|
||||
private Boolean includeInStats;
|
||||
|
||||
/** 最小金额(含),可选 */
|
||||
private BigDecimal minAmount;
|
||||
|
||||
/** 最大金额(含),可选 */
|
||||
private BigDecimal maxAmount;
|
||||
|
||||
/** 备注关键字,模糊匹配 */
|
||||
private String remark;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,12 @@ public class Transaction {
|
||||
/** 备注 */
|
||||
private String remark;
|
||||
|
||||
/** 备注是否隐藏:true-隐藏(列表中显示占位),false-显示 */
|
||||
private Boolean remarkHidden;
|
||||
|
||||
/** 外部账单交易号,用于账单导入去重(同一用户内唯一) */
|
||||
private String externalNo;
|
||||
|
||||
/** 交易时间 */
|
||||
private LocalDateTime transactionTime;
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.example.finance.enums;
|
||||
|
||||
/**
|
||||
* 账单类型枚举,标识支持的第三方账单来源。
|
||||
*
|
||||
* <p>当前仅支持支付宝,后续可扩展微信、银行等账单类型。</p>
|
||||
*
|
||||
* @date 2026-09-17 09:49:43
|
||||
*/
|
||||
public enum BillType {
|
||||
|
||||
/** 支付宝账单 */
|
||||
ALIPAY("支付宝");
|
||||
|
||||
/** 账单类型的中文名称 */
|
||||
private final String label;
|
||||
|
||||
BillType(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账单类型的中文名称
|
||||
*
|
||||
* @return 中文名称
|
||||
*/
|
||||
public String getLabel() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.example.finance.enums;
|
||||
|
||||
import com.example.finance.common.BusinessException;
|
||||
|
||||
/**
|
||||
* 统计维度枚举,定义分组统计时可选的分组方式。
|
||||
*
|
||||
* @date 2026-09-17 10:50:32
|
||||
*/
|
||||
public enum StatisticsDimension {
|
||||
|
||||
/** 按日 */
|
||||
DAY(true),
|
||||
/** 按周(ISO 周) */
|
||||
WEEK(true),
|
||||
/** 按月 */
|
||||
MONTH(true),
|
||||
/** 按季度 */
|
||||
QUARTER(true),
|
||||
/** 按年 */
|
||||
YEAR(true),
|
||||
/** 按支付方式 */
|
||||
PAYMENT_METHOD(false),
|
||||
/** 按分类 */
|
||||
CATEGORY(false),
|
||||
/** 按交易类型 */
|
||||
TYPE(false);
|
||||
|
||||
/** 是否为时间维度 */
|
||||
private final boolean time;
|
||||
|
||||
StatisticsDimension(boolean time) {
|
||||
this.time = time;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为时间维度
|
||||
*
|
||||
* @return true-时间维度,false-其它维度
|
||||
*/
|
||||
public boolean isTime() {
|
||||
return time;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字符串解析为统计维度,兼容 camelCase 与 UPPER_SNAKE_CASE
|
||||
*
|
||||
* @param value 维度字符串,如 day、paymentMethod、PAYMENT_METHOD
|
||||
* @return 统计维度
|
||||
*/
|
||||
public static StatisticsDimension from(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new BusinessException("统计维度不能为空");
|
||||
}
|
||||
String normalized = value.replaceAll("([a-z])([A-Z])", "$1_$2").toUpperCase();
|
||||
try {
|
||||
return valueOf(normalized);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new BusinessException("不支持的统计维度:" + value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package com.example.finance.importer;
|
||||
|
||||
import com.example.finance.enums.BillType;
|
||||
import org.apache.commons.csv.CSVFormat;
|
||||
import org.apache.commons.csv.CSVParser;
|
||||
import org.apache.commons.csv.CSVRecord;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 支付宝账单解析器。
|
||||
*
|
||||
* <p>解析支付宝「交易记录明细查询」导出的 CSV 文件。文件为 GBK 编码(兼容 UTF-8),
|
||||
* 前几行为账号、日期范围等元信息,之后为表头与数据行,末尾为统计信息。</p>
|
||||
*
|
||||
* @date 2026-09-17 09:50:01
|
||||
*/
|
||||
@Component
|
||||
public class AlipayBillParser implements BillParser {
|
||||
|
||||
/** 表头列名 */
|
||||
private static final String COL_TRADE_NO = "交易号";
|
||||
private static final String COL_CREATE_TIME = "交易创建时间";
|
||||
private static final String COL_PAY_TIME = "付款时间";
|
||||
private static final String COL_COUNTERPARTY = "交易对方";
|
||||
private static final String COL_PRODUCT = "商品名称";
|
||||
private static final String COL_AMOUNT = "金额(元)";
|
||||
private static final String COL_DIRECTION = "收/支";
|
||||
private static final String COL_STATUS = "交易状态";
|
||||
private static final String COL_FUND_STATUS = "资金状态";
|
||||
|
||||
/** 支付宝账单中的时间格式 */
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
/** CSV 解析格式:逗号分隔并去除字段首尾空白 */
|
||||
private static final CSVFormat CSV_FORMAT = CSVFormat.DEFAULT.builder().setTrim(true).build();
|
||||
|
||||
@Override
|
||||
public BillType getBillType() {
|
||||
return BillType.ALIPAY;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 定位表头后逐行解析数据,遇到分隔线或「共 N 笔记录」统计行即结束。
|
||||
*/
|
||||
@Override
|
||||
public List<ParsedBillRow> parse(byte[] content) throws IOException {
|
||||
String text = new String(content, detectCharset(content));
|
||||
String[] lines = text.split("\r?\n", -1);
|
||||
|
||||
// 定位表头行:以「交易号」开头
|
||||
int headerIndex = -1;
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
if (lines[i].startsWith(COL_TRADE_NO)) {
|
||||
headerIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (headerIndex < 0) {
|
||||
throw new IOException("未找到支付宝账单表头,请确认文件是否为支付宝导出的交易记录明细");
|
||||
}
|
||||
|
||||
List<String> header = parseLine(lines[headerIndex]);
|
||||
List<ParsedBillRow> rows = new ArrayList<>();
|
||||
int rowNo = 0;
|
||||
for (int i = headerIndex + 1; i < lines.length; i++) {
|
||||
String trimmed = lines[i].trim();
|
||||
// 分隔线、统计信息或空行表示数据结束
|
||||
if (trimmed.isEmpty() || trimmed.startsWith("---") || trimmed.startsWith("共")) {
|
||||
break;
|
||||
}
|
||||
List<String> values = parseLine(lines[i]);
|
||||
if (values.size() < 2) {
|
||||
continue;
|
||||
}
|
||||
rowNo++;
|
||||
rows.add(toRow(rowNo, header, values));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将一行 CSV 文本解析为字段列表
|
||||
*
|
||||
* @param line CSV 行文本
|
||||
* @return 字段列表
|
||||
* @throws IOException 解析失败
|
||||
*/
|
||||
private List<String> parseLine(String line) throws IOException {
|
||||
try (CSVParser parser = CSVParser.parse(line, CSV_FORMAT)) {
|
||||
for (CSVRecord record : parser) {
|
||||
List<String> values = new ArrayList<>(record.size());
|
||||
for (String value : record) {
|
||||
values.add(value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单行数据映射为账单行对象,并推断类型、是否计入统计及默认勾选状态
|
||||
*
|
||||
* @param rowNo 行号
|
||||
* @param header 表头字段列表
|
||||
* @param values 数据字段列表
|
||||
* @return 解析后的账单行
|
||||
*/
|
||||
private ParsedBillRow toRow(int rowNo, List<String> header, List<String> values) {
|
||||
ParsedBillRow row = new ParsedBillRow();
|
||||
row.setRowNo(rowNo);
|
||||
row.setExternalNo(get(header, values, COL_TRADE_NO));
|
||||
row.setCounterparty(get(header, values, COL_COUNTERPARTY));
|
||||
row.setProduct(get(header, values, COL_PRODUCT));
|
||||
row.setTradeStatus(get(header, values, COL_STATUS));
|
||||
|
||||
String direction = get(header, values, COL_DIRECTION);
|
||||
String fundStatus = get(header, values, COL_FUND_STATUS);
|
||||
row.setDirection(direction);
|
||||
|
||||
// 交易时间优先取付款时间,为空时回退交易创建时间
|
||||
String payTime = get(header, values, COL_PAY_TIME);
|
||||
String createTime = get(header, values, COL_CREATE_TIME);
|
||||
String timeStr = StringUtils.hasText(payTime) ? payTime : createTime;
|
||||
if (StringUtils.hasText(timeStr)) {
|
||||
try {
|
||||
row.setTransactionTime(LocalDateTime.parse(timeStr, TIME_FORMATTER));
|
||||
} catch (DateTimeParseException e) {
|
||||
row.setError("交易时间格式无法解析:" + timeStr);
|
||||
}
|
||||
}
|
||||
|
||||
// 金额去除千分位逗号后解析
|
||||
String amountStr = get(header, values, COL_AMOUNT);
|
||||
if (StringUtils.hasText(amountStr)) {
|
||||
try {
|
||||
row.setAmount(new BigDecimal(amountStr.replace(",", "")));
|
||||
} catch (NumberFormatException e) {
|
||||
row.setError("金额格式无法解析:" + amountStr);
|
||||
}
|
||||
}
|
||||
|
||||
// 根据收/支与交易状态推断交易类型
|
||||
boolean noStats = "不计收支".equals(direction);
|
||||
String type = resolveType(direction, fundStatus, row.getTradeStatus());
|
||||
row.setType(type);
|
||||
if (type == null && row.getError() == null) {
|
||||
row.setError("无法识别收/支类型:" + direction);
|
||||
}
|
||||
|
||||
// 不计收支(退款、交易关闭等)默认不计入统计
|
||||
row.setIncludeInStats(!noStats);
|
||||
|
||||
// 校验必要字段,决定是否可导入
|
||||
boolean importable = row.getError() == null
|
||||
&& StringUtils.hasText(row.getExternalNo())
|
||||
&& row.getTransactionTime() != null
|
||||
&& row.getAmount() != null
|
||||
&& row.getAmount().compareTo(BigDecimal.ZERO) > 0
|
||||
&& type != null;
|
||||
row.setImportable(importable);
|
||||
// 不计收支行默认不勾选,用户可手动勾选
|
||||
row.setSelected(importable && !noStats);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 推断交易类型
|
||||
*
|
||||
* @param direction 收/支标识
|
||||
* @param fundStatus 资金状态
|
||||
* @param tradeStatus 交易状态
|
||||
* @return INCOME、EXPENSE,无法识别时返回 null
|
||||
*/
|
||||
private String resolveType(String direction, String fundStatus, String tradeStatus) {
|
||||
if ("支出".equals(direction)) {
|
||||
return "EXPENSE";
|
||||
}
|
||||
if ("收入".equals(direction)) {
|
||||
return "INCOME";
|
||||
}
|
||||
if ("不计收支".equals(direction)) {
|
||||
// 退款成功或资金已收入视为收入,其余(如交易关闭)按支出处理
|
||||
if (StringUtils.hasText(tradeStatus) && tradeStatus.contains("退款")) {
|
||||
return "INCOME";
|
||||
}
|
||||
if ("已收入".equals(fundStatus)) {
|
||||
return "INCOME";
|
||||
}
|
||||
return "EXPENSE";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按表头列名取值
|
||||
*
|
||||
* @param header 表头字段列表
|
||||
* @param values 数据字段列表
|
||||
* @param column 列名
|
||||
* @return 字段值,列不存在时返回 null
|
||||
*/
|
||||
private String get(List<String> header, List<String> values, String column) {
|
||||
int index = header.indexOf(column);
|
||||
if (index < 0 || index >= values.size()) {
|
||||
return null;
|
||||
}
|
||||
String value = values.get(index);
|
||||
return value == null ? null : value.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测账单文件的字符编码:优先 UTF-8(含 BOM),否则回退 GBK
|
||||
*
|
||||
* @param content 文件字节内容
|
||||
* @return 字符集
|
||||
*/
|
||||
private Charset detectCharset(byte[] content) {
|
||||
// UTF-8 BOM
|
||||
if (content.length >= 3
|
||||
&& (content[0] & 0xFF) == 0xEF
|
||||
&& (content[1] & 0xFF) == 0xBB
|
||||
&& (content[2] & 0xFF) == 0xBF) {
|
||||
return StandardCharsets.UTF_8;
|
||||
}
|
||||
// 严格尝试 UTF-8 解码,失败则判定为 GBK
|
||||
try {
|
||||
StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(content));
|
||||
return StandardCharsets.UTF_8;
|
||||
} catch (CharacterCodingException e) {
|
||||
return Charset.forName("GBK");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.example.finance.importer;
|
||||
|
||||
import com.example.finance.enums.BillType;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 账单解析器接口,定义不同账单来源的解析行为。
|
||||
*
|
||||
* <p>各实现类通过 {@link #getBillType()} 声明自己支持的账单类型,
|
||||
* 由服务层按账单类型分发,便于后续扩展新的账单来源。</p>
|
||||
*
|
||||
* @date 2026-09-17 09:49:45
|
||||
*/
|
||||
public interface BillParser {
|
||||
|
||||
/**
|
||||
* 获取该解析器支持的账单类型
|
||||
*
|
||||
* @return 账单类型
|
||||
*/
|
||||
BillType getBillType();
|
||||
|
||||
/**
|
||||
* 解析账单文件内容
|
||||
*
|
||||
* @param content 账单文件的原始字节内容
|
||||
* @return 解析后的账单行列表
|
||||
* @throws IOException 读取或解析文件失败
|
||||
*/
|
||||
List<ParsedBillRow> parse(byte[] content) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.example.finance.importer;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 账单解析结果行,作为「先解析、后导入」流程中解析阶段的中间对象。
|
||||
*
|
||||
* <p>解析阶段不落库,仅返回给前端预览;确认导入时前端将勾选行回传,后端据此落库。</p>
|
||||
*
|
||||
* @date 2026-09-17 09:49:44
|
||||
*/
|
||||
@Data
|
||||
public class ParsedBillRow {
|
||||
|
||||
/** 账单内行号,从 1 开始 */
|
||||
private int rowNo;
|
||||
|
||||
/** 外部账单交易号,用于导入去重 */
|
||||
private String externalNo;
|
||||
|
||||
/** 交易时间 */
|
||||
private LocalDateTime transactionTime;
|
||||
|
||||
/** 交易类型:INCOME-收入,EXPENSE-支出 */
|
||||
private String type;
|
||||
|
||||
/** 交易金额 */
|
||||
private BigDecimal amount;
|
||||
|
||||
/** 交易对方 */
|
||||
private String counterparty;
|
||||
|
||||
/** 商品名称 */
|
||||
private String product;
|
||||
|
||||
/** 交易状态,如「交易成功」「交易关闭」「退款成功」 */
|
||||
private String tradeStatus;
|
||||
|
||||
/** 原始收/支标识,如「支出」「收入」「不计收支」 */
|
||||
private String direction;
|
||||
|
||||
/** 是否计入统计 */
|
||||
private Boolean includeInStats;
|
||||
|
||||
/** 是否可导入(解析失败或缺少必要字段时为 false) */
|
||||
private boolean importable;
|
||||
|
||||
/** 是否默认勾选 */
|
||||
private boolean selected;
|
||||
|
||||
/** 是否与系统中已有记录重复(同一用户下交易号已存在) */
|
||||
private boolean duplicate;
|
||||
|
||||
/** 解析失败原因,正常时为 null */
|
||||
private String error;
|
||||
}
|
||||
@@ -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"))
|
||||
: "");
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.example.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 账单导入结果视图对象,用于「后导入」阶段返回导入统计。
|
||||
*
|
||||
* @date 2026-09-17 09:50:03
|
||||
*/
|
||||
@Data
|
||||
public class BillImportResultVO {
|
||||
|
||||
/** 提交导入的行总数 */
|
||||
private int total;
|
||||
|
||||
/** 成功导入数 */
|
||||
private int imported;
|
||||
|
||||
/** 因重复而跳过数 */
|
||||
private int skipped;
|
||||
|
||||
/** 因校验失败而失败数 */
|
||||
private int failed;
|
||||
|
||||
/** 失败或跳过的明细说明 */
|
||||
private List<String> messages;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.example.finance.vo;
|
||||
|
||||
import com.example.finance.importer.ParsedBillRow;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 账单解析结果视图对象,用于「先解析」阶段向前端返回预览数据。
|
||||
*
|
||||
* @date 2026-09-17 09:50:02
|
||||
*/
|
||||
@Data
|
||||
public class BillParseResultVO {
|
||||
|
||||
/** 账单类型 */
|
||||
private String billType;
|
||||
|
||||
/** 解析出的账单行总数 */
|
||||
private int total;
|
||||
|
||||
/** 解析出的账单行列表 */
|
||||
private List<ParsedBillRow> rows;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.example.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 分组统计视图对象,用于展示按某一维度分组后的收支数据。
|
||||
*
|
||||
* @date 2026-09-17 10:50:32
|
||||
*/
|
||||
@Data
|
||||
public class StatisticsGroupVO {
|
||||
|
||||
/** 分组键(如分类ID、支付方式编码、日期标签) */
|
||||
private String key;
|
||||
|
||||
/** 分组显示名称 */
|
||||
private String name;
|
||||
|
||||
/** 分类图标(仅分类维度有值) */
|
||||
private String icon;
|
||||
|
||||
/** 交易类型(仅分类维度有值) */
|
||||
private String type;
|
||||
|
||||
/** 该分组收入金额 */
|
||||
private BigDecimal income;
|
||||
|
||||
/** 该分组支出金额 */
|
||||
private BigDecimal expense;
|
||||
|
||||
/** 该分组总金额(收入+支出) */
|
||||
private BigDecimal total;
|
||||
|
||||
/** 该分组交易笔数 */
|
||||
private Integer count;
|
||||
|
||||
/** 占总金额百分比(非时间维度有效) */
|
||||
private BigDecimal percentage;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.example.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 统计概览视图对象,用于展示指定时间范围内的收支汇总与派生指标。
|
||||
*
|
||||
* @date 2026-09-17 10:50:32
|
||||
*/
|
||||
@Data
|
||||
public class StatisticsSummaryVO {
|
||||
|
||||
/** 总收入 */
|
||||
private BigDecimal totalIncome;
|
||||
|
||||
/** 总支出 */
|
||||
private BigDecimal totalExpense;
|
||||
|
||||
/** 结余(收入减支出) */
|
||||
private BigDecimal balance;
|
||||
|
||||
/** 交易笔数 */
|
||||
private Integer transactionCount;
|
||||
|
||||
/** 统计区间天数 */
|
||||
private Integer dayCount;
|
||||
|
||||
/** 日均收入 */
|
||||
private BigDecimal avgDailyIncome;
|
||||
|
||||
/** 日均支出 */
|
||||
private BigDecimal avgDailyExpense;
|
||||
|
||||
/** 最大单笔支出金额 */
|
||||
private BigDecimal maxExpenseAmount;
|
||||
|
||||
/** 最大单笔支出发生时间 */
|
||||
private LocalDateTime maxExpenseTime;
|
||||
|
||||
/** 最大单笔支出备注 */
|
||||
private String maxExpenseRemark;
|
||||
|
||||
/** 平均每笔支出 */
|
||||
private BigDecimal avgExpenseAmount;
|
||||
}
|
||||
@@ -35,6 +35,9 @@ public class TransactionVO {
|
||||
/** 备注 */
|
||||
private String remark;
|
||||
|
||||
/** 备注是否隐藏:true-隐藏(前端显示占位),false-显示 */
|
||||
private Boolean remarkHidden;
|
||||
|
||||
/** 交易时间 */
|
||||
private LocalDateTime transactionTime;
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
spring:
|
||||
application:
|
||||
name: my-app
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 10MB
|
||||
max-request-size: 10MB
|
||||
datasource:
|
||||
url: ${DB_URL}
|
||||
username: ${DB_USERNAME}
|
||||
|
||||
@@ -25,11 +25,13 @@ CREATE TABLE IF NOT EXISTS t_category (
|
||||
CREATE TABLE IF NOT EXISTS t_transaction (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID,主键自增',
|
||||
user_id BIGINT NOT NULL COMMENT '所属用户ID',
|
||||
category_id BIGINT NOT NULL COMMENT '所属分类ID',
|
||||
category_id BIGINT COMMENT '所属分类ID,可为空表示未分类',
|
||||
type VARCHAR(10) NOT NULL COMMENT '记录类型:INCOME-收入,EXPENSE-支出',
|
||||
amount DECIMAL(12, 2) NOT NULL COMMENT '金额,精确到分',
|
||||
payment_method VARCHAR(30) COMMENT '支付方式:WECHAT-微信,ALIPAY-支付宝,CASH-现金,BANK-银行卡',
|
||||
remark VARCHAR(255) COMMENT '备注说明',
|
||||
remark_hidden TINYINT(1) NOT NULL DEFAULT 0 COMMENT '备注是否隐藏:1-隐藏,0-显示',
|
||||
external_no VARCHAR(64) COMMENT '外部账单交易号,用于账单导入去重',
|
||||
transaction_time DATETIME NOT NULL COMMENT '交易发生时间',
|
||||
include_in_stats TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否计入统计:1-计入,0-不计入(如借款、转账等)',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
@@ -37,5 +39,6 @@ CREATE TABLE IF NOT EXISTS t_transaction (
|
||||
INDEX idx_user_id (user_id) COMMENT '用户ID索引',
|
||||
INDEX idx_category_id (category_id) COMMENT '分类ID索引',
|
||||
INDEX idx_transaction_time (transaction_time) COMMENT '交易时间索引',
|
||||
INDEX idx_user_time (user_id, transaction_time) COMMENT '用户ID+交易时间联合索引'
|
||||
INDEX idx_user_time (user_id, transaction_time) COMMENT '用户ID+交易时间联合索引',
|
||||
UNIQUE INDEX uk_user_external (user_id, external_no) COMMENT '用户ID+外部交易号唯一索引,用于导入去重'
|
||||
);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
-- 初始化默认收支分类种子脚本
|
||||
-- 用途:为现有用户补齐一套默认分类(支出 10 个、收入 6 个)
|
||||
-- 特性:幂等,按「用户ID + 分类名称 + 分类类型」去重,可重复执行
|
||||
-- 编码:utf8mb4(图标使用 emoji,需以 --default-character-set=utf8mb4 执行)
|
||||
|
||||
INSERT INTO t_category (user_id, name, type, icon, sort_order)
|
||||
SELECT u.id, d.name, d.type, d.icon, d.sort_order
|
||||
FROM t_user u
|
||||
CROSS JOIN (
|
||||
SELECT '餐饮' AS name, 'EXPENSE' AS type, '🍜' AS icon, 10 AS sort_order
|
||||
UNION ALL SELECT '交通', 'EXPENSE', '🚌', 20
|
||||
UNION ALL SELECT '购物', 'EXPENSE', '🛍️', 30
|
||||
UNION ALL SELECT '居住', 'EXPENSE', '🏠', 40
|
||||
UNION ALL SELECT '通讯', 'EXPENSE', '📱', 50
|
||||
UNION ALL SELECT '娱乐', 'EXPENSE', '🎮', 60
|
||||
UNION ALL SELECT '医疗', 'EXPENSE', '💊', 70
|
||||
UNION ALL SELECT '教育', 'EXPENSE', '📚', 80
|
||||
UNION ALL SELECT '美容美发', 'EXPENSE', '💇', 85
|
||||
UNION ALL SELECT '人情往来', 'EXPENSE', '🎁', 90
|
||||
UNION ALL SELECT '其他支出', 'EXPENSE', '📦', 100
|
||||
UNION ALL SELECT '工资', 'INCOME', '💰', 10
|
||||
UNION ALL SELECT '奖金', 'INCOME', '🧧', 20
|
||||
UNION ALL SELECT '兼职', 'INCOME', '💼', 30
|
||||
UNION ALL SELECT '投资收益', 'INCOME', '📈', 40
|
||||
UNION ALL SELECT '退款', 'INCOME', '↩️', 50
|
||||
UNION ALL SELECT '其他收入', 'INCOME', '📦', 60
|
||||
) d
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM t_category c
|
||||
WHERE c.user_id = u.id AND c.name = d.name AND c.type = d.type
|
||||
);
|
||||
|
||||
-- 备选写法:若 MySQL 对上述 INSERT ... SELECT 引用目标表报错 1093,
|
||||
-- 可改用临时表方式:
|
||||
-- CREATE TEMPORARY TABLE tmp_default_category (
|
||||
-- name VARCHAR(50), type VARCHAR(10), icon VARCHAR(50), sort_order INT
|
||||
-- );
|
||||
-- INSERT INTO tmp_default_category VALUES ('餐饮','EXPENSE','🍜',10), ...;
|
||||
-- INSERT INTO t_category (user_id, name, type, icon, sort_order)
|
||||
-- SELECT u.id, d.name, d.type, d.icon, d.sort_order
|
||||
-- FROM t_user u JOIN tmp_default_category d
|
||||
-- WHERE NOT EXISTS (SELECT 1 FROM t_category c
|
||||
-- WHERE c.user_id = u.id AND c.name = d.name AND c.type = d.type);
|
||||
-- DROP TEMPORARY TABLE tmp_default_category;
|
||||
Reference in New Issue
Block a user