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
+2
View File
@@ -41,6 +41,8 @@ dependencies {
implementation 'org.apache.commons:commons-csv:1.12.0'
// zip 压缩包解析(支持带密码的 ZipCrypto / AES 加密压缩包)
implementation 'net.lingala.zip4j:zip4j:2.11.5'
// PDF 账单解析(招商银行「交易流水」PDF 为文本型,按字符坐标还原表格)
implementation 'org.apache.pdfbox:pdfbox:3.0.3'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
+4 -2
View File
@@ -124,7 +124,8 @@ export function parseBill(file: File, billType: BillType, password?: string) {
if (password) {
formData.append('password', password)
}
return request.post('/transaction/import/parse', formData)
// 大账单解析耗时较长,单独放宽超时
return request.post('/transaction/import/parse', formData, { timeout: 120000 })
}
/** 确认导入用户勾选的账单行 */
@@ -135,7 +136,8 @@ export function confirmImport(data: {
tagIds?: number[]
items: BillImportItem[]
}) {
return request.post('/transaction/import/confirm', data)
// 大批量导入耗时较长,单独放宽超时
return request.post('/transaction/import/confirm', data, { timeout: 120000 })
}
/** 批量设置交易记录的分类(按类型分别应用支出/收入分类) */
+7 -1
View File
@@ -332,6 +332,7 @@ export default {
pageExpense: { zh: '本页支出', en: 'Page Expense', ja: 'このページの支出' },
pageIncome: { zh: '本页收入', en: 'Page Income', ja: 'このページの収入' },
pageBalance: { zh: '本页结余', en: 'Page Balance', ja: 'このページの収支' },
pageExcluded: { zh: '(不计入统计:支出 {expense} · 收入 {income})', en: '(Excluded: expense {expense} · income {income})', ja: '(集計対象外:支出 {expense} · 収入 {income})' },
column: {
index: { zh: '序号', en: 'No.', ja: 'No.' },
transactionTime: { zh: '交易时间', en: 'Time', ja: '取引日時' },
@@ -396,6 +397,8 @@ export default {
batchDeleteNone: { zh: '请先选择要删除的记录', en: 'Please select records to delete', ja: '削除する記録を選択してください' },
batchDeleteConfirm: { zh: '确定删除选中的 {count} 条记录吗?', en: 'Delete the {count} selected record(s)?', ja: '選択した {count} 件を削除しますか?' },
batchDeleted: { zh: '已删除 {count} 条记录', en: 'Deleted {count} record(s)', ja: '{count} 件を削除しました' },
exportConfirmTitle: { zh: '导出确认', en: 'Export Confirmation', ja: 'エクスポート確認' },
exportConfirm: { zh: '确定要按当前筛选条件导出账单吗?', en: 'Export bills with the current filters?', ja: '現在の絞り込み条件で明細をエクスポートしますか?' },
exportSuccess: { zh: '导出成功', en: 'Exported successfully', ja: 'エクスポートしました' },
exportFailed: { zh: '导出失败', en: 'Export failed', ja: 'エクスポートに失敗しました' },
exportFileName: { zh: '交易记录', en: 'transactions', ja: '取引記録' },
@@ -485,7 +488,7 @@ export default {
exportHintALIPAY: { zh: '导出路径:支付宝 App → 我的 → 账单 → 开具交易流水证明 / 账单导出,选择 CSV 格式。', en: 'Export path: Alipay App → Me → Bills → Issue Transaction Record / Export Bill, choose CSV format.', ja: 'エクスポート手順:Alipay アプリ → マイ → 請求 → 取引履歴証明の発行 / 明細のエクスポート で CSV 形式を選択。' },
exportHintWECHAT: { zh: '导出路径:微信 App → 我 → 服务 → 钱包 → 账单 → 常见问题 → 下载账单 → 用于个人对账,选择 xlsx 格式。', en: 'Export path: WeChat App → Me → Services → Wallet → Bills → FAQ → Download Bills → For personal reconciliation, choose xlsx format.', ja: 'エクスポート手順:WeChat アプリ → 自分 → サービス → ウォレット → 請求 → よくある質問 → 請求書のダウンロード → 個人の照合用 で xlsx 形式を選択。' },
exportHintMEITUAN: { zh: '导出路径:美团 App → 我的 → 钱包 → 账单 → 开具账单证明(申请记录),选择 CSV 格式并设置解压密码。', en: 'Export path: Meituan App → Me → Wallet → Bills → Issue Bill Certificate (Application Record), choose CSV format and set an archive password.', ja: 'エクスポート手順:Meituan アプリ → マイ → ウォレット → 請求 → 請求証明の発行(申請履歴)で CSV 形式を選択し、解凍パスワードを設定。' },
exportHintCMB: { zh: '导出路径:招商银行 App → 我的 → 全部 → 交易流水,选择 CSV 格式导出(如需压缩包请设置解压密码)。', en: 'Export path: China Merchants Bank App → Me → All → Transaction Records, choose CSV format (set an archive password if zipped).', ja: 'エクスポート手順:招商銀行アプリ → マイ → すべて → 取引明細 で CSV 形式を選択(圧縮する場合は解凍パスワードを設定)。' },
exportHintCMB: { zh: '导出路径:招商银行 App → 我的 → 全部 → 交易流水,支持 CSV 或 PDF 格式导出(如需压缩包请设置解压密码)。', en: 'Export path: China Merchants Bank App → Me → All → Transaction Records; CSV or PDF is supported (set an archive password if zipped).', ja: 'エクスポート手順:招商銀行アプリ → マイ → すべて → 取引明細。CSV または PDF 形式に対応(圧縮する場合は解凍パスワードを設定)。' },
exportHintBOCOM: { zh: '导出路径:交通银行 App → 我的 → 银行卡 → 交易明细,选择查询时间段后导出 Excel(xls)。', en: 'Export path: Bank of Communications App → Me → Bank Cards → Transaction Details, choose a date range and export Excel (xls).', ja: 'エクスポート手順:交通銀行アプリ → マイ → 銀行カード → 取引明細 で期間を選択し Excel(xls)でエクスポート。' },
parse: { zh: '解析', en: 'Parse', ja: '解析' },
expenseDefaultCategory: { zh: '支出默认分类(可不选):', en: 'Default expense category (optional):', ja: '支出の既定カテゴリ(任意):' },
@@ -494,6 +497,8 @@ export default {
notSetCategory: { zh: '未选则不计分类', en: 'No category if unselected', ja: '未選択の場合は分類なし' },
notSetTag: { zh: '未选则不打标签', en: 'No tags if unselected', ja: '未選択の場合はタグなし' },
selectAll: { zh: '全选', en: 'Select All', ja: 'すべて選択' },
setIncluded: { zh: '设为计入', en: 'Set Included', ja: '集計に含める' },
setExcluded: { zh: '设为不计入', en: 'Set Excluded', ja: '集計から除外' },
previewSummary: { zh: '共 {total} 条,筛选后 {filtered} 条,已勾选 {checked} 条', en: 'Total {total}, {filtered} after filtering, {checked} selected', ja: '全 {total} 件、絞り込み後 {filtered} 件、選択済み {checked} 件' },
searchPlaceholder: { zh: '搜索交易对方/商品/单号', en: 'Search counterparty / product / order no.', ja: '取引相手/商品/注文番号を検索' },
typePlaceholder: { zh: '类型', en: 'Type', ja: '種類' },
@@ -522,6 +527,7 @@ export default {
product: { zh: '商品名称', en: 'Product', ja: '商品名' },
status: { zh: '状态', en: 'Status', ja: 'ステータス' },
matchedRule: { zh: '匹配规则/建议', en: 'Matched Rule', ja: '一致ルール' },
includeInStats: { zh: '计入统计', en: 'Include in Stats', ja: '集計に含める' },
},
errorFileType: { zh: '账单类型「{label}」仅支持 {exts} 格式的文件', en: 'Bill type "{label}" only supports files in {exts} format', ja: '明細の種類「{label}」は {exts} 形式のファイルのみ対応しています' },
selectFileFirst: { zh: '请先选择账单文件', en: 'Please select a bill file first', ja: '先に明細ファイルを選択してください' },
+35 -1
View File
@@ -66,7 +66,7 @@ const allowedExtensions: Record<BillType, string[]> = {
ALIPAY: ['.csv', '.zip'],
WECHAT: ['.xlsx', '.zip'],
MEITUAN: ['.csv', '.zip'],
CMB: ['.csv', '.zip'],
CMB: ['.csv', '.pdf', '.zip'],
BOCOM: ['.xls', '.zip'],
}
@@ -221,6 +221,18 @@ const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
)
},
},
{
title: t('import.column.includeInStats'),
key: 'includeInStats',
width: 100,
align: 'center',
render: (row) =>
h(NCheckbox, {
checked: !!row.includeInStats,
disabled: !row.importable,
onUpdateChecked: (v: boolean) => toggleIncludeInStats(row, v),
}),
},
{
title: t('import.column.amount'),
key: 'amount',
@@ -389,6 +401,22 @@ function toggleAll(checked: boolean) {
checkedRowNos.value = checked ? selectableRows.value.map((r) => r.rowNo) : []
}
/** 切换单行是否计入统计 */
function toggleIncludeInStats(row: ParsedBillRow, value: boolean) {
row.includeInStats = value
}
/** 批量设置已勾选行的计入统计标记 */
function setCheckedIncludeInStats(value: boolean) {
if (checkedRows.value.length === 0) {
message.warning(t('import.selectAtLeastOne'))
return
}
checkedRows.value.forEach((row) => {
row.includeInStats = value
})
}
/** 确认导入 */
async function handleConfirm() {
if (checkedRows.value.length === 0) {
@@ -544,6 +572,12 @@ onMounted(() => {
>
{{ t('import.selectAll') }}
</NCheckbox>
<NButton size="small" :disabled="checkedRows.length === 0" @click="setCheckedIncludeInStats(true)">
{{ t('import.setIncluded') }}
</NButton>
<NButton size="small" :disabled="checkedRows.length === 0" @click="setCheckedIncludeInStats(false)">
{{ t('import.setExcluded') }}
</NButton>
<NText depth="3">{{ t('import.previewSummary', { total: rows.length, filtered: filteredRows.length, checked: checkedRows.length }) }}</NText>
</NSpace>
+35 -3
View File
@@ -76,6 +76,18 @@ const pageSummary = computed(() => {
}
return { income, expense, balance: income - expense }
})
// 当前页不计入统计的金额汇总(仅统计 includeInStats=false 的记录)
const pageExcludedSummary = computed(() => {
let income = 0
let expense = 0
for (const t of transactions.value) {
if (t.includeInStats) continue
const amount = Number(t.amount) || 0
if (t.type === 'INCOME') income += amount
else expense += amount
}
return { income, expense }
})
const loading = ref(false) // 列表加载状态
const showModal = ref(false) // 新增/编辑弹窗显示状态
const showImportModal = ref(false) // 账单导入弹窗显示状态
@@ -1555,8 +1567,23 @@ async function handleBatchSubmit() {
}
}
/** 导出交易记录为 Excel 文件 */
async function handleExport() {
/** 导出中状态,防止重复点击 */
const exporting = ref(false)
/** 导出交易记录:弹出二次确认,确认后执行导出 */
function handleExport() {
dialog.warning({
title: t('transaction.exportConfirmTitle'),
content: t('transaction.exportConfirm'),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
onPositiveClick: () => doExport(),
})
}
/** 实际执行导出交易记录为 Excel 文件 */
async function doExport() {
exporting.value = true
try {
const res = await exportTransaction({
types: filterType.value.length > 0 ? filterType.value : undefined,
@@ -1584,6 +1611,8 @@ async function handleExport() {
message.success(t('transaction.exportSuccess'))
} catch (e: any) {
message.error(e.message || t('transaction.exportFailed'))
} finally {
exporting.value = false
}
}
@@ -1614,7 +1643,7 @@ onMounted(() => {
{{ t('transaction.batchDelete') }}
</NButton>
<NButton @click="showImportModal = true">{{ t('transaction.importBill') }}</NButton>
<NButton @click="handleExport">{{ t('transaction.exportExcel') }}</NButton>
<NButton :loading="exporting" @click="handleExport">{{ t('transaction.exportExcel') }}</NButton>
<NButton v-if="aiEnabled" @click="handleAiRecordOpen">
<template #icon>
<NIcon :component="SparklesOutline" />
@@ -1802,6 +1831,9 @@ onMounted(() => {
{{ t('transaction.pageExpense') }} <span style="color: #d03050;">{{ pageSummary.expense.toFixed(2) }}</span>
· {{ t('transaction.pageIncome') }} <span style="color: #18a058;">{{ pageSummary.income.toFixed(2) }}</span>
· {{ t('transaction.pageBalance') }} <span :style="{ color: pageSummary.balance < 0 ? '#d03050' : '#18a058' }">{{ pageSummary.balance.toFixed(2) }}</span>
<span style="margin-left: 8px;">
{{ t('transaction.pageExcluded', { expense: pageExcludedSummary.expense.toFixed(2), income: pageExcludedSummary.income.toFixed(2) }) }}
</span>
</span>
</NSpace>
@@ -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>