diff --git a/build.gradle b/build.gradle index d9a671f..1f67785 100644 --- a/build.gradle +++ b/build.gradle @@ -39,6 +39,8 @@ dependencies { implementation 'com.alibaba:easyexcel:4.0.3' 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' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index 90e2f0e..0c3a446 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -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 }, diff --git a/frontend/src/api/transaction.ts b/frontend/src/api/transaction.ts index b5d9269..4f4b84e 100644 --- a/frontend/src/api/transaction.ts +++ b/frontend/src/api/transaction.ts @@ -3,6 +3,11 @@ import request from './request' import axios from 'axios' import { useUserStore } from '../store/user' +/** 获取系统支持的支付方式编码列表(含账单导入来源与人工支付方式) */ +export function getPaymentMethods() { + return request.get('/transaction/payment-methods') +} + /** 分页获取交易记录列表,支持按类型、分类、支付方式、时间范围、计入统计、金额区间多选筛选 */ export function getTransactionPage(params: { page?: number @@ -57,6 +62,11 @@ export function deleteTransaction(id: number) { return request.delete(`/transaction/${id}`) } +/** 批量删除交易记录,返回实际删除数量 */ +export function batchDeleteTransactions(ids: number[]) { + return request.delete('/transaction/batch', { data: { ids } }) +} + /** 切换指定交易记录的"计入统计"标记 */ export function toggleTransactionStats(id: number, includeInStats: boolean) { 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 }) } -/** 支持的账单类型,当前支持支付宝与微信 */ -export type BillType = 'ALIPAY' | 'WECHAT' +/** 支持的账单类型,当前支持支付宝、微信、美团、招商银行与交通银行 */ +export type BillType = 'ALIPAY' | 'WECHAT' | 'MEITUAN' | 'CMB' | 'BOCOM' /** 账单解析结果行 */ export interface ParsedBillRow { @@ -106,11 +116,14 @@ export interface BillImportItem { rawData: string | null } -/** 解析账单文件(不落库),返回预览数据 */ -export function parseBill(file: File, billType: BillType) { +/** 解析账单文件(不落库),返回预览数据;支持 zip 压缩包,加密时需传解压密码 */ +export function parseBill(file: File, billType: BillType, password?: string) { const formData = new FormData() formData.append('file', file) formData.append('billType', billType) + if (password) { + formData.append('password', password) + } return request.post('/transaction/import/parse', formData) } diff --git a/frontend/src/locales/messages.ts b/frontend/src/locales/messages.ts index 47f4091..045897f 100644 --- a/frontend/src/locales/messages.ts +++ b/frontend/src/locales/messages.ts @@ -37,6 +37,9 @@ export default { alipay: { zh: '支付宝', en: 'Alipay', ja: 'Alipay' }, cash: { zh: '现金', en: 'Cash', 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: '現在のテーブルに設定できる記録がありません' }, 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} 件を更新しました' }, + 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: 'エクスポートしました' }, exportFailed: { zh: '导出失败', en: 'Export failed', ja: 'エクスポートに失敗しました' }, exportFileName: { zh: '交易记录', en: 'transactions', ja: '取引記録' }, @@ -468,12 +475,18 @@ export default { billType: { zh: '账单类型:', en: 'Bill type:', ja: '明細の種類:' }, billTypeALIPAY: { zh: '支付宝', en: 'Alipay', ja: 'Alipay' }, 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: '明細ファイル:' }, 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: '、' }, exportHintALIPAY: { zh: '导出路径:支付宝 App → 我的 → 账单 → 开具交易流水证明 / 账单导出,选择 CSV 格式。', en: 'Export path: Alipay App → Me → Bills → Issue Transaction Record / Export Bill, choose CSV format.', ja: 'エクスポート手順:Alipay アプリ → マイ → 請求 → 取引履歴証明の発行 / 明細のエクスポート で CSV 形式を選択。' }, exportHintWECHAT: { zh: '导出路径:微信 App → 我 → 服务 → 钱包 → 账单 → 常见问题 → 下载账单 → 用于个人对账,选择 xlsx 格式。', en: 'Export path: WeChat App → Me → Services → Wallet → Bills → FAQ → Download Bills → For personal reconciliation, choose xlsx format.', ja: 'エクスポート手順:WeChat アプリ → 自分 → サービス → ウォレット → 請求 → よくある質問 → 請求書のダウンロード → 個人の照合用 で xlsx 形式を選択。' }, + exportHintMEITUAN: { zh: '导出路径:美团 App → 我的 → 钱包 → 账单 → 开具账单证明(申请记录),选择 CSV 格式并设置解压密码。', en: 'Export path: Meituan App → Me → Wallet → Bills → Issue Bill Certificate (Application Record), choose CSV format and set an archive password.', ja: 'エクスポート手順:Meituan アプリ → マイ → ウォレット → 請求 → 請求証明の発行(申請履歴)で CSV 形式を選択し、解凍パスワードを設定。' }, + exportHintCMB: { zh: '导出路径:招商银行 App → 我的 → 全部 → 交易流水,选择 CSV 格式导出(如需压缩包请设置解压密码)。', en: 'Export path: China Merchants Bank App → Me → All → Transaction Records, choose CSV format (set an archive password if zipped).', ja: 'エクスポート手順:招商銀行アプリ → マイ → すべて → 取引明細 で CSV 形式を選択(圧縮する場合は解凍パスワードを設定)。' }, + exportHintBOCOM: { zh: '导出路径:交通银行 App → 我的 → 银行卡 → 交易明细,选择查询时间段后导出 Excel(xls)。', en: 'Export path: Bank of Communications App → Me → Bank Cards → Transaction Details, choose a date range and export Excel (xls).', ja: 'エクスポート手順:交通銀行アプリ → マイ → 銀行カード → 取引明細 で期間を選択し Excel(xls)でエクスポート。' }, parse: { zh: '解析', en: 'Parse', ja: '解析' }, expenseDefaultCategory: { zh: '支出默认分类(可不选):', en: 'Default expense category (optional):', ja: '支出の既定カテゴリ(任意):' }, incomeDefaultCategory: { zh: '收入默认分类(可不选):', en: 'Default income category (optional):', ja: '収入の既定カテゴリ(任意):' }, @@ -517,6 +530,11 @@ export default { importFailed: { zh: '导入失败', en: 'Import failed', ja: 'インポートに失敗しました' }, loadCategoriesFailed: { zh: '加载分类失败', en: 'Failed to load categories', 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 智能建议 diff --git a/frontend/src/views/BillImportPanel.vue b/frontend/src/views/BillImportPanel.vue index 5d6c1d5..694f32f 100644 --- a/frontend/src/views/BillImportPanel.vue +++ b/frontend/src/views/BillImportPanel.vue @@ -5,7 +5,7 @@ import { ref, computed, h, watch, onMounted } from 'vue' import { useI18n } from 'vue-i18n' import { 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' import type { DataTableColumns, UploadFileInfo } from 'naive-ui' import { CloudUploadOutline } from '@vicons/ionicons5' @@ -25,25 +25,49 @@ const { t } = useI18n() // 国际化 // 账单类型偏好存储键 const BILL_TYPE_PREFS_KEY = 'finance.billImport.billType' -// 账单类型选项,当前支持支付宝与微信 +// 账单类型选项,当前支持支付宝、微信、美团、招商银行与交通银行 const billTypeOptions = computed(() => [ { label: t('import.billTypeALIPAY'), value: 'ALIPAY' }, { 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 = { + ALIPAY: 'import.billTypeALIPAY', + WECHAT: 'import.billTypeWECHAT', + MEITUAN: 'import.billTypeMEITUAN', + CMB: 'import.billTypeCMB', + BOCOM: 'import.billTypeBOCOM', +} +const billTypeExportHintKeys: Record = { + ALIPAY: 'import.exportHintALIPAY', + WECHAT: 'import.exportHintWECHAT', + MEITUAN: 'import.exportHintMEITUAN', + CMB: 'import.exportHintCMB', + BOCOM: 'import.exportHintBOCOM', +} + /** 读取上次选择的账单类型偏好,非法值回退为支付宝 */ function loadBillTypePref(): BillType { 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(loadBillTypePref()) -// 各账单类型允许上传的文件扩展名 +// 各账单类型允许上传的文件扩展名(支持原始文件或 zip 压缩包) const allowedExtensions: Record = { - ALIPAY: ['.csv'], - WECHAT: ['.xlsx'], + ALIPAY: ['.csv', '.zip'], + WECHAT: ['.xlsx', '.zip'], + MEITUAN: ['.csv', '.zip'], + CMB: ['.csv', '.zip'], + BOCOM: ['.xls', '.zip'], } // 当前账单类型允许的扩展名,用于上传组件 accept 属性 @@ -51,15 +75,13 @@ const acceptTypes = computed(() => (allowedExtensions[billType.value] || []).joi // 当前账单类型的文件格式提示 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')) return t('import.fileHint', { label, exts }) }) // 当前账单类型的导出路径提示 -const exportHint = computed(() => - billType.value === 'WECHAT' ? t('import.exportHintWECHAT') : t('import.exportHintALIPAY'), -) +const exportHint = computed(() => t(billTypeExportHintKeys[billType.value])) // 面板阶段:选择文件 -> 预览勾选 -> 导入结果 const stage = ref<'select' | 'preview' | 'result'>('select') @@ -69,6 +91,10 @@ const uploadFileList = ref([]) const parsing = ref(false) const importing = ref(false) +// 压缩包解压密码弹窗状态 +const passwordModalVisible = ref(false) +const archivePassword = ref('') + // 解析结果与勾选状态 const rows = ref([]) const checkedRowNos = ref([]) @@ -295,7 +321,7 @@ function isAllowedFile(name: string) { function handleFileChange(options: { fileList: UploadFileInfo[] }) { const last = options.fileList[options.fileList.length - 1] 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')) message.error(t('import.errorFileType', { label, exts })) uploadFileList.value = [] @@ -306,27 +332,47 @@ function handleFileChange(options: { fileList: UploadFileInfo[] }) { file.value = last?.file ?? null } -/** 解析账单 */ -async function handleParse() { +/** 解析账单,password 用于加密压缩包(由密码弹窗传入) */ +async function handleParse(password?: string) { if (!file.value) { message.warning(t('import.selectFileFirst')) return } parsing.value = true 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 || [] // 默认勾选解析器建议勾选的行 checkedRowNos.value = rows.value.filter((r) => r.selected).map((r) => r.rowNo) resetFilters() stage.value = 'preview' + // 解析成功时关闭密码弹窗并清空密码 + passwordModalVisible.value = false + archivePassword.value = '' } catch (e: any) { - message.error(e.message || t('import.parseFailed')) + // 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')) + } } finally { 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) { if (checked) { @@ -389,6 +435,8 @@ function reset() { expenseCategoryId.value = null incomeCategoryId.value = null importTagIds.value = [] + passwordModalVisible.value = false + archivePassword.value = '' resetFilters() } @@ -450,7 +498,7 @@ onMounted(() => { {{ t('common.cancel') }} - {{ t('import.parse') }} + {{ t('import.parse') }} @@ -541,4 +589,30 @@ onMounted(() => { {{ t('import.done') }} + + + + + {{ t('import.passwordTip') }} + + + + diff --git a/frontend/src/views/TransactionList.vue b/frontend/src/views/TransactionList.vue index 9977f11..da9ae7b 100644 --- a/frontend/src/views/TransactionList.vue +++ b/frontend/src/views/TransactionList.vue @@ -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 { EyeOutline, EyeOffOutline, CloseCircleOutline, SparklesOutline, BulbOutline } from '@vicons/ionicons5' 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 type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai' import { getCategoryList, createCategory } from '../api/category' @@ -158,6 +158,7 @@ const editingId = ref(null) // 当前编辑的记录 ID(null const categories = ref([]) // 分类选项列表 const allTags = ref([]) // 全部标签(用于筛选、编辑、批量) const revealedRemarkIds = ref([]) // 临时展开查看原文的备注行 ID(不持久,刷新/翻页后重置) +const checkedTransactionIds = ref([]) // 表格多选选中的记录ID(用于批量删除) // 表单数据(新增/编辑共用) const form = ref({ @@ -177,13 +178,13 @@ const typeOptions = computed(() => [ { label: t('common.types.income'), value: 'INCOME' }, ]) -// 支付方式选项 -const paymentOptions = computed(() => [ - { label: t('common.payment.wechat'), value: 'WECHAT' }, - { label: t('common.payment.alipay'), value: 'ALIPAY' }, - { label: t('common.payment.cash'), value: 'CASH' }, - { label: t('common.payment.bank'), value: 'BANK' }, -]) +// 支付方式编码列表(从后端动态获取,含账单导入来源与人工支付方式) +const paymentMethodCodes = ref([]) + +// 支付方式选项:由后端返回的编码映射为当前语言标签 +const paymentOptions = computed(() => + paymentMethodCodes.value.map((code) => ({ label: getPaymentLabel(code), value: code })), +) // 筛选条件状态 const filterType = ref([]) // 类型筛选(多选) @@ -206,7 +207,6 @@ const showMoreFilter = ref(false) // 更多筛选弹层显示状态 // 更多筛选中已激活的条件数量 const moreFilterCount = computed(() => { let count = 0 - if (filterPaymentMethod.value.length > 0) count++ if (filterTag.value.length > 0) count++ if (filterIncludeInStats.value !== null) count++ if (filterMinAmount.value !== null || filterMaxAmount.value !== null) count++ @@ -215,7 +215,6 @@ const moreFilterCount = computed(() => { /** 清空「更多筛选」中的条件 */ function clearMoreFilters() { - filterPaymentMethod.value = [] filterTag.value = [] filterIncludeInStats.value = null filterMinAmount.value = null @@ -483,6 +482,8 @@ async function setRemarkHidden(row: TransactionItem, hidden: boolean) { /** 创建表格列配置 */ function createColumns(): DataTableColumns { return [ + // 多选列:用于批量操作(如批量删除) + { type: 'selection' }, { // 序号列:按分页计算全局序号 title: t('transaction.column.index'), key: 'index', width: 70, align: 'center', @@ -587,6 +588,16 @@ function createColumns(): DataTableColumns { const columns = computed(() => createColumns()) // 表格列配置(随语言切换响应式更新) +/** 从后端加载支付方式列表,供筛选与编辑表单使用 */ +async function loadPaymentMethods() { + try { + const res: any = await getPaymentMethods() + paymentMethodCodes.value = res.data || [] + } catch (e) { + // 静默处理,支付方式下拉保持为空 + } +} + /** 加载分类列表 */ async function fetchCategories() { try { @@ -630,6 +641,7 @@ async function fetchData() { transactions.value = res.data.records total.value = res.data.total revealedRemarkIds.value = [] + checkedTransactionIds.value = [] } catch (e: any) { message.error(e.message || t('common.loadFailed')) } 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) { page.value = p @@ -1558,6 +1595,7 @@ onMounted(() => { loadAiConfig() fetchCategories() fetchTags() + loadPaymentMethods() fetchData() }) @@ -1572,6 +1610,9 @@ onMounted(() => { {{ t('transaction.batchSet') }} + + {{ t('transaction.batchDelete') }} + {{ t('transaction.importBill') }} {{ t('transaction.exportExcel') }} @@ -1661,6 +1702,15 @@ onMounted(() => { @select="handleRemarkSelect" @blur="handleRemarkBlur" /> + -
-
{{ t('transaction.column.paymentMethod') }}
- -
{{ t('transaction.column.tags') }}
{ - + diff --git a/src/main/java/cn/rosercode/finance/controller/BillImportController.java b/src/main/java/cn/rosercode/finance/controller/BillImportController.java index d768070..04b1573 100644 --- a/src/main/java/cn/rosercode/finance/controller/BillImportController.java +++ b/src/main/java/cn/rosercode/finance/controller/BillImportController.java @@ -43,21 +43,23 @@ public class BillImportController { * 解析账单文件(不落库) * * @param request HTTP请求,从中获取用户ID - * @param file 账单文件 + * @param file 账单文件(支持 csv/xlsx 或 zip 压缩包) * @param billType 账单类型,如 ALIPAY + * @param password 压缩包解压密码,未加密时可不传 * @return 解析预览结果 * @throws IOException 文件读取失败 */ @PostMapping("/parse") - @Operation(summary = "解析账单文件", description = "解析账单文件并返回预览数据,不写入数据库") + @Operation(summary = "解析账单文件", description = "解析账单文件(支持 zip 压缩包)并返回预览数据,不写入数据库") public Result parse(HttpServletRequest request, @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"); if (file == null || file.isEmpty()) { throw new BusinessException("请选择账单文件"); } - return Result.success(billImportService.parse(userId, resolveBillType(billType), file.getBytes())); + return Result.success(billImportService.parse(userId, resolveBillType(billType), file.getBytes(), password)); } /** diff --git a/src/main/java/cn/rosercode/finance/controller/TransactionController.java b/src/main/java/cn/rosercode/finance/controller/TransactionController.java index 4c9ec62..ff015b7 100644 --- a/src/main/java/cn/rosercode/finance/controller/TransactionController.java +++ b/src/main/java/cn/rosercode/finance/controller/TransactionController.java @@ -4,6 +4,7 @@ import com.alibaba.excel.EasyExcel; import cn.rosercode.finance.common.PageResult; import cn.rosercode.finance.common.Result; import cn.rosercode.finance.dto.BatchCategoryRequest; +import cn.rosercode.finance.dto.BatchDeleteRequest; import cn.rosercode.finance.dto.BatchTagRequest; import cn.rosercode.finance.dto.TransactionQuery; import cn.rosercode.finance.dto.TransactionRequest; @@ -49,6 +50,17 @@ public class TransactionController { this.transactionService = transactionService; } + /** + * 获取系统支持的支付方式列表 + * + * @return 支付方式编码列表 + */ + @GetMapping("/payment-methods") + @Operation(summary = "获取支付方式列表", description = "返回系统支持的支付方式编码,含账单导入来源与人工支付方式") + public Result> paymentMethods() { + return Result.success(transactionService.listPaymentMethods()); + } + /** * 分页查询交易记录 * @@ -113,6 +125,21 @@ public class TransactionController { return Result.success(); } + /** + * 批量删除交易记录 + * + * @param request HTTP请求,从中获取用户ID + * @param body 批量删除请求(记录ID列表) + * @return 实际删除的记录数 + */ + @DeleteMapping("/batch") + @Operation(summary = "批量删除交易记录", description = "根据记录ID列表批量删除当前用户的交易记录") + public Result batchDelete(HttpServletRequest request, + @RequestBody @Valid BatchDeleteRequest body) { + Long userId = (Long) request.getAttribute("userId"); + return Result.success(transactionService.batchDelete(userId, body.getIds())); + } + /** * 切换交易记录的"计入统计"标记 * diff --git a/src/main/java/cn/rosercode/finance/dto/BatchDeleteRequest.java b/src/main/java/cn/rosercode/finance/dto/BatchDeleteRequest.java new file mode 100644 index 0000000..0c936a4 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/dto/BatchDeleteRequest.java @@ -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 ids; +} diff --git a/src/main/java/cn/rosercode/finance/enums/BillType.java b/src/main/java/cn/rosercode/finance/enums/BillType.java index b2eab88..2f498f2 100644 --- a/src/main/java/cn/rosercode/finance/enums/BillType.java +++ b/src/main/java/cn/rosercode/finance/enums/BillType.java @@ -3,7 +3,7 @@ package cn.rosercode.finance.enums; /** * 账单类型枚举,标识支持的第三方账单来源。 * - *

当前支持支付宝与微信支付账单,后续可扩展银行等账单类型。

+ *

当前支持支付宝、微信支付、美团、招商银行与交通银行账单,后续可扩展其他银行等账单类型。

* * @date 2026-09-17 09:49:43 */ @@ -13,7 +13,16 @@ public enum BillType { ALIPAY("支付宝"), /** 微信支付账单 */ - WECHAT("微信"); + WECHAT("微信"), + + /** 美团账单 */ + MEITUAN("美团"), + + /** 招商银行账单 */ + CMB("招商银行"), + + /** 交通银行账单 */ + BOCOM("交通银行"); /** 账单类型的中文名称 */ private final String label; diff --git a/src/main/java/cn/rosercode/finance/importer/BillArchiveExtractor.java b/src/main/java/cn/rosercode/finance/importer/BillArchiveExtractor.java new file mode 100644 index 0000000..bb0fff9 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/importer/BillArchiveExtractor.java @@ -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; + +/** + * 账单压缩包解压工具。 + * + *

支付宝、微信导出的账单常以 zip 压缩包形式提供,本工具负责从压缩包中提取 + * 待解析的账单文件字节:按期望扩展名匹配第一个文件,未匹配到时回退首个文件。 + * 压缩包若已加密,先返回「需要密码」的错误码,待前端输入密码后再行解密。

+ * + * @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; + } + + /** + * 判断压缩包内是否存在指定扩展名的文件。 + * + *

xlsx 等 Office 文件本身也是 zip 结构,需借此区分「账单压缩包」与「原始账单文件」: + * 仅当压缩包内确实包含目标扩展名的账单文件时才按压缩包处理。

+ * + * @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 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; + } + + /** + * 从压缩包中提取账单文件内容。 + * + *

优先返回文件名以期望扩展名结尾的第一个文件;若均不匹配,则返回第一个非目录文件。 + * 当目标文件已加密且未提供密码时抛出「需要密码」错误,密码错误时抛出对应错误。

+ * + * @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(); + } +} diff --git a/src/main/java/cn/rosercode/finance/importer/BocomBillParser.java b/src/main/java/cn/rosercode/finance/importer/BocomBillParser.java new file mode 100644 index 0000000..adac8b4 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/importer/BocomBillParser.java @@ -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; + +/** + * 交通银行账单解析器。 + * + *

解析交通银行「银行卡交易明细查询表」导出的 xls 文件。文件首行为标题, + * 第二行为卡号、姓名与查询区间等元信息,第三行为表头,之后为数据行。 + * 金额分为支出、收入两列且以「--」表示空值,无交易单号,需按行生成去重标识。

+ * + * @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 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 header = readHeader(sheet.getRow(headerIndex)); + + List 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 readHeader(Row row) { + List 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 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 header, Row row) { + Map 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 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; + } +} diff --git a/src/main/java/cn/rosercode/finance/importer/CmbBillParser.java b/src/main/java/cn/rosercode/finance/importer/CmbBillParser.java new file mode 100644 index 0000000..d33e150 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/importer/CmbBillParser.java @@ -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; + +/** + * 招商银行账单解析器。 + * + *

解析招商银行「交易流水」导出的 CSV 文件。文件为 UTF-8(含 BOM,兼容 GBK), + * 前几行以「#」开头的账号、日期范围等元信息,之后为表头与数据行,末尾为收支合计。 + * 交易日期与交易时间分列,金额分为收入、支出两列,且无交易单号,需按行生成去重标识。

+ * + * @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 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 header = parseLine(lines[headerIndex]); + List 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 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 parseLine(String line) throws IOException { + try (CSVParser parser = CSVParser.parse(line, CSV_FORMAT)) { + for (CSVRecord record : parser) { + List 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 header, List 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 header, List values) { + Map 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 header, List 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"); + } + } +} diff --git a/src/main/java/cn/rosercode/finance/importer/MeituanBillParser.java b/src/main/java/cn/rosercode/finance/importer/MeituanBillParser.java new file mode 100644 index 0000000..3842d29 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/importer/MeituanBillParser.java @@ -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; + +/** + * 美团账单解析器。 + * + *

解析美团「交易账单明细」导出的 CSV 文件。文件为 UTF-8(含 BOM,兼容 GBK), + * 前几行为用户名、起止时间等元信息与统计信息,之后为「【美团交易账单明细列表】」标记、 + * 表头与数据行。

+ * + * @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 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 header = parseLine(lines[headerIndex]); + List 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 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 parseLine(String line) throws IOException { + try (CSVParser parser = CSVParser.parse(line, CSV_FORMAT)) { + for (CSVRecord record : parser) { + List 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 header, List 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 header, List values) { + Map 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 header, List 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"); + } + } +} diff --git a/src/main/java/cn/rosercode/finance/service/BillImportService.java b/src/main/java/cn/rosercode/finance/service/BillImportService.java index 3a50c0d..820753e 100644 --- a/src/main/java/cn/rosercode/finance/service/BillImportService.java +++ b/src/main/java/cn/rosercode/finance/service/BillImportService.java @@ -17,13 +17,16 @@ public interface BillImportService { /** * 解析账单文件,返回预览数据(不落库),并标记与系统已有记录重复的行。 * + *

当文件为 zip 压缩包时,先解压出账单文件再解析;压缩包已加密时需提供密码。

+ * * @param userId 当前用户ID * @param billType 账单类型 * @param content 账单文件字节内容 + * @param password 压缩包解压密码,未加密时可为空 * @return 解析结果 * @throws IOException 文件解析失败 */ - BillParseResultVO parse(Long userId, BillType billType, byte[] content) throws IOException; + BillParseResultVO parse(Long userId, BillType billType, byte[] content, String password) throws IOException; /** * 导入用户勾选的账单行,按外部交易号去重并写入交易记录。 diff --git a/src/main/java/cn/rosercode/finance/service/TransactionService.java b/src/main/java/cn/rosercode/finance/service/TransactionService.java index df2bca0..58e00d3 100644 --- a/src/main/java/cn/rosercode/finance/service/TransactionService.java +++ b/src/main/java/cn/rosercode/finance/service/TransactionService.java @@ -49,6 +49,17 @@ public interface TransactionService { */ void delete(Long userId, Long id); + /** + * 批量删除交易记录。 + * + *

仅删除属于当前用户的记录,同时清理其标签关联。

+ * + * @param userId 用户ID + * @param ids 待删除的交易记录ID列表 + * @return 实际删除的记录数 + */ + int batchDelete(Long userId, List ids); + /** * 导出交易记录 * @@ -93,4 +104,14 @@ public interface TransactionService { * @return 涉及的记录数 */ int batchTags(Long userId, BatchTagRequest request); + + /** + * 获取系统支持的支付方式编码列表。 + * + *

由账单来源({@link cn.rosercode.finance.enums.BillType})与人工记账支付方式组成, + * 供前端筛选与编辑表单动态渲染。

+ * + * @return 支付方式编码列表 + */ + List listPaymentMethods(); } diff --git a/src/main/java/cn/rosercode/finance/service/impl/BillImportServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/BillImportServiceImpl.java index fcc8eb4..199b246 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/BillImportServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/BillImportServiceImpl.java @@ -9,6 +9,7 @@ import cn.rosercode.finance.entity.Tag; import cn.rosercode.finance.entity.Transaction; import cn.rosercode.finance.entity.TransactionTag; import cn.rosercode.finance.enums.BillType; +import cn.rosercode.finance.importer.BillArchiveExtractor; import cn.rosercode.finance.importer.BillParser; import cn.rosercode.finance.importer.ParsedBillRow; import cn.rosercode.finance.mapper.CategoryMapper; @@ -56,6 +57,7 @@ public class BillImportServiceImpl implements BillImportService { private final TransactionTagMapper transactionTagMapper; private final RuleService ruleService; private final VoConverter voConverter; + private final BillArchiveExtractor archiveExtractor; public BillImportServiceImpl(List parsers, TransactionMapper transactionMapper, @@ -63,7 +65,8 @@ public class BillImportServiceImpl implements BillImportService { TagMapper tagMapper, TransactionTagMapper transactionTagMapper, RuleService ruleService, - VoConverter voConverter) { + VoConverter voConverter, + BillArchiveExtractor archiveExtractor) { // 按账单类型注册解析器,便于扩展新账单来源 for (BillParser parser : parsers) { parserMap.put(parser.getBillType(), parser); @@ -74,6 +77,7 @@ public class BillImportServiceImpl implements BillImportService { this.transactionTagMapper = transactionTagMapper; this.ruleService = ruleService; this.voConverter = voConverter; + this.archiveExtractor = archiveExtractor; } /** @@ -82,13 +86,19 @@ public class BillImportServiceImpl implements BillImportService { * 解析后批量查询已存在的交易号,标记重复行并取消其默认勾选。 */ @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); if (parser == null) { throw new BusinessException("暂不支持的账单类型:" + billType); } - List rows = parser.parse(content); + // 压缩包先解压出账单文件,再交给对应解析器;xlsx 等本身是 zip,需确认包内含目标文件 + String extension = expectedExtension(billType); + byte[] fileContent = archiveExtractor.containsEntry(content, extension) + ? archiveExtractor.extract(content, extension, password) + : content; + + List rows = parser.parse(fileContent); // 批量查询已存在的交易号,用于标记重复 List externalNos = rows.stream() @@ -114,6 +124,22 @@ public class BillImportServiceImpl implements BillImportService { 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} * diff --git a/src/main/java/cn/rosercode/finance/service/impl/TransactionServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/TransactionServiceImpl.java index e928691..7f56300 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/TransactionServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/TransactionServiceImpl.java @@ -14,6 +14,7 @@ import cn.rosercode.finance.entity.Category; import cn.rosercode.finance.entity.Tag; import cn.rosercode.finance.entity.Transaction; import cn.rosercode.finance.entity.TransactionTag; +import cn.rosercode.finance.enums.BillType; import cn.rosercode.finance.mapper.CategoryMapper; import cn.rosercode.finance.mapper.TagMapper; import cn.rosercode.finance.mapper.TransactionMapper; @@ -244,6 +245,34 @@ public class TransactionServiceImpl implements TransactionService { transactionMapper.deleteById(id); } + /** + * {@inheritDoc} + * + * 仅删除归属当前用户的记录,先清理标签关联再批量删除,返回实际删除数。 + */ + @Override + @Transactional + public int batchDelete(Long userId, List ids) { + if (ids == null || ids.isEmpty()) { + return 0; + } + // 仅保留属于当前用户的记录ID + List ownedIds = transactionMapper.selectList(new LambdaQueryWrapper() + .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() + .in(TransactionTag::getTransactionId, ownedIds)); + return transactionMapper.deleteByIds(ownedIds); + } + /** * {@inheritDoc} * @@ -650,7 +679,27 @@ public class TransactionServiceImpl implements TransactionService { case "ALIPAY" -> "支付宝"; case "CASH" -> "现金"; case "BANK" -> "银行卡"; + case "MEITUAN" -> "美团"; + case "CMB" -> "招商银行"; + case "BOCOM" -> "交通银行"; default -> code; }; } + + /** + * {@inheritDoc} + * + * 由账单来源枚举(支付宝、微信、美团、招商银行、交通银行)与人工记账支付方式 + * (现金)组成,账单来源在前,人工支付方式在后。 + */ + @Override + public List listPaymentMethods() { + List methods = new ArrayList<>(); + for (BillType type : BillType.values()) { + methods.add(type.name()); + } + // 人工记账使用的支付方式,非账单来源 + methods.add("CASH"); + return methods; + } }