feat: 扩展账单导入(zip/美团/招行/交行)并新增批量删除与动态支付方式

- 账单导入支持 zip 压缩包,加密包自动检测并弹窗输入解压密码(引入 zip4j)
- 新增美团、招商银行、交通银行账单解析器及对应账单类型
- 支付方式列表改为后端动态获取,前端筛选与新增/编辑表单动态渲染
- 支付方式筛选从「更多筛选」移至常用筛选栏,移除「银行卡」选项
- 收支记录支持多选批量删除
- 补充相关三语文案并透传业务错误码
This commit is contained in:
2026-09-19 02:46:35 +08:00
parent c69e76ed4c
commit 9981e77bf1
18 changed files with 1558 additions and 55 deletions
+2
View File
@@ -39,6 +39,8 @@ dependencies {
implementation 'com.alibaba:easyexcel:4.0.3' implementation 'com.alibaba:easyexcel:4.0.3'
implementation 'org.apache.commons:commons-csv:1.12.0' implementation 'org.apache.commons:commons-csv:1.12.0'
// zip 压缩包解析(支持带密码的 ZipCrypto / AES 加密压缩包)
implementation 'net.lingala.zip4j:zip4j:2.11.5'
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'
+4 -1
View File
@@ -34,7 +34,10 @@ request.interceptors.response.use(
} }
}) })
} }
return Promise.reject(new Error(data.message || '请求失败')) // 在 Error 上保留业务错误码,便于调用方按 code 做差异化处理
const bizError: any = new Error(data.message || '请求失败')
bizError.code = data.code
return Promise.reject(bizError)
} }
return data return data
}, },
+17 -4
View File
@@ -3,6 +3,11 @@ import request from './request'
import axios from 'axios' import axios from 'axios'
import { useUserStore } from '../store/user' import { useUserStore } from '../store/user'
/** 获取系统支持的支付方式编码列表(含账单导入来源与人工支付方式) */
export function getPaymentMethods() {
return request.get('/transaction/payment-methods')
}
/** 分页获取交易记录列表,支持按类型、分类、支付方式、时间范围、计入统计、金额区间多选筛选 */ /** 分页获取交易记录列表,支持按类型、分类、支付方式、时间范围、计入统计、金额区间多选筛选 */
export function getTransactionPage(params: { export function getTransactionPage(params: {
page?: number page?: number
@@ -57,6 +62,11 @@ export function deleteTransaction(id: number) {
return request.delete(`/transaction/${id}`) return request.delete(`/transaction/${id}`)
} }
/** 批量删除交易记录,返回实际删除数量 */
export function batchDeleteTransactions(ids: number[]) {
return request.delete('/transaction/batch', { data: { ids } })
}
/** 切换指定交易记录的"计入统计"标记 */ /** 切换指定交易记录的"计入统计"标记 */
export function toggleTransactionStats(id: number, includeInStats: boolean) { export function toggleTransactionStats(id: number, includeInStats: boolean) {
return request.put(`/transaction/${id}/stats`, { includeInStats }) return request.put(`/transaction/${id}/stats`, { includeInStats })
@@ -67,8 +77,8 @@ export function toggleRemarkHidden(id: number, remarkHidden: boolean) {
return request.put(`/transaction/${id}/remark-hidden`, { remarkHidden }) return request.put(`/transaction/${id}/remark-hidden`, { remarkHidden })
} }
/** 支持的账单类型,当前支持支付宝与微信 */ /** 支持的账单类型,当前支持支付宝、微信、美团、招商银行与交通银行 */
export type BillType = 'ALIPAY' | 'WECHAT' export type BillType = 'ALIPAY' | 'WECHAT' | 'MEITUAN' | 'CMB' | 'BOCOM'
/** 账单解析结果行 */ /** 账单解析结果行 */
export interface ParsedBillRow { export interface ParsedBillRow {
@@ -106,11 +116,14 @@ export interface BillImportItem {
rawData: string | null rawData: string | null
} }
/** 解析账单文件(不落库),返回预览数据 */ /** 解析账单文件(不落库),返回预览数据;支持 zip 压缩包,加密时需传解压密码 */
export function parseBill(file: File, billType: BillType) { export function parseBill(file: File, billType: BillType, password?: string) {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
formData.append('billType', billType) formData.append('billType', billType)
if (password) {
formData.append('password', password)
}
return request.post('/transaction/import/parse', formData) return request.post('/transaction/import/parse', formData)
} }
+19 -1
View File
@@ -37,6 +37,9 @@ export default {
alipay: { zh: '支付宝', en: 'Alipay', ja: 'Alipay' }, alipay: { zh: '支付宝', en: 'Alipay', ja: 'Alipay' },
cash: { zh: '现金', en: 'Cash', ja: '現金' }, cash: { zh: '现金', en: 'Cash', ja: '現金' },
bank: { zh: '银行卡', en: 'Bank Card', ja: '銀行カード' }, bank: { zh: '银行卡', en: 'Bank Card', ja: '銀行カード' },
meituan: { zh: '美团', en: 'Meituan', ja: 'Meituan' },
cmb: { zh: '招商银行', en: 'China Merchants Bank', ja: '招商銀行' },
bocom: { zh: '交通银行', en: 'Bank of Communications', ja: '交通銀行' },
}, },
}, },
@@ -389,6 +392,10 @@ export default {
noRecordsToSet: { zh: '当前表格没有可设置的记录', en: 'No records available in the current table', ja: '現在のテーブルに設定できる記録がありません' }, noRecordsToSet: { zh: '当前表格没有可设置的记录', en: 'No records available in the current table', ja: '現在のテーブルに設定できる記録がありません' },
batchNothingSelected: { zh: '请至少选择分类、计入统计或标签', en: 'Please select a category, stats option or tag', ja: 'カテゴリ、集計設定、タグのいずれかを選択してください' }, batchNothingSelected: { zh: '请至少选择分类、计入统计或标签', en: 'Please select a category, stats option or tag', ja: 'カテゴリ、集計設定、タグのいずれかを選択してください' },
batchUpdated: { zh: '已更新 {count} 条记录 | 已更新 {count} 条记录', en: 'Updated {count} record | Updated {count} records', ja: '{count} 件を更新しました' }, batchUpdated: { zh: '已更新 {count} 条记录 | 已更新 {count} 条记录', en: 'Updated {count} record | Updated {count} records', ja: '{count} 件を更新しました' },
batchDelete: { zh: '批量删除', en: 'Batch Delete', ja: '一括削除' },
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} 件を削除しました' },
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: '取引記録' },
@@ -468,12 +475,18 @@ export default {
billType: { zh: '账单类型:', en: 'Bill type:', ja: '明細の種類:' }, billType: { zh: '账单类型:', en: 'Bill type:', ja: '明細の種類:' },
billTypeALIPAY: { zh: '支付宝', en: 'Alipay', ja: 'Alipay' }, billTypeALIPAY: { zh: '支付宝', en: 'Alipay', ja: 'Alipay' },
billTypeWECHAT: { zh: '微信', en: 'WeChat', ja: 'WeChat' }, billTypeWECHAT: { zh: '微信', en: 'WeChat', ja: 'WeChat' },
billTypeMEITUAN: { zh: '美团', en: 'Meituan', ja: 'Meituan' },
billTypeCMB: { zh: '招商银行', en: 'China Merchants Bank', ja: '招商銀行' },
billTypeBOCOM: { zh: '交通银行', en: 'Bank of Communications', ja: '交通銀行' },
billFile: { zh: '账单文件:', en: 'Bill file:', ja: '明細ファイル:' }, billFile: { zh: '账单文件:', en: 'Bill file:', ja: '明細ファイル:' },
dropText: { zh: '点击或拖拽文件到此处', en: 'Click or drag a file here', ja: 'クリックまたはドラッグでファイルを選択' }, dropText: { zh: '点击或拖拽文件到此处', en: 'Click or drag a file here', ja: 'クリックまたはドラッグでファイルを選択' },
fileHint: { zh: '仅支持{label}导出的账单文件({exts})', en: 'Only {label} bill files are supported ({exts})', ja: '{label}からエクスポートした明細ファイルのみ対応({exts})' }, fileHint: { zh: '仅支持{label}导出的账单文件或 zip 压缩包({exts})', en: 'Only {label} bill files or zip archives are supported ({exts})', ja: '{label}からエクスポートした明細ファイルまたは zip 圧縮ファイルのみ対応({exts})' },
extSeparator: { zh: '、', en: ', ', ja: '、' }, extSeparator: { zh: '、', en: ', ', ja: '、' },
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 形式を選択し、解凍パスワードを設定。' },
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 形式を選択(圧縮する場合は解凍パスワードを設定)。' },
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: '支出の既定カテゴリ(任意):' },
incomeDefaultCategory: { zh: '收入默认分类(可不选):', en: 'Default income category (optional):', ja: '収入の既定カテゴリ(任意):' }, incomeDefaultCategory: { zh: '收入默认分类(可不选):', en: 'Default income category (optional):', ja: '収入の既定カテゴリ(任意):' },
@@ -517,6 +530,11 @@ export default {
importFailed: { zh: '导入失败', en: 'Import failed', ja: 'インポートに失敗しました' }, importFailed: { zh: '导入失败', en: 'Import failed', ja: 'インポートに失敗しました' },
loadCategoriesFailed: { zh: '加载分类失败', en: 'Failed to load categories', ja: 'カテゴリの読み込みに失敗しました' }, loadCategoriesFailed: { zh: '加载分类失败', en: 'Failed to load categories', ja: 'カテゴリの読み込みに失敗しました' },
loadTagsFailed: { zh: '加载标签失败', en: 'Failed to load tags', ja: 'タグの読み込みに失敗しました' }, loadTagsFailed: { zh: '加载标签失败', en: 'Failed to load tags', ja: 'タグの読み込みに失敗しました' },
passwordTitle: { zh: '解压密码', en: 'Archive Password', ja: '解凍パスワード' },
passwordTip: { zh: '该压缩包已加密,请输入解压密码。', en: 'This archive is encrypted. Please enter the password.', ja: 'この圧縮ファイルは暗号化されています。パスワードを入力してください。' },
passwordPlaceholder: { zh: '请输入解压密码', en: 'Enter the archive password', ja: 'パスワードを入力してください' },
passwordRequired: { zh: '请输入解压密码', en: 'Please enter the archive password', ja: 'パスワードを入力してください' },
passwordWrong: { zh: '解压密码错误', en: 'Incorrect archive password', ja: 'パスワードが正しくありません' },
}, },
// AI 智能建议 // AI 智能建议
+89 -15
View File
@@ -5,7 +5,7 @@ import { ref, computed, h, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { import {
useMessage, NSpace, NButton, NSelect, NUpload, NUploadDragger, NDataTable, useMessage, NSpace, NButton, NSelect, NUpload, NUploadDragger, NDataTable,
NCheckbox, NTag, NAlert, NText, NIcon, NInput, NInputNumber, NCheckbox, NTag, NAlert, NText, NIcon, NInput, NInputNumber, NModal,
} from 'naive-ui' } from 'naive-ui'
import type { DataTableColumns, UploadFileInfo } from 'naive-ui' import type { DataTableColumns, UploadFileInfo } from 'naive-ui'
import { CloudUploadOutline } from '@vicons/ionicons5' import { CloudUploadOutline } from '@vicons/ionicons5'
@@ -25,25 +25,49 @@ const { t } = useI18n() // 国际化
// 账单类型偏好存储键 // 账单类型偏好存储键
const BILL_TYPE_PREFS_KEY = 'finance.billImport.billType' const BILL_TYPE_PREFS_KEY = 'finance.billImport.billType'
// 账单类型选项,当前支持支付宝与微信 // 账单类型选项,当前支持支付宝、微信、美团、招商银行与交通银行
const billTypeOptions = computed(() => [ const billTypeOptions = computed(() => [
{ label: t('import.billTypeALIPAY'), value: 'ALIPAY' }, { label: t('import.billTypeALIPAY'), value: 'ALIPAY' },
{ label: t('import.billTypeWECHAT'), value: 'WECHAT' }, { label: t('import.billTypeWECHAT'), value: 'WECHAT' },
{ label: t('import.billTypeMEITUAN'), value: 'MEITUAN' },
{ label: t('import.billTypeCMB'), value: 'CMB' },
{ label: t('import.billTypeBOCOM'), value: 'BOCOM' },
]) ])
// 账单类型对应的名称与导出路径文案 key
const billTypeLabelKeys: Record<BillType, string> = {
ALIPAY: 'import.billTypeALIPAY',
WECHAT: 'import.billTypeWECHAT',
MEITUAN: 'import.billTypeMEITUAN',
CMB: 'import.billTypeCMB',
BOCOM: 'import.billTypeBOCOM',
}
const billTypeExportHintKeys: Record<BillType, string> = {
ALIPAY: 'import.exportHintALIPAY',
WECHAT: 'import.exportHintWECHAT',
MEITUAN: 'import.exportHintMEITUAN',
CMB: 'import.exportHintCMB',
BOCOM: 'import.exportHintBOCOM',
}
/** 读取上次选择的账单类型偏好,非法值回退为支付宝 */ /** 读取上次选择的账单类型偏好,非法值回退为支付宝 */
function loadBillTypePref(): BillType { function loadBillTypePref(): BillType {
const saved = localStorage.getItem(BILL_TYPE_PREFS_KEY) const saved = localStorage.getItem(BILL_TYPE_PREFS_KEY)
return saved === 'ALIPAY' || saved === 'WECHAT' ? saved : 'ALIPAY' return saved === 'ALIPAY' || saved === 'WECHAT' || saved === 'MEITUAN' || saved === 'CMB' || saved === 'BOCOM'
? saved
: 'ALIPAY'
} }
// 当前账单类型,初始值取上次选择(个人偏好) // 当前账单类型,初始值取上次选择(个人偏好)
const billType = ref<BillType>(loadBillTypePref()) const billType = ref<BillType>(loadBillTypePref())
// 各账单类型允许上传的文件扩展名 // 各账单类型允许上传的文件扩展名(支持原始文件或 zip 压缩包)
const allowedExtensions: Record<BillType, string[]> = { const allowedExtensions: Record<BillType, string[]> = {
ALIPAY: ['.csv'], ALIPAY: ['.csv', '.zip'],
WECHAT: ['.xlsx'], WECHAT: ['.xlsx', '.zip'],
MEITUAN: ['.csv', '.zip'],
CMB: ['.csv', '.zip'],
BOCOM: ['.xls', '.zip'],
} }
// 当前账单类型允许的扩展名,用于上传组件 accept 属性 // 当前账单类型允许的扩展名,用于上传组件 accept 属性
@@ -51,15 +75,13 @@ const acceptTypes = computed(() => (allowedExtensions[billType.value] || []).joi
// 当前账单类型的文件格式提示 // 当前账单类型的文件格式提示
const fileHint = computed(() => { const fileHint = computed(() => {
const label = billType.value === 'WECHAT' ? t('import.billTypeWECHAT') : t('import.billTypeALIPAY') const label = t(billTypeLabelKeys[billType.value])
const exts = (allowedExtensions[billType.value] || []).join(t('import.extSeparator')) const exts = (allowedExtensions[billType.value] || []).join(t('import.extSeparator'))
return t('import.fileHint', { label, exts }) return t('import.fileHint', { label, exts })
}) })
// 当前账单类型的导出路径提示 // 当前账单类型的导出路径提示
const exportHint = computed(() => const exportHint = computed(() => t(billTypeExportHintKeys[billType.value]))
billType.value === 'WECHAT' ? t('import.exportHintWECHAT') : t('import.exportHintALIPAY'),
)
// 面板阶段:选择文件 -> 预览勾选 -> 导入结果 // 面板阶段:选择文件 -> 预览勾选 -> 导入结果
const stage = ref<'select' | 'preview' | 'result'>('select') const stage = ref<'select' | 'preview' | 'result'>('select')
@@ -69,6 +91,10 @@ const uploadFileList = ref<UploadFileInfo[]>([])
const parsing = ref(false) const parsing = ref(false)
const importing = ref(false) const importing = ref(false)
// 压缩包解压密码弹窗状态
const passwordModalVisible = ref(false)
const archivePassword = ref('')
// 解析结果与勾选状态 // 解析结果与勾选状态
const rows = ref<ParsedBillRow[]>([]) const rows = ref<ParsedBillRow[]>([])
const checkedRowNos = ref<number[]>([]) const checkedRowNos = ref<number[]>([])
@@ -295,7 +321,7 @@ function isAllowedFile(name: string) {
function handleFileChange(options: { fileList: UploadFileInfo[] }) { function handleFileChange(options: { fileList: UploadFileInfo[] }) {
const last = options.fileList[options.fileList.length - 1] const last = options.fileList[options.fileList.length - 1]
if (last?.file && !isAllowedFile(last.name)) { if (last?.file && !isAllowedFile(last.name)) {
const label = billType.value === 'WECHAT' ? t('import.billTypeWECHAT') : t('import.billTypeALIPAY') const label = t(billTypeLabelKeys[billType.value])
const exts = allowedExtensions[billType.value].join(t('import.extSeparator')) const exts = allowedExtensions[billType.value].join(t('import.extSeparator'))
message.error(t('import.errorFileType', { label, exts })) message.error(t('import.errorFileType', { label, exts }))
uploadFileList.value = [] uploadFileList.value = []
@@ -306,27 +332,47 @@ function handleFileChange(options: { fileList: UploadFileInfo[] }) {
file.value = last?.file ?? null file.value = last?.file ?? null
} }
/** 解析账单 */ /** 解析账单,password 用于加密压缩包(由密码弹窗传入) */
async function handleParse() { async function handleParse(password?: string) {
if (!file.value) { if (!file.value) {
message.warning(t('import.selectFileFirst')) message.warning(t('import.selectFileFirst'))
return return
} }
parsing.value = true parsing.value = true
try { try {
const res: any = await parseBill(file.value, billType.value) const res: any = await parseBill(file.value, billType.value, password)
rows.value = res.data.rows || [] rows.value = res.data.rows || []
// 默认勾选解析器建议勾选的行 // 默认勾选解析器建议勾选的行
checkedRowNos.value = rows.value.filter((r) => r.selected).map((r) => r.rowNo) checkedRowNos.value = rows.value.filter((r) => r.selected).map((r) => r.rowNo)
resetFilters() resetFilters()
stage.value = 'preview' stage.value = 'preview'
// 解析成功时关闭密码弹窗并清空密码
passwordModalVisible.value = false
archivePassword.value = ''
} catch (e: any) { } catch (e: any) {
// 4001:压缩包已加密,需要用户输入密码;4002:密码错误,留在弹窗内重试
if (e.code === 4001) {
passwordModalVisible.value = true
} else if (e.code === 4002) {
message.error(e.message || t('import.passwordWrong'))
passwordModalVisible.value = true
} else {
message.error(e.message || t('import.parseFailed')) message.error(e.message || t('import.parseFailed'))
}
} finally { } finally {
parsing.value = false parsing.value = false
} }
} }
/** 提交解压密码并重新解析 */
async function submitPassword() {
if (!archivePassword.value) {
message.warning(t('import.passwordRequired'))
return
}
await handleParse(archivePassword.value)
}
/** 切换单行勾选 */ /** 切换单行勾选 */
function toggleRow(row: ParsedBillRow, checked: boolean) { function toggleRow(row: ParsedBillRow, checked: boolean) {
if (checked) { if (checked) {
@@ -389,6 +435,8 @@ function reset() {
expenseCategoryId.value = null expenseCategoryId.value = null
incomeCategoryId.value = null incomeCategoryId.value = null
importTagIds.value = [] importTagIds.value = []
passwordModalVisible.value = false
archivePassword.value = ''
resetFilters() resetFilters()
} }
@@ -450,7 +498,7 @@ onMounted(() => {
<NSpace justify="end"> <NSpace justify="end">
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton> <NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="parsing" :disabled="!file" @click="handleParse">{{ t('import.parse') }}</NButton> <NButton type="primary" :loading="parsing" :disabled="!file" @click="handleParse()">{{ t('import.parse') }}</NButton>
</NSpace> </NSpace>
</NSpace> </NSpace>
@@ -541,4 +589,30 @@ onMounted(() => {
<NButton type="primary" @click="handleClose">{{ t('import.done') }}</NButton> <NButton type="primary" @click="handleClose">{{ t('import.done') }}</NButton>
</NSpace> </NSpace>
</NSpace> </NSpace>
<!-- 压缩包加密时弹出,要求输入解压密码 -->
<NModal
v-model:show="passwordModalVisible"
preset="card"
:title="t('import.passwordTitle')"
style="width: 420px; max-width: 92vw;"
:mask-closable="false"
>
<NSpace vertical :size="12">
<NText depth="3">{{ t('import.passwordTip') }}</NText>
<NInput
v-model:value="archivePassword"
type="password"
show-password-on="click"
:placeholder="t('import.passwordPlaceholder')"
@keyup.enter="submitPassword"
/>
</NSpace>
<template #footer>
<NSpace justify="end">
<NButton @click="passwordModalVisible = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="parsing" @click="submitPassword">{{ t('import.parse') }}</NButton>
</NSpace>
</template>
</NModal>
</template> </template>
+68 -23
View File
@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NDropdown } from 'naive-ui' import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NDropdown } from 'naive-ui'
import { EyeOutline, EyeOffOutline, CloseCircleOutline, SparklesOutline, BulbOutline } from '@vicons/ionicons5' import { EyeOutline, EyeOffOutline, CloseCircleOutline, SparklesOutline, BulbOutline } from '@vicons/ionicons5'
import type { DataTableColumns, AutoCompleteOption } from 'naive-ui' import type { DataTableColumns, AutoCompleteOption } from 'naive-ui'
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags } from '../api/transaction' import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getPaymentMethods, batchDeleteTransactions } from '../api/transaction'
import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi } from '../api/ai' import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi } from '../api/ai'
import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai' import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai'
import { getCategoryList, createCategory } from '../api/category' import { getCategoryList, createCategory } from '../api/category'
@@ -158,6 +158,7 @@ const editingId = ref<number | null>(null) // 当前编辑的记录 ID(null
const categories = ref<CategoryOption[]>([]) // 分类选项列表 const categories = ref<CategoryOption[]>([]) // 分类选项列表
const allTags = ref<TagOption[]>([]) // 全部标签(用于筛选、编辑、批量) const allTags = ref<TagOption[]>([]) // 全部标签(用于筛选、编辑、批量)
const revealedRemarkIds = ref<number[]>([]) // 临时展开查看原文的备注行 ID(不持久,刷新/翻页后重置) const revealedRemarkIds = ref<number[]>([]) // 临时展开查看原文的备注行 ID(不持久,刷新/翻页后重置)
const checkedTransactionIds = ref<number[]>([]) // 表格多选选中的记录ID(用于批量删除)
// 表单数据(新增/编辑共用) // 表单数据(新增/编辑共用)
const form = ref({ const form = ref({
@@ -177,13 +178,13 @@ const typeOptions = computed(() => [
{ label: t('common.types.income'), value: 'INCOME' }, { label: t('common.types.income'), value: 'INCOME' },
]) ])
// 支付方式选项 // 支付方式编码列表(从后端动态获取,含账单导入来源与人工支付方式)
const paymentOptions = computed(() => [ const paymentMethodCodes = ref<string[]>([])
{ label: t('common.payment.wechat'), value: 'WECHAT' },
{ label: t('common.payment.alipay'), value: 'ALIPAY' }, // 支付方式选项:由后端返回的编码映射为当前语言标签
{ label: t('common.payment.cash'), value: 'CASH' }, const paymentOptions = computed(() =>
{ label: t('common.payment.bank'), value: 'BANK' }, paymentMethodCodes.value.map((code) => ({ label: getPaymentLabel(code), value: code })),
]) )
// 筛选条件状态 // 筛选条件状态
const filterType = ref<string[]>([]) // 类型筛选(多选) const filterType = ref<string[]>([]) // 类型筛选(多选)
@@ -206,7 +207,6 @@ const showMoreFilter = ref(false) // 更多筛选弹层显示状态
// 更多筛选中已激活的条件数量 // 更多筛选中已激活的条件数量
const moreFilterCount = computed(() => { const moreFilterCount = computed(() => {
let count = 0 let count = 0
if (filterPaymentMethod.value.length > 0) count++
if (filterTag.value.length > 0) count++ if (filterTag.value.length > 0) count++
if (filterIncludeInStats.value !== null) count++ if (filterIncludeInStats.value !== null) count++
if (filterMinAmount.value !== null || filterMaxAmount.value !== null) count++ if (filterMinAmount.value !== null || filterMaxAmount.value !== null) count++
@@ -215,7 +215,6 @@ const moreFilterCount = computed(() => {
/** 清空「更多筛选」中的条件 */ /** 清空「更多筛选」中的条件 */
function clearMoreFilters() { function clearMoreFilters() {
filterPaymentMethod.value = []
filterTag.value = [] filterTag.value = []
filterIncludeInStats.value = null filterIncludeInStats.value = null
filterMinAmount.value = null filterMinAmount.value = null
@@ -483,6 +482,8 @@ async function setRemarkHidden(row: TransactionItem, hidden: boolean) {
/** 创建表格列配置 */ /** 创建表格列配置 */
function createColumns(): DataTableColumns<TransactionItem> { function createColumns(): DataTableColumns<TransactionItem> {
return [ return [
// 多选列:用于批量操作(如批量删除)
{ type: 'selection' },
{ {
// 序号列:按分页计算全局序号 // 序号列:按分页计算全局序号
title: t('transaction.column.index'), key: 'index', width: 70, align: 'center', title: t('transaction.column.index'), key: 'index', width: 70, align: 'center',
@@ -587,6 +588,16 @@ function createColumns(): DataTableColumns<TransactionItem> {
const columns = computed(() => createColumns()) // 表格列配置(随语言切换响应式更新) const columns = computed(() => createColumns()) // 表格列配置(随语言切换响应式更新)
/** 从后端加载支付方式列表,供筛选与编辑表单使用 */
async function loadPaymentMethods() {
try {
const res: any = await getPaymentMethods()
paymentMethodCodes.value = res.data || []
} catch (e) {
// 静默处理,支付方式下拉保持为空
}
}
/** 加载分类列表 */ /** 加载分类列表 */
async function fetchCategories() { async function fetchCategories() {
try { try {
@@ -630,6 +641,7 @@ async function fetchData() {
transactions.value = res.data.records transactions.value = res.data.records
total.value = res.data.total total.value = res.data.total
revealedRemarkIds.value = [] revealedRemarkIds.value = []
checkedTransactionIds.value = []
} catch (e: any) { } catch (e: any) {
message.error(e.message || t('common.loadFailed')) message.error(e.message || t('common.loadFailed'))
} finally { } finally {
@@ -739,6 +751,31 @@ function handleDelete(id: number) {
}) })
} }
/** 批量删除选中的记录:确认后调用接口并刷新列表 */
function handleBatchDelete() {
const count = checkedTransactionIds.value.length
if (count === 0) {
message.warning(t('transaction.batchDeleteNone'))
return
}
dialog.warning({
title: t('common.confirmDelete'),
content: t('transaction.batchDeleteConfirm', { count }),
positiveText: t('common.delete'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
try {
const res: any = await batchDeleteTransactions(checkedTransactionIds.value)
message.success(t('transaction.batchDeleted', { count: res.data }))
checkedTransactionIds.value = []
await fetchData()
} catch (e: any) {
message.error(e.message || t('common.deleteFailed'))
}
},
})
}
/** 翻页事件处理 */ /** 翻页事件处理 */
function handlePageChange(p: number) { function handlePageChange(p: number) {
page.value = p page.value = p
@@ -1558,6 +1595,7 @@ onMounted(() => {
loadAiConfig() loadAiConfig()
fetchCategories() fetchCategories()
fetchTags() fetchTags()
loadPaymentMethods()
fetchData() fetchData()
}) })
</script> </script>
@@ -1572,6 +1610,9 @@ onMounted(() => {
<NSelect v-model:value="sortValue" :options="sortOptions" style="width: 170px;" /> <NSelect v-model:value="sortValue" :options="sortOptions" style="width: 170px;" />
<NDivider vertical style="margin: 0 4px;" /> <NDivider vertical style="margin: 0 4px;" />
<NButton @click="handleBatchOpen">{{ t('transaction.batchSet') }}</NButton> <NButton @click="handleBatchOpen">{{ t('transaction.batchSet') }}</NButton>
<NButton type="error" :disabled="checkedTransactionIds.length === 0" @click="handleBatchDelete">
{{ t('transaction.batchDelete') }}
</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 @click="handleExport">{{ t('transaction.exportExcel') }}</NButton>
<NButton v-if="aiEnabled" @click="handleAiRecordOpen"> <NButton v-if="aiEnabled" @click="handleAiRecordOpen">
@@ -1661,6 +1702,15 @@ onMounted(() => {
@select="handleRemarkSelect" @select="handleRemarkSelect"
@blur="handleRemarkBlur" @blur="handleRemarkBlur"
/> />
<NSelect
v-model:value="filterPaymentMethod"
:options="paymentOptions"
multiple
clearable
:max-tag-count="2"
:placeholder="t('transaction.column.paymentMethod')"
style="width: 180px;"
/>
<NPopover v-model:show="showMoreFilter" trigger="click" placement="bottom-start"> <NPopover v-model:show="showMoreFilter" trigger="click" placement="bottom-start">
<template #trigger> <template #trigger>
<NButton> <NButton>
@@ -1671,18 +1721,6 @@ onMounted(() => {
</NButton> </NButton>
</template> </template>
<NSpace vertical :size="12" style="width: 260px;"> <NSpace vertical :size="12" style="width: 260px;">
<div>
<div class="filter-label">{{ t('transaction.column.paymentMethod') }}</div>
<NSelect
v-model:value="filterPaymentMethod"
:options="paymentOptions"
multiple
clearable
:max-tag-count="2"
:placeholder="t('common.all')"
style="width: 100%;"
/>
</div>
<div> <div>
<div class="filter-label">{{ t('transaction.column.tags') }}</div> <div class="filter-label">{{ t('transaction.column.tags') }}</div>
<NSelect <NSelect
@@ -1768,7 +1806,14 @@ onMounted(() => {
</NSpace> </NSpace>
<!-- 交易记录数据表格 --> <!-- 交易记录数据表格 -->
<NDataTable :columns="columns" :data="transactions" :loading="loading" :bordered="false" /> <NDataTable
:columns="columns"
:data="transactions"
:loading="loading"
:bordered="false"
:row-key="(row: TransactionItem) => row.id"
v-model:checked-row-keys="checkedTransactionIds"
/>
<!-- 分页控件 --> <!-- 分页控件 -->
<NSpace justify="center" style="margin-top: 16px;"> <NSpace justify="center" style="margin-top: 16px;">
@@ -43,21 +43,23 @@ public class BillImportController {
* 解析账单文件(不落库) * 解析账单文件(不落库)
* *
* @param request HTTP请求,从中获取用户ID * @param request HTTP请求,从中获取用户ID
* @param file 账单文件 * @param file 账单文件(支持 csv/xlsx 或 zip 压缩包)
* @param billType 账单类型,如 ALIPAY * @param billType 账单类型,如 ALIPAY
* @param password 压缩包解压密码,未加密时可不传
* @return 解析预览结果 * @return 解析预览结果
* @throws IOException 文件读取失败 * @throws IOException 文件读取失败
*/ */
@PostMapping("/parse") @PostMapping("/parse")
@Operation(summary = "解析账单文件", description = "解析账单文件并返回预览数据,不写入数据库") @Operation(summary = "解析账单文件", description = "解析账单文件(支持 zip 压缩包)并返回预览数据,不写入数据库")
public Result<BillParseResultVO> parse(HttpServletRequest request, public Result<BillParseResultVO> parse(HttpServletRequest request,
@Parameter(description = "账单文件") @RequestParam("file") MultipartFile file, @Parameter(description = "账单文件") @RequestParam("file") MultipartFile file,
@Parameter(description = "账单类型,如 ALIPAY") @RequestParam("billType") String billType) throws IOException { @Parameter(description = "账单类型,如 ALIPAY") @RequestParam("billType") String billType,
@Parameter(description = "压缩包解压密码") @RequestParam(value = "password", required = false) String password) throws IOException {
Long userId = (Long) request.getAttribute("userId"); Long userId = (Long) request.getAttribute("userId");
if (file == null || file.isEmpty()) { if (file == null || file.isEmpty()) {
throw new BusinessException("请选择账单文件"); throw new BusinessException("请选择账单文件");
} }
return Result.success(billImportService.parse(userId, resolveBillType(billType), file.getBytes())); return Result.success(billImportService.parse(userId, resolveBillType(billType), file.getBytes(), password));
} }
/** /**
@@ -4,6 +4,7 @@ import com.alibaba.excel.EasyExcel;
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;
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;
@@ -49,6 +50,17 @@ public class TransactionController {
this.transactionService = transactionService; this.transactionService = transactionService;
} }
/**
* 获取系统支持的支付方式列表
*
* @return 支付方式编码列表
*/
@GetMapping("/payment-methods")
@Operation(summary = "获取支付方式列表", description = "返回系统支持的支付方式编码,含账单导入来源与人工支付方式")
public Result<List<String>> paymentMethods() {
return Result.success(transactionService.listPaymentMethods());
}
/** /**
* 分页查询交易记录 * 分页查询交易记录
* *
@@ -113,6 +125,21 @@ public class TransactionController {
return Result.success(); return Result.success();
} }
/**
* 批量删除交易记录
*
* @param request HTTP请求,从中获取用户ID
* @param body 批量删除请求(记录ID列表)
* @return 实际删除的记录数
*/
@DeleteMapping("/batch")
@Operation(summary = "批量删除交易记录", description = "根据记录ID列表批量删除当前用户的交易记录")
public Result<Integer> batchDelete(HttpServletRequest request,
@RequestBody @Valid BatchDeleteRequest body) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(transactionService.batchDelete(userId, body.getIds()));
}
/** /**
* 切换交易记录的"计入统计"标记 * 切换交易记录的"计入统计"标记
* *
@@ -0,0 +1,19 @@
package cn.rosercode.finance.dto;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.List;
/**
* 批量删除交易记录请求DTO,用于一次性删除多条收支记录。
*
* @date 2026-09-19 02:32:21
*/
@Data
public class BatchDeleteRequest {
/** 待删除的交易记录ID列表 */
@NotEmpty(message = "请至少选择一条记录")
private List<Long> ids;
}
@@ -3,7 +3,7 @@ package cn.rosercode.finance.enums;
/** /**
* 账单类型枚举,标识支持的第三方账单来源。 * 账单类型枚举,标识支持的第三方账单来源。
* *
* <p>当前支持支付宝与微信支付账单,后续可扩展银行等账单类型。</p> * <p>当前支持支付宝、微信支付、美团、招商银行与交通银行账单,后续可扩展其他银行等账单类型。</p>
* *
* @date 2026-09-17 09:49:43 * @date 2026-09-17 09:49:43
*/ */
@@ -13,7 +13,16 @@ public enum BillType {
ALIPAY("支付宝"), ALIPAY("支付宝"),
/** 微信支付账单 */ /** 微信支付账单 */
WECHAT("微信"); WECHAT("微信"),
/** 美团账单 */
MEITUAN("美团"),
/** 招商银行账单 */
CMB("招商银行"),
/** 交通银行账单 */
BOCOM("交通银行");
/** 账单类型的中文名称 */ /** 账单类型的中文名称 */
private final String label; private final String label;
@@ -0,0 +1,212 @@
package cn.rosercode.finance.importer;
import cn.rosercode.finance.common.BusinessException;
import net.lingala.zip4j.ZipFile;
import net.lingala.zip4j.exception.ZipException;
import net.lingala.zip4j.model.FileHeader;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/**
* 账单压缩包解压工具。
*
* <p>支付宝、微信导出的账单常以 zip 压缩包形式提供,本工具负责从压缩包中提取
* 待解析的账单文件字节:按期望扩展名匹配第一个文件,未匹配到时回退首个文件。
* 压缩包若已加密,先返回「需要密码」的错误码,待前端输入密码后再行解密。</p>
*
* @date 2026-09-18 23:26:58
*/
@Component
public class BillArchiveExtractor {
/** 压缩包已加密、需要用户输入密码的业务错误码 */
public static final int CODE_PASSWORD_REQUIRED = 4001;
/** 解压密码错误的业务错误码 */
public static final int CODE_PASSWORD_WRONG = 4002;
/** 允许解压的最大条目数,防止压缩包内文件过多 */
private static final int MAX_ENTRIES = 100;
/** 允许解压的最大字节数(50MB),防止 zip bomb */
private static final long MAX_TOTAL_SIZE = 50L * 1024 * 1024;
/**
* 判断字节内容是否为 zip 压缩包(按本地文件头魔数 PK\x03\x04)。
*
* @param content 文件字节内容
* @return 是 zip 压缩包返回 true
*/
public boolean isZip(byte[] content) {
return content != null
&& content.length >= 4
&& (content[0] & 0xFF) == 0x50
&& (content[1] & 0xFF) == 0x4B
&& (content[2] & 0xFF) == 0x03
&& (content[3] & 0xFF) == 0x04;
}
/**
* 判断压缩包内是否存在指定扩展名的文件。
*
* <p>xlsx 等 Office 文件本身也是 zip 结构,需借此区分「账单压缩包」与「原始账单文件」:
* 仅当压缩包内确实包含目标扩展名的账单文件时才按压缩包处理。</p>
*
* @param content 文件字节内容
* @param extension 目标文件扩展名(小写,含点,如 .csv、.xlsx)
* @return 存在匹配文件返回 true
*/
public boolean containsEntry(byte[] content, String extension) {
if (!isZip(content)) {
return false;
}
Path temp = null;
try {
temp = writeTemp(content);
List<FileHeader> headers = new ZipFile(temp.toFile()).getFileHeaders();
for (FileHeader header : headers) {
if (!header.isDirectory() && header.getFileName().toLowerCase().endsWith(extension)) {
return true;
}
}
} catch (IOException e) {
// 读取失败按非账单压缩包处理,交由原解析器给出更准确的错误
return false;
} finally {
deleteQuietly(temp);
}
return false;
}
/**
* 从压缩包中提取账单文件内容。
*
* <p>优先返回文件名以期望扩展名结尾的第一个文件;若均不匹配,则返回第一个非目录文件。
* 当目标文件已加密且未提供密码时抛出「需要密码」错误,密码错误时抛出对应错误。</p>
*
* @param content 压缩包字节内容
* @param expectedExtension 期望的文件扩展名(小写,含点,如 .csv、.xlsx)
* @param password 解压密码,未加密时可为空
* @return 提取出的账单文件字节内容
*/
public byte[] extract(byte[] content, String expectedExtension, String password) {
char[] pwd = (password == null || password.isBlank()) ? null : password.toCharArray();
Path temp = null;
try {
temp = writeTemp(content);
ZipFile zipFile = pwd == null ? new ZipFile(temp.toFile()) : new ZipFile(temp.toFile(), pwd);
FileHeader target = selectEntry(zipFile, expectedExtension);
if (target == null) {
throw new BusinessException("压缩包为空或未找到可解析的文件");
}
// 目标文件加密但未提供密码时,提示前端输入密码
if (target.isEncrypted() && pwd == null) {
throw new BusinessException(CODE_PASSWORD_REQUIRED, "压缩包已加密,请输入解压密码");
}
if (target.getUncompressedSize() > MAX_TOTAL_SIZE) {
throw new BusinessException("压缩包内文件过大,已拒绝解析");
}
try (InputStream in = zipFile.getInputStream(target)) {
return readLimited(in);
}
} catch (BusinessException e) {
throw e;
} catch (ZipException e) {
// 已提供密码仍解压失败,按密码错误处理
if (pwd != null) {
throw new BusinessException(CODE_PASSWORD_WRONG, "解压密码错误");
}
throw new BusinessException("压缩包解析失败:" + e.getMessage());
} catch (IOException e) {
throw new BusinessException("压缩包解析失败:" + e.getMessage());
} finally {
deleteQuietly(temp);
}
}
/**
* 将字节内容写入临时文件,供 zip4j 按文件随机访问(其不支持直接从内存读取)。
*
* @param content 字节内容
* @return 临时文件路径
* @throws IOException 写入失败
*/
private Path writeTemp(byte[] content) throws IOException {
Path temp = Files.createTempFile("bill-archive-", ".zip");
Files.write(temp, content);
return temp;
}
/**
* 静默删除临时文件。
*
* @param temp 临时文件路径,可为 null
*/
private void deleteQuietly(Path temp) {
if (temp == null) {
return;
}
try {
Files.deleteIfExists(temp);
} catch (IOException ignored) {
// 删除失败不影响解析结果
}
}
/**
* 选择待提取的条目:优先匹配期望扩展名,否则回退第一个非目录文件。
*
* @param zipFile 已打开的压缩包
* @param expectedExtension 期望的文件扩展名(小写,含点)
* @return 选中的条目,压缩包为空时返回 null
* @throws ZipException 读取条目列表失败
*/
private FileHeader selectEntry(ZipFile zipFile, String expectedExtension) throws ZipException {
FileHeader matched = null;
FileHeader fallback = null;
int count = 0;
for (FileHeader header : zipFile.getFileHeaders()) {
if (header.isDirectory()) {
continue;
}
if (++count > MAX_ENTRIES) {
throw new BusinessException("压缩包内文件过多,已拒绝解析");
}
if (fallback == null) {
fallback = header;
}
if (matched == null && header.getFileName().toLowerCase().endsWith(expectedExtension)) {
matched = header;
}
}
return matched != null ? matched : fallback;
}
/**
* 读取输入流并限制最大字节数,防止解压内容过大。
*
* @param in 输入流
* @return 读取到的字节内容
* @throws IOException 读取失败
*/
private byte[] readLimited(InputStream in) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[8192];
long total = 0;
int read;
while ((read = in.read(buffer)) != -1) {
total += read;
if (total > MAX_TOTAL_SIZE) {
throw new BusinessException("压缩包内文件过大,已拒绝解析");
}
out.write(buffer, 0, read);
}
return out.toByteArray();
}
}
@@ -0,0 +1,365 @@
package cn.rosercode.finance.importer;
import cn.rosercode.finance.enums.BillType;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 交通银行账单解析器。
*
* <p>解析交通银行「银行卡交易明细查询表」导出的 xls 文件。文件首行为标题,
* 第二行为卡号、姓名与查询区间等元信息,第三行为表头,之后为数据行。
* 金额分为支出、收入两列且以「--」表示空值,无交易单号,需按行生成去重标识。</p>
*
* @date 2026-09-19 02:18:33
*/
@Component
public class BocomBillParser implements BillParser {
/** 表头列名 */
private static final String COL_DATE = "记账日期";
private static final String COL_TIME = "交易时间";
private static final String COL_LOCATION = "交易地点";
private static final String COL_METHOD = "交易方式";
private static final String COL_EXPENSE = "支出金额";
private static final String COL_INCOME = "收入金额";
private static final String COL_BALANCE = "余额";
private static final String COL_COUNTERPARTY = "对方户名";
private static final String COL_ACCOUNT = "对方账户";
private static final String COL_BANK = "对方开户行";
private static final String COL_SUMMARY = "摘要";
/** 表头首列名称,用于定位表头行 */
private static final String HEADER_FIRST_COLUMN = COL_DATE;
/** 交行账单中表示「无内容」的占位符 */
private static final String PLACEHOLDER = "--";
/** 交易时间格式(如 2026-09-16 08:29:32) */
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/** 记账日期格式(如 2026-09-16) */
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
/** 生成去重标识时使用的时间格式 */
private static final DateTimeFormatter ID_TIME_FORMATTER = DateTimeFormatter.ofPattern("HHmmss");
/** 生成去重标识时使用的日期格式 */
private static final DateTimeFormatter ID_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
/** JSON 序列化器,用于生成原始完整数据 */
private final ObjectMapper objectMapper;
public BocomBillParser(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public BillType getBillType() {
return BillType.BOCOM;
}
/**
* {@inheritDoc}
*
* 定位表头后逐行读取数据,跳过缺少有效时间的空行。
*/
@Override
public List<ParsedBillRow> parse(byte[] content) throws IOException {
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(content))) {
Sheet sheet = workbook.getSheetAt(0);
int headerIndex = locateHeader(sheet);
if (headerIndex < 0) {
throw new IOException("未找到交通银行账单表头,请确认文件是否为交通银行导出的交易明细");
}
List<String> header = readHeader(sheet.getRow(headerIndex));
List<ParsedBillRow> rows = new ArrayList<>();
int rowNo = 0;
for (int i = headerIndex + 1; i <= sheet.getLastRowNum(); i++) {
Row row = sheet.getRow(i);
if (row == null || isEmptyRow(row, header.size())) {
continue;
}
rowNo++;
rows.add(toRow(rowNo, header, row));
}
return rows;
} catch (IOException e) {
throw e;
} catch (Exception e) {
throw new IOException("交通银行账单解析失败:" + e.getMessage(), e);
}
}
/**
* 定位表头行:首个单元格以「记账日期」开头的行
*
* @param sheet 工作表
* @return 表头行索引,未找到时返回 -1
*/
private int locateHeader(Sheet sheet) {
for (int i = sheet.getFirstRowNum(); i <= sheet.getLastRowNum(); i++) {
Row row = sheet.getRow(i);
if (row == null) {
continue;
}
String first = readString(row.getCell(0));
if (first != null && first.startsWith(HEADER_FIRST_COLUMN)) {
return i;
}
}
return -1;
}
/**
* 读取表头行的各列名称
*
* @param row 表头行
* @return 列名列表
*/
private List<String> readHeader(Row row) {
List<String> header = new ArrayList<>();
for (int c = 0; c < row.getLastCellNum(); c++) {
String value = readString(row.getCell(c));
header.add(value == null ? "" : value);
}
return header;
}
/**
* 判断整行是否为空(所有单元格均无内容)
*
* @param row 数据行
* @param colCount 列数
* @return 整行为空返回 true
*/
private boolean isEmptyRow(Row row, int colCount) {
for (int c = 0; c < colCount; c++) {
if (StringUtils.hasText(readString(row.getCell(c)))) {
return false;
}
}
return true;
}
/**
* 将单行数据映射为账单行对象,并推断类型、是否计入统计及默认勾选状态
*
* @param rowNo 行号
* @param header 表头字段列表
* @param row 数据行
* @return 解析后的账单行
*/
private ParsedBillRow toRow(int rowNo, List<String> header, Row row) {
ParsedBillRow parsed = new ParsedBillRow();
parsed.setRowNo(rowNo);
parsed.setRawData(buildRawData(header, row));
// 对方户名作为交易对方,摘要作为商品名称
parsed.setCounterparty(cleanText(get(header, row, COL_COUNTERPARTY)));
parsed.setProduct(cleanText(get(header, row, COL_SUMMARY)));
// 交易时间列含完整时间,缺失时回退记账日期
String timeStr = cleanText(get(header, row, COL_TIME));
String dateStr = cleanText(get(header, row, COL_DATE));
parsed.setTransactionTime(parseTime(timeStr, dateStr));
if (parsed.getTransactionTime() == null) {
parsed.setError("交易时间为空或格式无法解析");
}
// 支出、收入两列互斥,非「--」的一列决定类型与金额
BigDecimal expense = readAmount(get(header, row, COL_EXPENSE));
BigDecimal income = readAmount(get(header, row, COL_INCOME));
String type;
BigDecimal amount;
if (expense != null) {
type = "EXPENSE";
amount = expense;
} else if (income != null) {
type = "INCOME";
amount = income;
} else {
type = null;
amount = null;
}
parsed.setType(type);
parsed.setAmount(amount);
if (type == null && parsed.getError() == null) {
parsed.setError("收入与支出金额均为空");
}
// 银行流水默认计入统计
parsed.setIncludeInStats(true);
// 无交易单号,按日期时间、类型与金额生成去重标识
parsed.setExternalNo(buildExternalNo(parsed.getTransactionTime(), type, amount));
boolean importable = parsed.getError() == null
&& StringUtils.hasText(parsed.getExternalNo())
&& parsed.getTransactionTime() != null
&& amount != null
&& amount.compareTo(BigDecimal.ZERO) > 0
&& type != null;
parsed.setImportable(importable);
parsed.setSelected(importable);
return parsed;
}
/**
* 解析交易时间:优先使用完整交易时间,缺失时回退记账日期
*
* @param timeStr 交易时间文本
* @param dateStr 记账日期文本
* @return 交易时间,无法解析时返回 null
*/
private LocalDateTime parseTime(String timeStr, String dateStr) {
if (StringUtils.hasText(timeStr)) {
try {
return LocalDateTime.parse(timeStr, TIME_FORMATTER);
} catch (DateTimeParseException ignored) {
// 尝试回退记账日期
}
}
if (StringUtils.hasText(dateStr)) {
try {
return LocalDate.parse(dateStr, DATE_FORMATTER).atStartOfDay();
} catch (DateTimeParseException ignored) {
// 无法解析
}
}
return null;
}
/**
* 生成去重标识:银行流水无交易单号,用日期时间、收支类型与金额组合。
*
* @param time 交易时间
* @param type 收支类型
* @param amount 金额
* @return 去重标识,信息不足时返回 null
*/
private String buildExternalNo(LocalDateTime time, String type, BigDecimal amount) {
if (time == null || type == null || amount == null) {
return null;
}
String date = time.format(ID_DATE_FORMATTER);
String clock = time.format(ID_TIME_FORMATTER);
String value = amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
return "BOCOM_" + date + "_" + clock + "_" + type + "_" + value;
}
/**
* 将整行数据按表头顺序组装为结构化 JSON,保留全部列(含系统未使用的字段)
*
* @param header 表头字段列表
* @param row 数据行
* @return JSON 字符串,序列化失败时返回 null
*/
private String buildRawData(List<String> header, Row row) {
Map<String, String> map = new LinkedHashMap<>();
for (int c = 0; c < header.size(); c++) {
String key = header.get(c);
if (!StringUtils.hasText(key)) {
continue;
}
String value = readString(row.getCell(c));
map.put(key, value == null ? "" : value);
}
try {
return objectMapper.writeValueAsString(map);
} catch (JsonProcessingException e) {
// 序列化失败不影响正常导入,原始数据置空
return null;
}
}
/**
* 按表头列名取值
*
* @param header 表头字段列表
* @param row 数据行
* @param column 列名
* @return 字段值,列不存在时返回 null
*/
private String get(List<String> header, Row row, String column) {
int index = header.indexOf(column);
if (index < 0) {
return null;
}
return readString(row.getCell(index));
}
/**
* 读取金额:去除千分位逗号,「--」或空值返回 null
*
* @param value 金额文本
* @return 金额,无法解析时返回 null
*/
private BigDecimal readAmount(String value) {
String text = cleanText(value);
if (text == null) {
return null;
}
try {
return new BigDecimal(text.replace(",", ""));
} catch (NumberFormatException e) {
return null;
}
}
/**
* 读取单元格的文本值
*
* @param cell 单元格
* @return 文本值,空单元格返回 null
*/
private String readString(Cell cell) {
if (cell == null) {
return null;
}
CellType type = cell.getCellType();
if (type == CellType.STRING) {
return cell.getStringCellValue().trim();
}
if (type == CellType.NUMERIC) {
return BigDecimal.valueOf(cell.getNumericCellValue()).stripTrailingZeros().toPlainString();
}
if (type == CellType.BOOLEAN) {
return String.valueOf(cell.getBooleanCellValue());
}
return null;
}
/**
* 清洗文本:空白或占位符「--」视为无内容
*
* @param value 原始文本
* @return 清洗后的文本,无内容时返回 null
*/
private String cleanText(String value) {
if (!StringUtils.hasText(value) || PLACEHOLDER.equals(value)) {
return null;
}
return value;
}
}
@@ -0,0 +1,306 @@
package cn.rosercode.finance.importer;
import cn.rosercode.finance.enums.BillType;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVParser;
import org.apache.commons.csv.CSVRecord;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 招商银行账单解析器。
*
* <p>解析招商银行「交易流水」导出的 CSV 文件。文件为 UTF-8(含 BOM,兼容 GBK),
* 前几行以「#」开头的账号、日期范围等元信息,之后为表头与数据行,末尾为收支合计。
* 交易日期与交易时间分列,金额分为收入、支出两列,且无交易单号,需按行生成去重标识。</p>
*
* @date 2026-09-19 02:13:20
*/
@Component
public class CmbBillParser implements BillParser {
/** 表头列名 */
private static final String COL_DATE = "交易日期";
private static final String COL_TIME = "交易时间";
private static final String COL_INCOME = "收入";
private static final String COL_EXPENSE = "支出";
private static final String COL_BALANCE = "余额";
private static final String COL_TRADE_TYPE = "交易类型";
private static final String COL_REMARK = "交易备注";
/** 表头首列名称,用于定位表头行 */
private static final String HEADER_FIRST_COLUMN = COL_DATE;
/** 元信息与合计行的前缀,遇到即视为数据结束 */
private static final String META_PREFIX = "#";
/** 交易日期格式(如 20260610) */
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
/** 交易时间格式(如 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) {
this.objectMapper = objectMapper;
}
@Override
public BillType getBillType() {
return BillType.CMB;
}
/**
* {@inheritDoc}
*
* 定位表头后逐行解析数据,遇到元信息或合计行结束。
*/
@Override
public List<ParsedBillRow> parse(byte[] content) throws IOException {
String text = new String(content, detectCharset(content));
String[] lines = text.split("\r?\n", -1);
// 定位表头行:去除引号后以「交易日期」开头
int headerIndex = -1;
for (int i = 0; i < lines.length; i++) {
if (lines[i].replace("\"", "").trim().startsWith(HEADER_FIRST_COLUMN)) {
headerIndex = i;
break;
}
}
if (headerIndex < 0) {
throw new IOException("未找到招商银行账单表头,请确认文件是否为招商银行导出的交易流水");
}
List<String> header = parseLine(lines[headerIndex]);
List<ParsedBillRow> rows = new ArrayList<>();
int rowNo = 0;
for (int i = headerIndex + 1; i < lines.length; i++) {
String trimmed = lines[i].replace("\"", "").trim();
// 空行、元信息或合计行表示数据结束
if (trimmed.isEmpty() || trimmed.startsWith(META_PREFIX)) {
break;
}
List<String> values = parseLine(lines[i]);
if (values.size() < 2) {
continue;
}
rowNo++;
rows.add(toRow(rowNo, header, values));
}
return rows;
}
/**
* 将一行 CSV 文本解析为字段列表
*
* @param line CSV 行文本
* @return 字段列表
* @throws IOException 解析失败
*/
private List<String> parseLine(String line) throws IOException {
try (CSVParser parser = CSVParser.parse(line, CSV_FORMAT)) {
for (CSVRecord record : parser) {
List<String> values = new ArrayList<>(record.size());
for (String value : record) {
values.add(value);
}
return values;
}
}
return List.of();
}
/**
* 将单行数据映射为账单行对象,并推断类型、是否计入统计及默认勾选状态
*
* @param rowNo 行号
* @param header 表头字段列表
* @param values 数据字段列表
* @return 解析后的账单行
*/
private ParsedBillRow toRow(int rowNo, List<String> header, List<String> values) {
ParsedBillRow row = new ParsedBillRow();
row.setRowNo(rowNo);
row.setRawData(buildRawData(header, values));
// 交易备注作为交易对方,交易类型作为商品名称
row.setCounterparty(get(header, values, COL_REMARK));
row.setProduct(get(header, values, COL_TRADE_TYPE));
row.setTradeStatus(get(header, values, COL_TRADE_TYPE));
// 交易日期与交易时间分列,拼接为完整时间
String dateStr = get(header, values, COL_DATE);
String timeStr = get(header, values, COL_TIME);
if (StringUtils.hasText(dateStr)) {
try {
LocalDate date = LocalDate.parse(dateStr, DATE_FORMATTER);
LocalTime time = StringUtils.hasText(timeStr) ? LocalTime.parse(timeStr, TIME_FORMATTER) : LocalTime.MIDNIGHT;
row.setTransactionTime(LocalDateTime.of(date, time));
} catch (DateTimeParseException e) {
row.setError("交易时间格式无法解析:" + dateStr + " " + timeStr);
}
} else {
row.setError("交易日期为空");
}
// 收入、支出两列互斥,有值的一列决定类型与金额
String incomeStr = get(header, values, COL_INCOME);
String expenseStr = get(header, values, COL_EXPENSE);
String type;
String amountStr;
if (StringUtils.hasText(expenseStr)) {
type = "EXPENSE";
amountStr = expenseStr;
} else if (StringUtils.hasText(incomeStr)) {
type = "INCOME";
amountStr = incomeStr;
} else {
type = null;
amountStr = null;
}
row.setType(type);
if (amountStr != null) {
try {
row.setAmount(new BigDecimal(amountStr.replace(",", "")));
} catch (NumberFormatException e) {
row.setError("金额格式无法解析:" + amountStr);
}
} else if (row.getError() == null) {
row.setError("收入与支出金额均为空");
}
if (type == null && row.getError() == null) {
row.setError("无法识别收/支类型");
}
// 银行流水默认计入统计
row.setIncludeInStats(true);
// 无交易单号,按日期时间、类型与金额生成去重标识
row.setExternalNo(buildExternalNo(row.getTransactionTime(), type, row.getAmount()));
// 校验必要字段,决定是否可导入
boolean importable = row.getError() == null
&& StringUtils.hasText(row.getExternalNo())
&& row.getTransactionTime() != null
&& row.getAmount() != null
&& row.getAmount().compareTo(BigDecimal.ZERO) > 0
&& type != null;
row.setImportable(importable);
row.setSelected(importable);
return row;
}
/**
* 生成去重标识:银行流水无交易单号,用日期时间、收支类型与金额组合。
*
* @param time 交易时间
* @param type 收支类型
* @param amount 金额
* @return 去重标识,信息不足时返回 null
*/
private String buildExternalNo(LocalDateTime time, String type, BigDecimal amount) {
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;
}
/**
* 将整行数据按表头顺序组装为结构化 JSON,保留全部列(含系统未使用的字段)
*
* @param header 表头字段列表
* @param values 数据字段列表
* @return JSON 字符串,序列化失败时返回 null
*/
private String buildRawData(List<String> header, List<String> values) {
Map<String, String> map = new LinkedHashMap<>();
for (int i = 0; i < header.size(); i++) {
String key = header.get(i);
if (key == null || key.isEmpty()) {
continue;
}
String value = i < values.size() ? values.get(i) : null;
map.put(key, value == null ? "" : value.trim());
}
try {
return objectMapper.writeValueAsString(map);
} catch (JsonProcessingException e) {
// 序列化失败不影响正常导入,原始数据置空
return null;
}
}
/**
* 按表头列名取值
*
* @param header 表头字段列表
* @param values 数据字段列表
* @param column 列名
* @return 字段值,列不存在时返回 null
*/
private String get(List<String> header, List<String> values, String column) {
int index = header.indexOf(column);
if (index < 0 || index >= values.size()) {
return null;
}
String value = values.get(index);
return value == null ? null : value.trim();
}
/**
* 检测账单文件的字符编码:优先 UTF-8(含 BOM),否则回退 GBK
*
* @param content 文件字节内容
* @return 字符集
*/
private Charset detectCharset(byte[] content) {
// UTF-8 BOM
if (content.length >= 3
&& (content[0] & 0xFF) == 0xEF
&& (content[1] & 0xFF) == 0xBB
&& (content[2] & 0xFF) == 0xBF) {
return StandardCharsets.UTF_8;
}
// 严格尝试 UTF-8 解码,失败则判定为 GBK
try {
StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(content));
return StandardCharsets.UTF_8;
} catch (CharacterCodingException e) {
return Charset.forName("GBK");
}
}
}
@@ -0,0 +1,309 @@
package cn.rosercode.finance.importer;
import cn.rosercode.finance.enums.BillType;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVParser;
import org.apache.commons.csv.CSVRecord;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 美团账单解析器。
*
* <p>解析美团「交易账单明细」导出的 CSV 文件。文件为 UTF-8(含 BOM,兼容 GBK),
* 前几行为用户名、起止时间等元信息与统计信息,之后为「【美团交易账单明细列表】」标记、
* 表头与数据行。</p>
*
* @date 2026-09-18 23:34:05
*/
@Component
public class MeituanBillParser implements BillParser {
/** 表头列名 */
private static final String COL_CREATE_TIME = "交易创建时间";
private static final String COL_SUCCESS_TIME = "交易成功时间";
private static final String COL_TRADE_TYPE = "交易类型";
private static final String COL_PRODUCT = "订单标题";
private static final String COL_DIRECTION = "收/支";
private static final String COL_PAYMENT = "支付方式";
private static final String COL_ORDER_AMOUNT = "订单金额";
private static final String COL_PAID_AMOUNT = "实付金额";
private static final String COL_TRADE_NO = "交易单号";
private static final String COL_MERCHANT_NO = "商家单号";
private static final String COL_REMARK = "备注";
/** 表头首列名称,用于定位表头行 */
private static final String HEADER_FIRST_COLUMN = COL_CREATE_TIME;
/** 美团账单中的时间格式 */
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/** CSV 解析格式:逗号分隔并去除字段首尾空白 */
private static final CSVFormat CSV_FORMAT = CSVFormat.DEFAULT.builder().setTrim(true).build();
/** 金额中需要剔除的货币符号与分隔符 */
private static final String AMOUNT_STRIP_PATTERN = "[¥¥,\\s]";
/** JSON 序列化器,用于生成原始完整数据 */
private final ObjectMapper objectMapper;
public MeituanBillParser(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public BillType getBillType() {
return BillType.MEITUAN;
}
/**
* {@inheritDoc}
*
* 定位表头后逐行解析数据,无法解析出有效字段的行直接跳过。
*/
@Override
public List<ParsedBillRow> parse(byte[] content) throws IOException {
String text = new String(content, detectCharset(content));
String[] lines = text.split("\r?\n", -1);
// 定位表头行:以「交易创建时间」开头
int headerIndex = -1;
for (int i = 0; i < lines.length; i++) {
if (lines[i].startsWith(HEADER_FIRST_COLUMN)) {
headerIndex = i;
break;
}
}
if (headerIndex < 0) {
throw new IOException("未找到美团账单表头,请确认文件是否为美团导出的交易账单明细");
}
List<String> header = parseLine(lines[headerIndex]);
List<ParsedBillRow> rows = new ArrayList<>();
int rowNo = 0;
for (int i = headerIndex + 1; i < lines.length; i++) {
String trimmed = lines[i].trim();
if (trimmed.isEmpty()) {
continue;
}
List<String> values = parseLine(lines[i]);
if (values.size() < 2) {
continue;
}
rowNo++;
rows.add(toRow(rowNo, header, values));
}
return rows;
}
/**
* 将一行 CSV 文本解析为字段列表
*
* @param line CSV 行文本
* @return 字段列表
* @throws IOException 解析失败
*/
private List<String> parseLine(String line) throws IOException {
try (CSVParser parser = CSVParser.parse(line, CSV_FORMAT)) {
for (CSVRecord record : parser) {
List<String> values = new ArrayList<>(record.size());
for (String value : record) {
values.add(value);
}
return values;
}
}
return List.of();
}
/**
* 将单行数据映射为账单行对象,并推断类型、是否计入统计及默认勾选状态
*
* @param rowNo 行号
* @param header 表头字段列表
* @param values 数据字段列表
* @return 解析后的账单行
*/
private ParsedBillRow toRow(int rowNo, List<String> header, List<String> values) {
ParsedBillRow row = new ParsedBillRow();
row.setRowNo(rowNo);
row.setRawData(buildRawData(header, values));
// 交易单号可能带引号与制表符,去除所有空白字符后作为去重标识
row.setExternalNo(compact(get(header, values, COL_TRADE_NO)));
row.setProduct(get(header, values, COL_PRODUCT));
row.setTradeStatus(get(header, values, COL_TRADE_TYPE));
String direction = get(header, values, COL_DIRECTION);
row.setDirection(direction);
// 交易时间优先取交易成功时间,为空时回退交易创建时间
String successTime = get(header, values, COL_SUCCESS_TIME);
String createTime = get(header, values, COL_CREATE_TIME);
String timeStr = StringUtils.hasText(successTime) ? successTime : createTime;
if (StringUtils.hasText(timeStr)) {
try {
row.setTransactionTime(LocalDateTime.parse(timeStr, TIME_FORMATTER));
} catch (DateTimeParseException e) {
row.setError("交易时间格式无法解析:" + timeStr);
}
}
// 金额优先取实付金额,为空时回退订单金额
String paidAmount = get(header, values, COL_PAID_AMOUNT);
String orderAmount = get(header, values, COL_ORDER_AMOUNT);
String amountStr = StringUtils.hasText(paidAmount) ? paidAmount : orderAmount;
if (StringUtils.hasText(amountStr)) {
try {
row.setAmount(new BigDecimal(amountStr.replaceAll(AMOUNT_STRIP_PATTERN, "")));
} catch (NumberFormatException e) {
row.setError("金额格式无法解析:" + amountStr);
}
}
// 根据收/支与交易类型推断交易类型
boolean noStats = "不计收支".equals(direction);
String type = resolveType(direction, row.getTradeStatus());
row.setType(type);
if (type == null && row.getError() == null) {
row.setError("无法识别收/支类型:" + direction);
}
// 不计收支(充值、提现、退款关闭等)默认不计入统计
row.setIncludeInStats(!noStats);
// 校验必要字段,决定是否可导入
boolean importable = row.getError() == null
&& StringUtils.hasText(row.getExternalNo())
&& row.getTransactionTime() != null
&& row.getAmount() != null
&& row.getAmount().compareTo(BigDecimal.ZERO) > 0
&& type != null;
row.setImportable(importable);
// 不计收支行默认不勾选,用户可手动勾选
row.setSelected(importable && !noStats);
return row;
}
/**
* 将整行数据按表头顺序组装为结构化 JSON,保留全部列(含系统未使用的字段)
*
* @param header 表头字段列表
* @param values 数据字段列表
* @return JSON 字符串,序列化失败时返回 null
*/
private String buildRawData(List<String> header, List<String> values) {
Map<String, String> map = new LinkedHashMap<>();
for (int i = 0; i < header.size(); i++) {
String key = header.get(i);
if (key == null || key.isEmpty()) {
continue;
}
String value = i < values.size() ? values.get(i) : null;
map.put(key, value == null ? "" : value.trim());
}
try {
return objectMapper.writeValueAsString(map);
} catch (JsonProcessingException e) {
// 序列化失败不影响正常导入,原始数据置空
return null;
}
}
/**
* 推断交易类型
*
* @param direction 收/支标识
* @param tradeType 交易类型(支付、退款等)
* @return INCOME、EXPENSE,无法识别时返回 null
*/
private String resolveType(String direction, String tradeType) {
if ("支出".equals(direction)) {
return "EXPENSE";
}
if ("收入".equals(direction)) {
return "INCOME";
}
if ("不计收支".equals(direction)) {
// 退款类交易视为收入,其余(如交易关闭)按支出处理
if (StringUtils.hasText(tradeType) && tradeType.contains("退款")) {
return "INCOME";
}
return "EXPENSE";
}
return null;
}
/**
* 按表头列名取值
*
* @param header 表头字段列表
* @param values 数据字段列表
* @param column 列名
* @return 字段值,列不存在时返回 null
*/
private String get(List<String> header, List<String> values, String column) {
int index = header.indexOf(column);
if (index < 0 || index >= values.size()) {
return null;
}
String value = values.get(index);
return value == null ? null : value.trim();
}
/**
* 去除字符串中的所有空白字符(含引号字段内的制表符)
*
* @param value 原字符串
* @return 去除空白后的字符串,无内容时返回 null
*/
private String compact(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
String result = value.replaceAll("\\s", "");
return result.isEmpty() ? null : result;
}
/**
* 检测账单文件的字符编码:优先 UTF-8(含 BOM),否则回退 GBK
*
* @param content 文件字节内容
* @return 字符集
*/
private Charset detectCharset(byte[] content) {
// UTF-8 BOM
if (content.length >= 3
&& (content[0] & 0xFF) == 0xEF
&& (content[1] & 0xFF) == 0xBB
&& (content[2] & 0xFF) == 0xBF) {
return StandardCharsets.UTF_8;
}
// 严格尝试 UTF-8 解码,失败则判定为 GBK
try {
StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(content));
return StandardCharsets.UTF_8;
} catch (CharacterCodingException e) {
return Charset.forName("GBK");
}
}
}
@@ -17,13 +17,16 @@ public interface BillImportService {
/** /**
* 解析账单文件,返回预览数据(不落库),并标记与系统已有记录重复的行。 * 解析账单文件,返回预览数据(不落库),并标记与系统已有记录重复的行。
* *
* <p>当文件为 zip 压缩包时,先解压出账单文件再解析;压缩包已加密时需提供密码。</p>
*
* @param userId 当前用户ID * @param userId 当前用户ID
* @param billType 账单类型 * @param billType 账单类型
* @param content 账单文件字节内容 * @param content 账单文件字节内容
* @param password 压缩包解压密码,未加密时可为空
* @return 解析结果 * @return 解析结果
* @throws IOException 文件解析失败 * @throws IOException 文件解析失败
*/ */
BillParseResultVO parse(Long userId, BillType billType, byte[] content) throws IOException; BillParseResultVO parse(Long userId, BillType billType, byte[] content, String password) throws IOException;
/** /**
* 导入用户勾选的账单行,按外部交易号去重并写入交易记录。 * 导入用户勾选的账单行,按外部交易号去重并写入交易记录。
@@ -49,6 +49,17 @@ public interface TransactionService {
*/ */
void delete(Long userId, Long id); void delete(Long userId, Long id);
/**
* 批量删除交易记录。
*
* <p>仅删除属于当前用户的记录,同时清理其标签关联。</p>
*
* @param userId 用户ID
* @param ids 待删除的交易记录ID列表
* @return 实际删除的记录数
*/
int batchDelete(Long userId, List<Long> ids);
/** /**
* 导出交易记录 * 导出交易记录
* *
@@ -93,4 +104,14 @@ public interface TransactionService {
* @return 涉及的记录数 * @return 涉及的记录数
*/ */
int batchTags(Long userId, BatchTagRequest request); int batchTags(Long userId, BatchTagRequest request);
/**
* 获取系统支持的支付方式编码列表。
*
* <p>由账单来源({@link cn.rosercode.finance.enums.BillType})与人工记账支付方式组成,
* 供前端筛选与编辑表单动态渲染。</p>
*
* @return 支付方式编码列表
*/
List<String> listPaymentMethods();
} }
@@ -9,6 +9,7 @@ import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.entity.Transaction; import cn.rosercode.finance.entity.Transaction;
import cn.rosercode.finance.entity.TransactionTag; import cn.rosercode.finance.entity.TransactionTag;
import cn.rosercode.finance.enums.BillType; import cn.rosercode.finance.enums.BillType;
import cn.rosercode.finance.importer.BillArchiveExtractor;
import cn.rosercode.finance.importer.BillParser; import cn.rosercode.finance.importer.BillParser;
import cn.rosercode.finance.importer.ParsedBillRow; import cn.rosercode.finance.importer.ParsedBillRow;
import cn.rosercode.finance.mapper.CategoryMapper; import cn.rosercode.finance.mapper.CategoryMapper;
@@ -56,6 +57,7 @@ public class BillImportServiceImpl implements BillImportService {
private final TransactionTagMapper transactionTagMapper; private final TransactionTagMapper transactionTagMapper;
private final RuleService ruleService; private final RuleService ruleService;
private final VoConverter voConverter; private final VoConverter voConverter;
private final BillArchiveExtractor archiveExtractor;
public BillImportServiceImpl(List<BillParser> parsers, public BillImportServiceImpl(List<BillParser> parsers,
TransactionMapper transactionMapper, TransactionMapper transactionMapper,
@@ -63,7 +65,8 @@ public class BillImportServiceImpl implements BillImportService {
TagMapper tagMapper, TagMapper tagMapper,
TransactionTagMapper transactionTagMapper, TransactionTagMapper transactionTagMapper,
RuleService ruleService, RuleService ruleService,
VoConverter voConverter) { VoConverter voConverter,
BillArchiveExtractor archiveExtractor) {
// 按账单类型注册解析器,便于扩展新账单来源 // 按账单类型注册解析器,便于扩展新账单来源
for (BillParser parser : parsers) { for (BillParser parser : parsers) {
parserMap.put(parser.getBillType(), parser); parserMap.put(parser.getBillType(), parser);
@@ -74,6 +77,7 @@ public class BillImportServiceImpl implements BillImportService {
this.transactionTagMapper = transactionTagMapper; this.transactionTagMapper = transactionTagMapper;
this.ruleService = ruleService; this.ruleService = ruleService;
this.voConverter = voConverter; this.voConverter = voConverter;
this.archiveExtractor = archiveExtractor;
} }
/** /**
@@ -82,13 +86,19 @@ public class BillImportServiceImpl implements BillImportService {
* 解析后批量查询已存在的交易号,标记重复行并取消其默认勾选。 * 解析后批量查询已存在的交易号,标记重复行并取消其默认勾选。
*/ */
@Override @Override
public BillParseResultVO parse(Long userId, BillType billType, byte[] content) throws IOException { public BillParseResultVO parse(Long userId, BillType billType, byte[] content, String password) throws IOException {
BillParser parser = parserMap.get(billType); BillParser parser = parserMap.get(billType);
if (parser == null) { if (parser == null) {
throw new BusinessException("暂不支持的账单类型:" + billType); throw new BusinessException("暂不支持的账单类型:" + billType);
} }
List<ParsedBillRow> rows = parser.parse(content); // 压缩包先解压出账单文件,再交给对应解析器;xlsx 等本身是 zip,需确认包内含目标文件
String extension = expectedExtension(billType);
byte[] fileContent = archiveExtractor.containsEntry(content, extension)
? archiveExtractor.extract(content, extension, password)
: content;
List<ParsedBillRow> rows = parser.parse(fileContent);
// 批量查询已存在的交易号,用于标记重复 // 批量查询已存在的交易号,用于标记重复
List<String> externalNos = rows.stream() List<String> externalNos = rows.stream()
@@ -114,6 +124,22 @@ public class BillImportServiceImpl implements BillImportService {
return vo; return vo;
} }
/**
* 获取账单类型对应的账单文件扩展名,用于从压缩包中匹配文件。
*
* @param billType 账单类型
* @return 扩展名(小写,含点)
*/
private String expectedExtension(BillType billType) {
if (billType == BillType.WECHAT) {
return ".xlsx";
}
if (billType == BillType.BOCOM) {
return ".xls";
}
return ".csv";
}
/** /**
* {@inheritDoc} * {@inheritDoc}
* *
@@ -14,6 +14,7 @@ import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.Tag; import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.entity.Transaction; import cn.rosercode.finance.entity.Transaction;
import cn.rosercode.finance.entity.TransactionTag; import cn.rosercode.finance.entity.TransactionTag;
import cn.rosercode.finance.enums.BillType;
import cn.rosercode.finance.mapper.CategoryMapper; import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TagMapper; import cn.rosercode.finance.mapper.TagMapper;
import cn.rosercode.finance.mapper.TransactionMapper; import cn.rosercode.finance.mapper.TransactionMapper;
@@ -244,6 +245,34 @@ public class TransactionServiceImpl implements TransactionService {
transactionMapper.deleteById(id); transactionMapper.deleteById(id);
} }
/**
* {@inheritDoc}
*
* 仅删除归属当前用户的记录,先清理标签关联再批量删除,返回实际删除数。
*/
@Override
@Transactional
public int batchDelete(Long userId, List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return 0;
}
// 仅保留属于当前用户的记录ID
List<Long> ownedIds = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.select(Transaction::getId)
.eq(Transaction::getUserId, userId)
.in(Transaction::getId, ids))
.stream()
.map(Transaction::getId)
.collect(Collectors.toList());
if (ownedIds.isEmpty()) {
return 0;
}
// 先清理标签关联,再批量删除记录
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
.in(TransactionTag::getTransactionId, ownedIds));
return transactionMapper.deleteByIds(ownedIds);
}
/** /**
* {@inheritDoc} * {@inheritDoc}
* *
@@ -650,7 +679,27 @@ public class TransactionServiceImpl implements TransactionService {
case "ALIPAY" -> "支付宝"; case "ALIPAY" -> "支付宝";
case "CASH" -> "现金"; case "CASH" -> "现金";
case "BANK" -> "银行卡"; case "BANK" -> "银行卡";
case "MEITUAN" -> "美团";
case "CMB" -> "招商银行";
case "BOCOM" -> "交通银行";
default -> code; default -> code;
}; };
} }
/**
* {@inheritDoc}
*
* 由账单来源枚举(支付宝、微信、美团、招商银行、交通银行)与人工记账支付方式
* (现金)组成,账单来源在前,人工支付方式在后。
*/
@Override
public List<String> listPaymentMethods() {
List<String> methods = new ArrayList<>();
for (BillType type : BillType.values()) {
methods.add(type.name());
}
// 人工记账使用的支付方式,非账单来源
methods.add("CASH");
return methods;
}
} }