From 9bc858eefc430f51821a7a947509ab6d11d08b47 Mon Sep 17 00:00:00 2001 From: rosercode Date: Sat, 19 Sep 2026 17:50:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=82=AE=E7=AE=B1=E8=B4=A6=E5=8D=95?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=8B=89=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增邮箱配置、发件人规则、待处理账单三张表(SQLite/MySQL 双脚本)及 CRUD 接口 - 按邮件发件人匹配账单类型,未命中忽略;发件人规则可配置解压密码与默认分类/标签 - 支持 IMAP IDLE 实时监听、定时兜底与手动立即检查;拉取范围可配置(仅新邮件/最近 N 天/最近 N 封) - 附件保存至本地 data/email-bill,解析预览后手动导入,复用账单解析与规则引擎 - 立即检查通过 SSE 实时推送执行进度 - 前端新增「邮箱账单」页(邮箱配置/发件人规则/待处理账单三 Tab),支持子路由与 Tab 偏好记忆,配置弹窗提供常用 IMAP 服务器候选项 - 新用户注册自动初始化默认发件人规则(支付宝/微信/美团/招商银行/交通银行) - 抽取共用账单预览与结果组件,供上传导入与邮箱账单复用 同时包含: - 统计新增口径筛选(仅计入/全部/仅不计入),支付方式改为后端动态获取 - 统一实体 createdAt/updatedAt 位置至 id 之后,调整部分日志级别 --- frontend/src/api/emailBill.ts | 191 ++++ frontend/src/api/statistics.ts | 2 + frontend/src/components/BillImportResult.vue | 31 + frontend/src/components/BillPreviewPanel.vue | 403 ++++++++ frontend/src/locales/messages.ts | 147 +++ frontend/src/router/index.ts | 14 + frontend/src/utils/payment.ts | 14 + frontend/src/views/BillImportPanel.vue | 413 +------- frontend/src/views/Dashboard.vue | 46 +- frontend/src/views/EmailBillManage.vue | 880 ++++++++++++++++++ frontend/src/views/Layout.vue | 60 +- frontend/src/views/TransactionList.vue | 10 +- .../common/GlobalExceptionHandler.java | 4 +- .../controller/EmailBillController.java | 340 +++++++ .../finance/converter/VoConverter.java | 40 + .../finance/dto/EmailBillConfigRequest.java | 57 ++ .../finance/dto/EmailBillParseRequest.java | 20 + .../dto/EmailBillSenderRuleRequest.java | 44 + .../finance/dto/StatisticsRangeQuery.java | 6 + .../finance/email/EmailAttachment.java | 29 + .../email/EmailAttachmentExtractor.java | 139 +++ .../finance/email/EmailBillMonitor.java | 220 +++++ .../email/EmailBillMonitorManager.java | 176 ++++ .../finance/email/EmailBillProcessor.java | 263 ++++++ .../finance/email/EmailBillPuller.java | 341 +++++++ .../email/EmailBillSenderRuleMatcher.java | 72 ++ .../finance/email/EmailBillStorage.java | 108 +++ .../finance/email/EmailImapConnector.java | 53 ++ .../cn/rosercode/finance/entity/AiConfig.java | 12 +- .../cn/rosercode/finance/entity/AiUsage.java | 6 +- .../cn/rosercode/finance/entity/Category.java | 6 +- .../finance/entity/EmailBillConfig.java | 87 ++ .../finance/entity/EmailBillMessage.java | 73 ++ .../finance/entity/EmailBillSenderRule.java | 61 ++ .../cn/rosercode/finance/entity/HttpLog.java | 6 +- .../cn/rosercode/finance/entity/LoginLog.java | 6 +- .../finance/entity/NotificationChannel.java | 12 +- .../finance/entity/NotificationConfig.java | 12 +- .../finance/entity/NotificationLog.java | 6 +- .../cn/rosercode/finance/entity/Rule.java | 12 +- .../cn/rosercode/finance/entity/RuleTag.java | 6 +- .../java/cn/rosercode/finance/entity/Tag.java | 6 +- .../rosercode/finance/entity/Transaction.java | 12 +- .../finance/entity/TransactionTag.java | 6 +- .../cn/rosercode/finance/entity/User.java | 12 +- .../finance/enums/EmailBillStatus.java | 37 + .../finance/mapper/EmailBillConfigMapper.java | 15 + .../mapper/EmailBillMessageMapper.java | 15 + .../mapper/EmailBillSenderRuleMapper.java | 15 + .../finance/scheduler/EmailBillScheduler.java | 76 ++ .../service/EmailBillCheckStreamService.java | 147 +++ .../service/EmailBillConfigService.java | 82 ++ .../service/EmailBillMessageService.java | 64 ++ .../service/EmailBillSenderRuleService.java | 66 ++ .../service/impl/DefaultDataInitializer.java | 65 +- .../impl/EmailBillConfigServiceImpl.java | 416 +++++++++ .../impl/EmailBillMessageServiceImpl.java | 243 +++++ .../impl/EmailBillSenderRuleServiceImpl.java | 307 ++++++ .../service/impl/StatisticsServiceImpl.java | 15 +- .../service/impl/TransactionServiceImpl.java | 4 +- .../finance/vo/EmailBillConfigVO.java | 75 ++ .../finance/vo/EmailBillMessageVO.java | 47 + .../finance/vo/EmailBillSenderRuleVO.java | 51 + src/main/resources/sql/init.sql | 109 ++- src/main/resources/sql/schema-sqlite.sql | 156 +++- 65 files changed, 5940 insertions(+), 529 deletions(-) create mode 100644 frontend/src/api/emailBill.ts create mode 100644 frontend/src/components/BillImportResult.vue create mode 100644 frontend/src/components/BillPreviewPanel.vue create mode 100644 frontend/src/utils/payment.ts create mode 100644 frontend/src/views/EmailBillManage.vue create mode 100644 src/main/java/cn/rosercode/finance/controller/EmailBillController.java create mode 100644 src/main/java/cn/rosercode/finance/dto/EmailBillConfigRequest.java create mode 100644 src/main/java/cn/rosercode/finance/dto/EmailBillParseRequest.java create mode 100644 src/main/java/cn/rosercode/finance/dto/EmailBillSenderRuleRequest.java create mode 100644 src/main/java/cn/rosercode/finance/email/EmailAttachment.java create mode 100644 src/main/java/cn/rosercode/finance/email/EmailAttachmentExtractor.java create mode 100644 src/main/java/cn/rosercode/finance/email/EmailBillMonitor.java create mode 100644 src/main/java/cn/rosercode/finance/email/EmailBillMonitorManager.java create mode 100644 src/main/java/cn/rosercode/finance/email/EmailBillProcessor.java create mode 100644 src/main/java/cn/rosercode/finance/email/EmailBillPuller.java create mode 100644 src/main/java/cn/rosercode/finance/email/EmailBillSenderRuleMatcher.java create mode 100644 src/main/java/cn/rosercode/finance/email/EmailBillStorage.java create mode 100644 src/main/java/cn/rosercode/finance/email/EmailImapConnector.java create mode 100644 src/main/java/cn/rosercode/finance/entity/EmailBillConfig.java create mode 100644 src/main/java/cn/rosercode/finance/entity/EmailBillMessage.java create mode 100644 src/main/java/cn/rosercode/finance/entity/EmailBillSenderRule.java create mode 100644 src/main/java/cn/rosercode/finance/enums/EmailBillStatus.java create mode 100644 src/main/java/cn/rosercode/finance/mapper/EmailBillConfigMapper.java create mode 100644 src/main/java/cn/rosercode/finance/mapper/EmailBillMessageMapper.java create mode 100644 src/main/java/cn/rosercode/finance/mapper/EmailBillSenderRuleMapper.java create mode 100644 src/main/java/cn/rosercode/finance/scheduler/EmailBillScheduler.java create mode 100644 src/main/java/cn/rosercode/finance/service/EmailBillCheckStreamService.java create mode 100644 src/main/java/cn/rosercode/finance/service/EmailBillConfigService.java create mode 100644 src/main/java/cn/rosercode/finance/service/EmailBillMessageService.java create mode 100644 src/main/java/cn/rosercode/finance/service/EmailBillSenderRuleService.java create mode 100644 src/main/java/cn/rosercode/finance/service/impl/EmailBillConfigServiceImpl.java create mode 100644 src/main/java/cn/rosercode/finance/service/impl/EmailBillMessageServiceImpl.java create mode 100644 src/main/java/cn/rosercode/finance/service/impl/EmailBillSenderRuleServiceImpl.java create mode 100644 src/main/java/cn/rosercode/finance/vo/EmailBillConfigVO.java create mode 100644 src/main/java/cn/rosercode/finance/vo/EmailBillMessageVO.java create mode 100644 src/main/java/cn/rosercode/finance/vo/EmailBillSenderRuleVO.java diff --git a/frontend/src/api/emailBill.ts b/frontend/src/api/emailBill.ts new file mode 100644 index 0000000..0a39877 --- /dev/null +++ b/frontend/src/api/emailBill.ts @@ -0,0 +1,191 @@ +// 邮箱账单相关 API:IMAP 配置、发件人规则与待处理账单的解析导入 +import request from './request' +import type { BillType, ParsedBillRow, BillImportItem } from './transaction' + +/** 邮箱配置 */ +export interface EmailBillConfig { + id: number + name: string + host: string + port: number + ssl: boolean + username: string + hasPassword: boolean + folder: string + enabled: boolean + status: string + lastError: string | null + lastConnectedAt: string | null + lastUid: number | null + expenseCategoryId: number | null + incomeCategoryId: number | null + tagIds: number[] + fetchScope: string + fetchLimit: number | null + createdAt: string + updatedAt: string +} + +/** 发件人识别规则 */ +export interface EmailBillSenderRule { + id: number + name: string + enabled: boolean + sortOrder: number + senderPatterns: string[] + billType: BillType + archivePassword: string | null + expenseCategoryId: number | null + incomeCategoryId: number | null + tagIds: number[] + createdAt: string + updatedAt: string +} + +/** 待处理账单消息 */ +export interface EmailBillMessage { + id: number + configId: number + uid: number + subject: string | null + fromAddress: string | null + receivedAt: string | null + attachmentName: string | null + billType: BillType | null + status: string + error: string | null + createdAt: string +} + +/** 邮箱配置保存请求 */ +export interface EmailBillConfigPayload { + name: string + host: string + port: number + ssl: boolean + username: string + password?: string + folder: string + enabled: boolean + expenseCategoryId?: number | null + incomeCategoryId?: number | null + tagIds?: number[] + fetchScope?: string + fetchLimit?: number | null +} + +/** 发件人规则保存请求 */ +export interface EmailBillSenderRulePayload { + name: string + enabled: boolean + sortOrder: number + senderPatterns: string[] + billType: BillType + archivePassword?: string | null + expenseCategoryId?: number | null + incomeCategoryId?: number | null + tagIds?: number[] +} + +/** 解析结果 */ +export interface EmailBillParseResult { + billType: string + total: number + rows: ParsedBillRow[] +} + +/** 查询邮箱配置列表 */ +export function listEmailBillConfigs() { + return request.get('/email-bill/configs') +} + +/** 新增邮箱配置 */ +export function createEmailBillConfig(data: EmailBillConfigPayload) { + return request.post('/email-bill/configs', data) +} + +/** 更新邮箱配置,授权码留空表示不修改 */ +export function updateEmailBillConfig(id: number, data: EmailBillConfigPayload) { + return request.put(`/email-bill/configs/${id}`, data) +} + +/** 删除邮箱配置 */ +export function deleteEmailBillConfig(id: number) { + return request.delete(`/email-bill/configs/${id}`) +} + +/** 启用或停用邮箱配置 */ +export function toggleEmailBillConfig(id: number, enabled: boolean) { + return request.put(`/email-bill/configs/${id}/enabled`, { enabled }) +} + +/** 测试邮箱连接 */ +export function testEmailBillConfig(id: number) { + return request.post(`/email-bill/configs/${id}/test`, {}, { timeout: 60000 }) +} + +/** 立即检查邮箱并增量拉取 */ +export function checkEmailBillConfig(id: number) { + return request.post(`/email-bill/configs/${id}/check`, {}, { timeout: 120000 }) +} + +/** 查询发件人规则列表 */ +export function listEmailBillSenderRules() { + return request.get('/email-bill/sender-rules') +} + +/** 新增发件人规则 */ +export function createEmailBillSenderRule(data: EmailBillSenderRulePayload) { + return request.post('/email-bill/sender-rules', data) +} + +/** 更新发件人规则 */ +export function updateEmailBillSenderRule(id: number, data: EmailBillSenderRulePayload) { + return request.put(`/email-bill/sender-rules/${id}`, data) +} + +/** 删除发件人规则 */ +export function deleteEmailBillSenderRule(id: number) { + return request.delete(`/email-bill/sender-rules/${id}`) +} + +/** 启用或停用发件人规则 */ +export function toggleEmailBillSenderRule(id: number, enabled: boolean) { + return request.put(`/email-bill/sender-rules/${id}/enabled`, { enabled }) +} + +/** 分页查询账单消息 */ +export function pageEmailBillMessages(params: { + status?: string + configId?: number + page?: number + size?: number +}) { + return request.get('/email-bill/messages', { params }) +} + +/** 解析账单附件(不落库),可覆盖账单类型与解压密码 */ +export function parseEmailBillMessage(id: number, data: { billType?: string; password?: string }) { + return request.post(`/email-bill/messages/${id}/parse`, data, { timeout: 120000 }) +} + +/** 确认导入勾选的账单行 */ +export function importEmailBillMessage(id: number, data: { + billType: BillType + expenseCategoryId?: number | null + incomeCategoryId?: number | null + tagIds?: number[] + items: BillImportItem[] +}) { + return request.post(`/email-bill/messages/${id}/import`, data, { timeout: 120000 }) +} + +/** 忽略账单消息 */ +export function ignoreEmailBillMessage(id: number) { + return request.put(`/email-bill/messages/${id}/ignore`) +} + +/** 删除账单消息 */ +export function deleteEmailBillMessage(id: number) { + return request.delete(`/email-bill/messages/${id}`) +} diff --git a/frontend/src/api/statistics.ts b/frontend/src/api/statistics.ts index 2e6ae37..f46722c 100644 --- a/frontend/src/api/statistics.ts +++ b/frontend/src/api/statistics.ts @@ -27,6 +27,8 @@ export interface StatisticsFilterParams { minAmount?: number maxAmount?: number remark?: string + /** 统计口径:INCLUDED-仅计入(默认)、ALL-全部、EXCLUDED-仅不计入 */ + statsScope?: 'INCLUDED' | 'ALL' | 'EXCLUDED' } /** 获取指定时间范围的统计概览(收支汇总、日均、最大单笔、平均每笔) */ diff --git a/frontend/src/components/BillImportResult.vue b/frontend/src/components/BillImportResult.vue new file mode 100644 index 0000000..19d77b1 --- /dev/null +++ b/frontend/src/components/BillImportResult.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/BillPreviewPanel.vue b/frontend/src/components/BillPreviewPanel.vue new file mode 100644 index 0000000..f178bd6 --- /dev/null +++ b/frontend/src/components/BillPreviewPanel.vue @@ -0,0 +1,403 @@ + + + diff --git a/frontend/src/locales/messages.ts b/frontend/src/locales/messages.ts index ad23253..7e73911 100644 --- a/frontend/src/locales/messages.ts +++ b/frontend/src/locales/messages.ts @@ -81,11 +81,13 @@ export default { // 主布局 layout: { appTitle: { zh: '收支管理', en: 'Finance Manager', ja: '収支管理' }, + appSubtitle: { zh: '个人财务管理', en: 'Personal Finance', ja: '個人財務管理' }, dashboard: { zh: '统计面板', en: 'Dashboard', ja: 'ダッシュボード' }, transactions: { zh: '收支记录', en: 'Transactions', ja: '収支記録' }, categories: { zh: '分类管理', en: 'Categories', ja: 'カテゴリ管理' }, tags: { zh: '标签管理', en: 'Tags', ja: 'タグ管理' }, rules: { zh: '规则管理', en: 'Rules', ja: 'ルール管理' }, + emailBills: { zh: '邮箱账单', en: 'Email Bills', ja: 'メール請求' }, logs: { zh: '系统日志', en: 'Logs', ja: 'システムログ' }, settings: { zh: '系统设置', en: 'Settings', ja: 'システム設定' }, user: { zh: '用户', en: 'User', ja: 'ユーザー' }, @@ -285,6 +287,12 @@ export default { quarter: { zh: '按季', en: 'Quarterly', ja: '四半期別' }, year: { zh: '按年', en: 'Yearly', ja: '年別' }, }, + filterStatsScope: { zh: '统计口径', en: 'Stats Scope', ja: '集計範囲' }, + statsScope: { + included: { zh: '仅计入统计', en: 'Included Only', ja: '集計対象のみ' }, + all: { zh: '全部记录', en: 'All Records', ja: 'すべての記録' }, + excluded: { zh: '仅不计入', en: 'Excluded Only', ja: '集計対象外のみ' }, + }, filterType: { zh: '类型', en: 'Type', ja: '種類' }, filterCategory: { zh: '分类', en: 'Category', ja: 'カテゴリ' }, filterPaymentMethod: { zh: '支付方式', en: 'Payment Method', ja: '支払方法' }, @@ -688,4 +696,143 @@ export default { }, }, }, + + // 邮箱账单 + emailBill: { + title: { zh: '邮箱账单', en: 'Email Bills', ja: 'メール請求' }, + tab: { + configs: { zh: '邮箱配置', en: 'Mailboxes', ja: 'メール設定' }, + senderRules: { zh: '发件人规则', en: 'Sender Rules', ja: '送信者ルール' }, + messages: { zh: '待处理账单', en: 'Pending Bills', ja: '未処理の請求' }, + }, + imapHosts: { + qq: { zh: 'QQ 邮箱', en: 'QQ Mail', ja: 'QQメール' }, + qqExmail: { zh: '腾讯企业邮', en: 'Tencent Exmail', ja: 'Tencent Exmail' }, + netease163: { zh: '网易 163 邮箱', en: 'NetEase 163', ja: 'NetEase 163' }, + netease126: { zh: '网易 126 邮箱', en: 'NetEase 126', ja: 'NetEase 126' }, + neteaseQiye: { zh: '网易企业邮', en: 'NetEase Enterprise', ja: 'NetEase Enterprise' }, + aliyun: { zh: '阿里云邮箱', en: 'Alibaba Mail', ja: 'Alibaba Mail' }, + sina: { zh: '新浪邮箱', en: 'Sina Mail', ja: 'Sina Mail' }, + mail139: { zh: '139 邮箱', en: '139 Mail', ja: '139メール' }, + mail189: { zh: '189 邮箱', en: '189 Mail', ja: '189メール' }, + gmail: { zh: 'Gmail', en: 'Gmail', ja: 'Gmail' }, + outlook: { zh: 'Outlook / Hotmail', en: 'Outlook / Hotmail', ja: 'Outlook / Hotmail' }, + icloud: { zh: 'iCloud 邮箱', en: 'iCloud Mail', ja: 'iCloudメール' }, + }, + common: { + add: { zh: '新增', en: 'Add', ja: '追加' }, + edit: { zh: '编辑', en: 'Edit', ja: '編集' }, + delete: { zh: '删除', en: 'Delete', ja: '削除' }, + actions: { zh: '操作', en: 'Actions', ja: '操作' }, + enabled: { zh: '启用', en: 'Enabled', ja: '有効' }, + save: { zh: '保存', en: 'Save', ja: '保存' }, + cancel: { zh: '取消', en: 'Cancel', ja: 'キャンセル' }, + confirm: { zh: '确定', en: 'Confirm', ja: '確定' }, + refresh: { zh: '刷新', en: 'Refresh', ja: '更新' }, + loadFailed: { zh: '加载失败', en: 'Failed to load', ja: '読み込みに失敗しました' }, + saveSuccess: { zh: '保存成功', en: 'Saved', ja: '保存しました' }, + deleteSuccess: { zh: '删除成功', en: 'Deleted', ja: '削除しました' }, + deleteConfirm: { zh: '确定删除吗?', en: 'Delete this item?', ja: '削除しますか?' }, + empty: { zh: '暂无数据', en: 'No data', ja: 'データがありません' }, + }, + config: { + name: { zh: '名称', en: 'Name', ja: '名称' }, + host: { zh: 'IMAP 服务器', en: 'IMAP Server', ja: 'IMAPサーバー' }, + hostPlaceholder: { zh: '选择或输入 IMAP 服务器', en: 'Select or enter IMAP server', ja: 'IMAPサーバーを選択または入力' }, + port: { zh: '端口', en: 'Port', ja: 'ポート' }, + ssl: { zh: '使用 SSL', en: 'Use SSL', ja: 'SSLを使用' }, + username: { zh: '邮箱账号', en: 'Email', ja: 'メールアドレス' }, + password: { zh: '授权码', en: 'Auth Code', ja: '認証コード' }, + passwordPlaceholder: { zh: '留空表示不修改', en: 'Leave blank to keep unchanged', ja: '空欄なら変更しません' }, + folder: { zh: '监听文件夹', en: 'Folder', ja: 'フォルダ' }, + status: { zh: '状态', en: 'Status', ja: '状態' }, + lastError: { zh: '最近错误', en: 'Last Error', ja: '最新エラー' }, + defaultExpenseCategory: { zh: '默认支出分类', en: 'Default Expense Category', ja: '既定の支出カテゴリ' }, + defaultIncomeCategory: { zh: '默认收入分类', en: 'Default Income Category', ja: '既定の収入カテゴリ' }, + defaultTags: { zh: '默认标签', en: 'Default Tags', ja: '既定のタグ' }, + fetchScope: { zh: '拉取范围', en: 'Fetch Scope', ja: '取得範囲' }, + fetchScopeIncrement: { zh: '仅新邮件', en: 'New only', ja: '新着のみ' }, + fetchScopeDays: { zh: '最近 N 天', en: 'Last N days', ja: '直近 N 日' }, + fetchScopeCount: { zh: '最近 N 封', en: 'Last N emails', ja: '直近 N 件' }, + fetchLimitDays: { zh: '天数', en: 'Days', ja: '日数' }, + fetchLimitCount: { zh: '封数', en: 'Count', ja: '件数' }, + test: { zh: '测试连接', en: 'Test', ja: '接続テスト' }, + check: { zh: '立即检查', en: 'Check Now', ja: '今すぐ確認' }, + testSuccess: { zh: '连接成功', en: 'Connected', ja: '接続に成功しました' }, + testFailed: { zh: '连接失败', en: 'Connection failed', ja: '接続に失敗しました' }, + checkResult: { zh: '新增 {count} 条待处理账单', en: '{count} new pending bill(s)', ja: '未処理の請求を {count} 件追加しました' }, + checkLogTitle: { zh: '检查进度', en: 'Check Progress', ja: '確認の進行状況' }, + checkRunning: { zh: '检查中…', en: 'Checking…', ja: '確認中…' }, + checkClear: { zh: '清空', en: 'Clear', ja: 'クリア' }, + checkFailed: { zh: '检查失败', en: 'Check failed', ja: '確認に失敗しました' }, + statusText: { + DISCONNECTED: { zh: '未连接', en: 'Disconnected', ja: '未接続' }, + CONNECTED: { zh: '已连接', en: 'Connected', ja: '接続済み' }, + POLLING: { zh: '轮询中', en: 'Polling', ja: 'ポーリング中' }, + ERROR: { zh: '异常', en: 'Error', ja: 'エラー' }, + }, + }, + senderRule: { + name: { zh: '规则名称', en: 'Rule Name', ja: 'ルール名' }, + patterns: { zh: '发件人匹配串', en: 'Sender Patterns', ja: '送信者パターン' }, + patternsHint: { zh: '每行一个,支持完整地址、@域名或关键字,包含匹配且忽略大小写', en: 'One per line; full address, @domain or keyword, case-insensitive contains', ja: '1行に1つ。完全なアドレス、@ドメイン、キーワードに対応(大文字小文字を区別しない部分一致)' }, + billType: { zh: '账单类型', en: 'Bill Type', ja: '請求タイプ' }, + archivePassword: { zh: '解压密码', en: 'Archive Password', ja: '解凍パスワード' }, + archivePasswordPlaceholder: { zh: '压缩包加密时填写', en: 'Fill if the archive is encrypted', ja: '暗号化されている場合に入力' }, + sortOrder: { zh: '优先级', en: 'Priority', ja: '優先度' }, + defaultExpenseCategory: { zh: '默认支出分类', en: 'Default Expense Category', ja: '既定の支出カテゴリ' }, + defaultIncomeCategory: { zh: '默认收入分类', en: 'Default Income Category', ja: '既定の収入カテゴリ' }, + defaultTags: { zh: '默认标签', en: 'Default Tags', ja: '既定のタグ' }, + patternsRequired: { zh: '请至少填写一个发件人匹配串', en: 'Please enter at least one sender pattern', ja: '送信者パターンを1つ以上入力してください' }, + }, + message: { + receivedAt: { zh: '接收时间', en: 'Received', ja: '受信日時' }, + fromAddress: { zh: '发件人', en: 'From', ja: '送信者' }, + subject: { zh: '主题', en: 'Subject', ja: '件名' }, + attachmentName: { zh: '附件', en: 'Attachment', ja: '添付ファイル' }, + billType: { zh: '账单类型', en: 'Bill Type', ja: '請求タイプ' }, + status: { zh: '状态', en: 'Status', ja: '状態' }, + error: { zh: '错误', en: 'Error', ja: 'エラー' }, + parse: { zh: '解析', en: 'Parse', ja: '解析' }, + ignore: { zh: '忽略', en: 'Ignore', ja: '無視' }, + statusText: { + PULLED: { zh: '待解析', en: 'Pending', ja: '未解析' }, + CONFIRMED: { zh: '已导入', en: 'Imported', ja: '取込済み' }, + IGNORED: { zh: '已忽略', en: 'Ignored', ja: '無視' }, + }, + filterStatus: { zh: '状态筛选', en: 'Filter by status', ja: '状態で絞り込み' }, + }, + parse: { + title: { zh: '解析账单', en: 'Parse Bill', ja: '請求を解析' }, + billType: { zh: '账单类型', en: 'Bill Type', ja: '請求タイプ' }, + password: { zh: '解压密码', en: 'Archive Password', ja: '解凍パスワード' }, + parseButton: { zh: '解析', en: 'Parse', ja: '解析' }, + parseFailed: { zh: '解析失败', en: 'Parse failed', ja: '解析に失敗しました' }, + needParse: { zh: '请先解析账单', en: 'Please parse the bill first', ja: '先に請求を解析してください' }, + expenseDefaultCategory: { zh: '支出默认分类', en: 'Expense Category', ja: '支出カテゴリ' }, + incomeDefaultCategory: { zh: '收入默认分类', en: 'Income Category', ja: '収入カテゴリ' }, + defaultTags: { zh: '默认标签', en: 'Default Tags', ja: '既定のタグ' }, + selectAll: { zh: '全选', en: 'Select All', ja: 'すべて選択' }, + setIncluded: { zh: '计入统计', en: 'Include', ja: '集計に含める' }, + setExcluded: { zh: '不计入', en: 'Exclude', ja: '集計から除外' }, + previewSummary: { zh: '共 {total} 行,筛选后 {filtered} 行,已选 {checked} 行', en: '{total} rows, {filtered} after filter, {checked} selected', ja: '全 {total} 行、絞り込み後 {filtered} 行、選択 {checked} 行' }, + confirmImport: { zh: '导入 {count} 条', en: 'Import {count}', ja: '{count} 件を取込' }, + importSuccess: { zh: '导入完成', en: 'Import completed', ja: '取込が完了しました' }, + resultSummary: { zh: '成功 {imported} 条,跳过 {skipped} 条,失败 {failed} 条', en: 'Imported {imported}, skipped {skipped}, failed {failed}', ja: '成功 {imported} 件、スキップ {skipped} 件、失敗 {failed} 件' }, + done: { zh: '完成', en: 'Done', ja: '完了' }, + prevStep: { zh: '返回', en: 'Back', ja: '戻る' }, + column: { + transactionTime: { zh: '交易时间', en: 'Time', ja: '取引日時' }, + type: { zh: '类型', en: 'Type', ja: '種別' }, + amount: { zh: '金额', en: 'Amount', ja: '金額' }, + counterparty: { zh: '交易对方', en: 'Counterparty', ja: '取引先' }, + product: { zh: '商品', en: 'Item', ja: '商品' }, + status: { zh: '状态', en: 'Status', ja: '状態' }, + matchedRule: { zh: '匹配规则/建议', en: 'Rule / Suggestion', ja: 'ルール/提案' }, + }, + notImportable: { zh: '不可导入', en: 'Not importable', ja: '取込不可' }, + notIncluded: { zh: '不计收支', en: 'Excluded', ja: '集計外' }, + duplicate: { zh: '重复', en: 'Duplicate', ja: '重複' }, + }, + }, } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 10233d6..5db71c1 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -58,6 +58,20 @@ const router = createRouter({ name: 'Rules', component: () => import('../views/RuleManage.vue'), // 规则管理(自定义分类与标签规则) }, + { + path: 'email-bills', + // 跳转到上次访问的标签页,无记录时默认邮箱配置 + redirect: () => { + const tabs = ['configs', 'senderRules', 'messages'] + const saved = typeof localStorage !== 'undefined' ? localStorage.getItem('emailBill.tab') : null + return `/email-bills/${saved && tabs.includes(saved) ? saved : 'configs'}` + }, + }, + { + path: 'email-bills/:tab(configs|senderRules|messages)', + name: 'EmailBills', + component: () => import('../views/EmailBillManage.vue'), // 邮箱账单(邮箱配置/发件人规则/待处理账单) + }, { path: 'settings', name: 'Settings', diff --git a/frontend/src/utils/payment.ts b/frontend/src/utils/payment.ts new file mode 100644 index 0000000..0f3a621 --- /dev/null +++ b/frontend/src/utils/payment.ts @@ -0,0 +1,14 @@ +// 支付方式相关工具:将后端返回的支付方式编码映射为当前语言标签 +import i18n from '../locales' + +/** i18n 全局实例的翻译函数签名(避免动态 key 触发过深的类型推断) */ +type Translate = (key: string) => string +type HasKey = (key: string) => boolean + +/** 将支付方式编码转换为当前语言的标签,无对应文案时回退为原始编码 */ +export function getPaymentLabel(val: string): string { + if (!val) return '-' + const { t, te } = i18n.global as unknown as { t: Translate; te: HasKey } + const key = `common.payment.${val.toLowerCase()}` + return te(key) ? t(key) : val +} diff --git a/frontend/src/views/BillImportPanel.vue b/frontend/src/views/BillImportPanel.vue index 5865eba..1ff5e3e 100644 --- a/frontend/src/views/BillImportPanel.vue +++ b/frontend/src/views/BillImportPanel.vue @@ -1,18 +1,17 @@ @@ -530,99 +255,17 @@ onMounted(() => { - - - - {{ t('import.expenseDefaultCategory') }} - - {{ t('import.incomeDefaultCategory') }} - - {{ t('import.defaultTags') }} - - - {{ t('import.selectAll') }} - - - {{ t('import.setIncluded') }} - - - {{ t('import.setExcluded') }} - - {{ t('import.previewSummary', { total: rows.length, filtered: filteredRows.length, checked: checkedRows.length }) }} - + + - - - - - - - ~ - - {{ t('common.reset') }} - - - - - - {{ t('import.prevStep') }} - - {{ t('import.confirmImport', { count: checkedRows.length }) }} - - - - - - - - {{ t('import.resultSummary', { imported: result?.imported ?? 0, skipped: result?.skipped ?? 0, failed: result?.failed ?? 0 }) }} - -
- - {{ msg }} - -
- - {{ t('import.done') }} - -
+ + (null) // 自定义起止时间 const granularity = ref<'day' | 'week' | 'month' | 'quarter' | 'year'>('day') // 趋势粒度 // 过滤条件 +const filterStatsScope = ref<'INCLUDED' | 'ALL' | 'EXCLUDED'>('INCLUDED') // 统计口径 const filterType = ref([]) // 类型(多选) const filterCategory = ref([]) // 分类(多选,0 表示未分类) const filterPaymentMethod = ref([]) // 支付方式(多选) @@ -44,17 +47,24 @@ const filterMinAmount = ref(null) // 最小金额 const filterMaxAmount = ref(null) // 最大金额 const filterRemark = ref('') // 备注关键字 +// 统计口径选项:仅计入 / 全部 / 仅不计入 +const statsScopeOptions = computed(() => [ + { label: t('dashboard.statsScope.included'), value: 'INCLUDED' }, + { label: t('dashboard.statsScope.all'), value: 'ALL' }, + { label: t('dashboard.statsScope.excluded'), value: 'EXCLUDED' }, +]) // 类型与支付方式选项 const typeOptions = computed(() => [ { label: t('common.types.expense'), value: 'EXPENSE' }, { 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([]) +// 支付方式选项:由后端返回的编码映射为当前语言标签,locale 变化时重新生成 +const paymentOptions = computed(() => { + void locale.value + return paymentMethodCodes.value.map((code) => ({ label: getPaymentLabel(code), value: code })) +}) /** 分类选项数据结构 */ interface CategoryOption { @@ -90,6 +100,7 @@ function buildCategoryFilter() { /** 统计接口的过滤条件 */ const filterParams = computed(() => ({ + statsScope: filterStatsScope.value, types: filterType.value.length > 0 ? filterType.value : undefined, ...buildCategoryFilter(), paymentMethods: filterPaymentMethod.value.length > 0 ? filterPaymentMethod.value : undefined, @@ -272,6 +283,7 @@ function loadPrefs() { suppressRangeWatch = true if (['month', '3month', '12month', 'custom'].includes(p.rangeType)) rangeType.value = p.rangeType if (Array.isArray(p.customRange) && p.customRange.length === 2) customRange.value = p.customRange + if (['INCLUDED', 'ALL', 'EXCLUDED'].includes(p.filterStatsScope)) filterStatsScope.value = p.filterStatsScope if (Array.isArray(p.filterType)) filterType.value = p.filterType if (Array.isArray(p.filterCategory)) filterCategory.value = p.filterCategory if (Array.isArray(p.filterPaymentMethod)) filterPaymentMethod.value = p.filterPaymentMethod @@ -291,6 +303,7 @@ function savePrefs() { rangeType: rangeType.value, customRange: customRange.value, granularity: granularity.value, + filterStatsScope: filterStatsScope.value, filterType: filterType.value, filterCategory: filterCategory.value, filterPaymentMethod: filterPaymentMethod.value, @@ -313,8 +326,19 @@ async function fetchCategories() { } } +/** 从后端加载支付方式列表,供筛选下拉使用 */ +async function loadPaymentMethods() { + try { + const res: any = await getPaymentMethods() + paymentMethodCodes.value = res.data || [] + } catch (e) { + // 静默处理,支付方式下拉保持为空 + } +} + /** 重置所有过滤条件(保留时间范围与粒度) */ function handleResetFilters() { + filterStatsScope.value = 'INCLUDED' filterType.value = [] filterCategory.value = [] filterPaymentMethod.value = [] @@ -334,7 +358,7 @@ watch(rangeType, (v) => { // 范围、粒度或过滤条件变化时重新加载 watch([() => range.value.startTime, () => range.value.endTime, granularity, filterParams], fetchData) // 任一偏好变化时自动保存 -watch([rangeType, customRange, granularity, filterType, filterCategory, filterPaymentMethod, filterMinAmount, filterMaxAmount, filterRemark], savePrefs, { deep: true }) +watch([rangeType, customRange, granularity, filterStatsScope, filterType, filterCategory, filterPaymentMethod, filterMinAmount, filterMaxAmount, filterRemark], savePrefs, { deep: true }) // 语言切换时重绘图表,更新标题与图例文案 watch(locale, () => { updatePieChart() @@ -345,6 +369,7 @@ watch(locale, () => { onMounted(async () => { loadPrefs() fetchCategories() + loadPaymentMethods() await nextTick() suppressRangeWatch = false fetchData() @@ -372,6 +397,12 @@ onMounted(async () => { + { :options="paymentOptions" multiple clearable + filterable :max-tag-count="2" :placeholder="t('dashboard.filterPaymentMethod')" style="width: 200px;" diff --git a/frontend/src/views/EmailBillManage.vue b/frontend/src/views/EmailBillManage.vue new file mode 100644 index 0000000..cce36f2 --- /dev/null +++ b/frontend/src/views/EmailBillManage.vue @@ -0,0 +1,880 @@ + + + diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue index 3e0c567..674b287 100644 --- a/frontend/src/views/Layout.vue +++ b/frontend/src/views/Layout.vue @@ -5,7 +5,7 @@ import { useRouter, useRoute } from 'vue-router' import { useI18n } from 'vue-i18n' import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown } from 'naive-ui' import type { MenuOption } from 'naive-ui' -import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, OptionsOutline, DocumentTextOutline, SettingsOutline, LogOutOutline, ChevronDownOutline } from '@vicons/ionicons5' +import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, OptionsOutline, MailOutline, DocumentTextOutline, SettingsOutline, LogOutOutline, ChevronDownOutline, WalletOutline } from '@vicons/ionicons5' import { useUserStore } from '../store/user' import LocaleSwitch from '../components/LocaleSwitch.vue' @@ -22,8 +22,15 @@ const activeKey = computed(() => { '/categories': 'categories', '/tags': 'tags', '/rules': 'rules', + '/email-bills': 'email-bills', } - return map[route.path] || 'dashboard' + // 支持子路径(如 /email-bills/messages)匹配到父级菜单 + for (const [path, key] of Object.entries(map)) { + if (route.path === path || route.path.startsWith(`${path}/`)) { + return key + } + } + return 'dashboard' }) /** 渲染菜单图标的高阶函数 */ @@ -38,6 +45,7 @@ const menuOptions = computed(() => [ { label: t('layout.categories'), key: 'categories', icon: renderIcon(FolderOpenOutline) }, { label: t('layout.tags'), key: 'tags', icon: renderIcon(PricetagsOutline) }, { label: t('layout.rules'), key: 'rules', icon: renderIcon(OptionsOutline) }, + { label: t('layout.emailBills'), key: 'email-bills', icon: renderIcon(MailOutline) }, ]) /** 菜单点击事件:根据 key 导航到对应路由 */ @@ -79,8 +87,14 @@ function handleLogout() { content-style="padding: 12px;" > -
- {{ t('layout.appTitle') }} +
+ +
+ {{ t('layout.appTitle') }} + {{ t('layout.appSubtitle') }} +
+ + diff --git a/frontend/src/views/TransactionList.vue b/frontend/src/views/TransactionList.vue index 0aef4ec..d84c3fa 100644 --- a/frontend/src/views/TransactionList.vue +++ b/frontend/src/views/TransactionList.vue @@ -10,11 +10,12 @@ import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai' import { getCategoryList, createCategory } from '../api/category' import { getTagList, createTag } from '../api/tag' +import { getPaymentLabel } from '../utils/payment' import BillImportModal from './BillImportModal.vue' const message = useMessage() // 消息提示 const dialog = useDialog() // 对话框 -const { t, te } = useI18n() // 国际化 +const { t } = useI18n() // 国际化 /** 交易记录数据结构 */ interface TransactionItem { @@ -431,13 +432,6 @@ function formatTimestamp(ts: number, isEnd = false): string { return `${date}T${time}` } -/** 将支付方式编码转换为当前语言的标签 */ -function getPaymentLabel(val: string) { - if (!val) return '-' - const key = `common.payment.${val.toLowerCase()}` - return te(key) ? t(key) : val -} - /** 分类下拉项渲染:图标 + 名称(下拉选项与已选项均生效) */ function renderCategoryLabel(option: any) { return h('span', null, [ diff --git a/src/main/java/cn/rosercode/finance/common/GlobalExceptionHandler.java b/src/main/java/cn/rosercode/finance/common/GlobalExceptionHandler.java index 33af329..4a1b6b0 100644 --- a/src/main/java/cn/rosercode/finance/common/GlobalExceptionHandler.java +++ b/src/main/java/cn/rosercode/finance/common/GlobalExceptionHandler.java @@ -31,7 +31,7 @@ public class GlobalExceptionHandler { */ @ExceptionHandler(BusinessException.class) public Result handleBusinessException(BusinessException e) { - log.warn("业务异常[{}]: {}", e.getCode(), e.getMessage()); + log.debug("业务异常[{}]: {}", e.getCode(), e.getMessage()); return Result.error(e.getCode(), e.getMessage()); } @@ -57,7 +57,7 @@ public class GlobalExceptionHandler { public Result handleValidationException(MethodArgumentNotValidException e) { // 提取第一个校验错误信息 String message = e.getBindingResult().getAllErrors().get(0).getDefaultMessage(); - log.warn("参数校验失败: {}", message); + log.debug("参数校验失败: {}", message); return Result.error(400, message); } diff --git a/src/main/java/cn/rosercode/finance/controller/EmailBillController.java b/src/main/java/cn/rosercode/finance/controller/EmailBillController.java new file mode 100644 index 0000000..a6d74af --- /dev/null +++ b/src/main/java/cn/rosercode/finance/controller/EmailBillController.java @@ -0,0 +1,340 @@ +package cn.rosercode.finance.controller; + +import java.util.List; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.Data; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import cn.rosercode.finance.common.PageResult; +import cn.rosercode.finance.common.Result; +import cn.rosercode.finance.dto.BillImportRequest; +import cn.rosercode.finance.dto.EmailBillConfigRequest; +import cn.rosercode.finance.dto.EmailBillParseRequest; +import cn.rosercode.finance.dto.EmailBillSenderRuleRequest; +import cn.rosercode.finance.email.EmailBillMonitorManager; +import cn.rosercode.finance.service.EmailBillCheckStreamService; +import cn.rosercode.finance.service.EmailBillConfigService; +import cn.rosercode.finance.service.EmailBillMessageService; +import cn.rosercode.finance.service.EmailBillSenderRuleService; +import cn.rosercode.finance.util.UserContext; +import cn.rosercode.finance.vo.BillImportResultVO; +import cn.rosercode.finance.vo.BillParseResultVO; +import cn.rosercode.finance.vo.EmailBillConfigVO; +import cn.rosercode.finance.vo.EmailBillMessageVO; +import cn.rosercode.finance.vo.EmailBillSenderRuleVO; + +/** + * 邮箱账单控制器 + * + *

