feat: 收支记录页支持基于勾选账单 AI 生成规则
在收支记录页批量操作区新增「AI 生成规则」入口,可基于勾选的账单调用 AI 归纳分类与标签规则,在弹窗中预览、勾选创建,并可选择同时将这些规则应用到所选账单,复用既有规则生成能力并补充本地匹配应用接口。 变更内容: - 后端新增 `POST /api/ai/rule/apply-transactions`(`AiRuleService.applyToTransactions`):按传入规则对指定交易做本地匹配,命中则覆盖分类、追加标签,必要时新建分类与标签,复用 `AiSuggestionService.apply` 落库。 - 新增 DTO `AiRuleTransactionApplyRequest`,复用 `AiRuleApplyRequest.Item` 结构承载规则定义。 - 前端 `api/ai.ts` 抽出 `AiRuleApplyItem` 类型并新增 `applyRulesToTransactions`。 - 新增组件 `AiRuleSuggestModal.vue`:展示规则建议、勾选创建,并提供「同时应用到当前勾选的 N 条账单」开关。 - `TransactionList.vue` 批量操作区新增「AI 生成规则」按钮(受 AI 启用状态控制),勾选账单后一步生成并预览规则。 - `locales/messages.ts` 补充相关中文、英文、日文文案。 说明: - `apply-transactions` 为纯本地匹配、不调用大模型,不外发任何敏感数据;`suggest` 仍遵循白名单只发送类型、备注、渠道与分类标签名称。
This commit is contained in:
+21
-15
@@ -185,25 +185,31 @@ export function suggestRulesByAi(transactionIds: number[]) {
|
|||||||
return request.post('/ai/rule/suggest', { transactionIds }, { timeout: 120000 })
|
return request.post('/ai/rule/suggest', { transactionIds }, { timeout: 120000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** AI 规则创建/应用的单条规则载荷 */
|
||||||
|
export interface AiRuleApplyItem {
|
||||||
|
name: string
|
||||||
|
enabled?: boolean
|
||||||
|
type?: string | null
|
||||||
|
keyword?: string | null
|
||||||
|
minAmount?: number | null
|
||||||
|
maxAmount?: number | null
|
||||||
|
categoryId?: number | null
|
||||||
|
newCategoryName?: string | null
|
||||||
|
newCategoryIcon?: string | null
|
||||||
|
tagIds?: number[]
|
||||||
|
newTags?: AiNewTag[]
|
||||||
|
}
|
||||||
|
|
||||||
/** 批量创建 AI 生成的规则,必要时新建分类与标签 */
|
/** 批量创建 AI 生成的规则,必要时新建分类与标签 */
|
||||||
export function applyAiRules(
|
export function applyAiRules(rules: AiRuleApplyItem[]) {
|
||||||
rules: {
|
|
||||||
name: string
|
|
||||||
enabled?: boolean
|
|
||||||
type?: string | null
|
|
||||||
keyword?: string | null
|
|
||||||
minAmount?: number | null
|
|
||||||
maxAmount?: number | null
|
|
||||||
categoryId?: number | null
|
|
||||||
newCategoryName?: string | null
|
|
||||||
newCategoryIcon?: string | null
|
|
||||||
tagIds?: number[]
|
|
||||||
newTags?: AiNewTag[]
|
|
||||||
}[],
|
|
||||||
) {
|
|
||||||
return request.post('/ai/rule/apply', { rules })
|
return request.post('/ai/rule/apply', { rules })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将规则按类型/关键词/金额匹配并应用到指定交易,命中则覆盖分类、追加标签 */
|
||||||
|
export function applyRulesToTransactions(transactionIds: number[], rules: AiRuleApplyItem[]) {
|
||||||
|
return request.post('/ai/rule/apply-transactions', { transactionIds, rules })
|
||||||
|
}
|
||||||
|
|
||||||
/** 一键应用建议:覆盖分类并追加标签,标记为新建的分类/标签会先创建再关联 */
|
/** 一键应用建议:覆盖分类并追加标签,标记为新建的分类/标签会先创建再关联 */
|
||||||
export function applyAiSuggestions(
|
export function applyAiSuggestions(
|
||||||
suggestions: {
|
suggestions: {
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// AI 规则建议弹窗:展示基于勾选账单生成的规则建议,勾选后创建规则,并可选同时应用到勾选账单
|
||||||
|
import { ref, computed, watch, h } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { NButton, NModal, NSpace, NDataTable, NTag, NSwitch, NText, NAlert } from 'naive-ui'
|
||||||
|
import type { DataTableColumns } from 'naive-ui'
|
||||||
|
import type { AiRuleSuggestion } from '../api/ai'
|
||||||
|
|
||||||
|
/** 规则建议行:附加本地 key 供表格选择使用 */
|
||||||
|
type AiRuleRow = AiRuleSuggestion & { key: number }
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
suggestions: AiRuleSuggestion[]
|
||||||
|
targetCount: number
|
||||||
|
applying: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:show', value: boolean): void
|
||||||
|
(e: 'submit', payload: { rules: AiRuleSuggestion[]; applyToSelection: boolean }): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const showModel = computed({
|
||||||
|
get: () => props.show,
|
||||||
|
set: (value: boolean) => emit('update:show', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 规则建议行(带本地 key)
|
||||||
|
const rows = computed<AiRuleRow[]>(() => props.suggestions.map((rule, index) => ({ ...rule, key: index })))
|
||||||
|
// 已勾选规则行 key
|
||||||
|
const checkedKeys = ref<number[]>([])
|
||||||
|
// 是否同时把规则应用到当前勾选的账单
|
||||||
|
const applyToSelection = ref(false)
|
||||||
|
|
||||||
|
// 打开时重置:默认勾选非重复项,应用开关默认关闭
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(visible) => {
|
||||||
|
if (!visible) return
|
||||||
|
checkedKeys.value = rows.value.filter((row) => !row.duplicate).map((row) => row.key)
|
||||||
|
applyToSelection.value = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 规则建议表格列(只读展示) */
|
||||||
|
const columns = computed<DataTableColumns<AiRuleRow>>(() => [
|
||||||
|
{ type: 'selection', disabled: (row) => !!row.duplicate },
|
||||||
|
{
|
||||||
|
title: t('common.name'),
|
||||||
|
key: 'name',
|
||||||
|
minWidth: 160,
|
||||||
|
render: (row) =>
|
||||||
|
h(
|
||||||
|
NSpace,
|
||||||
|
{ size: 4, align: 'center', wrap: false },
|
||||||
|
{
|
||||||
|
default: () => [
|
||||||
|
h('span', null, row.name),
|
||||||
|
row.duplicate
|
||||||
|
? h(NTag, { size: 'small', type: 'warning' }, { default: () => t('ai.rule.duplicate') })
|
||||||
|
: null,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('rule.keyword'),
|
||||||
|
key: 'keyword',
|
||||||
|
width: 140,
|
||||||
|
render: (row) => row.keyword || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('common.type'),
|
||||||
|
key: 'type',
|
||||||
|
width: 80,
|
||||||
|
render: (row) => (row.type ? (row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')) : '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('rule.targetCategory'),
|
||||||
|
key: 'category',
|
||||||
|
minWidth: 130,
|
||||||
|
render: (row) => {
|
||||||
|
if (row.categoryName) return row.categoryName
|
||||||
|
if (row.newCategoryName) return `${t('ai.rule.newPrefix')}${row.newCategoryName}`
|
||||||
|
return '—'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('rule.targetTags'),
|
||||||
|
key: 'tags',
|
||||||
|
minWidth: 150,
|
||||||
|
render: (row) => {
|
||||||
|
const names = [
|
||||||
|
...(row.tagNames || []),
|
||||||
|
...(row.newTags || []).map((tag) => `${t('ai.rule.newPrefix')}${tag.name}`),
|
||||||
|
]
|
||||||
|
return names.length > 0 ? names.join('、') : '—'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('ai.rule.reason'),
|
||||||
|
key: 'reason',
|
||||||
|
minWidth: 160,
|
||||||
|
ellipsis: { tooltip: true },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
// 已勾选规则(重复项不可选)
|
||||||
|
const selectedRules = computed(() => rows.value.filter((row) => !row.duplicate && checkedKeys.value.includes(row.key)))
|
||||||
|
|
||||||
|
/** 去除本地 key,还原为规则建议对象 */
|
||||||
|
function stripKey(row: AiRuleRow): AiRuleSuggestion {
|
||||||
|
const { key, ...rest } = row
|
||||||
|
void key
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交:传出勾选规则与是否同时应用到账单 */
|
||||||
|
function handleSubmit() {
|
||||||
|
if (selectedRules.value.length === 0) return
|
||||||
|
emit('submit', {
|
||||||
|
rules: selectedRules.value.map(stripKey),
|
||||||
|
applyToSelection: applyToSelection.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NModal v-model:show="showModel" preset="card" :title="t('ai.rule.title')" style="width: min(1100px, 94vw)">
|
||||||
|
<NSpace vertical :size="12">
|
||||||
|
<NText depth="3" style="font-size: 12px">{{ t('ai.rule.hint') }}</NText>
|
||||||
|
<NDataTable
|
||||||
|
v-model:checked-row-keys="checkedKeys"
|
||||||
|
:columns="columns"
|
||||||
|
:data="rows"
|
||||||
|
:max-height="420"
|
||||||
|
:scroll-x="900"
|
||||||
|
:row-key="(row: AiRuleRow) => row.key"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<NAlert v-if="applyToSelection" type="warning" :show-icon="true">
|
||||||
|
{{ t('ai.rule.applyToSelectionNote') }}
|
||||||
|
</NAlert>
|
||||||
|
<NSpace align="center" justify="space-between" :wrap="true">
|
||||||
|
<NSpace align="center" :size="8">
|
||||||
|
<NSwitch v-model:value="applyToSelection" />
|
||||||
|
<span>{{ t('ai.rule.applyToSelection', { count: targetCount }) }}</span>
|
||||||
|
</NSpace>
|
||||||
|
<NSpace justify="end">
|
||||||
|
<NButton @click="showModel = false">{{ t('common.cancel') }}</NButton>
|
||||||
|
<NButton type="primary" :loading="applying" :disabled="selectedRules.length === 0" @click="handleSubmit">
|
||||||
|
{{ t('ai.rule.apply') }}
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</NSpace>
|
||||||
|
</NSpace>
|
||||||
|
</NModal>
|
||||||
|
</template>
|
||||||
@@ -1440,6 +1440,26 @@ export default {
|
|||||||
en: 'All generated rules already exist',
|
en: 'All generated rules already exist',
|
||||||
ja: '生成されたルールはすべて既存です',
|
ja: '生成されたルールはすべて既存です',
|
||||||
},
|
},
|
||||||
|
selectFirst: {
|
||||||
|
zh: '请先勾选要用于生成规则的账单',
|
||||||
|
en: 'Please select the records to derive rules from',
|
||||||
|
ja: 'ルール生成に使う記録を先に選択してください',
|
||||||
|
},
|
||||||
|
applyToSelection: {
|
||||||
|
zh: '同时应用到当前勾选的 {count} 条账单',
|
||||||
|
en: 'Also apply to the {count} selected records',
|
||||||
|
ja: '選択中の {count} 件の記録にも適用する',
|
||||||
|
},
|
||||||
|
applyToSelectionNote: {
|
||||||
|
zh: '命中规则时会覆盖这些账单的分类并追加标签(不改动备注与金额)',
|
||||||
|
en: 'Matching rules overwrite the category and append tags of these records (remark and amount unchanged)',
|
||||||
|
ja: 'ルールが一致した場合、これらの記録のカテゴリを上書きしタグを追加します(備考と金額は変更しません)',
|
||||||
|
},
|
||||||
|
appliedAndApplied: {
|
||||||
|
zh: '已创建 {rules} 条规则,并应用到 {transactions} 条账单',
|
||||||
|
en: 'Created {rules} rule(s) and applied to {transactions} record(s)',
|
||||||
|
ja: '{rules} 件のルールを作成し、{transactions} 件の記録に適用しました',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
category: {
|
category: {
|
||||||
label: { zh: 'AI 生成分类', en: 'AI Category', ja: 'AI 生成カテゴリ' },
|
label: { zh: 'AI 生成分类', en: 'AI Category', ja: 'AI 生成カテゴリ' },
|
||||||
|
|||||||
@@ -39,9 +39,19 @@ import {
|
|||||||
getChannels,
|
getChannels,
|
||||||
batchDeleteTransactions,
|
batchDeleteTransactions,
|
||||||
} from '../api/transaction'
|
} from '../api/transaction'
|
||||||
import { suggestByAi, suggestOneByAi, queryByAi, parseCategoryByAi, parseTagByAi, parseChannelByAi } from '../api/ai'
|
import {
|
||||||
|
suggestByAi,
|
||||||
|
suggestOneByAi,
|
||||||
|
queryByAi,
|
||||||
|
parseCategoryByAi,
|
||||||
|
parseTagByAi,
|
||||||
|
parseChannelByAi,
|
||||||
|
suggestRulesByAi,
|
||||||
|
applyAiRules,
|
||||||
|
applyRulesToTransactions,
|
||||||
|
} from '../api/ai'
|
||||||
import { createChannel } from '../api/channel'
|
import { createChannel } from '../api/channel'
|
||||||
import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai'
|
import type { AiSuggestion, AiQueryResult, AiRecordResult, AiRuleSuggestion, AiRuleApplyItem } from '../api/ai'
|
||||||
import { getExcludedDisplayStyle, getShowHiddenData } from '../api/setting'
|
import { getExcludedDisplayStyle, getShowHiddenData } from '../api/setting'
|
||||||
import { getCategoryList, createCategory } from '../api/category'
|
import { getCategoryList, createCategory } from '../api/category'
|
||||||
import { getTagList, createTag } from '../api/tag'
|
import { getTagList, createTag } from '../api/tag'
|
||||||
@@ -59,6 +69,7 @@ import { aiEnabled, ensureAiEnabledLoaded } from '../composables/useAiEnabled'
|
|||||||
import EmojiPicker from '../components/EmojiPicker.vue'
|
import EmojiPicker from '../components/EmojiPicker.vue'
|
||||||
import AiRecordModal from '../components/AiRecordModal.vue'
|
import AiRecordModal from '../components/AiRecordModal.vue'
|
||||||
import AiSuggestModal from '../components/AiSuggestModal.vue'
|
import AiSuggestModal from '../components/AiSuggestModal.vue'
|
||||||
|
import AiRuleSuggestModal from '../components/AiRuleSuggestModal.vue'
|
||||||
import TransactionBatchModal from '../components/TransactionBatchModal.vue'
|
import TransactionBatchModal from '../components/TransactionBatchModal.vue'
|
||||||
import BillImportModal from './BillImportModal.vue'
|
import BillImportModal from './BillImportModal.vue'
|
||||||
import TransactionDetailModal from '../components/TransactionDetailModal.vue'
|
import TransactionDetailModal from '../components/TransactionDetailModal.vue'
|
||||||
@@ -220,6 +231,10 @@ const newTagColorSwatches = [
|
|||||||
'#000000',
|
'#000000',
|
||||||
]
|
]
|
||||||
const aiSuggestions = ref<AiSuggestion[]>([]) // AI 建议列表
|
const aiSuggestions = ref<AiSuggestion[]>([]) // AI 建议列表
|
||||||
|
const aiRuleSuggestions = ref<AiRuleSuggestion[]>([]) // AI 规则建议列表
|
||||||
|
const aiRuleGenerating = ref(false) // 规则生成中
|
||||||
|
const aiRuleApplying = ref(false) // 规则创建/应用中
|
||||||
|
const showAiRuleModal = ref(false) // AI 生成规则弹窗显示状态
|
||||||
const aiQuerying = ref(false) // AI 查询解析状态
|
const aiQuerying = ref(false) // AI 查询解析状态
|
||||||
const aiQueryResult = ref<AiQueryResult | null>(null) // AI 查询解析结果
|
const aiQueryResult = ref<AiQueryResult | null>(null) // AI 查询解析结果
|
||||||
const showAiQueryModal = ref(false) // AI 智能查询弹窗显示状态
|
const showAiQueryModal = ref(false) // AI 智能查询弹窗显示状态
|
||||||
@@ -1021,6 +1036,72 @@ async function handleAiSuggest() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 基于勾选的账单生成规则建议 */
|
||||||
|
async function handleAiRuleGenerate() {
|
||||||
|
const ids = checkedTransactionIds.value
|
||||||
|
if (ids.length === 0) {
|
||||||
|
message.warning(t('ai.rule.selectFirst'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aiRuleGenerating.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await suggestRulesByAi(ids)
|
||||||
|
const data = res.data || {}
|
||||||
|
aiRuleSuggestions.value = data.rules || []
|
||||||
|
if (aiRuleSuggestions.value.length === 0) {
|
||||||
|
message.warning(t('ai.noSuggestion'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showAiRuleModal.value = true
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message || t('ai.rule.failed'))
|
||||||
|
} finally {
|
||||||
|
aiRuleGenerating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将规则建议映射为创建/应用载荷 */
|
||||||
|
function toRulePayload(rule: AiRuleSuggestion): AiRuleApplyItem {
|
||||||
|
return {
|
||||||
|
name: rule.name,
|
||||||
|
enabled: true,
|
||||||
|
type: rule.type,
|
||||||
|
keyword: rule.keyword,
|
||||||
|
minAmount: rule.minAmount,
|
||||||
|
maxAmount: rule.maxAmount,
|
||||||
|
categoryId: rule.categoryId,
|
||||||
|
newCategoryName: rule.newCategoryName,
|
||||||
|
newCategoryIcon: rule.newCategoryIcon,
|
||||||
|
tagIds: rule.tagIds,
|
||||||
|
newTags: rule.newTags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建勾选的规则,并可选同时应用到勾选的账单 */
|
||||||
|
async function handleAiRuleSubmit(payload: { rules: AiRuleSuggestion[]; applyToSelection: boolean }) {
|
||||||
|
const rules = payload.rules.map(toRulePayload)
|
||||||
|
const targetIds = [...checkedTransactionIds.value]
|
||||||
|
aiRuleApplying.value = true
|
||||||
|
try {
|
||||||
|
await applyAiRules(rules)
|
||||||
|
if (payload.applyToSelection) {
|
||||||
|
await applyRulesToTransactions(targetIds, rules)
|
||||||
|
message.success(t('ai.rule.appliedAndApplied', { rules: rules.length, transactions: targetIds.length }))
|
||||||
|
} else {
|
||||||
|
message.success(t('ai.rule.applied', { count: rules.length }))
|
||||||
|
}
|
||||||
|
showAiRuleModal.value = false
|
||||||
|
exitSelectionMode()
|
||||||
|
await fetchData()
|
||||||
|
await fetchCategories()
|
||||||
|
await fetchTags()
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message || t('ai.rule.applyFailed'))
|
||||||
|
} finally {
|
||||||
|
aiRuleApplying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 编辑弹窗内 AI 推荐分类:基于表单当前内容(类型、金额、交易渠道、备注) */
|
/** 编辑弹窗内 AI 推荐分类:基于表单当前内容(类型、金额、交易渠道、备注) */
|
||||||
async function handleAiSuggestOne() {
|
async function handleAiSuggestOne() {
|
||||||
aiFormLoading.value = true
|
aiFormLoading.value = true
|
||||||
@@ -1857,6 +1938,9 @@ onMounted(() => {
|
|||||||
<NButton v-if="checkedTransactionIds.length >= 2" type="primary" ghost @click="handleCreateRelation">
|
<NButton v-if="checkedTransactionIds.length >= 2" type="primary" ghost @click="handleCreateRelation">
|
||||||
{{ t('transaction.relation.createFromSelection') }}
|
{{ t('transaction.relation.createFromSelection') }}
|
||||||
</NButton>
|
</NButton>
|
||||||
|
<NButton v-if="aiEnabled" :loading="aiRuleGenerating" @click="handleAiRuleGenerate">
|
||||||
|
{{ t('ai.rule.entry') }}
|
||||||
|
</NButton>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
|
|
||||||
<!-- 表格信息栏(右上角) -->
|
<!-- 表格信息栏(右上角) -->
|
||||||
@@ -2206,6 +2290,16 @@ onMounted(() => {
|
|||||||
@applied="onAiSuggestApplied"
|
@applied="onAiSuggestApplied"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- AI 生成规则弹窗(基于勾选账单生成规则,可选同时应用到账单) -->
|
||||||
|
<AiRuleSuggestModal
|
||||||
|
v-if="aiEnabled"
|
||||||
|
v-model:show="showAiRuleModal"
|
||||||
|
:suggestions="aiRuleSuggestions"
|
||||||
|
:target-count="checkedTransactionIds.length"
|
||||||
|
:applying="aiRuleApplying"
|
||||||
|
@submit="handleAiRuleSubmit"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- AI 智能记账弹窗(文字 / 图片) -->
|
<!-- AI 智能记账弹窗(文字 / 图片) -->
|
||||||
<AiRecordModal
|
<AiRecordModal
|
||||||
v-if="aiEnabled"
|
v-if="aiEnabled"
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import cn.rosercode.ledgerly.dto.AiRecordRequest;
|
|||||||
import cn.rosercode.ledgerly.dto.AiRelationRequest;
|
import cn.rosercode.ledgerly.dto.AiRelationRequest;
|
||||||
import cn.rosercode.ledgerly.dto.AiRuleApplyRequest;
|
import cn.rosercode.ledgerly.dto.AiRuleApplyRequest;
|
||||||
import cn.rosercode.ledgerly.dto.AiRuleSuggestRequest;
|
import cn.rosercode.ledgerly.dto.AiRuleSuggestRequest;
|
||||||
|
import cn.rosercode.ledgerly.dto.AiRuleTransactionApplyRequest;
|
||||||
import cn.rosercode.ledgerly.dto.AiSuggestOneRequest;
|
import cn.rosercode.ledgerly.dto.AiSuggestOneRequest;
|
||||||
import cn.rosercode.ledgerly.dto.AiSuggestRequest;
|
import cn.rosercode.ledgerly.dto.AiSuggestRequest;
|
||||||
import cn.rosercode.ledgerly.dto.AiTagRequest;
|
import cn.rosercode.ledgerly.dto.AiTagRequest;
|
||||||
@@ -480,6 +481,19 @@ public class AiController {
|
|||||||
return Result.success(aiRuleService.apply(userId, request));
|
return Result.success(aiRuleService.apply(userId, request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将规则匹配并应用到指定交易记录
|
||||||
|
*
|
||||||
|
* @param request 目标交易与规则定义
|
||||||
|
* @return 实际发生变更的记录数量
|
||||||
|
*/
|
||||||
|
@PostMapping("/rule/apply-transactions")
|
||||||
|
@Operation(summary = "应用 AI 规则到交易", description = "按传入的规则匹配并应用到指定交易,命中则覆盖分类、追加标签")
|
||||||
|
public Result<Integer> applyRulesToTransactions(@RequestBody @Valid AiRuleTransactionApplyRequest request) {
|
||||||
|
Long userId = UserContext.getUserId();
|
||||||
|
return Result.success(aiRuleService.applyToTransactions(userId, request));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将自然语言统计查询解析为统计面板参数
|
* 将自然语言统计查询解析为统计面板参数
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package cn.rosercode.ledgerly.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 规则应用到交易记录的请求,携带目标交易与用于匹配的规则定义。
|
||||||
|
*
|
||||||
|
* @date 2026-10-13 20:22:47
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class AiRuleTransactionApplyRequest {
|
||||||
|
|
||||||
|
/** 待应用的目标交易记录ID列表,不能为空 */
|
||||||
|
@NotEmpty(message = "请至少选择一条记录")
|
||||||
|
private List<Long> transactionIds;
|
||||||
|
|
||||||
|
/** 用于匹配并应用的规则列表,不能为空 */
|
||||||
|
@NotEmpty(message = "请至少选择一条规则")
|
||||||
|
@Valid
|
||||||
|
private List<AiRuleApplyRequest.Item> rules;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package cn.rosercode.ledgerly.service;
|
|||||||
|
|
||||||
import cn.rosercode.ledgerly.dto.AiRuleApplyRequest;
|
import cn.rosercode.ledgerly.dto.AiRuleApplyRequest;
|
||||||
import cn.rosercode.ledgerly.dto.AiRuleSuggestRequest;
|
import cn.rosercode.ledgerly.dto.AiRuleSuggestRequest;
|
||||||
|
import cn.rosercode.ledgerly.dto.AiRuleTransactionApplyRequest;
|
||||||
import cn.rosercode.ledgerly.vo.AiRuleSuggestResultVO;
|
import cn.rosercode.ledgerly.vo.AiRuleSuggestResultVO;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,4 +29,16 @@ public interface AiRuleService {
|
|||||||
* @return 实际创建的规则数量
|
* @return 实际创建的规则数量
|
||||||
*/
|
*/
|
||||||
int apply(Long userId, AiRuleApplyRequest request);
|
int apply(Long userId, AiRuleApplyRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将规则匹配并应用到指定交易记录。
|
||||||
|
*
|
||||||
|
* <p>纯本地匹配,不调用大模型:命中规则时覆盖分类并追加标签,必要时新建分类与标签。
|
||||||
|
* 规则的关键词匹配交易的备注(包含、忽略大小写),并遵循类型与金额区间条件。</p>
|
||||||
|
*
|
||||||
|
* @param userId 当前用户ID
|
||||||
|
* @param request 目标交易与规则定义
|
||||||
|
* @return 实际发生变更的记录数
|
||||||
|
*/
|
||||||
|
int applyToTransactions(Long userId, AiRuleTransactionApplyRequest request);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package cn.rosercode.ledgerly.service.impl;
|
package cn.rosercode.ledgerly.service.impl;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
@@ -24,8 +25,10 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import cn.rosercode.ledgerly.client.DeepSeekClient;
|
import cn.rosercode.ledgerly.client.DeepSeekClient;
|
||||||
import cn.rosercode.ledgerly.client.DeepSeekResponse;
|
import cn.rosercode.ledgerly.client.DeepSeekResponse;
|
||||||
import cn.rosercode.ledgerly.common.BusinessException;
|
import cn.rosercode.ledgerly.common.BusinessException;
|
||||||
|
import cn.rosercode.ledgerly.dto.AiApplyRequest;
|
||||||
import cn.rosercode.ledgerly.dto.AiRuleApplyRequest;
|
import cn.rosercode.ledgerly.dto.AiRuleApplyRequest;
|
||||||
import cn.rosercode.ledgerly.dto.AiRuleSuggestRequest;
|
import cn.rosercode.ledgerly.dto.AiRuleSuggestRequest;
|
||||||
|
import cn.rosercode.ledgerly.dto.AiRuleTransactionApplyRequest;
|
||||||
import cn.rosercode.ledgerly.dto.RuleRequest;
|
import cn.rosercode.ledgerly.dto.RuleRequest;
|
||||||
import cn.rosercode.ledgerly.entity.AiConfig;
|
import cn.rosercode.ledgerly.entity.AiConfig;
|
||||||
import cn.rosercode.ledgerly.entity.Category;
|
import cn.rosercode.ledgerly.entity.Category;
|
||||||
@@ -41,6 +44,7 @@ import cn.rosercode.ledgerly.mapper.TransactionTagMapper;
|
|||||||
import cn.rosercode.ledgerly.prompt.PromptTemplateService;
|
import cn.rosercode.ledgerly.prompt.PromptTemplateService;
|
||||||
import cn.rosercode.ledgerly.service.AiConfigService;
|
import cn.rosercode.ledgerly.service.AiConfigService;
|
||||||
import cn.rosercode.ledgerly.service.AiRuleService;
|
import cn.rosercode.ledgerly.service.AiRuleService;
|
||||||
|
import cn.rosercode.ledgerly.service.AiSuggestionService;
|
||||||
import cn.rosercode.ledgerly.service.AiUsageService;
|
import cn.rosercode.ledgerly.service.AiUsageService;
|
||||||
import cn.rosercode.ledgerly.service.RuleService;
|
import cn.rosercode.ledgerly.service.RuleService;
|
||||||
import cn.rosercode.ledgerly.vo.AiNewTagVO;
|
import cn.rosercode.ledgerly.vo.AiNewTagVO;
|
||||||
@@ -91,6 +95,7 @@ public class AiRuleServiceImpl implements AiRuleService {
|
|||||||
private final TransactionTagMapper transactionTagMapper;
|
private final TransactionTagMapper transactionTagMapper;
|
||||||
private final RuleService ruleService;
|
private final RuleService ruleService;
|
||||||
private final PromptTemplateService promptTemplateService;
|
private final PromptTemplateService promptTemplateService;
|
||||||
|
private final AiSuggestionService aiSuggestionService;
|
||||||
|
|
||||||
public AiRuleServiceImpl(AiConfigService aiConfigService,
|
public AiRuleServiceImpl(AiConfigService aiConfigService,
|
||||||
DeepSeekClient deepSeekClient,
|
DeepSeekClient deepSeekClient,
|
||||||
@@ -101,7 +106,8 @@ public class AiRuleServiceImpl implements AiRuleService {
|
|||||||
TagMapper tagMapper,
|
TagMapper tagMapper,
|
||||||
TransactionTagMapper transactionTagMapper,
|
TransactionTagMapper transactionTagMapper,
|
||||||
RuleService ruleService,
|
RuleService ruleService,
|
||||||
PromptTemplateService promptTemplateService) {
|
PromptTemplateService promptTemplateService,
|
||||||
|
AiSuggestionService aiSuggestionService) {
|
||||||
this.aiConfigService = aiConfigService;
|
this.aiConfigService = aiConfigService;
|
||||||
this.deepSeekClient = deepSeekClient;
|
this.deepSeekClient = deepSeekClient;
|
||||||
this.aiUsageService = aiUsageService;
|
this.aiUsageService = aiUsageService;
|
||||||
@@ -112,6 +118,7 @@ public class AiRuleServiceImpl implements AiRuleService {
|
|||||||
this.transactionTagMapper = transactionTagMapper;
|
this.transactionTagMapper = transactionTagMapper;
|
||||||
this.ruleService = ruleService;
|
this.ruleService = ruleService;
|
||||||
this.promptTemplateService = promptTemplateService;
|
this.promptTemplateService = promptTemplateService;
|
||||||
|
this.aiSuggestionService = aiSuggestionService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -439,6 +446,136 @@ public class AiRuleServiceImpl implements AiRuleService {
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* 纯本地匹配,不调用大模型:逐条交易按类型、关键词与金额区间匹配规则,
|
||||||
|
* 命中后聚合分类与标签,委托 {@link AiSuggestionService} 完成落库。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public int applyToTransactions(Long userId, AiRuleTransactionApplyRequest request) {
|
||||||
|
List<Long> transactionIds = request.getTransactionIds().stream()
|
||||||
|
.distinct().collect(Collectors.toList());
|
||||||
|
// 仅处理属于当前用户的记录,且保持请求顺序
|
||||||
|
Map<Long, Transaction> transactionMap = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
|
||||||
|
.eq(Transaction::getUserId, userId)
|
||||||
|
.in(Transaction::getId, transactionIds))
|
||||||
|
.stream().collect(Collectors.toMap(Transaction::getId, Function.identity()));
|
||||||
|
if (transactionMap.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AiRuleApplyRequest.Item> rules = request.getRules();
|
||||||
|
List<AiApplyRequest.Item> applyItems = new ArrayList<>();
|
||||||
|
for (Long transactionId : transactionIds) {
|
||||||
|
Transaction transaction = transactionMap.get(transactionId);
|
||||||
|
if (transaction == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
AiApplyRequest.Item item = buildApplyItem(transaction, rules);
|
||||||
|
if (item != null) {
|
||||||
|
applyItems.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (applyItems.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
AiApplyRequest applyRequest = new AiApplyRequest();
|
||||||
|
applyRequest.setSuggestions(applyItems);
|
||||||
|
int applied = aiSuggestionService.apply(userId, applyRequest);
|
||||||
|
log.info("AI 规则应用到交易完成: userId={}, 目标交易={}, 命中={}, 实际应用={}", userId,
|
||||||
|
transactionIds.size(), applyItems.size(), applied);
|
||||||
|
return applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按规则匹配单条交易并聚合目标分类与标签。
|
||||||
|
*
|
||||||
|
* @param transaction 目标交易
|
||||||
|
* @param rules 规则列表
|
||||||
|
* @return 应用项,无任何命中或可应用内容时返回 null
|
||||||
|
*/
|
||||||
|
private AiApplyRequest.Item buildApplyItem(Transaction transaction, List<AiRuleApplyRequest.Item> rules) {
|
||||||
|
String text = safe(transaction.getRemark()).toLowerCase();
|
||||||
|
Long categoryId = null;
|
||||||
|
String newCategoryName = null;
|
||||||
|
String newCategoryIcon = null;
|
||||||
|
List<Long> tagIds = new ArrayList<>();
|
||||||
|
List<AiNewTagVO> newTags = new ArrayList<>();
|
||||||
|
Set<String> newTagNames = new HashSet<>();
|
||||||
|
boolean matched = false;
|
||||||
|
for (AiRuleApplyRequest.Item rule : rules) {
|
||||||
|
if (rule == null || !matchesRule(rule, transaction, text)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
matched = true;
|
||||||
|
// 分类取首个命中规则:优先现有分类,其次新建分类
|
||||||
|
if (categoryId == null && isBlank(newCategoryName)) {
|
||||||
|
if (rule.getCategoryId() != null) {
|
||||||
|
categoryId = rule.getCategoryId();
|
||||||
|
} else if (!isBlank(rule.getNewCategoryName())) {
|
||||||
|
newCategoryName = rule.getNewCategoryName().trim();
|
||||||
|
newCategoryIcon = rule.getNewCategoryIcon();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 标签取所有命中规则的并集
|
||||||
|
if (rule.getTagIds() != null) {
|
||||||
|
for (Long tagId : rule.getTagIds()) {
|
||||||
|
if (tagId != null && !tagIds.contains(tagId)) {
|
||||||
|
tagIds.add(tagId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rule.getNewTags() != null) {
|
||||||
|
for (AiNewTagVO newTag : rule.getNewTags()) {
|
||||||
|
if (newTag == null || isBlank(newTag.getName()) || !newTagNames.add(newTag.getName().trim())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
newTags.add(newTag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matched || (categoryId == null && isBlank(newCategoryName) && tagIds.isEmpty() && newTags.isEmpty())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
AiApplyRequest.Item item = new AiApplyRequest.Item();
|
||||||
|
item.setTransactionId(transaction.getId());
|
||||||
|
item.setCategoryId(categoryId);
|
||||||
|
item.setNewCategoryName(newCategoryName);
|
||||||
|
item.setNewCategoryIcon(newCategoryIcon);
|
||||||
|
item.setTagIds(tagIds);
|
||||||
|
item.setNewTags(newTags);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断单条规则是否命中交易:类型限定、关键词包含与金额区间为「与」关系。
|
||||||
|
*
|
||||||
|
* @param rule 规则
|
||||||
|
* @param transaction 交易
|
||||||
|
* @param text 交易的匹配文本(备注转小写)
|
||||||
|
* @return 命中返回 true
|
||||||
|
*/
|
||||||
|
private boolean matchesRule(AiRuleApplyRequest.Item rule, Transaction transaction, String text) {
|
||||||
|
String type = validType(rule.getType());
|
||||||
|
if (type != null && !type.equals(transaction.getType())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!isBlank(rule.getKeyword()) && !text.contains(rule.getKeyword().trim().toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
BigDecimal amount = transaction.getAmount();
|
||||||
|
if (rule.getMinAmount() != null && (amount == null || amount.compareTo(rule.getMinAmount()) < 0)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (rule.getMaxAmount() != null && (amount == null || amount.compareTo(rule.getMaxAmount()) > 0)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建用户提示词,将可用分类、标签与已打标签记录序列化为 JSON。
|
* 构建用户提示词,将可用分类、标签与已打标签记录序列化为 JSON。
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user