feat: 支持自定义分类与标签规则并优化 AI 查询交互
- 新增 t_rule、t_rule_tag 表(MySQL/SQLite 双脚本)与规则引擎,支持关键词(备注/交易对方/商品名称)、收支类型、金额区间组合匹配 - 新增 /api/rule 增删改查与启用停用接口,侧边栏新增「规则管理」页,支持排序与启停 - 账单导入解析阶段批量匹配,返回命中规则与建议分类/标签,预览表格新增「匹配规则/建议」列 - 确认导入时按行重新匹配:规则建议分类优先于面板默认分类,规则标签与默认标签合并去重 - 删除标签时同步清理规则标签关联 - AI 自然语言查询解析后直接应用筛选并搜索,移除二次确认弹窗,成功提示展示识别意图 - 修正登录日志筛选 success 参数类型为 'true'/'false'
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: '先に明細ファイルを選択してください' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -220,6 +220,39 @@ const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
|
||||
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)
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
/** 加载分类选项 */
|
||||
|
||||
@@ -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<MenuOption[]>(() => [
|
||||
{ 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 导航到对应路由 */
|
||||
|
||||
@@ -53,7 +53,7 @@ const loginPage = ref(1)
|
||||
const loginSize = ref(20)
|
||||
const loginTotal = ref(0)
|
||||
const loginKeyword = ref('')
|
||||
const loginSuccess = ref<boolean | null>(null)
|
||||
const loginSuccess = ref<'true' | 'false' | 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(() => [
|
||||
{ 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
|
||||
|
||||
@@ -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>
|
||||
@@ -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<AiSuggestion[]>([]) // AI 建议列表
|
||||
const checkedAiRowKeys = ref<number[]>([]) // AI 建议弹窗中已勾选的代表记录ID
|
||||
const aiQueryText = ref('') // AI 自然语言查询输入
|
||||
const aiQuerying = ref(false) // AI 查询解析状态
|
||||
const showAiQueryModal = ref(false) // AI 查询结果弹窗显示状态
|
||||
const aiQueryResult = ref<AiQueryResult | null>(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(() => {
|
||||
</NSpace>
|
||||
</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 智能记账弹窗 -->
|
||||
<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">
|
||||
|
||||
@@ -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.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);
|
||||
|
||||
/**
|
||||
* 规则实体与关联分类转视图对象。
|
||||
*
|
||||
* <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;
|
||||
|
||||
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<String> matchedRuleNames;
|
||||
|
||||
/** 命中规则建议的分类ID,未命中或规则未指定分类时为 null */
|
||||
private Long suggestedCategoryId;
|
||||
|
||||
/** 命中规则建议的分类名称 */
|
||||
private String suggestedCategoryName;
|
||||
|
||||
/** 命中规则建议的分类图标 */
|
||||
private String suggestedCategoryIcon;
|
||||
|
||||
/** 命中规则建议的标签列表 */
|
||||
private List<TagVO> suggestedTags;
|
||||
|
||||
/** 原始账单行的完整数据(结构化JSON,保留全部列) */
|
||||
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 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<BillParser> 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<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 skipped = 0;
|
||||
int failed = 0;
|
||||
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() : "未知交易号";
|
||||
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<Long> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为解析出的账单行生成规则匹配建议(分类与标签)。
|
||||
*
|
||||
* <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.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<TransactionTag>()
|
||||
.eq(TransactionTag::getTagId, id));
|
||||
ruleTagMapper.delete(new LambdaQueryWrapper<RuleTag>()
|
||||
.eq(RuleTag::getTagId, 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;
|
||||
}
|
||||
@@ -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索引'
|
||||
);
|
||||
|
||||
@@ -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;;
|
||||
|
||||
Reference in New Issue
Block a user