feat: 招商银行 PDF 账单导入、跨格式去重与导入性能优化
- 新增招商银行「交易流水」PDF 解析(PDFBox 坐标还原,支持多页与折行),CSV/PDF 去重标识统一,支持跨格式去重 - 账单导入改为批量插入,MyBatis 日志改 Slf4j,导入接口超时放宽至 120s - 交易导出 Excel 增加样式、冻结表头与自动筛选,导出前二次确认 - 交易列表展示不计入统计金额,导入预览支持设置「计入统计」
This commit is contained in:
@@ -41,6 +41,8 @@ dependencies {
|
|||||||
implementation 'org.apache.commons:commons-csv:1.12.0'
|
implementation 'org.apache.commons:commons-csv:1.12.0'
|
||||||
// zip 压缩包解析(支持带密码的 ZipCrypto / AES 加密压缩包)
|
// zip 压缩包解析(支持带密码的 ZipCrypto / AES 加密压缩包)
|
||||||
implementation 'net.lingala.zip4j:zip4j:2.11.5'
|
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'
|
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
|
||||||
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
|
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
|
||||||
|
|||||||
@@ -124,7 +124,8 @@ export function parseBill(file: File, billType: BillType, password?: string) {
|
|||||||
if (password) {
|
if (password) {
|
||||||
formData.append('password', 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[]
|
tagIds?: number[]
|
||||||
items: BillImportItem[]
|
items: BillImportItem[]
|
||||||
}) {
|
}) {
|
||||||
return request.post('/transaction/import/confirm', data)
|
// 大批量导入耗时较长,单独放宽超时
|
||||||
|
return request.post('/transaction/import/confirm', data, { timeout: 120000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 批量设置交易记录的分类(按类型分别应用支出/收入分类) */
|
/** 批量设置交易记录的分类(按类型分别应用支出/收入分类) */
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ export default {
|
|||||||
pageExpense: { zh: '本页支出', en: 'Page Expense', ja: 'このページの支出' },
|
pageExpense: { zh: '本页支出', en: 'Page Expense', ja: 'このページの支出' },
|
||||||
pageIncome: { zh: '本页收入', en: 'Page Income', ja: 'このページの収入' },
|
pageIncome: { zh: '本页收入', en: 'Page Income', ja: 'このページの収入' },
|
||||||
pageBalance: { zh: '本页结余', en: 'Page Balance', ja: 'このページの収支' },
|
pageBalance: { zh: '本页结余', en: 'Page Balance', ja: 'このページの収支' },
|
||||||
|
pageExcluded: { zh: '(不计入统计:支出 {expense} · 收入 {income})', en: '(Excluded: expense {expense} · income {income})', ja: '(集計対象外:支出 {expense} · 収入 {income})' },
|
||||||
column: {
|
column: {
|
||||||
index: { zh: '序号', en: 'No.', ja: 'No.' },
|
index: { zh: '序号', en: 'No.', ja: 'No.' },
|
||||||
transactionTime: { zh: '交易时间', en: 'Time', ja: '取引日時' },
|
transactionTime: { zh: '交易时间', en: 'Time', ja: '取引日時' },
|
||||||
@@ -396,6 +397,8 @@ export default {
|
|||||||
batchDeleteNone: { zh: '请先选择要删除的记录', en: 'Please select records to delete', ja: '削除する記録を選択してください' },
|
batchDeleteNone: { zh: '请先选择要删除的记录', en: 'Please select records to delete', ja: '削除する記録を選択してください' },
|
||||||
batchDeleteConfirm: { zh: '确定删除选中的 {count} 条记录吗?', en: 'Delete the {count} selected record(s)?', ja: '選択した {count} 件を削除しますか?' },
|
batchDeleteConfirm: { zh: '确定删除选中的 {count} 条记录吗?', en: 'Delete the {count} selected record(s)?', ja: '選択した {count} 件を削除しますか?' },
|
||||||
batchDeleted: { zh: '已删除 {count} 条记录', en: 'Deleted {count} 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: 'エクスポートしました' },
|
exportSuccess: { zh: '导出成功', en: 'Exported successfully', ja: 'エクスポートしました' },
|
||||||
exportFailed: { zh: '导出失败', en: 'Export failed', ja: 'エクスポートに失敗しました' },
|
exportFailed: { zh: '导出失败', en: 'Export failed', ja: 'エクスポートに失敗しました' },
|
||||||
exportFileName: { zh: '交易记录', en: 'transactions', 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 形式を選択。' },
|
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 形式を選択。' },
|
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 形式を選択し、解凍パスワードを設定。' },
|
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)でエクスポート。' },
|
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: '解析' },
|
parse: { zh: '解析', en: 'Parse', ja: '解析' },
|
||||||
expenseDefaultCategory: { zh: '支出默认分类(可不选):', en: 'Default expense category (optional):', ja: '支出の既定カテゴリ(任意):' },
|
expenseDefaultCategory: { zh: '支出默认分类(可不选):', en: 'Default expense category (optional):', ja: '支出の既定カテゴリ(任意):' },
|
||||||
@@ -494,6 +497,8 @@ export default {
|
|||||||
notSetCategory: { zh: '未选则不计分类', en: 'No category if unselected', ja: '未選択の場合は分類なし' },
|
notSetCategory: { zh: '未选则不计分类', en: 'No category if unselected', ja: '未選択の場合は分類なし' },
|
||||||
notSetTag: { zh: '未选则不打标签', en: 'No tags if unselected', ja: '未選択の場合はタグなし' },
|
notSetTag: { zh: '未选则不打标签', en: 'No tags if unselected', ja: '未選択の場合はタグなし' },
|
||||||
selectAll: { zh: '全选', en: 'Select All', 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} 件' },
|
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: '取引相手/商品/注文番号を検索' },
|
searchPlaceholder: { zh: '搜索交易对方/商品/单号', en: 'Search counterparty / product / order no.', ja: '取引相手/商品/注文番号を検索' },
|
||||||
typePlaceholder: { zh: '类型', en: 'Type', ja: '種類' },
|
typePlaceholder: { zh: '类型', en: 'Type', ja: '種類' },
|
||||||
@@ -522,6 +527,7 @@ export default {
|
|||||||
product: { zh: '商品名称', en: 'Product', ja: '商品名' },
|
product: { zh: '商品名称', en: 'Product', ja: '商品名' },
|
||||||
status: { zh: '状态', en: 'Status', ja: 'ステータス' },
|
status: { zh: '状态', en: 'Status', ja: 'ステータス' },
|
||||||
matchedRule: { zh: '匹配规则/建议', en: 'Matched Rule', 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} 形式のファイルのみ対応しています' },
|
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: '先に明細ファイルを選択してください' },
|
selectFileFirst: { zh: '请先选择账单文件', en: 'Please select a bill file first', ja: '先に明細ファイルを選択してください' },
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const allowedExtensions: Record<BillType, string[]> = {
|
|||||||
ALIPAY: ['.csv', '.zip'],
|
ALIPAY: ['.csv', '.zip'],
|
||||||
WECHAT: ['.xlsx', '.zip'],
|
WECHAT: ['.xlsx', '.zip'],
|
||||||
MEITUAN: ['.csv', '.zip'],
|
MEITUAN: ['.csv', '.zip'],
|
||||||
CMB: ['.csv', '.zip'],
|
CMB: ['.csv', '.pdf', '.zip'],
|
||||||
BOCOM: ['.xls', '.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'),
|
title: t('import.column.amount'),
|
||||||
key: 'amount',
|
key: 'amount',
|
||||||
@@ -389,6 +401,22 @@ function toggleAll(checked: boolean) {
|
|||||||
checkedRowNos.value = checked ? selectableRows.value.map((r) => r.rowNo) : []
|
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() {
|
async function handleConfirm() {
|
||||||
if (checkedRows.value.length === 0) {
|
if (checkedRows.value.length === 0) {
|
||||||
@@ -544,6 +572,12 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
{{ t('import.selectAll') }}
|
{{ t('import.selectAll') }}
|
||||||
</NCheckbox>
|
</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>
|
<NText depth="3">{{ t('import.previewSummary', { total: rows.length, filtered: filteredRows.length, checked: checkedRows.length }) }}</NText>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,18 @@ const pageSummary = computed(() => {
|
|||||||
}
|
}
|
||||||
return { income, expense, balance: income - expense }
|
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 loading = ref(false) // 列表加载状态
|
||||||
const showModal = ref(false) // 新增/编辑弹窗显示状态
|
const showModal = ref(false) // 新增/编辑弹窗显示状态
|
||||||
const showImportModal = 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 {
|
try {
|
||||||
const res = await exportTransaction({
|
const res = await exportTransaction({
|
||||||
types: filterType.value.length > 0 ? filterType.value : undefined,
|
types: filterType.value.length > 0 ? filterType.value : undefined,
|
||||||
@@ -1584,6 +1611,8 @@ async function handleExport() {
|
|||||||
message.success(t('transaction.exportSuccess'))
|
message.success(t('transaction.exportSuccess'))
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || t('transaction.exportFailed'))
|
message.error(e.message || t('transaction.exportFailed'))
|
||||||
|
} finally {
|
||||||
|
exporting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1614,7 +1643,7 @@ onMounted(() => {
|
|||||||
{{ t('transaction.batchDelete') }}
|
{{ t('transaction.batchDelete') }}
|
||||||
</NButton>
|
</NButton>
|
||||||
<NButton @click="showImportModal = true">{{ t('transaction.importBill') }}</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">
|
<NButton v-if="aiEnabled" @click="handleAiRecordOpen">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<NIcon :component="SparklesOutline" />
|
<NIcon :component="SparklesOutline" />
|
||||||
@@ -1802,6 +1831,9 @@ onMounted(() => {
|
|||||||
{{ t('transaction.pageExpense') }} <span style="color: #d03050;">{{ pageSummary.expense.toFixed(2) }}</span>
|
{{ 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.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>
|
· {{ 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>
|
</span>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package cn.rosercode.finance.controller;
|
package cn.rosercode.finance.controller;
|
||||||
|
|
||||||
import com.alibaba.excel.EasyExcel;
|
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.PageResult;
|
||||||
import cn.rosercode.finance.common.Result;
|
import cn.rosercode.finance.common.Result;
|
||||||
import cn.rosercode.finance.dto.BatchCategoryRequest;
|
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.BatchTagRequest;
|
||||||
import cn.rosercode.finance.dto.TransactionQuery;
|
import cn.rosercode.finance.dto.TransactionQuery;
|
||||||
import cn.rosercode.finance.dto.TransactionRequest;
|
import cn.rosercode.finance.dto.TransactionRequest;
|
||||||
|
import cn.rosercode.finance.exporter.TransactionExportSheetHandler;
|
||||||
import cn.rosercode.finance.service.TransactionService;
|
import cn.rosercode.finance.service.TransactionService;
|
||||||
import cn.rosercode.finance.vo.TransactionExportVO;
|
import cn.rosercode.finance.vo.TransactionExportVO;
|
||||||
import cn.rosercode.finance.vo.TransactionVO;
|
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.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import jakarta.validation.Valid;
|
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.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
@@ -231,8 +237,22 @@ public class TransactionController {
|
|||||||
|
|
||||||
// 查询数据并写入 Excel
|
// 查询数据并写入 Excel
|
||||||
List<TransactionExportVO> data = transactionService.export(userId, query);
|
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>xlsx 等 Office 文件本身也是 zip 结构,需借此区分「账单压缩包」与「原始账单文件」:
|
||||||
* 仅当压缩包内确实包含目标扩展名的账单文件时才按压缩包处理。</p>
|
* 仅当压缩包内确实包含目标扩展名的账单文件时才按压缩包处理。同一账单类型可能支持
|
||||||
|
* 多种导出格式(如招商银行的 CSV 与 PDF),故按候选扩展名顺序依次匹配。</p>
|
||||||
*
|
*
|
||||||
* @param content 文件字节内容
|
* @param content 文件字节内容
|
||||||
* @param extension 目标文件扩展名(小写,含点,如 .csv、.xlsx)
|
* @param extensions 目标文件扩展名(小写,含点,如 .csv、.pdf)
|
||||||
* @return 存在匹配文件返回 true
|
* @return 命中的扩展名,未命中返回 null
|
||||||
*/
|
*/
|
||||||
public boolean containsEntry(byte[] content, String extension) {
|
public String findEntryExtension(byte[] content, String... extensions) {
|
||||||
if (!isZip(content)) {
|
if (!isZip(content) || extensions == null || extensions.length == 0) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
Path temp = null;
|
Path temp = null;
|
||||||
try {
|
try {
|
||||||
temp = writeTemp(content);
|
temp = writeTemp(content);
|
||||||
List<FileHeader> headers = new ZipFile(temp.toFile()).getFileHeaders();
|
List<FileHeader> headers = new ZipFile(temp.toFile()).getFileHeaders();
|
||||||
for (FileHeader header : headers) {
|
for (String extension : extensions) {
|
||||||
if (!header.isDirectory() && header.getFileName().toLowerCase().endsWith(extension)) {
|
for (FileHeader header : headers) {
|
||||||
return true;
|
if (!header.isDirectory() && header.getFileName().toLowerCase().endsWith(extension)) {
|
||||||
|
return extension;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
// 读取失败按非账单压缩包处理,交由原解析器给出更准确的错误
|
// 读取失败按非账单压缩包处理,交由原解析器给出更准确的错误
|
||||||
return false;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
deleteQuietly(temp);
|
deleteQuietly(temp);
|
||||||
}
|
}
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ import java.util.Map;
|
|||||||
*
|
*
|
||||||
* <p>解析招商银行「交易流水」导出的 CSV 文件。文件为 UTF-8(含 BOM,兼容 GBK),
|
* <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
|
* @date 2026-09-19 02:13:20
|
||||||
*/
|
*/
|
||||||
@@ -60,17 +64,18 @@ public class CmbBillParser implements BillParser {
|
|||||||
/** 交易时间格式(如 12:29:45) */
|
/** 交易时间格式(如 12:29:45) */
|
||||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");
|
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");
|
||||||
|
|
||||||
/** 生成去重标识时使用的时间格式 */
|
|
||||||
private static final DateTimeFormatter ID_TIME_FORMATTER = DateTimeFormatter.ofPattern("HHmmss");
|
|
||||||
|
|
||||||
/** CSV 解析格式:逗号分隔并去除字段首尾空白 */
|
/** CSV 解析格式:逗号分隔并去除字段首尾空白 */
|
||||||
private static final CSVFormat CSV_FORMAT = CSVFormat.DEFAULT.builder().setTrim(true).build();
|
private static final CSVFormat CSV_FORMAT = CSVFormat.DEFAULT.builder().setTrim(true).build();
|
||||||
|
|
||||||
/** JSON 序列化器,用于生成原始完整数据 */
|
/** JSON 序列化器,用于生成原始完整数据 */
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public CmbBillParser(ObjectMapper objectMapper) {
|
/** PDF 交易流水解析器,用于处理 PDF 格式的招商银行账单 */
|
||||||
|
private final CmbPdfParser cmbPdfParser;
|
||||||
|
|
||||||
|
public CmbBillParser(ObjectMapper objectMapper, CmbPdfParser cmbPdfParser) {
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.cmbPdfParser = cmbPdfParser;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -81,10 +86,14 @@ public class CmbBillParser implements BillParser {
|
|||||||
/**
|
/**
|
||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
*
|
*
|
||||||
* 定位表头后逐行解析数据,遇到元信息或合计行结束。
|
* 按文件头识别 PDF:PDF 交易流水交由 {@link CmbPdfParser} 处理;
|
||||||
|
* CSV 定位表头后逐行解析数据,遇到元信息或合计行结束。
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public List<ParsedBillRow> parse(byte[] content) throws IOException {
|
public List<ParsedBillRow> parse(byte[] content) throws IOException {
|
||||||
|
if (cmbPdfParser.isPdf(content)) {
|
||||||
|
return cmbPdfParser.parse(content);
|
||||||
|
}
|
||||||
String text = new String(content, detectCharset(content));
|
String text = new String(content, detectCharset(content));
|
||||||
String[] lines = text.split("\r?\n", -1);
|
String[] lines = text.split("\r?\n", -1);
|
||||||
|
|
||||||
@@ -203,8 +212,9 @@ public class CmbBillParser implements BillParser {
|
|||||||
// 银行流水默认计入统计
|
// 银行流水默认计入统计
|
||||||
row.setIncludeInStats(true);
|
row.setIncludeInStats(true);
|
||||||
|
|
||||||
// 无交易单号,按日期时间、类型与金额生成去重标识
|
// 无交易单号,按日期、类型、金额与余额生成去重标识(与 PDF 解析器统一)
|
||||||
row.setExternalNo(buildExternalNo(row.getTransactionTime(), type, row.getAmount()));
|
row.setExternalNo(buildExternalNo(row.getTransactionTime(), type, row.getAmount(),
|
||||||
|
get(header, values, COL_BALANCE)));
|
||||||
|
|
||||||
// 校验必要字段,决定是否可导入
|
// 校验必要字段,决定是否可导入
|
||||||
boolean importable = row.getError() == null
|
boolean importable = row.getError() == null
|
||||||
@@ -219,21 +229,26 @@ public class CmbBillParser implements BillParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成去重标识:银行流水无交易单号,用日期时间、收支类型与金额组合。
|
* 生成去重标识:银行流水无交易单号,用日期、收支类型、金额与余额组合。
|
||||||
*
|
*
|
||||||
* @param time 交易时间
|
* <p>格式与招商银行 PDF 解析器({@link CmbPdfParser})完全一致,以便同一笔交易
|
||||||
* @param type 收支类型
|
* 无论从 CSV 还是 PDF 导入都能相互识别为重复。余额用于区分同一天、同类型、同金额的
|
||||||
* @param amount 金额
|
* 不同交易。</p>
|
||||||
|
*
|
||||||
|
* @param time 交易时间
|
||||||
|
* @param type 收支类型
|
||||||
|
* @param amount 金额
|
||||||
|
* @param balanceText 余额文本
|
||||||
* @return 去重标识,信息不足时返回 null
|
* @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) {
|
if (time == null || type == null || amount == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
String date = time.format(DATE_FORMATTER);
|
String date = time.format(DATE_FORMATTER);
|
||||||
String clock = time.format(ID_TIME_FORMATTER);
|
|
||||||
String value = amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
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 com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import cn.rosercode.finance.entity.Transaction;
|
import cn.rosercode.finance.entity.Transaction;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 交易记录Mapper接口,继承MyBatis-Plus的BaseMapper,提供交易记录表的基础CRUD操作
|
* 交易记录Mapper接口,继承MyBatis-Plus的BaseMapper,提供交易记录表的基础CRUD操作
|
||||||
*/
|
*/
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface TransactionMapper extends BaseMapper<Transaction> {
|
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 com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import cn.rosercode.finance.entity.TransactionTag;
|
import cn.rosercode.finance.entity.TransactionTag;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 交易标签关联Mapper接口,继承MyBatis-Plus的BaseMapper,提供关联表的基础CRUD操作。
|
* 交易标签关联Mapper接口,继承MyBatis-Plus的BaseMapper,提供关联表的基础CRUD操作。
|
||||||
@@ -11,4 +14,12 @@ import org.apache.ibatis.annotations.Mapper;
|
|||||||
*/
|
*/
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface TransactionTagMapper extends BaseMapper<TransactionTag> {
|
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.math.BigDecimal;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.EnumMap;
|
import java.util.EnumMap;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -93,9 +94,10 @@ public class BillImportServiceImpl implements BillImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 压缩包先解压出账单文件,再交给对应解析器;xlsx 等本身是 zip,需确认包内含目标文件
|
// 压缩包先解压出账单文件,再交给对应解析器;xlsx 等本身是 zip,需确认包内含目标文件
|
||||||
String extension = expectedExtension(billType);
|
List<String> extensions = expectedExtensions(billType);
|
||||||
byte[] fileContent = archiveExtractor.containsEntry(content, extension)
|
String matchedExtension = archiveExtractor.findEntryExtension(content, extensions.toArray(new String[0]));
|
||||||
? archiveExtractor.extract(content, extension, password)
|
byte[] fileContent = matchedExtension != null
|
||||||
|
? archiveExtractor.extract(content, matchedExtension, password)
|
||||||
: content;
|
: content;
|
||||||
|
|
||||||
List<ParsedBillRow> rows = parser.parse(fileContent);
|
List<ParsedBillRow> rows = parser.parse(fileContent);
|
||||||
@@ -128,16 +130,20 @@ public class BillImportServiceImpl implements BillImportService {
|
|||||||
* 获取账单类型对应的账单文件扩展名,用于从压缩包中匹配文件。
|
* 获取账单类型对应的账单文件扩展名,用于从压缩包中匹配文件。
|
||||||
*
|
*
|
||||||
* @param billType 账单类型
|
* @param billType 账单类型
|
||||||
* @return 扩展名(小写,含点)
|
* @return 候选扩展名列表(小写,含点),按优先级排序
|
||||||
*/
|
*/
|
||||||
private String expectedExtension(BillType billType) {
|
private List<String> expectedExtensions(BillType billType) {
|
||||||
if (billType == BillType.WECHAT) {
|
if (billType == BillType.WECHAT) {
|
||||||
return ".xlsx";
|
return List.of(".xlsx");
|
||||||
}
|
}
|
||||||
if (billType == BillType.BOCOM) {
|
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());
|
.collect(Collectors.toList());
|
||||||
List<RuleMatchResult> ruleMatches = ruleService.matchBatch(userId, ruleContexts);
|
List<RuleMatchResult> ruleMatches = ruleService.matchBatch(userId, ruleContexts);
|
||||||
|
|
||||||
int imported = 0;
|
|
||||||
int skipped = 0;
|
int skipped = 0;
|
||||||
int failed = 0;
|
int failed = 0;
|
||||||
List<String> messages = new ArrayList<>();
|
List<String> messages = new ArrayList<>();
|
||||||
|
|
||||||
|
// 待批量插入的交易及其标签(按下标一一对应)
|
||||||
|
List<Transaction> pending = new ArrayList<>();
|
||||||
|
List<Set<Long>> pendingTagIds = new ArrayList<>();
|
||||||
|
|
||||||
for (int i = 0; i < items.size(); i++) {
|
for (int i = 0; i < items.size(); i++) {
|
||||||
BillImportRequest.Item item = items.get(i);
|
BillImportRequest.Item item = items.get(i);
|
||||||
String label = StringUtils.hasText(item.getExternalNo()) ? item.getExternalNo() : "未知交易号";
|
String label = StringUtils.hasText(item.getExternalNo()) ? item.getExternalNo() : "未知交易号";
|
||||||
try {
|
// 基础字段校验
|
||||||
// 基础字段校验
|
if (!"INCOME".equals(item.getType()) && !"EXPENSE".equals(item.getType())) {
|
||||||
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) {
|
|
||||||
failed++;
|
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();
|
BillImportResultVO result = new BillImportResultVO();
|
||||||
result.setTotal(items.size());
|
result.setTotal(items.size());
|
||||||
result.setImported(imported);
|
result.setImported(inserted.size());
|
||||||
result.setSkipped(skipped);
|
result.setSkipped(skipped);
|
||||||
result.setFailed(failed);
|
result.setFailed(failed);
|
||||||
result.setMessages(messages);
|
result.setMessages(messages);
|
||||||
return result;
|
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) {
|
||||||
|
// 关联失败不影响交易导入结果
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询指定交易号中已存在的记录
|
* 查询指定交易号中已存在的记录
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ mybatis-plus:
|
|||||||
id-type: auto
|
id-type: auto
|
||||||
configuration:
|
configuration:
|
||||||
map-underscore-to-camel-case: true
|
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:
|
jwt:
|
||||||
secret: finance-app-secret-key-2026-a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
|
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>
|
||||||
Reference in New Issue
Block a user