From c69e76ed4c06beaec348b847d412774648ef1291 Mon Sep 17 00:00:00 2001 From: rosercode Date: Fri, 18 Sep 2026 17:42:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E5=88=86=E7=B1=BB=E4=B8=8E=E6=A0=87=E7=AD=BE=E8=A7=84?= =?UTF-8?q?=E5=88=99=E5=B9=B6=E4=BC=98=E5=8C=96=20AI=20=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 t_rule、t_rule_tag 表(MySQL/SQLite 双脚本)与规则引擎,支持关键词(备注/交易对方/商品名称)、收支类型、金额区间组合匹配 - 新增 /api/rule 增删改查与启用停用接口,侧边栏新增「规则管理」页,支持排序与启停 - 账单导入解析阶段批量匹配,返回命中规则与建议分类/标签,预览表格新增「匹配规则/建议」列 - 确认导入时按行重新匹配:规则建议分类优先于面板默认分类,规则标签与默认标签合并去重 - 删除标签时同步清理规则标签关联 - AI 自然语言查询解析后直接应用筛选并搜索,移除二次确认弹窗,成功提示展示识别意图 - 修正登录日志筛选 success 参数类型为 'true'/'false' --- frontend/src/api/rule.ts | 65 +++ frontend/src/api/transaction.ts | 5 + frontend/src/locales/messages.ts | 31 ++ frontend/src/router/index.ts | 5 + frontend/src/views/BillImportPanel.vue | 33 ++ frontend/src/views/Layout.vue | 4 +- frontend/src/views/Logs.vue | 8 +- frontend/src/views/RuleManage.vue | 407 ++++++++++++++++++ frontend/src/views/TransactionList.vue | 75 +--- .../finance/controller/RuleController.java | 131 ++++++ .../finance/converter/VoConverter.java | 21 + .../cn/rosercode/finance/dto/RuleRequest.java | 44 ++ .../cn/rosercode/finance/entity/Rule.java | 58 +++ .../cn/rosercode/finance/entity/RuleTag.java | 31 ++ .../finance/importer/ParsedBillRow.java | 17 + .../rosercode/finance/mapper/RuleMapper.java | 14 + .../finance/mapper/RuleTagMapper.java | 14 + .../finance/service/RuleMatchContext.java | 31 ++ .../finance/service/RuleMatchResult.java | 33 ++ .../finance/service/RuleService.java | 68 +++ .../service/impl/BillImportServiceImpl.java | 101 ++++- .../finance/service/impl/RuleServiceImpl.java | 373 ++++++++++++++++ .../finance/service/impl/TagServiceImpl.java | 11 +- .../java/cn/rosercode/finance/vo/RuleVO.java | 60 +++ src/main/resources/sql/init.sql | 27 ++ src/main/resources/sql/schema-sqlite.sql | 33 ++ 26 files changed, 1622 insertions(+), 78 deletions(-) create mode 100644 frontend/src/api/rule.ts create mode 100644 frontend/src/views/RuleManage.vue create mode 100644 src/main/java/cn/rosercode/finance/controller/RuleController.java create mode 100644 src/main/java/cn/rosercode/finance/dto/RuleRequest.java create mode 100644 src/main/java/cn/rosercode/finance/entity/Rule.java create mode 100644 src/main/java/cn/rosercode/finance/entity/RuleTag.java create mode 100644 src/main/java/cn/rosercode/finance/mapper/RuleMapper.java create mode 100644 src/main/java/cn/rosercode/finance/mapper/RuleTagMapper.java create mode 100644 src/main/java/cn/rosercode/finance/service/RuleMatchContext.java create mode 100644 src/main/java/cn/rosercode/finance/service/RuleMatchResult.java create mode 100644 src/main/java/cn/rosercode/finance/service/RuleService.java create mode 100644 src/main/java/cn/rosercode/finance/service/impl/RuleServiceImpl.java create mode 100644 src/main/java/cn/rosercode/finance/vo/RuleVO.java diff --git a/frontend/src/api/rule.ts b/frontend/src/api/rule.ts new file mode 100644 index 0000000..dd0a0d9 --- /dev/null +++ b/frontend/src/api/rule.ts @@ -0,0 +1,65 @@ +// 规则相关 API 接口:自定义分类与标签规则的增删改查与启用切换 +import request from './request' + +/** 规则关联的标签 */ +export interface RuleTag { + id: number + name: string + color?: string +} + +/** 规则数据结构 */ +export interface RuleItem { + id: number + name: string + enabled: boolean + sortOrder: number + type: string | null + keyword: string | null + minAmount: number | null + maxAmount: number | null + categoryId: number | null + categoryName: string | null + categoryIcon: string | null + tags: RuleTag[] | null + createdAt: string + updatedAt: string +} + +/** 创建/更新规则的请求体 */ +export interface RulePayload { + name: string + enabled?: boolean + sortOrder?: number + type?: string | null + keyword?: string | null + minAmount?: number | null + maxAmount?: number | null + categoryId?: number | null + tagIds?: number[] +} + +/** 获取规则列表 */ +export function getRuleList() { + return request.get('/rule/list') +} + +/** 创建规则 */ +export function createRule(data: RulePayload) { + return request.post('/rule', data) +} + +/** 更新指定规则 */ +export function updateRule(id: number, data: RulePayload) { + return request.put(`/rule/${id}`, data) +} + +/** 删除指定规则 */ +export function deleteRule(id: number) { + return request.delete(`/rule/${id}`) +} + +/** 启用/停用指定规则 */ +export function toggleRuleEnabled(id: number, enabled: boolean) { + return request.put(`/rule/${id}/enabled`, { enabled }) +} diff --git a/frontend/src/api/transaction.ts b/frontend/src/api/transaction.ts index 639ac5f..b5d9269 100644 --- a/frontend/src/api/transaction.ts +++ b/frontend/src/api/transaction.ts @@ -85,6 +85,11 @@ export interface ParsedBillRow { importable: boolean selected: boolean duplicate: boolean + matchedRuleNames: string[] | null + suggestedCategoryId: number | null + suggestedCategoryName: string | null + suggestedCategoryIcon: string | null + suggestedTags: { id: number; name: string; color?: string }[] | null rawData: string | null error: string | null } diff --git a/frontend/src/locales/messages.ts b/frontend/src/locales/messages.ts index 104fc91..47f4091 100644 --- a/frontend/src/locales/messages.ts +++ b/frontend/src/locales/messages.ts @@ -82,6 +82,7 @@ export default { transactions: { zh: '收支记录', en: 'Transactions', ja: '収支記録' }, categories: { zh: '分类管理', en: 'Categories', ja: 'カテゴリ管理' }, tags: { zh: '标签管理', en: 'Tags', ja: 'タグ管理' }, + rules: { zh: '规则管理', en: 'Rules', ja: 'ルール管理' }, logs: { zh: '系统日志', en: 'Logs', ja: 'システムログ' }, settings: { zh: '系统设置', en: 'Settings', ja: 'システム設定' }, user: { zh: '用户', en: 'User', ja: 'ユーザー' }, @@ -432,6 +433,35 @@ export default { deleteConfirm: { zh: '删除后将从所有交易记录中移除该标签,确定删除吗?', en: 'This tag will be removed from all transactions. Delete it?', ja: '削除するとすべての取引記録からこのタグが外れます。削除してもよろしいですか?' }, }, + // 规则管理 + rule: { + title: { zh: '规则管理', en: 'Rules', ja: 'ルール管理' }, + add: { zh: '新增规则', en: 'Add Rule', ja: 'ルールを追加' }, + editTitle: { zh: '编辑规则', en: 'Edit Rule', ja: 'ルールを編集' }, + addTitle: { zh: '新增规则', en: 'Add Rule', ja: 'ルールを追加' }, + name: { zh: '规则名称', en: 'Rule Name', ja: 'ルール名' }, + namePlaceholder: { zh: '请输入规则名称', en: 'Enter rule name', ja: 'ルール名を入力してください' }, + nameRequired: { zh: '请输入规则名称', en: 'Please enter a rule name', ja: 'ルール名を入力してください' }, + condition: { zh: '匹配条件', en: 'Conditions', ja: '一致条件' }, + keyword: { zh: '关键词', en: 'Keyword', ja: 'キーワード' }, + keywordPlaceholder: { zh: '匹配备注/交易对方/商品名称,包含即命中', en: 'Matches remark / counterparty / product (contains)', ja: '備考/取引相手/商品名に含まれれば一致' }, + amountRange: { zh: '金额区间', en: 'Amount Range', ja: '金額範囲' }, + typeAny: { zh: '不限类型', en: 'Any type', ja: '種類を問わない' }, + targetCategory: { zh: '目标分类', en: 'Target Category', ja: '対象カテゴリ' }, + targetTags: { zh: '目标标签', en: 'Target Tags', ja: '対象タグ' }, + notSetCategory: { zh: '不设置分类', en: 'No category', ja: 'カテゴリなし' }, + notSetTag: { zh: '不设置标签', en: 'No tags', ja: 'タグなし' }, + enabled: { zh: '启用', en: 'Enabled', ja: '有効' }, + summaryKeyword: { zh: '含「{kw}」', en: 'contains "{kw}"', ja: '「{kw}」を含む' }, + summaryAmountRange: { zh: '{min} ~ {max}', en: '{min} ~ {max}', ja: '{min} ~ {max}' }, + summaryAmountMin: { zh: '≥ {min}', en: '≥ {min}', ja: '≥ {min}' }, + summaryAmountMax: { zh: '≤ {max}', en: '≤ {max}', ja: '≤ {max}' }, + summaryNone: { zh: '无条件', en: 'No condition', ja: '条件なし' }, + deleteConfirm: { zh: '确定要删除这个规则吗?', en: 'Are you sure you want to delete this rule?', ja: 'このルールを削除してもよろしいですか?' }, + loadCategoriesFailed: { zh: '加载分类失败', en: 'Failed to load categories', ja: 'カテゴリの読み込みに失敗しました' }, + loadTagsFailed: { zh: '加载标签失败', en: 'Failed to load tags', ja: 'タグの読み込みに失敗しました' }, + }, + // 账单导入 import: { title: { zh: '导入账单', en: 'Import Bill', ja: '明細をインポート' }, @@ -478,6 +508,7 @@ export default { counterparty: { zh: '交易对方', en: 'Counterparty', ja: '取引相手' }, product: { zh: '商品名称', en: 'Product', ja: '商品名' }, status: { zh: '状态', en: 'Status', ja: 'ステータス' }, + matchedRule: { zh: '匹配规则/建议', en: 'Matched Rule', ja: '一致ルール' }, }, errorFileType: { zh: '账单类型「{label}」仅支持 {exts} 格式的文件', en: 'Bill type "{label}" only supports files in {exts} format', ja: '明細の種類「{label}」は {exts} 形式のファイルのみ対応しています' }, selectFileFirst: { zh: '请先选择账单文件', en: 'Please select a bill file first', ja: '先に明細ファイルを選択してください' }, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index aba736f..10233d6 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -53,6 +53,11 @@ const router = createRouter({ name: 'Tags', component: () => import('../views/TagManage.vue'), // 标签管理 }, + { + path: 'rules', + name: 'Rules', + component: () => import('../views/RuleManage.vue'), // 规则管理(自定义分类与标签规则) + }, { path: 'settings', name: 'Settings', diff --git a/frontend/src/views/BillImportPanel.vue b/frontend/src/views/BillImportPanel.vue index 9327a8a..5d6c1d5 100644 --- a/frontend/src/views/BillImportPanel.vue +++ b/frontend/src/views/BillImportPanel.vue @@ -220,6 +220,39 @@ const columns = computed>(() => [ return h('span', null, row.tradeStatus || '-') }, }, + { + title: t('import.column.matchedRule'), + key: 'matchedRule', + width: 220, + render: (row) => { + const ruleNames = row.matchedRuleNames || [] + const suggestedTags = row.suggestedTags || [] + if (ruleNames.length === 0 && !row.suggestedCategoryId && suggestedTags.length === 0) { + return h('span', { style: { color: '#ccc' } }, '—') + } + const nodes: any[] = [] + // 命中规则名,多个时折叠为 +N + if (ruleNames.length) { + nodes.push(h(NTag, { size: 'small', type: 'warning' }, { + default: () => (ruleNames.length > 1 ? `${ruleNames[0]} +${ruleNames.length - 1}` : ruleNames[0]), + })) + } + // 建议分类 + if (row.suggestedCategoryId) { + nodes.push(h(NTag, { size: 'small', type: 'info' }, { + default: () => `${row.suggestedCategoryIcon || ''}${row.suggestedCategoryName || ''}`, + })) + } + // 建议标签 + for (const tag of suggestedTags) { + nodes.push(h(NTag, { + size: 'small', + color: tag.color ? { color: tag.color, textColor: '#fff' } : undefined, + }, { default: () => tag.name })) + } + return h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '4px' } }, nodes) + }, + }, ]) /** 加载分类选项 */ diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue index c72ea69..3e0c567 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, DocumentTextOutline, SettingsOutline, LogOutOutline, ChevronDownOutline } from '@vicons/ionicons5' +import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, OptionsOutline, DocumentTextOutline, SettingsOutline, LogOutOutline, ChevronDownOutline } from '@vicons/ionicons5' import { useUserStore } from '../store/user' import LocaleSwitch from '../components/LocaleSwitch.vue' @@ -21,6 +21,7 @@ const activeKey = computed(() => { '/transactions': 'transactions', '/categories': 'categories', '/tags': 'tags', + '/rules': 'rules', } return map[route.path] || 'dashboard' }) @@ -36,6 +37,7 @@ const menuOptions = computed(() => [ { label: t('layout.transactions'), key: 'transactions', icon: renderIcon(ListOutline) }, { 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) }, ]) /** 菜单点击事件:根据 key 导航到对应路由 */ diff --git a/frontend/src/views/Logs.vue b/frontend/src/views/Logs.vue index 8839856..4650ee5 100644 --- a/frontend/src/views/Logs.vue +++ b/frontend/src/views/Logs.vue @@ -53,7 +53,7 @@ const loginPage = ref(1) const loginSize = ref(20) const loginTotal = ref(0) const loginKeyword = ref('') -const loginSuccess = ref(null) +const loginSuccess = ref<'true' | 'false' | null>(null) // ---------------- 运行日志 ---------------- const appData = ref(null) @@ -80,8 +80,8 @@ const statusOptions = [200, 201, 400, 401, 403, 404, 500].map((value) => ({ labe /** 登录结果下拉选项 */ const loginResultOptions = computed(() => [ - { label: t('log.success'), value: true }, - { label: t('log.failed'), value: false }, + { label: t('log.success'), value: 'true' }, + { label: t('log.failed'), value: 'false' }, ]) /** 日志级别下拉选项 */ @@ -221,7 +221,7 @@ async function loadLogin() { page: loginPage.value, size: loginSize.value, keyword: loginKeyword.value.trim() || undefined, - success: loginSuccess.value ?? undefined, + success: loginSuccess.value === null ? undefined : loginSuccess.value === 'true', }) loginRecords.value = res.data.records || [] loginTotal.value = res.data.total || 0 diff --git a/frontend/src/views/RuleManage.vue b/frontend/src/views/RuleManage.vue new file mode 100644 index 0000000..7e76c69 --- /dev/null +++ b/frontend/src/views/RuleManage.vue @@ -0,0 +1,407 @@ + + + diff --git a/frontend/src/views/TransactionList.vue b/frontend/src/views/TransactionList.vue index bfda01f..9977f11 100644 --- a/frontend/src/views/TransactionList.vue +++ b/frontend/src/views/TransactionList.vue @@ -2,7 +2,7 @@ // 收支记录管理组件:展示交易记录列表(分页),支持新增、编辑、删除、多条件筛选 import { ref, onMounted, computed, h, watch } from 'vue' import { useI18n } from 'vue-i18n' -import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDescriptions, NDescriptionsItem, NDivider, NDropdown } from 'naive-ui' +import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NDropdown } from 'naive-ui' import { EyeOutline, EyeOffOutline, CloseCircleOutline, SparklesOutline, BulbOutline } from '@vicons/ionicons5' import type { DataTableColumns, AutoCompleteOption } from 'naive-ui' import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags } from '../api/transaction' @@ -126,7 +126,6 @@ const aiSuggestions = ref([]) // AI 建议列表 const checkedAiRowKeys = ref([]) // AI 建议弹窗中已勾选的代表记录ID const aiQueryText = ref('') // AI 自然语言查询输入 const aiQuerying = ref(false) // AI 查询解析状态 -const showAiQueryModal = ref(false) // AI 查询结果弹窗显示状态 const aiQueryResult = ref(null) // AI 查询解析结果 // AI 查询历史存储键与上限 const AI_QUERY_HISTORY_KEY = 'finance.transaction.aiQueryHistory' @@ -1115,7 +1114,13 @@ async function handleAiQuery() { try { const res: any = await queryByAi(text) aiQueryResult.value = res.data - showAiQueryModal.value = true + // 无任何可用筛选条件时提示,避免误清空当前筛选 + if (aiQueryEmpty.value) { + message.warning(t('ai.query.noCondition')) + return + } + // 直接应用解析结果并搜索,无需二次确认 + applyAiQuery() } catch (e: any) { message.error(e.message || t('ai.query.failed')) } finally { @@ -1140,9 +1145,9 @@ function applyAiQuery() { filterMaxAmount.value = r.maxAmount ?? null filterRemark.value = r.remark || '' filterTag.value = r.tagIds || [] - showAiQueryModal.value = false handleSearch() - message.success(t('ai.query.applied')) + // 成功提示优先展示 AI 识别的查询意图 + message.success(r.summary || t('ai.query.applied')) } /** 解析结果是否为空(无任何可用筛选条件) */ @@ -1161,11 +1166,6 @@ const aiQueryEmpty = computed(() => { && r.includeInStats == null }) -/** 格式化解析结果中的时间用于展示 */ -function formatAiDate(value: string | null | undefined) { - return value ? value.replace('T', ' ').substring(0, 16) : '' -} - /** AI 查询示例(从 i18n 拆分为数组) */ const queryExamples = computed(() => t('ai.query.examples').split('|')) @@ -1923,61 +1923,6 @@ onMounted(() => { - - - - {{ aiQueryResult.summary }} - {{ t('ai.query.noCondition') }} - - - - - {{ tp === 'INCOME' ? t('common.types.income') : t('common.types.expense') }} - - - - - - {{ c }} - {{ t('transaction.uncategorized') }} - - - - - {{ tg }} - - - - - {{ getPaymentLabel(pm) }} - - - - {{ formatAiDate(aiQueryResult.startTime) }} ~ {{ formatAiDate(aiQueryResult.endTime) }} - - - {{ aiQueryResult.minAmount ?? 0 }} ~ {{ aiQueryResult.maxAmount ?? '∞' }} - - {{ aiQueryResult.remark }} - - {{ aiQueryResult.includeInStats ? t('ai.query.includeYes') : t('ai.query.includeNo') }} - - - - {{ u }} - - - - - {{ t('common.cancel') }} - {{ t('ai.query.apply') }} - - - - diff --git a/src/main/java/cn/rosercode/finance/controller/RuleController.java b/src/main/java/cn/rosercode/finance/controller/RuleController.java new file mode 100644 index 0000000..9bb2204 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/controller/RuleController.java @@ -0,0 +1,131 @@ +package cn.rosercode.finance.controller; + +import cn.rosercode.finance.common.Result; +import cn.rosercode.finance.dto.RuleRequest; +import cn.rosercode.finance.service.RuleService; +import cn.rosercode.finance.vo.RuleVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +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.RestController; + +import java.util.List; + +/** + * 规则控制器 + * + *

