feat: 支持自定义分类与标签规则并优化 AI 查询交互

- 新增 t_rule、t_rule_tag 表(MySQL/SQLite 双脚本)与规则引擎,支持关键词(备注/交易对方/商品名称)、收支类型、金额区间组合匹配
- 新增 /api/rule 增删改查与启用停用接口,侧边栏新增「规则管理」页,支持排序与启停
- 账单导入解析阶段批量匹配,返回命中规则与建议分类/标签,预览表格新增「匹配规则/建议」列
- 确认导入时按行重新匹配:规则建议分类优先于面板默认分类,规则标签与默认标签合并去重
- 删除标签时同步清理规则标签关联
- AI 自然语言查询解析后直接应用筛选并搜索,移除二次确认弹窗,成功提示展示识别意图
- 修正登录日志筛选 success 参数类型为 'true'/'false'
This commit is contained in:
2026-09-18 17:42:59 +08:00
parent e82e0a4ef9
commit c69e76ed4c
26 changed files with 1622 additions and 78 deletions
+65
View File
@@ -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 })
}
+5
View File
@@ -85,6 +85,11 @@ export interface ParsedBillRow {
importable: boolean importable: boolean
selected: boolean selected: boolean
duplicate: 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 rawData: string | null
error: string | null error: string | null
} }
+31
View File
@@ -82,6 +82,7 @@ export default {
transactions: { zh: '收支记录', en: 'Transactions', ja: '収支記録' }, transactions: { zh: '收支记录', en: 'Transactions', ja: '収支記録' },
categories: { zh: '分类管理', en: 'Categories', ja: 'カテゴリ管理' }, categories: { zh: '分类管理', en: 'Categories', ja: 'カテゴリ管理' },
tags: { zh: '标签管理', en: 'Tags', ja: 'タグ管理' }, tags: { zh: '标签管理', en: 'Tags', ja: 'タグ管理' },
rules: { zh: '规则管理', en: 'Rules', ja: 'ルール管理' },
logs: { zh: '系统日志', en: 'Logs', ja: 'システムログ' }, logs: { zh: '系统日志', en: 'Logs', ja: 'システムログ' },
settings: { zh: '系统设置', en: 'Settings', ja: 'システム設定' }, settings: { zh: '系统设置', en: 'Settings', ja: 'システム設定' },
user: { zh: '用户', en: 'User', 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: '削除するとすべての取引記録からこのタグが外れます。削除してもよろしいですか?' }, 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: { import: {
title: { zh: '导入账单', en: 'Import Bill', ja: '明細をインポート' }, title: { zh: '导入账单', en: 'Import Bill', ja: '明細をインポート' },
@@ -478,6 +508,7 @@ export default {
counterparty: { zh: '交易对方', en: 'Counterparty', ja: '取引相手' }, counterparty: { zh: '交易对方', en: 'Counterparty', ja: '取引相手' },
product: { zh: '商品名称', en: 'Product', ja: '商品名' }, product: { zh: '商品名称', en: 'Product', ja: '商品名' },
status: { zh: '状态', en: 'Status', ja: 'ステータス' }, status: { zh: '状态', en: 'Status', ja: 'ステータス' },
matchedRule: { zh: '匹配规则/建议', en: 'Matched Rule', ja: '一致ルール' },
}, },
errorFileType: { zh: '账单类型「{label}」仅支持 {exts} 格式的文件', en: 'Bill type "{label}" only supports files in {exts} format', ja: '明細の種類「{label}」は {exts} 形式のファイルのみ対応しています' }, errorFileType: { zh: '账单类型「{label}」仅支持 {exts} 格式的文件', en: 'Bill type "{label}" only supports files in {exts} format', ja: '明細の種類「{label}」は {exts} 形式のファイルのみ対応しています' },
selectFileFirst: { zh: '请先选择账单文件', en: 'Please select a bill file first', ja: '先に明細ファイルを選択してください' }, selectFileFirst: { zh: '请先选择账单文件', en: 'Please select a bill file first', ja: '先に明細ファイルを選択してください' },
+5
View File
@@ -53,6 +53,11 @@ const router = createRouter({
name: 'Tags', name: 'Tags',
component: () => import('../views/TagManage.vue'), // 标签管理 component: () => import('../views/TagManage.vue'), // 标签管理
}, },
{
path: 'rules',
name: 'Rules',
component: () => import('../views/RuleManage.vue'), // 规则管理(自定义分类与标签规则)
},
{ {
path: 'settings', path: 'settings',
name: 'Settings', name: 'Settings',
+33
View File
@@ -220,6 +220,39 @@ const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
return h('span', null, row.tradeStatus || '-') 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)
},
},
]) ])
/** 加载分类选项 */ /** 加载分类选项 */
+3 -1
View File
@@ -5,7 +5,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown } from 'naive-ui' import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown } from 'naive-ui'
import type { MenuOption } 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 { useUserStore } from '../store/user'
import LocaleSwitch from '../components/LocaleSwitch.vue' import LocaleSwitch from '../components/LocaleSwitch.vue'
@@ -21,6 +21,7 @@ const activeKey = computed(() => {
'/transactions': 'transactions', '/transactions': 'transactions',
'/categories': 'categories', '/categories': 'categories',
'/tags': 'tags', '/tags': 'tags',
'/rules': 'rules',
} }
return map[route.path] || 'dashboard' return map[route.path] || 'dashboard'
}) })
@@ -36,6 +37,7 @@ const menuOptions = computed<MenuOption[]>(() => [
{ label: t('layout.transactions'), key: 'transactions', icon: renderIcon(ListOutline) }, { label: t('layout.transactions'), key: 'transactions', icon: renderIcon(ListOutline) },
{ label: t('layout.categories'), key: 'categories', icon: renderIcon(FolderOpenOutline) }, { label: t('layout.categories'), key: 'categories', icon: renderIcon(FolderOpenOutline) },
{ label: t('layout.tags'), key: 'tags', icon: renderIcon(PricetagsOutline) }, { label: t('layout.tags'), key: 'tags', icon: renderIcon(PricetagsOutline) },
{ label: t('layout.rules'), key: 'rules', icon: renderIcon(OptionsOutline) },
]) ])
/** 菜单点击事件:根据 key 导航到对应路由 */ /** 菜单点击事件:根据 key 导航到对应路由 */
+4 -4
View File
@@ -53,7 +53,7 @@ const loginPage = ref(1)
const loginSize = ref(20) const loginSize = ref(20)
const loginTotal = ref(0) const loginTotal = ref(0)
const loginKeyword = ref('') const loginKeyword = ref('')
const loginSuccess = ref<boolean | null>(null) const loginSuccess = ref<'true' | 'false' | null>(null)
// ---------------- 运行日志 ---------------- // ---------------- 运行日志 ----------------
const appData = ref<AppLog | null>(null) const appData = ref<AppLog | null>(null)
@@ -80,8 +80,8 @@ const statusOptions = [200, 201, 400, 401, 403, 404, 500].map((value) => ({ labe
/** 登录结果下拉选项 */ /** 登录结果下拉选项 */
const loginResultOptions = computed(() => [ const loginResultOptions = computed(() => [
{ label: t('log.success'), value: true }, { label: t('log.success'), value: 'true' },
{ label: t('log.failed'), value: false }, { label: t('log.failed'), value: 'false' },
]) ])
/** 日志级别下拉选项 */ /** 日志级别下拉选项 */
@@ -221,7 +221,7 @@ async function loadLogin() {
page: loginPage.value, page: loginPage.value,
size: loginSize.value, size: loginSize.value,
keyword: loginKeyword.value.trim() || undefined, keyword: loginKeyword.value.trim() || undefined,
success: loginSuccess.value ?? undefined, success: loginSuccess.value === null ? undefined : loginSuccess.value === 'true',
}) })
loginRecords.value = res.data.records || [] loginRecords.value = res.data.records || []
loginTotal.value = res.data.total || 0 loginTotal.value = res.data.total || 0
+407
View File
@@ -0,0 +1,407 @@
<script setup lang="ts">
// 规则管理组件:展示自定义分类与标签规则,支持新增、编辑、删除、启用停用与排序
import { ref, onMounted, computed, h, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem,
NInput, NInputNumber, NSelect, NSwitch, NTag,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { getRuleList, createRule, updateRule, deleteRule, toggleRuleEnabled } from '../api/rule'
import type { RuleItem } from '../api/rule'
import { getCategoryList } from '../api/category'
import { getTagList } from '../api/tag'
const message = useMessage() // 消息提示
const dialog = useDialog() // 对话框
const { t } = useI18n() // 国际化
/** 分类选项 */
interface CategoryOption {
label: string
value: number
icon: string
type: string
}
/** 标签选项 */
interface TagOption {
label: string
value: number
color: string
}
// 列表和弹窗状态
const rules = ref<RuleItem[]>([])
const loading = ref(false)
const showModal = ref(false)
const editingId = ref<number | null>(null)
const submitting = ref(false)
const categories = ref<CategoryOption[]>([])
const tags = ref<TagOption[]>([])
// 表单数据
const form = ref<{
name: string
enabled: boolean
sortOrder: number
type: string | null
keyword: string
minAmount: number | null
maxAmount: number | null
categoryId: number | null
tagIds: number[]
}>({
name: '',
enabled: true,
sortOrder: 0,
type: null,
keyword: '',
minAmount: null,
maxAmount: null,
categoryId: null,
tagIds: [],
})
// 收支类型选项(含「不限」)
const typeOptions = computed(() => [
{ label: t('rule.typeAny'), value: '' },
{ label: t('common.types.expense'), value: 'EXPENSE' },
{ label: t('common.types.income'), value: 'INCOME' },
])
// 分类选项:按当前收支类型过滤
const categoryOptions = computed(() => {
const list = form.value.type ? categories.value.filter((c) => c.type === form.value.type) : categories.value
return list.map((c) => ({ label: c.label, value: c.value, icon: c.icon }))
})
// 标签选项
const tagOptions = computed(() => tags.value.map((tag) => ({ label: tag.label, value: tag.value })))
/** 分类下拉项渲染:图标 + 名称 */
function renderCategoryLabel(option: any) {
return h('span', null, [
option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null,
option.label,
])
}
/** 构造条件摘要标签,便于列表快速识别规则 */
function conditionTags(row: RuleItem) {
const result: any[] = []
if (row.type) {
result.push(h(NTag, { size: 'small', type: row.type === 'INCOME' ? 'success' : 'error' }, {
default: () => (row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')),
}))
}
if (row.keyword) {
result.push(h(NTag, { size: 'small' }, { default: () => t('rule.summaryKeyword', { kw: row.keyword }) }))
}
if (row.minAmount != null && row.maxAmount != null) {
result.push(h(NTag, { size: 'small' }, {
default: () => t('rule.summaryAmountRange', { min: row.minAmount, max: row.maxAmount }),
}))
} else if (row.minAmount != null) {
result.push(h(NTag, { size: 'small' }, { default: () => t('rule.summaryAmountMin', { min: row.minAmount }) }))
} else if (row.maxAmount != null) {
result.push(h(NTag, { size: 'small' }, { default: () => t('rule.summaryAmountMax', { max: row.maxAmount }) }))
}
if (result.length === 0) {
result.push(h(NTag, { size: 'small', type: 'default' }, { default: () => t('rule.summaryNone') }))
}
return result
}
/** 创建表格列配置 */
const columns = computed<DataTableColumns<RuleItem>>(() => [
{
title: t('common.name'),
key: 'name',
width: 150,
render: (row) => h('span', { style: { fontWeight: '500' } }, row.name),
},
{
title: t('rule.condition'),
key: 'condition',
render: (row) => h(NSpace, { size: 4 }, { default: () => conditionTags(row) }),
},
{
title: t('rule.targetCategory'),
key: 'category',
width: 140,
render: (row) => {
if (!row.categoryId) {
return h('span', { style: { color: '#ccc' } }, '—')
}
return h('span', null, [
row.categoryIcon ? h('span', { style: { marginRight: '4px' } }, row.categoryIcon) : null,
row.categoryName || '',
])
},
},
{
title: t('rule.targetTags'),
key: 'tags',
width: 160,
render: (row) => {
const list = row.tags || []
if (list.length === 0) {
return h('span', { style: { color: '#ccc' } }, '—')
}
return h(NSpace, { size: 4 }, {
default: () => list.map((tag) => h(NTag, {
size: 'small',
color: tag.color ? { color: tag.color, textColor: '#fff' } : undefined,
}, { default: () => tag.name })),
})
},
},
{
title: t('rule.enabled'),
key: 'enabled',
width: 80,
render: (row) => h(NSwitch, {
value: row.enabled,
size: 'small',
onUpdateValue: (v: boolean) => { handleToggleEnabled(row, v) },
}),
},
{ title: t('common.sort'), key: 'sortOrder', width: 70 },
{
title: t('common.operation'),
key: 'actions',
width: 150,
render: (row) => h(NSpace, null, {
default: () => [
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }),
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => t('common.delete') }),
],
}),
},
])
/** 加载规则列表数据 */
async function fetchData() {
loading.value = true
try {
const res: any = await getRuleList()
rules.value = res.data || []
} catch (e: any) {
message.error(e.message || t('common.loadFailed'))
} finally {
loading.value = false
}
}
/** 加载分类选项 */
async function loadCategories() {
try {
const res: any = await getCategoryList()
categories.value = (res.data || []).map((c: any) => ({
label: c.name,
value: c.id,
icon: c.icon || '',
type: c.type,
}))
} catch (e: any) {
message.error(e.message || t('rule.loadCategoriesFailed'))
}
}
/** 加载标签选项 */
async function loadTags() {
try {
const res: any = await getTagList()
tags.value = (res.data || []).map((tag: any) => ({
label: tag.name,
value: tag.id,
color: tag.color || '',
}))
} catch (e: any) {
message.error(e.message || t('rule.loadTagsFailed'))
}
}
/** 打开新增规则弹窗,重置表单 */
function handleAdd() {
editingId.value = null
form.value = {
name: '',
enabled: true,
sortOrder: 0,
type: null,
keyword: '',
minAmount: null,
maxAmount: null,
categoryId: null,
tagIds: [],
}
showModal.value = true
}
/** 打开编辑规则弹窗,填充表单数据 */
function handleEdit(row: RuleItem) {
editingId.value = row.id
form.value = {
name: row.name,
enabled: row.enabled,
sortOrder: row.sortOrder,
type: row.type,
keyword: row.keyword || '',
minAmount: row.minAmount,
maxAmount: row.maxAmount,
categoryId: row.categoryId,
tagIds: (row.tags || []).map((tag) => tag.id),
}
showModal.value = true
}
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
async function handleSubmit() {
if (!form.value.name.trim()) {
message.warning(t('rule.nameRequired'))
return
}
const payload = {
name: form.value.name.trim(),
enabled: form.value.enabled,
sortOrder: form.value.sortOrder,
type: form.value.type || null,
keyword: form.value.keyword.trim() || null,
minAmount: form.value.minAmount,
maxAmount: form.value.maxAmount,
categoryId: form.value.categoryId,
tagIds: form.value.tagIds,
}
submitting.value = true
try {
if (editingId.value) {
await updateRule(editingId.value, payload)
message.success(t('common.updateSuccess'))
} else {
await createRule(payload)
message.success(t('common.createSuccess'))
}
showModal.value = false
await fetchData()
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
submitting.value = false
}
}
/** 启用/停用规则 */
async function handleToggleEnabled(row: RuleItem, enabled: boolean) {
try {
await toggleRuleEnabled(row.id, enabled)
row.enabled = enabled
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
await fetchData()
}
}
/** 删除规则:弹出确认对话框,确认后执行删除并刷新列表 */
function handleDelete(id: number) {
dialog.warning({
title: t('common.confirmDelete'),
content: t('rule.deleteConfirm'),
positiveText: t('common.delete'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
try {
await deleteRule(id)
message.success(t('common.deleteSuccess'))
await fetchData()
} catch (e: any) {
message.error(e.message || t('common.deleteFailed'))
}
},
})
}
// 收支类型变化时,若已选分类类型不匹配则清空,避免保存非法组合
watch(() => form.value.type, (type) => {
if (form.value.categoryId && type) {
const category = categories.value.find((c) => c.value === form.value.categoryId)
if (category && category.type !== type) {
form.value.categoryId = null
}
}
})
// 组件挂载时加载规则、分类与标签
onMounted(() => {
fetchData()
loadCategories()
loadTags()
})
</script>
<template>
<div>
<!-- 顶部:标题和新增按钮 -->
<NSpace justify="space-between" style="margin-bottom: 16px;">
<h2 style="margin: 0;">{{ t('rule.title') }}</h2>
<NButton type="primary" @click="handleAdd">{{ t('rule.add') }}</NButton>
</NSpace>
<NDataTable :columns="columns" :data="rules" :loading="loading" :bordered="false" />
<!-- 新增/编辑弹窗 -->
<NModal v-model:show="showModal" preset="card" :title="editingId ? t('rule.editTitle') : t('rule.addTitle')" style="width: 520px;">
<NForm>
<NFormItem :label="t('common.name')">
<NInput v-model:value="form.name" :placeholder="t('rule.namePlaceholder')" />
</NFormItem>
<NFormItem :label="t('common.type')">
<NSelect
:value="form.type || ''"
:options="typeOptions"
@update:value="(v: string) => (form.type = v || null)"
/>
</NFormItem>
<NFormItem :label="t('rule.keyword')">
<NInput v-model:value="form.keyword" :placeholder="t('rule.keywordPlaceholder')" />
</NFormItem>
<NFormItem :label="t('rule.amountRange')">
<NSpace align="center">
<NInputNumber v-model:value="form.minAmount" :min="0" :precision="2" :placeholder="t('common.min')" style="width: 150px;" />
<span style="color: #999;">~</span>
<NInputNumber v-model:value="form.maxAmount" :min="0" :precision="2" :placeholder="t('common.max')" style="width: 150px;" />
</NSpace>
</NFormItem>
<NFormItem :label="t('rule.targetCategory')">
<NSelect
v-model:value="form.categoryId"
:options="categoryOptions"
:render-label="renderCategoryLabel"
filterable
clearable
:placeholder="t('rule.notSetCategory')"
/>
</NFormItem>
<NFormItem :label="t('rule.targetTags')">
<NSelect
v-model:value="form.tagIds"
:options="tagOptions"
multiple
clearable
filterable
:max-tag-count="3"
:placeholder="t('rule.notSetTag')"
/>
</NFormItem>
<NFormItem :label="t('rule.enabled')">
<NSwitch v-model:value="form.enabled" />
</NFormItem>
<NFormItem :label="t('common.sort')">
<NInputNumber v-model:value="form.sortOrder" :min="0" />
</NFormItem>
<NButton type="primary" block :loading="submitting" @click="handleSubmit">{{ t('common.confirm') }}</NButton>
</NForm>
</NModal>
</div>
</template>
+10 -65
View File
@@ -2,7 +2,7 @@
// 收支记录管理组件:展示交易记录列表(分页),支持新增、编辑、删除、多条件筛选 // 收支记录管理组件:展示交易记录列表(分页),支持新增、编辑、删除、多条件筛选
import { ref, onMounted, computed, h, watch } from 'vue' import { ref, onMounted, computed, h, watch } from 'vue'
import { useI18n } from 'vue-i18n' 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 { EyeOutline, EyeOffOutline, CloseCircleOutline, SparklesOutline, BulbOutline } from '@vicons/ionicons5'
import type { DataTableColumns, AutoCompleteOption } from 'naive-ui' import type { DataTableColumns, AutoCompleteOption } from 'naive-ui'
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags } from '../api/transaction' import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags } from '../api/transaction'
@@ -126,7 +126,6 @@ const aiSuggestions = ref<AiSuggestion[]>([]) // AI 建议列表
const checkedAiRowKeys = ref<number[]>([]) // AI 建议弹窗中已勾选的代表记录ID const checkedAiRowKeys = ref<number[]>([]) // AI 建议弹窗中已勾选的代表记录ID
const aiQueryText = ref('') // AI 自然语言查询输入 const aiQueryText = ref('') // AI 自然语言查询输入
const aiQuerying = ref(false) // AI 查询解析状态 const aiQuerying = ref(false) // AI 查询解析状态
const showAiQueryModal = ref(false) // AI 查询结果弹窗显示状态
const aiQueryResult = ref<AiQueryResult | null>(null) // AI 查询解析结果 const aiQueryResult = ref<AiQueryResult | null>(null) // AI 查询解析结果
// AI 查询历史存储键与上限 // AI 查询历史存储键与上限
const AI_QUERY_HISTORY_KEY = 'finance.transaction.aiQueryHistory' const AI_QUERY_HISTORY_KEY = 'finance.transaction.aiQueryHistory'
@@ -1115,7 +1114,13 @@ async function handleAiQuery() {
try { try {
const res: any = await queryByAi(text) const res: any = await queryByAi(text)
aiQueryResult.value = res.data aiQueryResult.value = res.data
showAiQueryModal.value = true // 无任何可用筛选条件时提示,避免误清空当前筛选
if (aiQueryEmpty.value) {
message.warning(t('ai.query.noCondition'))
return
}
// 直接应用解析结果并搜索,无需二次确认
applyAiQuery()
} catch (e: any) { } catch (e: any) {
message.error(e.message || t('ai.query.failed')) message.error(e.message || t('ai.query.failed'))
} finally { } finally {
@@ -1140,9 +1145,9 @@ function applyAiQuery() {
filterMaxAmount.value = r.maxAmount ?? null filterMaxAmount.value = r.maxAmount ?? null
filterRemark.value = r.remark || '' filterRemark.value = r.remark || ''
filterTag.value = r.tagIds || [] filterTag.value = r.tagIds || []
showAiQueryModal.value = false
handleSearch() 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 && r.includeInStats == null
}) })
/** 格式化解析结果中的时间用于展示 */
function formatAiDate(value: string | null | undefined) {
return value ? value.replace('T', ' ').substring(0, 16) : ''
}
/** AI 查询示例(从 i18n 拆分为数组) */ /** AI 查询示例(从 i18n 拆分为数组) */
const queryExamples = computed(() => t('ai.query.examples').split('|')) const queryExamples = computed(() => t('ai.query.examples').split('|'))
@@ -1923,61 +1923,6 @@ onMounted(() => {
</NSpace> </NSpace>
</NModal> </NModal>
<!-- AI 自然语言查询解析结果弹窗 -->
<NModal v-if="aiEnabled" v-model:show="showAiQueryModal" preset="card" :title="t('ai.query.resultTitle')" style="width: 560px; max-width: 92vw;">
<NSpace vertical :size="12">
<NAlert v-if="aiQueryResult?.summary" type="info" :show-icon="true">{{ aiQueryResult.summary }}</NAlert>
<NText v-if="aiQueryEmpty" depth="3">{{ t('ai.query.noCondition') }}</NText>
<NDescriptions v-else :column="1" label-placement="left" bordered size="small">
<NDescriptionsItem v-if="aiQueryResult?.types?.length" :label="t('ai.query.type')">
<NSpace :size="6">
<NTag v-for="tp in aiQueryResult.types" :key="tp" size="small" :type="tp === 'INCOME' ? 'success' : 'error'">
{{ tp === 'INCOME' ? t('common.types.income') : t('common.types.expense') }}
</NTag>
</NSpace>
</NDescriptionsItem>
<NDescriptionsItem v-if="aiQueryResult?.categoryNames?.length || aiQueryResult?.uncategorized" :label="t('ai.query.category')">
<NSpace :size="6">
<NTag v-for="c in aiQueryResult?.categoryNames || []" :key="c" size="small">{{ c }}</NTag>
<NTag v-if="aiQueryResult?.uncategorized" size="small">{{ t('transaction.uncategorized') }}</NTag>
</NSpace>
</NDescriptionsItem>
<NDescriptionsItem v-if="aiQueryResult?.tagNames?.length" :label="t('ai.query.tag')">
<NSpace :size="6">
<NTag v-for="tg in aiQueryResult.tagNames" :key="tg" size="small">{{ tg }}</NTag>
</NSpace>
</NDescriptionsItem>
<NDescriptionsItem v-if="aiQueryResult?.paymentMethods?.length" :label="t('ai.query.payment')">
<NSpace :size="6">
<NTag v-for="pm in aiQueryResult.paymentMethods" :key="pm" size="small">{{ getPaymentLabel(pm) }}</NTag>
</NSpace>
</NDescriptionsItem>
<NDescriptionsItem v-if="aiQueryResult?.startTime && aiQueryResult?.endTime" :label="t('ai.query.dateRange')">
{{ formatAiDate(aiQueryResult.startTime) }} ~ {{ formatAiDate(aiQueryResult.endTime) }}
</NDescriptionsItem>
<NDescriptionsItem
v-if="aiQueryResult && (aiQueryResult.minAmount != null || aiQueryResult.maxAmount != null)"
:label="t('ai.query.amountRange')"
>
{{ aiQueryResult.minAmount ?? 0 }} ~ {{ aiQueryResult.maxAmount ?? '∞' }}
</NDescriptionsItem>
<NDescriptionsItem v-if="aiQueryResult?.remark" :label="t('ai.query.remark')">{{ aiQueryResult.remark }}</NDescriptionsItem>
<NDescriptionsItem v-if="aiQueryResult && aiQueryResult.includeInStats != null" :label="t('ai.query.includeInStats')">
{{ aiQueryResult.includeInStats ? t('ai.query.includeYes') : t('ai.query.includeNo') }}
</NDescriptionsItem>
<NDescriptionsItem v-if="aiQueryResult?.unmatched?.length" :label="t('ai.query.unmatched')">
<NSpace :size="6">
<NTag v-for="u in aiQueryResult.unmatched" :key="u" size="small" type="warning">{{ u }}</NTag>
</NSpace>
</NDescriptionsItem>
</NDescriptions>
<NSpace justify="end">
<NButton @click="showAiQueryModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :disabled="aiQueryEmpty" @click="applyAiQuery">{{ t('ai.query.apply') }}</NButton>
</NSpace>
</NSpace>
</NModal>
<!-- AI 智能记账弹窗 --> <!-- AI 智能记账弹窗 -->
<NModal v-if="aiEnabled" v-model:show="showAiRecordModal" preset="card" :title="t('ai.record.title')" style="width: 520px; max-width: 92vw;"> <NModal v-if="aiEnabled" v-model:show="showAiRecordModal" preset="card" :title="t('ai.record.title')" style="width: 520px; max-width: 92vw;">
<NSpace vertical :size="12"> <NSpace vertical :size="12">
@@ -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;
/**
* 规则控制器
*
* <p>提供自定义分类与标签规则的增删改查、启用停用接口</p>
*
* @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<RuleVO>> 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<Void> 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<Void> 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<Void> 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<Void> 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;
}
}
@@ -6,6 +6,7 @@ import cn.rosercode.finance.entity.HttpLog;
import cn.rosercode.finance.entity.LoginLog; import cn.rosercode.finance.entity.LoginLog;
import cn.rosercode.finance.entity.NotificationChannel; import cn.rosercode.finance.entity.NotificationChannel;
import cn.rosercode.finance.entity.NotificationLog; import cn.rosercode.finance.entity.NotificationLog;
import cn.rosercode.finance.entity.Rule;
import cn.rosercode.finance.entity.Tag; import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.entity.Transaction; import cn.rosercode.finance.entity.Transaction;
import cn.rosercode.finance.entity.User; 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.LoginLogVO;
import cn.rosercode.finance.vo.NotificationChannelVO; import cn.rosercode.finance.vo.NotificationChannelVO;
import cn.rosercode.finance.vo.NotificationLogVO; import cn.rosercode.finance.vo.NotificationLogVO;
import cn.rosercode.finance.vo.RuleVO;
import cn.rosercode.finance.vo.TagVO; import cn.rosercode.finance.vo.TagVO;
import cn.rosercode.finance.vo.TransactionVO; import cn.rosercode.finance.vo.TransactionVO;
import cn.rosercode.finance.vo.UserVO; import cn.rosercode.finance.vo.UserVO;
@@ -132,4 +134,23 @@ public interface VoConverter {
@Mapping(target = "config", ignore = true) @Mapping(target = "config", ignore = true)
@Mapping(target = "hasPassword", ignore = true) @Mapping(target = "hasPassword", ignore = true)
NotificationChannelVO toNotificationChannelVO(NotificationChannel channel); NotificationChannelVO toNotificationChannelVO(NotificationChannel channel);
/**
* 规则实体与关联分类转视图对象。
*
* <p>分类名称/图标取自关联分类,未关联时为空字符串;标签列表由调用方另行填充。</p>
*
* @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);
} }
@@ -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<Long> tagIds;
}
@@ -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。
*
* <p>规则按关键词、收支类型、金额区间等条件匹配交易,命中后自动应用目标分类与标签。</p>
*
* @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;
}
@@ -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;
}
@@ -1,9 +1,11 @@
package cn.rosercode.finance.importer; package cn.rosercode.finance.importer;
import cn.rosercode.finance.vo.TagVO;
import lombok.Data; import lombok.Data;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
/** /**
* 账单解析结果行,作为「先解析、后导入」流程中解析阶段的中间对象。 * 账单解析结果行,作为「先解析、后导入」流程中解析阶段的中间对象。
@@ -54,6 +56,21 @@ public class ParsedBillRow {
/** 是否与系统中已有记录重复(同一用户下交易号已存在) */ /** 是否与系统中已有记录重复(同一用户下交易号已存在) */
private boolean duplicate; private boolean duplicate;
/** 命中的规则名称列表,未命中为空 */
private List<String> matchedRuleNames;
/** 命中规则建议的分类ID,未命中或规则未指定分类时为 null */
private Long suggestedCategoryId;
/** 命中规则建议的分类名称 */
private String suggestedCategoryName;
/** 命中规则建议的分类图标 */
private String suggestedCategoryIcon;
/** 命中规则建议的标签列表 */
private List<TagVO> suggestedTags;
/** 原始账单行的完整数据(结构化JSON,保留全部列) */ /** 原始账单行的完整数据(结构化JSON,保留全部列) */
private String rawData; private String rawData;
@@ -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<Rule> {
}
@@ -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<RuleTag> {
}
@@ -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;
}
@@ -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<Long> tagIds = new ArrayList<>();
/** 命中的规则名称列表,按优先级排列 */
private List<String> matchedRuleNames = new ArrayList<>();
/**
* 是否命中任意规则。
*
* @return 命中规则名称非空时返回 true
*/
public boolean isMatched() {
return !matchedRuleNames.isEmpty();
}
}
@@ -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<RuleVO> 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);
/**
* 批量按交易信息匹配规则。
*
* <p>一次性加载启用规则后逐条匹配,避免重复查询;分类取优先级最高的命中规则,
* 标签取所有命中规则的并集。返回顺序与入参一致。</p>
*
* @param userId 当前用户ID
* @param contexts 匹配上下文列表
* @return 与入参一一对应的匹配结果列表
*/
List<RuleMatchResult> matchBatch(Long userId, List<RuleMatchContext> contexts);
}
@@ -2,6 +2,7 @@ package cn.rosercode.finance.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import cn.rosercode.finance.common.BusinessException; import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.converter.VoConverter;
import cn.rosercode.finance.dto.BillImportRequest; import cn.rosercode.finance.dto.BillImportRequest;
import cn.rosercode.finance.entity.Category; import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.Tag; 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.TransactionMapper;
import cn.rosercode.finance.mapper.TransactionTagMapper; import cn.rosercode.finance.mapper.TransactionTagMapper;
import cn.rosercode.finance.service.BillImportService; 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.BillImportResultVO;
import cn.rosercode.finance.vo.BillParseResultVO; import cn.rosercode.finance.vo.BillParseResultVO;
import cn.rosercode.finance.vo.TagVO;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@@ -26,8 +31,10 @@ import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.EnumMap; import java.util.EnumMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -47,12 +54,16 @@ public class BillImportServiceImpl implements BillImportService {
private final CategoryMapper categoryMapper; private final CategoryMapper categoryMapper;
private final TagMapper tagMapper; private final TagMapper tagMapper;
private final TransactionTagMapper transactionTagMapper; private final TransactionTagMapper transactionTagMapper;
private final RuleService ruleService;
private final VoConverter voConverter;
public BillImportServiceImpl(List<BillParser> parsers, public BillImportServiceImpl(List<BillParser> parsers,
TransactionMapper transactionMapper, TransactionMapper transactionMapper,
CategoryMapper categoryMapper, CategoryMapper categoryMapper,
TagMapper tagMapper, TagMapper tagMapper,
TransactionTagMapper transactionTagMapper) { TransactionTagMapper transactionTagMapper,
RuleService ruleService,
VoConverter voConverter) {
// 按账单类型注册解析器,便于扩展新账单来源 // 按账单类型注册解析器,便于扩展新账单来源
for (BillParser parser : parsers) { for (BillParser parser : parsers) {
parserMap.put(parser.getBillType(), parser); parserMap.put(parser.getBillType(), parser);
@@ -61,6 +72,8 @@ public class BillImportServiceImpl implements BillImportService {
this.categoryMapper = categoryMapper; this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper; this.tagMapper = tagMapper;
this.transactionTagMapper = transactionTagMapper; 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(); BillParseResultVO vo = new BillParseResultVO();
vo.setBillType(billType.name()); vo.setBillType(billType.name());
vo.setTotal(rows.size()); vo.setTotal(rows.size());
@@ -131,12 +147,21 @@ public class BillImportServiceImpl implements BillImportService {
.distinct() .distinct()
.collect(Collectors.toList())); .collect(Collectors.toList()));
// 批量运行规则匹配,规则建议优先于默认分类与标签
List<RuleMatchContext> ruleContexts = items.stream()
.map(item -> new RuleMatchContext(item.getType(), item.getAmount(),
item.getCounterparty(), item.getProduct(),
buildRemark(item.getCounterparty(), item.getProduct())))
.collect(Collectors.toList());
List<RuleMatchResult> ruleMatches = ruleService.matchBatch(userId, ruleContexts);
int imported = 0; int imported = 0;
int skipped = 0; int skipped = 0;
int failed = 0; int failed = 0;
List<String> messages = new ArrayList<>(); List<String> messages = new ArrayList<>();
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() : "未知交易号"; String label = StringUtils.hasText(item.getExternalNo()) ? item.getExternalNo() : "未知交易号";
try { try {
// 基础字段校验 // 基础字段校验
@@ -163,10 +188,14 @@ public class BillImportServiceImpl implements BillImportService {
} }
Category category = "INCOME".equals(item.getType()) ? incomeCategory : expenseCategory; 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 transaction = new Transaction();
transaction.setUserId(userId); transaction.setUserId(userId);
// 未选择默认分类时置空,表示未分类 transaction.setCategoryId(categoryId);
transaction.setCategoryId(category != null ? category.getId() : null);
transaction.setType(item.getType()); transaction.setType(item.getType());
transaction.setAmount(item.getAmount()); transaction.setAmount(item.getAmount());
transaction.setPaymentMethod(resolvePaymentMethod(request.getBillType())); transaction.setPaymentMethod(resolvePaymentMethod(request.getBillType()));
@@ -176,8 +205,10 @@ public class BillImportServiceImpl implements BillImportService {
transaction.setIncludeInStats(item.getIncludeInStats() == null ? Boolean.TRUE : item.getIncludeInStats()); transaction.setIncludeInStats(item.getIncludeInStats() == null ? Boolean.TRUE : item.getIncludeInStats());
transaction.setRawData(item.getRawData()); transaction.setRawData(item.getRawData());
transactionMapper.insert(transaction); transactionMapper.insert(transaction);
// 关联默认标签 // 关联标签:规则建议标签与默认标签合并去重
for (Long tagId : tagIds) { Set<Long> itemTagIds = new LinkedHashSet<>(tagIds);
itemTagIds.addAll(ruleMatches.get(i).getTagIds());
for (Long tagId : itemTagIds) {
TransactionTag relation = new TransactionTag(); TransactionTag relation = new TransactionTag();
relation.setTransactionId(transaction.getId()); relation.setTransactionId(transaction.getId());
relation.setTagId(tagId); relation.setTagId(tagId);
@@ -227,6 +258,64 @@ public class BillImportServiceImpl implements BillImportService {
return existing; return existing;
} }
/**
* 为解析出的账单行生成规则匹配建议(分类与标签)。
*
* <p>批量运行规则匹配后,再批量查询建议分类与标签,避免 N+1 查询。</p>
*
* @param userId 当前用户ID
* @param rows 解析出的账单行列表
*/
private void applyRuleSuggestions(Long userId, List<ParsedBillRow> rows) {
if (rows.isEmpty()) {
return;
}
List<RuleMatchContext> contexts = rows.stream()
.map(row -> new RuleMatchContext(row.getType(), row.getAmount(),
row.getCounterparty(), row.getProduct(),
buildRemark(row.getCounterparty(), row.getProduct())))
.collect(Collectors.toList());
List<RuleMatchResult> matches = ruleService.matchBatch(userId, contexts);
// 批量查询建议分类
Set<Long> categoryIds = matches.stream()
.map(RuleMatchResult::getCategoryId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, Category> categoryMap = categoryIds.isEmpty() ? Map.of()
: categoryMapper.selectBatchIds(categoryIds).stream()
.collect(Collectors.toMap(Category::getId, c -> c));
// 批量查询建议标签
Set<Long> tagIds = matches.stream()
.flatMap(match -> match.getTagIds().stream())
.collect(Collectors.toSet());
Map<Long, Tag> 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<TagVO> suggestedTags = match.getTagIds().stream()
.map(tagMap::get)
.filter(Objects::nonNull)
.map(voConverter::toTagVO)
.collect(Collectors.toList());
row.setSuggestedTags(suggestedTags);
}
}
/** /**
* 校验分类是否存在、归属当前用户且类型匹配 * 校验分类是否存在、归属当前用户且类型匹配
* *
@@ -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;
/**
* 规则服务实现类,处理自定义分类与标签规则的增删改查与匹配业务逻辑。
*
* <p>匹配时分类取优先级最高的命中规则,标签取所有命中规则的并集;
* 关键词在备注、交易对方、商品名称拼接文本中做包含匹配(忽略大小写)。</p>
*
* @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<RuleVO> list(Long userId) {
List<Rule> rules = ruleMapper.selectList(new LambdaQueryWrapper<Rule>()
.eq(Rule::getUserId, userId)
.orderByAsc(Rule::getSortOrder)
.orderByAsc(Rule::getId));
if (rules.isEmpty()) {
return List.of();
}
// 批量查询关联分类
List<Long> categoryIds = rules.stream()
.map(Rule::getCategoryId)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
Map<Long, Category> categoryMap = Map.of();
if (!categoryIds.isEmpty()) {
categoryMap = categoryMapper.selectBatchIds(categoryIds).stream()
.collect(Collectors.toMap(Category::getId, c -> c));
}
Map<Long, Category> finalCategoryMap = categoryMap;
List<RuleVO> 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<RuleTag>()
.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<RuleMatchResult> matchBatch(Long userId, List<RuleMatchContext> contexts) {
List<RuleMatchResult> results = new ArrayList<>();
if (contexts == null || contexts.isEmpty()) {
return results;
}
for (int i = 0; i < contexts.size(); i++) {
results.add(new RuleMatchResult());
}
List<Rule> rules = ruleMapper.selectList(new LambdaQueryWrapper<Rule>()
.eq(Rule::getUserId, userId)
.eq(Rule::getEnabled, true)
.orderByAsc(Rule::getSortOrder)
.orderByAsc(Rule::getId));
if (rules.isEmpty()) {
return results;
}
// 预加载规则标签关联,按规则分组
List<Long> ruleIds = rules.stream().map(Rule::getId).collect(Collectors.toList());
Map<Long, List<Long>> tagIdsByRule = new HashMap<>();
List<RuleTag> relations = ruleTagMapper.selectList(new LambdaQueryWrapper<RuleTag>()
.in(RuleTag::getRuleId, ruleIds));
for (RuleTag relation : relations) {
tagIdsByRule.computeIfAbsent(relation.getRuleId(), key -> new ArrayList<>())
.add(relation.getTagId());
}
// 预加载规则引用的分类,用于校验分类类型与交易类型一致
List<Long> categoryIds = rules.stream()
.map(Rule::getCategoryId)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
Map<Long, Category> 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<Long> 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<Long> tagIds) {
ruleTagMapper.delete(new LambdaQueryWrapper<RuleTag>()
.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<Long> validTagIds(Long userId, List<Long> 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<RuleVO> voList) {
List<Long> ruleIds = voList.stream().map(RuleVO::getId).collect(Collectors.toList());
List<RuleTag> relations = ruleTagMapper.selectList(new LambdaQueryWrapper<RuleTag>()
.in(RuleTag::getRuleId, ruleIds));
if (relations.isEmpty()) {
return;
}
List<Long> tagIds = relations.stream().map(RuleTag::getTagId).distinct().collect(Collectors.toList());
Map<Long, Tag> tagMap = tagMapper.selectBatchIds(tagIds).stream()
.collect(Collectors.toMap(Tag::getId, tag -> tag));
Map<Long, List<TagVO>> 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;
}
}
@@ -4,8 +4,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import cn.rosercode.finance.common.BusinessException; import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.converter.VoConverter; import cn.rosercode.finance.converter.VoConverter;
import cn.rosercode.finance.dto.TagRequest; import cn.rosercode.finance.dto.TagRequest;
import cn.rosercode.finance.entity.RuleTag;
import cn.rosercode.finance.entity.Tag; import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.entity.TransactionTag; import cn.rosercode.finance.entity.TransactionTag;
import cn.rosercode.finance.mapper.RuleTagMapper;
import cn.rosercode.finance.mapper.TagMapper; import cn.rosercode.finance.mapper.TagMapper;
import cn.rosercode.finance.mapper.TransactionTagMapper; import cn.rosercode.finance.mapper.TransactionTagMapper;
import cn.rosercode.finance.service.TagService; import cn.rosercode.finance.service.TagService;
@@ -26,11 +28,14 @@ public class TagServiceImpl implements TagService {
private final TagMapper tagMapper; private final TagMapper tagMapper;
private final TransactionTagMapper transactionTagMapper; private final TransactionTagMapper transactionTagMapper;
private final RuleTagMapper ruleTagMapper;
private final VoConverter voConverter; 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.tagMapper = tagMapper;
this.transactionTagMapper = transactionTagMapper; this.transactionTagMapper = transactionTagMapper;
this.ruleTagMapper = ruleTagMapper;
this.voConverter = voConverter; this.voConverter = voConverter;
} }
@@ -101,7 +106,7 @@ public class TagServiceImpl implements TagService {
/** /**
* {@inheritDoc} * {@inheritDoc}
* *
* 删除标签前先清理其与交易记录的关联,避免残留脏数据。 * 删除标签前先清理其与交易记录、规则的关联,避免残留脏数据。
*/ */
@Override @Override
@Transactional @Transactional
@@ -112,6 +117,8 @@ public class TagServiceImpl implements TagService {
} }
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>() transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
.eq(TransactionTag::getTagId, id)); .eq(TransactionTag::getTagId, id));
ruleTagMapper.delete(new LambdaQueryWrapper<RuleTag>()
.eq(RuleTag::getTagId, id));
tagMapper.deleteById(id); tagMapper.deleteById(id);
} }
@@ -0,0 +1,60 @@
package cn.rosercode.finance.vo;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 规则视图对象,用于向前端返回自定义分类与标签规则信息。
*
* <p>目标分类名称/图标取自关联分类,标签列表由调用方另行填充。</p>
*
* @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<TagVO> tags;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
}
+27
View File
@@ -166,3 +166,30 @@ CREATE TABLE IF NOT EXISTS t_login_log (
INDEX idx_login_log_created (created_at) COMMENT '创建时间索引', INDEX idx_login_log_created (created_at) COMMENT '创建时间索引',
INDEX idx_login_log_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引' 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索引'
);
+33
View File
@@ -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_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 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;;