feat: 新增标签体系、微信账单导入与收支记录体验优化

- 标签:新增 t_tag / t_transaction_tag 表与标签 CRUD,支持交易打标签、按标签筛选、批量添加/移除标签、导入账单默认标签,新增标签管理页
- 账单导入:新增微信支付 xlsx 解析器(WechatBillParser),支持支付宝 / 微信按类型导入;解析行保存原始完整数据(raw_data)并可在列表查看
- 收支记录:排序移至顶部并改为服务端排序(交易时间 / 金额,记忆偏好);筛选条件收纳为「更多筛选」弹层;分类筛选显示图标;一键设置支持「计入统计」;导入预览支持关键字 / 类型 / 状态 / 金额筛选
- 数据库:init.sql 新增 raw_data 字段与标签表,新增 init_tags.sql 默认标签种子
- 其他:更新 README 测试账号
This commit is contained in:
2026-09-17 16:47:26 +08:00
parent 1df6e23510
commit 65c0d8c550
33 changed files with 1854 additions and 100 deletions
@@ -0,0 +1,102 @@
package com.example.finance.controller;
import com.example.finance.common.Result;
import com.example.finance.dto.TagRequest;
import com.example.finance.service.TagService;
import com.example.finance.vo.TagVO;
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.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 标签控制器
*
* <p>提供标签的增删改查接口,用于给交易记录打标签</p>
*
* @date 2026-09-17 16:26:58
*/
@RestController
@RequestMapping("/api/tag")
@Tag(name = "标签管理", description = "标签的增删改查相关接口")
public class TagController {
private final TagService tagService;
public TagController(TagService tagService) {
this.tagService = tagService;
}
/**
* 获取标签列表
*
* @param request HTTP请求,从中获取用户ID
* @return 标签列表
*/
@GetMapping("/list")
@Operation(summary = "获取标签列表", description = "获取当前用户的全部标签")
public Result<List<TagVO>> list(HttpServletRequest request) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(tagService.list(userId));
}
/**
* 创建标签
*
* @param request HTTP请求,从中获取用户ID
* @param tagRequest 标签请求参数
* @return 操作结果
*/
@PostMapping
@Operation(summary = "创建标签", description = "为当前用户创建新的标签")
public Result<Void> create(HttpServletRequest request, @RequestBody @Valid TagRequest tagRequest) {
Long userId = (Long) request.getAttribute("userId");
tagService.create(userId, tagRequest);
return Result.success();
}
/**
* 更新标签
*
* @param request HTTP请求,从中获取用户ID
* @param id 标签ID
* @param tagRequest 标签请求参数
* @return 操作结果
*/
@PutMapping("/{id}")
@Operation(summary = "更新标签", description = "根据标签ID更新标签信息")
public Result<Void> update(HttpServletRequest request,
@Parameter(description = "标签ID") @PathVariable Long id,
@RequestBody @Valid TagRequest tagRequest) {
Long userId = (Long) request.getAttribute("userId");
tagService.update(userId, id, tagRequest);
return Result.success();
}
/**
* 删除标签
*
* @param request HTTP请求,从中获取用户ID
* @param id 标签ID
* @return 操作结果
*/
@DeleteMapping("/{id}")
@Operation(summary = "删除标签", description = "根据标签ID删除指定标签及其交易关联")
public Result<Void> delete(HttpServletRequest request,
@Parameter(description = "标签ID") @PathVariable Long id) {
Long userId = (Long) request.getAttribute("userId");
tagService.delete(userId, id);
return Result.success();
}
}
@@ -4,6 +4,7 @@ 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.BatchTagRequest;
import com.example.finance.dto.TransactionQuery;
import com.example.finance.dto.TransactionRequest;
import com.example.finance.service.TransactionService;
@@ -165,6 +166,21 @@ public class TransactionController {
return Result.success(transactionService.batchUpdateCategory(userId, body));
}
/**
* 批量给交易记录添加/移除标签
*
* @param request HTTP请求,从中获取用户ID
* @param body 批量标签请求参数
* @return 涉及的记录数
*/
@PutMapping("/batch-tags")
@Operation(summary = "批量设置标签", description = "为选中的交易记录添加或移除标签")
public Result<Integer> batchTags(HttpServletRequest request,
@RequestBody @Valid BatchTagRequest body) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(transactionService.batchTags(userId, body));
}
/**
* 导出交易记录为 Excel 文件
*
@@ -22,4 +22,7 @@ public class BatchCategoryRequest {
/** 收入默认分类ID,为空表示不更新收入记录 */
private Long incomeCategoryId;
/** 是否计入统计:null-不修改,true-计入,false-不计入 */
private Boolean includeInStats;
}
@@ -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 16:27:22
*/
@Data
public class BatchTagRequest {
/** 待操作的交易记录ID列表 */
@NotEmpty(message = "请至少选择一条记录")
private List<Long> ids;
/** 需要添加的标签ID列表,可选 */
private List<Long> addTagIds;
/** 需要移除的标签ID列表,可选 */
private List<Long> removeTagIds;
}
@@ -25,6 +25,9 @@ public class BillImportRequest {
/** 收入默认分类ID,适用于所有收入类型行 */
private Long incomeCategoryId;
/** 导入记录的默认标签ID列表,可选 */
private List<Long> tagIds;
/** 勾选待导入的账单行列表 */
private List<Item> items;
@@ -54,5 +57,8 @@ public class BillImportRequest {
/** 是否计入统计 */
private Boolean includeInStats;
/** 原始账单行的完整数据(结构化JSON,由解析阶段返回、确认导入时回传) */
private String rawData;
}
}
@@ -0,0 +1,23 @@
package com.example.finance.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 标签请求DTO,用于接收创建或更新标签时提交的信息。
*
* @date 2026-09-17 16:26:52
*/
@Data
public class TagRequest {
/** 标签名称,不能为空 */
@NotBlank(message = "标签名称不能为空")
private String name;
/** 标签颜色,可选 */
private String color;
/** 排序序号,可选 */
private Integer sortOrder;
}
@@ -46,4 +46,13 @@ public class TransactionQuery {
/** 备注关键字,模糊匹配 */
private String remark;
/** 排序字段:transactionTime-交易时间(默认),amount-金额 */
private String sortField;
/** 排序方向:asc-升序,desc-降序(默认) */
private String sortOrder;
/** 标签ID筛选,支持多选 */
private List<Long> tagIds;
}
@@ -7,6 +7,7 @@ import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 交易请求DTO,用于接收创建或更新交易记录时提交的信息
@@ -39,4 +40,7 @@ public class TransactionRequest {
/** 是否计入统计,默认 true(借款、转账等可设为 false) */
private Boolean includeInStats;
/** 标签ID列表,可选;更新时为空表示清空标签 */
private List<Long> tagIds;
}
@@ -0,0 +1,37 @@
package com.example.finance.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 标签实体类,对应数据库表 t_tag,用于记录用户自定义标签。
*
* @date 2026-09-17 16:26:50
*/
@Data
@TableName("t_tag")
public class Tag {
/** 标签ID,主键自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 所属用户ID */
private Long userId;
/** 标签名称 */
private String name;
/** 标签颜色,用于前端展示 */
private String color;
/** 排序序号 */
private Integer sortOrder;
/** 创建时间 */
private LocalDateTime createdAt;
}
@@ -49,6 +49,9 @@ public class Transaction {
/** 是否计入统计:true-计入,false-不计入(如借款、账户间转账等) */
private Boolean includeInStats;
/** 账单导入的原始完整数据(结构化JSON),手动记录为空 */
private String rawData;
/** 创建时间 */
private LocalDateTime createdAt;
@@ -0,0 +1,31 @@
package com.example.finance.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 交易标签关联实体类,对应数据库表 t_transaction_tag,维护交易与标签的多对多关系。
*
* @date 2026-09-17 16:26:50
*/
@Data
@TableName("t_transaction_tag")
public class TransactionTag {
/** 关联ID,主键自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 交易记录ID */
private Long transactionId;
/** 标签ID */
private Long tagId;
/** 创建时间 */
private LocalDateTime createdAt;
}
@@ -3,14 +3,17 @@ package com.example.finance.enums;
/**
* 账单类型枚举,标识支持的第三方账单来源。
*
* <p>当前仅支持支付宝,后续可扩展微信、银行等账单类型。</p>
* <p>当前支持支付宝与微信支付账单,后续可扩展银行等账单类型。</p>
*
* @date 2026-09-17 09:49:43
*/
public enum BillType {
/** 支付宝账单 */
ALIPAY("支付宝");
ALIPAY("支付宝"),
/** 微信支付账单 */
WECHAT("微信");
/** 账单类型的中文名称 */
private final String label;
@@ -1,6 +1,8 @@
package com.example.finance.importer;
import com.example.finance.enums.BillType;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVParser;
import org.apache.commons.csv.CSVRecord;
@@ -18,7 +20,9 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 支付宝账单解析器。
@@ -48,6 +52,13 @@ public class AlipayBillParser implements BillParser {
/** CSV 解析格式:逗号分隔并去除字段首尾空白 */
private static final CSVFormat CSV_FORMAT = CSVFormat.DEFAULT.builder().setTrim(true).build();
/** JSON 序列化器,用于生成原始完整数据 */
private final ObjectMapper objectMapper;
public AlipayBillParser(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public BillType getBillType() {
return BillType.ALIPAY;
@@ -125,6 +136,7 @@ public class AlipayBillParser implements BillParser {
private ParsedBillRow toRow(int rowNo, List<String> header, List<String> values) {
ParsedBillRow row = new ParsedBillRow();
row.setRowNo(rowNo);
row.setRawData(buildRawData(header, values));
row.setExternalNo(get(header, values, COL_TRADE_NO));
row.setCounterparty(get(header, values, COL_COUNTERPARTY));
row.setProduct(get(header, values, COL_PRODUCT));
@@ -180,6 +192,31 @@ public class AlipayBillParser implements BillParser {
return row;
}
/**
* 将整行数据按表头顺序组装为结构化 JSON,保留全部列(含系统未使用的字段)
*
* @param header 表头字段列表
* @param values 数据字段列表
* @return JSON 字符串,序列化失败时返回 null
*/
private String buildRawData(List<String> header, List<String> values) {
Map<String, String> map = new LinkedHashMap<>();
for (int i = 0; i < header.size(); i++) {
String key = header.get(i);
if (key == null || key.isEmpty()) {
continue;
}
String value = i < values.size() ? values.get(i) : null;
map.put(key, value == null ? "" : value.trim());
}
try {
return objectMapper.writeValueAsString(map);
} catch (JsonProcessingException e) {
// 序列化失败不影响正常导入,原始数据置空
return null;
}
}
/**
* 推断交易类型
*
@@ -54,6 +54,9 @@ public class ParsedBillRow {
/** 是否与系统中已有记录重复(同一用户下交易号已存在) */
private boolean duplicate;
/** 原始账单行的完整数据(结构化JSON,保留全部列) */
private String rawData;
/** 解析失败原因,正常时为 null */
private String error;
}
@@ -0,0 +1,321 @@
package com.example.finance.importer;
import com.example.finance.enums.BillType;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 微信支付账单解析器。
*
* <p>解析微信「支付账单明细」导出的 xlsx 文件。文件前若干行为昵称、起止时间、统计与注释,
* 之后为表头与数据行。交易时间以 Excel 日期序列号存储,金额为数值,其余列均为文本。</p>
*
* @date 2026-09-17 13:39:08
*/
@Component
public class WechatBillParser implements BillParser {
/** 表头首列名称,用于定位表头行 */
private static final String HEADER_FIRST_COLUMN = "交易时间";
/** 微信账单的列数 */
private static final int COLUMN_COUNT = 11;
/** Excel 日期序列号基准(1900 日期系统,已规避 1900 闰年问题) */
private static final LocalDateTime EXCEL_EPOCH = LocalDateTime.of(1899, 12, 30, 0, 0);
/** 微信账单中表示「无内容」的占位符 */
private static final String PLACEHOLDER = "/";
/** 微信账单中的时间格式 */
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/** 兼容部分版本以文本形式导出的时间格式 */
private static final List<DateTimeFormatter> TEXT_TIME_FORMATTERS = List.of(
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
DateTimeFormatter.ofPattern("yyyy/M/d H:mm:ss"),
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
/** JSON 序列化器,用于生成原始完整数据 */
private final ObjectMapper objectMapper;
public WechatBillParser(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public BillType getBillType() {
return BillType.WECHAT;
}
/**
* {@inheritDoc}
*
* 定位表头后逐行读取数据,跳过缺少有效交易时间的空行。
*/
@Override
public List<ParsedBillRow> parse(byte[] content) throws IOException {
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(content))) {
Sheet sheet = workbook.getSheetAt(0);
int headerIndex = locateHeader(sheet);
if (headerIndex < 0) {
throw new IOException("未找到微信账单表头,请确认文件是否为微信导出的支付账单明细");
}
List<String> header = readHeader(sheet.getRow(headerIndex));
List<ParsedBillRow> rows = new ArrayList<>();
int rowNo = 0;
for (int i = headerIndex + 1; i <= sheet.getLastRowNum(); i++) {
Row row = sheet.getRow(i);
LocalDateTime time = readTime(row);
// 空行(含被裁剪文件中的中间空行)直接跳过
if (time == null) {
continue;
}
rowNo++;
rows.add(toRow(rowNo, header, row, time));
}
return rows;
} catch (IOException e) {
throw e;
} catch (Exception e) {
throw new IOException("微信账单解析失败:" + e.getMessage(), e);
}
}
/**
* 定位表头行:首个单元格以「交易时间」开头的行
*
* @param sheet 工作表
* @return 表头行索引,未找到时返回 -1
*/
private int locateHeader(Sheet sheet) {
for (int i = sheet.getFirstRowNum(); i <= sheet.getLastRowNum(); i++) {
Row row = sheet.getRow(i);
if (row == null) {
continue;
}
String first = readString(row.getCell(0));
if (first != null && first.startsWith(HEADER_FIRST_COLUMN)) {
return i;
}
}
return -1;
}
/**
* 读取表头行的各列名称
*
* @param row 表头行
* @return 列名列表
*/
private List<String> readHeader(Row row) {
List<String> header = new ArrayList<>(COLUMN_COUNT);
for (int c = 0; c < COLUMN_COUNT; c++) {
String value = readString(row.getCell(c));
header.add(value == null ? "" : value);
}
return header;
}
/**
* 将单行数据映射为账单行对象,并推断类型、是否计入统计及默认勾选状态
*
* @param rowNo 行号
* @param header 表头字段列表
* @param row 数据行
* @param time 交易时间
* @return 解析后的账单行
*/
private ParsedBillRow toRow(int rowNo, List<String> header, Row row, LocalDateTime time) {
ParsedBillRow parsed = new ParsedBillRow();
parsed.setRowNo(rowNo);
parsed.setTransactionTime(time);
parsed.setRawData(buildRawData(header, row, time));
parsed.setExternalNo(readString(row.getCell(8)));
parsed.setCounterparty(cleanText(readString(row.getCell(2))));
parsed.setProduct(cleanText(readString(row.getCell(3))));
parsed.setTradeStatus(cleanText(readString(row.getCell(7))));
String direction = cleanText(readString(row.getCell(4)));
parsed.setDirection(direction);
BigDecimal amount = readAmount(row.getCell(5));
parsed.setAmount(amount);
// 无收/支方向(如充值、提现、还款等中性交易)默认按支出处理且不计入统计
boolean neutral = direction == null;
parsed.setType(resolveType(direction));
parsed.setIncludeInStats(!neutral);
boolean importable = StringUtils.hasText(parsed.getExternalNo())
&& amount != null
&& amount.compareTo(BigDecimal.ZERO) > 0;
parsed.setImportable(importable);
parsed.setSelected(importable && !neutral);
if (!StringUtils.hasText(parsed.getExternalNo())) {
parsed.setError("缺少交易单号");
} else if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
parsed.setError("金额格式无法解析");
}
return parsed;
}
/**
* 推断交易类型
*
* @param direction 收/支标识
* @return INCOME 或 EXPENSE
*/
private String resolveType(String direction) {
if ("收入".equals(direction)) {
return "INCOME";
}
return "EXPENSE";
}
/**
* 读取交易时间:第一列为 Excel 日期序列号,转换为本地日期时间
*
* @param row 数据行
* @return 交易时间,非数值或无效时返回 null
*/
private LocalDateTime readTime(Row row) {
if (row == null) {
return null;
}
Cell cell = row.getCell(0);
if (cell == null) {
return null;
}
if (cell.getCellType() == CellType.NUMERIC) {
double serial = cell.getNumericCellValue();
if (serial <= 0) {
return null;
}
long days = (long) Math.floor(serial);
long seconds = Math.round((serial - days) * 86400.0);
return EXCEL_EPOCH.plusDays(days).plusSeconds(seconds);
}
// 兼容以文本形式存储的时间
String text = readString(cell);
if (!StringUtils.hasText(text)) {
return null;
}
for (DateTimeFormatter formatter : TEXT_TIME_FORMATTERS) {
try {
return LocalDateTime.parse(text, formatter);
} catch (DateTimeParseException ignored) {
// 尝试下一种格式
}
}
return null;
}
/**
* 读取金额:优先按数值解析,兼容文本形式
*
* @param cell 金额单元格
* @return 金额,无法解析时返回 null
*/
private BigDecimal readAmount(Cell cell) {
if (cell == null) {
return null;
}
if (cell.getCellType() == CellType.NUMERIC) {
return BigDecimal.valueOf(cell.getNumericCellValue());
}
String text = readString(cell);
if (!StringUtils.hasText(text)) {
return null;
}
try {
return new BigDecimal(text.replace(",", ""));
} catch (NumberFormatException e) {
return null;
}
}
/**
* 读取单元格的文本值
*
* @param cell 单元格
* @return 文本值,空单元格返回 null
*/
private String readString(Cell cell) {
if (cell == null) {
return null;
}
CellType type = cell.getCellType();
if (type == CellType.STRING) {
return cell.getStringCellValue().trim();
}
if (type == CellType.NUMERIC) {
return BigDecimal.valueOf(cell.getNumericCellValue()).stripTrailingZeros().toPlainString();
}
if (type == CellType.BOOLEAN) {
return String.valueOf(cell.getBooleanCellValue());
}
return null;
}
/**
* 将整行数据按表头顺序组装为结构化 JSON,保留全部列(含系统未使用的字段)
*
* @param header 表头字段列表
* @param row 数据行
* @param time 交易时间,第一列以可读时间替代原始序列号
* @return JSON 字符串,序列化失败时返回 null
*/
private String buildRawData(List<String> header, Row row, LocalDateTime time) {
Map<String, String> map = new LinkedHashMap<>();
for (int c = 0; c < header.size(); c++) {
String key = header.get(c);
if (!StringUtils.hasText(key)) {
continue;
}
String value = c == 0 ? time.format(TIME_FORMATTER) : readString(row.getCell(c));
map.put(key, value == null ? "" : value);
}
try {
return objectMapper.writeValueAsString(map);
} catch (JsonProcessingException e) {
// 序列化失败不影响正常导入,原始数据置空
return null;
}
}
/**
* 清洗文本:空白或占位符「/」视为无内容
*
* @param value 原始文本
* @return 清洗后的文本,无内容时返回 null
*/
private String cleanText(String value) {
if (!StringUtils.hasText(value) || PLACEHOLDER.equals(value)) {
return null;
}
return value;
}
}
@@ -0,0 +1,14 @@
package com.example.finance.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.finance.entity.Tag;
import org.apache.ibatis.annotations.Mapper;
/**
* 标签Mapper接口,继承MyBatis-Plus的BaseMapper,提供标签表的基础CRUD操作。
*
* @date 2026-09-17 16:26:51
*/
@Mapper
public interface TagMapper extends BaseMapper<Tag> {
}
@@ -0,0 +1,14 @@
package com.example.finance.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.finance.entity.TransactionTag;
import org.apache.ibatis.annotations.Mapper;
/**
* 交易标签关联Mapper接口,继承MyBatis-Plus的BaseMapper,提供关联表的基础CRUD操作。
*
* @date 2026-09-17 16:26:51
*/
@Mapper
public interface TransactionTagMapper extends BaseMapper<TransactionTag> {
}
@@ -0,0 +1,47 @@
package com.example.finance.service;
import com.example.finance.dto.TagRequest;
import com.example.finance.vo.TagVO;
import java.util.List;
/**
* 标签服务接口,提供标签的增删改查操作。
*
* @date 2026-09-17 16:26:53
*/
public interface TagService {
/**
* 获取用户的所有标签列表
*
* @param userId 用户ID
* @return 标签列表
*/
List<TagVO> list(Long userId);
/**
* 创建新标签
*
* @param userId 用户ID
* @param request 标签请求参数(名称、颜色等)
*/
void create(Long userId, TagRequest request);
/**
* 更新标签信息
*
* @param userId 用户ID
* @param id 标签ID
* @param request 标签请求参数
*/
void update(Long userId, Long id, TagRequest request);
/**
* 删除标签,同时清理交易与标签的关联
*
* @param userId 用户ID
* @param id 标签ID
*/
void delete(Long userId, Long id);
}
@@ -2,6 +2,7 @@ package com.example.finance.service;
import com.example.finance.common.PageResult;
import com.example.finance.dto.BatchCategoryRequest;
import com.example.finance.dto.BatchTagRequest;
import com.example.finance.dto.TransactionQuery;
import com.example.finance.dto.TransactionRequest;
import com.example.finance.vo.TransactionExportVO;
@@ -83,4 +84,13 @@ public interface TransactionService {
* @return 实际更新的记录数
*/
int batchUpdateCategory(Long userId, BatchCategoryRequest request);
/**
* 批量给交易记录添加/移除标签
*
* @param userId 用户ID
* @param request 批量标签请求(记录ID列表、待添加/移除的标签)
* @return 涉及的记录数
*/
int batchTags(Long userId, BatchTagRequest request);
}
@@ -4,12 +4,16 @@ 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.Tag;
import com.example.finance.entity.Transaction;
import com.example.finance.entity.TransactionTag;
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.TagMapper;
import com.example.finance.mapper.TransactionMapper;
import com.example.finance.mapper.TransactionTagMapper;
import com.example.finance.service.BillImportService;
import com.example.finance.vo.BillImportResultVO;
import com.example.finance.vo.BillParseResultVO;
@@ -41,16 +45,22 @@ public class BillImportServiceImpl implements BillImportService {
private final Map<BillType, BillParser> parserMap = new EnumMap<>(BillType.class);
private final TransactionMapper transactionMapper;
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
private final TransactionTagMapper transactionTagMapper;
public BillImportServiceImpl(List<BillParser> parsers,
TransactionMapper transactionMapper,
CategoryMapper categoryMapper) {
CategoryMapper categoryMapper,
TagMapper tagMapper,
TransactionTagMapper transactionTagMapper) {
// 按账单类型注册解析器,便于扩展新账单来源
for (BillParser parser : parsers) {
parserMap.put(parser.getBillType(), parser);
}
this.transactionMapper = transactionMapper;
this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
this.transactionTagMapper = transactionTagMapper;
}
/**
@@ -107,6 +117,13 @@ public class BillImportServiceImpl implements BillImportService {
Category incomeCategory = request.getIncomeCategoryId() == null ? null
: requireCategory(userId, request.getIncomeCategoryId(), "INCOME");
// 默认标签:仅保留属于当前用户的标签
List<Long> tagIds = request.getTagIds() == null ? List.of()
: tagMapper.selectBatchIds(request.getTagIds()).stream()
.filter(tag -> tag.getUserId().equals(userId))
.map(Tag::getId)
.collect(Collectors.toList());
// 已存在交易号 + 本批次已插入交易号,共同用于去重
Set<String> seen = findExistingExternalNos(userId, items.stream()
.map(BillImportRequest.Item::getExternalNo)
@@ -152,12 +169,20 @@ public class BillImportServiceImpl implements BillImportService {
transaction.setCategoryId(category != null ? category.getId() : null);
transaction.setType(item.getType());
transaction.setAmount(item.getAmount());
transaction.setPaymentMethod("ALIPAY");
transaction.setPaymentMethod(resolvePaymentMethod(request.getBillType()));
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());
transaction.setRawData(item.getRawData());
transactionMapper.insert(transaction);
// 关联默认标签
for (Long tagId : tagIds) {
TransactionTag relation = new TransactionTag();
relation.setTransactionId(transaction.getId());
relation.setTagId(tagId);
transactionTagMapper.insert(relation);
}
if (StringUtils.hasText(item.getExternalNo())) {
seen.add(item.getExternalNo());
@@ -224,6 +249,20 @@ public class BillImportServiceImpl implements BillImportService {
return category;
}
/**
* 根据账单类型解析对应的支付方式编码
*
* @param billType 账单类型,如 ALIPAY、WECHAT
* @return 支付方式编码,无法识别时返回 null
*/
private String resolvePaymentMethod(String billType) {
try {
return BillType.valueOf(billType).name();
} catch (IllegalArgumentException e) {
return null;
}
}
/**
* 根据交易对方与商品名称构造备注
*
@@ -0,0 +1,130 @@
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.TagRequest;
import com.example.finance.entity.Tag;
import com.example.finance.entity.TransactionTag;
import com.example.finance.mapper.TagMapper;
import com.example.finance.mapper.TransactionTagMapper;
import com.example.finance.service.TagService;
import com.example.finance.vo.TagVO;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
/**
* 标签服务实现类,处理标签的增删改查业务逻辑。
*
* @date 2026-09-17 16:26:56
*/
@Service
public class TagServiceImpl implements TagService {
private final TagMapper tagMapper;
private final TransactionTagMapper transactionTagMapper;
public TagServiceImpl(TagMapper tagMapper, TransactionTagMapper transactionTagMapper) {
this.tagMapper = tagMapper;
this.transactionTagMapper = transactionTagMapper;
}
/**
* {@inheritDoc}
*
* 查询用户所有标签,按排序字段和ID升序排列。
*/
@Override
public List<TagVO> list(Long userId) {
List<Tag> tags = tagMapper.selectList(new LambdaQueryWrapper<Tag>()
.eq(Tag::getUserId, userId)
.orderByAsc(Tag::getSortOrder)
.orderByAsc(Tag::getId));
return tags.stream().map(this::toVO).collect(Collectors.toList());
}
/**
* {@inheritDoc}
*
* 创建标签前校验同一用户下标签名唯一。
*/
@Override
@Transactional
public void create(Long userId, TagRequest request) {
Long count = tagMapper.selectCount(new LambdaQueryWrapper<Tag>()
.eq(Tag::getUserId, userId)
.eq(Tag::getName, request.getName()));
if (count != null && count > 0) {
throw new BusinessException("标签名称已存在");
}
Tag tag = new Tag();
tag.setUserId(userId);
tag.setName(request.getName());
tag.setColor(request.getColor());
// 排序值未提供时默认为0
tag.setSortOrder(request.getSortOrder() != null ? request.getSortOrder() : 0);
tagMapper.insert(tag);
}
/**
* {@inheritDoc}
*
* 更新标签前校验归属当前用户,并校验改名后不与其它标签重名。
*/
@Override
@Transactional
public void update(Long userId, Long id, TagRequest request) {
Tag tag = tagMapper.selectById(id);
if (tag == null || !tag.getUserId().equals(userId)) {
throw new BusinessException("标签不存在");
}
Long count = tagMapper.selectCount(new LambdaQueryWrapper<Tag>()
.eq(Tag::getUserId, userId)
.eq(Tag::getName, request.getName())
.ne(Tag::getId, id));
if (count != null && count > 0) {
throw new BusinessException("标签名称已存在");
}
tag.setName(request.getName());
tag.setColor(request.getColor());
if (request.getSortOrder() != null) {
tag.setSortOrder(request.getSortOrder());
}
tagMapper.updateById(tag);
}
/**
* {@inheritDoc}
*
* 删除标签前先清理其与交易记录的关联,避免残留脏数据。
*/
@Override
@Transactional
public void delete(Long userId, Long id) {
Tag tag = tagMapper.selectById(id);
if (tag == null || !tag.getUserId().equals(userId)) {
throw new BusinessException("标签不存在");
}
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
.eq(TransactionTag::getTagId, id));
tagMapper.deleteById(id);
}
/**
* 将标签实体转换为视图对象
*
* @param tag 标签实体
* @return 标签视图对象
*/
private TagVO toVO(Tag tag) {
TagVO vo = new TagVO();
vo.setId(tag.getId());
vo.setName(tag.getName());
vo.setColor(tag.getColor());
vo.setSortOrder(tag.getSortOrder());
vo.setCreatedAt(tag.getCreatedAt());
return vo;
}
}
@@ -6,13 +6,19 @@ 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.BatchTagRequest;
import com.example.finance.dto.TransactionQuery;
import com.example.finance.dto.TransactionRequest;
import com.example.finance.entity.Category;
import com.example.finance.entity.Tag;
import com.example.finance.entity.Transaction;
import com.example.finance.entity.TransactionTag;
import com.example.finance.mapper.CategoryMapper;
import com.example.finance.mapper.TagMapper;
import com.example.finance.mapper.TransactionMapper;
import com.example.finance.mapper.TransactionTagMapper;
import com.example.finance.service.TransactionService;
import com.example.finance.vo.TagVO;
import com.example.finance.vo.TransactionExportVO;
import com.example.finance.vo.TransactionVO;
import org.springframework.stereotype.Service;
@@ -21,8 +27,11 @@ import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
@@ -36,10 +45,17 @@ public class TransactionServiceImpl implements TransactionService {
private final TransactionMapper transactionMapper;
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
private final TransactionTagMapper transactionTagMapper;
public TransactionServiceImpl(TransactionMapper transactionMapper, CategoryMapper categoryMapper) {
public TransactionServiceImpl(TransactionMapper transactionMapper,
CategoryMapper categoryMapper,
TagMapper tagMapper,
TransactionTagMapper transactionTagMapper) {
this.transactionMapper = transactionMapper;
this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
this.transactionTagMapper = transactionTagMapper;
}
/**
@@ -97,9 +113,14 @@ public class TransactionServiceImpl implements TransactionService {
if (StringUtils.hasText(query.getRemark())) {
wrapper.like(Transaction::getRemark, query.getRemark());
}
// 按标签筛选:命中任一选中标签的记录
if (query.getTagIds() != null && !query.getTagIds().isEmpty()) {
wrapper.inSql(Transaction::getId, "SELECT transaction_id FROM t_transaction_tag WHERE tag_id IN ("
+ query.getTagIds().stream().map(String::valueOf).collect(Collectors.joining(",")) + ")");
}
// 按交易时间降序排列
wrapper.orderByDesc(Transaction::getTransactionTime);
// 应用排序:默认按交易时间降序,支持按交易时间/金额排序
applySort(wrapper, query);
Page<Transaction> page = new Page<>(query.getPage(), query.getSize());
Page<Transaction> result = transactionMapper.selectPage(page, wrapper);
@@ -121,6 +142,8 @@ public class TransactionServiceImpl implements TransactionService {
List<TransactionVO> voList = result.getRecords().stream()
.map(t -> toVO(t, finalCategoryMap.get(t.getCategoryId())))
.collect(Collectors.toList());
// 批量填充标签
fillTags(voList);
return new PageResult<>(voList, result.getTotal(), result.getCurrent(), result.getSize());
}
@@ -149,6 +172,8 @@ public class TransactionServiceImpl implements TransactionService {
transaction.setTransactionTime(request.getTransactionTime());
transaction.setIncludeInStats(request.getIncludeInStats() == null ? Boolean.TRUE : request.getIncludeInStats());
transactionMapper.insert(transaction);
// 同步标签关联
saveTransactionTags(userId, transaction.getId(), request.getTagIds());
}
/**
@@ -179,6 +204,8 @@ public class TransactionServiceImpl implements TransactionService {
transaction.setTransactionTime(request.getTransactionTime());
transaction.setIncludeInStats(request.getIncludeInStats() == null ? Boolean.TRUE : request.getIncludeInStats());
transactionMapper.updateById(transaction);
// 全量替换标签关联
saveTransactionTags(userId, id, request.getTagIds());
}
/**
@@ -193,6 +220,9 @@ public class TransactionServiceImpl implements TransactionService {
if (transaction == null || !transaction.getUserId().equals(userId)) {
throw new BusinessException("记录不存在");
}
// 先清理标签关联,再删除记录
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
.eq(TransactionTag::getTransactionId, id));
transactionMapper.deleteById(id);
}
@@ -244,13 +274,14 @@ public class TransactionServiceImpl implements TransactionService {
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("请至少选择一个分类");
Boolean includeInStats = request.getIncludeInStats();
if (expenseCategory == null && incomeCategory == null && includeInStats == null) {
throw new BusinessException("请至少选择分类或计入统计");
}
// 查询待更新记录,按交易类型拆分ID,避免类型不匹配的更新
@@ -267,22 +298,197 @@ public class TransactionServiceImpl implements TransactionService {
.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));
}
updated += batchUpdate(userId, expenseIds, expenseCategory, includeInStats);
updated += batchUpdate(userId, incomeIds, incomeCategory, includeInStats);
return updated;
}
/**
* 对指定记录批量写入分类与计入统计标记
*
* @param userId 用户ID
* @param ids 待更新记录ID列表
* @param category 目标分类,为 null 时不修改分类
* @param includeInStats 计入统计标记,为 null 时不修改
* @return 实际更新的记录数
*/
private int batchUpdate(Long userId, List<Long> ids, Category category, Boolean includeInStats) {
if (ids.isEmpty() || (category == null && includeInStats == null)) {
return 0;
}
LambdaUpdateWrapper<Transaction> wrapper = new LambdaUpdateWrapper<Transaction>()
.eq(Transaction::getUserId, userId)
.in(Transaction::getId, ids);
if (category != null) {
wrapper.set(Transaction::getCategoryId, category.getId());
}
if (includeInStats != null) {
wrapper.set(Transaction::getIncludeInStats, includeInStats);
}
return transactionMapper.update(null, wrapper);
}
/**
* {@inheritDoc}
*
* 仅对属于当前用户的记录生效;先移除指定标签,再补充尚未存在的标签。
*/
@Override
@Transactional
public int batchTags(Long userId, BatchTagRequest request) {
List<Long> ids = request.getIds();
if (ids == null || ids.isEmpty()) {
throw new BusinessException("请至少选择一条记录");
}
List<Long> addTagIds = validTagIds(userId, request.getAddTagIds());
List<Long> removeTagIds = validTagIds(userId, request.getRemoveTagIds());
if (addTagIds.isEmpty() && removeTagIds.isEmpty()) {
throw new BusinessException("请至少选择要添加或移除的标签");
}
// 仅处理属于当前用户的记录
List<Long> validIds = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.select(Transaction::getId)
.eq(Transaction::getUserId, userId)
.in(Transaction::getId, ids))
.stream().map(Transaction::getId).collect(Collectors.toList());
if (validIds.isEmpty()) {
return 0;
}
// 移除标签关联
if (!removeTagIds.isEmpty()) {
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
.in(TransactionTag::getTransactionId, validIds)
.in(TransactionTag::getTagId, removeTagIds));
}
// 添加标签关联,跳过已存在的组合
if (!addTagIds.isEmpty()) {
List<TransactionTag> existing = transactionTagMapper.selectList(new LambdaQueryWrapper<TransactionTag>()
.in(TransactionTag::getTransactionId, validIds)
.in(TransactionTag::getTagId, addTagIds));
Set<String> existingKeys = existing.stream()
.map(rel -> rel.getTransactionId() + ":" + rel.getTagId())
.collect(Collectors.toSet());
for (Long transactionId : validIds) {
for (Long tagId : addTagIds) {
if (existingKeys.contains(transactionId + ":" + tagId)) {
continue;
}
TransactionTag relation = new TransactionTag();
relation.setTransactionId(transactionId);
relation.setTagId(tagId);
transactionTagMapper.insert(relation);
}
}
}
return validIds.size();
}
/**
* 过滤出属于当前用户的标签ID
*
* @param userId 用户ID
* @param tagIds 待校验的标签ID列表
* @return 有效的标签ID列表
*/
private List<Long> validTagIds(Long userId, List<Long> tagIds) {
if (tagIds == null || tagIds.isEmpty()) {
return List.of();
}
return tagMapper.selectBatchIds(tagIds).stream()
.filter(tag -> tag.getUserId().equals(userId))
.map(Tag::getId)
.collect(Collectors.toList());
}
/**
* 全量替换某条交易记录的标签关联
*
* @param userId 用户ID
* @param transactionId 交易记录ID
* @param tagIds 目标标签ID列表,为空表示清空
*/
private void saveTransactionTags(Long userId, Long transactionId, List<Long> tagIds) {
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
.eq(TransactionTag::getTransactionId, transactionId));
for (Long tagId : validTagIds(userId, tagIds)) {
TransactionTag relation = new TransactionTag();
relation.setTransactionId(transactionId);
relation.setTagId(tagId);
transactionTagMapper.insert(relation);
}
}
/**
* 批量填充交易视图对象的标签列表
*
* @param voList 交易视图对象列表
*/
private void fillTags(List<TransactionVO> voList) {
if (voList.isEmpty()) {
return;
}
List<Long> transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList());
List<TransactionTag> relations = transactionTagMapper.selectList(new LambdaQueryWrapper<TransactionTag>()
.in(TransactionTag::getTransactionId, transactionIds));
if (relations.isEmpty()) {
return;
}
List<Long> tagIds = relations.stream().map(TransactionTag::getTagId).distinct().collect(Collectors.toList());
Map<Long, Tag> tagMap = tagMapper.selectBatchIds(tagIds).stream()
.collect(Collectors.toMap(Tag::getId, tag -> tag));
Map<Long, List<TagVO>> tagsByTransaction = new HashMap<>();
for (TransactionTag relation : relations) {
Tag tag = tagMap.get(relation.getTagId());
if (tag == null) {
continue;
}
tagsByTransaction.computeIfAbsent(relation.getTransactionId(), key -> new ArrayList<>())
.add(toTagVO(tag));
}
for (TransactionVO vo : voList) {
vo.setTags(tagsByTransaction.getOrDefault(vo.getId(), List.of()));
}
}
/**
* 将标签实体转换为视图对象
*
* @param tag 标签实体
* @return 标签视图对象
*/
private TagVO toTagVO(Tag tag) {
TagVO vo = new TagVO();
vo.setId(tag.getId());
vo.setName(tag.getName());
vo.setColor(tag.getColor());
vo.setSortOrder(tag.getSortOrder());
vo.setCreatedAt(tag.getCreatedAt());
return vo;
}
/**
* 应用排序条件:默认按交易时间降序,支持按交易时间或金额升/降序。
*
* <p>排序字段与方向均采用白名单,非法值回退为交易时间降序,避免注入风险。</p>
*
* @param wrapper 查询条件
* @param query 查询参数
*/
private void applySort(LambdaQueryWrapper<Transaction> wrapper, TransactionQuery query) {
boolean asc = "asc".equalsIgnoreCase(query.getSortOrder());
if ("amount".equals(query.getSortField())) {
wrapper.orderBy(true, asc, Transaction::getAmount);
} else if ("transactionTime".equals(query.getSortField())) {
wrapper.orderBy(true, asc, Transaction::getTransactionTime);
} else {
wrapper.orderByDesc(Transaction::getTransactionTime);
}
}
/**
* 校验分类是否存在、归属当前用户且类型匹配
*
@@ -322,6 +528,7 @@ public class TransactionServiceImpl implements TransactionService {
vo.setRemarkHidden(t.getRemarkHidden() == null ? Boolean.FALSE : t.getRemarkHidden());
vo.setTransactionTime(t.getTransactionTime());
vo.setIncludeInStats(t.getIncludeInStats() == null ? Boolean.TRUE : t.getIncludeInStats());
vo.setRawData(t.getRawData());
vo.setCreatedAt(t.getCreatedAt());
return vo;
}
@@ -380,9 +587,14 @@ public class TransactionServiceImpl implements TransactionService {
if (StringUtils.hasText(query.getRemark())) {
wrapper.like(Transaction::getRemark, query.getRemark());
}
// 按标签筛选:命中任一选中标签的记录
if (query.getTagIds() != null && !query.getTagIds().isEmpty()) {
wrapper.inSql(Transaction::getId, "SELECT transaction_id FROM t_transaction_tag WHERE tag_id IN ("
+ query.getTagIds().stream().map(String::valueOf).collect(Collectors.joining(",")) + ")");
}
// 按交易时间降序排列
wrapper.orderByDesc(Transaction::getTransactionTime);
// 应用排序:默认按交易时间降序,支持按交易时间/金额排序
applySort(wrapper, query);
List<Transaction> transactions = transactionMapper.selectList(wrapper);
@@ -0,0 +1,29 @@
package com.example.finance.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 标签视图对象,用于向前端返回标签信息。
*
* @date 2026-09-17 16:26:52
*/
@Data
public class TagVO {
/** 标签ID */
private Long id;
/** 标签名称 */
private String name;
/** 标签颜色 */
private String color;
/** 排序序号 */
private Integer sortOrder;
/** 创建时间 */
private LocalDateTime createdAt;
}
@@ -4,6 +4,7 @@ import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 交易记录视图对象,用于向前端返回交易详情(包含关联的分类信息)
@@ -44,6 +45,12 @@ public class TransactionVO {
/** 是否计入统计 */
private Boolean includeInStats;
/** 账单导入的原始完整数据(结构化JSON),手动记录为空 */
private String rawData;
/** 创建时间 */
private LocalDateTime createdAt;
/** 标签列表 */
private List<TagVO> tags;
}