提供自定义分类与标签规则的增删改查、启用停用接口

+ * + * @date 2026-09-18 17:25:10 + */ +@RestController +@RequestMapping("/api/rule") +@Tag(name = "规则管理", description = "自定义分类与标签规则的增删改查相关接口") +public class RuleController { + + private final RuleService ruleService; + + public RuleController(RuleService ruleService) { + this.ruleService = ruleService; + } + + /** + * 获取规则列表 + * + * @param request HTTP请求,从中获取用户ID + * @return 规则列表 + */ + @GetMapping("/list") + @Operation(summary = "获取规则列表", description = "获取当前用户的全部规则,按优先级排序") + public Result> list(HttpServletRequest request) { + Long userId = (Long) request.getAttribute("userId"); + return Result.success(ruleService.list(userId)); + } + + /** + * 创建规则 + * + * @param request HTTP请求,从中获取用户ID + * @param ruleRequest 规则请求参数 + * @return 操作结果 + */ + @PostMapping + @Operation(summary = "创建规则", description = "为当前用户创建新的分类与标签规则") + public Result create(HttpServletRequest request, @RequestBody @Valid RuleRequest ruleRequest) { + Long userId = (Long) request.getAttribute("userId"); + ruleService.create(userId, ruleRequest); + return Result.success(); + } + + /** + * 更新规则 + * + * @param request HTTP请求,从中获取用户ID + * @param id 规则ID + * @param ruleRequest 规则请求参数 + * @return 操作结果 + */ + @PutMapping("/{id}") + @Operation(summary = "更新规则", description = "根据规则ID更新规则信息") + public Result update(HttpServletRequest request, + @Parameter(description = "规则ID") @PathVariable Long id, + @RequestBody @Valid RuleRequest ruleRequest) { + Long userId = (Long) request.getAttribute("userId"); + ruleService.update(userId, id, ruleRequest); + return Result.success(); + } + + /** + * 删除规则 + * + * @param request HTTP请求,从中获取用户ID + * @param id 规则ID + * @return 操作结果 + */ + @DeleteMapping("/{id}") + @Operation(summary = "删除规则", description = "根据规则ID删除指定规则及其标签关联") + public Result delete(HttpServletRequest request, + @Parameter(description = "规则ID") @PathVariable Long id) { + Long userId = (Long) request.getAttribute("userId"); + ruleService.delete(userId, id); + return Result.success(); + } + + /** + * 启用或停用规则 + * + * @param request HTTP请求,从中获取用户ID + * @param id 规则ID + * @param enabled 是否启用 + * @return 操作结果 + */ + @PutMapping("/{id}/enabled") + @Operation(summary = "启用/停用规则", description = "切换指定规则的启用状态") + public Result toggleEnabled(HttpServletRequest request, + @Parameter(description = "规则ID") @PathVariable Long id, + @RequestBody RuleEnabledRequest enabled) { + Long userId = (Long) request.getAttribute("userId"); + ruleService.toggleEnabled(userId, id, enabled.getEnabled()); + return Result.success(); + } + + /** + * 启用状态请求体 + */ + @Data + public static class RuleEnabledRequest { + + /** 是否启用 */ + 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 b86b862..71b9b81 100644 --- a/src/main/java/cn/rosercode/finance/converter/VoConverter.java +++ b/src/main/java/cn/rosercode/finance/converter/VoConverter.java @@ -6,6 +6,7 @@ import cn.rosercode.finance.entity.HttpLog; import cn.rosercode.finance.entity.LoginLog; import cn.rosercode.finance.entity.NotificationChannel; import cn.rosercode.finance.entity.NotificationLog; +import cn.rosercode.finance.entity.Rule; import cn.rosercode.finance.entity.Tag; import cn.rosercode.finance.entity.Transaction; import cn.rosercode.finance.entity.User; @@ -15,6 +16,7 @@ import cn.rosercode.finance.vo.HttpLogVO; import cn.rosercode.finance.vo.LoginLogVO; import cn.rosercode.finance.vo.NotificationChannelVO; import cn.rosercode.finance.vo.NotificationLogVO; +import cn.rosercode.finance.vo.RuleVO; import cn.rosercode.finance.vo.TagVO; import cn.rosercode.finance.vo.TransactionVO; import cn.rosercode.finance.vo.UserVO; @@ -132,4 +134,23 @@ public interface VoConverter { @Mapping(target = "config", ignore = true) @Mapping(target = "hasPassword", ignore = true) NotificationChannelVO toNotificationChannelVO(NotificationChannel channel); + + /** + * 规则实体与关联分类转视图对象。 + * + *

分类名称/图标取自关联分类,未关联时为空字符串;标签列表由调用方另行填充。

+ * + * @param rule 规则实体 + * @param category 关联分类实体,可为 null + * @return 规则视图对象 + */ + @Mapping(target = "id", source = "rule.id") + @Mapping(target = "name", source = "rule.name") + @Mapping(target = "type", source = "rule.type") + @Mapping(target = "sortOrder", source = "rule.sortOrder") + @Mapping(target = "createdAt", source = "rule.createdAt") + @Mapping(target = "categoryName", source = "category.name", defaultValue = "") + @Mapping(target = "categoryIcon", source = "category.icon", defaultValue = "") + @Mapping(target = "tags", ignore = true) + RuleVO toRuleVO(Rule rule, Category category); } diff --git a/src/main/java/cn/rosercode/finance/dto/RuleRequest.java b/src/main/java/cn/rosercode/finance/dto/RuleRequest.java new file mode 100644 index 0000000..12be6c1 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/dto/RuleRequest.java @@ -0,0 +1,44 @@ +package cn.rosercode.finance.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 规则请求DTO,用于接收创建或更新自定义分类与标签规则时提交的信息。 + * + * @date 2026-09-18 17:23:11 + */ +@Data +public class RuleRequest { + + /** 规则名称,不能为空 */ + @NotBlank(message = "规则名称不能为空") + private String name; + + /** 是否启用,可选,默认启用 */ + private Boolean enabled; + + /** 优先级,数值越小越优先,可选 */ + private Integer sortOrder; + + /** 限定收支类型:INCOME/EXPENSE,为空表示不限 */ + private String type; + + /** 关键词,匹配备注/交易对方/商品名称,为空表示不限 */ + private String keyword; + + /** 金额下限(含),可选 */ + private BigDecimal minAmount; + + /** 金额上限(含),可选 */ + private BigDecimal maxAmount; + + /** 命中的目标分类ID,可选 */ + private Long categoryId; + + /** 命中的目标标签ID列表,可选 */ + private List tagIds; +} diff --git a/src/main/java/cn/rosercode/finance/entity/Rule.java b/src/main/java/cn/rosercode/finance/entity/Rule.java new file mode 100644 index 0000000..9887764 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/entity/Rule.java @@ -0,0 +1,58 @@ +package cn.rosercode.finance.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 自定义分类与标签规则实体类,对应数据库表 t_rule。 + * + *

规则按关键词、收支类型、金额区间等条件匹配交易,命中后自动应用目标分类与标签。

+ * + * @date 2026-09-18 17:22:59 + */ +@Data +@TableName("t_rule") +public class Rule { + + /** 规则ID,主键自增 */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 所属用户ID */ + private Long userId; + + /** 规则名称 */ + private String name; + + /** 是否启用:true-启用,false-停用 */ + private Boolean enabled; + + /** 优先级,数值越小越优先 */ + private Integer sortOrder; + + /** 限定收支类型:INCOME-收入,EXPENSE-支出,null 表示不限 */ + private String type; + + /** 关键词,匹配备注/交易对方/商品名称(包含匹配,忽略大小写),null 表示不限 */ + private String keyword; + + /** 金额下限(含),null 表示不限 */ + private BigDecimal minAmount; + + /** 金额上限(含),null 表示不限 */ + private BigDecimal maxAmount; + + /** 命中的目标分类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 new file mode 100644 index 0000000..ea61d3e --- /dev/null +++ b/src/main/java/cn/rosercode/finance/entity/RuleTag.java @@ -0,0 +1,31 @@ +package cn.rosercode.finance.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 规则标签关联实体类,对应数据库表 t_rule_tag,表示规则与标签的多对多关系。 + * + * @date 2026-09-18 17:23:02 + */ +@Data +@TableName("t_rule_tag") +public class RuleTag { + + /** 关联ID,主键自增 */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 规则ID */ + private Long ruleId; + + /** 标签ID */ + private Long tagId; + + /** 创建时间 */ + private LocalDateTime createdAt; +} diff --git a/src/main/java/cn/rosercode/finance/importer/ParsedBillRow.java b/src/main/java/cn/rosercode/finance/importer/ParsedBillRow.java index 531f7a5..c2276e3 100644 --- a/src/main/java/cn/rosercode/finance/importer/ParsedBillRow.java +++ b/src/main/java/cn/rosercode/finance/importer/ParsedBillRow.java @@ -1,9 +1,11 @@ package cn.rosercode.finance.importer; +import cn.rosercode.finance.vo.TagVO; import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.List; /** * 账单解析结果行,作为「先解析、后导入」流程中解析阶段的中间对象。 @@ -54,6 +56,21 @@ public class ParsedBillRow { /** 是否与系统中已有记录重复(同一用户下交易号已存在) */ private boolean duplicate; + /** 命中的规则名称列表,未命中为空 */ + private List matchedRuleNames; + + /** 命中规则建议的分类ID,未命中或规则未指定分类时为 null */ + private Long suggestedCategoryId; + + /** 命中规则建议的分类名称 */ + private String suggestedCategoryName; + + /** 命中规则建议的分类图标 */ + private String suggestedCategoryIcon; + + /** 命中规则建议的标签列表 */ + private List suggestedTags; + /** 原始账单行的完整数据(结构化JSON,保留全部列) */ private String rawData; diff --git a/src/main/java/cn/rosercode/finance/mapper/RuleMapper.java b/src/main/java/cn/rosercode/finance/mapper/RuleMapper.java new file mode 100644 index 0000000..c77df60 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/mapper/RuleMapper.java @@ -0,0 +1,14 @@ +package cn.rosercode.finance.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import cn.rosercode.finance.entity.Rule; +import org.apache.ibatis.annotations.Mapper; + +/** + * 规则Mapper接口,继承MyBatis-Plus的BaseMapper,提供规则表的基础CRUD操作。 + * + * @date 2026-09-18 17:23:05 + */ +@Mapper +public interface RuleMapper extends BaseMapper { +} diff --git a/src/main/java/cn/rosercode/finance/mapper/RuleTagMapper.java b/src/main/java/cn/rosercode/finance/mapper/RuleTagMapper.java new file mode 100644 index 0000000..740c102 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/mapper/RuleTagMapper.java @@ -0,0 +1,14 @@ +package cn.rosercode.finance.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import cn.rosercode.finance.entity.RuleTag; +import org.apache.ibatis.annotations.Mapper; + +/** + * 规则标签关联Mapper接口,继承MyBatis-Plus的BaseMapper,提供规则标签关联表的基础CRUD操作。 + * + * @date 2026-09-18 17:23:08 + */ +@Mapper +public interface RuleTagMapper extends BaseMapper { +} diff --git a/src/main/java/cn/rosercode/finance/service/RuleMatchContext.java b/src/main/java/cn/rosercode/finance/service/RuleMatchContext.java new file mode 100644 index 0000000..385a441 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/service/RuleMatchContext.java @@ -0,0 +1,31 @@ +package cn.rosercode.finance.service; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.math.BigDecimal; + +/** + * 规则匹配上下文,承载单条交易的匹配所需信息。 + * + * @date 2026-09-18 17:25:25 + */ +@Data +@AllArgsConstructor +public class RuleMatchContext { + + /** 交易类型:INCOME-收入,EXPENSE-支出 */ + private String type; + + /** 交易金额 */ + private BigDecimal amount; + + /** 交易对方 */ + private String counterparty; + + /** 商品名称 */ + private String product; + + /** 备注 */ + private String remark; +} diff --git a/src/main/java/cn/rosercode/finance/service/RuleMatchResult.java b/src/main/java/cn/rosercode/finance/service/RuleMatchResult.java new file mode 100644 index 0000000..cf09ab5 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/service/RuleMatchResult.java @@ -0,0 +1,33 @@ +package cn.rosercode.finance.service; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 规则匹配结果,承载命中规则后建议应用的分类与标签。 + * + * @date 2026-09-18 17:24:52 + */ +@Data +public class RuleMatchResult { + + /** 命中规则建议的分类ID,未命中或规则未指定分类时为 null */ + private Long categoryId; + + /** 命中规则建议的标签ID列表(并集去重) */ + private List tagIds = new ArrayList<>(); + + /** 命中的规则名称列表,按优先级排列 */ + private List matchedRuleNames = new ArrayList<>(); + + /** + * 是否命中任意规则。 + * + * @return 命中规则名称非空时返回 true + */ + public boolean isMatched() { + return !matchedRuleNames.isEmpty(); + } +} diff --git a/src/main/java/cn/rosercode/finance/service/RuleService.java b/src/main/java/cn/rosercode/finance/service/RuleService.java new file mode 100644 index 0000000..6304e6a --- /dev/null +++ b/src/main/java/cn/rosercode/finance/service/RuleService.java @@ -0,0 +1,68 @@ +package cn.rosercode.finance.service; + +import cn.rosercode.finance.dto.RuleRequest; +import cn.rosercode.finance.vo.RuleVO; + +import java.util.List; + +/** + * 规则服务,负责自定义分类与标签规则的增删改查及匹配。 + * + * @date 2026-09-18 17:23:26 + */ +public interface RuleService { + + /** + * 查询当前用户的全部规则,按优先级和ID升序排列。 + * + * @param userId 当前用户ID + * @return 规则视图对象列表 + */ + List list(Long userId); + + /** + * 创建规则。 + * + * @param userId 当前用户ID + * @param request 规则请求参数 + */ + void create(Long userId, RuleRequest request); + + /** + * 更新规则。 + * + * @param userId 当前用户ID + * @param id 规则ID + * @param request 规则请求参数 + */ + void update(Long userId, Long id, RuleRequest 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 + * @param contexts 匹配上下文列表 + * @return 与入参一一对应的匹配结果列表 + */ + List matchBatch(Long userId, List contexts); +} diff --git a/src/main/java/cn/rosercode/finance/service/impl/BillImportServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/BillImportServiceImpl.java index 729f56b..fcc8eb4 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/BillImportServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/BillImportServiceImpl.java @@ -2,6 +2,7 @@ package cn.rosercode.finance.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import cn.rosercode.finance.common.BusinessException; +import cn.rosercode.finance.converter.VoConverter; import cn.rosercode.finance.dto.BillImportRequest; import cn.rosercode.finance.entity.Category; import cn.rosercode.finance.entity.Tag; @@ -15,8 +16,12 @@ import cn.rosercode.finance.mapper.TagMapper; import cn.rosercode.finance.mapper.TransactionMapper; import cn.rosercode.finance.mapper.TransactionTagMapper; import cn.rosercode.finance.service.BillImportService; +import cn.rosercode.finance.service.RuleMatchContext; +import cn.rosercode.finance.service.RuleMatchResult; +import cn.rosercode.finance.service.RuleService; import cn.rosercode.finance.vo.BillImportResultVO; import cn.rosercode.finance.vo.BillParseResultVO; +import cn.rosercode.finance.vo.TagVO; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -26,8 +31,10 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.EnumMap; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -47,12 +54,16 @@ public class BillImportServiceImpl implements BillImportService { private final CategoryMapper categoryMapper; private final TagMapper tagMapper; private final TransactionTagMapper transactionTagMapper; + private final RuleService ruleService; + private final VoConverter voConverter; public BillImportServiceImpl(List parsers, TransactionMapper transactionMapper, CategoryMapper categoryMapper, TagMapper tagMapper, - TransactionTagMapper transactionTagMapper) { + TransactionTagMapper transactionTagMapper, + RuleService ruleService, + VoConverter voConverter) { // 按账单类型注册解析器,便于扩展新账单来源 for (BillParser parser : parsers) { parserMap.put(parser.getBillType(), parser); @@ -61,6 +72,8 @@ public class BillImportServiceImpl implements BillImportService { this.categoryMapper = categoryMapper; this.tagMapper = tagMapper; this.transactionTagMapper = transactionTagMapper; + this.ruleService = ruleService; + this.voConverter = voConverter; } /** @@ -91,6 +104,9 @@ public class BillImportServiceImpl implements BillImportService { } } + // 按规则引擎为每行生成分类与标签建议 + applyRuleSuggestions(userId, rows); + BillParseResultVO vo = new BillParseResultVO(); vo.setBillType(billType.name()); vo.setTotal(rows.size()); @@ -131,12 +147,21 @@ public class BillImportServiceImpl implements BillImportService { .distinct() .collect(Collectors.toList())); + // 批量运行规则匹配,规则建议优先于默认分类与标签 + List ruleContexts = items.stream() + .map(item -> new RuleMatchContext(item.getType(), item.getAmount(), + item.getCounterparty(), item.getProduct(), + buildRemark(item.getCounterparty(), item.getProduct()))) + .collect(Collectors.toList()); + List ruleMatches = ruleService.matchBatch(userId, ruleContexts); + int imported = 0; int skipped = 0; int failed = 0; List messages = new ArrayList<>(); - for (BillImportRequest.Item item : items) { + for (int i = 0; i < items.size(); i++) { + BillImportRequest.Item item = items.get(i); String label = StringUtils.hasText(item.getExternalNo()) ? item.getExternalNo() : "未知交易号"; try { // 基础字段校验 @@ -163,10 +188,14 @@ public class BillImportServiceImpl implements BillImportService { } Category category = "INCOME".equals(item.getType()) ? incomeCategory : expenseCategory; + // 未选择默认分类时置空,表示未分类;规则建议分类优先于默认分类 + Long categoryId = category != null ? category.getId() : null; + if (ruleMatches.get(i).getCategoryId() != null) { + categoryId = ruleMatches.get(i).getCategoryId(); + } Transaction transaction = new Transaction(); transaction.setUserId(userId); - // 未选择默认分类时置空,表示未分类 - transaction.setCategoryId(category != null ? category.getId() : null); + transaction.setCategoryId(categoryId); transaction.setType(item.getType()); transaction.setAmount(item.getAmount()); transaction.setPaymentMethod(resolvePaymentMethod(request.getBillType())); @@ -176,8 +205,10 @@ public class BillImportServiceImpl implements BillImportService { transaction.setIncludeInStats(item.getIncludeInStats() == null ? Boolean.TRUE : item.getIncludeInStats()); transaction.setRawData(item.getRawData()); transactionMapper.insert(transaction); - // 关联默认标签 - for (Long tagId : tagIds) { + // 关联标签:规则建议标签与默认标签合并去重 + Set itemTagIds = new LinkedHashSet<>(tagIds); + itemTagIds.addAll(ruleMatches.get(i).getTagIds()); + for (Long tagId : itemTagIds) { TransactionTag relation = new TransactionTag(); relation.setTransactionId(transaction.getId()); relation.setTagId(tagId); @@ -227,6 +258,64 @@ public class BillImportServiceImpl implements BillImportService { return existing; } + /** + * 为解析出的账单行生成规则匹配建议(分类与标签)。 + * + *

批量运行规则匹配后,再批量查询建议分类与标签,避免 N+1 查询。

+ * + * @param userId 当前用户ID + * @param rows 解析出的账单行列表 + */ + private void applyRuleSuggestions(Long userId, List rows) { + if (rows.isEmpty()) { + return; + } + List contexts = rows.stream() + .map(row -> new RuleMatchContext(row.getType(), row.getAmount(), + row.getCounterparty(), row.getProduct(), + buildRemark(row.getCounterparty(), row.getProduct()))) + .collect(Collectors.toList()); + List matches = ruleService.matchBatch(userId, contexts); + + // 批量查询建议分类 + Set categoryIds = matches.stream() + .map(RuleMatchResult::getCategoryId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map categoryMap = categoryIds.isEmpty() ? Map.of() + : categoryMapper.selectBatchIds(categoryIds).stream() + .collect(Collectors.toMap(Category::getId, c -> c)); + + // 批量查询建议标签 + Set tagIds = matches.stream() + .flatMap(match -> match.getTagIds().stream()) + .collect(Collectors.toSet()); + Map tagMap = tagIds.isEmpty() ? Map.of() + : tagMapper.selectBatchIds(tagIds).stream() + .collect(Collectors.toMap(Tag::getId, t -> t)); + + for (int i = 0; i < rows.size(); i++) { + ParsedBillRow row = rows.get(i); + RuleMatchResult match = matches.get(i); + row.setMatchedRuleNames(match.getMatchedRuleNames()); + Long categoryId = match.getCategoryId(); + row.setSuggestedCategoryId(categoryId); + if (categoryId != null) { + Category category = categoryMap.get(categoryId); + if (category != null) { + row.setSuggestedCategoryName(category.getName()); + row.setSuggestedCategoryIcon(category.getIcon()); + } + } + List suggestedTags = match.getTagIds().stream() + .map(tagMap::get) + .filter(Objects::nonNull) + .map(voConverter::toTagVO) + .collect(Collectors.toList()); + row.setSuggestedTags(suggestedTags); + } + } + /** * 校验分类是否存在、归属当前用户且类型匹配 * diff --git a/src/main/java/cn/rosercode/finance/service/impl/RuleServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/RuleServiceImpl.java new file mode 100644 index 0000000..d9a21ae --- /dev/null +++ b/src/main/java/cn/rosercode/finance/service/impl/RuleServiceImpl.java @@ -0,0 +1,373 @@ +package cn.rosercode.finance.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import cn.rosercode.finance.common.BusinessException; +import cn.rosercode.finance.converter.VoConverter; +import cn.rosercode.finance.dto.RuleRequest; +import cn.rosercode.finance.entity.Category; +import cn.rosercode.finance.entity.Rule; +import cn.rosercode.finance.entity.RuleTag; +import cn.rosercode.finance.entity.Tag; +import cn.rosercode.finance.mapper.CategoryMapper; +import cn.rosercode.finance.mapper.RuleMapper; +import cn.rosercode.finance.mapper.RuleTagMapper; +import cn.rosercode.finance.mapper.TagMapper; +import cn.rosercode.finance.service.RuleMatchContext; +import cn.rosercode.finance.service.RuleMatchResult; +import cn.rosercode.finance.service.RuleService; +import cn.rosercode.finance.vo.RuleVO; +import cn.rosercode.finance.vo.TagVO; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 规则服务实现类,处理自定义分类与标签规则的增删改查与匹配业务逻辑。 + * + *

匹配时分类取优先级最高的命中规则,标签取所有命中规则的并集; + * 关键词在备注、交易对方、商品名称拼接文本中做包含匹配(忽略大小写)。

+ * + * @date 2026-09-18 17:24:59 + */ +@Service +public class RuleServiceImpl implements RuleService { + + private final RuleMapper ruleMapper; + private final RuleTagMapper ruleTagMapper; + private final CategoryMapper categoryMapper; + private final TagMapper tagMapper; + private final VoConverter voConverter; + + public RuleServiceImpl(RuleMapper ruleMapper, + RuleTagMapper ruleTagMapper, + CategoryMapper categoryMapper, + TagMapper tagMapper, + VoConverter voConverter) { + this.ruleMapper = ruleMapper; + this.ruleTagMapper = ruleTagMapper; + this.categoryMapper = categoryMapper; + this.tagMapper = tagMapper; + this.voConverter = voConverter; + } + + /** + * {@inheritDoc} + * + * 批量查询关联分类与标签,避免 N+1 查询。 + */ + @Override + public List list(Long userId) { + List rules = ruleMapper.selectList(new LambdaQueryWrapper() + .eq(Rule::getUserId, userId) + .orderByAsc(Rule::getSortOrder) + .orderByAsc(Rule::getId)); + if (rules.isEmpty()) { + return List.of(); + } + + // 批量查询关联分类 + List categoryIds = rules.stream() + .map(Rule::getCategoryId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + Map categoryMap = Map.of(); + if (!categoryIds.isEmpty()) { + categoryMap = categoryMapper.selectBatchIds(categoryIds).stream() + .collect(Collectors.toMap(Category::getId, c -> c)); + } + + Map finalCategoryMap = categoryMap; + List voList = rules.stream() + .map(rule -> voConverter.toRuleVO(rule, + rule.getCategoryId() == null ? null : finalCategoryMap.get(rule.getCategoryId()))) + .collect(Collectors.toList()); + // 批量填充规则标签 + fillTags(voList); + return voList; + } + + /** + * {@inheritDoc} + * + * 校验目标分类与标签归属,插入规则后同步标签关联。 + */ + @Override + @Transactional + public void create(Long userId, RuleRequest request) { + validate(userId, request); + Rule rule = new Rule(); + rule.setUserId(userId); + applyRequest(rule, request); + ruleMapper.insert(rule); + saveRuleTags(userId, rule.getId(), request.getTagIds()); + } + + /** + * {@inheritDoc} + * + * 校验规则归属后更新,并全量替换标签关联。 + */ + @Override + @Transactional + public void update(Long userId, Long id, RuleRequest request) { + Rule rule = ruleMapper.selectById(id); + if (rule == null || !rule.getUserId().equals(userId)) { + throw new BusinessException("规则不存在"); + } + validate(userId, request); + applyRequest(rule, request); + ruleMapper.updateById(rule); + saveRuleTags(userId, id, request.getTagIds()); + } + + /** + * {@inheritDoc} + * + * 删除规则前先清理其标签关联,避免残留脏数据。 + */ + @Override + @Transactional + public void delete(Long userId, Long id) { + Rule rule = ruleMapper.selectById(id); + if (rule == null || !rule.getUserId().equals(userId)) { + throw new BusinessException("规则不存在"); + } + ruleTagMapper.delete(new LambdaQueryWrapper() + .eq(RuleTag::getRuleId, id)); + ruleMapper.deleteById(id); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional + public void toggleEnabled(Long userId, Long id, Boolean enabled) { + Rule rule = ruleMapper.selectById(id); + if (rule == null || !rule.getUserId().equals(userId)) { + throw new BusinessException("规则不存在"); + } + rule.setEnabled(enabled == null ? Boolean.TRUE : enabled); + ruleMapper.updateById(rule); + } + + /** + * {@inheritDoc} + * + * 一次性加载启用规则及其标签关联,再对每条上下文按优先级逐条判断,命中即累计。 + */ + @Override + public List matchBatch(Long userId, List contexts) { + List results = new ArrayList<>(); + if (contexts == null || contexts.isEmpty()) { + return results; + } + for (int i = 0; i < contexts.size(); i++) { + results.add(new RuleMatchResult()); + } + + List rules = ruleMapper.selectList(new LambdaQueryWrapper() + .eq(Rule::getUserId, userId) + .eq(Rule::getEnabled, true) + .orderByAsc(Rule::getSortOrder) + .orderByAsc(Rule::getId)); + if (rules.isEmpty()) { + return results; + } + + // 预加载规则标签关联,按规则分组 + List ruleIds = rules.stream().map(Rule::getId).collect(Collectors.toList()); + Map> tagIdsByRule = new HashMap<>(); + List relations = ruleTagMapper.selectList(new LambdaQueryWrapper() + .in(RuleTag::getRuleId, ruleIds)); + for (RuleTag relation : relations) { + tagIdsByRule.computeIfAbsent(relation.getRuleId(), key -> new ArrayList<>()) + .add(relation.getTagId()); + } + + // 预加载规则引用的分类,用于校验分类类型与交易类型一致 + List categoryIds = rules.stream() + .map(Rule::getCategoryId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + Map categoryMap = categoryIds.isEmpty() ? Map.of() + : categoryMapper.selectBatchIds(categoryIds).stream() + .collect(Collectors.toMap(Category::getId, c -> c)); + + for (int i = 0; i < contexts.size(); i++) { + RuleMatchContext context = contexts.get(i); + RuleMatchResult result = results.get(i); + // 拼接匹配文本:备注 + 交易对方 + 商品名称 + String text = (safe(context.getCounterparty()) + " " + safe(context.getProduct()) + + " " + safe(context.getRemark())).toLowerCase(); + Set matchedTagIds = new LinkedHashSet<>(); + + for (Rule rule : rules) { + // 收支类型限制 + if (StringUtils.hasText(rule.getType()) && !rule.getType().equals(context.getType())) { + continue; + } + // 关键词包含匹配 + if (StringUtils.hasText(rule.getKeyword()) + && !text.contains(rule.getKeyword().toLowerCase())) { + continue; + } + // 金额区间 + if (rule.getMinAmount() != null + && (context.getAmount() == null + || context.getAmount().compareTo(rule.getMinAmount()) < 0)) { + continue; + } + if (rule.getMaxAmount() != null + && (context.getAmount() == null + || context.getAmount().compareTo(rule.getMaxAmount()) > 0)) { + continue; + } + + result.getMatchedRuleNames().add(rule.getName()); + // 分类取首个命中且类型匹配的规则 + if (result.getCategoryId() == null && rule.getCategoryId() != null) { + Category category = categoryMap.get(rule.getCategoryId()); + if (category != null + && (context.getType() == null || context.getType().equals(category.getType()))) { + result.setCategoryId(rule.getCategoryId()); + } + } + matchedTagIds.addAll(tagIdsByRule.getOrDefault(rule.getId(), List.of())); + } + result.setTagIds(new ArrayList<>(matchedTagIds)); + } + return results; + } + + /** + * 将请求字段写入规则实体,统一处理默认值。 + * + * @param rule 目标规则实体 + * @param request 规则请求参数 + */ + private void applyRequest(Rule rule, RuleRequest request) { + rule.setName(request.getName()); + rule.setEnabled(request.getEnabled() == null ? Boolean.TRUE : request.getEnabled()); + rule.setSortOrder(request.getSortOrder() != null ? request.getSortOrder() : 0); + rule.setType(StringUtils.hasText(request.getType()) ? request.getType() : null); + rule.setKeyword(StringUtils.hasText(request.getKeyword()) ? request.getKeyword().trim() : null); + rule.setMinAmount(request.getMinAmount()); + rule.setMaxAmount(request.getMaxAmount()); + rule.setCategoryId(request.getCategoryId()); + } + + /** + * 校验请求中的分类、标签归属与金额区间合法性。 + * + * @param userId 当前用户ID + * @param request 规则请求参数 + */ + private void validate(Long userId, RuleRequest request) { + // 收支类型白名单 + if (StringUtils.hasText(request.getType()) + && !"INCOME".equals(request.getType()) && !"EXPENSE".equals(request.getType())) { + throw new BusinessException("收支类型无效"); + } + // 金额区间 + if (request.getMinAmount() != null && request.getMaxAmount() != null + && request.getMinAmount().compareTo(request.getMaxAmount()) > 0) { + throw new BusinessException("金额下限不能大于上限"); + } + // 目标分类归属与类型一致性 + if (request.getCategoryId() != null) { + Category category = categoryMapper.selectById(request.getCategoryId()); + if (category == null || !category.getUserId().equals(userId)) { + throw new BusinessException("分类不存在"); + } + if (StringUtils.hasText(request.getType()) && !request.getType().equals(category.getType())) { + throw new BusinessException("规则分类与收支类型不匹配"); + } + } + } + + /** + * 全量替换规则的标签关联。 + * + * @param userId 当前用户ID + * @param ruleId 规则ID + * @param tagIds 目标标签ID列表,为空表示清空 + */ + private void saveRuleTags(Long userId, Long ruleId, List tagIds) { + ruleTagMapper.delete(new LambdaQueryWrapper() + .eq(RuleTag::getRuleId, ruleId)); + for (Long tagId : validTagIds(userId, tagIds)) { + RuleTag relation = new RuleTag(); + relation.setRuleId(ruleId); + relation.setTagId(tagId); + ruleTagMapper.insert(relation); + } + } + + /** + * 过滤出属于当前用户的标签ID。 + * + * @param userId 当前用户ID + * @param tagIds 待校验的标签ID列表 + * @return 有效的标签ID列表 + */ + private List validTagIds(Long userId, List tagIds) { + if (tagIds == null || tagIds.isEmpty()) { + return List.of(); + } + return tagMapper.selectBatchIds(tagIds).stream() + .filter(tag -> tag.getUserId().equals(userId)) + .map(Tag::getId) + .collect(Collectors.toList()); + } + + /** + * 批量填充规则视图对象的标签列表。 + * + * @param voList 规则视图对象列表 + */ + private void fillTags(List voList) { + List ruleIds = voList.stream().map(RuleVO::getId).collect(Collectors.toList()); + List relations = ruleTagMapper.selectList(new LambdaQueryWrapper() + .in(RuleTag::getRuleId, ruleIds)); + if (relations.isEmpty()) { + return; + } + List tagIds = relations.stream().map(RuleTag::getTagId).distinct().collect(Collectors.toList()); + Map tagMap = tagMapper.selectBatchIds(tagIds).stream() + .collect(Collectors.toMap(Tag::getId, tag -> tag)); + Map> tagsByRule = new HashMap<>(); + for (RuleTag relation : relations) { + Tag tag = tagMap.get(relation.getTagId()); + if (tag == null) { + continue; + } + tagsByRule.computeIfAbsent(relation.getRuleId(), key -> new ArrayList<>()) + .add(voConverter.toTagVO(tag)); + } + for (RuleVO vo : voList) { + vo.setTags(tagsByRule.getOrDefault(vo.getId(), List.of())); + } + } + + /** + * 空值安全的字符串处理。 + * + * @param value 原字符串 + * @return 非空字符串,null 时返回空串 + */ + private String safe(String value) { + return value == null ? "" : value; + } +} diff --git a/src/main/java/cn/rosercode/finance/service/impl/TagServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/TagServiceImpl.java index 41480a2..50b56e1 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/TagServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/TagServiceImpl.java @@ -4,8 +4,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import cn.rosercode.finance.common.BusinessException; import cn.rosercode.finance.converter.VoConverter; import cn.rosercode.finance.dto.TagRequest; +import cn.rosercode.finance.entity.RuleTag; import cn.rosercode.finance.entity.Tag; import cn.rosercode.finance.entity.TransactionTag; +import cn.rosercode.finance.mapper.RuleTagMapper; import cn.rosercode.finance.mapper.TagMapper; import cn.rosercode.finance.mapper.TransactionTagMapper; import cn.rosercode.finance.service.TagService; @@ -26,11 +28,14 @@ public class TagServiceImpl implements TagService { private final TagMapper tagMapper; private final TransactionTagMapper transactionTagMapper; + private final RuleTagMapper ruleTagMapper; private final VoConverter voConverter; - public TagServiceImpl(TagMapper tagMapper, TransactionTagMapper transactionTagMapper, VoConverter voConverter) { + public TagServiceImpl(TagMapper tagMapper, TransactionTagMapper transactionTagMapper, + RuleTagMapper ruleTagMapper, VoConverter voConverter) { this.tagMapper = tagMapper; this.transactionTagMapper = transactionTagMapper; + this.ruleTagMapper = ruleTagMapper; this.voConverter = voConverter; } @@ -101,7 +106,7 @@ public class TagServiceImpl implements TagService { /** * {@inheritDoc} * - * 删除标签前先清理其与交易记录的关联,避免残留脏数据。 + * 删除标签前先清理其与交易记录、规则的关联,避免残留脏数据。 */ @Override @Transactional @@ -112,6 +117,8 @@ public class TagServiceImpl implements TagService { } transactionTagMapper.delete(new LambdaQueryWrapper() .eq(TransactionTag::getTagId, id)); + ruleTagMapper.delete(new LambdaQueryWrapper() + .eq(RuleTag::getTagId, id)); tagMapper.deleteById(id); } diff --git a/src/main/java/cn/rosercode/finance/vo/RuleVO.java b/src/main/java/cn/rosercode/finance/vo/RuleVO.java new file mode 100644 index 0000000..518bc08 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/vo/RuleVO.java @@ -0,0 +1,60 @@ +package cn.rosercode.finance.vo; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 规则视图对象,用于向前端返回自定义分类与标签规则信息。 + * + *

目标分类名称/图标取自关联分类,标签列表由调用方另行填充。

+ * + * @date 2026-09-18 17:23:15 + */ +@Data +public class RuleVO { + + /** 规则ID */ + private Long id; + + /** 规则名称 */ + private String name; + + /** 是否启用 */ + private Boolean enabled; + + /** 优先级,数值越小越优先 */ + private Integer sortOrder; + + /** 限定收支类型:INCOME/EXPENSE,为空表示不限 */ + private String type; + + /** 关键词 */ + private String keyword; + + /** 金额下限(含) */ + private BigDecimal minAmount; + + /** 金额上限(含) */ + private BigDecimal maxAmount; + + /** 目标分类ID */ + private Long categoryId; + + /** 目标分类名称 */ + private String categoryName; + + /** 目标分类图标 */ + private String categoryIcon; + + /** 目标标签列表 */ + private List tags; + + /** 创建时间 */ + private LocalDateTime createdAt; + + /** 更新时间 */ + private LocalDateTime updatedAt; +} diff --git a/src/main/resources/sql/init.sql b/src/main/resources/sql/init.sql index e419218..1497d94 100644 --- a/src/main/resources/sql/init.sql +++ b/src/main/resources/sql/init.sql @@ -166,3 +166,30 @@ CREATE TABLE IF NOT EXISTS t_login_log ( INDEX idx_login_log_created (created_at) COMMENT '创建时间索引', INDEX idx_login_log_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引' ); + +-- 自定义分类与标签规则表:按关键词、收支类型、金额区间自动归类与打标签 +CREATE TABLE IF NOT EXISTS t_rule ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '规则ID,主键自增', + 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 '优先级,数值越小越优先', + type VARCHAR(10) COMMENT '限定收支类型:INCOME-收入,EXPENSE-支出,为空表示不限', + keyword VARCHAR(100) COMMENT '关键词,匹配备注/交易对方/商品名称(包含匹配,忽略大小写)', + 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,主键自增', + 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索引' +); diff --git a/src/main/resources/sql/schema-sqlite.sql b/src/main/resources/sql/schema-sqlite.sql index f3393b0..734c23e 100644 --- a/src/main/resources/sql/schema-sqlite.sql +++ b/src/main/resources/sql/schema-sqlite.sql @@ -204,3 +204,36 @@ CREATE TABLE IF NOT EXISTS t_login_log ( );; 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, + user_id INTEGER NOT NULL, + name VARCHAR(50) NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + type VARCHAR(10), + 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')) +);; +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, + 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')) +);; +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);; + +CREATE TRIGGER IF NOT EXISTS trg_rule_updated_at +AFTER UPDATE ON t_rule +FOR EACH ROW +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;;