提供 IMAP 邮箱配置与发件人识别规则的增删改查、启停与连接测试接口; + * 邮件拉取与账单导入接口在后续版本补充。

+ * + * @date 2026-09-19 16:41:41 + */ +@RestController +@RequestMapping("/api/email-bill") +@Tag(name = "邮箱账单", description = "IMAP 邮箱配置与发件人识别规则相关接口") +public class EmailBillController { + + private final EmailBillConfigService configService; + private final EmailBillSenderRuleService senderRuleService; + private final EmailBillMessageService messageService; + private final EmailBillMonitorManager monitorManager; + private final EmailBillCheckStreamService checkStreamService; + + public EmailBillController(EmailBillConfigService configService, + EmailBillSenderRuleService senderRuleService, + EmailBillMessageService messageService, + EmailBillMonitorManager monitorManager, + EmailBillCheckStreamService checkStreamService) { + this.configService = configService; + this.senderRuleService = senderRuleService; + this.messageService = messageService; + this.monitorManager = monitorManager; + this.checkStreamService = checkStreamService; + } + + /** + * 获取邮箱配置列表 + * + * @return 邮箱配置列表 + */ + @GetMapping("/configs") + @Operation(summary = "获取邮箱配置列表", description = "获取当前用户的全部 IMAP 邮箱配置,授权码已脱敏") + public Result> listConfigs() { + Long userId = UserContext.getUserId(); + return Result.success(configService.list(userId)); + } + + /** + * 创建邮箱配置 + * + * @param request 配置请求参数 + * @return 新配置ID + */ + @PostMapping("/configs") + @Operation(summary = "创建邮箱配置", description = "为当前用户新增 IMAP 邮箱监听配置") + public Result createConfig(@RequestBody @Valid EmailBillConfigRequest request) { + Long userId = UserContext.getUserId(); + return Result.success(configService.create(userId, request)); + } + + /** + * 更新邮箱配置 + * + * @param id 配置ID + * @param request 配置请求参数 + * @return 操作结果 + */ + @PutMapping("/configs/{id}") + @Operation(summary = "更新邮箱配置", description = "更新指定邮箱配置,授权码留空表示不修改") + public Result updateConfig(@Parameter(description = "配置ID") @PathVariable Long id, + @RequestBody @Valid EmailBillConfigRequest request) { + Long userId = UserContext.getUserId(); + configService.update(userId, id, request); + return Result.success(); + } + + /** + * 删除邮箱配置 + * + * @param id 配置ID + * @return 操作结果 + */ + @DeleteMapping("/configs/{id}") + @Operation(summary = "删除邮箱配置", description = "删除指定邮箱配置及其已拉取的消息记录") + public Result deleteConfig(@Parameter(description = "配置ID") @PathVariable Long id) { + Long userId = UserContext.getUserId(); + configService.delete(userId, id); + return Result.success(); + } + + /** + * 启用或停用邮箱配置 + * + * @param id 配置ID + * @param enabled 是否启用 + * @return 操作结果 + */ + @PutMapping("/configs/{id}/enabled") + @Operation(summary = "启用/停用邮箱配置", description = "切换指定邮箱配置的启用状态,启用后开始监听新邮件") + public Result toggleConfig(@Parameter(description = "配置ID") @PathVariable Long id, + @RequestBody EnabledRequest enabled) { + Long userId = UserContext.getUserId(); + configService.toggleEnabled(userId, id, enabled.getEnabled()); + return Result.success(); + } + + /** + * 测试邮箱连接 + * + * @param id 配置ID + * @return 操作结果 + */ + @PostMapping("/configs/{id}/test") + @Operation(summary = "测试邮箱连接", description = "使用指定配置建立 IMAP 连接并回写连接状态") + public Result testConfig(@Parameter(description = "配置ID") @PathVariable Long id) { + Long userId = UserContext.getUserId(); + configService.testConnection(userId, id); + return Result.success(); + } + + /** + * 获取发件人规则列表 + * + * @return 发件人规则列表 + */ + @GetMapping("/sender-rules") + @Operation(summary = "获取发件人规则列表", description = "获取当前用户的全部发件人识别规则,按优先级排序") + public Result> listSenderRules() { + Long userId = UserContext.getUserId(); + return Result.success(senderRuleService.list(userId)); + } + + /** + * 创建发件人规则 + * + * @param request 规则请求参数 + * @return 新规则ID + */ + @PostMapping("/sender-rules") + @Operation(summary = "创建发件人规则", description = "新增发件人识别规则,命中后确定账单类型与默认归类") + public Result createSenderRule(@RequestBody @Valid EmailBillSenderRuleRequest request) { + Long userId = UserContext.getUserId(); + return Result.success(senderRuleService.create(userId, request)); + } + + /** + * 更新发件人规则 + * + * @param id 规则ID + * @param request 规则请求参数 + * @return 操作结果 + */ + @PutMapping("/sender-rules/{id}") + @Operation(summary = "更新发件人规则", description = "根据规则ID更新发件人识别规则") + public Result updateSenderRule(@Parameter(description = "规则ID") @PathVariable Long id, + @RequestBody @Valid EmailBillSenderRuleRequest request) { + Long userId = UserContext.getUserId(); + senderRuleService.update(userId, id, request); + return Result.success(); + } + + /** + * 删除发件人规则 + * + * @param id 规则ID + * @return 操作结果 + */ + @DeleteMapping("/sender-rules/{id}") + @Operation(summary = "删除发件人规则", description = "根据规则ID删除指定发件人识别规则") + public Result deleteSenderRule(@Parameter(description = "规则ID") @PathVariable Long id) { + Long userId = UserContext.getUserId(); + senderRuleService.delete(userId, id); + return Result.success(); + } + + /** + * 启用或停用发件人规则 + * + * @param id 规则ID + * @param enabled 是否启用 + * @return 操作结果 + */ + @PutMapping("/sender-rules/{id}/enabled") + @Operation(summary = "启用/停用发件人规则", description = "切换指定发件人规则的启用状态") + public Result toggleSenderRule(@Parameter(description = "规则ID") @PathVariable Long id, + @RequestBody EnabledRequest enabled) { + Long userId = UserContext.getUserId(); + senderRuleService.toggleEnabled(userId, id, enabled.getEnabled()); + return Result.success(); + } + + /** + * 立即检查邮箱新邮件 + * + * @param id 配置ID + * @return 新登记的待解析消息数量 + */ + @PostMapping("/configs/{id}/check") + @Operation(summary = "立即检查邮箱", description = "立即连接邮箱并增量拉取新邮件附件") + public Result checkConfig(@Parameter(description = "配置ID") @PathVariable Long id) { + Long userId = UserContext.getUserId(); + configService.getRawConfig(userId, id); + return Result.success(monitorManager.checkNow(id)); + } + + /** + * 立即检查邮箱(实时进度) + * + * @param id 配置ID + * @return SSE 进度流 + */ + @GetMapping("/configs/{id}/check/stream") + @Operation(summary = "立即检查邮箱(实时进度)", description = "以 SSE 实时推送立即检查的执行进度,结束时返回新增待处理条数") + public SseEmitter checkConfigStream(@Parameter(description = "配置ID") @PathVariable Long id) { + Long userId = UserContext.getUserId(); + return checkStreamService.stream(userId, id); + } + + /** + * 分页查询账单消息 + * + * @param status 处理状态筛选 + * @param configId 邮箱配置ID筛选 + * @param page 页码 + * @param size 每页大小 + * @return 分页结果 + */ + @GetMapping("/messages") + @Operation(summary = "查询账单消息", description = "分页查询拉取到的账单消息,可按状态与邮箱筛选") + public Result> pageMessages( + @Parameter(description = "处理状态") @RequestParam(required = false) String status, + @Parameter(description = "邮箱配置ID") @RequestParam(required = false) Long configId, + @Parameter(description = "页码") @RequestParam(defaultValue = "1") int page, + @Parameter(description = "每页大小") @RequestParam(defaultValue = "20") int size) { + Long userId = UserContext.getUserId(); + return Result.success(messageService.page(userId, status, configId, page, size)); + } + + /** + * 解析账单附件(不落库) + * + * @param id 消息ID + * @param request 解析请求 + * @return 解析预览结果 + */ + @PostMapping("/messages/{id}/parse") + @Operation(summary = "解析账单附件", description = "读取本地附件并返回预览数据,可覆盖账单类型与解压密码") + public Result parseMessage(@Parameter(description = "消息ID") @PathVariable Long id, + @RequestBody(required = false) EmailBillParseRequest request) { + Long userId = UserContext.getUserId(); + return Result.success(messageService.parse(userId, id, request)); + } + + /** + * 确认导入勾选的账单行 + * + * @param id 消息ID + * @param request 导入请求 + * @return 导入结果统计 + */ + @PostMapping("/messages/{id}/import") + @Operation(summary = "导入账单", description = "将用户勾选的账单行写入交易记录,成功后标记消息为已导入") + public Result importMessage(@Parameter(description = "消息ID") @PathVariable Long id, + @RequestBody @Valid BillImportRequest request) { + Long userId = UserContext.getUserId(); + return Result.success(messageService.importBill(userId, id, request)); + } + + /** + * 忽略账单消息 + * + * @param id 消息ID + * @return 操作结果 + */ + @PutMapping("/messages/{id}/ignore") + @Operation(summary = "忽略账单消息", description = "将指定消息标记为已忽略") + public Result ignoreMessage(@Parameter(description = "消息ID") @PathVariable Long id) { + Long userId = UserContext.getUserId(); + messageService.ignore(userId, id); + return Result.success(); + } + + /** + * 删除账单消息 + * + * @param id 消息ID + * @return 操作结果 + */ + @DeleteMapping("/messages/{id}") + @Operation(summary = "删除账单消息", description = "删除指定消息及其本地附件") + public Result deleteMessage(@Parameter(description = "消息ID") @PathVariable Long id) { + Long userId = UserContext.getUserId(); + messageService.delete(userId, id); + return Result.success(); + } + + /** + * 启用状态请求体 + */ + @Data + public static class EnabledRequest { + + /** 是否启用 */ + private Boolean enabled; + } +} diff --git a/src/main/java/cn/rosercode/finance/converter/VoConverter.java b/src/main/java/cn/rosercode/finance/converter/VoConverter.java index e2ca976..b910981 100644 --- a/src/main/java/cn/rosercode/finance/converter/VoConverter.java +++ b/src/main/java/cn/rosercode/finance/converter/VoConverter.java @@ -8,6 +8,9 @@ import org.mapstruct.ReportingPolicy; import cn.rosercode.finance.entity.AiUsage; import cn.rosercode.finance.entity.Category; +import cn.rosercode.finance.entity.EmailBillConfig; +import cn.rosercode.finance.entity.EmailBillMessage; +import cn.rosercode.finance.entity.EmailBillSenderRule; import cn.rosercode.finance.entity.HttpLog; import cn.rosercode.finance.entity.LoginLog; import cn.rosercode.finance.entity.NotificationChannel; @@ -18,6 +21,9 @@ import cn.rosercode.finance.entity.Transaction; import cn.rosercode.finance.entity.User; import cn.rosercode.finance.vo.AiUsageVO; import cn.rosercode.finance.vo.CategoryVO; +import cn.rosercode.finance.vo.EmailBillConfigVO; +import cn.rosercode.finance.vo.EmailBillMessageVO; +import cn.rosercode.finance.vo.EmailBillSenderRuleVO; import cn.rosercode.finance.vo.HttpLogVO; import cn.rosercode.finance.vo.LoginLogVO; import cn.rosercode.finance.vo.NotificationChannelVO; @@ -154,4 +160,38 @@ public interface VoConverter { @Mapping(target = "categoryIcon", source = "category.icon", defaultValue = "") @Mapping(target = "tags", ignore = true) RuleVO toRuleVO(Rule rule, Category category); + + /** + * 邮箱账单配置实体转视图对象。 + * + *

授权码不返回,仅标记是否已配置;默认标签列表由调用方解析填充。

+ * + * @param config 邮箱配置实体 + * @return 邮箱配置视图对象 + */ + @Mapping(target = "hasPassword", + expression = "java(config.getPassword() != null && !config.getPassword().isBlank())") + @Mapping(target = "ssl", source = "useSsl") + @Mapping(target = "tagIds", ignore = true) + EmailBillConfigVO toEmailBillConfigVO(EmailBillConfig config); + + /** + * 邮箱账单发件人规则实体转视图对象。 + * + *

发件人匹配串与默认标签列表由调用方解析填充。

+ * + * @param rule 发件人规则实体 + * @return 发件人规则视图对象 + */ + @Mapping(target = "senderPatterns", ignore = true) + @Mapping(target = "tagIds", ignore = true) + EmailBillSenderRuleVO toEmailBillSenderRuleVO(EmailBillSenderRule rule); + + /** + * 邮箱账单消息实体转视图对象。 + * + * @param message 消息实体 + * @return 消息视图对象 + */ + EmailBillMessageVO toEmailBillMessageVO(EmailBillMessage message); } diff --git a/src/main/java/cn/rosercode/finance/dto/EmailBillConfigRequest.java b/src/main/java/cn/rosercode/finance/dto/EmailBillConfigRequest.java new file mode 100644 index 0000000..80ece35 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/dto/EmailBillConfigRequest.java @@ -0,0 +1,57 @@ +package cn.rosercode.finance.dto; + +import java.util.List; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 邮箱账单配置请求DTO,用于创建或更新 IMAP 邮箱监听配置。 + * + * @date 2026-09-19 16:40:37 + */ +@Data +public class EmailBillConfigRequest { + + /** 配置名称,不能为空 */ + @NotBlank(message = "配置名称不能为空") + private String name; + + /** IMAP 服务器地址,不能为空 */ + @NotBlank(message = "IMAP 服务器地址不能为空") + private String host; + + /** IMAP 端口,可选,默认 993 */ + private Integer port; + + /** 是否使用 SSL,可选,默认启用 */ + private Boolean ssl; + + /** 邮箱账号,不能为空 */ + @NotBlank(message = "邮箱账号不能为空") + private String username; + + /** 邮箱授权码,更新时留空表示不修改 */ + private String password; + + /** 监听的邮件文件夹,可选,默认 INBOX */ + private String folder; + + /** 是否启用监听,可选,默认关闭 */ + private Boolean enabled; + + /** 默认支出分类ID,可选 */ + private Long expenseCategoryId; + + /** 默认收入分类ID,可选 */ + private Long incomeCategoryId; + + /** 默认标签ID列表,可选 */ + private List tagIds; + + /** 拉取范围:INCREMENT-仅新邮件,DAYS-最近N天,COUNT-最近N封,可选,默认 INCREMENT */ + private String fetchScope; + + /** 拉取范围数量:DAYS 为天数,COUNT 为封数,可选 */ + private Integer fetchLimit; +} diff --git a/src/main/java/cn/rosercode/finance/dto/EmailBillParseRequest.java b/src/main/java/cn/rosercode/finance/dto/EmailBillParseRequest.java new file mode 100644 index 0000000..bb14d93 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/dto/EmailBillParseRequest.java @@ -0,0 +1,20 @@ +package cn.rosercode.finance.dto; + +import lombok.Data; + +/** + * 邮箱账单解析请求DTO,用于手动触发待处理附件的解析。 + * + *

账单类型与解压密码默认取发件人规则,可在请求中覆盖,便于用户手动纠正。

+ * + * @date 2026-09-19 16:48:34 + */ +@Data +public class EmailBillParseRequest { + + /** 账单类型,为空时使用发件人规则识别的类型 */ + private String billType; + + /** 压缩包解压密码,为空时使用发件人规则配置的密码 */ + private String password; +} diff --git a/src/main/java/cn/rosercode/finance/dto/EmailBillSenderRuleRequest.java b/src/main/java/cn/rosercode/finance/dto/EmailBillSenderRuleRequest.java new file mode 100644 index 0000000..75baf28 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/dto/EmailBillSenderRuleRequest.java @@ -0,0 +1,44 @@ +package cn.rosercode.finance.dto; + +import java.util.List; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 邮箱账单发件人规则请求DTO,用于创建或更新发件人识别规则。 + * + * @date 2026-09-19 16:40:39 + */ +@Data +public class EmailBillSenderRuleRequest { + + /** 规则名称,不能为空 */ + @NotBlank(message = "规则名称不能为空") + private String name; + + /** 是否启用,可选,默认启用 */ + private Boolean enabled; + + /** 优先级,数值越小越优先,可选 */ + private Integer sortOrder; + + /** 发件人匹配串列表,至少一个 */ + private List senderPatterns; + + /** 命中的账单类型,不能为空 */ + @NotBlank(message = "请选择账单类型") + private String billType; + + /** 该来源账单压缩包解压密码,可选 */ + private String archivePassword; + + /** 默认支出分类ID,可选 */ + private Long expenseCategoryId; + + /** 默认收入分类ID,可选 */ + private Long incomeCategoryId; + + /** 默认标签ID列表,可选 */ + private List tagIds; +} diff --git a/src/main/java/cn/rosercode/finance/dto/StatisticsRangeQuery.java b/src/main/java/cn/rosercode/finance/dto/StatisticsRangeQuery.java index fd20cce..574965e 100644 --- a/src/main/java/cn/rosercode/finance/dto/StatisticsRangeQuery.java +++ b/src/main/java/cn/rosercode/finance/dto/StatisticsRangeQuery.java @@ -39,4 +39,10 @@ public class StatisticsRangeQuery { /** 备注关键字,模糊匹配 */ private String remark; + + /** + * 统计口径:INCLUDED-仅计入统计(默认)、ALL-全部记录、EXCLUDED-仅不计入统计。 + * 为空时按 INCLUDED 处理,兼容旧调用。 + */ + private String statsScope; } diff --git a/src/main/java/cn/rosercode/finance/email/EmailAttachment.java b/src/main/java/cn/rosercode/finance/email/EmailAttachment.java new file mode 100644 index 0000000..92f5014 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/email/EmailAttachment.java @@ -0,0 +1,29 @@ +package cn.rosercode.finance.email; + +import lombok.Data; + +/** + * 邮件附件中间对象,承载从邮件中提取出的附件文件名与字节内容。 + * + * @date 2026-09-19 16:43:43 + */ +@Data +public class EmailAttachment { + + /** 附件文件名 */ + private String fileName; + + /** 附件字节内容 */ + private byte[] content; + + /** + * 构造附件对象。 + * + * @param fileName 附件文件名 + * @param content 附件字节内容 + */ + public EmailAttachment(String fileName, byte[] content) { + this.fileName = fileName; + this.content = content; + } +} diff --git a/src/main/java/cn/rosercode/finance/email/EmailAttachmentExtractor.java b/src/main/java/cn/rosercode/finance/email/EmailAttachmentExtractor.java new file mode 100644 index 0000000..648ce23 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/email/EmailAttachmentExtractor.java @@ -0,0 +1,139 @@ +package cn.rosercode.finance.email; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; +import java.util.List; + +import jakarta.mail.MessagingException; +import jakarta.mail.Multipart; +import jakarta.mail.Part; +import jakarta.mail.internet.MimeUtility; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 邮件附件提取器,从 MIME 邮件中递归提取附件文件名与字节内容。 + * + *

