feat: 招商银行 PDF 账单导入、跨格式去重与导入性能优化

- 新增招商银行「交易流水」PDF 解析(PDFBox 坐标还原,支持多页与折行),CSV/PDF 去重标识统一,支持跨格式去重
- 账单导入改为批量插入,MyBatis 日志改 Slf4j,导入接口超时放宽至 120s
- 交易导出 Excel 增加样式、冻结表头与自动筛选,导出前二次确认
- 交易列表展示不计入统计金额,导入预览支持设置「计入统计」
This commit is contained in:
2026-09-19 14:30:09 +08:00
parent 9981e77bf1
commit 196afb682d
16 changed files with 1072 additions and 107 deletions
@@ -1,6 +1,9 @@
package cn.rosercode.finance.controller;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.alibaba.excel.write.style.HorizontalCellStyleStrategy;
import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.common.Result;
import cn.rosercode.finance.dto.BatchCategoryRequest;
@@ -8,6 +11,7 @@ import cn.rosercode.finance.dto.BatchDeleteRequest;
import cn.rosercode.finance.dto.BatchTagRequest;
import cn.rosercode.finance.dto.TransactionQuery;
import cn.rosercode.finance.dto.TransactionRequest;
import cn.rosercode.finance.exporter.TransactionExportSheetHandler;
import cn.rosercode.finance.service.TransactionService;
import cn.rosercode.finance.vo.TransactionExportVO;
import cn.rosercode.finance.vo.TransactionVO;
@@ -17,6 +21,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.util.CellRangeAddress;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -231,8 +237,22 @@ public class TransactionController {
// 查询数据并写入 Excel
List<TransactionExportVO> data = transactionService.export(userId, query);
EasyExcel.write(response.getOutputStream(), TransactionExportVO.class)
.sheet("交易记录")
.doWrite(data);
// 表头样式与内容样式
HorizontalCellStyleStrategy styleStrategy = new HorizontalCellStyleStrategy(
TransactionExportSheetHandler.buildHeadStyle(),
TransactionExportSheetHandler.buildContentStyle());
try (ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), TransactionExportVO.class).build()) {
WriteSheet writeSheet = EasyExcel.writerSheet("交易记录")
.registerWriteHandler(styleStrategy)
.registerWriteHandler(new TransactionExportSheetHandler())
.build();
excelWriter.write(data, writeSheet);
// 数据写入完成后设置自动筛选,范围覆盖表头与全部数据行
Sheet sheet = excelWriter.writeContext().writeSheetHolder().getSheet();
sheet.setAutoFilter(new CellRangeAddress(0, data.size(), 0, TransactionExportSheetHandler.LAST_COLUMN_INDEX));
}
}
}
@@ -0,0 +1,85 @@
package cn.rosercode.finance.exporter;
import com.alibaba.excel.write.handler.SheetWriteHandler;
import com.alibaba.excel.write.handler.context.SheetWriteHandlerContext;
import com.alibaba.excel.write.metadata.style.WriteCellStyle;
import com.alibaba.excel.write.metadata.style.WriteFont;
import org.apache.poi.ss.usermodel.BorderStyle;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.VerticalAlignment;
/**
* 交易记录导出 Excel 的样式与冻结窗格处理器。
*
* <p>负责冻结首行表头,并提供表头与内容的单元格样式,使导出的 Excel 更易读。</p>
*
* @date 2026-09-19 13:41:40
*/
public class TransactionExportSheetHandler implements SheetWriteHandler {
/** 导出表头所在的列索引范围:0 ~ 6,共 7 列 */
public static final int LAST_COLUMN_INDEX = 6;
/** 冻结的行数(表头行) */
private static final int FROZEN_ROW_COUNT = 1;
/**
* 创建 Sheet 后冻结首行表头,滚动数据时保持表头可见。
*
* @param context Sheet 写入上下文
*/
@Override
public void afterSheetCreate(SheetWriteHandlerContext context) {
Sheet sheet = context.getWriteSheetHolder().getSheet();
sheet.createFreezePane(0, FROZEN_ROW_COUNT);
}
/**
* 构建表头样式:深青色背景、白色加粗字体、水平垂直居中、四周细边框。
*
* @return 表头单元格样式
*/
public static WriteCellStyle buildHeadStyle() {
WriteCellStyle headStyle = new WriteCellStyle();
headStyle.setFillPatternType(FillPatternType.SOLID_FOREGROUND);
headStyle.setFillForegroundColor(IndexedColors.DARK_TEAL.getIndex());
headStyle.setHorizontalAlignment(HorizontalAlignment.CENTER);
headStyle.setVerticalAlignment(VerticalAlignment.CENTER);
applyThinBorder(headStyle);
WriteFont headFont = new WriteFont();
headFont.setBold(true);
headFont.setColor(IndexedColors.WHITE.getIndex());
headFont.setFontHeightInPoints((short) 11);
headStyle.setWriteFont(headFont);
return headStyle;
}
/**
* 构建内容样式:垂直居中、自动换行、四周细边框。
*
* @return 内容单元格样式
*/
public static WriteCellStyle buildContentStyle() {
WriteCellStyle contentStyle = new WriteCellStyle();
contentStyle.setVerticalAlignment(VerticalAlignment.CENTER);
contentStyle.setWrapped(true);
applyThinBorder(contentStyle);
return contentStyle;
}
/**
* 为单元格样式添加四周细边框。
*
* @param style 待设置的单元格样式
*/
private static void applyThinBorder(WriteCellStyle style) {
style.setBorderTop(BorderStyle.THIN);
style.setBorderBottom(BorderStyle.THIN);
style.setBorderLeft(BorderStyle.THIN);
style.setBorderRight(BorderStyle.THIN);
}
}
@@ -53,35 +53,38 @@ public class BillArchiveExtractor {
}
/**
* 判断压缩包内是否存在指定扩展名的文件。
* 在压缩包内查找匹配任一扩展名的文件,返回命中的扩展名。
*
* <p>xlsx 等 Office 文件本身也是 zip 结构,需借此区分「账单压缩包」与「原始账单文件」:
* 仅当压缩包内确实包含目标扩展名的账单文件时才按压缩包处理。</p>
* 仅当压缩包内确实包含目标扩展名的账单文件时才按压缩包处理。同一账单类型可能支持
* 多种导出格式(如招商银行的 CSV 与 PDF),故按候选扩展名顺序依次匹配。</p>
*
* @param content 文件字节内容
* @param extension 目标文件扩展名(小写,含点,如 .csv、.xlsx)
* @return 存在匹配文件返回 true
* @param content 文件字节内容
* @param extensions 目标文件扩展名(小写,含点,如 .csv、.pdf)
* @return 命中的扩展名,未命中返回 null
*/
public boolean containsEntry(byte[] content, String extension) {
if (!isZip(content)) {
return false;
public String findEntryExtension(byte[] content, String... extensions) {
if (!isZip(content) || extensions == null || extensions.length == 0) {
return null;
}
Path temp = null;
try {
temp = writeTemp(content);
List<FileHeader> headers = new ZipFile(temp.toFile()).getFileHeaders();
for (FileHeader header : headers) {
if (!header.isDirectory() && header.getFileName().toLowerCase().endsWith(extension)) {
return true;
for (String extension : extensions) {
for (FileHeader header : headers) {
if (!header.isDirectory() && header.getFileName().toLowerCase().endsWith(extension)) {
return extension;
}
}
}
} catch (IOException e) {
// 读取失败按非账单压缩包处理,交由原解析器给出更准确的错误
return false;
return null;
} finally {
deleteQuietly(temp);
}
return false;
return null;
}
/**
@@ -32,7 +32,11 @@ import java.util.Map;
*
* <p>解析招商银行「交易流水」导出的 CSV 文件。文件为 UTF-8(含 BOM,兼容 GBK),
* 前几行以「#」开头的账号、日期范围等元信息,之后为表头与数据行,末尾为收支合计。
* 交易日期与交易时间分列,金额分为收入、支出两列,且无交易单号,需按行生成去重标识。</p>
* 交易日期与交易时间分列,金额分为收入、支出两列,且无交易单号,需按行生成去重标识:
* 由日期、收支类型、金额与余额组合,与 PDF 解析器({@link CmbPdfParser})保持一致,
* 使同一笔交易无论从 CSV 还是 PDF 导入都能相互识别为重复。</p>
*
* <p>招商银行同时提供「交易流水」PDF 导出,按文件头魔数识别后交由 {@link CmbPdfParser} 处理。</p>
*
* @date 2026-09-19 02:13:20
*/
@@ -60,17 +64,18 @@ public class CmbBillParser implements BillParser {
/** 交易时间格式(如 12:29:45) */
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");
/** 生成去重标识时使用的时间格式 */
private static final DateTimeFormatter ID_TIME_FORMATTER = DateTimeFormatter.ofPattern("HHmmss");
/** CSV 解析格式:逗号分隔并去除字段首尾空白 */
private static final CSVFormat CSV_FORMAT = CSVFormat.DEFAULT.builder().setTrim(true).build();
/** JSON 序列化器,用于生成原始完整数据 */
private final ObjectMapper objectMapper;
public CmbBillParser(ObjectMapper objectMapper) {
/** PDF 交易流水解析器,用于处理 PDF 格式的招商银行账单 */
private final CmbPdfParser cmbPdfParser;
public CmbBillParser(ObjectMapper objectMapper, CmbPdfParser cmbPdfParser) {
this.objectMapper = objectMapper;
this.cmbPdfParser = cmbPdfParser;
}
@Override
@@ -81,10 +86,14 @@ public class CmbBillParser implements BillParser {
/**
* {@inheritDoc}
*
* 定位表头后逐行解析数据,遇到元信息或合计行结束。
* 按文件头识别 PDF:PDF 交易流水交由 {@link CmbPdfParser} 处理;
* CSV 定位表头后逐行解析数据,遇到元信息或合计行结束。
*/
@Override
public List<ParsedBillRow> parse(byte[] content) throws IOException {
if (cmbPdfParser.isPdf(content)) {
return cmbPdfParser.parse(content);
}
String text = new String(content, detectCharset(content));
String[] lines = text.split("\r?\n", -1);
@@ -203,8 +212,9 @@ public class CmbBillParser implements BillParser {
// 银行流水默认计入统计
row.setIncludeInStats(true);
// 无交易单号,按日期时间、类型与金额生成去重标识
row.setExternalNo(buildExternalNo(row.getTransactionTime(), type, row.getAmount()));
// 无交易单号,按日期、类型、金额与余额生成去重标识(与 PDF 解析器统一)
row.setExternalNo(buildExternalNo(row.getTransactionTime(), type, row.getAmount(),
get(header, values, COL_BALANCE)));
// 校验必要字段,决定是否可导入
boolean importable = row.getError() == null
@@ -219,21 +229,26 @@ public class CmbBillParser implements BillParser {
}
/**
* 生成去重标识:银行流水无交易单号,用日期时间、收支类型与金额组合。
* 生成去重标识:银行流水无交易单号,用日期、收支类型、金额与余额组合。
*
* @param time 交易时间
* @param type 收支类型
* @param amount 金额
* <p>格式与招商银行 PDF 解析器({@link CmbPdfParser})完全一致,以便同一笔交易
* 无论从 CSV 还是 PDF 导入都能相互识别为重复。余额用于区分同一天、同类型、同金额的
* 不同交易。</p>
*
* @param time 交易时间
* @param type 收支类型
* @param amount 金额
* @param balanceText 余额文本
* @return 去重标识,信息不足时返回 null
*/
private String buildExternalNo(LocalDateTime time, String type, BigDecimal amount) {
private String buildExternalNo(LocalDateTime time, String type, BigDecimal amount, String balanceText) {
if (time == null || type == null || amount == null) {
return null;
}
String date = time.format(DATE_FORMATTER);
String clock = time.format(ID_TIME_FORMATTER);
String value = amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
return "CMB_" + date + "_" + clock + "_" + type + "_" + value;
String balance = StringUtils.hasText(balanceText) ? balanceText.replace(",", "").trim() : "";
return "CMB_" + date + "_" + type + "_" + value + "_" + balance;
}
/**
@@ -0,0 +1,604 @@
package cn.rosercode.finance.importer;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* 招商银行「交易流水」PDF 解析器。
*
* <p>招商银行导出的「交易流水」PDF(由 openhtmltopdf 生成)为文本型文件,表格列为
* 「记账日期 / 货币 / 交易金额 / 联机余额 / 交易摘要 / 对手信息」。解析时不依赖空白对齐
* (列内容变长会漂移),而是利用 PDFBox 抽取的每个字符坐标:按纵坐标聚成行、依据表头
* 字符横坐标切列,再逐列解析。</p>
*
* <p>流水可能多页,且摘要或对手信息较长时会在单元格内折行。解析按页进行(避免不同页的
* 纵坐标互相干扰),页内以数据行(首列为日期)为锚,将相邻的折行文本行按纵坐标归属到
* 最近的数据行后逐列合并,从而还原被折行的单元格内容。</p>
*
* <p>金额为带符号的单列,负号为支出、正号为收入。由于该流水无交易单号与交易时间,
* 去重标识由日期、收支类型、金额与联机余额组合生成。解析后按「联机余额」做连续性校验
* (本笔余额应等于上笔余额加减本笔金额),校验不通过的行标记为不可导入,交由人工确认。</p>
*
* @date 2026-09-19 12:25:17
*/
@Component
public class CmbPdfParser {
/** 表头锚点,含该文本的行视为表头行 */
private static final String HEADER_ANCHOR = "记账日期";
/** 表格列名,顺序与 PDF 中一致,用于生成原始数据 */
private static final String[] COLUMN_NAMES = {"记账日期", "货币", "交易金额", "联机余额", "交易摘要", "对手信息"};
/** 列索引 */
private static final int COL_DATE = 0;
private static final int COL_AMOUNT = 2;
private static final int COL_BALANCE = 3;
private static final int COL_SUMMARY = 4;
private static final int COL_COUNTERPARTY = 5;
/** 期望的列数,少于该值视为未正确识别表头 */
private static final int COLUMN_COUNT = COLUMN_NAMES.length;
/** 聚合成行时允许的纵坐标误差(磅) */
private static final float Y_TOLERANCE = 3.0f;
/** 表头分词时判定列间隙的横坐标阈值(磅) */
private static final float COLUMN_GAP = 4.0f;
/** 同一列内补空格的字符间隙阈值(磅) */
private static final float CHAR_GAP = 1.5f;
/** 判定字符归属列时允许的横坐标左移误差(磅) */
private static final float COLUMN_X_TOLERANCE = 1.0f;
/** 折行文本行归属到数据行的最大纵坐标距离(磅),超出视为页眉页脚等无关行 */
private static final float FOLD_MAX_DISTANCE = 40.0f;
/** 余额连续性校验允许的误差 */
private static final BigDecimal BALANCE_TOLERANCE = new BigDecimal("0.005");
/** 交易日期格式(如 2026-09-13) */
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
/** 生成去重标识时使用的日期格式 */
private static final DateTimeFormatter ID_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
/** 数据行首列的日期正则,用于剔除页眉页脚等非数据行 */
private static final Pattern DATE_PATTERN = Pattern.compile("\\d{4}-\\d{2}-\\d{2}");
/** PDF 文件头魔数 %PDF- */
private static final byte[] PDF_MAGIC = {0x25, 0x50, 0x44, 0x46, 0x2D};
/** JSON 序列化器,用于生成原始完整数据 */
private final ObjectMapper objectMapper;
public CmbPdfParser(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* 判断字节内容是否为 PDF 文件(按文件头魔数 %PDF-)。
*
* @param content 文件字节内容
* @return 是 PDF 返回 true
*/
public boolean isPdf(byte[] content) {
if (content == null || content.length < PDF_MAGIC.length) {
return false;
}
for (int i = 0; i < PDF_MAGIC.length; i++) {
if (content[i] != PDF_MAGIC[i]) {
return false;
}
}
return true;
}
/**
* 解析招商银行交易流水 PDF。
*
* <p>逐页抽取字符坐标并按行聚类,页内定位表头推导列边界、合并折行后逐行解析。
* 每笔交易按联机余额做连续性校验(跨页保持),校验失败的行标记为不可导入。</p>
*
* @param content PDF 文件字节内容
* @return 解析后的账单行列表
* @throws IOException 读取 PDF 失败
*/
public List<ParsedBillRow> parse(byte[] content) throws IOException {
List<ParsedBillRow> rows = new ArrayList<>();
BigDecimal previousBalance = null;
int rowNo = 0;
try (PDDocument document = Loader.loadPDF(content)) {
PositionCollector collector = new PositionCollector();
collector.setSortByPosition(true);
collector.setStartPage(1);
collector.setEndPage(document.getNumberOfPages());
collector.getText(document);
for (int pageNo = 1; pageNo <= document.getNumberOfPages(); pageNo++) {
PageResult result = parsePage(collector.linesOfPage(pageNo), rowNo, previousBalance);
rows.addAll(result.rows());
rowNo += result.rows().size();
previousBalance = result.previousBalance();
}
}
return rows;
}
/**
* 解析单页:定位表头切列,以数据行为锚合并折行文本,逐行生成账单行并做余额校验。
*
* @param lines 该页按纵坐标聚合后的行
* @param startRowNo 起始行号(跨页累加)
* @param previousBalance 上一笔(可能来自上一页)的联机余额,用于连续性校验
* @return 该页解析结果与末笔余额
*/
private PageResult parsePage(List<List<TextPosition>> lines, int startRowNo, BigDecimal previousBalance) {
List<ParsedBillRow> rows = new ArrayList<>();
List<Float> columnStarts = null;
List<RowCells> parsed = new ArrayList<>();
for (List<TextPosition> line : lines) {
List<TextPosition> sorted = new ArrayList<>(line);
sorted.sort(Comparator.comparingDouble(TextPosition::getXDirAdj));
// 表头行:记录各列起始横坐标,供后续数据行切列
if (columnStarts == null && joinText(sorted).contains(HEADER_ANCHOR)) {
columnStarts = detectColumnStarts(sorted);
continue;
}
if (columnStarts == null || columnStarts.size() < COLUMN_COUNT) {
continue;
}
List<String> cells = splitColumns(sorted, columnStarts);
boolean dataRow = DATE_PATTERN.matcher(cells.get(COL_DATE).trim()).matches();
// 表头的中文行由锚点识别,其下的英文说明行既无中文也无数字,借此跳过;
// 其余非数据行(含中文或数字)保留,作为数据行的折行片段参与合并
if (!dataRow && isHeaderText(cells)) {
continue;
}
parsed.add(new RowCells(averageY(sorted), cells, dataRow));
}
List<Integer> dataIndexes = new ArrayList<>();
for (int i = 0; i < parsed.size(); i++) {
if (parsed.get(i).dataRow) {
dataIndexes.add(i);
}
}
if (dataIndexes.isEmpty()) {
return new PageResult(rows, previousBalance);
}
// 折行片段归属:非数据行按其纵坐标归属到最近的数据行
Map<Integer, List<RowCells>> grouped = new LinkedHashMap<>();
for (int index : dataIndexes) {
grouped.put(index, new ArrayList<>());
grouped.get(index).add(parsed.get(index));
}
for (int i = 0; i < parsed.size(); i++) {
RowCells rowCells = parsed.get(i);
if (rowCells.dataRow || isBlankCells(rowCells.cells)) {
continue;
}
int nearest = nearestDataIndex(rowCells.y, parsed, dataIndexes);
if (Math.abs(rowCells.y - parsed.get(nearest).y) <= FOLD_MAX_DISTANCE) {
grouped.get(nearest).add(rowCells);
}
}
int rowNo = startRowNo;
for (int index : dataIndexes) {
rowNo++;
List<RowCells> group = grouped.get(index);
group.sort(Comparator.comparingDouble(cell -> cell.y));
List<String> merged = mergeCells(group, columnStarts.size());
ParsedBillRow row = toRow(rowNo, merged);
// 余额连续性校验:本笔余额 = 上笔余额 + 带符号金额
BigDecimal balance = parseAmount(merged.get(COL_BALANCE));
if (row.getError() == null && previousBalance != null && balance != null && row.getAmount() != null) {
BigDecimal signed = "EXPENSE".equals(row.getType()) ? row.getAmount().negate() : row.getAmount();
BigDecimal expected = previousBalance.add(signed);
if (expected.subtract(balance).abs().compareTo(BALANCE_TOLERANCE) > 0) {
row.setError("余额连续性校验失败:上笔余额 " + previousBalance.toPlainString()
+ " 加减本笔金额应为 " + expected.toPlainString()
+ ",实际 " + balance.toPlainString());
row.setImportable(false);
row.setSelected(false);
}
}
if (balance != null) {
previousBalance = balance;
}
rows.add(row);
}
return new PageResult(rows, previousBalance);
}
/**
* 将单行数据映射为账单行对象。
*
* @param rowNo 行号
* @param cells 合并折行后按列切分的单元格文本
* @return 解析后的账单行
*/
private ParsedBillRow toRow(int rowNo, List<String> cells) {
String dateText = cells.get(COL_DATE).trim();
String amountText = cells.get(COL_AMOUNT).trim();
String balanceText = cells.get(COL_BALANCE).trim();
String summary = cells.get(COL_SUMMARY).trim();
String counterparty = cells.get(COL_COUNTERPARTY).trim();
ParsedBillRow row = new ParsedBillRow();
row.setRowNo(rowNo);
// 交易摘要作为商品名称,对手信息作为交易对方
row.setCounterparty(counterparty);
row.setProduct(summary);
row.setTradeStatus(summary);
row.setIncludeInStats(true);
row.setRawData(buildRawData(cells));
// 交易日期:流水只有日期无时间,时间置为当日零点
try {
row.setTransactionTime(LocalDate.parse(dateText, DATE_FORMATTER).atStartOfDay());
} catch (DateTimeParseException e) {
row.setError("交易日期格式无法解析:" + dateText);
}
// 金额为带符号单列:负号为支出、正号为收入
BigDecimal signed = parseAmount(amountText);
if (signed == null) {
if (row.getError() == null) {
row.setError("交易金额格式无法解析:" + amountText);
}
} else {
row.setType(signed.signum() < 0 ? "EXPENSE" : "INCOME");
row.setAmount(signed.abs());
}
// 无交易单号与时间,用日期、类型、金额与余额组合生成去重标识
if (row.getType() != null && row.getAmount() != null) {
row.setExternalNo(buildExternalNo(dateText, row.getType(), row.getAmount(), balanceText));
}
boolean importable = row.getError() == null
&& StringUtils.hasText(row.getExternalNo())
&& row.getTransactionTime() != null
&& row.getAmount() != null
&& row.getAmount().compareTo(BigDecimal.ZERO) > 0
&& row.getType() != null;
row.setImportable(importable);
row.setSelected(importable);
return row;
}
/**
* 生成去重标识:流水无交易单号,用日期、收支类型、金额与联机余额组合。
*
* <p>格式与招商银行 CSV 解析器({@link CmbBillParser})完全一致,以便同一笔交易
* 无论从 CSV 还是 PDF 导入都能相互识别为重复。</p>
*
* @param dateText 交易日期文本
* @param type 收支类型
* @param amount 金额(绝对值)
* @param balanceText 联机余额文本
* @return 去重标识
*/
private String buildExternalNo(String dateText, String type, BigDecimal amount, String balanceText) {
String date = LocalDate.parse(dateText, DATE_FORMATTER).format(ID_DATE_FORMATTER);
String value = amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
String balance = StringUtils.hasText(balanceText) ? balanceText.replace(",", "").trim() : "";
return "CMB_" + date + "_" + type + "_" + value + "_" + balance;
}
/**
* 解析金额文本,去除千分位逗号。
*
* @param text 金额文本
* @return 金额,无法解析时返回 null
*/
private BigDecimal parseAmount(String text) {
if (!StringUtils.hasText(text)) {
return null;
}
try {
return new BigDecimal(text.replace(",", "").trim());
} catch (NumberFormatException e) {
return null;
}
}
/**
* 从表头行推导各列起始横坐标:按字符间隙分词,取每个词的起始横坐标。
*
* @param headerLine 已按横坐标排序的表头行字符
* @return 各列起始横坐标列表
*/
private List<Float> detectColumnStarts(List<TextPosition> headerLine) {
List<Float> starts = new ArrayList<>();
float previousEnd = Float.NaN;
for (TextPosition text : headerLine) {
float x = text.getXDirAdj();
if (Float.isNaN(previousEnd) || x - previousEnd > COLUMN_GAP) {
starts.add(x);
}
previousEnd = x + text.getWidthDirAdj();
}
return starts;
}
/**
* 按列边界将一行字符切分为各列文本,同一列内字符间隙较大时补空格。
*
* @param line 已按横坐标排序的行字符
* @param columnStarts 各列起始横坐标
* @return 各列文本列表
*/
private List<String> splitColumns(List<TextPosition> line, List<Float> columnStarts) {
List<StringBuilder> builders = new ArrayList<>(columnStarts.size());
List<Float> previousEnds = new ArrayList<>(columnStarts.size());
for (int i = 0; i < columnStarts.size(); i++) {
builders.add(new StringBuilder());
previousEnds.add(Float.NaN);
}
for (TextPosition text : line) {
int column = columnOf(text.getXDirAdj(), columnStarts);
if (column < 0) {
continue;
}
StringBuilder builder = builders.get(column);
float previousEnd = previousEnds.get(column);
if (!Float.isNaN(previousEnd) && text.getXDirAdj() - previousEnd > CHAR_GAP) {
builder.append(' ');
}
builder.append(text.getUnicode());
previousEnds.set(column, text.getXDirAdj() + text.getWidthDirAdj());
}
List<String> cells = new ArrayList<>(builders.size());
for (StringBuilder builder : builders) {
cells.add(builder.toString());
}
return cells;
}
/**
* 将归属同一数据行的若干文本行按纵坐标合并为完整单元格。
*
* <p>折行文本与数据行本身可能上下交错,按纵坐标升序拼接即为阅读顺序;
* 折行处 PDF 不留空格,故各片段直接相连。</p>
*
* @param group 同一数据行的文本行(含数据行本身)
* @param columnCount 列数
* @return 合并后的各列文本
*/
private List<String> mergeCells(List<RowCells> group, int columnCount) {
List<String> merged = new ArrayList<>(columnCount);
for (int column = 0; column < columnCount; column++) {
StringBuilder builder = new StringBuilder();
for (RowCells rowCells : group) {
if (column < rowCells.cells.size()) {
builder.append(rowCells.cells.get(column).trim());
}
}
merged.add(builder.toString());
}
return merged;
}
/**
* 在数据行集合中查找纵坐标与给定值最近的数据行索引。
*
* @param y 目标纵坐标
* @param parsed 页内全部文本行
* @param dataIndexes 数据行索引列表
* @return 最近数据行的索引
*/
private int nearestDataIndex(float y, List<RowCells> parsed, List<Integer> dataIndexes) {
int nearest = dataIndexes.get(0);
float minDistance = Float.MAX_VALUE;
for (int index : dataIndexes) {
float distance = Math.abs(parsed.get(index).y - y);
if (distance < minDistance) {
minDistance = distance;
nearest = index;
}
}
return nearest;
}
/**
* 判断各列是否均为空白。
*
* @param cells 各列文本
* @return 全部为空返回 true
*/
private boolean isBlankCells(List<String> cells) {
for (String cell : cells) {
if (StringUtils.hasText(cell)) {
return false;
}
}
return true;
}
/**
* 判断是否为表头的中英文说明行:既不含中文也不含数字。
*
* <p>表头的中文行由表头锚点识别,其下的英文说明行(Date、Currency、Transaction Amount 等)
* 既无中文也无数字,借此与数据行及含中文或数字的折行片段区分。</p>
*
* @param cells 各列文本
* @return 是表头说明行返回 true
*/
private boolean isHeaderText(List<String> cells) {
for (String cell : cells) {
for (int i = 0; i < cell.length(); i++) {
char c = cell.charAt(i);
if (Character.isDigit(c) || (c >= '\u4e00' && c <= '\u9fff')) {
return false;
}
}
}
return true;
}
/**
* 计算一行字符的平均纵坐标。
*
* @param line 行字符
* @return 平均纵坐标
*/
private float averageY(List<TextPosition> line) {
float sum = 0;
for (TextPosition text : line) {
sum += text.getYDirAdj();
}
return line.isEmpty() ? 0 : sum / line.size();
}
/**
* 判断字符横坐标归属的列索引。
*
* @param x 字符横坐标
* @param columnStarts 各列起始横坐标
* @return 列索引,落在所有列之前时返回 -1
*/
private int columnOf(float x, List<Float> columnStarts) {
int column = -1;
for (int i = 0; i < columnStarts.size(); i++) {
if (x >= columnStarts.get(i) - COLUMN_X_TOLERANCE) {
column = i;
} else {
break;
}
}
return column;
}
/**
* 按顺序拼接一行字符的文本。
*
* @param line 行字符
* @return 行文本
*/
private String joinText(List<TextPosition> line) {
StringBuilder builder = new StringBuilder();
for (TextPosition text : line) {
builder.append(text.getUnicode());
}
return builder.toString();
}
/**
* 将各列文本按列名组装为结构化 JSON,保留全部列。
*
* @param cells 各列文本
* @return JSON 字符串,序列化失败时返回 null
*/
private String buildRawData(List<String> cells) {
Map<String, String> map = new LinkedHashMap<>();
for (int i = 0; i < COLUMN_NAMES.length && i < cells.size(); i++) {
map.put(COLUMN_NAMES[i], cells.get(i).trim());
}
try {
return objectMapper.writeValueAsString(map);
} catch (JsonProcessingException e) {
// 序列化失败不影响正常导入,原始数据置空
return null;
}
}
/**
* 单页解析结果。
*
* @param rows 该页解析出的账单行
* @param previousBalance 该页末笔联机余额,供下一页连续性校验
*/
private record PageResult(List<ParsedBillRow> rows, BigDecimal previousBalance) {
}
/**
* 页内一行文本及其切分后的单元格,用于折行合并。
*/
private static final class RowCells {
/** 该行平均纵坐标 */
private final float y;
/** 各列文本 */
private final List<String> cells;
/** 是否为数据行(首列为合法日期) */
private final boolean dataRow;
RowCells(float y, List<String> cells, boolean dataRow) {
this.y = y;
this.cells = cells;
this.dataRow = dataRow;
}
}
/**
* 字符坐标收集器:绕过 {@link PDFTextStripper} 的默认排版逻辑,直接收集全部字符,
* 再按纵坐标聚类成行,以便按列坐标精确还原表格。
*/
private static class PositionCollector extends PDFTextStripper {
/** 按页码收集的字符,页码从 1 开始 */
private final Map<Integer, List<TextPosition>> charactersByPage = new LinkedHashMap<>();
PositionCollector() throws IOException {
super();
}
@Override
protected void processTextPosition(TextPosition text) {
charactersByPage.computeIfAbsent(getCurrentPageNo(), page -> new ArrayList<>()).add(text);
}
/**
* 取指定页的字符并按纵坐标聚合成行。
*
* @param pageNo 页码(从 1 开始)
* @return 行列表
*/
List<List<TextPosition>> linesOfPage(int pageNo) {
List<TextPosition> sorted = new ArrayList<>(charactersByPage.getOrDefault(pageNo, List.of()));
sorted.sort(Comparator.comparingDouble(TextPosition::getYDirAdj));
List<List<TextPosition>> lines = new ArrayList<>();
List<TextPosition> current = null;
float currentY = Float.NaN;
for (TextPosition text : sorted) {
float y = text.getYDirAdj();
if (current == null || Math.abs(y - currentY) > Y_TOLERANCE) {
current = new ArrayList<>();
lines.add(current);
currentY = y;
}
current.add(text);
}
return lines;
}
}
}
@@ -3,10 +3,21 @@ package cn.rosercode.finance.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import cn.rosercode.finance.entity.Transaction;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 交易记录Mapper接口,继承MyBatis-Plus的BaseMapper,提供交易记录表的基础CRUD操作
*/
@Mapper
public interface TransactionMapper extends BaseMapper<Transaction> {
/**
* 批量插入交易记录(合并为多值 INSERT),用于账单导入等大批量场景。
*
* @param list 交易记录列表
* @return 受影响行数
*/
int insertBatch(@Param("list") List<Transaction> list);
}
@@ -3,6 +3,9 @@ package cn.rosercode.finance.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import cn.rosercode.finance.entity.TransactionTag;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 交易标签关联Mapper接口,继承MyBatis-Plus的BaseMapper,提供关联表的基础CRUD操作。
@@ -11,4 +14,12 @@ import org.apache.ibatis.annotations.Mapper;
*/
@Mapper
public interface TransactionTagMapper extends BaseMapper<TransactionTag> {
/**
* 批量插入交易标签关联(合并为多值 INSERT)。
*
* @param list 交易标签关联列表
* @return 受影响行数
*/
int insertBatch(@Param("list") List<TransactionTag> list);
}
@@ -31,6 +31,7 @@ import java.io.IOException;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
@@ -93,9 +94,10 @@ public class BillImportServiceImpl implements BillImportService {
}
// 压缩包先解压出账单文件,再交给对应解析器;xlsx 等本身是 zip,需确认包内含目标文件
String extension = expectedExtension(billType);
byte[] fileContent = archiveExtractor.containsEntry(content, extension)
? archiveExtractor.extract(content, extension, password)
List<String> extensions = expectedExtensions(billType);
String matchedExtension = archiveExtractor.findEntryExtension(content, extensions.toArray(new String[0]));
byte[] fileContent = matchedExtension != null
? archiveExtractor.extract(content, matchedExtension, password)
: content;
List<ParsedBillRow> rows = parser.parse(fileContent);
@@ -128,16 +130,20 @@ public class BillImportServiceImpl implements BillImportService {
* 获取账单类型对应的账单文件扩展名,用于从压缩包中匹配文件。
*
* @param billType 账单类型
* @return 扩展名(小写,含点)
* @return 候选扩展名列表(小写,含点),按优先级排序
*/
private String expectedExtension(BillType billType) {
private List<String> expectedExtensions(BillType billType) {
if (billType == BillType.WECHAT) {
return ".xlsx";
return List.of(".xlsx");
}
if (billType == BillType.BOCOM) {
return ".xls";
return List.of(".xls");
}
return ".csv";
if (billType == BillType.CMB) {
// 招商银行支持 CSV(交易记录)与 PDF(交易流水)两种导出格式
return List.of(".csv", ".pdf");
}
return List.of(".csv");
}
/**
@@ -181,85 +187,183 @@ public class BillImportServiceImpl implements BillImportService {
.collect(Collectors.toList());
List<RuleMatchResult> ruleMatches = ruleService.matchBatch(userId, ruleContexts);
int imported = 0;
int skipped = 0;
int failed = 0;
List<String> messages = new ArrayList<>();
// 待批量插入的交易及其标签(按下标一一对应)
List<Transaction> pending = new ArrayList<>();
List<Set<Long>> pendingTagIds = new ArrayList<>();
for (int i = 0; i < items.size(); i++) {
BillImportRequest.Item item = items.get(i);
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;
// 未选择默认分类时置空,表示未分类;规则建议分类优先于默认分类
Long categoryId = category != null ? category.getId() : null;
if (ruleMatches.get(i).getCategoryId() != null) {
categoryId = ruleMatches.get(i).getCategoryId();
}
Transaction transaction = new Transaction();
transaction.setUserId(userId);
transaction.setCategoryId(categoryId);
transaction.setType(item.getType());
transaction.setAmount(item.getAmount());
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);
// 关联标签:规则建议标签与默认标签合并去重
Set<Long> itemTagIds = new LinkedHashSet<>(tagIds);
itemTagIds.addAll(ruleMatches.get(i).getTagIds());
for (Long tagId : itemTagIds) {
TransactionTag relation = new TransactionTag();
relation.setTransactionId(transaction.getId());
relation.setTagId(tagId);
transactionTagMapper.insert(relation);
}
if (StringUtils.hasText(item.getExternalNo())) {
seen.add(item.getExternalNo());
}
imported++;
} catch (RuntimeException e) {
// 基础字段校验
if (!"INCOME".equals(item.getType()) && !"EXPENSE".equals(item.getType())) {
failed++;
messages.add(label + ":导入失败," + e.getMessage());
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;
// 未选择默认分类时置空,表示未分类;规则建议分类优先于默认分类
Long categoryId = category != null ? category.getId() : null;
if (ruleMatches.get(i).getCategoryId() != null) {
categoryId = ruleMatches.get(i).getCategoryId();
}
Transaction transaction = new Transaction();
transaction.setUserId(userId);
transaction.setCategoryId(categoryId);
transaction.setType(item.getType());
transaction.setAmount(item.getAmount());
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());
pending.add(transaction);
// 关联标签:规则建议标签与默认标签合并去重
Set<Long> itemTagIds = new LinkedHashSet<>(tagIds);
itemTagIds.addAll(ruleMatches.get(i).getTagIds());
pendingTagIds.add(itemTagIds);
if (StringUtils.hasText(item.getExternalNo())) {
seen.add(item.getExternalNo());
}
}
// 批量插入交易记录,失败时回退逐条以定位问题行
List<Transaction> inserted = insertTransactions(pending, messages);
failed += pending.size() - inserted.size();
// 批量插入标签关联(批量插入不回填主键,按交易号回查ID)
insertTransactionTags(userId, pending, pendingTagIds, inserted, messages);
BillImportResultVO result = new BillImportResultVO();
result.setTotal(items.size());
result.setImported(imported);
result.setImported(inserted.size());
result.setSkipped(skipped);
result.setFailed(failed);
result.setMessages(messages);
return result;
}
/** 批量插入的分批大小 */
private static final int BATCH_SIZE = 500;
/**
* 分批批量插入交易记录;某一批失败时回退逐条插入以定位并跳过问题行。
*
* @param pending 待插入的交易列表
* @param messages 失败信息收集列表
* @return 成功插入的交易列表
*/
private List<Transaction> insertTransactions(List<Transaction> pending, List<String> messages) {
List<Transaction> inserted = new ArrayList<>();
for (int i = 0; i < pending.size(); i += BATCH_SIZE) {
List<Transaction> batch = pending.subList(i, Math.min(i + BATCH_SIZE, pending.size()));
try {
transactionMapper.insertBatch(batch);
inserted.addAll(batch);
} catch (RuntimeException e) {
// 批量失败时逐条重试,跳过并记录问题行
for (Transaction transaction : batch) {
try {
transactionMapper.insert(transaction);
inserted.add(transaction);
} catch (RuntimeException ex) {
messages.add(transaction.getExternalNo() + ":导入失败," + ex.getMessage());
}
}
}
}
return inserted;
}
/**
* 批量插入交易与标签的关联关系。
*
* <p>批量插入不会回填自增主键,故对未回填主键的交易按交易号批量回查ID。</p>
*
* @param userId 用户ID
* @param pending 全部待插入交易(含失败行,用于与标签列表对齐)
* @param pendingTagIds 各交易对应的标签ID集合,下标与 pending 对应
* @param inserted 成功插入的交易列表
* @param messages 失败信息收集列表
*/
private void insertTransactionTags(Long userId, List<Transaction> pending, List<Set<Long>> pendingTagIds,
List<Transaction> inserted, List<String> messages) {
// 未回填主键的交易按交易号批量回查ID
List<String> missingExternalNos = inserted.stream()
.filter(transaction -> transaction.getId() == null)
.map(Transaction::getExternalNo)
.filter(StringUtils::hasText)
.distinct()
.collect(Collectors.toList());
Map<String, Long> idByExternalNo = new HashMap<>();
for (int i = 0; i < missingExternalNos.size(); i += BATCH_SIZE) {
List<String> batch = missingExternalNos.subList(i, Math.min(i + BATCH_SIZE, missingExternalNos.size()));
List<Transaction> list = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.select(Transaction::getId, Transaction::getExternalNo)
.eq(Transaction::getUserId, userId)
.in(Transaction::getExternalNo, batch));
for (Transaction transaction : list) {
idByExternalNo.put(transaction.getExternalNo(), transaction.getId());
}
}
List<TransactionTag> relations = new ArrayList<>();
for (int i = 0; i < pending.size(); i++) {
Transaction transaction = pending.get(i);
Long transactionId = transaction.getId() != null
? transaction.getId()
: idByExternalNo.get(transaction.getExternalNo());
// 插入失败或无交易号导致无法定位的记录,跳过标签关联
if (transactionId == null) {
continue;
}
for (Long tagId : pendingTagIds.get(i)) {
TransactionTag relation = new TransactionTag();
relation.setTransactionId(transactionId);
relation.setTagId(tagId);
relations.add(relation);
}
}
for (int i = 0; i < relations.size(); i += BATCH_SIZE) {
List<TransactionTag> batch = relations.subList(i, Math.min(i + BATCH_SIZE, relations.size()));
try {
transactionTagMapper.insertBatch(batch);
} catch (RuntimeException e) {
for (TransactionTag relation : batch) {
try {
transactionTagMapper.insert(relation);
} catch (RuntimeException ignored) {
// 关联失败不影响交易导入结果
}
}
}
}
}
/**
* 查询指定交易号中已存在的记录
*
+2 -1
View File
@@ -16,7 +16,8 @@ mybatis-plus:
id-type: auto
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# 交由 SLF4J/logback 控制 SQL 日志输出,避免 StdOutImpl 直接同步打印导致大批量导入变慢
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
jwt:
secret: finance-app-secret-key-2026-a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.rosercode.finance.mapper.TransactionMapper">
<!--
批量插入交易记录:将逐条 INSERT 合并为多值 INSERT,减少数据库往返。
注意 remark_hidden / include_in_stats 为 NOT NULL 且带默认值,实体未设置时以默认值兜底。
-->
<insert id="insertBatch">
INSERT INTO t_transaction
(user_id, category_id, type, amount, payment_method, remark, remark_hidden,
external_no, transaction_time, include_in_stats, raw_data)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.userId}, #{item.categoryId}, #{item.type}, #{item.amount}, #{item.paymentMethod},
#{item.remark}, IFNULL(#{item.remarkHidden}, 0), #{item.externalNo},
#{item.transactionTime}, IFNULL(#{item.includeInStats}, 1), #{item.rawData})
</foreach>
</insert>
</mapper>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.rosercode.finance.mapper.TransactionTagMapper">
<!-- 批量插入交易标签关联:将逐条 INSERT 合并为多值 INSERT -->
<insert id="insertBatch">
INSERT INTO t_transaction_tag (transaction_id, tag_id)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.transactionId}, #{item.tagId})
</foreach>
</insert>
</mapper>