diff --git a/build.gradle b/build.gradle
index 1f67785..2f609d1 100644
--- a/build.gradle
+++ b/build.gradle
@@ -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'
diff --git a/frontend/src/api/transaction.ts b/frontend/src/api/transaction.ts
index 4f4b84e..00c8191 100644
--- a/frontend/src/api/transaction.ts
+++ b/frontend/src/api/transaction.ts
@@ -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 })
}
/** 批量设置交易记录的分类(按类型分别应用支出/收入分类) */
diff --git a/frontend/src/locales/messages.ts b/frontend/src/locales/messages.ts
index 045897f..ad23253 100644
--- a/frontend/src/locales/messages.ts
+++ b/frontend/src/locales/messages.ts
@@ -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: '先に明細ファイルを選択してください' },
diff --git a/frontend/src/views/BillImportPanel.vue b/frontend/src/views/BillImportPanel.vue
index 694f32f..5865eba 100644
--- a/frontend/src/views/BillImportPanel.vue
+++ b/frontend/src/views/BillImportPanel.vue
@@ -66,7 +66,7 @@ const allowedExtensions: Record = {
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>(() => [
)
},
},
+ {
+ 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') }}
+
+ {{ t('import.setIncluded') }}
+
+
+ {{ t('import.setExcluded') }}
+
{{ t('import.previewSummary', { total: rows.length, filtered: filteredRows.length, checked: checkedRows.length }) }}
diff --git a/frontend/src/views/TransactionList.vue b/frontend/src/views/TransactionList.vue
index da9ae7b..0aef4ec 100644
--- a/frontend/src/views/TransactionList.vue
+++ b/frontend/src/views/TransactionList.vue
@@ -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') }}
{{ t('transaction.importBill') }}
- {{ t('transaction.exportExcel') }}
+ {{ t('transaction.exportExcel') }}
@@ -1802,6 +1831,9 @@ onMounted(() => {
{{ t('transaction.pageExpense') }} {{ pageSummary.expense.toFixed(2) }}
· {{ t('transaction.pageIncome') }} {{ pageSummary.income.toFixed(2) }}
· {{ t('transaction.pageBalance') }} {{ pageSummary.balance.toFixed(2) }}
+
+ {{ t('transaction.pageExcluded', { expense: pageExcludedSummary.expense.toFixed(2), income: pageExcludedSummary.income.toFixed(2) }) }}
+
diff --git a/src/main/java/cn/rosercode/finance/controller/TransactionController.java b/src/main/java/cn/rosercode/finance/controller/TransactionController.java
index ff015b7..1b355cc 100644
--- a/src/main/java/cn/rosercode/finance/controller/TransactionController.java
+++ b/src/main/java/cn/rosercode/finance/controller/TransactionController.java
@@ -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 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));
+ }
}
}
diff --git a/src/main/java/cn/rosercode/finance/exporter/TransactionExportSheetHandler.java b/src/main/java/cn/rosercode/finance/exporter/TransactionExportSheetHandler.java
new file mode 100644
index 0000000..fadd442
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/exporter/TransactionExportSheetHandler.java
@@ -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 的样式与冻结窗格处理器。
+ *
+ * 负责冻结首行表头,并提供表头与内容的单元格样式,使导出的 Excel 更易读。
+ *
+ * @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);
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/importer/BillArchiveExtractor.java b/src/main/java/cn/rosercode/finance/importer/BillArchiveExtractor.java
index bb0fff9..4f1477d 100644
--- a/src/main/java/cn/rosercode/finance/importer/BillArchiveExtractor.java
+++ b/src/main/java/cn/rosercode/finance/importer/BillArchiveExtractor.java
@@ -53,35 +53,38 @@ public class BillArchiveExtractor {
}
/**
- * 判断压缩包内是否存在指定扩展名的文件。
+ * 在压缩包内查找匹配任一扩展名的文件,返回命中的扩展名。
*
* xlsx 等 Office 文件本身也是 zip 结构,需借此区分「账单压缩包」与「原始账单文件」:
- * 仅当压缩包内确实包含目标扩展名的账单文件时才按压缩包处理。
+ * 仅当压缩包内确实包含目标扩展名的账单文件时才按压缩包处理。同一账单类型可能支持
+ * 多种导出格式(如招商银行的 CSV 与 PDF),故按候选扩展名顺序依次匹配。
*
- * @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 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;
}
/**
diff --git a/src/main/java/cn/rosercode/finance/importer/CmbBillParser.java b/src/main/java/cn/rosercode/finance/importer/CmbBillParser.java
index d33e150..fcbd892 100644
--- a/src/main/java/cn/rosercode/finance/importer/CmbBillParser.java
+++ b/src/main/java/cn/rosercode/finance/importer/CmbBillParser.java
@@ -32,7 +32,11 @@ import java.util.Map;
*
* 解析招商银行「交易流水」导出的 CSV 文件。文件为 UTF-8(含 BOM,兼容 GBK),
* 前几行以「#」开头的账号、日期范围等元信息,之后为表头与数据行,末尾为收支合计。
- * 交易日期与交易时间分列,金额分为收入、支出两列,且无交易单号,需按行生成去重标识。
+ * 交易日期与交易时间分列,金额分为收入、支出两列,且无交易单号,需按行生成去重标识:
+ * 由日期、收支类型、金额与余额组合,与 PDF 解析器({@link CmbPdfParser})保持一致,
+ * 使同一笔交易无论从 CSV 还是 PDF 导入都能相互识别为重复。
+ *
+ * 招商银行同时提供「交易流水」PDF 导出,按文件头魔数识别后交由 {@link CmbPdfParser} 处理。
*
* @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 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 金额
+ * 格式与招商银行 PDF 解析器({@link CmbPdfParser})完全一致,以便同一笔交易
+ * 无论从 CSV 还是 PDF 导入都能相互识别为重复。余额用于区分同一天、同类型、同金额的
+ * 不同交易。
+ *
+ * @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;
}
/**
diff --git a/src/main/java/cn/rosercode/finance/importer/CmbPdfParser.java b/src/main/java/cn/rosercode/finance/importer/CmbPdfParser.java
new file mode 100644
index 0000000..8b0562e
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/importer/CmbPdfParser.java
@@ -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 解析器。
+ *
+ * 招商银行导出的「交易流水」PDF(由 openhtmltopdf 生成)为文本型文件,表格列为
+ * 「记账日期 / 货币 / 交易金额 / 联机余额 / 交易摘要 / 对手信息」。解析时不依赖空白对齐
+ * (列内容变长会漂移),而是利用 PDFBox 抽取的每个字符坐标:按纵坐标聚成行、依据表头
+ * 字符横坐标切列,再逐列解析。
+ *
+ * 流水可能多页,且摘要或对手信息较长时会在单元格内折行。解析按页进行(避免不同页的
+ * 纵坐标互相干扰),页内以数据行(首列为日期)为锚,将相邻的折行文本行按纵坐标归属到
+ * 最近的数据行后逐列合并,从而还原被折行的单元格内容。
+ *
+ * 金额为带符号的单列,负号为支出、正号为收入。由于该流水无交易单号与交易时间,
+ * 去重标识由日期、收支类型、金额与联机余额组合生成。解析后按「联机余额」做连续性校验
+ * (本笔余额应等于上笔余额加减本笔金额),校验不通过的行标记为不可导入,交由人工确认。
+ *
+ * @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。
+ *
+ * 逐页抽取字符坐标并按行聚类,页内定位表头推导列边界、合并折行后逐行解析。
+ * 每笔交易按联机余额做连续性校验(跨页保持),校验失败的行标记为不可导入。
+ *
+ * @param content PDF 文件字节内容
+ * @return 解析后的账单行列表
+ * @throws IOException 读取 PDF 失败
+ */
+ public List parse(byte[] content) throws IOException {
+ List 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> lines, int startRowNo, BigDecimal previousBalance) {
+ List rows = new ArrayList<>();
+ List columnStarts = null;
+ List parsed = new ArrayList<>();
+
+ for (List line : lines) {
+ List 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 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 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> 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 group = grouped.get(index);
+ group.sort(Comparator.comparingDouble(cell -> cell.y));
+ List 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 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;
+ }
+
+ /**
+ * 生成去重标识:流水无交易单号,用日期、收支类型、金额与联机余额组合。
+ *
+ * 格式与招商银行 CSV 解析器({@link CmbBillParser})完全一致,以便同一笔交易
+ * 无论从 CSV 还是 PDF 导入都能相互识别为重复。
+ *
+ * @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 detectColumnStarts(List headerLine) {
+ List 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 splitColumns(List line, List columnStarts) {
+ List builders = new ArrayList<>(columnStarts.size());
+ List 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 cells = new ArrayList<>(builders.size());
+ for (StringBuilder builder : builders) {
+ cells.add(builder.toString());
+ }
+ return cells;
+ }
+
+ /**
+ * 将归属同一数据行的若干文本行按纵坐标合并为完整单元格。
+ *
+ * 折行文本与数据行本身可能上下交错,按纵坐标升序拼接即为阅读顺序;
+ * 折行处 PDF 不留空格,故各片段直接相连。
+ *
+ * @param group 同一数据行的文本行(含数据行本身)
+ * @param columnCount 列数
+ * @return 合并后的各列文本
+ */
+ private List mergeCells(List group, int columnCount) {
+ List 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 parsed, List 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 cells) {
+ for (String cell : cells) {
+ if (StringUtils.hasText(cell)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * 判断是否为表头的中英文说明行:既不含中文也不含数字。
+ *
+ * 表头的中文行由表头锚点识别,其下的英文说明行(Date、Currency、Transaction Amount 等)
+ * 既无中文也无数字,借此与数据行及含中文或数字的折行片段区分。
+ *
+ * @param cells 各列文本
+ * @return 是表头说明行返回 true
+ */
+ private boolean isHeaderText(List 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 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 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 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 cells) {
+ Map 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 rows, BigDecimal previousBalance) {
+ }
+
+ /**
+ * 页内一行文本及其切分后的单元格,用于折行合并。
+ */
+ private static final class RowCells {
+
+ /** 该行平均纵坐标 */
+ private final float y;
+
+ /** 各列文本 */
+ private final List cells;
+
+ /** 是否为数据行(首列为合法日期) */
+ private final boolean dataRow;
+
+ RowCells(float y, List cells, boolean dataRow) {
+ this.y = y;
+ this.cells = cells;
+ this.dataRow = dataRow;
+ }
+ }
+
+ /**
+ * 字符坐标收集器:绕过 {@link PDFTextStripper} 的默认排版逻辑,直接收集全部字符,
+ * 再按纵坐标聚类成行,以便按列坐标精确还原表格。
+ */
+ private static class PositionCollector extends PDFTextStripper {
+
+ /** 按页码收集的字符,页码从 1 开始 */
+ private final Map> 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> linesOfPage(int pageNo) {
+ List sorted = new ArrayList<>(charactersByPage.getOrDefault(pageNo, List.of()));
+ sorted.sort(Comparator.comparingDouble(TextPosition::getYDirAdj));
+ List> lines = new ArrayList<>();
+ List 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;
+ }
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/mapper/TransactionMapper.java b/src/main/java/cn/rosercode/finance/mapper/TransactionMapper.java
index d016287..ed38e67 100644
--- a/src/main/java/cn/rosercode/finance/mapper/TransactionMapper.java
+++ b/src/main/java/cn/rosercode/finance/mapper/TransactionMapper.java
@@ -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 {
+
+ /**
+ * 批量插入交易记录(合并为多值 INSERT),用于账单导入等大批量场景。
+ *
+ * @param list 交易记录列表
+ * @return 受影响行数
+ */
+ int insertBatch(@Param("list") List list);
}
diff --git a/src/main/java/cn/rosercode/finance/mapper/TransactionTagMapper.java b/src/main/java/cn/rosercode/finance/mapper/TransactionTagMapper.java
index 8d80b27..3e29f91 100644
--- a/src/main/java/cn/rosercode/finance/mapper/TransactionTagMapper.java
+++ b/src/main/java/cn/rosercode/finance/mapper/TransactionTagMapper.java
@@ -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 {
+
+ /**
+ * 批量插入交易标签关联(合并为多值 INSERT)。
+ *
+ * @param list 交易标签关联列表
+ * @return 受影响行数
+ */
+ int insertBatch(@Param("list") List list);
}
diff --git a/src/main/java/cn/rosercode/finance/service/impl/BillImportServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/BillImportServiceImpl.java
index 199b246..21e4e77 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/BillImportServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/BillImportServiceImpl.java
@@ -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 extensions = expectedExtensions(billType);
+ String matchedExtension = archiveExtractor.findEntryExtension(content, extensions.toArray(new String[0]));
+ byte[] fileContent = matchedExtension != null
+ ? archiveExtractor.extract(content, matchedExtension, password)
: content;
List rows = parser.parse(fileContent);
@@ -128,16 +130,20 @@ public class BillImportServiceImpl implements BillImportService {
* 获取账单类型对应的账单文件扩展名,用于从压缩包中匹配文件。
*
* @param billType 账单类型
- * @return 扩展名(小写,含点)
+ * @return 候选扩展名列表(小写,含点),按优先级排序
*/
- private String expectedExtension(BillType billType) {
+ private List 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 ruleMatches = ruleService.matchBatch(userId, ruleContexts);
- int imported = 0;
int skipped = 0;
int failed = 0;
List messages = new ArrayList<>();
+ // 待批量插入的交易及其标签(按下标一一对应)
+ List pending = new ArrayList<>();
+ List> 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 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 itemTagIds = new LinkedHashSet<>(tagIds);
+ itemTagIds.addAll(ruleMatches.get(i).getTagIds());
+ pendingTagIds.add(itemTagIds);
+
+ if (StringUtils.hasText(item.getExternalNo())) {
+ seen.add(item.getExternalNo());
}
}
+ // 批量插入交易记录,失败时回退逐条以定位问题行
+ List 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 insertTransactions(List pending, List messages) {
+ List inserted = new ArrayList<>();
+ for (int i = 0; i < pending.size(); i += BATCH_SIZE) {
+ List 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;
+ }
+
+ /**
+ * 批量插入交易与标签的关联关系。
+ *
+ * 批量插入不会回填自增主键,故对未回填主键的交易按交易号批量回查ID。
+ *
+ * @param userId 用户ID
+ * @param pending 全部待插入交易(含失败行,用于与标签列表对齐)
+ * @param pendingTagIds 各交易对应的标签ID集合,下标与 pending 对应
+ * @param inserted 成功插入的交易列表
+ * @param messages 失败信息收集列表
+ */
+ private void insertTransactionTags(Long userId, List pending, List> pendingTagIds,
+ List inserted, List messages) {
+ // 未回填主键的交易按交易号批量回查ID
+ List missingExternalNos = inserted.stream()
+ .filter(transaction -> transaction.getId() == null)
+ .map(Transaction::getExternalNo)
+ .filter(StringUtils::hasText)
+ .distinct()
+ .collect(Collectors.toList());
+ Map idByExternalNo = new HashMap<>();
+ for (int i = 0; i < missingExternalNos.size(); i += BATCH_SIZE) {
+ List batch = missingExternalNos.subList(i, Math.min(i + BATCH_SIZE, missingExternalNos.size()));
+ List list = transactionMapper.selectList(new LambdaQueryWrapper()
+ .select(Transaction::getId, Transaction::getExternalNo)
+ .eq(Transaction::getUserId, userId)
+ .in(Transaction::getExternalNo, batch));
+ for (Transaction transaction : list) {
+ idByExternalNo.put(transaction.getExternalNo(), transaction.getId());
+ }
+ }
+
+ List 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 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) {
+ // 关联失败不影响交易导入结果
+ }
+ }
+ }
+ }
+ }
+
/**
* 查询指定交易号中已存在的记录
*
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index b0ce996..af3c68b 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -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
diff --git a/src/main/resources/mapper/TransactionMapper.xml b/src/main/resources/mapper/TransactionMapper.xml
new file mode 100644
index 0000000..886cc22
--- /dev/null
+++ b/src/main/resources/mapper/TransactionMapper.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+ 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
+
+ (#{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})
+
+
+
+
diff --git a/src/main/resources/mapper/TransactionTagMapper.xml b/src/main/resources/mapper/TransactionTagMapper.xml
new file mode 100644
index 0000000..257da41
--- /dev/null
+++ b/src/main/resources/mapper/TransactionTagMapper.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ INSERT INTO t_transaction_tag (transaction_id, tag_id)
+ VALUES
+
+ (#{item.transactionId}, #{item.tagId})
+
+
+
+