仅处理带文件名且非纯正文的部件,并限制单个附件与整封邮件的总大小, + * 避免超大附件导致内存膨胀。

+ * + * @date 2026-09-19 16:43:47 + */ +@Component +@Slf4j +public class EmailAttachmentExtractor { + + /** 单个附件最大字节数(20MB) */ + private static final long MAX_ATTACHMENT_SIZE = 20L * 1024 * 1024; + + /** 单封邮件附件总大小上限(50MB) */ + private static final long MAX_TOTAL_SIZE = 50L * 1024 * 1024; + + /** 单封邮件最多提取的附件数 */ + private static final int MAX_ATTACHMENTS = 20; + + /** + * 提取邮件中的全部附件。 + * + * @param message 邮件消息 + * @return 附件列表,无附件时返回空列表 + * @throws MessagingException 邮件解析失败 + * @throws IOException 附件读取失败 + */ + public List extract(Part message) throws MessagingException, IOException { + List attachments = new ArrayList<>(); + // 以数组承载累计大小,便于递归传递 + long[] total = new long[]{0L}; + extractPart(message, attachments, total); + return attachments; + } + + /** + * 递归提取单个 MIME 部件中的附件。 + * + * @param part 当前部件 + * @param attachments 附件收集列表 + * @param total 已提取总字节数(单元素数组) + * @throws MessagingException 邮件解析失败 + * @throws IOException 附件读取失败 + */ + private void extractPart(Part part, List attachments, long[] total) + throws MessagingException, IOException { + if (attachments.size() >= MAX_ATTACHMENTS) { + return; + } + // 多部分内容递归处理 + if (part.isMimeType("multipart/*")) { + Multipart multipart = (Multipart) part.getContent(); + for (int i = 0; i < multipart.getCount(); i++) { + extractPart(multipart.getBodyPart(i), attachments, total); + } + return; + } + + String fileName = decodeFileName(part.getFileName()); + if (fileName == null || fileName.isBlank()) { + return; + } + String disposition = part.getDisposition(); + // 仅处理明确的附件或内联文件,跳过正文文本 + if (disposition != null && !Part.ATTACHMENT.equalsIgnoreCase(disposition) + && !Part.INLINE.equalsIgnoreCase(disposition)) { + return; + } + + byte[] content = readLimited(part.getInputStream()); + if (content == null) { + log.warn("跳过超过大小上限的邮件附件:{}", fileName); + return; + } + total[0] += content.length; + if (total[0] > MAX_TOTAL_SIZE) { + log.warn("邮件附件总大小超过上限,停止提取:configId 未知"); + return; + } + attachments.add(new EmailAttachment(fileName, content)); + } + + /** + * 读取附件内容并限制最大字节数。 + * + * @param in 输入流 + * @return 字节内容,超过上限时返回 null + * @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_ATTACHMENT_SIZE) { + return null; + } + out.write(buffer, 0, read); + } + return out.toByteArray(); + } + + /** + * 解码附件文件名(处理 RFC 2047 编码)。 + * + * @param fileName 原始文件名 + * @return 解码后的文件名,为空时返回 null + */ + private String decodeFileName(String fileName) { + if (fileName == null) { + return null; + } + try { + return MimeUtility.decodeText(fileName); + } catch (UnsupportedEncodingException e) { + return fileName; + } + } +} diff --git a/src/main/java/cn/rosercode/finance/email/EmailBillMonitor.java b/src/main/java/cn/rosercode/finance/email/EmailBillMonitor.java new file mode 100644 index 0000000..96cde4b --- /dev/null +++ b/src/main/java/cn/rosercode/finance/email/EmailBillMonitor.java @@ -0,0 +1,220 @@ +package cn.rosercode.finance.email; + +import jakarta.mail.Folder; +import jakarta.mail.MessagingException; +import jakarta.mail.Store; +import jakarta.mail.event.MessageCountAdapter; +import jakarta.mail.event.MessageCountEvent; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.angus.mail.imap.IMAPFolder; +import org.eclipse.angus.mail.imap.IMAPStore; + +import cn.rosercode.finance.entity.EmailBillConfig; + +/** + * 邮箱账单监听线程,为单个邮箱配置维持 IMAP IDLE 长连接。 + * + *

服务器支持 IDLE 时实时响应新邮件;不支持时降级为定时轮询。连接断开后 + * 按指数退避重连,收到新邮件或轮询到变更时交由 {@link EmailBillPuller} 增量拉取。

+ * + * @date 2026-09-19 16:44:45 + */ +@Slf4j +public class EmailBillMonitor implements Runnable { + + /** 初始重连间隔(毫秒) */ + private static final long BASE_RECONNECT_DELAY_MS = 5000L; + + /** 最大重连间隔(毫秒) */ + private static final long MAX_RECONNECT_DELAY_MS = 300000L; + + /** 降级轮询间隔(毫秒) */ + private static final long POLL_INTERVAL_MS = 60000L; + + private final EmailBillConfig config; + private final EmailImapConnector imapConnector; + private final EmailBillPuller puller; + + /** 监听是否继续运行 */ + private volatile boolean running = true; + + private volatile Store store; + private volatile Folder folder; + private volatile IMAPFolder imapFolder; + + /** + * 构造监听线程。 + * + * @param config 邮箱配置 + * @param imapConnector IMAP 连接器 + * @param puller 拉取器 + */ + public EmailBillMonitor(EmailBillConfig config, + EmailImapConnector imapConnector, + EmailBillPuller puller) { + this.config = config; + this.imapConnector = imapConnector; + this.puller = puller; + } + + /** + * 获取配置ID。 + * + * @return 配置ID + */ + public Long getConfigId() { + return config.getId(); + } + + /** + * 监听主循环:连接、IDLE 或轮询、断线重连,直至被停止。 + */ + @Override + public void run() { + long delay = BASE_RECONNECT_DELAY_MS; + while (running) { + try { + listen(); + // listen 正常返回表示已被停止 + break; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (MessagingException e) { + if (!running) { + break; + } + log.warn("邮箱账单监听断开,{}ms 后重连: configId={}, error={}", + delay, config.getId(), e.getMessage()); + sleep(delay); + delay = Math.min(delay * 2, MAX_RECONNECT_DELAY_MS); + } catch (RuntimeException e) { + if (!running) { + break; + } + log.error("邮箱账单监听异常,{}ms 后重连: configId={}", delay, config.getId(), e); + sleep(delay); + delay = Math.min(delay * 2, MAX_RECONNECT_DELAY_MS); + } + } + closeResources(); + log.info("邮箱账单监听已停止: configId={}", config.getId()); + } + + /** + * 建立连接并进入 IDLE 或轮询循环。 + * + * @throws MessagingException 连接或监听失败 + * @throws InterruptedException 线程被中断 + */ + private void listen() throws MessagingException, InterruptedException { + Store localStore = imapConnector.connect(config); + Folder localFolder = localStore.getFolder(config.getFolder()); + localFolder.open(Folder.READ_ONLY); + this.store = localStore; + this.folder = localFolder; + + // 连接建立后先拉取一次,避免错过上次断开期间的邮件 + safePull(); + + boolean idleSupported = localStore instanceof IMAPStore imapStore && imapStore.hasCapability("IDLE"); + if (idleSupported && localFolder instanceof IMAPFolder imap) { + this.imapFolder = imap; + localFolder.addMessageCountListener(new MessageCountAdapter() { + @Override + public void messagesAdded(MessageCountEvent event) { + safePull(); + } + }); + log.info("邮箱账单监听已启动(IDLE): configId={}, folder={}", config.getId(), config.getFolder()); + while (running) { + imap.idle(); + } + } else { + log.info("邮箱账单监听降级为轮询: configId={}, folder={}", config.getId(), config.getFolder()); + while (running) { + Thread.sleep(POLL_INTERVAL_MS); + if (running) { + safePull(); + } + } + } + } + + /** + * 执行一次拉取并吞掉异常,避免中断监听循环。 + */ + private void safePull() { + try { + puller.pull(config); + } catch (RuntimeException e) { + log.warn("邮箱账单拉取失败: configId={}, error={}", config.getId(), e.getMessage()); + } + } + + /** + * 停止监听:异步请求终止 IDLE 并关闭连接。 + * + *

连接与 IDLE 的关闭可能阻塞,故在独立守护线程中执行,避免拖慢应用关闭或配置刷新。

+ */ + public void stop() { + running = false; + Thread closer = new Thread(this::closeSafely, "email-bill-monitor-stop-" + config.getId()); + closer.setDaemon(true); + closer.start(); + } + + /** + * 终止 IDLE 并关闭连接,异常时忽略。 + */ + private void closeSafely() { + IMAPFolder local = imapFolder; + if (local != null && local.isOpen()) { + try { + // 主动结束 IDLE,使阻塞中的 idle() 返回 + local.idle(false); + } catch (MessagingException ignored) { + // 未处于 IDLE 时可能抛异常,忽略即可 + } + } + closeResources(); + } + + /** + * 关闭文件夹与连接。 + */ + private void closeResources() { + Folder localFolder = folder; + Store localStore = store; + folder = null; + store = null; + imapFolder = null; + if (localFolder != null && localFolder.isOpen()) { + try { + localFolder.close(false); + } catch (MessagingException ignored) { + // 关闭失败不影响停止流程 + } + } + if (localStore != null && localStore.isConnected()) { + try { + localStore.close(); + } catch (MessagingException ignored) { + // 关闭失败不影响停止流程 + } + } + } + + /** + * 休眠指定毫秒,被中断时保留中断标记。 + * + * @param millis 休眠毫秒数 + */ + private void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/main/java/cn/rosercode/finance/email/EmailBillMonitorManager.java b/src/main/java/cn/rosercode/finance/email/EmailBillMonitorManager.java new file mode 100644 index 0000000..3e30d61 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/email/EmailBillMonitorManager.java @@ -0,0 +1,176 @@ +package cn.rosercode.finance.email; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import cn.rosercode.finance.common.BusinessException; +import cn.rosercode.finance.entity.EmailBillConfig; +import cn.rosercode.finance.mapper.EmailBillConfigMapper; + +/** + * 邮箱账单监听管理器,负责各邮箱配置监听线程的生命周期。 + * + *

应用启动与定时同步时加载所有已启用的配置并启动监听,配置变更时按需启停; + * 提供手动立即检查入口,并在关闭时优雅停止全部线程。

+ * + * @date 2026-09-19 16:44:58 + */ +@Component +@Slf4j +public class EmailBillMonitorManager { + + /** 同时监听的邮箱配置数量上限 */ + private static final int MAX_MONITORS = 10; + + /** 监听线程命名计数器 */ + private static final AtomicLong THREAD_COUNTER = new AtomicLong(); + + private final EmailBillConfigMapper configMapper; + private final EmailImapConnector imapConnector; + private final EmailBillPuller puller; + + private final ConcurrentHashMap monitors = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> futures = new ConcurrentHashMap<>(); + private final ExecutorService executor = Executors.newCachedThreadPool(runnable -> { + Thread thread = new Thread(runnable, "email-bill-monitor-" + THREAD_COUNTER.incrementAndGet()); + thread.setDaemon(true); + return thread; + }); + + public EmailBillMonitorManager(EmailBillConfigMapper configMapper, + EmailImapConnector imapConnector, + EmailBillPuller puller) { + this.configMapper = configMapper; + this.imapConnector = imapConnector; + this.puller = puller; + } + + /** + * 应用启动时尝试加载并启动监听;数据库未就绪时忽略,交由定时同步补启动。 + */ + @PostConstruct + public void init() { + try { + sync(); + } catch (Exception e) { + log.debug("启动邮箱账单监听失败,等待定时同步:{}", e.getMessage()); + } + } + + /** + * 应用关闭时停止全部监听线程。 + */ + @PreDestroy + public void destroy() { + int count = monitors.size(); + for (Long configId : new ArrayList<>(monitors.keySet())) { + stopMonitor(configId); + } + executor.shutdownNow(); + log.info("邮箱账单监听管理器已关闭,已停止 {} 个监听", count); + } + + /** + * 同步监听线程:为新增的启用配置启动监听,停止已停用或删除的监听。 + */ + public void sync() { + List enabledConfigs; + try { + enabledConfigs = configMapper.selectList(new LambdaQueryWrapper() + .eq(EmailBillConfig::getEnabled, true) + .orderByAsc(EmailBillConfig::getId)); + } catch (Exception e) { + log.debug("跳过邮箱账单监听同步,读取配置失败:{}", e.getMessage()); + return; + } + + Set enabledIds = enabledConfigs.stream() + .map(EmailBillConfig::getId) + .collect(Collectors.toSet()); + for (EmailBillConfig config : enabledConfigs) { + if (!monitors.containsKey(config.getId())) { + startMonitor(config); + } + } + for (Long configId : new ArrayList<>(monitors.keySet())) { + if (!enabledIds.contains(configId)) { + stopMonitor(configId); + } + } + } + + /** + * 按最新配置刷新单个监听:启用则重启,停用或删除则停止。 + * + * @param configId 邮箱配置ID + */ + public void refresh(Long configId) { + EmailBillConfig config = configMapper.selectById(configId); + stopMonitor(configId); + if (config != null && Boolean.TRUE.equals(config.getEnabled())) { + startMonitor(config); + } + } + + /** + * 立即执行一次拉取(手动检查)。 + * + * @param configId 邮箱配置ID + * @return 新登记的待解析消息数量 + */ + public int checkNow(Long configId) { + EmailBillConfig config = configMapper.selectById(configId); + if (config == null) { + throw new BusinessException("邮箱配置不存在"); + } + return puller.pull(config); + } + + /** + * 启动单个监听线程。 + * + * @param config 邮箱配置 + */ + private synchronized void startMonitor(EmailBillConfig config) { + if (monitors.containsKey(config.getId())) { + return; + } + if (monitors.size() >= MAX_MONITORS) { + log.warn("邮箱账单监听数量已达上限 {},跳过配置: configId={}", MAX_MONITORS, config.getId()); + return; + } + EmailBillMonitor monitor = new EmailBillMonitor(config, imapConnector, puller); + monitors.put(config.getId(), monitor); + futures.put(config.getId(), executor.submit(monitor)); + log.info("启动邮箱账单监听: configId={}, host={}", config.getId(), config.getHost()); + } + + /** + * 停止单个监听线程。 + * + * @param configId 邮箱配置ID + */ + private void stopMonitor(Long configId) { + EmailBillMonitor monitor = monitors.remove(configId); + Future future = futures.remove(configId); + if (monitor != null) { + monitor.stop(); + } + if (future != null) { + future.cancel(true); + } + } +} diff --git a/src/main/java/cn/rosercode/finance/email/EmailBillProcessor.java b/src/main/java/cn/rosercode/finance/email/EmailBillProcessor.java new file mode 100644 index 0000000..38f0a36 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/email/EmailBillProcessor.java @@ -0,0 +1,263 @@ +package cn.rosercode.finance.email; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import jakarta.mail.Address; +import jakarta.mail.Message; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import cn.rosercode.finance.entity.EmailBillConfig; +import cn.rosercode.finance.entity.EmailBillMessage; +import cn.rosercode.finance.entity.EmailBillSenderRule; +import cn.rosercode.finance.enums.EmailBillStatus; +import cn.rosercode.finance.mapper.EmailBillMessageMapper; +import cn.rosercode.finance.service.EmailBillSenderRuleService; + +/** + * 邮箱账单处理器,将命中的邮件附件保存到本地并登记为待解析消息。 + * + *

仅处理发件人命中规则且扩展名受支持的附件;处理阶段不解析账单内容, + * 解析与入库由用户在前端手动触发。

+ * + * @date 2026-09-19 16:44:05 + */ +@Component +@Slf4j +public class EmailBillProcessor { + + /** 支持的账单附件扩展名 */ + private static final Set SUPPORTED_EXTENSIONS = Set.of(".csv", ".xlsx", ".xls", ".pdf", ".zip"); + + private final EmailBillMessageMapper messageMapper; + private final EmailBillSenderRuleService senderRuleService; + private final EmailBillSenderRuleMatcher senderRuleMatcher; + private final EmailAttachmentExtractor attachmentExtractor; + private final EmailBillStorage storage; + + public EmailBillProcessor(EmailBillMessageMapper messageMapper, + EmailBillSenderRuleService senderRuleService, + EmailBillSenderRuleMatcher senderRuleMatcher, + EmailAttachmentExtractor attachmentExtractor, + EmailBillStorage storage) { + this.messageMapper = messageMapper; + this.senderRuleService = senderRuleService; + this.senderRuleMatcher = senderRuleMatcher; + this.attachmentExtractor = attachmentExtractor; + this.storage = storage; + } + + /** + * 处理单封邮件:匹配发件人规则,保存附件并登记待解析消息。 + * + * @param config 邮箱配置 + * @param message 邮件消息 + * @param uid 邮件 UID + * @return 新登记的消息数量 + */ + public int process(EmailBillConfig config, Message message, long uid) { + return process(config, message, uid, line -> { + }); + } + + /** + * 处理单封邮件,并通过 {@code progress} 上报匹配与保存进度。 + * + * @param config 邮箱配置 + * @param message 邮件消息 + * @param uid 邮件 UID + * @param progress 进度回调 + * @return 新登记的消息数量 + */ + public int process(EmailBillConfig config, Message message, long uid, Consumer progress) { + String from = extractFrom(message); + List rules = senderRuleService.listEnabledRules(config.getUserId()); + EmailBillSenderRule rule = senderRuleMatcher.match(from, rules); + if (rule == null) { + log.debug("邮件发件人未命中发件人规则,已忽略: configId={}, uid={}, from={}", + config.getId(), uid, from); + progress.accept(" → 发件人未命中规则,忽略"); + return 0; + } + progress.accept(" → 命中规则「" + rule.getName() + "」,账单类型 " + rule.getBillType()); + + List attachments; + try { + attachments = attachmentExtractor.extract(message); + } catch (MessagingException | IOException e) { + log.warn("提取邮件附件失败: configId={}, uid={}, error={}", config.getId(), uid, e.getMessage()); + progress.accept(" → 提取附件失败:" + e.getMessage()); + return 0; + } + + int saved = 0; + for (int i = 0; i < attachments.size(); i++) { + EmailAttachment attachment = attachments.get(i); + if (!isSupported(attachment.getFileName()) || exists(config.getId(), uid, i)) { + continue; + } + String path = storage.save(config.getId(), uid, i, attachment.getFileName(), attachment.getContent()); + EmailBillMessage entity = buildMessage(config, message, uid, i, from, rule, attachment, path); + messageMapper.insert(entity); + saved++; + } + if (saved > 0) { + log.info("邮箱账单附件已保存: configId={}, uid={}, from={}, billType={}, 附件数={}", + config.getId(), uid, from, rule.getBillType(), saved); + } + progress.accept(" → 保存 " + saved + " 个附件"); + return saved; + } + + /** + * 构造待解析消息实体。 + * + * @param config 邮箱配置 + * @param message 邮件消息 + * @param uid 邮件 UID + * @param index 附件序号 + * @param from 发件人地址 + * @param rule 命中的发件人规则 + * @param attachment 附件 + * @param path 附件本地相对路径 + * @return 消息实体 + */ + private EmailBillMessage buildMessage(EmailBillConfig config, Message message, long uid, int index, + String from, EmailBillSenderRule rule, EmailAttachment attachment, + String path) { + EmailBillMessage entity = new EmailBillMessage(); + entity.setConfigId(config.getId()); + entity.setUserId(config.getUserId()); + entity.setUid(uid); + entity.setMessageId(messageId(message)); + entity.setSubject(truncate(safeSubject(message), 500)); + entity.setFromAddress(truncate(from, 255)); + entity.setReceivedAt(receivedAt(message)); + entity.setAttachmentIndex(index); + entity.setAttachmentName(truncate(attachment.getFileName(), 255)); + entity.setAttachmentPath(path); + entity.setSenderRuleId(rule.getId()); + entity.setBillType(rule.getBillType()); + entity.setStatus(EmailBillStatus.PULLED.name()); + return entity; + } + + /** + * 判断该邮件附件是否已登记,避免重复处理。 + * + * @param configId 邮箱配置ID + * @param uid 邮件 UID + * @param index 附件序号 + * @return 已存在返回 true + */ + private boolean exists(Long configId, long uid, int index) { + return messageMapper.selectCount(new LambdaQueryWrapper() + .eq(EmailBillMessage::getConfigId, configId) + .eq(EmailBillMessage::getUid, uid) + .eq(EmailBillMessage::getAttachmentIndex, index)) > 0; + } + + /** + * 提取邮件发件人地址。 + * + * @param message 邮件消息 + * @return 发件人字符串,读取失败时返回 null + */ + private String extractFrom(Message message) { + try { + Address[] from = message.getFrom(); + if (from == null || from.length == 0) { + return null; + } + return from[0].toString(); + } catch (MessagingException e) { + return null; + } + } + + /** + * 读取邮件 Message-ID。 + * + * @param message 邮件消息 + * @return Message-ID,读取失败或非 MimeMessage 时返回 null + */ + private String messageId(Message message) { + if (message instanceof MimeMessage mimeMessage) { + try { + return truncate(mimeMessage.getMessageID(), 255); + } catch (MessagingException e) { + return null; + } + } + return null; + } + + /** + * 读取邮件主题。 + * + * @param message 邮件消息 + * @return 主题,读取失败时返回 null + */ + private String safeSubject(Message message) { + try { + return message.getSubject(); + } catch (MessagingException e) { + return null; + } + } + + /** + * 读取邮件接收时间,缺失时回退当前时间。 + * + * @param message 邮件消息 + * @return 接收时间 + */ + private LocalDateTime receivedAt(Message message) { + try { + Date date = message.getReceivedDate(); + if (date == null) { + return LocalDateTime.now(); + } + return LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault()); + } catch (MessagingException e) { + return LocalDateTime.now(); + } + } + + /** + * 判断附件扩展名是否受支持。 + * + * @param fileName 附件文件名 + * @return 受支持返回 true + */ + private boolean isSupported(String fileName) { + if (fileName == null) { + return false; + } + String lower = fileName.toLowerCase(); + return SUPPORTED_EXTENSIONS.stream().anyMatch(lower::endsWith); + } + + /** + * 截断字符串以适配数据库字段长度。 + * + * @param value 原字符串 + * @param maxLength 最大长度 + * @return 截断后的字符串 + */ + private String truncate(String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength); + } +} diff --git a/src/main/java/cn/rosercode/finance/email/EmailBillPuller.java b/src/main/java/cn/rosercode/finance/email/EmailBillPuller.java new file mode 100644 index 0000000..ecb865c --- /dev/null +++ b/src/main/java/cn/rosercode/finance/email/EmailBillPuller.java @@ -0,0 +1,341 @@ +package cn.rosercode.finance.email; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import jakarta.mail.Folder; +import jakarta.mail.Message; +import jakarta.mail.MessagingException; +import jakarta.mail.Store; +import jakarta.mail.UIDFolder; +import jakarta.mail.search.ComparisonTerm; +import jakarta.mail.search.ReceivedDateTerm; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import cn.rosercode.finance.common.BusinessException; +import cn.rosercode.finance.entity.EmailBillConfig; +import cn.rosercode.finance.mapper.EmailBillConfigMapper; + +/** + * 邮箱账单拉取器,按配置的拉取范围执行一次拉取(连接、获取目标邮件、交给处理器)。 + * + *

支持三种范围:仅新邮件(增量)、最近 N 天、最近 N 封;IDLE 实时监听、定时兜底与手动检查 + * 均复用本类。同一配置的拉取通过锁串行化,配合消息唯一索引避免并发重复处理。

+ * + * @date 2026-09-19 16:44:37 + */ +@Component +@Slf4j +public class EmailBillPuller { + + /** 连接状态:已连接 */ + private static final String STATUS_CONNECTED = "CONNECTED"; + + /** 连接状态:异常 */ + private static final String STATUS_ERROR = "ERROR"; + + /** 失败原因最大长度 */ + private static final int ERROR_LIMIT = 500; + + /** 拉取范围:仅新邮件 */ + private static final String SCOPE_INCREMENT = "INCREMENT"; + + /** 拉取范围:最近 N 天 */ + private static final String SCOPE_DAYS = "DAYS"; + + /** 拉取范围:最近 N 封 */ + private static final String SCOPE_COUNT = "COUNT"; + + /** 最近 N 天模式的默认天数 */ + private static final int DEFAULT_DAYS = 1; + + /** 最近 N 封模式的默认封数 */ + private static final int DEFAULT_COUNT = 20; + + private final EmailImapConnector imapConnector; + private final EmailBillProcessor processor; + private final EmailBillConfigMapper configMapper; + + /** 每个配置一把锁,串行化同一配置的拉取 */ + private final ConcurrentHashMap locks = new ConcurrentHashMap<>(); + + public EmailBillPuller(EmailImapConnector imapConnector, + EmailBillProcessor processor, + EmailBillConfigMapper configMapper) { + this.imapConnector = imapConnector; + this.processor = processor; + this.configMapper = configMapper; + } + + /** + * 执行一次拉取。 + * + *

增量模式下首次以当前最大 UID 为基线、不回溯历史;最近 N 天/最近 N 封模式每次按范围扫描, + * 依赖消息唯一索引去重。

+ * + * @param config 邮箱配置 + * @return 新登记的待解析消息数量 + */ + public int pull(EmailBillConfig config) { + return pull(config, message -> { + }); + } + + /** + * 执行一次拉取,并通过 {@code progress} 实时上报执行进度。 + * + *

增量模式下首次以当前最大 UID 为基线、不回溯历史;最近 N 天/最近 N 封模式每次按范围扫描, + * 依赖消息唯一索引去重。

+ * + * @param config 邮箱配置 + * @param progress 进度回调,逐行接收中文进度描述 + * @return 新登记的待解析消息数量 + */ + public int pull(EmailBillConfig config, Consumer progress) { + Object lock = locks.computeIfAbsent(config.getId(), key -> new Object()); + synchronized (lock) { + Store store = null; + Folder folder = null; + try { + progress.accept("连接邮箱 " + config.getHost() + ":" + config.getPort() + + (Boolean.FALSE.equals(config.getUseSsl()) ? "(明文)" : "(SSL)")); + store = imapConnector.connect(config); + folder = store.getFolder(config.getFolder()); + folder.open(Folder.READ_ONLY); + progress.accept("已连接,打开文件夹 " + config.getFolder()); + UIDFolder uidFolder = (UIDFolder) folder; + + String scope = resolveScope(config); + progress.accept("拉取范围:" + scopeLabel(scope)); + long baseUid = initLastUid(config, folder, uidFolder); + Message[] messages = fetchMessages(config, folder, uidFolder, baseUid, scope); + progress.accept("检索到 " + messages.length + " 封邮件"); + int processed = 0; + long maxUid = baseUid; + int index = 0; + for (Message message : messages) { + long uid = uidFolder.getUID(message); + if (uid <= 0 || (SCOPE_INCREMENT.equals(scope) && uid <= baseUid)) { + continue; + } + index++; + progress.accept("(" + index + "/" + messages.length + ") uid=" + uid + + " " + safeSubject(message)); + try { + processed += processor.process(config, message, uid, progress); + } catch (RuntimeException e) { + // 单封邮件处理失败不影响其余邮件 + log.warn("处理邮件失败: configId={}, uid={}, error={}", config.getId(), uid, e.getMessage()); + progress.accept(" → 处理失败:" + e.getMessage()); + } + maxUid = Math.max(maxUid, uid); + } + if (maxUid > baseUid) { + updateLastUid(config, maxUid); + } + updateStatus(config, STATUS_CONNECTED, null, true); + progress.accept("完成,新增 " + processed + " 条待处理账单"); + return processed; + } catch (MessagingException e) { + updateStatus(config, STATUS_ERROR, e.getMessage(), false); + progress.accept("失败:" + e.getMessage()); + throw new BusinessException("拉取邮件失败:" + e.getMessage()); + } finally { + closeQuietly(folder); + closeQuietly(store); + } + } + } + + /** + * 将拉取范围编码转换为中文描述。 + * + * @param scope 拉取范围编码 + * @return 中文描述 + */ + private String scopeLabel(String scope) { + if (SCOPE_DAYS.equals(scope)) { + return "最近 N 天"; + } + if (SCOPE_COUNT.equals(scope)) { + return "最近 N 封"; + } + return "仅新邮件"; + } + + /** + * 读取邮件主题,失败时返回空串。 + * + * @param message 邮件消息 + * @return 主题 + */ + private String safeSubject(Message message) { + try { + String subject = message.getSubject(); + return subject == null ? "" : subject; + } catch (MessagingException e) { + return ""; + } + } + + /** + * 按配置的拉取范围获取待处理邮件。 + * + * @param config 邮箱配置 + * @param folder 已打开的文件夹 + * @param uidFolder UID 文件夹 + * @param baseUid 增量模式下的基线 UID + * @param scope 拉取范围 + * @return 待处理邮件数组 + * @throws MessagingException 搜索失败 + */ + private Message[] fetchMessages(EmailBillConfig config, Folder folder, UIDFolder uidFolder, + long baseUid, String scope) throws MessagingException { + if (SCOPE_DAYS.equals(scope)) { + int days = config.getFetchLimit() != null && config.getFetchLimit() > 0 + ? config.getFetchLimit() : DEFAULT_DAYS; + // 含当天,往前推 days-1 天的 0 点 + Date since = Date.from(LocalDate.now().minusDays(days - 1L) + .atStartOfDay(ZoneId.systemDefault()).toInstant()); + return folder.search(new ReceivedDateTerm(ComparisonTerm.GE, since)); + } + if (SCOPE_COUNT.equals(scope)) { + int count = config.getFetchLimit() != null && config.getFetchLimit() > 0 + ? config.getFetchLimit() : DEFAULT_COUNT; + int total = folder.getMessageCount(); + if (total <= 0) { + return new Message[0]; + } + return folder.getMessages(Math.max(1, total - count + 1), total); + } + return uidFolder.getMessagesByUID(baseUid + 1, UIDFolder.LASTUID); + } + + /** + * 解析配置的拉取范围,非法值回退为增量模式。 + * + * @param config 邮箱配置 + * @return INCREMENT / DAYS / COUNT + */ + private String resolveScope(EmailBillConfig config) { + String scope = config.getFetchScope(); + if (scope == null) { + return SCOPE_INCREMENT; + } + String upper = scope.trim().toUpperCase(); + if (SCOPE_DAYS.equals(upper) || SCOPE_COUNT.equals(upper)) { + return upper; + } + return SCOPE_INCREMENT; + } + + /** + * 初始化拉取基线 UID:已存在则直接使用,否则取当前最大 UID 并持久化。 + * + * @param config 邮箱配置 + * @param folder 已打开的文件夹 + * @param uidFolder UID 文件夹 + * @return 基线 UID + * @throws MessagingException 读取失败 + */ + private long initLastUid(EmailBillConfig config, Folder folder, UIDFolder uidFolder) throws MessagingException { + if (config.getLastUid() != null) { + return config.getLastUid(); + } + long maxUid = 0L; + int count = folder.getMessageCount(); + if (count > 0) { + Message last = folder.getMessage(count); + maxUid = uidFolder.getUID(last); + } + updateLastUid(config, maxUid); + log.info("初始化邮箱账单拉取基线: configId={}, baseUid={}", config.getId(), maxUid); + return maxUid; + } + + /** + * 更新已处理的最大 UID。 + * + * @param config 邮箱配置 + * @param uid 最大 UID + */ + private void updateLastUid(EmailBillConfig config, long uid) { + config.setLastUid(uid); + configMapper.update(null, new LambdaUpdateWrapper() + .eq(EmailBillConfig::getId, config.getId()) + .set(EmailBillConfig::getLastUid, uid)); + } + + /** + * 回写连接状态与最近连接时间。 + * + * @param config 邮箱配置 + * @param status 连接状态 + * @param error 失败原因,可为空 + * @param touchConnected 是否刷新最近连接时间 + */ + private void updateStatus(EmailBillConfig config, String status, String error, boolean touchConnected) { + config.setStatus(status); + config.setLastError(error); + LambdaUpdateWrapper update = new LambdaUpdateWrapper() + .eq(EmailBillConfig::getId, config.getId()) + .set(EmailBillConfig::getStatus, status) + .set(EmailBillConfig::getLastError, truncate(error)); + if (touchConnected) { + config.setLastConnectedAt(LocalDateTime.now()); + update.set(EmailBillConfig::getLastConnectedAt, config.getLastConnectedAt()); + } + configMapper.update(null, update); + } + + /** + * 静默关闭邮件文件夹。 + * + * @param folder 文件夹,可为 null + */ + private void closeQuietly(Folder folder) { + if (folder == null || !folder.isOpen()) { + return; + } + try { + folder.close(false); + } catch (MessagingException ignored) { + // 关闭失败不影响拉取结果 + } + } + + /** + * 静默关闭邮件连接。 + * + * @param store 连接,可为 null + */ + private void closeQuietly(Store store) { + if (store == null) { + return; + } + try { + store.close(); + } catch (MessagingException ignored) { + // 关闭失败不影响拉取结果 + } + } + + /** + * 截断过长的失败原因。 + * + * @param message 原文 + * @return 截断后的文本 + */ + private String truncate(String message) { + if (message == null) { + return null; + } + return message.length() <= ERROR_LIMIT ? message : message.substring(0, ERROR_LIMIT); + } +} diff --git a/src/main/java/cn/rosercode/finance/email/EmailBillSenderRuleMatcher.java b/src/main/java/cn/rosercode/finance/email/EmailBillSenderRuleMatcher.java new file mode 100644 index 0000000..8de820b --- /dev/null +++ b/src/main/java/cn/rosercode/finance/email/EmailBillSenderRuleMatcher.java @@ -0,0 +1,72 @@ +package cn.rosercode.finance.email; + +import java.util.List; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import cn.rosercode.finance.entity.EmailBillSenderRule; + +/** + * 发件人规则匹配器,按邮件发件人地址匹配已启用的发件人识别规则。 + * + *

规则中的匹配串与发件人地址做包含匹配(忽略大小写),可同时覆盖完整地址、 + * {@code @域名} 与关键字;按规则传入顺序(已按优先级排序)取首个命中。

+ * + * @date 2026-09-19 16:44:01 + */ +@Component +@Slf4j +public class EmailBillSenderRuleMatcher { + + private final ObjectMapper objectMapper; + + public EmailBillSenderRuleMatcher(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * 匹配发件人所属的规则。 + * + * @param fromAddress 发件人地址 + * @param rules 已启用的规则列表,需按优先级排序 + * @return 命中的规则,未命中时返回 null + */ + public EmailBillSenderRule match(String fromAddress, List rules) { + if (!StringUtils.hasText(fromAddress) || rules == null || rules.isEmpty()) { + return null; + } + String from = fromAddress.toLowerCase(); + for (EmailBillSenderRule rule : rules) { + for (String pattern : parsePatterns(rule.getSenderPatterns())) { + if (from.contains(pattern.toLowerCase())) { + return rule; + } + } + } + return null; + } + + /** + * 解析规则中的发件人匹配串列表。 + * + * @param json 匹配串 JSON 数组 + * @return 匹配串列表,解析失败或为空时返回空列表 + */ + public List parsePatterns(String json) { + if (!StringUtils.hasText(json)) { + return List.of(); + } + try { + return objectMapper.readValue(json, new TypeReference>() { + }); + } catch (JsonProcessingException e) { + log.warn("解析发件人匹配串失败:{}", e.getMessage()); + return List.of(); + } + } +} diff --git a/src/main/java/cn/rosercode/finance/email/EmailBillStorage.java b/src/main/java/cn/rosercode/finance/email/EmailBillStorage.java new file mode 100644 index 0000000..8542c91 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/email/EmailBillStorage.java @@ -0,0 +1,108 @@ +package cn.rosercode.finance.email; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import cn.rosercode.finance.common.BusinessException; + +/** + * 邮箱账单附件本地存储。 + * + *

附件保存在工作目录下的 {@code data/email-bill/{configId}/{uid}/{index}_{name}}, + * 数据库仅记录相对路径;导入或忽略后不自动删除,便于追溯与重新解析。

+ * + * @date 2026-09-19 16:43:56 + */ +@Component +@Slf4j +public class EmailBillStorage { + + /** 附件存储根目录(相对工作目录) */ + private static final Path ROOT = Paths.get("data", "email-bill"); + + /** + * 保存附件到本地,返回相对存储根目录的路径。 + * + * @param configId 邮箱配置ID + * @param uid 邮件 UID + * @param index 附件序号 + * @param fileName 附件文件名 + * @param content 附件字节内容 + * @return 相对存储根目录的路径 + */ + public String save(Long configId, long uid, int index, String fileName, byte[] content) { + String relative = configId + "/" + uid + "/" + index + "_" + sanitize(fileName); + Path target = ROOT.resolve(relative); + try { + Files.createDirectories(target.getParent()); + Files.write(target, content); + return relative; + } catch (IOException e) { + throw new BusinessException("保存邮件附件失败:" + e.getMessage()); + } + } + + /** + * 读取附件内容。 + * + * @param relativePath 相对存储根目录的路径 + * @return 附件字节内容 + */ + public byte[] read(String relativePath) { + Path target = resolveSafely(relativePath); + try { + return Files.readAllBytes(target); + } catch (IOException e) { + throw new BusinessException("读取邮件附件失败:" + e.getMessage()); + } + } + + /** + * 删除附件文件,文件不存在时忽略。 + * + * @param relativePath 相对存储根目录的路径 + */ + public void delete(String relativePath) { + if (relativePath == null || relativePath.isBlank()) { + return; + } + try { + Files.deleteIfExists(resolveSafely(relativePath)); + } catch (IOException e) { + log.warn("删除邮件附件失败:{}", e.getMessage()); + } + } + + /** + * 解析相对路径并防止路径穿越。 + * + * @param relativePath 相对存储根目录的路径 + * @return 绝对路径 + */ + private Path resolveSafely(String relativePath) { + Path target = ROOT.resolve(relativePath).normalize(); + if (!target.startsWith(ROOT.normalize())) { + throw new BusinessException("非法的附件路径"); + } + return target; + } + + /** + * 清洗附件文件名,去除路径分隔符与非法字符。 + * + * @param fileName 原始文件名 + * @return 安全的文件名 + */ + private String sanitize(String fileName) { + if (fileName == null || fileName.isBlank()) { + return "attachment"; + } + String name = Paths.get(fileName).getFileName().toString(); + return name.replaceAll("[\\\\/:*?\"<>|]", "_"); + } +} diff --git a/src/main/java/cn/rosercode/finance/email/EmailImapConnector.java b/src/main/java/cn/rosercode/finance/email/EmailImapConnector.java new file mode 100644 index 0000000..a35292d --- /dev/null +++ b/src/main/java/cn/rosercode/finance/email/EmailImapConnector.java @@ -0,0 +1,53 @@ +package cn.rosercode.finance.email; + +import java.util.Properties; + +import jakarta.mail.MessagingException; +import jakarta.mail.Session; +import jakarta.mail.Store; +import org.springframework.stereotype.Component; + +import cn.rosercode.finance.entity.EmailBillConfig; + +/** + * IMAP 连接器,负责按邮箱配置建立 IMAP/IMAPS 连接。 + * + *

QQ、163 等邮箱通常使用 993 端口并开启 SSL;超时时间设置较短, + * 避免监听线程在断网时长时间阻塞。

+ * + * @date 2026-09-19 16:40:47 + */ +@Component +public class EmailImapConnector { + + /** 连接超时时间(毫秒) */ + private static final String CONNECT_TIMEOUT = "15000"; + + /** 读取超时时间(毫秒) */ + private static final String READ_TIMEOUT = "30000"; + + /** + * 根据配置建立 IMAP 连接。 + * + * @param config 邮箱配置 + * @return 已连接的 {@link Store},调用方负责关闭 + * @throws MessagingException 连接失败 + */ + public Store connect(EmailBillConfig config) throws MessagingException { + boolean ssl = config.getUseSsl() == null || config.getUseSsl(); + String protocol = ssl ? "imaps" : "imap"; + + Properties props = new Properties(); + props.put("mail.store.protocol", protocol); + props.put("mail." + protocol + ".connectiontimeout", CONNECT_TIMEOUT); + props.put("mail." + protocol + ".timeout", READ_TIMEOUT); + if (ssl) { + props.put("mail.imaps.ssl.enable", "true"); + } + + Session session = Session.getInstance(props); + Store store = session.getStore(protocol); + store.connect(config.getHost(), config.getPort(), config.getUsername(), config.getPassword()); + return store; + } +} diff --git a/src/main/java/cn/rosercode/finance/entity/AiConfig.java b/src/main/java/cn/rosercode/finance/entity/AiConfig.java index f11a585..1936edd 100644 --- a/src/main/java/cn/rosercode/finance/entity/AiConfig.java +++ b/src/main/java/cn/rosercode/finance/entity/AiConfig.java @@ -20,6 +20,12 @@ public class AiConfig { @TableId(type = IdType.AUTO) private Long id; + /** 创建时间 */ + private LocalDateTime createdAt; + + /** 更新时间 */ + private LocalDateTime updatedAt; + /** 所属用户ID */ private Long userId; @@ -34,10 +40,4 @@ public class AiConfig { /** 使用的模型名称 */ private String model; - - /** 创建时间 */ - private LocalDateTime createdAt; - - /** 更新时间 */ - private LocalDateTime updatedAt; } diff --git a/src/main/java/cn/rosercode/finance/entity/AiUsage.java b/src/main/java/cn/rosercode/finance/entity/AiUsage.java index 9ab0b27..6a0658d 100644 --- a/src/main/java/cn/rosercode/finance/entity/AiUsage.java +++ b/src/main/java/cn/rosercode/finance/entity/AiUsage.java @@ -20,6 +20,9 @@ public class AiUsage { @TableId(type = IdType.AUTO) private Long id; + /** 创建时间 */ + private LocalDateTime createdAt; + /** 所属用户ID */ private Long userId; @@ -52,7 +55,4 @@ public class AiUsage { /** 调用耗时(毫秒) */ private Long durationMs; - - /** 创建时间 */ - private LocalDateTime createdAt; } diff --git a/src/main/java/cn/rosercode/finance/entity/Category.java b/src/main/java/cn/rosercode/finance/entity/Category.java index 2747dd5..e4ba68d 100644 --- a/src/main/java/cn/rosercode/finance/entity/Category.java +++ b/src/main/java/cn/rosercode/finance/entity/Category.java @@ -18,6 +18,9 @@ public class Category { @TableId(type = IdType.AUTO) private Long id; + /** 创建时间 */ + private LocalDateTime createdAt; + /** 所属用户ID */ private Long userId; @@ -32,7 +35,4 @@ public class Category { /** 排序序号 */ private Integer sortOrder; - - /** 创建时间 */ - private LocalDateTime createdAt; } diff --git a/src/main/java/cn/rosercode/finance/entity/EmailBillConfig.java b/src/main/java/cn/rosercode/finance/entity/EmailBillConfig.java new file mode 100644 index 0000000..9774b6b --- /dev/null +++ b/src/main/java/cn/rosercode/finance/entity/EmailBillConfig.java @@ -0,0 +1,87 @@ +package cn.rosercode.finance.entity; + +import java.time.LocalDateTime; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +/** + * 邮箱账单配置实体类,对应数据库表 t_email_bill_config。 + * + *

保存用于自动拉取账单附件的 IMAP 邮箱连接信息,以及默认分类与标签; + * 每个配置对应一个后台监听线程。

+ * + * @date 2026-09-19 16:40:20 + */ +@Data +@TableName("t_email_bill_config") +public class EmailBillConfig { + + /** 配置ID,主键自增 */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 创建时间 */ + private LocalDateTime createdAt; + + /** 更新时间 */ + private LocalDateTime updatedAt; + + /** 所属用户ID */ + private Long userId; + + /** 配置名称,便于用户识别 */ + private String name; + + /** IMAP 服务器地址 */ + private String host; + + /** IMAP 端口 */ + private Integer port; + + /** 是否使用 SSL:true-是,false-否(列名 use_ssl,避免与 MySQL 关键字冲突) */ + @TableField("use_ssl") + private Boolean useSsl; + + /** 邮箱账号 */ + private String username; + + /** 邮箱授权码(明文存储,前端脱敏返回) */ + private String password; + + /** 监听的邮件文件夹 */ + private String folder; + + /** 是否启用监听:true-启用,false-关闭 */ + private Boolean enabled; + + /** 连接状态:DISCONNECTED-未连接,CONNECTED-已连接,POLLING-轮询中,ERROR-异常 */ + private String status; + + /** 最近一次连接或拉取失败原因 */ + private String lastError; + + /** 最近一次成功连接时间 */ + private LocalDateTime lastConnectedAt; + + /** 已处理的最大邮件 UID,用于增量拉取 */ + private Long lastUid; + + /** 默认支出分类ID,可为空 */ + private Long expenseCategoryId; + + /** 默认收入分类ID,可为空 */ + private Long incomeCategoryId; + + /** 默认标签ID列表(JSON数组) */ + private String tagIds; + + /** 拉取范围:INCREMENT-仅新邮件,DAYS-最近N天,COUNT-最近N封 */ + private String fetchScope; + + /** 拉取范围数量:DAYS 为天数,COUNT 为封数 */ + private Integer fetchLimit; +} diff --git a/src/main/java/cn/rosercode/finance/entity/EmailBillMessage.java b/src/main/java/cn/rosercode/finance/entity/EmailBillMessage.java new file mode 100644 index 0000000..f1db0fe --- /dev/null +++ b/src/main/java/cn/rosercode/finance/entity/EmailBillMessage.java @@ -0,0 +1,73 @@ +package cn.rosercode.finance.entity; + +import java.time.LocalDateTime; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +/** + * 邮箱账单消息实体类,对应数据库表 t_email_bill_message。 + * + *

记录从邮箱拉取到的账单附件(仅保存附件到本地并登记元信息), + * 解析与入库均由用户在前端手动触发,故不持久化解析结果。

+ * + * @date 2026-09-19 16:40:26 + */ +@Data +@TableName("t_email_bill_message") +public class EmailBillMessage { + + /** 消息ID,主键自增 */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 创建时间 */ + private LocalDateTime createdAt; + + /** 更新时间 */ + private LocalDateTime updatedAt; + + /** 邮箱配置ID */ + private Long configId; + + /** 所属用户ID */ + private Long userId; + + /** 邮件 IMAP UID */ + private Long uid; + + /** 邮件 Message-ID */ + private String messageId; + + /** 邮件主题 */ + private String subject; + + /** 发件人地址 */ + private String fromAddress; + + /** 邮件接收时间 */ + private LocalDateTime receivedAt; + + /** 附件在邮件中的序号(从0开始) */ + private Integer attachmentIndex; + + /** 附件文件名 */ + private String attachmentName; + + /** 附件本地存储相对路径 */ + private String attachmentPath; + + /** 命中的发件人规则ID */ + private Long senderRuleId; + + /** 命中的账单类型,未命中时为空 */ + private String billType; + + /** 处理状态:PULLED-待解析,CONFIRMED-已导入,IGNORED-已忽略 */ + private String status; + + /** 解析失败原因 */ + private String error; +} diff --git a/src/main/java/cn/rosercode/finance/entity/EmailBillSenderRule.java b/src/main/java/cn/rosercode/finance/entity/EmailBillSenderRule.java new file mode 100644 index 0000000..c1b8443 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/entity/EmailBillSenderRule.java @@ -0,0 +1,61 @@ +package cn.rosercode.finance.entity; + +import java.time.LocalDateTime; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +/** + * 邮箱账单发件人规则实体类,对应数据库表 t_email_bill_sender_rule。 + * + *

按邮件发件人匹配账单类型与默认归类:发件人匹配串命中后,即确定该附件所属的 + * 账单类型、压缩包解压密码以及默认分类与标签。

+ * + * @date 2026-09-19 16:40:24 + */ +@Data +@TableName("t_email_bill_sender_rule") +public class EmailBillSenderRule { + + /** 规则ID,主键自增 */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 创建时间 */ + private LocalDateTime createdAt; + + /** 更新时间 */ + private LocalDateTime updatedAt; + + /** 所属用户ID */ + private Long userId; + + /** 规则名称 */ + private String name; + + /** 是否启用:true-启用,false-停用 */ + private Boolean enabled; + + /** 优先级,数值越小越优先 */ + private Integer sortOrder; + + /** 发件人匹配串列表(JSON数组),包含匹配且忽略大小写 */ + private String senderPatterns; + + /** 命中的账单类型:ALIPAY/WECHAT/MEITUAN/CMB/BOCOM */ + private String billType; + + /** 该来源账单压缩包解压密码,可为空 */ + private String archivePassword; + + /** 默认支出分类ID,可为空 */ + private Long expenseCategoryId; + + /** 默认收入分类ID,可为空 */ + private Long incomeCategoryId; + + /** 默认标签ID列表(JSON数组) */ + private String tagIds; +} diff --git a/src/main/java/cn/rosercode/finance/entity/HttpLog.java b/src/main/java/cn/rosercode/finance/entity/HttpLog.java index 10fe1ed..05fee88 100644 --- a/src/main/java/cn/rosercode/finance/entity/HttpLog.java +++ b/src/main/java/cn/rosercode/finance/entity/HttpLog.java @@ -20,6 +20,9 @@ public class HttpLog { @TableId(type = IdType.AUTO) private Long id; + /** 创建时间 */ + private LocalDateTime createdAt; + /** 用户ID,未登录或认证失败时为空 */ private Long userId; @@ -43,7 +46,4 @@ public class HttpLog { /** User-Agent */ private String userAgent; - - /** 创建时间 */ - private LocalDateTime createdAt; } diff --git a/src/main/java/cn/rosercode/finance/entity/LoginLog.java b/src/main/java/cn/rosercode/finance/entity/LoginLog.java index 0ad5981..31c55b5 100644 --- a/src/main/java/cn/rosercode/finance/entity/LoginLog.java +++ b/src/main/java/cn/rosercode/finance/entity/LoginLog.java @@ -20,6 +20,9 @@ public class LoginLog { @TableId(type = IdType.AUTO) private Long id; + /** 创建时间 */ + private LocalDateTime createdAt; + /** 用户ID,登录失败或用户不存在时为空 */ private Long userId; @@ -37,7 +40,4 @@ public class LoginLog { /** User-Agent */ private String userAgent; - - /** 创建时间 */ - private LocalDateTime createdAt; } diff --git a/src/main/java/cn/rosercode/finance/entity/NotificationChannel.java b/src/main/java/cn/rosercode/finance/entity/NotificationChannel.java index 312d2c6..cfe5a33 100644 --- a/src/main/java/cn/rosercode/finance/entity/NotificationChannel.java +++ b/src/main/java/cn/rosercode/finance/entity/NotificationChannel.java @@ -20,6 +20,12 @@ public class NotificationChannel { @TableId(type = IdType.AUTO) private Long id; + /** 创建时间 */ + private LocalDateTime createdAt; + + /** 更新时间 */ + private LocalDateTime updatedAt; + /** 所属用户ID */ private Long userId; @@ -34,10 +40,4 @@ public class NotificationChannel { /** 渠道配置(JSON),如 webhook 地址、SMTP 账号等 */ private String config; - - /** 创建时间 */ - private LocalDateTime createdAt; - - /** 更新时间 */ - private LocalDateTime updatedAt; } diff --git a/src/main/java/cn/rosercode/finance/entity/NotificationConfig.java b/src/main/java/cn/rosercode/finance/entity/NotificationConfig.java index 1913512..64357ef 100644 --- a/src/main/java/cn/rosercode/finance/entity/NotificationConfig.java +++ b/src/main/java/cn/rosercode/finance/entity/NotificationConfig.java @@ -20,6 +20,12 @@ public class NotificationConfig { @TableId(type = IdType.AUTO) private Long id; + /** 创建时间 */ + private LocalDateTime createdAt; + + /** 更新时间 */ + private LocalDateTime updatedAt; + /** 所属用户ID */ private Long userId; @@ -40,10 +46,4 @@ public class NotificationConfig { /** 最近一次发送时间,用于避免同一周期重复提醒 */ private LocalDateTime lastSentAt; - - /** 创建时间 */ - private LocalDateTime createdAt; - - /** 更新时间 */ - private LocalDateTime updatedAt; } diff --git a/src/main/java/cn/rosercode/finance/entity/NotificationLog.java b/src/main/java/cn/rosercode/finance/entity/NotificationLog.java index 2169282..b6729e1 100644 --- a/src/main/java/cn/rosercode/finance/entity/NotificationLog.java +++ b/src/main/java/cn/rosercode/finance/entity/NotificationLog.java @@ -20,6 +20,9 @@ public class NotificationLog { @TableId(type = IdType.AUTO) private Long id; + /** 创建时间 */ + private LocalDateTime createdAt; + /** 所属用户ID */ private Long userId; @@ -34,7 +37,4 @@ public class NotificationLog { /** 结果描述或失败原因 */ private String message; - - /** 创建时间 */ - private LocalDateTime createdAt; } diff --git a/src/main/java/cn/rosercode/finance/entity/Rule.java b/src/main/java/cn/rosercode/finance/entity/Rule.java index 9dafddb..c3b5820 100644 --- a/src/main/java/cn/rosercode/finance/entity/Rule.java +++ b/src/main/java/cn/rosercode/finance/entity/Rule.java @@ -23,6 +23,12 @@ public class Rule { @TableId(type = IdType.AUTO) private Long id; + /** 创建时间 */ + private LocalDateTime createdAt; + + /** 更新时间 */ + private LocalDateTime updatedAt; + /** 所属用户ID */ private Long userId; @@ -49,10 +55,4 @@ public class Rule { /** 命中的目标分类ID,null 表示不指定分类 */ private Long categoryId; - - /** 创建时间 */ - private LocalDateTime createdAt; - - /** 更新时间 */ - private LocalDateTime updatedAt; } diff --git a/src/main/java/cn/rosercode/finance/entity/RuleTag.java b/src/main/java/cn/rosercode/finance/entity/RuleTag.java index 5caf948..f3aa8fb 100644 --- a/src/main/java/cn/rosercode/finance/entity/RuleTag.java +++ b/src/main/java/cn/rosercode/finance/entity/RuleTag.java @@ -20,12 +20,12 @@ public class RuleTag { @TableId(type = IdType.AUTO) private Long id; + /** 创建时间 */ + private LocalDateTime createdAt; + /** 规则ID */ private Long ruleId; /** 标签ID */ private Long tagId; - - /** 创建时间 */ - private LocalDateTime createdAt; } diff --git a/src/main/java/cn/rosercode/finance/entity/Tag.java b/src/main/java/cn/rosercode/finance/entity/Tag.java index d0378fd..dce85a7 100644 --- a/src/main/java/cn/rosercode/finance/entity/Tag.java +++ b/src/main/java/cn/rosercode/finance/entity/Tag.java @@ -20,6 +20,9 @@ public class Tag { @TableId(type = IdType.AUTO) private Long id; + /** 创建时间 */ + private LocalDateTime createdAt; + /** 所属用户ID */ private Long userId; @@ -31,7 +34,4 @@ public class Tag { /** 排序序号 */ private Integer sortOrder; - - /** 创建时间 */ - private LocalDateTime createdAt; } diff --git a/src/main/java/cn/rosercode/finance/entity/Transaction.java b/src/main/java/cn/rosercode/finance/entity/Transaction.java index 75b5453..432409a 100644 --- a/src/main/java/cn/rosercode/finance/entity/Transaction.java +++ b/src/main/java/cn/rosercode/finance/entity/Transaction.java @@ -19,6 +19,12 @@ public class Transaction { @TableId(type = IdType.AUTO) private Long id; + /** 创建时间 */ + private LocalDateTime createdAt; + + /** 更新时间 */ + private LocalDateTime updatedAt; + /** 所属用户ID */ private Long userId; @@ -51,10 +57,4 @@ public class Transaction { /** 账单导入的原始完整数据(结构化JSON),手动记录为空 */ private String rawData; - - /** 创建时间 */ - private LocalDateTime createdAt; - - /** 更新时间 */ - private LocalDateTime updatedAt; } diff --git a/src/main/java/cn/rosercode/finance/entity/TransactionTag.java b/src/main/java/cn/rosercode/finance/entity/TransactionTag.java index 6846541..91a93b0 100644 --- a/src/main/java/cn/rosercode/finance/entity/TransactionTag.java +++ b/src/main/java/cn/rosercode/finance/entity/TransactionTag.java @@ -20,12 +20,12 @@ public class TransactionTag { @TableId(type = IdType.AUTO) private Long id; + /** 创建时间 */ + private LocalDateTime createdAt; + /** 交易记录ID */ private Long transactionId; /** 标签ID */ private Long tagId; - - /** 创建时间 */ - private LocalDateTime createdAt; } diff --git a/src/main/java/cn/rosercode/finance/entity/User.java b/src/main/java/cn/rosercode/finance/entity/User.java index 05a12de..59e135d 100644 --- a/src/main/java/cn/rosercode/finance/entity/User.java +++ b/src/main/java/cn/rosercode/finance/entity/User.java @@ -18,6 +18,12 @@ public class User { @TableId(type = IdType.AUTO) private Long id; + /** 创建时间 */ + private LocalDateTime createdAt; + + /** 更新时间 */ + private LocalDateTime updatedAt; + /** 用户名 */ private String username; @@ -26,10 +32,4 @@ public class User { /** 昵称 */ private String nickname; - - /** 创建时间 */ - private LocalDateTime createdAt; - - /** 更新时间 */ - private LocalDateTime updatedAt; } diff --git a/src/main/java/cn/rosercode/finance/enums/EmailBillStatus.java b/src/main/java/cn/rosercode/finance/enums/EmailBillStatus.java new file mode 100644 index 0000000..2691bcc --- /dev/null +++ b/src/main/java/cn/rosercode/finance/enums/EmailBillStatus.java @@ -0,0 +1,37 @@ +package cn.rosercode.finance.enums; + +/** + * 邮箱账单消息处理状态枚举。 + * + *

拉取到的账单附件默认处于 {@link #PULLED}(待解析),用户手动解析并导入后置为 + * {@link #CONFIRMED},用户主动忽略后置为 {@link #IGNORED}。

+ * + * @date 2026-09-19 16:40:18 + */ +public enum EmailBillStatus { + + /** 已拉取,待用户手动解析 */ + PULLED("待解析"), + + /** 已确认导入 */ + CONFIRMED("已导入"), + + /** 已忽略 */ + IGNORED("已忽略"); + + /** 状态的中文名称 */ + private final String label; + + EmailBillStatus(String label) { + this.label = label; + } + + /** + * 获取状态的中文名称 + * + * @return 中文名称 + */ + public String getLabel() { + return label; + } +} diff --git a/src/main/java/cn/rosercode/finance/mapper/EmailBillConfigMapper.java b/src/main/java/cn/rosercode/finance/mapper/EmailBillConfigMapper.java new file mode 100644 index 0000000..c8b8481 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/mapper/EmailBillConfigMapper.java @@ -0,0 +1,15 @@ +package cn.rosercode.finance.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +import cn.rosercode.finance.entity.EmailBillConfig; + +/** + * 邮箱账单配置Mapper接口,继承MyBatis-Plus的BaseMapper,提供邮箱配置表的基础CRUD操作。 + * + * @date 2026-09-19 16:40:29 + */ +@Mapper +public interface EmailBillConfigMapper extends BaseMapper { +} diff --git a/src/main/java/cn/rosercode/finance/mapper/EmailBillMessageMapper.java b/src/main/java/cn/rosercode/finance/mapper/EmailBillMessageMapper.java new file mode 100644 index 0000000..beb2faf --- /dev/null +++ b/src/main/java/cn/rosercode/finance/mapper/EmailBillMessageMapper.java @@ -0,0 +1,15 @@ +package cn.rosercode.finance.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +import cn.rosercode.finance.entity.EmailBillMessage; + +/** + * 邮箱账单消息Mapper接口,继承MyBatis-Plus的BaseMapper,提供账单消息表的基础CRUD操作。 + * + * @date 2026-09-19 16:40:35 + */ +@Mapper +public interface EmailBillMessageMapper extends BaseMapper { +} diff --git a/src/main/java/cn/rosercode/finance/mapper/EmailBillSenderRuleMapper.java b/src/main/java/cn/rosercode/finance/mapper/EmailBillSenderRuleMapper.java new file mode 100644 index 0000000..576d051 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/mapper/EmailBillSenderRuleMapper.java @@ -0,0 +1,15 @@ +package cn.rosercode.finance.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +import cn.rosercode.finance.entity.EmailBillSenderRule; + +/** + * 邮箱账单发件人规则Mapper接口,继承MyBatis-Plus的BaseMapper,提供发件人规则表的基础CRUD操作。 + * + * @date 2026-09-19 16:40:32 + */ +@Mapper +public interface EmailBillSenderRuleMapper extends BaseMapper { +} diff --git a/src/main/java/cn/rosercode/finance/scheduler/EmailBillScheduler.java b/src/main/java/cn/rosercode/finance/scheduler/EmailBillScheduler.java new file mode 100644 index 0000000..8e362ef --- /dev/null +++ b/src/main/java/cn/rosercode/finance/scheduler/EmailBillScheduler.java @@ -0,0 +1,76 @@ +package cn.rosercode.finance.scheduler; + +import java.util.List; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import cn.rosercode.finance.email.EmailBillMonitorManager; +import cn.rosercode.finance.email.EmailBillPuller; +import cn.rosercode.finance.entity.EmailBillConfig; +import cn.rosercode.finance.mapper.EmailBillConfigMapper; + +/** + * 邮箱账单调度器。 + * + *

定期同步监听线程(保证启用配置都有常驻监听),并对所有启用配置执行兜底增量检查, + * 防止 IDLE 漏事件导致账单邮件未被拉取。

+ * + * @date 2026-09-19 16:45:16 + */ +@Component +@Slf4j +public class EmailBillScheduler { + + /** 兜底检查间隔(毫秒) */ + private static final long FALLBACK_INTERVAL_MS = 300000L; + + private final EmailBillMonitorManager monitorManager; + private final EmailBillPuller puller; + private final EmailBillConfigMapper configMapper; + + public EmailBillScheduler(EmailBillMonitorManager monitorManager, + EmailBillPuller puller, + EmailBillConfigMapper configMapper) { + this.monitorManager = monitorManager; + this.puller = puller; + this.configMapper = configMapper; + } + + /** + * 定期同步监听线程,补启动数据库就绪后新增或遗漏的监听。 + */ + @Scheduled(fixedDelay = 60000L, initialDelay = 30000L) + public void syncMonitors() { + try { + monitorManager.sync(); + } catch (Exception e) { + log.debug("同步邮箱账单监听失败:{}", e.getMessage()); + } + } + + /** + * 定时兜底增量检查:逐个启用配置拉取一次,失败不影响其他配置。 + */ + @Scheduled(fixedDelay = FALLBACK_INTERVAL_MS, initialDelay = 90000L) + public void fallbackCheck() { + List configs; + try { + configs = configMapper.selectList(new LambdaQueryWrapper() + .eq(EmailBillConfig::getEnabled, true) + .orderByAsc(EmailBillConfig::getId)); + } catch (Exception e) { + log.debug("跳过邮箱账单兜底检查,读取配置失败:{}", e.getMessage()); + return; + } + for (EmailBillConfig config : configs) { + try { + puller.pull(config); + } catch (Exception e) { + log.warn("邮箱账单兜底检查失败: configId={}, error={}", config.getId(), e.getMessage()); + } + } + } +} diff --git a/src/main/java/cn/rosercode/finance/service/EmailBillCheckStreamService.java b/src/main/java/cn/rosercode/finance/service/EmailBillCheckStreamService.java new file mode 100644 index 0000000..e556485 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/service/EmailBillCheckStreamService.java @@ -0,0 +1,147 @@ +package cn.rosercode.finance.service; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import cn.rosercode.finance.common.BusinessException; +import cn.rosercode.finance.email.EmailBillPuller; +import cn.rosercode.finance.entity.EmailBillConfig; + +/** + * 邮箱账单立即检查的实时进度推送服务。 + * + *

基于 SSE 将「立即检查」的执行进度实时推送给前端:先发送 {@code meta} 事件, + * 随后逐行推送 {@code line} 事件,正常结束发送 {@code done}(含新增待处理条数), + * 异常时发送 {@code failed}(含失败原因)。同一配置同时只允许一个检查任务。

+ * + * @date 2026-09-19 17:21:46 + */ +@Slf4j +@Service +public class EmailBillCheckStreamService { + + private final EmailBillPuller puller; + private final EmailBillConfigService configService; + + /** 执行检查任务的守护线程池 */ + private final ExecutorService executor = Executors.newCachedThreadPool(runnable -> { + Thread thread = new Thread(runnable, "email-bill-check-stream"); + thread.setDaemon(true); + return thread; + }); + + /** 正在检查的配置ID集合 */ + private final Map running = new ConcurrentHashMap<>(); + + public EmailBillCheckStreamService(EmailBillPuller puller, EmailBillConfigService configService) { + this.puller = puller; + this.configService = configService; + } + + /** + * 建立立即检查的实时进度流。 + * + * @param userId 当前用户ID + * @param configId 邮箱配置ID + * @return SSE 发射器 + */ + public SseEmitter stream(Long userId, Long configId) { + EmailBillConfig config = configService.getRawConfig(userId, configId); + if (config == null) { + throw new BusinessException("邮箱配置不存在"); + } + + SseEmitter emitter = new SseEmitter(0L); + if (running.putIfAbsent(configId, Boolean.TRUE) != null) { + executor.submit(() -> { + trySend(emitter, "line", "已有检查正在进行中,请稍候"); + complete(emitter); + }); + return emitter; + } + executor.submit(() -> runCheck(emitter, config)); + return emitter; + } + + /** + * 应用关闭时停止线程池。 + */ + @PreDestroy + public void shutdown() { + executor.shutdownNow(); + } + + /** + * 执行检查并把进度推送到发射器。 + * + * @param emitter SSE 发射器 + * @param config 邮箱配置 + */ + private void runCheck(SseEmitter emitter, EmailBillConfig config) { + try { + trySend(emitter, "meta", "{\"configId\":" + config.getId() + + ",\"name\":\"" + escape(config.getName()) + "\"" + + ",\"host\":\"" + escape(config.getHost()) + "\"}"); + int count = puller.pull(config, line -> trySend(emitter, "line", line)); + trySend(emitter, "done", "{\"count\":" + count + "}"); + } catch (RuntimeException e) { + log.warn("邮箱账单立即检查失败: configId={}, error={}", config.getId(), e.getMessage()); + trySend(emitter, "failed", "{\"message\":\"" + escape(e.getMessage()) + "\"}"); + } finally { + running.remove(config.getId()); + complete(emitter); + } + } + + /** + * 推送一个 SSE 事件,客户端已断开时静默失败。 + * + * @param emitter SSE 发射器 + * @param event 事件名 + * @param data 事件数据 + */ + private void trySend(SseEmitter emitter, String event, String data) { + try { + emitter.send(SseEmitter.event().name(event).data(data)); + } catch (IOException | IllegalStateException e) { + // 连接已断开或发射器已结束,忽略 + } + } + + /** + * 结束 SSE 连接,忽略已完成或已断开的异常。 + * + * @param emitter SSE 发射器 + */ + private void complete(SseEmitter emitter) { + try { + emitter.complete(); + } catch (Exception ignored) { + // 连接可能已断开 + } + } + + /** + * 转义 JSON 字符串中的特殊字符。 + * + * @param value 原始字符串 + * @return 转义后的字符串 + */ + private String escape(String value) { + if (value == null) { + return ""; + } + return value.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", " ") + .replace("\r", " "); + } +} diff --git a/src/main/java/cn/rosercode/finance/service/EmailBillConfigService.java b/src/main/java/cn/rosercode/finance/service/EmailBillConfigService.java new file mode 100644 index 0000000..29d5186 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/service/EmailBillConfigService.java @@ -0,0 +1,82 @@ +package cn.rosercode.finance.service; + +import java.util.List; + +import cn.rosercode.finance.dto.EmailBillConfigRequest; +import cn.rosercode.finance.entity.EmailBillConfig; +import cn.rosercode.finance.vo.EmailBillConfigVO; + +/** + * 邮箱账单配置服务,负责 IMAP 邮箱配置的增删改查、启停与连接测试。 + * + * @date 2026-09-19 16:41:17 + */ +public interface EmailBillConfigService { + + /** + * 查询当前用户的全部邮箱配置。 + * + * @param userId 当前用户ID + * @return 邮箱配置视图对象列表 + */ + List list(Long userId); + + /** + * 新增邮箱配置。 + * + * @param userId 当前用户ID + * @param request 配置请求 + * @return 新配置ID + */ + Long create(Long userId, EmailBillConfigRequest request); + + /** + * 更新邮箱配置,授权码留空表示不修改。 + * + * @param userId 当前用户ID + * @param id 配置ID + * @param request 配置请求 + */ + void update(Long userId, Long id, EmailBillConfigRequest request); + + /** + * 删除邮箱配置,同时清理其拉取到的消息记录。 + * + * @param userId 当前用户ID + * @param id 配置ID + */ + void delete(Long userId, Long id); + + /** + * 切换邮箱配置的启用状态。 + * + * @param userId 当前用户ID + * @param id 配置ID + * @param enabled 是否启用 + */ + void toggleEnabled(Long userId, Long id, Boolean enabled); + + /** + * 测试邮箱配置的 IMAP 连接是否可用,并回写连接状态。 + * + * @param userId 当前用户ID + * @param id 配置ID + */ + void testConnection(Long userId, Long id); + + /** + * 获取归属当前用户的邮箱配置实体。 + * + * @param userId 当前用户ID + * @param id 配置ID + * @return 邮箱配置实体 + */ + EmailBillConfig getRawConfig(Long userId, Long id); + + /** + * 查询所有已启用的邮箱配置,供后台监听线程加载。 + * + * @return 已启用的邮箱配置列表 + */ + List listEnabledConfigs(); +} diff --git a/src/main/java/cn/rosercode/finance/service/EmailBillMessageService.java b/src/main/java/cn/rosercode/finance/service/EmailBillMessageService.java new file mode 100644 index 0000000..db32fe8 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/service/EmailBillMessageService.java @@ -0,0 +1,64 @@ +package cn.rosercode.finance.service; + +import cn.rosercode.finance.common.PageResult; +import cn.rosercode.finance.dto.BillImportRequest; +import cn.rosercode.finance.dto.EmailBillParseRequest; +import cn.rosercode.finance.vo.BillImportResultVO; +import cn.rosercode.finance.vo.BillParseResultVO; +import cn.rosercode.finance.vo.EmailBillMessageVO; + +/** + * 邮箱账单消息服务,负责待处理列表与「解析、选择、入库」三步流程。 + * + * @date 2026-09-19 16:48:42 + */ +public interface EmailBillMessageService { + + /** + * 分页查询拉取到的账单消息。 + * + * @param userId 当前用户ID + * @param status 处理状态筛选,可为空 + * @param configId 邮箱配置ID筛选,可为空 + * @param page 页码,从 1 开始 + * @param size 每页大小 + * @return 分页结果 + */ + PageResult page(Long userId, String status, Long configId, int page, int size); + + /** + * 解析指定消息的本地附件,返回预览行(不落库)。 + * + * @param userId 当前用户ID + * @param messageId 消息ID + * @param request 解析请求,可覆盖账单类型与解压密码 + * @return 解析预览结果 + */ + BillParseResultVO parse(Long userId, Long messageId, EmailBillParseRequest request); + + /** + * 导入用户在预览中勾选的账单行,成功后标记消息为已导入。 + * + * @param userId 当前用户ID + * @param messageId 消息ID + * @param request 导入请求 + * @return 导入结果统计 + */ + BillImportResultVO importBill(Long userId, Long messageId, BillImportRequest request); + + /** + * 忽略指定消息。 + * + * @param userId 当前用户ID + * @param messageId 消息ID + */ + void ignore(Long userId, Long messageId); + + /** + * 删除指定消息及其本地附件。 + * + * @param userId 当前用户ID + * @param messageId 消息ID + */ + void delete(Long userId, Long messageId); +} diff --git a/src/main/java/cn/rosercode/finance/service/EmailBillSenderRuleService.java b/src/main/java/cn/rosercode/finance/service/EmailBillSenderRuleService.java new file mode 100644 index 0000000..f256e64 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/service/EmailBillSenderRuleService.java @@ -0,0 +1,66 @@ +package cn.rosercode.finance.service; + +import java.util.List; + +import cn.rosercode.finance.dto.EmailBillSenderRuleRequest; +import cn.rosercode.finance.entity.EmailBillSenderRule; +import cn.rosercode.finance.vo.EmailBillSenderRuleVO; + +/** + * 邮箱账单发件人规则服务,负责发件人识别规则的增删改查与启用停用。 + * + * @date 2026-09-19 16:40:50 + */ +public interface EmailBillSenderRuleService { + + /** + * 查询当前用户的全部发件人规则。 + * + * @param userId 当前用户ID + * @return 规则视图对象列表,按优先级升序 + */ + List list(Long userId); + + /** + * 新增发件人规则。 + * + * @param userId 当前用户ID + * @param request 规则请求 + * @return 新规则ID + */ + Long create(Long userId, EmailBillSenderRuleRequest request); + + /** + * 更新发件人规则。 + * + * @param userId 当前用户ID + * @param id 规则ID + * @param request 规则请求 + */ + void update(Long userId, Long id, EmailBillSenderRuleRequest request); + + /** + * 删除发件人规则。 + * + * @param userId 当前用户ID + * @param id 规则ID + */ + void delete(Long userId, Long id); + + /** + * 切换发件人规则的启用状态。 + * + * @param userId 当前用户ID + * @param id 规则ID + * @param enabled 是否启用 + */ + void toggleEnabled(Long userId, Long id, Boolean enabled); + + /** + * 查询当前用户所有已启用的发件人规则,供邮件拉取时匹配。 + * + * @param userId 当前用户ID + * @return 已启用规则列表,按优先级升序 + */ + List listEnabledRules(Long userId); +} diff --git a/src/main/java/cn/rosercode/finance/service/impl/DefaultDataInitializer.java b/src/main/java/cn/rosercode/finance/service/impl/DefaultDataInitializer.java index 4fa79d5..fc8d4ae 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/DefaultDataInitializer.java +++ b/src/main/java/cn/rosercode/finance/service/impl/DefaultDataInitializer.java @@ -2,17 +2,21 @@ package cn.rosercode.finance.service.impl; import java.util.List; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.stereotype.Component; import cn.rosercode.finance.entity.Category; +import cn.rosercode.finance.entity.EmailBillSenderRule; import cn.rosercode.finance.entity.Tag; import cn.rosercode.finance.mapper.CategoryMapper; +import cn.rosercode.finance.mapper.EmailBillSenderRuleMapper; import cn.rosercode.finance.mapper.TagMapper; /** * 默认数据初始化器。 * - *

为新注册用户创建一套默认分类与标签。相比原先需要手动执行的 SQL 种子脚本, + *

为新注册用户创建一套默认分类、标签与邮箱账单发件人规则。相比原先需要手动执行的 SQL 种子脚本, * 使用 Java 代码插入可同时兼容 MySQL 与 SQLite 两种数据库。

* * @date 2026-09-18 11:02:15 @@ -59,16 +63,36 @@ public class DefaultDataInitializer { new TagSeed("宠物", "#a16207", 120), new TagSeed("电影", "#14b8a6", 130)); + /** 默认邮箱账单发件人规则(名称、发件人匹配串、账单类型、排序) */ + private static final List DEFAULT_SENDER_RULES = List.of( + new SenderRuleSeed("支付宝账单", + List.of("service@mail.alipay.com", "@alipay.com"), "ALIPAY", 10), + new SenderRuleSeed("微信支付账单", + List.of("@wechatpay.com", "@tenpay.com"), "WECHAT", 20), + new SenderRuleSeed("美团账单", + List.of("it_paybill@meituan.com", "@meituan.com"), "MEITUAN", 30), + new SenderRuleSeed("招商银行账单", + List.of("95555@message.cmbchina.com", "@message.cmbchina.com"), "CMB", 40), + new SenderRuleSeed("交通银行账单", + List.of("@bankcomm.com"), "BOCOM", 50)); + private final CategoryMapper categoryMapper; private final TagMapper tagMapper; + private final EmailBillSenderRuleMapper emailBillSenderRuleMapper; + private final ObjectMapper objectMapper; - public DefaultDataInitializer(CategoryMapper categoryMapper, TagMapper tagMapper) { + public DefaultDataInitializer(CategoryMapper categoryMapper, + TagMapper tagMapper, + EmailBillSenderRuleMapper emailBillSenderRuleMapper, + ObjectMapper objectMapper) { this.categoryMapper = categoryMapper; this.tagMapper = tagMapper; + this.emailBillSenderRuleMapper = emailBillSenderRuleMapper; + this.objectMapper = objectMapper; } /** - * 为指定用户初始化默认分类与标签。 + * 为指定用户初始化默认分类、标签与发件人规则。 * * @param userId 用户ID */ @@ -87,6 +111,16 @@ public class DefaultDataInitializer { tag.setSortOrder(seed.sortOrder()); tagMapper.insert(tag); } + for (SenderRuleSeed seed : DEFAULT_SENDER_RULES) { + EmailBillSenderRule rule = new EmailBillSenderRule(); + rule.setUserId(userId); + rule.setName(seed.name()); + rule.setEnabled(true); + rule.setSortOrder(seed.sortOrder()); + rule.setSenderPatterns(writePatterns(seed.patterns())); + rule.setBillType(seed.billType()); + emailBillSenderRuleMapper.insert(rule); + } } /** @@ -106,6 +140,20 @@ public class DefaultDataInitializer { categoryMapper.insert(category); } + /** + * 将发件人匹配串序列化为 JSON 数组。 + * + * @param patterns 匹配串列表 + * @return JSON 字符串 + */ + private String writePatterns(List patterns) { + try { + return objectMapper.writeValueAsString(patterns); + } catch (JsonProcessingException e) { + throw new IllegalStateException("序列化默认发件人规则失败:" + e.getMessage(), e); + } + } + /** * 分类种子数据。 * @@ -125,4 +173,15 @@ public class DefaultDataInitializer { */ private record TagSeed(String name, String color, int sortOrder) { } + + /** + * 发件人规则种子数据。 + * + * @param name 规则名称 + * @param patterns 发件人匹配串列表 + * @param billType 账单类型 + * @param sortOrder 排序序号 + */ + private record SenderRuleSeed(String name, List patterns, String billType, int sortOrder) { + } } diff --git a/src/main/java/cn/rosercode/finance/service/impl/EmailBillConfigServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/EmailBillConfigServiceImpl.java new file mode 100644 index 0000000..745922f --- /dev/null +++ b/src/main/java/cn/rosercode/finance/service/impl/EmailBillConfigServiceImpl.java @@ -0,0 +1,416 @@ +package cn.rosercode.finance.service.impl; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.mail.Folder; +import jakarta.mail.MessagingException; +import jakarta.mail.Store; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import cn.rosercode.finance.common.BusinessException; +import cn.rosercode.finance.converter.VoConverter; +import cn.rosercode.finance.dto.EmailBillConfigRequest; +import cn.rosercode.finance.email.EmailBillMonitorManager; +import cn.rosercode.finance.email.EmailImapConnector; +import cn.rosercode.finance.entity.Category; +import cn.rosercode.finance.entity.EmailBillConfig; +import cn.rosercode.finance.entity.EmailBillMessage; +import cn.rosercode.finance.entity.Tag; +import cn.rosercode.finance.mapper.CategoryMapper; +import cn.rosercode.finance.mapper.EmailBillConfigMapper; +import cn.rosercode.finance.mapper.EmailBillMessageMapper; +import cn.rosercode.finance.mapper.TagMapper; +import cn.rosercode.finance.service.EmailBillConfigService; +import cn.rosercode.finance.vo.EmailBillConfigVO; + +/** + * 邮箱账单配置服务实现类,处理 IMAP 邮箱配置的增删改查、启停与连接测试。 + * + *

授权码明文持久化但不在视图对象中返回;测试连接成功或失败都会回写连接状态, + * 便于前端直接展示邮箱的可用性。

+ * + * @date 2026-09-19 16:41:23 + */ +@Service +@Slf4j +public class EmailBillConfigServiceImpl implements EmailBillConfigService { + + /** 默认 IMAP 端口 */ + private static final int DEFAULT_PORT = 993; + + /** 默认监听文件夹 */ + private static final String DEFAULT_FOLDER = "INBOX"; + + /** 失败原因最大长度 */ + private static final int ERROR_LIMIT = 500; + + /** 拉取范围:仅新邮件 */ + private static final String SCOPE_INCREMENT = "INCREMENT"; + + /** 拉取范围:最近 N 天 */ + private static final String SCOPE_DAYS = "DAYS"; + + /** 拉取范围:最近 N 封 */ + private static final String SCOPE_COUNT = "COUNT"; + + private final EmailBillConfigMapper configMapper; + private final EmailBillMessageMapper messageMapper; + private final CategoryMapper categoryMapper; + private final TagMapper tagMapper; + private final ObjectMapper objectMapper; + private final VoConverter voConverter; + private final EmailImapConnector imapConnector; + private final EmailBillMonitorManager monitorManager; + + public EmailBillConfigServiceImpl(EmailBillConfigMapper configMapper, + EmailBillMessageMapper messageMapper, + CategoryMapper categoryMapper, + TagMapper tagMapper, + ObjectMapper objectMapper, + VoConverter voConverter, + EmailImapConnector imapConnector, + EmailBillMonitorManager monitorManager) { + this.configMapper = configMapper; + this.messageMapper = messageMapper; + this.categoryMapper = categoryMapper; + this.tagMapper = tagMapper; + this.objectMapper = objectMapper; + this.voConverter = voConverter; + this.imapConnector = imapConnector; + this.monitorManager = monitorManager; + } + + /** + * {@inheritDoc} + */ + @Override + public List list(Long userId) { + List configs = configMapper.selectList(new LambdaQueryWrapper() + .eq(EmailBillConfig::getUserId, userId) + .orderByAsc(EmailBillConfig::getId)); + List result = new ArrayList<>(); + for (EmailBillConfig config : configs) { + result.add(toVO(config)); + } + return result; + } + + /** + * {@inheritDoc} + * + * 新增时授权码必填。 + */ + @Override + @Transactional + public Long create(Long userId, EmailBillConfigRequest request) { + if (!StringUtils.hasText(request.getPassword())) { + throw new BusinessException("请填写邮箱授权码"); + } + validateCategories(userId, request); + EmailBillConfig config = new EmailBillConfig(); + config.setUserId(userId); + applyRequest(config, request); + config.setStatus("DISCONNECTED"); + configMapper.insert(config); + monitorManager.refresh(config.getId()); + log.info("新增邮箱账单配置: userId={}, id={}, host={}", userId, config.getId(), config.getHost()); + return config.getId(); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional + public void update(Long userId, Long id, EmailBillConfigRequest request) { + EmailBillConfig config = getRawConfig(userId, id); + validateCategories(userId, request); + applyRequest(config, request); + configMapper.updateById(config); + monitorManager.refresh(id); + log.info("更新邮箱账单配置: userId={}, id={}, host={}", userId, id, config.getHost()); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional + public void delete(Long userId, Long id) { + getRawConfig(userId, id); + messageMapper.delete(new LambdaQueryWrapper() + .eq(EmailBillMessage::getConfigId, id)); + configMapper.deleteById(id); + monitorManager.refresh(id); + log.info("删除邮箱账单配置: userId={}, id={}", userId, id); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional + public void toggleEnabled(Long userId, Long id, Boolean enabled) { + EmailBillConfig config = getRawConfig(userId, id); + config.setEnabled(enabled == null ? Boolean.TRUE : enabled); + configMapper.updateById(config); + monitorManager.refresh(id); + log.info("切换邮箱账单配置状态: userId={}, id={}, enabled={}", userId, id, config.getEnabled()); + } + + /** + * {@inheritDoc} + */ + @Override + public void testConnection(Long userId, Long id) { + EmailBillConfig config = getRawConfig(userId, id); + Store store = null; + Folder folder = null; + try { + store = imapConnector.connect(config); + folder = store.getFolder(config.getFolder()); + folder.open(Folder.READ_ONLY); + config.setStatus("CONNECTED"); + config.setLastError(null); + config.setLastConnectedAt(LocalDateTime.now()); + configMapper.updateById(config); + log.info("邮箱账单配置连接测试成功: userId={}, id={}, host={}", userId, id, config.getHost()); + } catch (MessagingException e) { + config.setStatus("ERROR"); + config.setLastError(truncate(e.getMessage())); + configMapper.updateById(config); + log.warn("邮箱账单配置连接测试失败: userId={}, id={}, error={}", userId, id, e.getMessage()); + throw new BusinessException("连接失败:" + e.getMessage()); + } finally { + closeQuietly(folder); + closeQuietly(store); + } + } + + /** + * {@inheritDoc} + */ + @Override + public EmailBillConfig getRawConfig(Long userId, Long id) { + EmailBillConfig config = configMapper.selectById(id); + if (config == null || !config.getUserId().equals(userId)) { + throw new BusinessException("邮箱配置不存在"); + } + return config; + } + + /** + * {@inheritDoc} + */ + @Override + public List listEnabledConfigs() { + return configMapper.selectList(new LambdaQueryWrapper() + .eq(EmailBillConfig::getEnabled, true) + .orderByAsc(EmailBillConfig::getId)); + } + + /** + * 将请求字段写入配置实体,统一处理默认值;授权码留空时保留原值。 + * + * @param config 目标配置实体 + * @param request 配置请求参数 + */ + private void applyRequest(EmailBillConfig config, EmailBillConfigRequest request) { + config.setName(request.getName().trim()); + config.setHost(request.getHost().trim()); + config.setPort(request.getPort() != null ? request.getPort() : DEFAULT_PORT); + config.setUseSsl(request.getSsl() == null || request.getSsl()); + config.setUsername(request.getUsername().trim()); + // 授权码留空表示不修改 + if (StringUtils.hasText(request.getPassword())) { + config.setPassword(request.getPassword().trim()); + } + config.setFolder(StringUtils.hasText(request.getFolder()) ? request.getFolder().trim() : DEFAULT_FOLDER); + config.setEnabled(request.getEnabled() != null && request.getEnabled()); + config.setExpenseCategoryId(request.getExpenseCategoryId()); + config.setIncomeCategoryId(request.getIncomeCategoryId()); + List tagIds = validTagIds(request.getTagIds()); + config.setTagIds(tagIds.isEmpty() ? null : writeJson(tagIds)); + config.setFetchScope(resolveFetchScope(request.getFetchScope())); + config.setFetchLimit(resolveFetchLimit(config.getFetchScope(), request.getFetchLimit())); + } + + /** + * 规范化拉取范围,非法值回退为增量模式。 + * + * @param scope 原始范围 + * @return INCREMENT / DAYS / COUNT + */ + private String resolveFetchScope(String scope) { + if (!StringUtils.hasText(scope)) { + return SCOPE_INCREMENT; + } + String upper = scope.trim().toUpperCase(); + if (SCOPE_DAYS.equals(upper) || SCOPE_COUNT.equals(upper)) { + return upper; + } + return SCOPE_INCREMENT; + } + + /** + * 规范化拉取范围数量,按范围类型回退默认值。 + * + * @param scope 拉取范围 + * @param limit 原始数量 + * @return 规范化后的数量,增量模式返回 null + */ + private Integer resolveFetchLimit(String scope, Integer limit) { + if (SCOPE_DAYS.equals(scope)) { + return limit != null && limit > 0 ? limit : 1; + } + if (SCOPE_COUNT.equals(scope)) { + return limit != null && limit > 0 ? limit : 20; + } + return null; + } + + /** + * 校验默认分类的归属与类型一致性。 + * + * @param userId 当前用户ID + * @param request 配置请求参数 + */ + private void validateCategories(Long userId, EmailBillConfigRequest request) { + validateCategory(userId, request.getExpenseCategoryId(), "EXPENSE"); + validateCategory(userId, request.getIncomeCategoryId(), "INCOME"); + } + + /** + * 校验分类是否存在、归属当前用户且类型匹配。 + * + * @param userId 当前用户ID + * @param categoryId 分类ID,可为空 + * @param type 期望的分类类型 + */ + private void validateCategory(Long userId, Long categoryId, String type) { + if (categoryId == null) { + return; + } + Category category = categoryMapper.selectById(categoryId); + if (category == null || !category.getUserId().equals(userId)) { + throw new BusinessException("分类不存在"); + } + if (!type.equals(category.getType())) { + throw new BusinessException("默认分类与收支类型不匹配"); + } + } + + /** + * 过滤出属于当前用户的有效标签ID。 + * + * @param tagIds 待校验的标签ID列表 + * @return 有效的标签ID列表 + */ + private List validTagIds(List tagIds) { + if (tagIds == null || tagIds.isEmpty()) { + return List.of(); + } + return tagMapper.selectBatchIds(tagIds).stream() + .map(Tag::getId) + .toList(); + } + + /** + * 将配置实体转为视图对象,还原默认标签列表。 + * + * @param config 配置实体 + * @return 配置视图对象 + */ + private EmailBillConfigVO toVO(EmailBillConfig config) { + EmailBillConfigVO vo = voConverter.toEmailBillConfigVO(config); + vo.setTagIds(parseLongList(config.getTagIds())); + return vo; + } + + /** + * 序列化对象为 JSON 字符串。 + * + * @param value 待序列化对象 + * @return JSON 字符串 + */ + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new BusinessException("序列化邮箱配置失败:" + e.getMessage()); + } + } + + /** + * 解析 JSON 数字数组。 + * + * @param json JSON 字符串 + * @return 数字列表,解析失败或为空时返回空列表 + */ + private List parseLongList(String json) { + if (!StringUtils.hasText(json)) { + return List.of(); + } + try { + return objectMapper.readValue(json, new TypeReference>() { + }); + } catch (JsonProcessingException e) { + log.warn("解析默认标签列表失败:{}", e.getMessage()); + return List.of(); + } + } + + /** + * 静默关闭邮件文件夹。 + * + * @param folder 文件夹,可为 null + */ + private void closeQuietly(Folder folder) { + if (folder == null || !folder.isOpen()) { + return; + } + try { + folder.close(false); + } catch (MessagingException ignored) { + // 关闭失败不影响测试结果 + } + } + + /** + * 静默关闭邮件连接。 + * + * @param store 连接,可为 null + */ + private void closeQuietly(Store store) { + if (store == null) { + return; + } + try { + store.close(); + } catch (MessagingException ignored) { + // 关闭失败不影响测试结果 + } + } + + /** + * 截断过长的失败原因。 + * + * @param message 原文 + * @return 截断后的文本 + */ + private String truncate(String message) { + if (message == null) { + return null; + } + return message.length() <= ERROR_LIMIT ? message : message.substring(0, ERROR_LIMIT); + } +} diff --git a/src/main/java/cn/rosercode/finance/service/impl/EmailBillMessageServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/EmailBillMessageServiceImpl.java new file mode 100644 index 0000000..1ef18e5 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/service/impl/EmailBillMessageServiceImpl.java @@ -0,0 +1,243 @@ +package cn.rosercode.finance.service.impl; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import cn.rosercode.finance.common.BusinessException; +import cn.rosercode.finance.common.PageResult; +import cn.rosercode.finance.converter.VoConverter; +import cn.rosercode.finance.dto.BillImportRequest; +import cn.rosercode.finance.dto.EmailBillParseRequest; +import cn.rosercode.finance.email.EmailBillStorage; +import cn.rosercode.finance.entity.EmailBillMessage; +import cn.rosercode.finance.entity.EmailBillSenderRule; +import cn.rosercode.finance.enums.BillType; +import cn.rosercode.finance.enums.EmailBillStatus; +import cn.rosercode.finance.mapper.EmailBillMessageMapper; +import cn.rosercode.finance.mapper.EmailBillSenderRuleMapper; +import cn.rosercode.finance.service.BillImportService; +import cn.rosercode.finance.service.EmailBillMessageService; +import cn.rosercode.finance.vo.BillImportResultVO; +import cn.rosercode.finance.vo.BillParseResultVO; +import cn.rosercode.finance.vo.EmailBillMessageVO; + +/** + * 邮箱账单消息服务实现类,实现待处理列表与「解析、选择、入库」三步流程。 + * + *

解析阶段读取本地附件并复用账单导入服务,返回预览行但不落库; + * 导入阶段复用账单导入服务写入交易记录,成功后标记消息为已导入。

+ * + * @date 2026-09-19 16:48:46 + */ +@Service +@Slf4j +public class EmailBillMessageServiceImpl implements EmailBillMessageService { + + /** 单页最大条数 */ + private static final int MAX_PAGE_SIZE = 100; + + /** 失败原因最大长度 */ + private static final int ERROR_LIMIT = 500; + + private final EmailBillMessageMapper messageMapper; + private final EmailBillSenderRuleMapper senderRuleMapper; + private final EmailBillStorage storage; + private final BillImportService billImportService; + private final VoConverter voConverter; + + public EmailBillMessageServiceImpl(EmailBillMessageMapper messageMapper, + EmailBillSenderRuleMapper senderRuleMapper, + EmailBillStorage storage, + BillImportService billImportService, + VoConverter voConverter) { + this.messageMapper = messageMapper; + this.senderRuleMapper = senderRuleMapper; + this.storage = storage; + this.billImportService = billImportService; + this.voConverter = voConverter; + } + + /** + * {@inheritDoc} + */ + @Override + public PageResult page(Long userId, String status, Long configId, int page, int size) { + int safePage = Math.max(page, 1); + int safeSize = Math.min(Math.max(size, 1), MAX_PAGE_SIZE); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(EmailBillMessage::getUserId, userId) + .eq(StringUtils.hasText(status), EmailBillMessage::getStatus, status) + .eq(configId != null, EmailBillMessage::getConfigId, configId); + + Long total = messageMapper.selectCount(wrapper); + long offset = (long) (safePage - 1) * safeSize; + List messages = messageMapper.selectList(wrapper + .orderByDesc(EmailBillMessage::getId) + .last("LIMIT " + safeSize + " OFFSET " + offset)); + + List records = new ArrayList<>(); + for (EmailBillMessage message : messages) { + records.add(voConverter.toEmailBillMessageVO(message)); + } + return new PageResult<>(records, total == null ? 0L : total, safePage, safeSize); + } + + /** + * {@inheritDoc} + * + * 账单类型与解压密码默认取发件人规则,请求中提供时覆盖;解析失败会记录失败原因。 + */ + @Override + public BillParseResultVO parse(Long userId, Long messageId, EmailBillParseRequest request) { + EmailBillMessage message = requireMessage(userId, messageId); + BillType billType = resolveBillType(message, request); + String password = resolvePassword(message, request); + + byte[] content; + try { + content = storage.read(message.getAttachmentPath()); + } catch (RuntimeException e) { + updateError(message, "读取附件失败:" + e.getMessage()); + throw e; + } + + try { + BillParseResultVO result = billImportService.parse(userId, billType, content, password); + updateError(message, null); + return result; + } catch (IOException e) { + updateError(message, "解析失败:" + e.getMessage()); + throw new BusinessException("解析失败:" + e.getMessage()); + } catch (RuntimeException e) { + updateError(message, e.getMessage()); + throw e; + } + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional + public BillImportResultVO importBill(Long userId, Long messageId, BillImportRequest request) { + EmailBillMessage message = requireMessage(userId, messageId); + BillImportResultVO result = billImportService.confirm(userId, request); + message.setStatus(EmailBillStatus.CONFIRMED.name()); + message.setError(null); + messageMapper.updateById(message); + log.info("邮箱账单导入完成: userId={}, messageId={}, 成功={}", userId, messageId, result.getImported()); + return result; + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional + public void ignore(Long userId, Long messageId) { + EmailBillMessage message = requireMessage(userId, messageId); + message.setStatus(EmailBillStatus.IGNORED.name()); + messageMapper.updateById(message); + log.info("忽略邮箱账单消息: userId={}, messageId={}", userId, messageId); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional + public void delete(Long userId, Long messageId) { + EmailBillMessage message = requireMessage(userId, messageId); + storage.delete(message.getAttachmentPath()); + messageMapper.deleteById(messageId); + log.info("删除邮箱账单消息: userId={}, messageId={}", userId, messageId); + } + + /** + * 获取归属当前用户的消息实体。 + * + * @param userId 当前用户ID + * @param messageId 消息ID + * @return 消息实体 + */ + private EmailBillMessage requireMessage(Long userId, Long messageId) { + EmailBillMessage message = messageMapper.selectById(messageId); + if (message == null || !message.getUserId().equals(userId)) { + throw new BusinessException("账单消息不存在"); + } + return message; + } + + /** + * 解析账单类型:优先请求指定,其次发件人规则识别结果。 + * + * @param message 消息实体 + * @param request 解析请求 + * @return 账单类型 + */ + private BillType resolveBillType(EmailBillMessage message, EmailBillParseRequest request) { + String value = request != null && StringUtils.hasText(request.getBillType()) + ? request.getBillType() : message.getBillType(); + if (!StringUtils.hasText(value)) { + throw new BusinessException("请选择账单类型"); + } + try { + return BillType.valueOf(value.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + throw new BusinessException("不支持的账单类型:" + value); + } + } + + /** + * 解析解压密码:优先请求指定,其次发件人规则配置。 + * + * @param message 消息实体 + * @param request 解析请求 + * @return 解压密码,均为空时返回 null + */ + private String resolvePassword(EmailBillMessage message, EmailBillParseRequest request) { + if (request != null && StringUtils.hasText(request.getPassword())) { + return request.getPassword().trim(); + } + if (message.getSenderRuleId() != null) { + EmailBillSenderRule rule = senderRuleMapper.selectById(message.getSenderRuleId()); + if (rule != null && StringUtils.hasText(rule.getArchivePassword())) { + return rule.getArchivePassword(); + } + } + return null; + } + + /** + * 更新消息的解析失败原因。 + * + * @param message 消息实体 + * @param error 失败原因,null 表示清空 + */ + private void updateError(EmailBillMessage message, String error) { + messageMapper.update(null, new LambdaUpdateWrapper() + .eq(EmailBillMessage::getId, message.getId()) + .set(EmailBillMessage::getError, truncate(error))); + } + + /** + * 截断过长的失败原因。 + * + * @param error 原文 + * @return 截断后的文本 + */ + private String truncate(String error) { + if (error == null) { + return null; + } + return error.length() <= ERROR_LIMIT ? error : error.substring(0, ERROR_LIMIT); + } +} diff --git a/src/main/java/cn/rosercode/finance/service/impl/EmailBillSenderRuleServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/EmailBillSenderRuleServiceImpl.java new file mode 100644 index 0000000..3ccb049 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/service/impl/EmailBillSenderRuleServiceImpl.java @@ -0,0 +1,307 @@ +package cn.rosercode.finance.service.impl; + +import java.util.ArrayList; +import java.util.List; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import cn.rosercode.finance.common.BusinessException; +import cn.rosercode.finance.converter.VoConverter; +import cn.rosercode.finance.dto.EmailBillSenderRuleRequest; +import cn.rosercode.finance.entity.Category; +import cn.rosercode.finance.entity.EmailBillSenderRule; +import cn.rosercode.finance.entity.Tag; +import cn.rosercode.finance.enums.BillType; +import cn.rosercode.finance.mapper.CategoryMapper; +import cn.rosercode.finance.mapper.EmailBillSenderRuleMapper; +import cn.rosercode.finance.mapper.TagMapper; +import cn.rosercode.finance.service.EmailBillSenderRuleService; +import cn.rosercode.finance.vo.EmailBillSenderRuleVO; + +/** + * 邮箱账单发件人规则服务实现类,处理发件人识别规则的增删改查与匹配数据准备。 + * + *

发件人匹配串与默认标签以 JSON 数组持久化,视图对象中还原为列表; + * 匹配本身在邮件拉取时由 {@code EmailBillProcessor} 按优先级完成。

+ * + * @date 2026-09-19 16:41:07 + */ +@Service +@Slf4j +public class EmailBillSenderRuleServiceImpl implements EmailBillSenderRuleService { + + private final EmailBillSenderRuleMapper senderRuleMapper; + private final CategoryMapper categoryMapper; + private final TagMapper tagMapper; + private final ObjectMapper objectMapper; + private final VoConverter voConverter; + + public EmailBillSenderRuleServiceImpl(EmailBillSenderRuleMapper senderRuleMapper, + CategoryMapper categoryMapper, + TagMapper tagMapper, + ObjectMapper objectMapper, + VoConverter voConverter) { + this.senderRuleMapper = senderRuleMapper; + this.categoryMapper = categoryMapper; + this.tagMapper = tagMapper; + this.objectMapper = objectMapper; + this.voConverter = voConverter; + } + + /** + * {@inheritDoc} + */ + @Override + public List list(Long userId) { + List rules = senderRuleMapper.selectList(new LambdaQueryWrapper() + .eq(EmailBillSenderRule::getUserId, userId) + .orderByAsc(EmailBillSenderRule::getSortOrder) + .orderByAsc(EmailBillSenderRule::getId)); + List result = new ArrayList<>(); + for (EmailBillSenderRule rule : rules) { + result.add(toVO(rule)); + } + return result; + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional + public Long create(Long userId, EmailBillSenderRuleRequest request) { + validate(userId, request); + EmailBillSenderRule rule = new EmailBillSenderRule(); + rule.setUserId(userId); + applyRequest(rule, request); + senderRuleMapper.insert(rule); + log.info("新增邮箱账单发件人规则: userId={}, id={}, name={}", userId, rule.getId(), rule.getName()); + return rule.getId(); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional + public void update(Long userId, Long id, EmailBillSenderRuleRequest request) { + EmailBillSenderRule rule = senderRuleMapper.selectById(id); + if (rule == null || !rule.getUserId().equals(userId)) { + throw new BusinessException("发件人规则不存在"); + } + validate(userId, request); + applyRequest(rule, request); + senderRuleMapper.updateById(rule); + log.info("更新邮箱账单发件人规则: userId={}, id={}, name={}", userId, id, rule.getName()); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional + public void delete(Long userId, Long id) { + EmailBillSenderRule rule = senderRuleMapper.selectById(id); + if (rule == null || !rule.getUserId().equals(userId)) { + throw new BusinessException("发件人规则不存在"); + } + senderRuleMapper.deleteById(id); + log.info("删除邮箱账单发件人规则: userId={}, id={}", userId, id); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional + public void toggleEnabled(Long userId, Long id, Boolean enabled) { + EmailBillSenderRule rule = senderRuleMapper.selectById(id); + if (rule == null || !rule.getUserId().equals(userId)) { + throw new BusinessException("发件人规则不存在"); + } + rule.setEnabled(enabled == null ? Boolean.TRUE : enabled); + senderRuleMapper.updateById(rule); + log.info("切换邮箱账单发件人规则状态: userId={}, id={}, enabled={}", userId, id, rule.getEnabled()); + } + + /** + * {@inheritDoc} + */ + @Override + public List listEnabledRules(Long userId) { + return senderRuleMapper.selectList(new LambdaQueryWrapper() + .eq(EmailBillSenderRule::getUserId, userId) + .eq(EmailBillSenderRule::getEnabled, true) + .orderByAsc(EmailBillSenderRule::getSortOrder) + .orderByAsc(EmailBillSenderRule::getId)); + } + + /** + * 将请求字段写入规则实体,统一处理默认值与 JSON 序列化。 + * + * @param rule 目标规则实体 + * @param request 规则请求参数 + */ + private void applyRequest(EmailBillSenderRule rule, EmailBillSenderRuleRequest request) { + rule.setName(request.getName().trim()); + rule.setEnabled(request.getEnabled() == null ? Boolean.TRUE : request.getEnabled()); + rule.setSortOrder(request.getSortOrder() != null ? request.getSortOrder() : 0); + rule.setSenderPatterns(writeJson(cleanPatterns(request.getSenderPatterns()))); + rule.setBillType(request.getBillType().trim().toUpperCase()); + rule.setArchivePassword(StringUtils.hasText(request.getArchivePassword()) + ? request.getArchivePassword().trim() : null); + rule.setExpenseCategoryId(request.getExpenseCategoryId()); + rule.setIncomeCategoryId(request.getIncomeCategoryId()); + List tagIds = validTagIds(request.getTagIds()); + rule.setTagIds(tagIds.isEmpty() ? null : writeJson(tagIds)); + } + + /** + * 校验账单类型、发件人匹配串、默认分类与标签的合法性。 + * + * @param userId 当前用户ID + * @param request 规则请求参数 + */ + private void validate(Long userId, EmailBillSenderRuleRequest request) { + // 账单类型白名单 + try { + BillType.valueOf(request.getBillType().trim().toUpperCase()); + } catch (IllegalArgumentException e) { + throw new BusinessException("不支持的账单类型:" + request.getBillType()); + } + // 至少一个非空发件人匹配串 + if (cleanPatterns(request.getSenderPatterns()).isEmpty()) { + throw new BusinessException("请至少填写一个发件人匹配串"); + } + // 默认分类归属与类型一致性 + validateCategory(userId, request.getExpenseCategoryId(), "EXPENSE"); + validateCategory(userId, request.getIncomeCategoryId(), "INCOME"); + } + + /** + * 校验分类是否存在、归属当前用户且类型匹配。 + * + * @param userId 当前用户ID + * @param categoryId 分类ID,可为空 + * @param type 期望的分类类型 + */ + private void validateCategory(Long userId, Long categoryId, String type) { + if (categoryId == null) { + return; + } + Category category = categoryMapper.selectById(categoryId); + if (category == null || !category.getUserId().equals(userId)) { + throw new BusinessException("分类不存在"); + } + if (!type.equals(category.getType())) { + throw new BusinessException("默认分类与收支类型不匹配"); + } + } + + /** + * 过滤出属于当前用户的有效标签ID。 + * + * @param tagIds 待校验的标签ID列表 + * @return 有效的标签ID列表 + */ + private List validTagIds(List tagIds) { + if (tagIds == null || tagIds.isEmpty()) { + return List.of(); + } + return tagMapper.selectBatchIds(tagIds).stream() + .map(Tag::getId) + .toList(); + } + + /** + * 清理发件人匹配串:去空白、去空项、去重。 + * + * @param patterns 原始匹配串列表 + * @return 清理后的匹配串列表 + */ + private List cleanPatterns(List patterns) { + if (patterns == null || patterns.isEmpty()) { + return List.of(); + } + return patterns.stream() + .filter(StringUtils::hasText) + .map(String::trim) + .distinct() + .toList(); + } + + /** + * 将规则实体转为视图对象,还原 JSON 字段。 + * + * @param rule 规则实体 + * @return 规则视图对象 + */ + private EmailBillSenderRuleVO toVO(EmailBillSenderRule rule) { + EmailBillSenderRuleVO vo = voConverter.toEmailBillSenderRuleVO(rule); + vo.setSenderPatterns(parseStringList(rule.getSenderPatterns())); + vo.setTagIds(parseLongList(rule.getTagIds())); + return vo; + } + + /** + * 序列化对象为 JSON 字符串。 + * + * @param value 待序列化对象,为空时返回 null + * @return JSON 字符串 + */ + private String writeJson(Object value) { + if (value == null) { + return null; + } + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new BusinessException("序列化发件人规则失败:" + e.getMessage()); + } + } + + /** + * 解析 JSON 字符串数组。 + * + * @param json JSON 字符串 + * @return 字符串列表,解析失败或为空时返回空列表 + */ + private List parseStringList(String json) { + if (!StringUtils.hasText(json)) { + return List.of(); + } + try { + return objectMapper.readValue(json, new TypeReference>() { + }); + } catch (JsonProcessingException e) { + log.warn("解析发件人匹配串失败:{}", e.getMessage()); + return List.of(); + } + } + + /** + * 解析 JSON 数字数组。 + * + * @param json JSON 字符串 + * @return 数字列表,解析失败或为空时返回空列表 + */ + private List parseLongList(String json) { + if (!StringUtils.hasText(json)) { + return List.of(); + } + try { + return objectMapper.readValue(json, new TypeReference>() { + }); + } catch (JsonProcessingException e) { + log.warn("解析标签ID列表失败:{}", e.getMessage()); + return List.of(); + } + } +} diff --git a/src/main/java/cn/rosercode/finance/service/impl/StatisticsServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/StatisticsServiceImpl.java index 5cc57ae..3604241 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/StatisticsServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/StatisticsServiceImpl.java @@ -39,7 +39,8 @@ import cn.rosercode.finance.vo.TrendStatisticsVO; * 统计服务实现类,提供月度收支汇总、分类占比分析、趋势统计、概览指标及通用分组统计。 * *

所有统计均基于 SQL 聚合(MyBatis-Plus {@code selectMaps})完成,避免将整段区间的 - * 交易记录加载到内存;仅统计计入统计({@code include_in_stats = 1 或 NULL})的记录。

+ * 交易记录加载到内存;统计口径由 {@code statsScope} 控制,默认仅统计计入统计 + * ({@code include_in_stats = 1 或 NULL})的记录。

*/ @Service public class StatisticsServiceImpl implements StatisticsService { @@ -280,8 +281,16 @@ public class StatisticsServiceImpl implements StatisticsService { } } - // 仅统计计入统计的记录(排除借款、账户间转账等) - wrapper.and(w -> w.eq("include_in_stats", true).or().isNull("include_in_stats")); + // 统计口径:默认仅统计计入统计的记录(排除借款、账户间转账等) + String statsScope = query == null ? null : query.getStatsScope(); + if ("ALL".equalsIgnoreCase(statsScope)) { + // 全部记录,不追加计入统计过滤 + } else if ("EXCLUDED".equalsIgnoreCase(statsScope)) { + // 仅不计入统计的记录(NULL 视为计入,故此处只取 false) + wrapper.eq("include_in_stats", false); + } else { + wrapper.and(w -> w.eq("include_in_stats", true).or().isNull("include_in_stats")); + } return wrapper; } 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 49883b2..71c9855 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/TransactionServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/TransactionServiceImpl.java @@ -296,7 +296,7 @@ public class TransactionServiceImpl implements TransactionService { } transaction.setIncludeInStats(includeInStats == null ? Boolean.TRUE : includeInStats); transactionMapper.updateById(transaction); - log.info("切换计入统计标记: userId={}, id={}, includeInStats={}", userId, id, transaction.getIncludeInStats()); + log.debug("切换计入统计标记: userId={}, id={}, includeInStats={}", userId, id, transaction.getIncludeInStats()); } /** @@ -313,7 +313,7 @@ public class TransactionServiceImpl implements TransactionService { } transaction.setRemarkHidden(remarkHidden == null ? Boolean.FALSE : remarkHidden); transactionMapper.updateById(transaction); - log.info("切换备注隐藏标记: userId={}, id={}, remarkHidden={}", userId, id, transaction.getRemarkHidden()); + log.debug("切换备注隐藏标记: userId={}, id={}, remarkHidden={}", userId, id, transaction.getRemarkHidden()); } /** diff --git a/src/main/java/cn/rosercode/finance/vo/EmailBillConfigVO.java b/src/main/java/cn/rosercode/finance/vo/EmailBillConfigVO.java new file mode 100644 index 0000000..0803e6e --- /dev/null +++ b/src/main/java/cn/rosercode/finance/vo/EmailBillConfigVO.java @@ -0,0 +1,75 @@ +package cn.rosercode.finance.vo; + +import java.time.LocalDateTime; +import java.util.List; + +import lombok.Data; + +/** + * 邮箱账单配置视图对象,用于前端展示 IMAP 配置(授权码已脱敏)。 + * + * @date 2026-09-19 16:40:42 + */ +@Data +public class EmailBillConfigVO { + + /** 配置ID */ + private Long id; + + /** 配置名称 */ + private String name; + + /** IMAP 服务器地址 */ + private String host; + + /** IMAP 端口 */ + private Integer port; + + /** 是否使用 SSL */ + private Boolean ssl; + + /** 邮箱账号 */ + private String username; + + /** 是否已配置授权码 */ + private Boolean hasPassword; + + /** 监听的邮件文件夹 */ + private String folder; + + /** 是否启用监听 */ + private Boolean enabled; + + /** 连接状态 */ + private String status; + + /** 最近一次失败原因 */ + private String lastError; + + /** 最近一次成功连接时间 */ + private LocalDateTime lastConnectedAt; + + /** 已处理的最大邮件 UID */ + private Long lastUid; + + /** 默认支出分类ID */ + private Long expenseCategoryId; + + /** 默认收入分类ID */ + private Long incomeCategoryId; + + /** 默认标签ID列表 */ + private List tagIds; + + /** 拉取范围:INCREMENT-仅新邮件,DAYS-最近N天,COUNT-最近N封 */ + private String fetchScope; + + /** 拉取范围数量:DAYS 为天数,COUNT 为封数 */ + private Integer fetchLimit; + + /** 创建时间 */ + private LocalDateTime createdAt; + + /** 更新时间 */ + private LocalDateTime updatedAt; +} diff --git a/src/main/java/cn/rosercode/finance/vo/EmailBillMessageVO.java b/src/main/java/cn/rosercode/finance/vo/EmailBillMessageVO.java new file mode 100644 index 0000000..4f0842f --- /dev/null +++ b/src/main/java/cn/rosercode/finance/vo/EmailBillMessageVO.java @@ -0,0 +1,47 @@ +package cn.rosercode.finance.vo; + +import java.time.LocalDateTime; + +import lombok.Data; + +/** + * 邮箱账单消息视图对象,用于前端展示拉取到的待处理账单附件。 + * + * @date 2026-09-19 16:48:39 + */ +@Data +public class EmailBillMessageVO { + + /** 消息ID */ + private Long id; + + /** 邮箱配置ID */ + private Long configId; + + /** 邮件 IMAP UID */ + private Long uid; + + /** 邮件主题 */ + private String subject; + + /** 发件人地址 */ + private String fromAddress; + + /** 邮件接收时间 */ + private LocalDateTime receivedAt; + + /** 附件文件名 */ + private String attachmentName; + + /** 命中的账单类型 */ + private String billType; + + /** 处理状态:PULLED/CONFIRMED/IGNORED */ + private String status; + + /** 解析失败原因 */ + private String error; + + /** 创建时间 */ + private LocalDateTime createdAt; +} diff --git a/src/main/java/cn/rosercode/finance/vo/EmailBillSenderRuleVO.java b/src/main/java/cn/rosercode/finance/vo/EmailBillSenderRuleVO.java new file mode 100644 index 0000000..6127473 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/vo/EmailBillSenderRuleVO.java @@ -0,0 +1,51 @@ +package cn.rosercode.finance.vo; + +import java.time.LocalDateTime; +import java.util.List; + +import lombok.Data; + +/** + * 邮箱账单发件人规则视图对象,用于前端展示发件人识别规则。 + * + * @date 2026-09-19 16:40:44 + */ +@Data +public class EmailBillSenderRuleVO { + + /** 规则ID */ + private Long id; + + /** 规则名称 */ + private String name; + + /** 是否启用 */ + private Boolean enabled; + + /** 优先级,数值越小越优先 */ + private Integer sortOrder; + + /** 发件人匹配串列表 */ + private List senderPatterns; + + /** 命中的账单类型 */ + private String billType; + + /** 该来源账单压缩包解压密码 */ + private String archivePassword; + + /** 默认支出分类ID */ + private Long expenseCategoryId; + + /** 默认收入分类ID */ + private Long incomeCategoryId; + + /** 默认标签ID列表 */ + private List tagIds; + + /** 创建时间 */ + private LocalDateTime createdAt; + + /** 更新时间 */ + private LocalDateTime updatedAt; +} diff --git a/src/main/resources/sql/init.sql b/src/main/resources/sql/init.sql index 1497d94..6de13eb 100644 --- a/src/main/resources/sql/init.sql +++ b/src/main/resources/sql/init.sql @@ -1,22 +1,22 @@ -- 用户表:存储系统用户的基本信息和登录凭证 CREATE TABLE IF NOT EXISTS t_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID,主键自增', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名,唯一标识,用于登录', password_hash VARCHAR(255) NOT NULL COMMENT '密码哈希值,使用BCrypt加密存储', - nickname VARCHAR(50) COMMENT '用户昵称,显示名称', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' + nickname VARCHAR(50) COMMENT '用户昵称,显示名称' ); -- 分类表:存储收支分类信息,支持用户自定义 CREATE TABLE IF NOT EXISTS t_category ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '分类ID,主键自增', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', user_id BIGINT NOT NULL COMMENT '所属用户ID', name VARCHAR(50) NOT NULL COMMENT '分类名称', type VARCHAR(10) NOT NULL COMMENT '分类类型:INCOME-收入,EXPENSE-支出', icon VARCHAR(50) COMMENT '分类图标,可选', sort_order INT NOT NULL DEFAULT 0 COMMENT '排序序号,数值越小越靠前', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', INDEX idx_user_id (user_id) COMMENT '用户ID索引', INDEX idx_user_type (user_id, type) COMMENT '用户ID+类型联合索引' ); @@ -24,6 +24,8 @@ CREATE TABLE IF NOT EXISTS t_category ( -- 收支记录表:存储每一笔收入或支出的详细信息 CREATE TABLE IF NOT EXISTS t_transaction ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID,主键自增', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', user_id BIGINT NOT NULL COMMENT '所属用户ID', category_id BIGINT COMMENT '所属分类ID,可为空表示未分类', type VARCHAR(10) NOT NULL COMMENT '记录类型:INCOME-收入,EXPENSE-支出', @@ -35,8 +37,6 @@ CREATE TABLE IF NOT EXISTS t_transaction ( transaction_time DATETIME NOT NULL COMMENT '交易发生时间', include_in_stats TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否计入统计:1-计入,0-不计入(如借款、转账等)', raw_data JSON COMMENT '账单导入的原始完整数据(结构化JSON),手动记录为空', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', INDEX idx_user_id (user_id) COMMENT '用户ID索引', INDEX idx_category_id (category_id) COMMENT '分类ID索引', INDEX idx_transaction_time (transaction_time) COMMENT '交易时间索引', @@ -47,11 +47,11 @@ CREATE TABLE IF NOT EXISTS t_transaction ( -- 标签表:存储用户自定义标签,用于给交易打标签 CREATE TABLE IF NOT EXISTS t_tag ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '标签ID,主键自增', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', user_id BIGINT NOT NULL COMMENT '所属用户ID', name VARCHAR(50) NOT NULL COMMENT '标签名称', color VARCHAR(20) COMMENT '标签颜色,用于前端展示', sort_order INT NOT NULL DEFAULT 0 COMMENT '排序序号,数值越小越靠前', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', INDEX idx_user_id (user_id) COMMENT '用户ID索引', UNIQUE INDEX uk_user_name (user_id, name) COMMENT '用户ID+标签名唯一索引' ); @@ -59,9 +59,9 @@ CREATE TABLE IF NOT EXISTS t_tag ( -- 交易标签关联表:交易与标签的多对多关系 CREATE TABLE IF NOT EXISTS t_transaction_tag ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID,主键自增', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', transaction_id BIGINT NOT NULL COMMENT '交易记录ID', tag_id BIGINT NOT NULL COMMENT '标签ID', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', UNIQUE INDEX uk_transaction_tag (transaction_id, tag_id) COMMENT '交易+标签唯一索引', INDEX idx_tag_id (tag_id) COMMENT '标签ID索引' ); @@ -69,19 +69,20 @@ CREATE TABLE IF NOT EXISTS t_transaction_tag ( -- AI 配置表:存储每个用户的 AI 智能建议相关配置 CREATE TABLE IF NOT EXISTS t_ai_config ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '配置ID,主键自增', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', user_id BIGINT NOT NULL COMMENT '所属用户ID', enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用AI智能建议:1-启用,0-关闭', api_key VARCHAR(255) COMMENT 'DeepSeek API Key(明文存储)', base_url VARCHAR(255) NOT NULL DEFAULT 'https://api.deepseek.com' COMMENT 'API 基础地址', model VARCHAR(50) NOT NULL DEFAULT 'deepseek-chat' COMMENT '使用的模型名称', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE INDEX uk_user_id (user_id) COMMENT '用户ID唯一索引,每个用户一条配置' ); -- AI 使用记录表:记录每次调用 DeepSeek 的用量与结果 CREATE TABLE IF NOT EXISTS t_ai_usage ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID,主键自增', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', user_id BIGINT NOT NULL COMMENT '所属用户ID', type VARCHAR(20) NOT NULL COMMENT '调用类型:SUGGEST-生成建议,TEST-测试连接', model VARCHAR(50) COMMENT '模型名称', @@ -93,13 +94,14 @@ CREATE TABLE IF NOT EXISTS t_ai_usage ( success TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否成功:1-成功,0-失败', error_message VARCHAR(500) COMMENT '失败原因', duration_ms BIGINT COMMENT '调用耗时(毫秒)', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', INDEX idx_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引' ); -- 通知配置表:存储每个用户的账单导入提醒设置 CREATE TABLE IF NOT EXISTS t_notification_config ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '配置ID,主键自增', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', user_id BIGINT NOT NULL COMMENT '所属用户ID', enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用定时提醒:1-启用,0-关闭', frequency VARCHAR(10) NOT NULL DEFAULT 'MONTHLY' COMMENT '提醒周期:DAILY-每天,WEEKLY-每周,MONTHLY-每月', @@ -107,39 +109,38 @@ CREATE TABLE IF NOT EXISTS t_notification_config ( day_of_week INT NOT NULL DEFAULT 1 COMMENT '每周提醒日(1-7,1为周一),frequency=WEEKLY 时生效', send_time VARCHAR(5) NOT NULL DEFAULT '09:00' COMMENT '提醒时间(HH:mm)', last_sent_at DATETIME COMMENT '最近一次发送时间,用于避免重复提醒', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE INDEX uk_notification_config_user_id (user_id) COMMENT '用户ID唯一索引,每个用户一条配置' ); -- 通知渠道表:存储用户配置的邮箱、Server 酱等推送渠道 CREATE TABLE IF NOT EXISTS t_notification_channel ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '渠道ID,主键自增', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', user_id BIGINT NOT NULL COMMENT '所属用户ID', type VARCHAR(20) NOT NULL COMMENT '渠道类型:EMAIL-邮箱,SERVERCHAN-Server 酱', name VARCHAR(50) COMMENT '渠道名称,便于用户识别', enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用:1-启用,0-关闭', config TEXT COMMENT '渠道配置(JSON),如 webhook 地址、SMTP 账号等', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', INDEX idx_notification_channel_user_id (user_id) COMMENT '用户ID索引' ); -- 通知发送日志表:记录每次提醒的发送结果,便于排查问题 CREATE TABLE IF NOT EXISTS t_notification_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '日志ID,主键自增', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', user_id BIGINT NOT NULL COMMENT '所属用户ID', channel_id BIGINT COMMENT '渠道ID,测试或渠道已删除时可能为空', channel_type VARCHAR(20) COMMENT '渠道类型', success TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否成功:1-成功,0-失败', message VARCHAR(500) COMMENT '结果描述或失败原因', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', INDEX idx_notification_log_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引' ); -- HTTP 访问日志表:记录每次接口请求的方法、路径、状态与耗时,便于追溯接口调用 CREATE TABLE IF NOT EXISTS t_http_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '日志ID,主键自增', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', user_id BIGINT COMMENT '用户ID,未登录或认证失败时为空', method VARCHAR(10) COMMENT '请求方法:GET/POST/PUT/DELETE 等', path VARCHAR(255) COMMENT '请求路径', @@ -148,7 +149,6 @@ CREATE TABLE IF NOT EXISTS t_http_log ( duration_ms BIGINT COMMENT '处理耗时(毫秒)', ip VARCHAR(64) COMMENT '客户端IP', user_agent VARCHAR(500) COMMENT 'User-Agent', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', INDEX idx_http_log_created (created_at) COMMENT '创建时间索引', INDEX idx_http_log_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引' ); @@ -156,13 +156,13 @@ CREATE TABLE IF NOT EXISTS t_http_log ( -- 登录日志表:记录每次登录尝试的结果,便于审计与排查 CREATE TABLE IF NOT EXISTS t_login_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '日志ID,主键自增', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', user_id BIGINT COMMENT '用户ID,登录失败或用户不存在时为空', username VARCHAR(50) COMMENT '登录用户名', success TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否成功:1-成功,0-失败', message VARCHAR(500) COMMENT '结果描述或失败原因', ip VARCHAR(64) COMMENT '客户端IP', user_agent VARCHAR(500) COMMENT 'User-Agent', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', INDEX idx_login_log_created (created_at) COMMENT '创建时间索引', INDEX idx_login_log_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引' ); @@ -170,6 +170,8 @@ CREATE TABLE IF NOT EXISTS t_login_log ( -- 自定义分类与标签规则表:按关键词、收支类型、金额区间自动归类与打标签 CREATE TABLE IF NOT EXISTS t_rule ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '规则ID,主键自增', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', user_id BIGINT NOT NULL COMMENT '所属用户ID', name VARCHAR(50) NOT NULL COMMENT '规则名称', enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用:1-启用,0-停用', @@ -179,17 +181,82 @@ CREATE TABLE IF NOT EXISTS t_rule ( min_amount DECIMAL(12, 2) COMMENT '金额下限(含),可为空', max_amount DECIMAL(12, 2) COMMENT '金额上限(含),可为空', category_id BIGINT COMMENT '命中的目标分类ID,可为空', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', INDEX idx_rule_user_id (user_id) COMMENT '用户ID索引' ); -- 规则标签关联表:规则与标签的多对多关系 CREATE TABLE IF NOT EXISTS t_rule_tag ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID,主键自增', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', rule_id BIGINT NOT NULL COMMENT '规则ID', tag_id BIGINT NOT NULL COMMENT '标签ID', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', UNIQUE INDEX uk_rule_tag (rule_id, tag_id) COMMENT '规则+标签唯一索引', INDEX idx_rule_tag_tag_id (tag_id) COMMENT '标签ID索引' ); + +-- 邮箱账单配置表:存储用户用于自动拉取账单附件的 IMAP 邮箱连接信息 +CREATE TABLE IF NOT EXISTS t_email_bill_config ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '配置ID,主键自增', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + user_id BIGINT NOT NULL COMMENT '所属用户ID', + name VARCHAR(50) NOT NULL COMMENT '配置名称,便于用户识别', + host VARCHAR(255) NOT NULL COMMENT 'IMAP 服务器地址', + port INT NOT NULL DEFAULT 993 COMMENT 'IMAP 端口', + use_ssl TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否使用 SSL:1-是,0-否', + username VARCHAR(255) NOT NULL COMMENT '邮箱账号', + password VARCHAR(255) NOT NULL COMMENT '邮箱授权码(明文存储,前端脱敏返回)', + folder VARCHAR(100) NOT NULL DEFAULT 'INBOX' COMMENT '监听的邮件文件夹', + enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用监听:1-启用,0-关闭', + status VARCHAR(20) NOT NULL DEFAULT 'DISCONNECTED' COMMENT '连接状态:DISCONNECTED-未连接,CONNECTED-已连接,POLLING-轮询中,ERROR-异常', + last_error VARCHAR(500) COMMENT '最近一次连接或拉取失败原因', + last_connected_at DATETIME COMMENT '最近一次成功连接时间', + last_uid BIGINT COMMENT '已处理的最大邮件 UID,用于增量拉取', + expense_category_id BIGINT COMMENT '默认支出分类ID,可为空', + income_category_id BIGINT COMMENT '默认收入分类ID,可为空', + tag_ids TEXT COMMENT '默认标签ID列表(JSON数组)', + fetch_scope VARCHAR(20) NOT NULL DEFAULT 'INCREMENT' COMMENT '拉取范围:INCREMENT-仅新邮件,DAYS-最近N天,COUNT-最近N封', + fetch_limit INT COMMENT '拉取范围数量:DAYS 为天数,COUNT 为封数', + INDEX idx_email_bill_config_user_id (user_id) COMMENT '用户ID索引' +); + +-- 邮箱账单发件人规则表:按邮件发件人匹配账单类型与默认归类 +CREATE TABLE IF NOT EXISTS t_email_bill_sender_rule ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '规则ID,主键自增', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + user_id BIGINT NOT NULL COMMENT '所属用户ID', + name VARCHAR(50) NOT NULL COMMENT '规则名称', + enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用:1-启用,0-停用', + sort_order INT NOT NULL DEFAULT 0 COMMENT '优先级,数值越小越优先', + sender_patterns TEXT COMMENT '发件人匹配串列表(JSON数组),包含匹配且忽略大小写', + bill_type VARCHAR(20) NOT NULL COMMENT '命中的账单类型:ALIPAY/WECHAT/MEITUAN/CMB/BOCOM', + archive_password VARCHAR(100) COMMENT '该来源账单压缩包解压密码,可为空', + expense_category_id BIGINT COMMENT '默认支出分类ID,可为空', + income_category_id BIGINT COMMENT '默认收入分类ID,可为空', + tag_ids TEXT COMMENT '默认标签ID列表(JSON数组)', + INDEX idx_email_bill_sender_rule_user_id (user_id) COMMENT '用户ID索引' +); + +-- 邮箱账单消息表:记录拉取到的账单附件,解析与入库由用户手动触发 +CREATE TABLE IF NOT EXISTS t_email_bill_message ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息ID,主键自增', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + config_id BIGINT NOT NULL COMMENT '邮箱配置ID', + user_id BIGINT NOT NULL COMMENT '所属用户ID', + uid BIGINT NOT NULL COMMENT '邮件 IMAP UID', + message_id VARCHAR(255) COMMENT '邮件 Message-ID', + subject VARCHAR(500) COMMENT '邮件主题', + from_address VARCHAR(255) COMMENT '发件人地址', + received_at DATETIME COMMENT '邮件接收时间', + attachment_index INT NOT NULL DEFAULT 0 COMMENT '附件在邮件中的序号(从0开始)', + attachment_name VARCHAR(255) COMMENT '附件文件名', + attachment_path VARCHAR(500) COMMENT '附件本地存储相对路径', + sender_rule_id BIGINT COMMENT '命中的发件人规则ID', + bill_type VARCHAR(20) COMMENT '命中的账单类型,未命中时为空', + status VARCHAR(20) NOT NULL DEFAULT 'PULLED' COMMENT '处理状态:PULLED-待解析,CONFIRMED-已导入,IGNORED-已忽略', + error VARCHAR(500) COMMENT '解析失败原因', + UNIQUE INDEX uk_email_bill_message (config_id, uid, attachment_index) COMMENT '配置+UID+附件序号唯一索引,用于去重', + INDEX idx_email_bill_message_user_status (user_id, status) COMMENT '用户ID+状态联合索引' +); diff --git a/src/main/resources/sql/schema-sqlite.sql b/src/main/resources/sql/schema-sqlite.sql index 734c23e..6814e5e 100644 --- a/src/main/resources/sql/schema-sqlite.sql +++ b/src/main/resources/sql/schema-sqlite.sql @@ -9,27 +9,29 @@ CREATE TABLE IF NOT EXISTS t_user ( id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), + updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, - nickname VARCHAR(50), - created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), - updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')) + nickname VARCHAR(50) );; CREATE TABLE IF NOT EXISTS t_category ( id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), user_id INTEGER NOT NULL, name VARCHAR(50) NOT NULL, type VARCHAR(10) NOT NULL, icon VARCHAR(50), - sort_order INTEGER NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')) + sort_order INTEGER NOT NULL DEFAULT 0 );; CREATE INDEX IF NOT EXISTS idx_category_user_id ON t_category (user_id);; CREATE INDEX IF NOT EXISTS idx_category_user_type ON t_category (user_id, type);; CREATE TABLE IF NOT EXISTS t_transaction ( id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), + updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), user_id INTEGER NOT NULL, category_id INTEGER, type VARCHAR(10) NOT NULL, @@ -40,9 +42,7 @@ CREATE TABLE IF NOT EXISTS t_transaction ( external_no VARCHAR(64), transaction_time DATETIME NOT NULL, include_in_stats INTEGER NOT NULL DEFAULT 1, - raw_data TEXT, - created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), - updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')) + raw_data TEXT );; CREATE INDEX IF NOT EXISTS idx_transaction_user_id ON t_transaction (user_id);; CREATE INDEX IF NOT EXISTS idx_transaction_category_id ON t_transaction (category_id);; @@ -52,38 +52,39 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_user_external ON t_transaction (user_id, ex CREATE TABLE IF NOT EXISTS t_tag ( id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), user_id INTEGER NOT NULL, name VARCHAR(50) NOT NULL, color VARCHAR(20), - sort_order INTEGER NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')) + sort_order INTEGER NOT NULL DEFAULT 0 );; CREATE INDEX IF NOT EXISTS idx_tag_user_id ON t_tag (user_id);; CREATE UNIQUE INDEX IF NOT EXISTS uk_tag_user_name ON t_tag (user_id, name);; CREATE TABLE IF NOT EXISTS t_transaction_tag ( id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), transaction_id INTEGER NOT NULL, - tag_id INTEGER NOT NULL, - created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')) + tag_id INTEGER NOT NULL );; CREATE UNIQUE INDEX IF NOT EXISTS uk_transaction_tag ON t_transaction_tag (transaction_id, tag_id);; CREATE INDEX IF NOT EXISTS idx_transaction_tag_tag_id ON t_transaction_tag (tag_id);; CREATE TABLE IF NOT EXISTS t_ai_config ( id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), + updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), user_id INTEGER NOT NULL, enabled INTEGER NOT NULL DEFAULT 0, api_key VARCHAR(255), base_url VARCHAR(255) NOT NULL DEFAULT 'https://api.deepseek.com', - model VARCHAR(50) NOT NULL DEFAULT 'deepseek-chat', - created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), - updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')) + model VARCHAR(50) NOT NULL DEFAULT 'deepseek-chat' );; CREATE UNIQUE INDEX IF NOT EXISTS uk_ai_config_user_id ON t_ai_config (user_id);; CREATE TABLE IF NOT EXISTS t_ai_usage ( id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), user_id INTEGER NOT NULL, type VARCHAR(20) NOT NULL, model VARCHAR(50), @@ -94,8 +95,7 @@ CREATE TABLE IF NOT EXISTS t_ai_usage ( cached_tokens INTEGER NOT NULL DEFAULT 0, success INTEGER NOT NULL DEFAULT 1, error_message VARCHAR(500), - duration_ms INTEGER, - created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')) + duration_ms INTEGER );; CREATE INDEX IF NOT EXISTS idx_ai_usage_user_created ON t_ai_usage (user_id, created_at);; @@ -126,38 +126,38 @@ END;; CREATE TABLE IF NOT EXISTS t_notification_config ( id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), + updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), user_id INTEGER NOT NULL, enabled INTEGER NOT NULL DEFAULT 0, frequency VARCHAR(10) NOT NULL DEFAULT 'MONTHLY', day_of_month INTEGER NOT NULL DEFAULT 1, day_of_week INTEGER NOT NULL DEFAULT 1, send_time VARCHAR(5) NOT NULL DEFAULT '09:00', - last_sent_at DATETIME, - created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), - updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')) + last_sent_at DATETIME );; CREATE UNIQUE INDEX IF NOT EXISTS uk_notification_config_user_id ON t_notification_config (user_id);; CREATE TABLE IF NOT EXISTS t_notification_channel ( id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), + updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), user_id INTEGER NOT NULL, type VARCHAR(20) NOT NULL, name VARCHAR(50), enabled INTEGER NOT NULL DEFAULT 1, - config TEXT, - created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), - updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')) + config TEXT );; CREATE INDEX IF NOT EXISTS idx_notification_channel_user_id ON t_notification_channel (user_id);; CREATE TABLE IF NOT EXISTS t_notification_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), user_id INTEGER NOT NULL, channel_id INTEGER, channel_type VARCHAR(20), success INTEGER NOT NULL DEFAULT 1, - message VARCHAR(500), - created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')) + message VARCHAR(500) );; CREATE INDEX IF NOT EXISTS idx_notification_log_user_created ON t_notification_log (user_id, created_at);; @@ -179,6 +179,7 @@ END;; CREATE TABLE IF NOT EXISTS t_http_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), user_id INTEGER, method VARCHAR(10), path VARCHAR(255), @@ -186,27 +187,28 @@ CREATE TABLE IF NOT EXISTS t_http_log ( status INTEGER, duration_ms INTEGER, ip VARCHAR(64), - user_agent VARCHAR(500), - created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')) + user_agent VARCHAR(500) );; CREATE INDEX IF NOT EXISTS idx_http_log_created ON t_http_log (created_at);; CREATE INDEX IF NOT EXISTS idx_http_log_user_created ON t_http_log (user_id, created_at);; CREATE TABLE IF NOT EXISTS t_login_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), user_id INTEGER, username VARCHAR(50), success INTEGER NOT NULL DEFAULT 1, message VARCHAR(500), ip VARCHAR(64), - user_agent VARCHAR(500), - created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')) + user_agent VARCHAR(500) );; CREATE INDEX IF NOT EXISTS idx_login_log_created ON t_login_log (created_at);; CREATE INDEX IF NOT EXISTS idx_login_log_user_created ON t_login_log (user_id, created_at);; CREATE TABLE IF NOT EXISTS t_rule ( id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), + updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), user_id INTEGER NOT NULL, name VARCHAR(50) NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, @@ -215,17 +217,15 @@ CREATE TABLE IF NOT EXISTS t_rule ( keyword VARCHAR(100), min_amount NUMERIC(12, 2), max_amount NUMERIC(12, 2), - category_id INTEGER, - created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), - updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')) + category_id INTEGER );; CREATE INDEX IF NOT EXISTS idx_rule_user_id ON t_rule (user_id);; CREATE TABLE IF NOT EXISTS t_rule_tag ( id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), rule_id INTEGER NOT NULL, - tag_id INTEGER NOT NULL, - created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')) + tag_id INTEGER NOT NULL );; CREATE UNIQUE INDEX IF NOT EXISTS uk_rule_tag ON t_rule_tag (rule_id, tag_id);; CREATE INDEX IF NOT EXISTS idx_rule_tag_tag_id ON t_rule_tag (tag_id);; @@ -237,3 +237,91 @@ WHEN NEW.updated_at = OLD.updated_at BEGIN UPDATE t_rule SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id; END;; + +CREATE TABLE IF NOT EXISTS t_email_bill_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), + updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), + user_id INTEGER NOT NULL, + name VARCHAR(50) NOT NULL, + host VARCHAR(255) NOT NULL, + port INTEGER NOT NULL DEFAULT 993, + use_ssl INTEGER NOT NULL DEFAULT 1, + username VARCHAR(255) NOT NULL, + password VARCHAR(255) NOT NULL, + folder VARCHAR(100) NOT NULL DEFAULT 'INBOX', + enabled INTEGER NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'DISCONNECTED', + last_error VARCHAR(500), + last_connected_at DATETIME, + last_uid INTEGER, + expense_category_id INTEGER, + income_category_id INTEGER, + tag_ids TEXT, + fetch_scope VARCHAR(20) NOT NULL DEFAULT 'INCREMENT', + fetch_limit INTEGER +);; +CREATE INDEX IF NOT EXISTS idx_email_bill_config_user_id ON t_email_bill_config (user_id);; + +CREATE TABLE IF NOT EXISTS t_email_bill_sender_rule ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), + updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), + user_id INTEGER NOT NULL, + name VARCHAR(50) NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + sender_patterns TEXT, + bill_type VARCHAR(20) NOT NULL, + archive_password VARCHAR(100), + expense_category_id INTEGER, + income_category_id INTEGER, + tag_ids TEXT +);; +CREATE INDEX IF NOT EXISTS idx_email_bill_sender_rule_user_id ON t_email_bill_sender_rule (user_id);; + +CREATE TABLE IF NOT EXISTS t_email_bill_message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), + updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), + config_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + uid INTEGER NOT NULL, + message_id VARCHAR(255), + subject VARCHAR(500), + from_address VARCHAR(255), + received_at DATETIME, + attachment_index INTEGER NOT NULL DEFAULT 0, + attachment_name VARCHAR(255), + attachment_path VARCHAR(500), + sender_rule_id INTEGER, + bill_type VARCHAR(20), + status VARCHAR(20) NOT NULL DEFAULT 'PULLED', + error VARCHAR(500) +);; +CREATE UNIQUE INDEX IF NOT EXISTS uk_email_bill_message ON t_email_bill_message (config_id, uid, attachment_index);; +CREATE INDEX IF NOT EXISTS idx_email_bill_message_user_status ON t_email_bill_message (user_id, status);; + +CREATE TRIGGER IF NOT EXISTS trg_email_bill_config_updated_at +AFTER UPDATE ON t_email_bill_config +FOR EACH ROW +WHEN NEW.updated_at = OLD.updated_at +BEGIN + UPDATE t_email_bill_config SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id; +END;; + +CREATE TRIGGER IF NOT EXISTS trg_email_bill_sender_rule_updated_at +AFTER UPDATE ON t_email_bill_sender_rule +FOR EACH ROW +WHEN NEW.updated_at = OLD.updated_at +BEGIN + UPDATE t_email_bill_sender_rule SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id; +END;; + +CREATE TRIGGER IF NOT EXISTS trg_email_bill_message_updated_at +AFTER UPDATE ON t_email_bill_message +FOR EACH ROW +WHEN NEW.updated_at = OLD.updated_at +BEGIN + UPDATE t_email_bill_message SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id; +END;;