feat: 交易渠道重命名、账单来源统计默认值、AI 规则生成与统计搜索,AI 改用 openai-java SDK
- 交易记录「支付方式」全链路重命名为「交易渠道」(channel):DB 列、实体/DTO/VO、接口 /api/transaction/channels、统计维度 CHANNEL、AI 提示词与 JSON 字段、前端与中英日文案;新增 SchemaMigrator 在启动/切换数据源时幂等迁移列名(MySQL 用 information_schema、SQLite 用 PRAGMA) - 按账单来源设置「计入统计」默认值:AppConfig 持久化 + GET/PUT /api/app/bill-source-include-in-stats,导入解析按「解析器判定 && 来源默认」应用并覆盖上传与邮箱两条路径,设置页新增面板 - AI 生成规则:基于所选已打标签记录归纳分类/标签规则(POST /api/ai/rule/suggest、/api/ai/rule/apply),规则页两步弹窗(选择记录→预览创建),支持候选记录分页/搜索/筛选;按「类型+关键词+分类+标签」签名与已有规则去重,重复项标记「已存在」并禁止导入 - 规则列表新增分页与搜索:GET /api/rule/page(名称/关键词、启用状态、收支类型) - 统计面板 AI 搜索:POST /api/ai/statistics 将自然语言解析为时间/类型/分类/标签/渠道/金额/统计口径/趋势粒度并回填;交互对齐账单列表 AI 查询(弹窗 + 历史 + 示例 + 空条件守卫);统计后端补充标签筛选,仪表盘新增标签筛选与 AI 搜索 - AI 客户端改用 OpenAI 官方 Java SDK(com.openai:openai-java),自定义 baseUrl 指向 DeepSeek,保留 chat 签名与 DeepSeekResponse - AI 数据外发收敛:分类/标签建议与规则生成不再发送 rawData 与金额,仅发送类型/备注/渠道/分类/标签;新增 AiCallType.RULE / STATISTICS - 收支记录页新增「刷新」按钮
This commit is contained in:
@@ -45,6 +45,9 @@ dependencies {
|
||||
// PDF 账单解析(招商银行「交易流水」PDF 为文本型,按字符坐标还原表格)
|
||||
implementation 'org.apache.pdfbox:pdfbox:3.0.3'
|
||||
|
||||
// OpenAI Java SDK:调用 DeepSeek 等 OpenAI 兼容接口(通过自定义 baseUrl 兼容)
|
||||
implementation 'com.openai:openai-java:4.65.0'
|
||||
|
||||
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
|
||||
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
|
||||
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
|
||||
|
||||
+75
-4
@@ -16,7 +16,7 @@ export interface AiNewTag {
|
||||
color: string
|
||||
}
|
||||
|
||||
/** 单条 AI 建议(按类型、备注、支付方式聚合后的一组记录) */
|
||||
/** 单条 AI 建议(按类型、备注、交易渠道聚合后的一组记录) */
|
||||
export interface AiSuggestion {
|
||||
transactionId: number
|
||||
transactionIds: number[]
|
||||
@@ -78,7 +78,7 @@ export function parseTagByAi(text: string) {
|
||||
}
|
||||
|
||||
/** 根据表单当前内容生成单条分类与标签建议(调用大模型,超时放宽) */
|
||||
export function suggestOneByAi(data: { type: string; amount?: number | null; paymentMethod?: string; remark?: string }) {
|
||||
export function suggestOneByAi(data: { type: string; amount?: number | null; channel?: string; remark?: string }) {
|
||||
return request.post('/ai/suggest-one', data, { timeout: 60000 })
|
||||
}
|
||||
|
||||
@@ -92,6 +92,77 @@ export function suggestColor(name: string) {
|
||||
return request.post('/ai/color', { name }, { timeout: 60000 })
|
||||
}
|
||||
|
||||
/** AI 生成的规则建议 */
|
||||
export interface AiRuleSuggestion {
|
||||
name: string
|
||||
type: string | null
|
||||
keyword: string | null
|
||||
minAmount: number | null
|
||||
maxAmount: number | null
|
||||
categoryId: number | null
|
||||
categoryName: string | null
|
||||
newCategoryName: string | null
|
||||
newCategoryIcon: string | null
|
||||
tagIds: number[]
|
||||
tagNames: string[]
|
||||
newTags: AiNewTag[]
|
||||
reason: string
|
||||
/** 是否与已有规则重复:重复时默认不勾选且禁止导入 */
|
||||
duplicate: boolean
|
||||
}
|
||||
|
||||
/** AI 生成规则的结果 */
|
||||
export interface AiRuleSuggestResult {
|
||||
summary: string
|
||||
rules: AiRuleSuggestion[]
|
||||
}
|
||||
|
||||
/** AI 统计查询解析结果 */
|
||||
export interface AiStatisticsResult {
|
||||
summary: string
|
||||
types: string[]
|
||||
categoryIds: number[]
|
||||
categoryNames: string[]
|
||||
tagIds: number[]
|
||||
tagNames: string[]
|
||||
channels: string[]
|
||||
startTime: string | null
|
||||
endTime: string | null
|
||||
minAmount: number | null
|
||||
maxAmount: number | null
|
||||
remark: string | null
|
||||
statsScope: 'INCLUDED' | 'ALL' | 'EXCLUDED' | null
|
||||
dimension: 'day' | 'week' | 'month' | 'quarter' | 'year' | null
|
||||
unmatched: string[]
|
||||
}
|
||||
|
||||
/** 将自然语言统计查询解析为统计面板参数(调用大模型,超时放宽) */
|
||||
export function parseStatisticsByAi(query: string) {
|
||||
return request.post('/ai/statistics', { query }, { timeout: 120000 })
|
||||
}
|
||||
|
||||
/** 根据选中的已打标签记录生成规则建议(调用大模型,超时放宽) */
|
||||
export function suggestRulesByAi(transactionIds: number[]) {
|
||||
return request.post('/ai/rule/suggest', { transactionIds }, { timeout: 120000 })
|
||||
}
|
||||
|
||||
/** 批量创建 AI 生成的规则,必要时新建分类与标签 */
|
||||
export function applyAiRules(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 })
|
||||
}
|
||||
|
||||
/** 一键应用建议:覆盖分类并追加标签,标记为新建的分类/标签会先创建再关联 */
|
||||
export function applyAiSuggestions(suggestions: {
|
||||
transactionId: number
|
||||
@@ -111,7 +182,7 @@ export interface AiQueryResult {
|
||||
categoryIds: number[]
|
||||
categoryNames: string[]
|
||||
uncategorized: boolean
|
||||
paymentMethods: string[]
|
||||
channels: string[]
|
||||
startTime: string | null
|
||||
endTime: string | null
|
||||
includeInStats: boolean | null
|
||||
@@ -135,7 +206,7 @@ export interface AiRecordResult {
|
||||
amount: number | null
|
||||
categoryId: number | null
|
||||
categoryName: string | null
|
||||
paymentMethod: string | null
|
||||
channel: string | null
|
||||
transactionTime: string | null
|
||||
remark: string | null
|
||||
includeInStats: boolean | null
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// 应用级配置接口:按账单来源的「计入统计」默认值
|
||||
import request from './request'
|
||||
|
||||
/** 查询按账单来源的「计入统计」默认值(key 为账单来源编码) */
|
||||
export function getBillSourceIncludeInStats() {
|
||||
return request.get('/app/bill-source-include-in-stats')
|
||||
}
|
||||
|
||||
/** 更新按账单来源的「计入统计」默认值 */
|
||||
export function updateBillSourceIncludeInStats(defaults: Record<string, boolean>) {
|
||||
return request.put('/app/bill-source-include-in-stats', { defaults })
|
||||
}
|
||||
@@ -39,11 +39,25 @@ export interface RulePayload {
|
||||
tagIds?: number[]
|
||||
}
|
||||
|
||||
/** 规则分页查询参数 */
|
||||
export interface RulePageParams {
|
||||
page?: number
|
||||
size?: number
|
||||
keyword?: string
|
||||
enabled?: boolean
|
||||
type?: string
|
||||
}
|
||||
|
||||
/** 获取规则列表 */
|
||||
export function getRuleList() {
|
||||
return request.get('/rule/list')
|
||||
}
|
||||
|
||||
/** 分页查询规则(按名称/关键词、启用状态、收支类型筛选) */
|
||||
export function getRulePage(params: RulePageParams) {
|
||||
return request.get('/rule/page', { params })
|
||||
}
|
||||
|
||||
/** 创建规则 */
|
||||
export function createRule(data: RulePayload) {
|
||||
return request.post('/rule', data)
|
||||
|
||||
@@ -23,8 +23,9 @@ export interface StatisticsFilterParams {
|
||||
types?: string[]
|
||||
categoryIds?: number[]
|
||||
uncategorized?: boolean
|
||||
tagIds?: number[]
|
||||
untagged?: boolean
|
||||
paymentMethods?: string[]
|
||||
channels?: string[]
|
||||
minAmount?: number
|
||||
maxAmount?: number
|
||||
remark?: string
|
||||
@@ -37,7 +38,7 @@ export function getStatisticsSummary(params: StatisticsFilterParams) {
|
||||
return request.get('/statistics/summary', { params })
|
||||
}
|
||||
|
||||
/** 按维度获取分组统计数据(dimension:day/week/month/quarter/year/paymentMethod/category/type) */
|
||||
/** 按维度获取分组统计数据(dimension:day/week/month/quarter/year/channel/category/type) */
|
||||
export function getStatisticsGroup(params: StatisticsFilterParams & { dimension: string }) {
|
||||
return request.get('/statistics/group', { params })
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
// 收支记录相关 API 接口:交易记录的增删改查(分页)
|
||||
import request from './request'
|
||||
|
||||
/** 获取系统支持的支付方式编码列表(含账单导入来源与人工支付方式) */
|
||||
export function getPaymentMethods() {
|
||||
return request.get('/transaction/payment-methods')
|
||||
/** 获取系统支持的交易渠道编码列表(含账单导入来源与人工交易渠道) */
|
||||
export function getChannels() {
|
||||
return request.get('/transaction/channels')
|
||||
}
|
||||
|
||||
/** 分页获取交易记录列表,支持按类型、分类、支付方式、时间范围、计入统计、金额区间多选筛选 */
|
||||
/** 分页获取交易记录列表,支持按类型、分类、交易渠道、时间范围、计入统计、金额区间多选筛选 */
|
||||
export function getTransactionPage(params: {
|
||||
page?: number
|
||||
size?: number
|
||||
types?: string[]
|
||||
categoryIds?: number[]
|
||||
uncategorized?: boolean
|
||||
paymentMethods?: string[]
|
||||
channels?: string[]
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
includeInStats?: boolean
|
||||
@@ -24,6 +24,7 @@ export function getTransactionPage(params: {
|
||||
sortOrder?: string
|
||||
tagIds?: number[]
|
||||
untagged?: boolean
|
||||
tagged?: boolean
|
||||
}) {
|
||||
return request.get('/transaction/page', { params })
|
||||
}
|
||||
@@ -33,7 +34,7 @@ export function createTransaction(data: {
|
||||
categoryId: number
|
||||
type: string
|
||||
amount: number
|
||||
paymentMethod?: string
|
||||
channel?: string
|
||||
remark?: string
|
||||
transactionTime: string
|
||||
includeInStats?: boolean
|
||||
@@ -47,7 +48,7 @@ export function updateTransaction(id: number, data: {
|
||||
categoryId: number | null
|
||||
type: string
|
||||
amount: number
|
||||
paymentMethod?: string
|
||||
channel?: string
|
||||
remark?: string
|
||||
transactionTime: string
|
||||
includeInStats?: boolean
|
||||
@@ -163,7 +164,7 @@ export function exportTransaction(params: {
|
||||
types?: string[]
|
||||
categoryIds?: number[]
|
||||
uncategorized?: boolean
|
||||
paymentMethods?: string[]
|
||||
channels?: string[]
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
includeInStats?: boolean
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
// 账单来源统计默认值面板:按账单来源配置导入时「是否计入统计」的默认值
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
useMessage, NCard, NSpace, NSwitch, NButton, NIcon, NText,
|
||||
} from 'naive-ui'
|
||||
import { OptionsOutline, SaveOutline } from '@vicons/ionicons5'
|
||||
import { getBillSourceIncludeInStats, updateBillSourceIncludeInStats } from '../api/app'
|
||||
import { getChannelLabel } from '../utils/channel'
|
||||
|
||||
const message = useMessage()
|
||||
const { t } = useI18n()
|
||||
|
||||
const defaults = ref<Record<string, boolean>>({})
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
/** 加载各账单来源的默认值并回显 */
|
||||
async function loadDefaults() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getBillSourceIncludeInStats()
|
||||
defaults.value = { ...(res.data || {}) }
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('billSourceStats.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存各账单来源的默认值 */
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const res: any = await updateBillSourceIncludeInStats(defaults.value)
|
||||
defaults.value = { ...(res.data || {}) }
|
||||
message.success(t('billSourceStats.saveSuccess'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('billSourceStats.saveFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadDefaults)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard :bordered="false" class="config-card">
|
||||
<template #header>
|
||||
<NSpace align="center" :size="8">
|
||||
<NIcon :component="OptionsOutline" />
|
||||
<span>{{ t('settings.billSourceStatsSection') }}</span>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<NText depth="3" style="font-size: 12px;">{{ t('billSourceStats.hint') }}</NText>
|
||||
|
||||
<div class="source-list">
|
||||
<div v-for="(value, code) in defaults" :key="code" class="source-row">
|
||||
<NSpace align="center" :size="12">
|
||||
<NSwitch v-model:value="defaults[code]" :disabled="loading" />
|
||||
<span>{{ getChannelLabel(String(code)) }}</span>
|
||||
<NText depth="3" style="font-size: 12px;">
|
||||
{{ value ? t('billSourceStats.included') : t('billSourceStats.excluded') }}
|
||||
</NText>
|
||||
</NSpace>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NSpace justify="end" style="margin-top: 16px; width: 100%;">
|
||||
<NButton type="primary" :loading="saving" @click="handleSave">
|
||||
<template #icon>
|
||||
<NIcon :component="SaveOutline" />
|
||||
</template>
|
||||
{{ t('billSourceStats.save') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.source-list {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -28,11 +28,12 @@ export default {
|
||||
deleteSuccess: { zh: '删除成功', en: 'Deleted successfully', ja: '削除しました' },
|
||||
deleteFailed: { zh: '删除失败', en: 'Failed to delete', ja: '削除に失敗しました' },
|
||||
confirmDelete: { zh: '确认删除', en: 'Confirm Deletion', ja: '削除の確認' },
|
||||
refresh: { zh: '刷新', en: 'Refresh', ja: '更新' },
|
||||
types: {
|
||||
expense: { zh: '支出', en: 'Expense', ja: '支出' },
|
||||
income: { zh: '收入', en: 'Income', ja: '収入' },
|
||||
},
|
||||
payment: {
|
||||
channel: {
|
||||
wechat: { zh: '微信', en: 'WeChat', ja: 'WeChat' },
|
||||
alipay: { zh: '支付宝', en: 'Alipay', ja: 'Alipay' },
|
||||
cash: { zh: '现金', en: 'Cash', ja: '現金' },
|
||||
@@ -135,9 +136,21 @@ export default {
|
||||
aiSection: { zh: 'AI 模型配置', en: 'AI Model', ja: 'AI モデル' },
|
||||
viewUsage: { zh: '使用情况', en: 'Usage', ja: '利用状況' },
|
||||
registrationSection: { zh: '注册设置', en: 'Registration', ja: '登録設定' },
|
||||
billSourceStatsSection: { zh: '账单来源统计默认值', en: 'Bill Source Stats Defaults', ja: '請求元の集計既定値' },
|
||||
notificationSection: { zh: '定时通知', en: 'Scheduled Notifications', ja: '定期通知' },
|
||||
},
|
||||
|
||||
// 账单来源统计默认值
|
||||
billSourceStats: {
|
||||
hint: { zh: '设置各账单来源导入时是否默认计入统计;不计入的记录仍会导入,但不参与统计', en: 'Set whether bills from each source count toward statistics by default. Excluded records are still imported but not counted.', ja: '各請求元の取り込み時に集計へ含めるかを設定します。除外した記録も取り込みますが集計対象外になります。' },
|
||||
included: { zh: '计入统计', en: 'Included', ja: '集計対象' },
|
||||
excluded: { zh: '不计入统计', en: 'Excluded', ja: '集計対象外' },
|
||||
save: { zh: '保存', en: 'Save', ja: '保存' },
|
||||
loadFailed: { zh: '加载账单来源统计默认值失败', en: 'Failed to load bill source stats defaults', ja: '請求元の集計既定値の読み込みに失敗しました' },
|
||||
saveSuccess: { zh: '账单来源统计默认值已保存', en: 'Bill source stats defaults saved', ja: '請求元の集計既定値を保存しました' },
|
||||
saveFailed: { zh: '保存账单来源统计默认值失败', en: 'Failed to save bill source stats defaults', ja: '請求元の集計既定値の保存に失敗しました' },
|
||||
},
|
||||
|
||||
// 注册设置
|
||||
registration: {
|
||||
statusOn: { zh: '已开启', en: 'Enabled', ja: '有効' },
|
||||
@@ -295,7 +308,16 @@ export default {
|
||||
},
|
||||
filterType: { zh: '类型', en: 'Type', ja: '種類' },
|
||||
filterCategory: { zh: '分类', en: 'Category', ja: 'カテゴリ' },
|
||||
filterPaymentMethod: { zh: '支付方式', en: 'Payment Method', ja: '支払方法' },
|
||||
filterChannel: { zh: '交易渠道', en: 'Channel', ja: '取引チャネル' },
|
||||
filterTag: { zh: '标签', en: 'Tag', ja: 'タグ' },
|
||||
aiSearchPlaceholder: { zh: '用一句话描述统计需求,如「上个月餐饮花了多少」', en: 'Describe what to analyze, e.g. "How much did I spend on dining last month?"', ja: '集計したい内容を文章で入力(例:先月の飲食費は?)' },
|
||||
aiSearchButton: { zh: 'AI 搜索', en: 'AI Search', ja: 'AI 検索' },
|
||||
aiSearchEmpty: { zh: '请输入统计需求', en: 'Please enter a query', ja: '検索内容を入力してください' },
|
||||
aiSearchApplied: { zh: '已按 AI 解析结果更新统计', en: 'Statistics updated from AI parsing', ja: 'AI の解析結果で統計を更新しました' },
|
||||
aiSearchFailed: { zh: 'AI 统计搜索失败', en: 'AI statistics search failed', ja: 'AI 統計検索に失敗しました' },
|
||||
aiExample1: { zh: '上个月餐饮花了多少', en: 'How much on dining last month?', ja: '先月の飲食費は?' },
|
||||
aiExample2: { zh: '近半年交通支出趋势,按周看', en: 'Transport spending trend over the last 6 months, weekly', ja: '直近半年の交通費の推移を週別で' },
|
||||
aiExample3: { zh: '今年各类支出占比', en: 'Expense breakdown by category this year', ja: '今年のカテゴリ別支出割合' },
|
||||
minAmount: { zh: '最小金额', en: 'Min Amount', ja: '最小金額' },
|
||||
maxAmount: { zh: '最大金额', en: 'Max Amount', ja: '最大金額' },
|
||||
remark: { zh: '备注搜索', en: 'Search Remark', ja: '備考検索' },
|
||||
@@ -311,8 +333,8 @@ export default {
|
||||
trend: { zh: '收支趋势', en: 'Income & Expense Trend', ja: '収支の推移' },
|
||||
expenseCategoryPie: { zh: '支出分类占比', en: 'Expense by Category', ja: '支出カテゴリの割合' },
|
||||
incomeCategoryPie: { zh: '收入分类占比', en: 'Income by Category', ja: '収入カテゴリの割合' },
|
||||
expensePaymentPie: { zh: '支出支付方式占比', en: 'Expense by Payment Method', ja: '支出の支払方法の割合' },
|
||||
incomePaymentPie: { zh: '收入支付方式占比', en: 'Income by Payment Method', ja: '収入の支払方法の割合' },
|
||||
expenseChannelPie: { zh: '支出交易渠道占比', en: 'Expense by Channel', ja: '支出の取引チャネル割合' },
|
||||
incomeChannelPie: { zh: '收入交易渠道占比', en: 'Income by Channel', ja: '収入の取引チャネル割合' },
|
||||
uncategorized: { zh: '未分类', en: 'Uncategorized', ja: '未分類' },
|
||||
},
|
||||
|
||||
@@ -349,7 +371,7 @@ export default {
|
||||
type: { zh: '类型', en: 'Type', ja: '種類' },
|
||||
category: { zh: '分类', en: 'Category', ja: 'カテゴリ' },
|
||||
amount: { zh: '金额', en: 'Amount', ja: '金額' },
|
||||
paymentMethod: { zh: '支付方式', en: 'Payment', ja: '支払方法' },
|
||||
channel: { zh: '交易渠道', en: 'Channel', ja: '取引チャネル' },
|
||||
remark: { zh: '备注', en: 'Remark', ja: '備考' },
|
||||
tags: { zh: '标签', en: 'Tags', ja: 'タグ' },
|
||||
includeInStats: { zh: '计入统计', en: 'In Stats', ja: '集計対象' },
|
||||
@@ -391,7 +413,7 @@ export default {
|
||||
validAmount: { zh: '请输入有效金额', en: 'Please enter a valid amount', ja: '有効な金額を入力してください' },
|
||||
selectCategoryPlaceholder: { zh: '请选择分类', en: 'Select a category', ja: 'カテゴリを選択' },
|
||||
amountPlaceholder: { zh: '请输入金额', en: 'Enter amount', ja: '金額を入力してください' },
|
||||
paymentPlaceholder: { zh: '选择支付方式', en: 'Select payment method', ja: '支払方法を選択' },
|
||||
channelPlaceholder: { zh: '选择交易渠道', en: 'Select channel', ja: '取引チャネルを選択' },
|
||||
remarkPlaceholder: { zh: '可选备注说明', en: 'Optional remark', ja: '備考(任意)' },
|
||||
remarkHistoryClear: { zh: '清空历史', en: 'Clear history', ja: '履歴をクリア' },
|
||||
tagPlaceholder: { zh: '选择标签', en: 'Select tags', ja: 'タグを選択' },
|
||||
@@ -480,6 +502,10 @@ export default {
|
||||
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: 'タグの読み込みに失敗しました' },
|
||||
searchPlaceholder: { zh: '搜索规则名称或关键词', en: 'Search name or keyword', ja: 'ルール名またはキーワードを検索' },
|
||||
status: { zh: '启用状态', en: 'Status', ja: '有効状態' },
|
||||
statusOn: { zh: '已启用', en: 'Enabled', ja: '有効' },
|
||||
statusOff: { zh: '已停用', en: 'Disabled', ja: '無効' },
|
||||
},
|
||||
|
||||
// 账单导入
|
||||
@@ -601,6 +627,28 @@ export default {
|
||||
nameRequired: { zh: '请先输入名称', en: 'Please enter a name first', ja: '先に名前を入力してください' },
|
||||
failed: { zh: 'AI 推荐失败', en: 'AI recommendation failed', ja: 'AI の提案に失敗しました' },
|
||||
},
|
||||
rule: {
|
||||
entry: { zh: 'AI 生成规则', en: 'AI Generate Rules', ja: 'AI でルール生成' },
|
||||
title: { zh: 'AI 生成规则建议', en: 'AI Rule Suggestions', ja: 'AI ルール提案' },
|
||||
hint: { zh: 'AI 将依据所选记录的已有标签与分类,生成分类与标签规则建议,可编辑后勾选创建', en: 'AI derives rules from the tags and categories of the selected records; edit and select to create.', ja: 'AI が選択した記録の既存タグとカテゴリからルールを提案します。編集して選択後に作成できます。' },
|
||||
selectHint: { zh: '选择要用于生成规则的已打标签记录(默认全选当前页,已选 {count} 条)', en: 'Select tagged records to derive rules (current page selected by default, {count} selected)', ja: 'ルール生成に使うタグ付き記録を選択(既定で現在ページを全選択、選択中 {count} 件)' },
|
||||
searchPlaceholder: { zh: '搜索备注', en: 'Search remark', ja: '備考を検索' },
|
||||
selectPageAll: { zh: '全选本页', en: 'Select page', ja: 'このページを全選択' },
|
||||
clearSelection: { zh: '清空选择', en: 'Clear selection', ja: '選択をクリア' },
|
||||
generate: { zh: '生成规则', en: 'Generate rules', ja: 'ルールを生成' },
|
||||
back: { zh: '返回上一步', en: 'Back', ja: '戻る' },
|
||||
noTagged: { zh: '暂无可用的已打标签记录', en: 'No tagged records available', ja: 'タグ付きの記録がありません' },
|
||||
loadRecordsFailed: { zh: '加载已打标签记录失败', en: 'Failed to load tagged records', ja: 'タグ付き記録の読み込みに失敗しました' },
|
||||
reason: { zh: '理由', en: 'Reason', ja: '理由' },
|
||||
newPrefix: { zh: '新建:', en: 'New: ', ja: '新規:' },
|
||||
apply: { zh: '创建勾选规则', en: 'Create selected', ja: '選択したルールを作成' },
|
||||
selectRequired: { zh: '请至少勾选一条记录或规则', en: 'Please select at least one record or rule', ja: '少なくとも1件の記録またはルールを選択してください' },
|
||||
applied: { zh: '已创建 {count} 条规则', en: 'Created {count} rule(s)', ja: '{count} 件のルールを作成しました' },
|
||||
failed: { zh: 'AI 规则生成失败', en: 'Failed to generate AI rules', ja: 'AI ルールの生成に失敗しました' },
|
||||
applyFailed: { zh: '创建规则失败', en: 'Failed to create rules', ja: 'ルールの作成に失敗しました' },
|
||||
duplicate: { zh: '已存在', en: 'Exists', ja: '既存' },
|
||||
allDuplicate: { zh: '生成的规则均已存在,无需创建', en: 'All generated rules already exist', ja: '生成されたルールはすべて既存です' },
|
||||
},
|
||||
category: {
|
||||
label: { zh: 'AI 生成分类', en: 'AI Category', ja: 'AI 生成カテゴリ' },
|
||||
placeholder: { zh: '描述用途,如:日常通勤打车地铁', en: 'Describe it, e.g. daily commute by taxi and metro', ja: '用途を説明してください(例:日常の通勤でタクシーと地下鉄)' },
|
||||
@@ -643,7 +691,7 @@ export default {
|
||||
type: { zh: '类型', en: 'Type', ja: '種類' },
|
||||
category: { zh: '分类', en: 'Category', ja: 'カテゴリ' },
|
||||
tag: { zh: '标签', en: 'Tag', ja: 'タグ' },
|
||||
payment: { zh: '支付方式', en: 'Payment', ja: '支払方法' },
|
||||
channel: { zh: '交易渠道', en: 'Channel', ja: '取引チャネル' },
|
||||
dateRange: { zh: '时间范围', en: 'Date range', ja: '期間' },
|
||||
amountRange: { zh: '金额范围', en: 'Amount range', ja: '金額範囲' },
|
||||
remark: { zh: '备注', en: 'Remark', ja: '備考' },
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// 支付方式相关工具:将后端返回的支付方式编码映射为当前语言标签
|
||||
// 交易渠道相关工具:将后端返回的交易渠道编码映射为当前语言标签
|
||||
import i18n from '../locales'
|
||||
|
||||
/** i18n 全局实例的翻译函数签名(避免动态 key 触发过深的类型推断) */
|
||||
type Translate = (key: string) => string
|
||||
type HasKey = (key: string) => boolean
|
||||
|
||||
/** 将支付方式编码转换为当前语言的标签,无对应文案时回退为原始编码 */
|
||||
export function getPaymentLabel(val: string): string {
|
||||
/** 将交易渠道编码转换为当前语言的标签,无对应文案时回退为原始编码 */
|
||||
export function getChannelLabel(val: string): string {
|
||||
if (!val) return '-'
|
||||
const { t, te } = i18n.global as unknown as { t: Translate; te: HasKey }
|
||||
const key = `common.payment.${val.toLowerCase()}`
|
||||
const key = `common.channel.${val.toLowerCase()}`
|
||||
return te(key) ? t(key) : val
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
// 统计面板组件:展示指定时间范围的收支汇总、概览指标、趋势、分类占比与支付方式分布
|
||||
// 统计面板组件:展示指定时间范围的收支汇总、概览指标、趋势、分类占比与交易渠道分布
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect, NDatePicker, NInput, NInputNumber, NButton } from 'naive-ui'
|
||||
import { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect, NDatePicker, NInput, NInputNumber, NButton, NAutoComplete, NModal, NAlert, NDropdown, NIcon, useMessage } from 'naive-ui'
|
||||
import { SparklesOutline, BulbOutline } from '@vicons/ionicons5'
|
||||
import VChart from 'vue-echarts'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
@@ -10,14 +11,19 @@ import { LineChart, PieChart, BarChart } from 'echarts/charts'
|
||||
import { TitleComponent, TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
|
||||
import { getStatisticsSummary, getStatisticsGroup } from '../api/statistics'
|
||||
import { getCategoryList } from '../api/category'
|
||||
import { getPaymentMethods } from '../api/transaction'
|
||||
import { getPaymentLabel } from '../utils/payment'
|
||||
import { getTagList } from '../api/tag'
|
||||
import { getChannels } from '../api/transaction'
|
||||
import { getAiConfig, parseStatisticsByAi } from '../api/ai'
|
||||
import type { AiStatisticsResult } from '../api/ai'
|
||||
import { useKeywordHistory } from '../composables/useKeywordHistory'
|
||||
import { getChannelLabel } from '../utils/channel'
|
||||
import { renderCategoryLabel } from '../utils/render'
|
||||
|
||||
// 注册 ECharts 所需组件
|
||||
use([CanvasRenderer, LineChart, PieChart, BarChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
|
||||
|
||||
const { t, locale } = useI18n() // 国际化
|
||||
const message = useMessage() // 消息提示
|
||||
|
||||
// 时间范围快捷选项
|
||||
const rangeOptions = computed(() => [
|
||||
@@ -43,30 +49,60 @@ const granularity = ref<'day' | 'week' | 'month' | 'quarter' | 'year'>('day') //
|
||||
const filterStatsScope = ref<'INCLUDED' | 'ALL' | 'EXCLUDED'>('INCLUDED') // 统计口径
|
||||
const filterType = ref<string[]>([]) // 类型(多选)
|
||||
const filterCategory = ref<number[]>([]) // 分类(多选,0 表示未分类)
|
||||
const filterPaymentMethod = ref<string[]>([]) // 支付方式(多选)
|
||||
const filterChannel = ref<string[]>([]) // 交易渠道(多选)
|
||||
const filterTag = ref<number[]>([]) // 标签(多选)
|
||||
const filterMinAmount = ref<number | null>(null) // 最小金额
|
||||
const filterMaxAmount = ref<number | null>(null) // 最大金额
|
||||
const filterRemark = ref('') // 备注关键字
|
||||
|
||||
// AI 统计搜索状态
|
||||
const aiEnabled = ref(false)
|
||||
const aiQueryText = ref('')
|
||||
const aiQuerying = ref(false)
|
||||
const aiSearchResult = ref<AiStatisticsResult | null>(null)
|
||||
const showAiSearchModal = ref(false)
|
||||
|
||||
// AI 搜索示例(下拉选择后填入输入框)
|
||||
const aiExampleOptions = computed(() => [
|
||||
{ label: t('dashboard.aiExample1'), key: t('dashboard.aiExample1') },
|
||||
{ label: t('dashboard.aiExample2'), key: t('dashboard.aiExample2') },
|
||||
{ label: t('dashboard.aiExample3'), key: t('dashboard.aiExample3') },
|
||||
])
|
||||
|
||||
// AI 搜索历史记录(与账单列表一致:持久化、聚焦展示、可清空)
|
||||
const aiSearchHistory = useKeywordHistory({
|
||||
storageKey: 'finance.dashboard.aiSearchHistory',
|
||||
text: aiQueryText,
|
||||
clearLabel: () => t('ai.query.clearHistory'),
|
||||
})
|
||||
|
||||
// 统计口径选项:仅计入 / 全部 / 仅不计入
|
||||
const statsScopeOptions = computed(() => [
|
||||
{ label: t('dashboard.statsScope.included'), value: 'INCLUDED' },
|
||||
{ label: t('dashboard.statsScope.all'), value: 'ALL' },
|
||||
{ label: t('dashboard.statsScope.excluded'), value: 'EXCLUDED' },
|
||||
])
|
||||
// 类型与支付方式选项
|
||||
// 类型与交易渠道选项
|
||||
const typeOptions = computed(() => [
|
||||
{ label: t('common.types.expense'), value: 'EXPENSE' },
|
||||
{ label: t('common.types.income'), value: 'INCOME' },
|
||||
])
|
||||
// 支付方式编码列表(从后端动态获取,含账单导入来源与人工支付方式)
|
||||
const paymentMethodCodes = ref<string[]>([])
|
||||
// 支付方式选项:由后端返回的编码映射为当前语言标签,locale 变化时重新生成
|
||||
const paymentOptions = computed(() => {
|
||||
// 交易渠道编码列表(从后端动态获取,含账单导入来源与人工交易渠道)
|
||||
const channelCodes = ref<string[]>([])
|
||||
// 交易渠道选项:由后端返回的编码映射为当前语言标签,locale 变化时重新生成
|
||||
const channelOptions = computed(() => {
|
||||
void locale.value
|
||||
return paymentMethodCodes.value.map((code) => ({ label: getPaymentLabel(code), value: code }))
|
||||
return channelCodes.value.map((code) => ({ label: getChannelLabel(code), value: code }))
|
||||
})
|
||||
|
||||
/** 标签选项 */
|
||||
interface TagOption {
|
||||
label: string
|
||||
value: number
|
||||
}
|
||||
const tags = ref<TagOption[]>([])
|
||||
const tagOptions = computed(() => tags.value)
|
||||
|
||||
/** 分类选项数据结构 */
|
||||
interface CategoryOption {
|
||||
label: string
|
||||
@@ -96,7 +132,8 @@ const filterParams = computed(() => ({
|
||||
statsScope: filterStatsScope.value,
|
||||
types: filterType.value.length > 0 ? filterType.value : undefined,
|
||||
...buildCategoryFilter(),
|
||||
paymentMethods: filterPaymentMethod.value.length > 0 ? filterPaymentMethod.value : undefined,
|
||||
channels: filterChannel.value.length > 0 ? filterChannel.value : undefined,
|
||||
tagIds: filterTag.value.length > 0 ? filterTag.value : undefined,
|
||||
minAmount: filterMinAmount.value ?? undefined,
|
||||
maxAmount: filterMaxAmount.value ?? undefined,
|
||||
remark: filterRemark.value.trim() || undefined,
|
||||
@@ -110,12 +147,12 @@ const summary = ref<any>({
|
||||
})
|
||||
const trendData = ref<any[]>([]) // 趋势分组数据
|
||||
const categoryData = ref<any[]>([]) // 分类分组数据
|
||||
const paymentData = ref<any[]>([]) // 支付方式分组数据
|
||||
const channelData = ref<any[]>([]) // 交易渠道分组数据
|
||||
|
||||
const pieOption = ref({}) // 支出分类饼图
|
||||
const incomePieOption = ref({}) // 收入分类饼图
|
||||
const paymentOption = ref({}) // 支出支付方式饼图
|
||||
const incomePaymentOption = ref({}) // 收入支付方式饼图
|
||||
const channelOption = ref({}) // 支出交易渠道饼图
|
||||
const incomeChannelOption = ref({}) // 收入交易渠道饼图
|
||||
const trendOption = ref({}) // 趋势柱状图
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
@@ -220,35 +257,35 @@ function updateIncomePieChart() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新支出支付方式占比饼图:仅统计支出,标签按当前语言本地化 */
|
||||
function updatePaymentChart() {
|
||||
paymentOption.value = {
|
||||
title: { text: t('dashboard.expensePaymentPie'), left: 'center' },
|
||||
/** 更新支出交易渠道占比饼图:仅统计支出,标签按当前语言本地化 */
|
||||
function updateChannelChart() {
|
||||
channelOption.value = {
|
||||
title: { text: t('dashboard.expenseChannelPie'), left: 'center' },
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
data: paymentData.value
|
||||
data: channelData.value
|
||||
.filter((p: any) => Number(p.expense) > 0)
|
||||
.map((p: any) => ({ name: getPaymentLabel(p.key), value: Number(p.expense) })),
|
||||
.map((p: any) => ({ name: getChannelLabel(p.key), value: Number(p.expense) })),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新收入支付方式占比饼图:仅统计收入,标签按当前语言本地化 */
|
||||
function updateIncomePaymentChart() {
|
||||
incomePaymentOption.value = {
|
||||
title: { text: t('dashboard.incomePaymentPie'), left: 'center' },
|
||||
/** 更新收入交易渠道占比饼图:仅统计收入,标签按当前语言本地化 */
|
||||
function updateIncomeChannelChart() {
|
||||
incomeChannelOption.value = {
|
||||
title: { text: t('dashboard.incomeChannelPie'), left: 'center' },
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
data: paymentData.value
|
||||
data: channelData.value
|
||||
.filter((p: any) => Number(p.income) > 0)
|
||||
.map((p: any) => ({ name: getPaymentLabel(p.key), value: Number(p.income) })),
|
||||
.map((p: any) => ({ name: getChannelLabel(p.key), value: Number(p.income) })),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -283,20 +320,20 @@ async function fetchData() {
|
||||
const { startTime, endTime } = range.value
|
||||
const filters = filterParams.value
|
||||
try {
|
||||
const [summaryRes, trendRes, categoryRes, paymentRes]: any[] = await Promise.all([
|
||||
const [summaryRes, trendRes, categoryRes, channelRes]: any[] = await Promise.all([
|
||||
getStatisticsSummary({ startTime, endTime, ...filters }),
|
||||
getStatisticsGroup({ dimension: granularity.value, startTime, endTime, ...filters }),
|
||||
getStatisticsGroup({ dimension: 'category', startTime, endTime, ...filters }),
|
||||
getStatisticsGroup({ dimension: 'paymentMethod', startTime, endTime, ...filters }),
|
||||
getStatisticsGroup({ dimension: 'channel', startTime, endTime, ...filters }),
|
||||
])
|
||||
summary.value = summaryRes.data
|
||||
trendData.value = trendRes.data
|
||||
categoryData.value = categoryRes.data
|
||||
paymentData.value = paymentRes.data
|
||||
channelData.value = channelRes.data
|
||||
updatePieChart()
|
||||
updateIncomePieChart()
|
||||
updatePaymentChart()
|
||||
updateIncomePaymentChart()
|
||||
updateChannelChart()
|
||||
updateIncomeChannelChart()
|
||||
updateTrendChart()
|
||||
} catch (e: any) {
|
||||
console.error('加载统计数据失败', e)
|
||||
@@ -319,7 +356,10 @@ function loadPrefs() {
|
||||
if (['INCLUDED', 'ALL', 'EXCLUDED'].includes(p.filterStatsScope)) filterStatsScope.value = p.filterStatsScope
|
||||
if (Array.isArray(p.filterType)) filterType.value = p.filterType
|
||||
if (Array.isArray(p.filterCategory)) filterCategory.value = p.filterCategory
|
||||
if (Array.isArray(p.filterPaymentMethod)) filterPaymentMethod.value = p.filterPaymentMethod
|
||||
// 兼容旧版本以 filterPaymentMethod 存储的偏好
|
||||
const savedChannel = Array.isArray(p.filterChannel) ? p.filterChannel : p.filterPaymentMethod
|
||||
if (Array.isArray(savedChannel)) filterChannel.value = savedChannel
|
||||
if (Array.isArray(p.filterTag)) filterTag.value = p.filterTag
|
||||
if (typeof p.filterMinAmount === 'number') filterMinAmount.value = p.filterMinAmount
|
||||
if (typeof p.filterMaxAmount === 'number') filterMaxAmount.value = p.filterMaxAmount
|
||||
if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark
|
||||
@@ -339,7 +379,8 @@ function savePrefs() {
|
||||
filterStatsScope: filterStatsScope.value,
|
||||
filterType: filterType.value,
|
||||
filterCategory: filterCategory.value,
|
||||
filterPaymentMethod: filterPaymentMethod.value,
|
||||
filterChannel: filterChannel.value,
|
||||
filterTag: filterTag.value,
|
||||
filterMinAmount: filterMinAmount.value,
|
||||
filterMaxAmount: filterMaxAmount.value,
|
||||
filterRemark: filterRemark.value,
|
||||
@@ -359,22 +400,149 @@ async function fetchCategories() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 从后端加载支付方式列表,供筛选下拉使用 */
|
||||
async function loadPaymentMethods() {
|
||||
/** 从后端加载交易渠道列表,供筛选下拉使用 */
|
||||
async function loadChannels() {
|
||||
try {
|
||||
const res: any = await getPaymentMethods()
|
||||
paymentMethodCodes.value = res.data || []
|
||||
const res: any = await getChannels()
|
||||
channelCodes.value = res.data || []
|
||||
} catch (e) {
|
||||
// 静默处理,支付方式下拉保持为空
|
||||
// 静默处理,交易渠道下拉保持为空
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载标签选项,供筛选下拉与 AI 统计使用 */
|
||||
async function loadTags() {
|
||||
try {
|
||||
const res: any = await getTagList()
|
||||
tags.value = (res.data || []).map((tag: any) => ({ label: tag.name, value: tag.id }))
|
||||
} catch (e) {
|
||||
// 静默处理
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取 AI 启用状态,未启用时不渲染 AI 搜索框 */
|
||||
async function loadAiConfig() {
|
||||
try {
|
||||
const res: any = await getAiConfig()
|
||||
aiEnabled.value = !!res.data?.enabled
|
||||
} catch (e) {
|
||||
aiEnabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 将 AI 解析结果应用到统计面板筛选条件与粒度 */
|
||||
async function applyAiStatistics(result: AiStatisticsResult) {
|
||||
if (result.startTime && result.endTime) {
|
||||
suppressRangeWatch = true
|
||||
rangeType.value = 'custom'
|
||||
customRange.value = [
|
||||
new Date(result.startTime.replace('T', ' ').replace(/-/g, '/')).getTime(),
|
||||
new Date(result.endTime.replace('T', ' ').replace(/-/g, '/')).getTime(),
|
||||
]
|
||||
}
|
||||
if (result.dimension) {
|
||||
granularity.value = result.dimension
|
||||
}
|
||||
if (result.statsScope) {
|
||||
filterStatsScope.value = result.statsScope
|
||||
}
|
||||
filterType.value = result.types || []
|
||||
filterCategory.value = result.categoryIds || []
|
||||
filterChannel.value = result.channels || []
|
||||
filterTag.value = result.tagIds || []
|
||||
filterMinAmount.value = result.minAmount
|
||||
filterMaxAmount.value = result.maxAmount
|
||||
filterRemark.value = result.remark || ''
|
||||
await nextTick()
|
||||
suppressRangeWatch = false
|
||||
}
|
||||
|
||||
/** 解析结果是否为空(无任何可用条件) */
|
||||
const aiSearchEmpty = computed(() => {
|
||||
const r = aiSearchResult.value
|
||||
if (!r) return true
|
||||
return (r.types?.length ?? 0) === 0
|
||||
&& (r.categoryIds?.length ?? 0) === 0
|
||||
&& (r.tagIds?.length ?? 0) === 0
|
||||
&& (r.channels?.length ?? 0) === 0
|
||||
&& !r.startTime
|
||||
&& r.minAmount == null
|
||||
&& r.maxAmount == null
|
||||
&& !r.remark
|
||||
&& !r.statsScope
|
||||
&& !r.dimension
|
||||
})
|
||||
|
||||
/** 打开 AI 搜索弹窗 */
|
||||
function handleAiSearchOpen() {
|
||||
if (!aiEnabled.value) {
|
||||
message.warning(t('ai.needConfig'))
|
||||
return
|
||||
}
|
||||
showAiSearchModal.value = true
|
||||
}
|
||||
|
||||
/** 提交自然语言统计搜索 */
|
||||
async function handleAiStatistics() {
|
||||
if (!aiEnabled.value) {
|
||||
message.warning(t('ai.needConfig'))
|
||||
return
|
||||
}
|
||||
const text = aiQueryText.value.trim()
|
||||
if (!text) {
|
||||
message.warning(t('ai.query.emptyInput'))
|
||||
return
|
||||
}
|
||||
aiSearchHistory.record()
|
||||
aiQuerying.value = true
|
||||
try {
|
||||
const res: any = await parseStatisticsByAi(text)
|
||||
aiSearchResult.value = res.data
|
||||
// 无任何可用条件时提示,避免误清空当前筛选与时间范围
|
||||
if (aiSearchEmpty.value) {
|
||||
message.warning(t('ai.query.noCondition'))
|
||||
return
|
||||
}
|
||||
await applyAiStatistics(aiSearchResult.value as AiStatisticsResult)
|
||||
showAiSearchModal.value = false
|
||||
message.success(aiSearchResult.value?.summary || t('dashboard.aiSearchApplied'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('dashboard.aiSearchFailed'))
|
||||
} finally {
|
||||
aiQuerying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 选中历史项:清空历史,或填入并立即查询 */
|
||||
function handleAiSearchSelect(value: string) {
|
||||
if (value === aiSearchHistory.clearValue) {
|
||||
aiSearchHistory.clear()
|
||||
return
|
||||
}
|
||||
aiQueryText.value = value
|
||||
handleAiStatistics()
|
||||
}
|
||||
|
||||
/** 回车时发起查询(阻止 NAutoComplete 内部选中建议项) */
|
||||
function handleAiSearchKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation()
|
||||
handleAiStatistics()
|
||||
}
|
||||
}
|
||||
|
||||
/** 选择示例后填入输入框 */
|
||||
function handleExampleSelect(key: string) {
|
||||
aiQueryText.value = key
|
||||
}
|
||||
|
||||
/** 重置所有过滤条件(保留时间范围与粒度) */
|
||||
function handleResetFilters() {
|
||||
filterStatsScope.value = 'INCLUDED'
|
||||
filterType.value = []
|
||||
filterCategory.value = []
|
||||
filterPaymentMethod.value = []
|
||||
filterChannel.value = []
|
||||
filterTag.value = []
|
||||
filterMinAmount.value = null
|
||||
filterMaxAmount.value = null
|
||||
filterRemark.value = ''
|
||||
@@ -391,20 +559,23 @@ watch(rangeType, (v) => {
|
||||
// 范围、粒度或过滤条件变化时重新加载
|
||||
watch([() => range.value.startTime, () => range.value.endTime, granularity, filterParams], fetchData)
|
||||
// 任一偏好变化时自动保存
|
||||
watch([rangeType, customRange, granularity, filterStatsScope, filterType, filterCategory, filterPaymentMethod, filterMinAmount, filterMaxAmount, filterRemark], savePrefs, { deep: true })
|
||||
watch([rangeType, customRange, granularity, filterStatsScope, filterType, filterCategory, filterChannel, filterTag, filterMinAmount, filterMaxAmount, filterRemark], savePrefs, { deep: true })
|
||||
// 语言切换时重绘图表,更新标题与图例文案
|
||||
watch(locale, () => {
|
||||
updatePieChart()
|
||||
updateIncomePieChart()
|
||||
updatePaymentChart()
|
||||
updateIncomePaymentChart()
|
||||
updateChannelChart()
|
||||
updateIncomeChannelChart()
|
||||
updateTrendChart()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
loadPrefs()
|
||||
fetchCategories()
|
||||
loadPaymentMethods()
|
||||
loadChannels()
|
||||
loadTags()
|
||||
loadAiConfig()
|
||||
aiSearchHistory.load()
|
||||
await nextTick()
|
||||
suppressRangeWatch = false
|
||||
fetchData()
|
||||
@@ -426,6 +597,12 @@ onMounted(async () => {
|
||||
style="width: 260px;"
|
||||
/>
|
||||
<span style="color: #999; font-size: 12px;">{{ range.startTime.substring(0, 10) }} ~ {{ range.endTime.substring(0, 10) }}</span>
|
||||
<NButton v-if="aiEnabled" @click="handleAiSearchOpen">
|
||||
<template #icon>
|
||||
<NIcon :component="SparklesOutline" />
|
||||
</template>
|
||||
{{ t('ai.query.entry') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
@@ -459,15 +636,25 @@ onMounted(async () => {
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="filterPaymentMethod"
|
||||
:options="paymentOptions"
|
||||
v-model:value="filterChannel"
|
||||
:options="channelOptions"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
:max-tag-count="2"
|
||||
:placeholder="t('dashboard.filterPaymentMethod')"
|
||||
:placeholder="t('dashboard.filterChannel')"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="filterTag"
|
||||
:options="tagOptions"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
:max-tag-count="2"
|
||||
:placeholder="t('dashboard.filterTag')"
|
||||
style="width: 180px;"
|
||||
/>
|
||||
<NInputNumber
|
||||
v-model:value="filterMinAmount"
|
||||
:min="0"
|
||||
@@ -562,7 +749,7 @@ onMounted(async () => {
|
||||
<VChart :option="trendOption" style="height: 350px;" autoresize />
|
||||
</NCard>
|
||||
|
||||
<!-- 饼图区域:支出/收入分类占比 + 支出/收入支付方式占比 -->
|
||||
<!-- 饼图区域:支出/收入分类占比 + 支出/收入交易渠道占比 -->
|
||||
<NGrid :cols="2" :x-gap="16" :y-gap="16">
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
@@ -576,14 +763,57 @@ onMounted(async () => {
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<VChart :option="paymentOption" style="height: 350px;" autoresize />
|
||||
<VChart :option="channelOption" style="height: 350px;" autoresize />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<VChart :option="incomePaymentOption" style="height: 350px;" autoresize />
|
||||
<VChart :option="incomeChannelOption" style="height: 350px;" autoresize />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
|
||||
<!-- AI 统计搜索弹窗(交互与账单列表 AI 查询一致) -->
|
||||
<NModal
|
||||
v-if="aiEnabled"
|
||||
v-model:show="showAiSearchModal"
|
||||
preset="card"
|
||||
:title="t('ai.query.title')"
|
||||
style="width: 520px; max-width: 92vw;"
|
||||
>
|
||||
<NSpace vertical :size="12">
|
||||
<NAlert type="info" :show-icon="true">{{ t('ai.query.hint') }}</NAlert>
|
||||
<NAutoComplete
|
||||
:value="aiQueryText"
|
||||
:options="aiSearchHistory.options.value"
|
||||
:get-show="aiSearchHistory.showMenu"
|
||||
:render-label="aiSearchHistory.renderLabel"
|
||||
:input-props="{ onKeydown: handleAiSearchKeydown }"
|
||||
clearable
|
||||
:placeholder="t('dashboard.aiSearchPlaceholder')"
|
||||
style="width: 100%;"
|
||||
@update:value="aiSearchHistory.handleInput"
|
||||
@select="handleAiSearchSelect"
|
||||
>
|
||||
<template #prefix>
|
||||
<NIcon :component="SparklesOutline" />
|
||||
</template>
|
||||
</NAutoComplete>
|
||||
<NSpace justify="space-between" align="center">
|
||||
<NDropdown trigger="click" :options="aiExampleOptions" @select="handleExampleSelect">
|
||||
<NButton text type="primary" size="small">
|
||||
<template #icon>
|
||||
<NIcon :component="BulbOutline" />
|
||||
</template>
|
||||
{{ t('transaction.exampleLabel') }}
|
||||
</NButton>
|
||||
</NDropdown>
|
||||
<NSpace>
|
||||
<NButton @click="showAiSearchModal = false">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="aiQuerying" @click="handleAiStatistics">{{ t('ai.query.button') }}</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,13 +4,17 @@ 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,
|
||||
NInput, NInputNumber, NSelect, NSwitch, NTag, NText, NCard,
|
||||
} from 'naive-ui'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { getRuleList, createRule, updateRule, deleteRule, toggleRuleEnabled } from '../api/rule'
|
||||
import { getRulePage, createRule, updateRule, deleteRule, toggleRuleEnabled } from '../api/rule'
|
||||
import type { RuleItem } from '../api/rule'
|
||||
import { getCategoryList } from '../api/category'
|
||||
import { getTagList } from '../api/tag'
|
||||
import { getTransactionPage } from '../api/transaction'
|
||||
import { getAiConfig, suggestRulesByAi, applyAiRules } from '../api/ai'
|
||||
import type { AiRuleSuggestion } from '../api/ai'
|
||||
import { getChannelLabel } from '../utils/channel'
|
||||
import { renderCategoryLabel } from '../utils/render'
|
||||
|
||||
const message = useMessage() // 消息提示
|
||||
@@ -41,6 +45,46 @@ const submitting = ref(false)
|
||||
const categories = ref<CategoryOption[]>([])
|
||||
const tags = ref<TagOption[]>([])
|
||||
|
||||
// 规则列表分页与筛选
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const searchKeyword = ref('')
|
||||
const filterEnabled = ref<string | null>(null) // 'ON' | 'OFF' | null
|
||||
const filterType = ref<string | null>(null)
|
||||
|
||||
/** AI 规则建议行(带本地 key 供表格选择使用) */
|
||||
type AiRuleRow = AiRuleSuggestion & { key: number }
|
||||
|
||||
/** 已打标签的候选记录 */
|
||||
interface CandidateRow {
|
||||
id: number
|
||||
type: string
|
||||
remark: string
|
||||
channel: string
|
||||
categoryName: string | null
|
||||
tags: { id: number; name: string; color?: string }[]
|
||||
}
|
||||
|
||||
// AI 生成规则状态
|
||||
const aiEnabled = ref(false)
|
||||
const showAiRuleModal = ref(false)
|
||||
const aiStep = ref<'select' | 'suggest'>('select')
|
||||
const aiCandidateRows = ref<CandidateRow[]>([])
|
||||
const aiCandidateLoading = ref(false)
|
||||
const checkedCandidateKeys = ref<number[]>([])
|
||||
const aiCandidatePage = ref(1)
|
||||
const aiCandidatePageSize = ref(20)
|
||||
const aiCandidateTotal = ref(0)
|
||||
const aiCandidateSearch = ref('')
|
||||
const aiCandidateType = ref<string | null>(null)
|
||||
const aiCandidateTagIds = ref<number[]>([])
|
||||
const aiRuleLoading = ref(false)
|
||||
const aiRuleApplying = ref(false)
|
||||
const aiRuleSummary = ref('')
|
||||
const aiRuleRows = ref<AiRuleRow[]>([])
|
||||
const checkedAiRuleKeys = ref<number[]>([])
|
||||
|
||||
// 表单数据
|
||||
const form = ref<{
|
||||
name: string
|
||||
@@ -71,6 +115,16 @@ const typeOptions = computed(() => [
|
||||
{ label: t('common.types.income'), value: 'INCOME' },
|
||||
])
|
||||
|
||||
// 规则列表筛选:收支类型(可清空)与启用状态
|
||||
const typeFilterOptions = computed(() => [
|
||||
{ label: t('common.types.expense'), value: 'EXPENSE' },
|
||||
{ label: t('common.types.income'), value: 'INCOME' },
|
||||
])
|
||||
const enabledFilterOptions = computed(() => [
|
||||
{ label: t('rule.statusOn'), value: 'ON' },
|
||||
{ label: t('rule.statusOff'), value: 'OFF' },
|
||||
])
|
||||
|
||||
// 分类选项:按当前收支类型过滤
|
||||
const categoryOptions = computed(() => {
|
||||
const list = form.value.type ? categories.value.filter((c) => c.type === form.value.type) : categories.value
|
||||
@@ -174,12 +228,317 @@ const columns = computed<DataTableColumns<RuleItem>>(() => [
|
||||
},
|
||||
])
|
||||
|
||||
/** 加载规则列表数据 */
|
||||
/** AI 规则建议表格行键 */
|
||||
function aiRuleRowKey(row: AiRuleRow) {
|
||||
return row.key
|
||||
}
|
||||
|
||||
/** AI 规则建议表格列:名称与关键词可编辑,分类/标签展示 AI 建议 */
|
||||
const aiRuleColumns = computed<DataTableColumns<AiRuleRow>>(() => [
|
||||
// 与已有规则重复的条目禁止勾选(禁止导入)
|
||||
{ type: 'selection', disabled: (row) => !!row.duplicate },
|
||||
{
|
||||
title: t('rule.name'),
|
||||
key: 'name',
|
||||
width: 240,
|
||||
render: (row) => h(NSpace, { size: 4, align: 'center', wrap: false }, {
|
||||
default: () => [
|
||||
h(NInput, {
|
||||
value: row.name,
|
||||
size: 'small',
|
||||
disabled: !!row.duplicate,
|
||||
onUpdateValue: (v: string) => { row.name = v },
|
||||
}),
|
||||
row.duplicate ? h(NTag, { size: 'small', type: 'warning' }, {
|
||||
default: () => t('ai.rule.duplicate'),
|
||||
}) : null,
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: t('rule.keyword'),
|
||||
key: 'keyword',
|
||||
width: 160,
|
||||
render: (row) => h(NInput, {
|
||||
value: row.keyword || '',
|
||||
size: 'small',
|
||||
placeholder: '—',
|
||||
disabled: !!row.duplicate,
|
||||
onUpdateValue: (v: string) => { row.keyword = v || null },
|
||||
}),
|
||||
},
|
||||
{
|
||||
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',
|
||||
width: 150,
|
||||
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',
|
||||
width: 170,
|
||||
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',
|
||||
render: (row) => row.reason || '',
|
||||
},
|
||||
])
|
||||
|
||||
/** 候选记录表格行键 */
|
||||
function aiCandidateRowKey(row: CandidateRow) {
|
||||
return row.id
|
||||
}
|
||||
|
||||
/** 候选记录分页配置 */
|
||||
const aiCandidatePagination = computed(() => ({
|
||||
page: aiCandidatePage.value,
|
||||
pageSize: aiCandidatePageSize.value,
|
||||
itemCount: aiCandidateTotal.value,
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
showSizePicker: true,
|
||||
}))
|
||||
|
||||
/** 候选记录类型筛选选项 */
|
||||
const aiCandidateTypeOptions = computed(() => [
|
||||
{ label: t('common.types.income'), value: 'INCOME' },
|
||||
{ label: t('common.types.expense'), value: 'EXPENSE' },
|
||||
])
|
||||
|
||||
/** 候选记录表格列:展示已打标签的记录供勾选 */
|
||||
const aiCandidateColumns = computed<DataTableColumns<CandidateRow>>(() => [
|
||||
{ type: 'selection' },
|
||||
{
|
||||
title: t('transaction.column.remark'),
|
||||
key: 'remark',
|
||||
ellipsis: { tooltip: true },
|
||||
},
|
||||
{
|
||||
title: t('common.type'),
|
||||
key: 'type',
|
||||
width: 70,
|
||||
render: (row) => (row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')),
|
||||
},
|
||||
{
|
||||
title: t('dashboard.filterChannel'),
|
||||
key: 'channel',
|
||||
width: 100,
|
||||
render: (row) => getChannelLabel(row.channel),
|
||||
},
|
||||
{
|
||||
title: t('dashboard.filterTag'),
|
||||
key: 'tags',
|
||||
width: 220,
|
||||
render: (row) => {
|
||||
if (!row.tags || row.tags.length === 0) {
|
||||
return '—'
|
||||
}
|
||||
return h(NSpace, { size: 4 }, {
|
||||
default: () => row.tags.map((tag) => h(NTag, {
|
||||
size: 'small',
|
||||
color: tag.color ? { color: tag.color, textColor: '#fff' } : undefined,
|
||||
}, { default: () => tag.name })),
|
||||
})
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
/** 读取 AI 启用状态,未启用时不渲染 AI 入口 */
|
||||
async function loadAiConfig() {
|
||||
try {
|
||||
const res: any = await getAiConfig()
|
||||
aiEnabled.value = !!res.data?.enabled
|
||||
} catch (e) {
|
||||
aiEnabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载候选记录(已打标签),autoSelect 为 true 时默认勾选当前页 */
|
||||
async function loadCandidates(autoSelect: boolean) {
|
||||
aiCandidateLoading.value = true
|
||||
try {
|
||||
const res: any = await getTransactionPage({
|
||||
// 有标签筛选时由其隐含「已打标签」,否则显式筛选有标签记录
|
||||
tagged: aiCandidateTagIds.value.length === 0 ? true : undefined,
|
||||
tagIds: aiCandidateTagIds.value.length > 0 ? aiCandidateTagIds.value : undefined,
|
||||
types: aiCandidateType.value ? [aiCandidateType.value] : undefined,
|
||||
remark: aiCandidateSearch.value.trim() || undefined,
|
||||
page: aiCandidatePage.value,
|
||||
size: aiCandidatePageSize.value,
|
||||
sortField: 'transactionTime',
|
||||
sortOrder: 'desc',
|
||||
})
|
||||
const data = res.data || {}
|
||||
aiCandidateRows.value = (data.records || []).map((r: any) => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
remark: r.remark || '',
|
||||
channel: r.channel,
|
||||
categoryName: r.categoryName,
|
||||
tags: r.tags || [],
|
||||
}))
|
||||
aiCandidateTotal.value = data.total || 0
|
||||
if (autoSelect) {
|
||||
checkedCandidateKeys.value = aiCandidateRows.value.map((row) => row.id)
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('ai.rule.loadRecordsFailed'))
|
||||
} finally {
|
||||
aiCandidateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开 AI 生成规则弹窗,重置候选条件并加载已打标签记录(默认全选当前页) */
|
||||
async function handleAiRuleOpen() {
|
||||
showAiRuleModal.value = true
|
||||
aiStep.value = 'select'
|
||||
aiRuleRows.value = []
|
||||
aiRuleSummary.value = ''
|
||||
checkedAiRuleKeys.value = []
|
||||
aiCandidateRows.value = []
|
||||
checkedCandidateKeys.value = []
|
||||
aiCandidatePage.value = 1
|
||||
aiCandidateSearch.value = ''
|
||||
aiCandidateType.value = null
|
||||
aiCandidateTagIds.value = []
|
||||
await loadCandidates(true)
|
||||
if (aiCandidateTotal.value === 0) {
|
||||
message.warning(t('ai.rule.noTagged'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 候选记录搜索/筛选:回到第一页并默认勾选 */
|
||||
function handleCandidateSearch() {
|
||||
aiCandidatePage.value = 1
|
||||
loadCandidates(true)
|
||||
}
|
||||
|
||||
/** 清空候选记录搜索与筛选条件 */
|
||||
function handleCandidateClear() {
|
||||
aiCandidateSearch.value = ''
|
||||
aiCandidateType.value = null
|
||||
aiCandidateTagIds.value = []
|
||||
aiCandidatePage.value = 1
|
||||
loadCandidates(true)
|
||||
}
|
||||
|
||||
/** 候选记录翻页(保留已有勾选,避免跨页选择丢失) */
|
||||
function handleCandidatePageChange(page: number) {
|
||||
aiCandidatePage.value = page
|
||||
loadCandidates(false)
|
||||
}
|
||||
|
||||
/** 候选记录切换每页条数 */
|
||||
function handleCandidatePageSizeChange(size: number) {
|
||||
aiCandidatePageSize.value = size
|
||||
aiCandidatePage.value = 1
|
||||
loadCandidates(false)
|
||||
}
|
||||
|
||||
/** 勾选当前页全部候选记录(与已有勾选取并集) */
|
||||
function handleCandidateSelectPage() {
|
||||
const merged = new Set([...checkedCandidateKeys.value, ...aiCandidateRows.value.map((row) => row.id)])
|
||||
checkedCandidateKeys.value = Array.from(merged)
|
||||
}
|
||||
|
||||
/** 清空候选记录勾选 */
|
||||
function handleCandidateClearSelection() {
|
||||
checkedCandidateKeys.value = []
|
||||
}
|
||||
|
||||
/** 将勾选的记录发送给 AI,生成规则建议并切换到建议步骤 */
|
||||
async function handleAiRuleGenerate() {
|
||||
if (checkedCandidateKeys.value.length === 0) {
|
||||
message.warning(t('ai.rule.selectRequired'))
|
||||
return
|
||||
}
|
||||
aiRuleLoading.value = true
|
||||
try {
|
||||
const res: any = await suggestRulesByAi(checkedCandidateKeys.value)
|
||||
const data = res.data || {}
|
||||
aiRuleSummary.value = data.summary || ''
|
||||
aiRuleRows.value = (data.rules || []).map((rule: AiRuleSuggestion, index: number) => ({ ...rule, key: index }))
|
||||
// 与已有规则重复的条目不勾选
|
||||
checkedAiRuleKeys.value = aiRuleRows.value.filter((row) => !row.duplicate).map((row) => row.key)
|
||||
if (aiRuleRows.value.length > 0 && checkedAiRuleKeys.value.length === 0) {
|
||||
message.warning(t('ai.rule.allDuplicate'))
|
||||
}
|
||||
aiStep.value = 'suggest'
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('ai.rule.failed'))
|
||||
} finally {
|
||||
aiRuleLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 应用勾选的 AI 规则建议:批量创建并刷新列表(跳过与已有规则重复项) */
|
||||
async function handleAiRuleApply() {
|
||||
const selected = aiRuleRows.value.filter((row) => !row.duplicate && checkedAiRuleKeys.value.includes(row.key))
|
||||
if (selected.length === 0) {
|
||||
message.warning(t('ai.rule.selectRequired'))
|
||||
return
|
||||
}
|
||||
aiRuleApplying.value = true
|
||||
try {
|
||||
const res: any = await applyAiRules(selected.map((row) => ({
|
||||
name: row.name,
|
||||
enabled: true,
|
||||
type: row.type,
|
||||
keyword: row.keyword,
|
||||
minAmount: row.minAmount,
|
||||
maxAmount: row.maxAmount,
|
||||
categoryId: row.categoryId,
|
||||
newCategoryName: row.newCategoryName,
|
||||
newCategoryIcon: row.newCategoryIcon,
|
||||
tagIds: row.tagIds,
|
||||
newTags: row.newTags,
|
||||
})))
|
||||
message.success(t('ai.rule.applied', { count: res.data ?? selected.length }))
|
||||
showAiRuleModal.value = false
|
||||
await fetchData()
|
||||
await loadCategories()
|
||||
await loadTags()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('ai.rule.applyFailed'))
|
||||
} finally {
|
||||
aiRuleApplying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载规则列表数据(分页 + 筛选) */
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getRuleList()
|
||||
rules.value = res.data || []
|
||||
const res: any = await getRulePage({
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
keyword: searchKeyword.value.trim() || undefined,
|
||||
enabled: filterEnabled.value === null ? undefined : filterEnabled.value === 'ON',
|
||||
type: filterType.value || undefined,
|
||||
})
|
||||
rules.value = res.data?.records || []
|
||||
total.value = res.data?.total || 0
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('common.loadFailed'))
|
||||
} finally {
|
||||
@@ -187,6 +546,43 @@ async function fetchData() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 规则列表分页配置 */
|
||||
const pagination = computed(() => ({
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
itemCount: total.value,
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
showSizePicker: true,
|
||||
}))
|
||||
|
||||
/** 执行规则搜索:回到第一页 */
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
/** 清空规则搜索与筛选 */
|
||||
function handleClearSearch() {
|
||||
searchKeyword.value = ''
|
||||
filterEnabled.value = null
|
||||
filterType.value = null
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
/** 规则列表翻页 */
|
||||
function handlePageChange(value: number) {
|
||||
page.value = value
|
||||
fetchData()
|
||||
}
|
||||
|
||||
/** 规则列表切换每页条数 */
|
||||
function handlePageSizeChange(value: number) {
|
||||
pageSize.value = value
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
/** 加载分类选项 */
|
||||
async function loadCategories() {
|
||||
try {
|
||||
@@ -275,6 +671,8 @@ async function handleSubmit() {
|
||||
} else {
|
||||
await createRule(payload)
|
||||
message.success(t('common.createSuccess'))
|
||||
// 新增后回到第一页,便于看到新规则
|
||||
page.value = 1
|
||||
}
|
||||
showModal.value = false
|
||||
await fetchData()
|
||||
@@ -325,11 +723,12 @@ watch(() => form.value.type, (type) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 组件挂载时加载规则、分类与标签
|
||||
// 组件挂载时加载规则、分类、标签与 AI 启用状态
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
loadCategories()
|
||||
loadTags()
|
||||
loadAiConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -338,10 +737,51 @@ onMounted(() => {
|
||||
<!-- 顶部:标题和新增按钮 -->
|
||||
<NSpace justify="space-between" style="margin-bottom: 16px;">
|
||||
<h2 style="margin: 0;">{{ t('rule.title') }}</h2>
|
||||
<NSpace>
|
||||
<NButton v-if="aiEnabled" @click="handleAiRuleOpen">{{ t('ai.rule.entry') }}</NButton>
|
||||
<NButton type="primary" @click="handleAdd">{{ t('rule.add') }}</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NDataTable :columns="columns" :data="rules" :loading="loading" :bordered="false" />
|
||||
<!-- 搜索与筛选 -->
|
||||
<NCard :bordered="false" style="margin-bottom: 16px;">
|
||||
<NSpace align="center" :wrap="true" :size="[8, 8]">
|
||||
<NInput
|
||||
v-model:value="searchKeyword"
|
||||
clearable
|
||||
:placeholder="t('rule.searchPlaceholder')"
|
||||
style="width: 220px;"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="filterEnabled"
|
||||
:options="enabledFilterOptions"
|
||||
clearable
|
||||
:placeholder="t('rule.status')"
|
||||
style="width: 120px;"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="filterType"
|
||||
:options="typeFilterOptions"
|
||||
clearable
|
||||
:placeholder="t('dashboard.filterType')"
|
||||
style="width: 120px;"
|
||||
/>
|
||||
<NButton type="primary" @click="handleSearch">{{ t('transaction.search') }}</NButton>
|
||||
<NButton @click="handleClearSearch">{{ t('transaction.clearAll') }}</NButton>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
|
||||
<NDataTable
|
||||
remote
|
||||
:columns="columns"
|
||||
:data="rules"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
:pagination="pagination"
|
||||
@update:page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
/>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<NModal v-model:show="showModal" preset="card" :title="editingId ? t('rule.editTitle') : t('rule.addTitle')" style="width: 520px;">
|
||||
@@ -396,5 +836,110 @@ onMounted(() => {
|
||||
<NButton type="primary" block :loading="submitting" @click="handleSubmit">{{ t('common.confirm') }}</NButton>
|
||||
</NForm>
|
||||
</NModal>
|
||||
|
||||
<!-- AI 生成规则弹窗:先选择已打标签记录,再预览规则建议 -->
|
||||
<NModal
|
||||
v-if="aiEnabled"
|
||||
v-model:show="showAiRuleModal"
|
||||
preset="card"
|
||||
:title="t('ai.rule.title')"
|
||||
style="width: min(1200px, 94vw);"
|
||||
>
|
||||
<!-- 步骤一:选择记录 -->
|
||||
<template v-if="aiStep === 'select'">
|
||||
<NSpace align="center" :wrap="true" :size="[8, 8]" style="margin-bottom: 12px;">
|
||||
<NInput
|
||||
v-model:value="aiCandidateSearch"
|
||||
clearable
|
||||
:placeholder="t('ai.rule.searchPlaceholder')"
|
||||
style="width: 220px;"
|
||||
@keyup.enter="handleCandidateSearch"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="aiCandidateType"
|
||||
:options="aiCandidateTypeOptions"
|
||||
clearable
|
||||
:placeholder="t('dashboard.filterType')"
|
||||
style="width: 120px;"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="aiCandidateTagIds"
|
||||
:options="tagOptions"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
:max-tag-count="2"
|
||||
:placeholder="t('dashboard.filterTag')"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<NButton type="primary" @click="handleCandidateSearch">{{ t('transaction.search') }}</NButton>
|
||||
<NButton @click="handleCandidateClear">{{ t('transaction.clearAll') }}</NButton>
|
||||
</NSpace>
|
||||
|
||||
<NSpace align="center" justify="space-between" :wrap="true" style="margin-bottom: 8px;">
|
||||
<NText depth="3" style="font-size: 12px;">
|
||||
{{ t('ai.rule.selectHint', { count: checkedCandidateKeys.length }) }}
|
||||
</NText>
|
||||
<NSpace :size="8">
|
||||
<NButton size="small" :disabled="aiCandidateRows.length === 0" @click="handleCandidateSelectPage">
|
||||
{{ t('ai.rule.selectPageAll') }}
|
||||
</NButton>
|
||||
<NButton size="small" :disabled="checkedCandidateKeys.length === 0" @click="handleCandidateClearSelection">
|
||||
{{ t('ai.rule.clearSelection') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NDataTable
|
||||
v-model:checked-row-keys="checkedCandidateKeys"
|
||||
remote
|
||||
:columns="aiCandidateColumns"
|
||||
:data="aiCandidateRows"
|
||||
:loading="aiCandidateLoading"
|
||||
:row-key="aiCandidateRowKey"
|
||||
:pagination="aiCandidatePagination"
|
||||
:max-height="360"
|
||||
:scroll-x="1100"
|
||||
size="small"
|
||||
@update:page="handleCandidatePageChange"
|
||||
@update:page-size="handleCandidatePageSizeChange"
|
||||
/>
|
||||
<NSpace justify="end" style="margin-top: 16px;">
|
||||
<NButton @click="showAiRuleModal = false">{{ t('common.cancel') }}</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
:loading="aiRuleLoading"
|
||||
:disabled="aiCandidateLoading || checkedCandidateKeys.length === 0"
|
||||
@click="handleAiRuleGenerate"
|
||||
>
|
||||
{{ t('ai.rule.generate') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<!-- 步骤二:预览规则建议 -->
|
||||
<template v-else>
|
||||
<NText depth="3" style="font-size: 12px; display: block; margin-bottom: 12px;">
|
||||
{{ aiRuleSummary || t('ai.rule.hint') }}
|
||||
</NText>
|
||||
<NDataTable
|
||||
v-model:checked-row-keys="checkedAiRuleKeys"
|
||||
:columns="aiRuleColumns"
|
||||
:data="aiRuleRows"
|
||||
:loading="aiRuleLoading"
|
||||
:row-key="aiRuleRowKey"
|
||||
:max-height="420"
|
||||
:scroll-x="1100"
|
||||
size="small"
|
||||
/>
|
||||
<NSpace justify="end" style="margin-top: 16px;">
|
||||
<NButton @click="aiStep = 'select'">{{ t('ai.rule.back') }}</NButton>
|
||||
<NButton @click="showAiRuleModal = false">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="aiRuleApplying" :disabled="aiRuleLoading" @click="handleAiRuleApply">
|
||||
{{ t('ai.rule.apply') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
// 系统设置页:上下分区展示数据库配置、注册设置、AI 模型配置与定时通知
|
||||
// 系统设置页:上下分区展示数据库配置、注册设置、账单来源统计、AI 模型配置与定时通知
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import DatabaseConfigPanel from '../components/DatabaseConfigPanel.vue'
|
||||
import RegistrationConfigPanel from '../components/RegistrationConfigPanel.vue'
|
||||
import BillSourceStatsPanel from '../components/BillSourceStatsPanel.vue'
|
||||
import AiConfigPanel from '../components/AiConfigPanel.vue'
|
||||
import NotificationConfigPanel from '../components/NotificationConfigPanel.vue'
|
||||
|
||||
@@ -14,6 +15,7 @@ const { t } = useI18n()
|
||||
<h2 class="page-title">{{ t('settings.title') }}</h2>
|
||||
<DatabaseConfigPanel />
|
||||
<RegistrationConfigPanel />
|
||||
<BillSourceStatsPanel />
|
||||
<AiConfigPanel />
|
||||
<NotificationConfigPanel />
|
||||
</div>
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
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, NDivider, NDropdown } from 'naive-ui'
|
||||
import { EyeOutline, EyeOffOutline, SparklesOutline, BulbOutline } from '@vicons/ionicons5'
|
||||
import { EyeOutline, EyeOffOutline, SparklesOutline, BulbOutline, RefreshOutline } from '@vicons/ionicons5'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getPaymentMethods, batchDeleteTransactions } from '../api/transaction'
|
||||
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getChannels, batchDeleteTransactions } from '../api/transaction'
|
||||
import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi } from '../api/ai'
|
||||
import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai'
|
||||
import { getCategoryList, createCategory } from '../api/category'
|
||||
import { getTagList, createTag } from '../api/tag'
|
||||
import { getPaymentLabel } from '../utils/payment'
|
||||
import { getChannelLabel } from '../utils/channel'
|
||||
import { renderCategoryLabel } from '../utils/render'
|
||||
import { useKeywordHistory } from '../composables/useKeywordHistory'
|
||||
import BillImportModal from './BillImportModal.vue'
|
||||
@@ -27,7 +27,7 @@ interface TransactionItem {
|
||||
categoryIcon: string
|
||||
type: string // INCOME 或 EXPENSE
|
||||
amount: number
|
||||
paymentMethod: string
|
||||
channel: string
|
||||
remark: string
|
||||
remarkHidden: boolean
|
||||
transactionTime: string
|
||||
@@ -185,7 +185,7 @@ const form = ref({
|
||||
categoryId: null as number | null,
|
||||
type: 'EXPENSE',
|
||||
amount: null as number | null,
|
||||
paymentMethod: '',
|
||||
channel: '',
|
||||
remark: '',
|
||||
transactionTime: Date.now(),
|
||||
includeInStats: true,
|
||||
@@ -198,18 +198,18 @@ const typeOptions = computed(() => [
|
||||
{ label: t('common.types.income'), value: 'INCOME' },
|
||||
])
|
||||
|
||||
// 支付方式编码列表(从后端动态获取,含账单导入来源与人工支付方式)
|
||||
const paymentMethodCodes = ref<string[]>([])
|
||||
// 交易渠道编码列表(从后端动态获取,含账单导入来源与人工交易渠道)
|
||||
const channelCodes = ref<string[]>([])
|
||||
|
||||
// 支付方式选项:由后端返回的编码映射为当前语言标签
|
||||
const paymentOptions = computed(() =>
|
||||
paymentMethodCodes.value.map((code) => ({ label: getPaymentLabel(code), value: code })),
|
||||
// 交易渠道选项:由后端返回的编码映射为当前语言标签
|
||||
const channelOptions = computed(() =>
|
||||
channelCodes.value.map((code) => ({ label: getChannelLabel(code), value: code })),
|
||||
)
|
||||
|
||||
// 筛选条件状态
|
||||
const filterType = ref<string[]>([]) // 类型筛选(多选)
|
||||
const filterCategory = ref<number[]>([]) // 分类筛选(多选)
|
||||
const filterPaymentMethod = ref<string[]>([]) // 支付方式筛选(多选)
|
||||
const filterChannel = ref<string[]>([]) // 交易渠道筛选(多选)
|
||||
const filterDateRange = ref<[number, number] | null>(null) // 日期范围筛选(起止时间戳)
|
||||
const filterIncludeInStats = ref<string | null>(null) // 计入统计筛选:null-全部,'true'-计入,'false'-不计入
|
||||
const filterRemark = ref('') // 备注关键字搜索
|
||||
@@ -293,7 +293,9 @@ function loadFilterPrefs() {
|
||||
const p = JSON.parse(raw)
|
||||
if (Array.isArray(p.filterType)) filterType.value = p.filterType
|
||||
if (Array.isArray(p.filterCategory)) filterCategory.value = p.filterCategory
|
||||
if (Array.isArray(p.filterPaymentMethod)) filterPaymentMethod.value = p.filterPaymentMethod
|
||||
// 兼容旧版本以 filterPaymentMethod 存储的偏好
|
||||
const savedChannel = Array.isArray(p.filterChannel) ? p.filterChannel : p.filterPaymentMethod
|
||||
if (Array.isArray(savedChannel)) filterChannel.value = savedChannel
|
||||
if (Array.isArray(p.filterDateRange) && p.filterDateRange.length === 2) filterDateRange.value = p.filterDateRange
|
||||
if (p.filterIncludeInStats === 'true' || p.filterIncludeInStats === 'false') filterIncludeInStats.value = p.filterIncludeInStats
|
||||
if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark
|
||||
@@ -313,7 +315,7 @@ function saveFilterPrefs() {
|
||||
localStorage.setItem(FILTER_PREFS_KEY, JSON.stringify({
|
||||
filterType: filterType.value,
|
||||
filterCategory: filterCategory.value,
|
||||
filterPaymentMethod: filterPaymentMethod.value,
|
||||
filterChannel: filterChannel.value,
|
||||
filterDateRange: filterDateRange.value,
|
||||
filterIncludeInStats: filterIncludeInStats.value,
|
||||
filterRemark: filterRemark.value,
|
||||
@@ -330,7 +332,7 @@ function saveFilterPrefs() {
|
||||
|
||||
// 筛选条件变化时自动保存用户偏好
|
||||
watch(
|
||||
[filterType, filterCategory, filterPaymentMethod, filterDateRange, filterIncludeInStats, filterRemark, filterMinAmount, filterMaxAmount, pageSize, sortValue, filterTag],
|
||||
[filterType, filterCategory, filterChannel, filterDateRange, filterIncludeInStats, filterRemark, filterMinAmount, filterMaxAmount, pageSize, sortValue, filterTag],
|
||||
saveFilterPrefs,
|
||||
{ deep: true },
|
||||
)
|
||||
@@ -461,8 +463,8 @@ function createColumns(): DataTableColumns<TransactionItem> {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('transaction.column.paymentMethod'), key: 'paymentMethod', width: 100,
|
||||
render(row) { return getPaymentLabel(row.paymentMethod) },
|
||||
title: t('transaction.column.channel'), key: 'channel', width: 100,
|
||||
render(row) { return getChannelLabel(row.channel) },
|
||||
},
|
||||
{
|
||||
title: t('transaction.column.remark'), key: 'remark', minWidth: 220,
|
||||
@@ -523,13 +525,13 @@ function createColumns(): DataTableColumns<TransactionItem> {
|
||||
|
||||
const columns = computed(() => createColumns()) // 表格列配置(随语言切换响应式更新)
|
||||
|
||||
/** 从后端加载支付方式列表,供筛选与编辑表单使用 */
|
||||
async function loadPaymentMethods() {
|
||||
/** 从后端加载交易渠道列表,供筛选与编辑表单使用 */
|
||||
async function loadChannels() {
|
||||
try {
|
||||
const res: any = await getPaymentMethods()
|
||||
paymentMethodCodes.value = res.data || []
|
||||
const res: any = await getChannels()
|
||||
channelCodes.value = res.data || []
|
||||
} catch (e) {
|
||||
// 静默处理,支付方式下拉保持为空
|
||||
// 静默处理,交易渠道下拉保持为空
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,7 +564,7 @@ async function fetchData() {
|
||||
size: pageSize.value,
|
||||
types: filterType.value.length > 0 ? filterType.value : undefined,
|
||||
...buildCategoryFilter(),
|
||||
paymentMethods: filterPaymentMethod.value.length > 0 ? filterPaymentMethod.value : undefined,
|
||||
channels: filterChannel.value.length > 0 ? filterChannel.value : undefined,
|
||||
startTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[0]) : undefined,
|
||||
endTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[1], true) : undefined,
|
||||
includeInStats: filterIncludeInStats.value === null ? undefined : filterIncludeInStats.value === 'true',
|
||||
@@ -607,7 +609,7 @@ function handleViewRaw(row: TransactionItem) {
|
||||
/** 打开新增记录弹窗,重置表单 */
|
||||
function handleAdd() {
|
||||
editingId.value = null
|
||||
form.value = { categoryId: null, type: 'EXPENSE', amount: null, paymentMethod: '', remark: '', transactionTime: Date.now(), includeInStats: true, tagIds: [] }
|
||||
form.value = { categoryId: null, type: 'EXPENSE', amount: null, channel: '', remark: '', transactionTime: Date.now(), includeInStats: true, tagIds: [] }
|
||||
pendingCategory.value = null
|
||||
pendingTag.value = null
|
||||
showModal.value = true
|
||||
@@ -620,7 +622,7 @@ function handleEdit(row: TransactionItem) {
|
||||
categoryId: row.categoryId,
|
||||
type: row.type,
|
||||
amount: row.amount,
|
||||
paymentMethod: row.paymentMethod || '',
|
||||
channel: row.channel || '',
|
||||
remark: row.remark || '',
|
||||
transactionTime: new Date(row.transactionTime).getTime(),
|
||||
includeInStats: row.includeInStats ?? true,
|
||||
@@ -646,7 +648,7 @@ async function handleSubmit() {
|
||||
categoryId: form.value.categoryId!,
|
||||
type: form.value.type,
|
||||
amount: form.value.amount!,
|
||||
paymentMethod: form.value.paymentMethod || undefined,
|
||||
channel: form.value.channel || undefined,
|
||||
remark: form.value.remark || undefined,
|
||||
transactionTime: formatLocalDateTime(form.value.transactionTime),
|
||||
includeInStats: form.value.includeInStats,
|
||||
@@ -737,7 +739,7 @@ function handleSearch() {
|
||||
function handleReset() {
|
||||
filterType.value = []
|
||||
filterCategory.value = []
|
||||
filterPaymentMethod.value = []
|
||||
filterChannel.value = []
|
||||
filterDateRange.value = null
|
||||
filterIncludeInStats.value = null
|
||||
filterRemark.value = ''
|
||||
@@ -797,14 +799,14 @@ async function handleAiSuggest() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 编辑弹窗内 AI 推荐分类:基于表单当前内容(类型、金额、支付方式、备注) */
|
||||
/** 编辑弹窗内 AI 推荐分类:基于表单当前内容(类型、金额、交易渠道、备注) */
|
||||
async function handleAiSuggestOne() {
|
||||
aiFormLoading.value = true
|
||||
try {
|
||||
const res: any = await suggestOneByAi({
|
||||
type: form.value.type,
|
||||
amount: form.value.amount,
|
||||
paymentMethod: form.value.paymentMethod || undefined,
|
||||
channel: form.value.channel || undefined,
|
||||
remark: form.value.remark || undefined,
|
||||
})
|
||||
const suggestion = res.data
|
||||
@@ -850,7 +852,7 @@ function buildCategoryPrompt() {
|
||||
const parts = [`这是一笔${typeLabel}记录`]
|
||||
if (form.value.remark) parts.push(`备注:${form.value.remark}`)
|
||||
if (form.value.amount) parts.push(`金额:${form.value.amount}`)
|
||||
if (form.value.paymentMethod) parts.push(`支付方式:${getPaymentLabel(form.value.paymentMethod)}`)
|
||||
if (form.value.channel) parts.push(`交易渠道:${getChannelLabel(form.value.channel)}`)
|
||||
parts.push('请生成一个能涵盖这类记录的记账分类')
|
||||
return parts.join(',')
|
||||
}
|
||||
@@ -894,7 +896,7 @@ async function handleAiTagSuggest() {
|
||||
const res: any = await suggestOneByAi({
|
||||
type: form.value.type,
|
||||
amount: form.value.amount,
|
||||
paymentMethod: form.value.paymentMethod || undefined,
|
||||
channel: form.value.channel || undefined,
|
||||
remark: form.value.remark || undefined,
|
||||
})
|
||||
const suggestion = res.data
|
||||
@@ -1055,7 +1057,7 @@ async function handleAiRecordParse() {
|
||||
categoryId: r.categoryId ?? null,
|
||||
type: r.type || 'EXPENSE',
|
||||
amount: r.amount ?? null,
|
||||
paymentMethod: r.paymentMethod || '',
|
||||
channel: r.channel || '',
|
||||
remark: r.remark || '',
|
||||
transactionTime: r.transactionTime ? new Date(r.transactionTime).getTime() : Date.now(),
|
||||
includeInStats: r.includeInStats ?? true,
|
||||
@@ -1121,7 +1123,7 @@ function applyAiQuery() {
|
||||
const categoryIds = [...(r.categoryIds || [])]
|
||||
if (r.uncategorized) categoryIds.push(0)
|
||||
filterCategory.value = categoryIds
|
||||
filterPaymentMethod.value = r.paymentMethods || []
|
||||
filterChannel.value = r.channels || []
|
||||
filterDateRange.value = r.startTime && r.endTime
|
||||
? [new Date(r.startTime).getTime(), new Date(r.endTime).getTime()]
|
||||
: null
|
||||
@@ -1143,7 +1145,7 @@ const aiQueryEmpty = computed(() => {
|
||||
&& (r.categoryIds?.length ?? 0) === 0
|
||||
&& !r.uncategorized
|
||||
&& (r.tagIds?.length ?? 0) === 0
|
||||
&& (r.paymentMethods?.length ?? 0) === 0
|
||||
&& (r.channels?.length ?? 0) === 0
|
||||
&& !r.startTime
|
||||
&& r.minAmount == null
|
||||
&& r.maxAmount == null
|
||||
@@ -1226,11 +1228,11 @@ const activeFilterTags = computed(() => {
|
||||
})
|
||||
}
|
||||
|
||||
filterPaymentMethod.value.forEach((v) => {
|
||||
filterChannel.value.forEach((v) => {
|
||||
tags.push({
|
||||
key: `payment:${v}`,
|
||||
label: getPaymentLabel(v),
|
||||
clear: () => { filterPaymentMethod.value = filterPaymentMethod.value.filter((x) => x !== v) },
|
||||
key: `channel:`,
|
||||
label: getChannelLabel(v),
|
||||
clear: () => { filterChannel.value = filterChannel.value.filter((x) => x !== v) },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1451,7 +1453,7 @@ async function doExport() {
|
||||
const res = await exportTransaction({
|
||||
types: filterType.value.length > 0 ? filterType.value : undefined,
|
||||
...buildCategoryFilter(),
|
||||
paymentMethods: filterPaymentMethod.value.length > 0 ? filterPaymentMethod.value : undefined,
|
||||
channels: filterChannel.value.length > 0 ? filterChannel.value : undefined,
|
||||
startTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[0]) : undefined,
|
||||
endTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[1], true) : undefined,
|
||||
includeInStats: filterIncludeInStats.value === null ? undefined : filterIncludeInStats.value === 'true',
|
||||
@@ -1487,7 +1489,7 @@ onMounted(() => {
|
||||
loadAiConfig()
|
||||
fetchCategories()
|
||||
fetchTags()
|
||||
loadPaymentMethods()
|
||||
loadChannels()
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
@@ -1507,6 +1509,12 @@ onMounted(() => {
|
||||
</NButton>
|
||||
<NButton @click="showImportModal = true">{{ t('transaction.importBill') }}</NButton>
|
||||
<NButton :loading="exporting" @click="handleExport">{{ t('transaction.exportExcel') }}</NButton>
|
||||
<NButton :loading="loading" @click="fetchData">
|
||||
<template #icon>
|
||||
<NIcon :component="RefreshOutline" />
|
||||
</template>
|
||||
{{ t('common.refresh') }}
|
||||
</NButton>
|
||||
<NButton v-if="aiEnabled" @click="handleAiRecordOpen">
|
||||
<template #icon>
|
||||
<NIcon :component="SparklesOutline" />
|
||||
@@ -1562,12 +1570,12 @@ onMounted(() => {
|
||||
@blur="handleRemarkBlur"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="filterPaymentMethod"
|
||||
:options="paymentOptions"
|
||||
v-model:value="filterChannel"
|
||||
:options="channelOptions"
|
||||
multiple
|
||||
clearable
|
||||
:max-tag-count="2"
|
||||
:placeholder="t('transaction.column.paymentMethod')"
|
||||
:placeholder="t('transaction.column.channel')"
|
||||
style="width: 180px;"
|
||||
/>
|
||||
<NPopover v-model:show="showMoreFilter" trigger="click" placement="bottom-start">
|
||||
@@ -1721,8 +1729,8 @@ onMounted(() => {
|
||||
<NFormItem :label="t('transaction.column.amount')">
|
||||
<NInputNumber v-model:value="form.amount" :min="0.01" :precision="2" style="width: 100%;" :placeholder="t('transaction.amountPlaceholder')" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('transaction.column.paymentMethod')">
|
||||
<NSelect v-model:value="form.paymentMethod" :options="paymentOptions" clearable :placeholder="t('transaction.paymentPlaceholder')" />
|
||||
<NFormItem :label="t('transaction.column.channel')">
|
||||
<NSelect v-model:value="form.channel" :options="channelOptions" clearable :placeholder="t('transaction.channelPlaceholder')" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('transaction.column.transactionTime')">
|
||||
<NDatePicker v-model:value="form.transactionTime" type="datetime" style="width: 100%;" />
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
package cn.rosercode.finance.client;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.openai.client.OpenAIClient;
|
||||
import com.openai.client.okhttp.OpenAIOkHttpClient;
|
||||
import com.openai.errors.OpenAIException;
|
||||
import com.openai.models.ResponseFormatJsonObject;
|
||||
import com.openai.models.chat.completions.ChatCompletion;
|
||||
import com.openai.models.chat.completions.ChatCompletionCreateParams;
|
||||
import com.openai.models.completions.CompletionUsage;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import cn.rosercode.finance.common.BusinessException;
|
||||
|
||||
/**
|
||||
* DeepSeek 客户端,封装 Chat Completions 接口的 HTTP 调用与响应解析。
|
||||
* DeepSeek 客户端,基于 OpenAI Java SDK 封装 Chat Completions 调用与响应解析。
|
||||
*
|
||||
* <p>DeepSeek 提供 OpenAI 兼容接口,因此复用官方 SDK,通过自定义 baseUrl 指向 DeepSeek,
|
||||
* 由 SDK 负责请求构造与响应解析。对外保持 {@link #chat(String, String, String, String, String)}
|
||||
* 签名与 {@link DeepSeekResponse} 不变。</p>
|
||||
*
|
||||
* @date 2026-09-17 17:52:17
|
||||
*/
|
||||
@@ -31,26 +32,14 @@ public class DeepSeekClient {
|
||||
/** 单次请求的读取超时时间 */
|
||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(60);
|
||||
|
||||
/** 连接超时时间 */
|
||||
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
|
||||
/** 缓存的客户端上限,超出后清空重建,避免按用户无限增长 */
|
||||
private static final int MAX_CACHED_CLIENTS = 32;
|
||||
|
||||
/** 错误信息中保留的响应体最大长度 */
|
||||
private static final int ERROR_BODY_LIMIT = 300;
|
||||
/** 采样温度,保持较低随机性以获得稳定的结构化输出 */
|
||||
private static final double TEMPERATURE = 0.2;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
/**
|
||||
* 构造方法,初始化 JSON 映射器与 HTTP 客户端。
|
||||
*
|
||||
* @param objectMapper Jackson 对象映射器
|
||||
*/
|
||||
public DeepSeekClient(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(CONNECT_TIMEOUT)
|
||||
.build();
|
||||
}
|
||||
/** 按 baseUrl + apiKey 缓存的 SDK 客户端,避免每次调用重复构建 */
|
||||
private final Map<String, OpenAIClient> clientCache = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 调用 Chat Completions 接口,返回模型输出的文本内容。
|
||||
@@ -63,89 +52,84 @@ public class DeepSeekClient {
|
||||
* @return 模型输出内容与 token 用量
|
||||
*/
|
||||
public DeepSeekResponse chat(String baseUrl, String apiKey, String model, String systemPrompt, String userPrompt) {
|
||||
// 兼容以斜杠结尾的 baseUrl
|
||||
String normalized = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||
String url = normalized + "/chat/completions";
|
||||
long start = System.currentTimeMillis();
|
||||
OpenAIClient client = resolveClient(baseUrl, apiKey);
|
||||
|
||||
// 构造请求体,强制返回 JSON 对象
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("model", model);
|
||||
body.put("temperature", 0.2);
|
||||
body.put("stream", false);
|
||||
body.put("response_format", Map.of("type", "json_object"));
|
||||
body.put("messages", List.of(
|
||||
Map.of("role", "system", "content", systemPrompt),
|
||||
Map.of("role", "user", "content", userPrompt)
|
||||
));
|
||||
|
||||
String requestBody;
|
||||
try {
|
||||
requestBody = objectMapper.writeValueAsString(body);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("构建 AI 请求体失败: model={}, error={}", model, e.getMessage());
|
||||
throw new BusinessException("构建 AI 请求失败: " + e.getMessage());
|
||||
}
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.header("Authorization", "Bearer " + apiKey)
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(requestBody, StandardCharsets.UTF_8))
|
||||
// 强制返回 JSON 对象,与各 AI 服务的结构化输出约定一致
|
||||
ChatCompletionCreateParams params = ChatCompletionCreateParams.builder()
|
||||
.model(model)
|
||||
.temperature(TEMPERATURE)
|
||||
.responseFormat(ResponseFormatJsonObject.builder().build())
|
||||
.addSystemMessage(systemPrompt)
|
||||
.addUserMessage(userPrompt)
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response;
|
||||
ChatCompletion completion;
|
||||
try {
|
||||
response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
} catch (IOException e) {
|
||||
log.error("调用 DeepSeek 失败: model={}, error={}", model, e.getMessage());
|
||||
completion = client.chat().completions().create(params);
|
||||
} catch (OpenAIException e) {
|
||||
log.warn("调用 DeepSeek 失败: model={}, error={}", model, e.getMessage());
|
||||
throw new BusinessException("调用 DeepSeek 失败: " + e.getMessage());
|
||||
} catch (InterruptedException e) {
|
||||
// 恢复中断标记,避免吞掉中断信号
|
||||
Thread.currentThread().interrupt();
|
||||
log.warn("调用 DeepSeek 被中断: model={}", model);
|
||||
throw new BusinessException("调用 DeepSeek 被中断");
|
||||
}
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
log.warn("DeepSeek 返回异常状态: model={}, status={}, body={}",
|
||||
model, response.statusCode(), truncate(response.body()));
|
||||
throw new BusinessException("DeepSeek 返回异常(" + response.statusCode() + "): " + truncate(response.body()));
|
||||
}
|
||||
|
||||
// 解析 choices[0].message.content 与 usage 用量
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(response.body());
|
||||
JsonNode content = root.path("choices").path(0).path("message").path("content");
|
||||
if (content.isMissingNode() || content.isNull() || content.asText().isBlank()) {
|
||||
String content = completion.choices().stream()
|
||||
.findFirst()
|
||||
.flatMap(choice -> choice.message().content())
|
||||
.orElse("");
|
||||
if (content.isBlank()) {
|
||||
throw new BusinessException("DeepSeek 返回内容为空");
|
||||
}
|
||||
JsonNode usage = root.path("usage");
|
||||
|
||||
DeepSeekResponse result = new DeepSeekResponse();
|
||||
result.setContent(content.asText());
|
||||
result.setPromptTokens(usage.path("prompt_tokens").asInt(0));
|
||||
result.setCompletionTokens(usage.path("completion_tokens").asInt(0));
|
||||
result.setTotalTokens(usage.path("total_tokens").asInt(0));
|
||||
// DeepSeek 用 prompt_cache_hit_tokens 表示缓存命中的输入 token
|
||||
result.setCachedTokens(usage.path("prompt_cache_hit_tokens").asInt(0));
|
||||
result.setContent(content);
|
||||
completion.usage().ifPresent(usage -> fillUsage(result, usage));
|
||||
log.info("DeepSeek 调用完成: model={}, 耗时={}ms, tokens={}", model,
|
||||
System.currentTimeMillis() - start, result.getTotalTokens());
|
||||
return result;
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new BusinessException("解析 DeepSeek 响应失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断过长的响应体,避免错误信息过长。
|
||||
* 将 SDK 用量信息填充到响应对象。
|
||||
*
|
||||
* @param body 响应体
|
||||
* @return 截断后的字符串
|
||||
* <p>DeepSeek 的缓存命中 token 使用私有字段 {@code prompt_cache_hit_tokens},
|
||||
* SDK 标准模型未必能读取,缺失时保持为 0。</p>
|
||||
*
|
||||
* @param result 响应对象
|
||||
* @param usage SDK 返回的用量信息
|
||||
*/
|
||||
private String truncate(String body) {
|
||||
if (body == null) {
|
||||
return "";
|
||||
private void fillUsage(DeepSeekResponse result, CompletionUsage usage) {
|
||||
result.setPromptTokens((int) usage.promptTokens());
|
||||
result.setCompletionTokens((int) usage.completionTokens());
|
||||
result.setTotalTokens((int) usage.totalTokens());
|
||||
usage.promptTokensDetails()
|
||||
.flatMap(CompletionUsage.PromptTokensDetails::cachedTokens)
|
||||
.ifPresent(cached -> result.setCachedTokens(cached.intValue()));
|
||||
}
|
||||
return body.length() <= ERROR_BODY_LIMIT ? body : body.substring(0, ERROR_BODY_LIMIT) + "...";
|
||||
|
||||
/**
|
||||
* 获取(并按需构建)对应配置的 SDK 客户端。
|
||||
*
|
||||
* @param baseUrl API 基础地址
|
||||
* @param apiKey API Key
|
||||
* @return SDK 客户端
|
||||
*/
|
||||
private OpenAIClient resolveClient(String baseUrl, String apiKey) {
|
||||
String normalized = baseUrl != null && baseUrl.endsWith("/")
|
||||
? baseUrl.substring(0, baseUrl.length() - 1)
|
||||
: baseUrl;
|
||||
String key = normalized + "\n" + apiKey;
|
||||
OpenAIClient cached = clientCache.get(key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
if (clientCache.size() >= MAX_CACHED_CLIENTS) {
|
||||
clientCache.clear();
|
||||
}
|
||||
return clientCache.computeIfAbsent(key, ignored -> OpenAIOkHttpClient.builder()
|
||||
.baseUrl(normalized)
|
||||
.apiKey(apiKey)
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.maxRetries(0)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package cn.rosercode.finance.config.app;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -18,6 +21,9 @@ public class AppConfig {
|
||||
/** 默认日志保留天数 */
|
||||
public static final int DEFAULT_LOG_RETENTION_DAYS = 30;
|
||||
|
||||
/** 系统默认计入统计的账单来源,其余来源默认不计入 */
|
||||
private static final Set<String> DEFAULT_INCLUDED_SOURCES = Set.of("WECHAT", "ALIPAY");
|
||||
|
||||
/** 是否开放注册:true 开放,false 关闭(默认关闭) */
|
||||
private boolean registrationEnabled = false;
|
||||
|
||||
@@ -26,4 +32,23 @@ public class AppConfig {
|
||||
|
||||
/** 日志保留天数(访问日志/登录日志),0 或负数表示永久保留,默认 30 天 */
|
||||
private Integer logRetentionDays = DEFAULT_LOG_RETENTION_DAYS;
|
||||
|
||||
/** 按账单来源的「计入统计」默认值,key 为账单来源编码(BillType 名称);为空或缺少某来源时使用系统默认 */
|
||||
private Map<String, Boolean> billSourceIncludeInStats;
|
||||
|
||||
/**
|
||||
* 解析指定账单来源的「计入统计」默认值。
|
||||
*
|
||||
* <p>优先取用户配置;未配置时,微信与支付宝默认计入,其余来源默认不计入。</p>
|
||||
*
|
||||
* @param billType 账单来源编码
|
||||
* @return 是否默认计入统计
|
||||
*/
|
||||
public boolean resolveIncludeInStats(String billType) {
|
||||
if (billType != null && billSourceIncludeInStats != null
|
||||
&& billSourceIncludeInStats.containsKey(billType)) {
|
||||
return Boolean.TRUE.equals(billSourceIncludeInStats.get(billType));
|
||||
}
|
||||
return billType != null && DEFAULT_INCLUDED_SOURCES.contains(billType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package cn.rosercode.finance.config.datasource;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 数据库结构迁移器。
|
||||
*
|
||||
* <p>建表脚本均为幂等的 {@code CREATE TABLE IF NOT EXISTS},无法作用于已存在库的列变更,
|
||||
* 因此在此集中处理需要就地迁移的结构调整。迁移逻辑必须可重复执行(幂等)。</p>
|
||||
*
|
||||
* @date 2026-09-20 15:45:00
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class SchemaMigrator {
|
||||
|
||||
/** 交易记录表名 */
|
||||
private static final String TRANSACTION_TABLE = "t_transaction";
|
||||
|
||||
/** 旧列名:支付方式 */
|
||||
private static final String OLD_CHANNEL_COLUMN = "payment_method";
|
||||
|
||||
/** 新列名:交易渠道 */
|
||||
private static final String NEW_CHANNEL_COLUMN = "channel";
|
||||
|
||||
/**
|
||||
* 执行结构迁移。
|
||||
*
|
||||
* @param dataSource 目标数据源
|
||||
* @param type 数据库类型
|
||||
*/
|
||||
public void migrate(DataSource dataSource, DataSourceType type) {
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
renameChannelColumn(connection, type);
|
||||
} catch (SQLException e) {
|
||||
throw new IllegalStateException("数据库结构迁移失败:" + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 {@code t_transaction.payment_method} 重命名为 {@code channel}。
|
||||
*
|
||||
* <p>仅当旧列存在且新列不存在时执行,保证幂等。</p>
|
||||
*
|
||||
* @param connection 数据库连接
|
||||
* @param type 数据库类型
|
||||
* @throws SQLException 迁移失败
|
||||
*/
|
||||
private void renameChannelColumn(Connection connection, DataSourceType type) throws SQLException {
|
||||
if (!columnExists(connection, type, TRANSACTION_TABLE, OLD_CHANNEL_COLUMN)) {
|
||||
return;
|
||||
}
|
||||
if (columnExists(connection, type, TRANSACTION_TABLE, NEW_CHANNEL_COLUMN)) {
|
||||
log.warn("{} 同时存在 {} 与 {} 列,跳过重命名迁移",
|
||||
TRANSACTION_TABLE, OLD_CHANNEL_COLUMN, NEW_CHANNEL_COLUMN);
|
||||
return;
|
||||
}
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.execute("ALTER TABLE " + TRANSACTION_TABLE + " RENAME COLUMN "
|
||||
+ OLD_CHANNEL_COLUMN + " TO " + NEW_CHANNEL_COLUMN);
|
||||
}
|
||||
log.info("已将 {} 的列 {} 重命名为 {}", TRANSACTION_TABLE, OLD_CHANNEL_COLUMN, NEW_CHANNEL_COLUMN);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断列是否存在。
|
||||
*
|
||||
* @param connection 数据库连接
|
||||
* @param type 数据库类型
|
||||
* @param table 表名
|
||||
* @param column 列名
|
||||
* @return 是否存在
|
||||
* @throws SQLException 查询失败
|
||||
*/
|
||||
private boolean columnExists(Connection connection, DataSourceType type, String table, String column)
|
||||
throws SQLException {
|
||||
if (type == DataSourceType.SQLITE) {
|
||||
return sqliteColumnExists(connection, table, column);
|
||||
}
|
||||
return mysqlColumnExists(connection, table, column);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 PRAGMA 查询 SQLite 表结构判断列是否存在。
|
||||
*
|
||||
* @param connection 数据库连接
|
||||
* @param table 表名
|
||||
* @param column 列名
|
||||
* @return 是否存在
|
||||
* @throws SQLException 查询失败
|
||||
*/
|
||||
private boolean sqliteColumnExists(Connection connection, String table, String column) throws SQLException {
|
||||
try (Statement statement = connection.createStatement();
|
||||
ResultSet resultSet = statement.executeQuery("PRAGMA table_info(" + table + ")")) {
|
||||
while (resultSet.next()) {
|
||||
if (column.equalsIgnoreCase(resultSet.getString("name"))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 information_schema 判断 MySQL 列是否存在。
|
||||
*
|
||||
* @param connection 数据库连接
|
||||
* @param table 表名
|
||||
* @param column 列名
|
||||
* @return 是否存在
|
||||
* @throws SQLException 查询失败
|
||||
*/
|
||||
private boolean mysqlColumnExists(Connection connection, String table, String column) throws SQLException {
|
||||
String sql = "SELECT COUNT(*) FROM information_schema.COLUMNS "
|
||||
+ "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?";
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, table);
|
||||
statement.setString(2, column);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
return resultSet.next() && resultSet.getInt(1) > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import cn.rosercode.finance.dto.AiConfigRequest;
|
||||
import cn.rosercode.finance.dto.AiDecorateRequest;
|
||||
import cn.rosercode.finance.dto.AiQueryRequest;
|
||||
import cn.rosercode.finance.dto.AiRecordRequest;
|
||||
import cn.rosercode.finance.dto.AiRuleApplyRequest;
|
||||
import cn.rosercode.finance.dto.AiRuleSuggestRequest;
|
||||
import cn.rosercode.finance.dto.AiSuggestOneRequest;
|
||||
import cn.rosercode.finance.dto.AiSuggestRequest;
|
||||
import cn.rosercode.finance.dto.AiTagRequest;
|
||||
@@ -28,6 +30,8 @@ import cn.rosercode.finance.service.AiConfigService;
|
||||
import cn.rosercode.finance.service.AiDecorationService;
|
||||
import cn.rosercode.finance.service.AiQueryService;
|
||||
import cn.rosercode.finance.service.AiRecordService;
|
||||
import cn.rosercode.finance.service.AiRuleService;
|
||||
import cn.rosercode.finance.service.AiStatisticsService;
|
||||
import cn.rosercode.finance.service.AiSuggestionService;
|
||||
import cn.rosercode.finance.service.AiTagService;
|
||||
import cn.rosercode.finance.service.AiUsageService;
|
||||
@@ -36,6 +40,8 @@ import cn.rosercode.finance.vo.AiCategoryResultVO;
|
||||
import cn.rosercode.finance.vo.AiConfigVO;
|
||||
import cn.rosercode.finance.vo.AiQueryResultVO;
|
||||
import cn.rosercode.finance.vo.AiRecordResultVO;
|
||||
import cn.rosercode.finance.vo.AiRuleSuggestResultVO;
|
||||
import cn.rosercode.finance.vo.AiStatisticsResultVO;
|
||||
import cn.rosercode.finance.vo.AiSuggestionVO;
|
||||
import cn.rosercode.finance.vo.AiTagResultVO;
|
||||
import cn.rosercode.finance.vo.AiUsageResultVO;
|
||||
@@ -60,11 +66,14 @@ public class AiController {
|
||||
private final AiDecorationService aiDecorationService;
|
||||
private final AiCategoryService aiCategoryService;
|
||||
private final AiTagService aiTagService;
|
||||
private final AiRuleService aiRuleService;
|
||||
private final AiStatisticsService aiStatisticsService;
|
||||
|
||||
public AiController(AiConfigService aiConfigService, AiSuggestionService aiSuggestionService,
|
||||
AiUsageService aiUsageService, AiQueryService aiQueryService,
|
||||
AiRecordService aiRecordService, AiDecorationService aiDecorationService,
|
||||
AiCategoryService aiCategoryService, AiTagService aiTagService) {
|
||||
AiCategoryService aiCategoryService, AiTagService aiTagService,
|
||||
AiRuleService aiRuleService, AiStatisticsService aiStatisticsService) {
|
||||
this.aiConfigService = aiConfigService;
|
||||
this.aiSuggestionService = aiSuggestionService;
|
||||
this.aiUsageService = aiUsageService;
|
||||
@@ -73,6 +82,8 @@ public class AiController {
|
||||
this.aiDecorationService = aiDecorationService;
|
||||
this.aiCategoryService = aiCategoryService;
|
||||
this.aiTagService = aiTagService;
|
||||
this.aiRuleService = aiRuleService;
|
||||
this.aiStatisticsService = aiStatisticsService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,4 +254,43 @@ public class AiController {
|
||||
Long userId = UserContext.getUserId();
|
||||
return Result.success(aiDecorationService.suggestColor(userId, decorateRequest.getName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据选中的已打标签记录生成分类与标签规则建议
|
||||
*
|
||||
* @param request 选中的记录
|
||||
* @return 规则建议结果
|
||||
*/
|
||||
@PostMapping("/rule/suggest")
|
||||
@Operation(summary = "AI 生成规则", description = "根据选中的已打标签记录,生成分类与标签规则建议")
|
||||
public Result<AiRuleSuggestResultVO> suggestRules(@RequestBody @Valid AiRuleSuggestRequest request) {
|
||||
Long userId = UserContext.getUserId();
|
||||
return Result.success(aiRuleService.suggest(userId, request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量创建 AI 生成的规则
|
||||
*
|
||||
* @param request 待创建的规则
|
||||
* @return 实际创建的规则数量
|
||||
*/
|
||||
@PostMapping("/rule/apply")
|
||||
@Operation(summary = "应用 AI 规则", description = "批量创建 AI 生成的规则,必要时新建分类与标签")
|
||||
public Result<Integer> applyRules(@RequestBody @Valid AiRuleApplyRequest request) {
|
||||
Long userId = UserContext.getUserId();
|
||||
return Result.success(aiRuleService.apply(userId, request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将自然语言统计查询解析为统计面板参数
|
||||
*
|
||||
* @param queryRequest 自然语言查询
|
||||
* @return 统计查询参数
|
||||
*/
|
||||
@PostMapping("/statistics")
|
||||
@Operation(summary = "AI 统计搜索", description = "将自然语言解析为统计面板的筛选、维度与统计口径参数")
|
||||
public Result<AiStatisticsResultVO> statistics(@RequestBody @Valid AiQueryRequest queryRequest) {
|
||||
Long userId = UserContext.getUserId();
|
||||
return Result.success(aiStatisticsService.parse(userId, queryRequest.getQuery()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package cn.rosercode.finance.controller;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -11,8 +14,10 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import cn.rosercode.finance.common.Result;
|
||||
import cn.rosercode.finance.config.app.AppConfig;
|
||||
import cn.rosercode.finance.config.app.AppConfigStore;
|
||||
import cn.rosercode.finance.dto.BillSourceStatsRequest;
|
||||
import cn.rosercode.finance.dto.LogRetentionRequest;
|
||||
import cn.rosercode.finance.dto.NotificationUrlRequest;
|
||||
import cn.rosercode.finance.enums.BillType;
|
||||
|
||||
/**
|
||||
* 应用配置控制器
|
||||
@@ -85,4 +90,55 @@ public class AppConfigController {
|
||||
appConfigStore.save(config);
|
||||
return Result.success(config.getLogRetentionDays());
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询按账单来源的「计入统计」默认值。
|
||||
*
|
||||
* @return 账单来源编码到是否计入统计的映射,包含全部支持的来源
|
||||
*/
|
||||
@GetMapping("/bill-source-include-in-stats")
|
||||
@Operation(summary = "查询账单来源统计默认值", description = "返回各账单来源导入时「是否计入统计」的默认值")
|
||||
public Result<Map<String, Boolean>> getBillSourceIncludeInStats() {
|
||||
return Result.success(effectiveBillSourceStats(appConfigStore.load()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新按账单来源的「计入统计」默认值。
|
||||
*
|
||||
* @param request 各账单来源的默认值
|
||||
* @return 更新后的有效映射
|
||||
*/
|
||||
@PutMapping("/bill-source-include-in-stats")
|
||||
@Operation(summary = "更新账单来源统计默认值", description = "设置各账单来源导入时「是否计入统计」的默认值")
|
||||
public Result<Map<String, Boolean>> updateBillSourceIncludeInStats(@RequestBody BillSourceStatsRequest request) {
|
||||
AppConfig config = appConfigStore.load();
|
||||
Map<String, Boolean> defaults = new LinkedHashMap<>();
|
||||
Map<String, Boolean> requested = request.getDefaults();
|
||||
if (requested != null) {
|
||||
// 仅保留系统支持的账单来源,忽略非法键
|
||||
for (BillType type : BillType.values()) {
|
||||
Boolean value = requested.get(type.name());
|
||||
if (value != null) {
|
||||
defaults.put(type.name(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
config.setBillSourceIncludeInStats(defaults);
|
||||
appConfigStore.save(config);
|
||||
return Result.success(effectiveBillSourceStats(config));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建包含全部账单来源的有效默认值映射。
|
||||
*
|
||||
* @param config 应用配置
|
||||
* @return 账单来源编码到是否计入统计的映射
|
||||
*/
|
||||
private Map<String, Boolean> effectiveBillSourceStats(AppConfig config) {
|
||||
Map<String, Boolean> result = new LinkedHashMap<>();
|
||||
for (BillType type : BillType.values()) {
|
||||
result.put(type.name(), config.resolveIncludeInStats(type.name()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import cn.rosercode.finance.common.PageResult;
|
||||
import cn.rosercode.finance.common.Result;
|
||||
import cn.rosercode.finance.dto.RuleQuery;
|
||||
import cn.rosercode.finance.dto.RuleRequest;
|
||||
import cn.rosercode.finance.service.RuleService;
|
||||
import cn.rosercode.finance.util.UserContext;
|
||||
@@ -52,6 +54,19 @@ public class RuleController {
|
||||
return Result.success(ruleService.list(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询规则
|
||||
*
|
||||
* @param query 分页与筛选条件
|
||||
* @return 分页规则列表
|
||||
*/
|
||||
@GetMapping("/page")
|
||||
@Operation(summary = "分页查询规则", description = "按名称/关键词、启用状态与收支类型分页查询当前用户的规则")
|
||||
public Result<PageResult<RuleVO>> page(@Parameter(description = "分页查询参数") RuleQuery query) {
|
||||
Long userId = UserContext.getUserId();
|
||||
return Result.success(ruleService.page(userId, query));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建规则
|
||||
*
|
||||
|
||||
@@ -97,7 +97,7 @@ public class StatisticsController {
|
||||
/**
|
||||
* 按维度获取分组统计数据
|
||||
*
|
||||
* @param dimension 统计维度(day/week/month/quarter/year/paymentMethod/category/type)
|
||||
* @param dimension 统计维度(day/week/month/quarter/year/channel/category/type)
|
||||
* @param query 时间范围查询参数
|
||||
* @return 分组统计列表
|
||||
*/
|
||||
|
||||
@@ -58,14 +58,14 @@ public class TransactionController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统支持的支付方式列表
|
||||
* 获取系统支持的交易渠道列表
|
||||
*
|
||||
* @return 支付方式编码列表
|
||||
* @return 交易渠道编码列表
|
||||
*/
|
||||
@GetMapping("/payment-methods")
|
||||
@Operation(summary = "获取支付方式列表", description = "返回系统支持的支付方式编码,含账单导入来源与人工支付方式")
|
||||
public Result<List<String>> paymentMethods() {
|
||||
return Result.success(transactionService.listPaymentMethods());
|
||||
@GetMapping("/channels")
|
||||
@Operation(summary = "获取交易渠道列表", description = "返回系统支持的交易渠道编码,含账单导入来源与人工交易渠道")
|
||||
public Result<List<String>> channels() {
|
||||
return Result.success(transactionService.listChannels());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package cn.rosercode.finance.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
import cn.rosercode.finance.vo.AiNewTagVO;
|
||||
|
||||
/**
|
||||
* AI 生成规则的批量创建请求。
|
||||
*
|
||||
* @date 2026-09-20 16:05:00
|
||||
*/
|
||||
@Data
|
||||
public class AiRuleApplyRequest {
|
||||
|
||||
/** 待创建的规则列表,不能为空 */
|
||||
@NotEmpty(message = "请至少选择一条规则")
|
||||
private List<Item> rules;
|
||||
|
||||
/**
|
||||
* 单条待创建规则。
|
||||
*/
|
||||
@Data
|
||||
public static class Item {
|
||||
|
||||
/** 规则名称 */
|
||||
private String name;
|
||||
|
||||
/** 是否启用,默认启用 */
|
||||
private Boolean enabled;
|
||||
|
||||
/** 限定收支类型:INCOME/EXPENSE,为空表示不限 */
|
||||
private String type;
|
||||
|
||||
/** 匹配关键词 */
|
||||
private String keyword;
|
||||
|
||||
/** 金额下限(含) */
|
||||
private BigDecimal minAmount;
|
||||
|
||||
/** 金额上限(含) */
|
||||
private BigDecimal maxAmount;
|
||||
|
||||
/** 现有分类ID */
|
||||
private Long categoryId;
|
||||
|
||||
/** 需新建的分类名称 */
|
||||
private String newCategoryName;
|
||||
|
||||
/** 需新建的分类图标 */
|
||||
private String newCategoryIcon;
|
||||
|
||||
/** 现有标签ID列表 */
|
||||
private List<Long> tagIds;
|
||||
|
||||
/** 需新建的标签列表 */
|
||||
private List<AiNewTagVO> newTags;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package cn.rosercode.finance.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* AI 规则生成请求,携带选中的已打标签记录。
|
||||
*
|
||||
* @date 2026-09-20 17:10:00
|
||||
*/
|
||||
@Data
|
||||
public class AiRuleSuggestRequest {
|
||||
|
||||
/** 选中的交易记录ID列表,不能为空 */
|
||||
@NotEmpty(message = "请至少选择一条记录")
|
||||
private List<Long> transactionIds;
|
||||
}
|
||||
@@ -20,8 +20,8 @@ public class AiSuggestOneRequest {
|
||||
/** 交易金额 */
|
||||
private BigDecimal amount;
|
||||
|
||||
/** 支付方式 */
|
||||
private String paymentMethod;
|
||||
/** 交易渠道 */
|
||||
private String channel;
|
||||
|
||||
/** 备注 */
|
||||
private String remark;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package cn.rosercode.finance.dto;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 按账单来源的「计入统计」默认值更新请求。
|
||||
*
|
||||
* @date 2026-09-20 15:55:00
|
||||
*/
|
||||
@Data
|
||||
public class BillSourceStatsRequest {
|
||||
|
||||
/** 账单来源编码(BillType 名称)到是否计入统计的映射 */
|
||||
private Map<String, Boolean> defaults;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package cn.rosercode.finance.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 规则分页查询 DTO。
|
||||
*
|
||||
* @date 2026-09-20 18:10:00
|
||||
*/
|
||||
@Data
|
||||
public class RuleQuery {
|
||||
|
||||
/** 当前页码,默认第1页 */
|
||||
private Integer page = 1;
|
||||
|
||||
/** 每页条数,默认20条 */
|
||||
private Integer size = 20;
|
||||
|
||||
/** 关键字,模糊匹配规则名称或关键词 */
|
||||
private String keyword;
|
||||
|
||||
/** 启用状态筛选:null-全部,true-启用,false-停用 */
|
||||
private Boolean enabled;
|
||||
|
||||
/** 收支类型筛选:INCOME/EXPENSE,为空表示不限 */
|
||||
private String type;
|
||||
}
|
||||
@@ -28,8 +28,14 @@ public class StatisticsRangeQuery {
|
||||
/** 是否仅筛选未分类记录;与 categoryIds 同时存在时取并集 */
|
||||
private Boolean uncategorized;
|
||||
|
||||
/** 支付方式筛选(WECHAT/ALIPAY/CASH/BANK),支持多选 */
|
||||
private List<String> paymentMethods;
|
||||
/** 交易渠道筛选(WECHAT/ALIPAY/CASH/BANK),支持多选 */
|
||||
private List<String> channels;
|
||||
|
||||
/** 标签ID筛选,支持多选 */
|
||||
private List<Long> tagIds;
|
||||
|
||||
/** 是否仅筛选未打标签记录;与 tagIds 同时存在时取并集 */
|
||||
private Boolean untagged;
|
||||
|
||||
/** 最小金额(含),可选 */
|
||||
private BigDecimal minAmount;
|
||||
|
||||
@@ -32,8 +32,8 @@ public class TransactionQuery {
|
||||
/** 查询结束时间 */
|
||||
private String endTime;
|
||||
|
||||
/** 支付方式筛选(WECHAT/ALIPAY/CASH/BANK),支持多选 */
|
||||
private List<String> paymentMethods;
|
||||
/** 交易渠道筛选(WECHAT/ALIPAY/CASH/BANK),支持多选 */
|
||||
private List<String> channels;
|
||||
|
||||
/** 是否计入统计筛选:null-全部,true-仅计入,false-仅不计入 */
|
||||
private Boolean includeInStats;
|
||||
@@ -58,4 +58,7 @@ public class TransactionQuery {
|
||||
|
||||
/** 是否仅筛选无标签记录;与 tagIds 同时存在时取并集 */
|
||||
private Boolean untagged;
|
||||
|
||||
/** 是否仅筛选有标签记录;与 tagIds/untagged 同时存在时取并集 */
|
||||
private Boolean tagged;
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ public class TransactionRequest {
|
||||
@DecimalMin(value = "0.01", message = "金额必须大于0")
|
||||
private BigDecimal amount;
|
||||
|
||||
/** 支付方式,可选 */
|
||||
private String paymentMethod;
|
||||
/** 交易渠道,可选 */
|
||||
private String channel;
|
||||
|
||||
/** 备注,可选 */
|
||||
private String remark;
|
||||
|
||||
@@ -37,8 +37,8 @@ public class Transaction {
|
||||
/** 交易金额 */
|
||||
private BigDecimal amount;
|
||||
|
||||
/** 支付方式 */
|
||||
private String paymentMethod;
|
||||
/** 交易渠道 */
|
||||
private String channel;
|
||||
|
||||
/** 备注 */
|
||||
private String remark;
|
||||
|
||||
@@ -26,5 +26,11 @@ public enum AiCallType {
|
||||
CATEGORY,
|
||||
|
||||
/** 自然语言生成标签 */
|
||||
TAG
|
||||
TAG,
|
||||
|
||||
/** 根据账单数据生成分类与标签规则 */
|
||||
RULE,
|
||||
|
||||
/** 自然语言统计查询解析 */
|
||||
STATISTICS
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ public enum StatisticsDimension {
|
||||
QUARTER(true),
|
||||
/** 按年 */
|
||||
YEAR(true),
|
||||
/** 按支付方式 */
|
||||
PAYMENT_METHOD(false),
|
||||
/** 按交易渠道 */
|
||||
CHANNEL(false),
|
||||
/** 按分类 */
|
||||
CATEGORY(false),
|
||||
/** 按交易类型 */
|
||||
@@ -45,7 +45,7 @@ public enum StatisticsDimension {
|
||||
/**
|
||||
* 将字符串解析为统计维度,兼容 camelCase 与 UPPER_SNAKE_CASE
|
||||
*
|
||||
* @param value 维度字符串,如 day、paymentMethod、PAYMENT_METHOD
|
||||
* @param value 维度字符串,如 day、channel、CHANNEL
|
||||
* @return 统计维度
|
||||
*/
|
||||
public static StatisticsDimension from(String value) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package cn.rosercode.finance.service;
|
||||
|
||||
import cn.rosercode.finance.dto.AiRuleApplyRequest;
|
||||
import cn.rosercode.finance.dto.AiRuleSuggestRequest;
|
||||
import cn.rosercode.finance.vo.AiRuleSuggestResultVO;
|
||||
|
||||
/**
|
||||
* AI 规则服务,根据账单数据生成分类与标签规则建议并批量创建。
|
||||
*
|
||||
* @date 2026-09-20 16:05:00
|
||||
*/
|
||||
public interface AiRuleService {
|
||||
|
||||
/**
|
||||
* 基于选中的已打标签记录生成规则建议。
|
||||
*
|
||||
* @param userId 当前用户ID
|
||||
* @param request 选中的记录
|
||||
* @return 规则建议结果
|
||||
*/
|
||||
AiRuleSuggestResultVO suggest(Long userId, AiRuleSuggestRequest request);
|
||||
|
||||
/**
|
||||
* 批量创建规则,必要时新建分类与标签。
|
||||
*
|
||||
* @param userId 当前用户ID
|
||||
* @param request 待创建的规则
|
||||
* @return 实际创建的规则数量
|
||||
*/
|
||||
int apply(Long userId, AiRuleApplyRequest request);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package cn.rosercode.finance.service;
|
||||
|
||||
import cn.rosercode.finance.vo.AiStatisticsResultVO;
|
||||
|
||||
/**
|
||||
* AI 统计查询服务,将自然语言解析为统计面板的查询参数。
|
||||
*
|
||||
* @date 2026-09-20 16:30:00
|
||||
*/
|
||||
public interface AiStatisticsService {
|
||||
|
||||
/**
|
||||
* 解析自然语言统计查询。
|
||||
*
|
||||
* @param userId 当前用户ID
|
||||
* @param query 自然语言查询
|
||||
* @return 统计查询参数
|
||||
*/
|
||||
AiStatisticsResultVO parse(Long userId, String query);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package cn.rosercode.finance.service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import cn.rosercode.finance.common.PageResult;
|
||||
import cn.rosercode.finance.dto.RuleQuery;
|
||||
import cn.rosercode.finance.dto.RuleRequest;
|
||||
import cn.rosercode.finance.vo.RuleVO;
|
||||
|
||||
@@ -20,6 +22,15 @@ public interface RuleService {
|
||||
*/
|
||||
List<RuleVO> list(Long userId);
|
||||
|
||||
/**
|
||||
* 分页查询规则,支持按名称/关键词、启用状态与收支类型筛选。
|
||||
*
|
||||
* @param userId 当前用户ID
|
||||
* @param query 分页与筛选条件
|
||||
* @return 分页规则列表
|
||||
*/
|
||||
PageResult<RuleVO> page(Long userId, RuleQuery query);
|
||||
|
||||
/**
|
||||
* 创建规则。
|
||||
*
|
||||
|
||||
@@ -106,12 +106,12 @@ public interface TransactionService {
|
||||
int batchTags(Long userId, BatchTagRequest request);
|
||||
|
||||
/**
|
||||
* 获取系统支持的支付方式编码列表。
|
||||
* 获取系统支持的交易渠道编码列表。
|
||||
*
|
||||
* <p>由账单来源({@link cn.rosercode.finance.enums.BillType})与人工记账支付方式组成,
|
||||
* <p>由账单来源({@link cn.rosercode.finance.enums.BillType})与人工记账交易渠道组成,
|
||||
* 供前端筛选与编辑表单动态渲染。</p>
|
||||
*
|
||||
* @return 支付方式编码列表
|
||||
* @return 交易渠道编码列表
|
||||
*/
|
||||
List<String> listPaymentMethods();
|
||||
List<String> listChannels();
|
||||
}
|
||||
|
||||
@@ -45,8 +45,8 @@ public class AiQueryServiceImpl implements AiQueryService {
|
||||
/** 合法的收支类型 */
|
||||
private static final List<String> VALID_TYPES = List.of("INCOME", "EXPENSE");
|
||||
|
||||
/** 合法的支付方式 */
|
||||
private static final List<String> VALID_PAYMENTS = List.of("WECHAT", "ALIPAY", "CASH", "BANK");
|
||||
/** 合法的交易渠道 */
|
||||
private static final List<String> VALID_CHANNELS = List.of("WECHAT", "ALIPAY", "CASH", "BANK");
|
||||
|
||||
private final AiConfigService aiConfigService;
|
||||
private final CategoryMapper categoryMapper;
|
||||
@@ -123,7 +123,7 @@ public class AiQueryServiceImpl implements AiQueryService {
|
||||
String today = LocalDate.now().format(DATE_FMT);
|
||||
return "你是一个记账系统的查询解析助手。请把用户的自然语言查询解析为结构化筛选条件,只输出 JSON,不要输出任何多余内容。\n"
|
||||
+ "可用的收支类型:INCOME(收入)、EXPENSE(支出)。\n"
|
||||
+ "可用的支付方式:WECHAT(微信)、ALIPAY(支付宝)、CASH(现金)、BANK(银行卡)。\n"
|
||||
+ "可用的交易渠道:WECHAT(微信)、ALIPAY(支付宝)、CASH(现金)、BANK(银行卡)。\n"
|
||||
+ "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
|
||||
+ "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
|
||||
+ "时间范围请使用 preset 表示,取值:TODAY、YESTERDAY、THIS_WEEK、LAST_WEEK、THIS_MONTH、LAST_MONTH、"
|
||||
@@ -131,7 +131,7 @@ public class AiQueryServiceImpl implements AiQueryService {
|
||||
+ "未提及的字段请留空(数组用 [],其余用 null)。分类和标签只能使用上面给出的名称。\n"
|
||||
+ "今天是 " + today + "。\n"
|
||||
+ "输出格式:{\"summary\":\"简要说明识别到的查询意图\",\"types\":[],\"categoryNames\":[],\"tagNames\":[],"
|
||||
+ "\"paymentMethods\":[],\"dateRange\":{\"preset\":\"NONE\",\"start\":null,\"end\":null},\"minAmount\":null,"
|
||||
+ "\"channels\":[],\"dateRange\":{\"preset\":\"NONE\",\"start\":null,\"end\":null},\"minAmount\":null,"
|
||||
+ "\"maxAmount\":null,\"remark\":null,\"includeInStats\":null,\"uncategorized\":false,\"unmatched\":[]}";
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ public class AiQueryServiceImpl implements AiQueryService {
|
||||
AiQueryResultVO vo = new AiQueryResultVO();
|
||||
vo.setSummary(root.path("summary").asText(""));
|
||||
vo.setTypes(filterValid(root.path("types"), VALID_TYPES));
|
||||
vo.setPaymentMethods(filterValid(root.path("paymentMethods"), VALID_PAYMENTS));
|
||||
vo.setChannels(filterValid(root.path("channels"), VALID_CHANNELS));
|
||||
vo.setUncategorized(root.path("uncategorized").asBoolean(false));
|
||||
|
||||
JsonNode statsNode = root.path("includeInStats");
|
||||
|
||||
@@ -42,8 +42,8 @@ public class AiRecordServiceImpl implements AiRecordService {
|
||||
/** 合法的收支类型 */
|
||||
private static final List<String> VALID_TYPES = List.of("INCOME", "EXPENSE");
|
||||
|
||||
/** 合法的支付方式 */
|
||||
private static final List<String> VALID_PAYMENTS = List.of("WECHAT", "ALIPAY", "CASH", "BANK");
|
||||
/** 合法的交易渠道 */
|
||||
private static final List<String> VALID_CHANNELS = List.of("WECHAT", "ALIPAY", "CASH", "BANK");
|
||||
|
||||
private final AiConfigService aiConfigService;
|
||||
private final CategoryMapper categoryMapper;
|
||||
@@ -120,17 +120,17 @@ public class AiRecordServiceImpl implements AiRecordService {
|
||||
String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||
return "你是一个记账助手。请把用户的自然语言记账内容解析为一条结构化交易记录,只输出 JSON,不要输出任何多余内容。\n"
|
||||
+ "可用的收支类型:INCOME(收入)、EXPENSE(支出)。\n"
|
||||
+ "可用的支付方式:WECHAT(微信)、ALIPAY(支付宝)、CASH(现金)、BANK(银行卡)。\n"
|
||||
+ "可用的交易渠道:WECHAT(微信)、ALIPAY(支付宝)、CASH(现金)、BANK(银行卡)。\n"
|
||||
+ "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
|
||||
+ "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
|
||||
+ "规则:1. type 默认为 EXPENSE,除非明显是收入;2. amount 为必填数字;"
|
||||
+ "3. categoryNames 从可用分类中选择最合适的一个(数组,最多一个),无法判断则为空数组;"
|
||||
+ "4. tagNames 从可用标签中选择(数组,可为空);5. paymentMethod 从可用支付方式中选择,无法判断为 null;"
|
||||
+ "4. tagNames 从可用标签中选择(数组,可为空);5. channel 从可用交易渠道中选择,无法判断为 null;"
|
||||
+ "6. transactionTime 输出 yyyy-MM-ddTHH:mm:ss,未提及时间用当前时间,相对时间(如昨天、上周五)请换算;"
|
||||
+ "7. includeInStats 默认 true;8. remark 给出简洁备注。\n"
|
||||
+ "当前时间:" + now + "。\n"
|
||||
+ "输出格式:{\"summary\":\"简要说明识别到的记账内容\",\"type\":\"EXPENSE\",\"amount\":35.5,"
|
||||
+ "\"categoryNames\":[\"餐饮\"],\"tagNames\":[],\"paymentMethod\":\"ALIPAY\","
|
||||
+ "\"categoryNames\":[\"餐饮\"],\"tagNames\":[],\"channel\":\"ALIPAY\","
|
||||
+ "\"transactionTime\":\"" + LocalDateTime.now().format(DATETIME_FMT) + "\",\"remark\":\"午餐\","
|
||||
+ "\"includeInStats\":true,\"unmatched\":[]}";
|
||||
}
|
||||
@@ -193,10 +193,10 @@ public class AiRecordServiceImpl implements AiRecordService {
|
||||
vo.setTagIds(tagIds);
|
||||
vo.setTagNames(tagNames);
|
||||
|
||||
// 支付方式
|
||||
String payment = root.path("paymentMethod").asText("").toUpperCase();
|
||||
if (VALID_PAYMENTS.contains(payment)) {
|
||||
vo.setPaymentMethod(payment);
|
||||
// 交易渠道
|
||||
String channel = root.path("channel").asText("").toUpperCase();
|
||||
if (VALID_CHANNELS.contains(channel)) {
|
||||
vo.setChannel(channel);
|
||||
}
|
||||
|
||||
// 交易时间
|
||||
|
||||
@@ -0,0 +1,811 @@
|
||||
package cn.rosercode.finance.service.impl;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import cn.rosercode.finance.client.DeepSeekClient;
|
||||
import cn.rosercode.finance.client.DeepSeekResponse;
|
||||
import cn.rosercode.finance.common.BusinessException;
|
||||
import cn.rosercode.finance.dto.AiRuleApplyRequest;
|
||||
import cn.rosercode.finance.dto.AiRuleSuggestRequest;
|
||||
import cn.rosercode.finance.dto.RuleRequest;
|
||||
import cn.rosercode.finance.entity.AiConfig;
|
||||
import cn.rosercode.finance.entity.Category;
|
||||
import cn.rosercode.finance.entity.Tag;
|
||||
import cn.rosercode.finance.entity.Transaction;
|
||||
import cn.rosercode.finance.entity.TransactionTag;
|
||||
import cn.rosercode.finance.enums.AiCallType;
|
||||
import cn.rosercode.finance.mapper.CategoryMapper;
|
||||
import cn.rosercode.finance.mapper.TagMapper;
|
||||
import cn.rosercode.finance.mapper.TransactionMapper;
|
||||
import cn.rosercode.finance.mapper.TransactionTagMapper;
|
||||
import cn.rosercode.finance.service.AiConfigService;
|
||||
import cn.rosercode.finance.service.AiRuleService;
|
||||
import cn.rosercode.finance.service.AiUsageService;
|
||||
import cn.rosercode.finance.service.RuleService;
|
||||
import cn.rosercode.finance.vo.AiNewTagVO;
|
||||
import cn.rosercode.finance.vo.AiRuleSuggestResultVO;
|
||||
import cn.rosercode.finance.vo.AiRuleSuggestionVO;
|
||||
import cn.rosercode.finance.vo.RuleVO;
|
||||
|
||||
/**
|
||||
* AI 规则服务实现类,根据选中的已打标签记录反推分类与标签规则。
|
||||
*
|
||||
* @date 2026-09-20 16:05:00
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class AiRuleServiceImpl implements AiRuleService {
|
||||
|
||||
/** 单次分析的最大记录条数 */
|
||||
private static final int MAX_SELECTED_TRANSACTIONS = 500;
|
||||
|
||||
/** 规则名称最大长度 */
|
||||
private static final int RULE_NAME_MAX_LENGTH = 50;
|
||||
|
||||
/** 关键词最大长度 */
|
||||
private static final int KEYWORD_MAX_LENGTH = 100;
|
||||
|
||||
/** 分类名称最大长度 */
|
||||
private static final int CATEGORY_NAME_MAX_LENGTH = 20;
|
||||
|
||||
/** 标签名称最大长度 */
|
||||
private static final int TAG_NAME_MAX_LENGTH = 20;
|
||||
|
||||
/** 图标最大长度 */
|
||||
private static final int ICON_MAX_LENGTH = 16;
|
||||
|
||||
/** 默认标签颜色 */
|
||||
private static final String DEFAULT_TAG_COLOR = "#2080f0";
|
||||
|
||||
/** 合法的十六进制颜色 */
|
||||
private static final Pattern COLOR_PATTERN = Pattern.compile("^#[0-9a-fA-F]{6}$");
|
||||
|
||||
/** 系统提示词,要求模型严格返回 JSON */
|
||||
private static final String SYSTEM_PROMPT = "你是一个个人记账应用的规则生成助手。"
|
||||
+ "请根据给定的已打标签记录,总结出可用于自动分类与打标签的规则,只输出 JSON。\n"
|
||||
+ "每条记录包含收支类型、备注、交易渠道、已有分类(category)与已有标签(tags),请据此归纳可复用的关键词规则。\n"
|
||||
+ "规则字段说明:\n"
|
||||
+ "- name:规则名称,简洁中文,不超过 20 字;\n"
|
||||
+ "- type:限定收支类型,只能是 INCOME(收入)或 EXPENSE(支出),无法判断为 null;\n"
|
||||
+ "- keyword:匹配备注的关键词(包含匹配,忽略大小写),无法判断为 null;\n"
|
||||
+ "- categoryId:从「可用分类」中选择最合适的分类ID,无法对应时为 null;\n"
|
||||
+ "- newCategory:现有分类都不合适时给出 {\"name\":\"分类名\",\"type\":\"EXPENSE\",\"icon\":\"🚌\"},否则为 null;\n"
|
||||
+ "- tagIds:从「可用标签」中选择,数组;\n"
|
||||
+ "- newTags:需要新建的标签,格式 [{\"name\":\"标签名\",\"color\":\"#2f80ed\"}],可为空数组;\n"
|
||||
+ "- reason:简要中文理由。\n"
|
||||
+ "优先依据记录已有的标签与分类归纳规则;规则应聚焦于可复用的关键词,避免一条规则只对应单笔记录。\n"
|
||||
+ "输出格式:{\"summary\":\"简要中文摘要\",\"rules\":[{\"name\":\"...\",\"type\":\"EXPENSE\","
|
||||
+ "\"keyword\":\"星巴克\",\"categoryId\":12,\"newCategory\":null,\"tagIds\":[3],"
|
||||
+ "\"newTags\":[],\"reason\":\"...\"}]}";
|
||||
|
||||
private final AiConfigService aiConfigService;
|
||||
private final DeepSeekClient deepSeekClient;
|
||||
private final AiUsageService aiUsageService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final TransactionMapper transactionMapper;
|
||||
private final CategoryMapper categoryMapper;
|
||||
private final TagMapper tagMapper;
|
||||
private final TransactionTagMapper transactionTagMapper;
|
||||
private final RuleService ruleService;
|
||||
|
||||
public AiRuleServiceImpl(AiConfigService aiConfigService,
|
||||
DeepSeekClient deepSeekClient,
|
||||
AiUsageService aiUsageService,
|
||||
ObjectMapper objectMapper,
|
||||
TransactionMapper transactionMapper,
|
||||
CategoryMapper categoryMapper,
|
||||
TagMapper tagMapper,
|
||||
TransactionTagMapper transactionTagMapper,
|
||||
RuleService ruleService) {
|
||||
this.aiConfigService = aiConfigService;
|
||||
this.deepSeekClient = deepSeekClient;
|
||||
this.aiUsageService = aiUsageService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.transactionMapper = transactionMapper;
|
||||
this.categoryMapper = categoryMapper;
|
||||
this.tagMapper = tagMapper;
|
||||
this.transactionTagMapper = transactionTagMapper;
|
||||
this.ruleService = ruleService;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 仅加载属于当前用户且被勾选的记录,并附带其已有标签作为规则归纳依据。
|
||||
*/
|
||||
@Override
|
||||
public AiRuleSuggestResultVO suggest(Long userId, AiRuleSuggestRequest request) {
|
||||
AiConfig config = requireConfig(userId);
|
||||
|
||||
List<Long> requestedIds = request.getTransactionIds().stream().distinct().collect(Collectors.toList());
|
||||
if (requestedIds.size() > MAX_SELECTED_TRANSACTIONS) {
|
||||
throw new BusinessException("单次最多分析 " + MAX_SELECTED_TRANSACTIONS + " 条记录");
|
||||
}
|
||||
|
||||
List<Category> categories = categoryMapper.selectList(new LambdaQueryWrapper<Category>()
|
||||
.eq(Category::getUserId, userId));
|
||||
List<Tag> tags = tagMapper.selectList(new LambdaQueryWrapper<Tag>()
|
||||
.eq(Tag::getUserId, userId));
|
||||
|
||||
// 仅保留属于当前用户的记录,并保持请求顺序
|
||||
Map<Long, Transaction> transactionMap = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
|
||||
.eq(Transaction::getUserId, userId)
|
||||
.in(Transaction::getId, requestedIds))
|
||||
.stream().collect(Collectors.toMap(Transaction::getId, Function.identity()));
|
||||
List<Transaction> transactions = requestedIds.stream()
|
||||
.map(transactionMap::get)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
if (transactions.isEmpty()) {
|
||||
throw new BusinessException("所选记录不存在或不属于当前用户");
|
||||
}
|
||||
|
||||
// 批量查询所选记录的标签名称
|
||||
List<Long> selectedIds = transactions.stream().map(Transaction::getId).collect(Collectors.toList());
|
||||
Map<Long, List<String>> tagNamesByTransaction = loadTagNames(selectedIds, tags);
|
||||
|
||||
String userPrompt = buildUserPrompt(categories, tags, transactions, tagNamesByTransaction);
|
||||
long start = System.currentTimeMillis();
|
||||
DeepSeekResponse aiResponse;
|
||||
try {
|
||||
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
|
||||
SYSTEM_PROMPT, userPrompt);
|
||||
} catch (RuntimeException e) {
|
||||
aiUsageService.recordFailure(userId, AiCallType.RULE, config.getModel(), transactions.size(),
|
||||
System.currentTimeMillis() - start, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
aiUsageService.recordSuccess(userId, AiCallType.RULE, config.getModel(), transactions.size(),
|
||||
aiResponse, System.currentTimeMillis() - start);
|
||||
AiRuleSuggestResultVO result = parseResult(aiResponse.getContent(), categories, tags);
|
||||
// 标记与已有规则重复、或本次建议内部重复的条目
|
||||
markDuplicates(userId, result.getRules(), categories, tags);
|
||||
log.info("AI 规则生成完成: userId={}, 分析记录={}, 建议规则={}", userId, transactions.size(),
|
||||
result.getRules().size());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记重复的规则建议:与已有规则签名一致,或本次建议内部重复(保留首条)。
|
||||
*
|
||||
* @param userId 当前用户ID
|
||||
* @param rules 规则建议列表
|
||||
* @param categories 用户分类列表
|
||||
* @param tags 用户标签列表
|
||||
*/
|
||||
private void markDuplicates(Long userId, List<AiRuleSuggestionVO> rules,
|
||||
List<Category> categories, List<Tag> tags) {
|
||||
Set<String> existing = loadExistingRuleSignatures(userId);
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (AiRuleSuggestionVO rule : rules) {
|
||||
String signature = signatureOf(rule, categories, tags);
|
||||
boolean duplicated = existing.contains(signature) || !seen.add(signature);
|
||||
rule.setDuplicate(duplicated);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载当前用户已有规则的签名集合。
|
||||
*
|
||||
* @param userId 当前用户ID
|
||||
* @return 规则签名集合
|
||||
*/
|
||||
private Set<String> loadExistingRuleSignatures(Long userId) {
|
||||
List<RuleVO> existing = ruleService.list(userId);
|
||||
Set<String> signatures = new HashSet<>();
|
||||
for (RuleVO rule : existing) {
|
||||
String categoryKey = rule.getCategoryId() == null ? "" : "c:" + rule.getCategoryId();
|
||||
List<String> tagKeys = rule.getTags() == null ? List.of()
|
||||
: rule.getTags().stream().map(tag -> "t:" + tag.getId()).collect(Collectors.toList());
|
||||
signatures.add(ruleSignature(rule.getType(), rule.getKeyword(), categoryKey, tagKeys));
|
||||
}
|
||||
return signatures;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算规则建议的签名,newCategory/newTags 先按名称解析到已有分类/标签 ID。
|
||||
*
|
||||
* @param rule 规则建议
|
||||
* @param categories 用户分类列表
|
||||
* @param tags 用户标签列表
|
||||
* @return 规则签名
|
||||
*/
|
||||
private String signatureOf(AiRuleSuggestionVO rule, List<Category> categories, List<Tag> tags) {
|
||||
List<String> newTagNames = rule.getNewTags() == null ? null
|
||||
: rule.getNewTags().stream().map(AiNewTagVO::getName).collect(Collectors.toList());
|
||||
return buildSignature(rule.getType(), rule.getKeyword(), rule.getCategoryId(),
|
||||
rule.getNewCategoryName(), rule.getTagIds(), newTagNames, categories, tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建规则签名,newCategory/newTags 先按名称解析到已有分类/标签 ID。
|
||||
*
|
||||
* @param type 收支类型
|
||||
* @param keyword 关键词
|
||||
* @param categoryId 分类ID
|
||||
* @param newCategoryName 新建分类名称
|
||||
* @param tagIds 标签ID列表
|
||||
* @param newTagNames 新建标签名称列表
|
||||
* @param categories 用户分类列表
|
||||
* @param tags 用户标签列表
|
||||
* @return 规则签名
|
||||
*/
|
||||
private String buildSignature(String type, String keyword, Long categoryId, String newCategoryName,
|
||||
List<Long> tagIds, List<String> newTagNames,
|
||||
List<Category> categories, List<Tag> tags) {
|
||||
String categoryKey = "";
|
||||
if (categoryId != null) {
|
||||
categoryKey = "c:" + categoryId;
|
||||
} else if (!isBlank(newCategoryName)) {
|
||||
Category matched = findCategoryByName(categories, type, newCategoryName);
|
||||
categoryKey = matched != null ? "c:" + matched.getId()
|
||||
: "n:" + newCategoryName.trim().toLowerCase();
|
||||
}
|
||||
List<String> tagKeys = new ArrayList<>();
|
||||
if (tagIds != null) {
|
||||
tagIds.forEach(tagId -> tagKeys.add("t:" + tagId));
|
||||
}
|
||||
if (newTagNames != null) {
|
||||
for (String name : newTagNames) {
|
||||
if (isBlank(name)) {
|
||||
continue;
|
||||
}
|
||||
Tag matched = findTagByName(tags, name);
|
||||
tagKeys.add(matched != null ? "t:" + matched.getId() : "n:" + name.trim().toLowerCase());
|
||||
}
|
||||
}
|
||||
return ruleSignature(type, keyword, categoryKey, tagKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成规则签名:类型 + 归一化关键词 + 分类键 + 排序后的标签键。
|
||||
*
|
||||
* @param type 收支类型
|
||||
* @param keyword 关键词
|
||||
* @param categoryKey 分类键(c:ID 或 n:名称)
|
||||
* @param tagKeys 标签键列表
|
||||
* @return 规则签名
|
||||
*/
|
||||
private String ruleSignature(String type, String keyword, String categoryKey, List<String> tagKeys) {
|
||||
String normalizedType = isBlank(type) ? "" : type.trim().toUpperCase();
|
||||
String normalizedKeyword = isBlank(keyword) ? "" : keyword.trim().toLowerCase();
|
||||
List<String> sortedTags = new ArrayList<>(tagKeys);
|
||||
Collections.sort(sortedTags);
|
||||
return normalizedType + "|" + normalizedKeyword + "|" + (categoryKey == null ? "" : categoryKey)
|
||||
+ "|" + String.join(",", sortedTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按名称与类型查找已有分类。
|
||||
*
|
||||
* @param categories 用户分类列表
|
||||
* @param type 收支类型
|
||||
* @param name 分类名称
|
||||
* @return 匹配的分类,未命中返回 null
|
||||
*/
|
||||
private Category findCategoryByName(List<Category> categories, String type, String name) {
|
||||
if (isBlank(name)) {
|
||||
return null;
|
||||
}
|
||||
String target = name.trim();
|
||||
for (Category category : categories) {
|
||||
if ((type == null || type.equals(category.getType())) && category.getName().equalsIgnoreCase(target)) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按名称查找已有标签。
|
||||
*
|
||||
* @param tags 用户标签列表
|
||||
* @param name 标签名称
|
||||
* @return 匹配的标签,未命中返回 null
|
||||
*/
|
||||
private Tag findTagByName(List<Tag> tags, String name) {
|
||||
if (isBlank(name)) {
|
||||
return null;
|
||||
}
|
||||
String target = name.trim();
|
||||
for (Tag tag : tags) {
|
||||
if (tag.getName().equalsIgnoreCase(target)) {
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量加载交易的标签名称。
|
||||
*
|
||||
* @param transactionIds 交易ID列表
|
||||
* @param tags 用户标签列表
|
||||
* @return 交易ID到标签名称列表的映射
|
||||
*/
|
||||
private Map<Long, List<String>> loadTagNames(List<Long> transactionIds, List<Tag> tags) {
|
||||
Map<Long, String> tagNameMap = tags.stream()
|
||||
.collect(Collectors.toMap(Tag::getId, Tag::getName, (a, b) -> a));
|
||||
List<TransactionTag> relations = transactionTagMapper.selectList(new LambdaQueryWrapper<TransactionTag>()
|
||||
.in(TransactionTag::getTransactionId, transactionIds));
|
||||
Map<Long, List<String>> result = new LinkedHashMap<>();
|
||||
for (TransactionTag relation : relations) {
|
||||
String name = tagNameMap.get(relation.getTagId());
|
||||
if (name != null) {
|
||||
result.computeIfAbsent(relation.getTransactionId(), key -> new ArrayList<>()).add(name);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 逐条创建规则,必要时新建分类与标签;复用 {@link RuleService} 完成校验与落库。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public int apply(Long userId, AiRuleApplyRequest request) {
|
||||
List<AiRuleApplyRequest.Item> items = request.getRules();
|
||||
List<Category> categories = categoryMapper.selectList(new LambdaQueryWrapper<Category>()
|
||||
.eq(Category::getUserId, userId));
|
||||
List<Tag> tags = tagMapper.selectList(new LambdaQueryWrapper<Tag>()
|
||||
.eq(Tag::getUserId, userId));
|
||||
Map<Long, Category> categoryMap = categories.stream()
|
||||
.collect(Collectors.toMap(Category::getId, Function.identity()));
|
||||
Map<Long, Tag> tagMap = tags.stream()
|
||||
.collect(Collectors.toMap(Tag::getId, Function.identity()));
|
||||
// 名称映射同时作为本次新建的缓存,避免同批重复创建
|
||||
Map<String, Category> categoryNameMap = categories.stream()
|
||||
.collect(Collectors.toMap(c -> c.getType() + "|" + c.getName(), Function.identity(), (a, b) -> a));
|
||||
Map<String, Tag> tagNameMap = tags.stream()
|
||||
.collect(Collectors.toMap(Tag::getName, Function.identity(), (a, b) -> a));
|
||||
// 已有规则签名与本次计划创建签名,用于跳过重复规则
|
||||
Set<String> existingSignatures = loadExistingRuleSignatures(userId);
|
||||
Set<String> plannedSignatures = new HashSet<>();
|
||||
|
||||
int created = 0;
|
||||
for (AiRuleApplyRequest.Item item : items) {
|
||||
if (item == null || isBlank(item.getName())) {
|
||||
continue;
|
||||
}
|
||||
String type = validType(item.getType());
|
||||
// 与已有规则或本次已创建规则重复时跳过(禁止导入)
|
||||
List<String> newTagNames = item.getNewTags() == null ? null
|
||||
: item.getNewTags().stream().map(AiNewTagVO::getName).collect(Collectors.toList());
|
||||
String signature = buildSignature(type, item.getKeyword(), item.getCategoryId(),
|
||||
item.getNewCategoryName(), item.getTagIds(), newTagNames, categories, tags);
|
||||
if (existingSignatures.contains(signature) || plannedSignatures.contains(signature)) {
|
||||
continue;
|
||||
}
|
||||
Category category = item.getCategoryId() == null ? null : categoryMap.get(item.getCategoryId());
|
||||
if (category != null && type != null && !type.equals(category.getType())) {
|
||||
category = null;
|
||||
}
|
||||
// 未匹配到现有分类时按建议新建
|
||||
if (category == null && !isBlank(item.getNewCategoryName())) {
|
||||
String name = item.getNewCategoryName().trim();
|
||||
if (name.length() > CATEGORY_NAME_MAX_LENGTH) {
|
||||
continue;
|
||||
}
|
||||
String newType = type != null ? type : "EXPENSE";
|
||||
category = resolveCategory(userId, newType, name,
|
||||
truncate(item.getNewCategoryIcon(), ICON_MAX_LENGTH), categoryNameMap);
|
||||
if (type == null) {
|
||||
type = newType;
|
||||
}
|
||||
}
|
||||
|
||||
List<Long> tagIds = new ArrayList<>();
|
||||
if (item.getTagIds() != null) {
|
||||
item.getTagIds().stream().filter(tagMap::containsKey).distinct().forEach(tagIds::add);
|
||||
}
|
||||
if (item.getNewTags() != null) {
|
||||
for (AiNewTagVO newTag : item.getNewTags()) {
|
||||
if (newTag == null || isBlank(newTag.getName())
|
||||
|| newTag.getName().trim().length() > TAG_NAME_MAX_LENGTH) {
|
||||
continue;
|
||||
}
|
||||
String color = newTag.getColor();
|
||||
if (color == null || !COLOR_PATTERN.matcher(color).matches()) {
|
||||
color = DEFAULT_TAG_COLOR;
|
||||
}
|
||||
Tag tag = resolveTag(userId, newTag.getName().trim(), color.toLowerCase(), tagNameMap);
|
||||
if (!tagIds.contains(tag.getId())) {
|
||||
tagIds.add(tag.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RuleRequest ruleRequest = new RuleRequest();
|
||||
ruleRequest.setName(truncate(item.getName().trim(), RULE_NAME_MAX_LENGTH));
|
||||
ruleRequest.setEnabled(item.getEnabled() == null ? Boolean.TRUE : item.getEnabled());
|
||||
ruleRequest.setType(type);
|
||||
ruleRequest.setKeyword(isBlank(item.getKeyword()) ? null
|
||||
: truncate(item.getKeyword().trim(), KEYWORD_MAX_LENGTH));
|
||||
ruleRequest.setCategoryId(category != null ? category.getId() : null);
|
||||
ruleRequest.setTagIds(tagIds);
|
||||
plannedSignatures.add(signature);
|
||||
ruleService.create(userId, ruleRequest);
|
||||
created++;
|
||||
}
|
||||
log.info("AI 规则批量创建完成: userId={}, 创建数量={}", userId, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建用户提示词,将可用分类、标签与已打标签记录序列化为 JSON。
|
||||
*
|
||||
* @param categories 用户分类列表
|
||||
* @param tags 用户标签列表
|
||||
* @param transactions 选中的记录列表
|
||||
* @param tagNamesByTransaction 记录ID到标签名称列表的映射
|
||||
* @return JSON 格式的用户提示词
|
||||
*/
|
||||
private String buildUserPrompt(List<Category> categories, List<Tag> tags,
|
||||
List<Transaction> transactions,
|
||||
Map<Long, List<String>> tagNamesByTransaction) {
|
||||
Map<Long, String> categoryNameMap = categories.stream()
|
||||
.collect(Collectors.toMap(Category::getId, Category::getName, (a, b) -> a));
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("categories", categories.stream().map(category -> {
|
||||
Map<String, Object> node = new LinkedHashMap<>();
|
||||
node.put("id", category.getId());
|
||||
node.put("name", category.getName());
|
||||
node.put("type", category.getType());
|
||||
return node;
|
||||
}).collect(Collectors.toList()));
|
||||
payload.put("tags", tags.stream().map(tag -> {
|
||||
Map<String, Object> node = new LinkedHashMap<>();
|
||||
node.put("id", tag.getId());
|
||||
node.put("name", tag.getName());
|
||||
return node;
|
||||
}).collect(Collectors.toList()));
|
||||
|
||||
// 相同「类型 + 备注 + 渠道 + 标签」的记录只保留一条,降低 token 消耗
|
||||
List<Map<String, Object>> records = new ArrayList<>();
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
for (Transaction transaction : transactions) {
|
||||
List<String> recordTagNames = tagNamesByTransaction.getOrDefault(transaction.getId(), List.of());
|
||||
String key = transaction.getType() + "|" + safe(transaction.getRemark()) + "|"
|
||||
+ safe(transaction.getChannel()) + "|" + String.join(",", recordTagNames);
|
||||
if (!seen.add(key)) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> node = new LinkedHashMap<>();
|
||||
node.put("type", transaction.getType());
|
||||
node.put("remark", transaction.getRemark());
|
||||
node.put("channel", transaction.getChannel());
|
||||
node.put("category", transaction.getCategoryId() == null
|
||||
? null : categoryNameMap.get(transaction.getCategoryId()));
|
||||
node.put("tags", recordTagNames);
|
||||
records.add(node);
|
||||
}
|
||||
payload.put("records", records);
|
||||
|
||||
try {
|
||||
return objectMapper.writeValueAsString(payload);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new BusinessException("构建 AI 提示词失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 空值安全的字符串处理。
|
||||
*
|
||||
* @param value 原字符串
|
||||
* @return 非空字符串,null 时返回空串
|
||||
*/
|
||||
private String safe(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模型返回的规则 JSON,并校验分类与标签合法性。
|
||||
*
|
||||
* @param content 模型输出内容
|
||||
* @param categories 用户分类列表
|
||||
* @param tags 用户标签列表
|
||||
* @return 规则建议结果
|
||||
*/
|
||||
private AiRuleSuggestResultVO parseResult(String content, List<Category> categories, List<Tag> tags) {
|
||||
JsonNode root;
|
||||
try {
|
||||
root = objectMapper.readTree(stripCodeFence(content));
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new BusinessException("AI 返回内容不是有效 JSON,请重试");
|
||||
}
|
||||
JsonNode array = root.path("rules");
|
||||
if (!array.isArray()) {
|
||||
throw new BusinessException("AI 返回格式不正确,请重试");
|
||||
}
|
||||
|
||||
Map<Long, Category> categoryMap = categories.stream()
|
||||
.collect(Collectors.toMap(Category::getId, Function.identity()));
|
||||
Map<Long, Tag> tagMap = tags.stream()
|
||||
.collect(Collectors.toMap(Tag::getId, Function.identity()));
|
||||
|
||||
List<AiRuleSuggestionVO> rules = new ArrayList<>();
|
||||
for (JsonNode node : array) {
|
||||
AiRuleSuggestionVO rule = parseRuleNode(node, categoryMap, tagMap);
|
||||
if (rule != null) {
|
||||
rules.add(rule);
|
||||
}
|
||||
}
|
||||
if (rules.isEmpty()) {
|
||||
throw new BusinessException("AI 未生成有效规则,请重试");
|
||||
}
|
||||
|
||||
AiRuleSuggestResultVO result = new AiRuleSuggestResultVO();
|
||||
result.setSummary(root.path("summary").asText(""));
|
||||
result.setRules(rules);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析单条规则建议,无有效条件时返回 null。
|
||||
*
|
||||
* @param node 规则节点
|
||||
* @param categoryMap 现有分类映射
|
||||
* @param tagMap 现有标签映射
|
||||
* @return 规则建议,或 null
|
||||
*/
|
||||
private AiRuleSuggestionVO parseRuleNode(JsonNode node, Map<Long, Category> categoryMap,
|
||||
Map<Long, Tag> tagMap) {
|
||||
String type = validType(node.path("type").asText(""));
|
||||
String keyword = node.path("keyword").asText("").trim();
|
||||
if (keyword.length() > KEYWORD_MAX_LENGTH) {
|
||||
keyword = keyword.substring(0, KEYWORD_MAX_LENGTH);
|
||||
}
|
||||
// 现有分类:ID 必须存在且类型一致
|
||||
Long categoryId = null;
|
||||
String categoryName = null;
|
||||
long rawCategoryId = node.path("categoryId").asLong(0);
|
||||
Category category = categoryMap.get(rawCategoryId);
|
||||
if (category != null && (type == null || type.equals(category.getType()))) {
|
||||
categoryId = category.getId();
|
||||
categoryName = category.getName();
|
||||
}
|
||||
|
||||
// 建议新建的分类
|
||||
String newCategoryName = null;
|
||||
String newCategoryIcon = null;
|
||||
JsonNode newCategory = node.path("newCategory");
|
||||
if (newCategory.isObject()) {
|
||||
String name = newCategory.path("name").asText("").trim();
|
||||
if (!isBlank(name) && name.length() <= CATEGORY_NAME_MAX_LENGTH) {
|
||||
newCategoryName = name;
|
||||
newCategoryIcon = truncate(newCategory.path("icon").asText("").trim(), ICON_MAX_LENGTH);
|
||||
if (type == null) {
|
||||
type = validType(newCategory.path("type").asText(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 现有标签
|
||||
List<Long> tagIds = new ArrayList<>();
|
||||
List<String> tagNames = new ArrayList<>();
|
||||
JsonNode tagArray = node.path("tagIds");
|
||||
if (tagArray.isArray()) {
|
||||
for (JsonNode tagNode : tagArray) {
|
||||
long tagId = tagNode.asLong(0);
|
||||
Tag tag = tagMap.get(tagId);
|
||||
if (tag != null && !tagIds.contains(tagId)) {
|
||||
tagIds.add(tagId);
|
||||
tagNames.add(tag.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 建议新建的标签
|
||||
List<AiNewTagVO> newTags = new ArrayList<>();
|
||||
JsonNode newTagArray = node.path("newTags");
|
||||
if (newTagArray.isArray()) {
|
||||
for (JsonNode tagNode : newTagArray) {
|
||||
String name = tagNode.path("name").asText("").trim();
|
||||
if (isBlank(name) || name.length() > TAG_NAME_MAX_LENGTH) {
|
||||
continue;
|
||||
}
|
||||
String color = tagNode.path("color").asText("").trim();
|
||||
if (!COLOR_PATTERN.matcher(color).matches()) {
|
||||
color = DEFAULT_TAG_COLOR;
|
||||
}
|
||||
AiNewTagVO newTag = new AiNewTagVO();
|
||||
newTag.setName(name);
|
||||
newTag.setColor(color.toLowerCase());
|
||||
newTags.add(newTag);
|
||||
}
|
||||
}
|
||||
|
||||
// 无任何有效条件时丢弃
|
||||
boolean hasCondition = !isBlank(keyword)
|
||||
|| categoryId != null || !isBlank(newCategoryName) || !tagIds.isEmpty() || !newTags.isEmpty();
|
||||
if (!hasCondition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String name = node.path("name").asText("").trim();
|
||||
if (isBlank(name)) {
|
||||
name = !isBlank(keyword) ? keyword + " 规则"
|
||||
: (categoryName != null ? categoryName + " 规则" : "AI 规则");
|
||||
}
|
||||
|
||||
AiRuleSuggestionVO vo = new AiRuleSuggestionVO();
|
||||
vo.setName(truncate(name, RULE_NAME_MAX_LENGTH));
|
||||
vo.setType(type);
|
||||
vo.setKeyword(isBlank(keyword) ? null : keyword);
|
||||
vo.setCategoryId(categoryId);
|
||||
vo.setCategoryName(categoryName);
|
||||
vo.setNewCategoryName(newCategoryName);
|
||||
vo.setNewCategoryIcon(newCategoryIcon);
|
||||
vo.setTagIds(tagIds);
|
||||
vo.setTagNames(tagNames);
|
||||
vo.setNewTags(newTags);
|
||||
vo.setReason(node.path("reason").asText(""));
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按「类型 + 名称」查找分类,不存在时创建并写入缓存。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param type 分类类型
|
||||
* @param name 分类名称
|
||||
* @param icon 分类图标
|
||||
* @param categoryNameMap 类型与名称到分类的映射,同时作为新建缓存
|
||||
* @return 已存在或新建的分类
|
||||
*/
|
||||
private Category resolveCategory(Long userId, String type, String name, String icon,
|
||||
Map<String, Category> categoryNameMap) {
|
||||
String key = type + "|" + name;
|
||||
Category existing = categoryNameMap.get(key);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
Category category = new Category();
|
||||
category.setUserId(userId);
|
||||
category.setName(name);
|
||||
category.setType(type);
|
||||
category.setIcon(icon);
|
||||
category.setSortOrder(0);
|
||||
categoryMapper.insert(category);
|
||||
categoryNameMap.put(key, category);
|
||||
return category;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按名称查找标签,不存在时创建并写入缓存。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param name 标签名称
|
||||
* @param color 标签颜色
|
||||
* @param tagNameMap 名称到标签的映射,同时作为新建缓存
|
||||
* @return 已存在或新建的标签
|
||||
*/
|
||||
private Tag resolveTag(Long userId, String name, String color, Map<String, Tag> tagNameMap) {
|
||||
Tag existing = tagNameMap.get(name);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
Tag tag = new Tag();
|
||||
tag.setUserId(userId);
|
||||
tag.setName(name);
|
||||
tag.setColor(color);
|
||||
tag.setSortOrder(0);
|
||||
tagMapper.insert(tag);
|
||||
tagNameMap.put(name, tag);
|
||||
return tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取并校验当前用户的 AI 配置。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 有效的 AI 配置
|
||||
*/
|
||||
private AiConfig requireConfig(Long userId) {
|
||||
AiConfig config = aiConfigService.getRawConfig(userId);
|
||||
if (config == null || !Boolean.TRUE.equals(config.getEnabled())) {
|
||||
throw new BusinessException("AI 功能未启用,请先在 AI 设置中开启");
|
||||
}
|
||||
if (isBlank(config.getApiKey())) {
|
||||
throw new BusinessException("请先配置 DeepSeek API Key");
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计模型返回内容中的规则数量,用于日志。
|
||||
*
|
||||
* @param content 模型输出内容
|
||||
* @return 规则数量,解析失败时为 0
|
||||
*/
|
||||
private int parseRuleCount(String content) {
|
||||
try {
|
||||
JsonNode array = objectMapper.readTree(stripCodeFence(content)).path("rules");
|
||||
return array.isArray() ? array.size() : 0;
|
||||
} catch (JsonProcessingException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 去除模型可能附加的 Markdown 代码块围栏。
|
||||
*
|
||||
* @param content 原始内容
|
||||
* @return 清理后的内容
|
||||
*/
|
||||
private String stripCodeFence(String content) {
|
||||
if (content == null) {
|
||||
return "";
|
||||
}
|
||||
String cleaned = content.trim();
|
||||
if (cleaned.startsWith("```")) {
|
||||
int firstLineEnd = cleaned.indexOf('\n');
|
||||
if (firstLineEnd > 0) {
|
||||
cleaned = cleaned.substring(firstLineEnd + 1);
|
||||
}
|
||||
if (cleaned.endsWith("```")) {
|
||||
cleaned = cleaned.substring(0, cleaned.length() - 3);
|
||||
}
|
||||
}
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并归一化收支类型。
|
||||
*
|
||||
* @param type 原始类型
|
||||
* @return INCOME/EXPENSE,非法时返回 null
|
||||
*/
|
||||
private String validType(String type) {
|
||||
if (type == null) {
|
||||
return null;
|
||||
}
|
||||
String upper = type.trim().toUpperCase();
|
||||
return "INCOME".equals(upper) || "EXPENSE".equals(upper) ? upper : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断字符串到指定长度。
|
||||
*
|
||||
* @param value 原字符串
|
||||
* @param maxLength 最大长度
|
||||
* @return 截断后的字符串,原值为 null 时返回 null
|
||||
*/
|
||||
private String truncate(String value, int maxLength) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return value.length() <= maxLength ? value : value.substring(0, maxLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否为空白。
|
||||
*
|
||||
* @param value 待判断字符串
|
||||
* @return 为 null 或全空白时返回 true
|
||||
*/
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
package cn.rosercode.finance.service.impl;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import cn.rosercode.finance.client.DeepSeekClient;
|
||||
import cn.rosercode.finance.client.DeepSeekResponse;
|
||||
import cn.rosercode.finance.common.BusinessException;
|
||||
import cn.rosercode.finance.entity.AiConfig;
|
||||
import cn.rosercode.finance.entity.Category;
|
||||
import cn.rosercode.finance.entity.Tag;
|
||||
import cn.rosercode.finance.enums.AiCallType;
|
||||
import cn.rosercode.finance.enums.BillType;
|
||||
import cn.rosercode.finance.mapper.CategoryMapper;
|
||||
import cn.rosercode.finance.mapper.TagMapper;
|
||||
import cn.rosercode.finance.service.AiConfigService;
|
||||
import cn.rosercode.finance.service.AiStatisticsService;
|
||||
import cn.rosercode.finance.service.AiUsageService;
|
||||
import cn.rosercode.finance.vo.AiStatisticsResultVO;
|
||||
|
||||
/**
|
||||
* AI 统计查询服务实现类,负责提示词组装、日期推算与名称映射。
|
||||
*
|
||||
* @date 2026-09-20 16:30:00
|
||||
*/
|
||||
@Service
|
||||
public class AiStatisticsServiceImpl implements AiStatisticsService {
|
||||
|
||||
/** 日期格式 */
|
||||
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
/** 日期时间格式,与前端筛选参数保持一致 */
|
||||
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
|
||||
|
||||
/** 合法的收支类型 */
|
||||
private static final List<String> VALID_TYPES = List.of("INCOME", "EXPENSE");
|
||||
|
||||
/** 合法的统计口径 */
|
||||
private static final List<String> VALID_STATS_SCOPES = List.of("INCLUDED", "ALL", "EXCLUDED");
|
||||
|
||||
/** 合法的统计维度(趋势粒度) */
|
||||
private static final List<String> VALID_DIMENSIONS = List.of("day", "week", "month", "quarter", "year");
|
||||
|
||||
/** 合法的交易渠道 */
|
||||
private static final List<String> VALID_CHANNELS;
|
||||
|
||||
static {
|
||||
List<String> channels = new ArrayList<>();
|
||||
for (BillType type : BillType.values()) {
|
||||
channels.add(type.name());
|
||||
}
|
||||
channels.add("CASH");
|
||||
VALID_CHANNELS = List.copyOf(channels);
|
||||
}
|
||||
|
||||
private final AiConfigService aiConfigService;
|
||||
private final CategoryMapper categoryMapper;
|
||||
private final TagMapper tagMapper;
|
||||
private final DeepSeekClient deepSeekClient;
|
||||
private final AiUsageService aiUsageService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public AiStatisticsServiceImpl(AiConfigService aiConfigService,
|
||||
CategoryMapper categoryMapper,
|
||||
TagMapper tagMapper,
|
||||
DeepSeekClient deepSeekClient,
|
||||
AiUsageService aiUsageService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.aiConfigService = aiConfigService;
|
||||
this.categoryMapper = categoryMapper;
|
||||
this.tagMapper = tagMapper;
|
||||
this.deepSeekClient = deepSeekClient;
|
||||
this.aiUsageService = aiUsageService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 将用户分类、标签与当前日期注入提示词,解析模型输出并映射为统计查询参数。
|
||||
*/
|
||||
@Override
|
||||
public AiStatisticsResultVO parse(Long userId, String query) {
|
||||
if (query == null || query.isBlank()) {
|
||||
throw new BusinessException("请输入查询内容");
|
||||
}
|
||||
AiConfig config = aiConfigService.getRawConfig(userId);
|
||||
if (config == null || !Boolean.TRUE.equals(config.getEnabled())) {
|
||||
throw new BusinessException("AI 功能未启用,请先在 AI 设置中开启");
|
||||
}
|
||||
if (isBlank(config.getApiKey())) {
|
||||
throw new BusinessException("请先配置 DeepSeek API Key");
|
||||
}
|
||||
|
||||
List<Category> categories = categoryMapper.selectList(new LambdaQueryWrapper<Category>()
|
||||
.eq(Category::getUserId, userId));
|
||||
List<Tag> tags = tagMapper.selectList(new LambdaQueryWrapper<Tag>()
|
||||
.eq(Tag::getUserId, userId));
|
||||
|
||||
String systemPrompt = buildSystemPrompt(categories, tags);
|
||||
long start = System.currentTimeMillis();
|
||||
DeepSeekResponse aiResponse;
|
||||
try {
|
||||
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
|
||||
systemPrompt, query);
|
||||
} catch (RuntimeException e) {
|
||||
// 失败也记录一次使用,便于排查问题
|
||||
aiUsageService.recordFailure(userId, AiCallType.STATISTICS, config.getModel(), 0,
|
||||
System.currentTimeMillis() - start, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
aiUsageService.recordSuccess(userId, AiCallType.STATISTICS, config.getModel(), 0,
|
||||
aiResponse, System.currentTimeMillis() - start);
|
||||
|
||||
return parseResult(aiResponse.getContent(), categories, tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建系统提示词,注入可用分类、标签与当前日期。
|
||||
*
|
||||
* @param categories 用户分类列表
|
||||
* @param tags 用户标签列表
|
||||
* @return 系统提示词
|
||||
*/
|
||||
private String buildSystemPrompt(List<Category> categories, List<Tag> tags) {
|
||||
String categoryNames = categories.stream().map(Category::getName).collect(Collectors.joining("、"));
|
||||
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
|
||||
String today = LocalDate.now().format(DATE_FMT);
|
||||
return "你是一个记账系统的统计查询解析助手。请把用户的自然语言统计意图解析为结构化统计参数,只输出 JSON,不要输出任何多余内容。\n"
|
||||
+ "可用的收支类型:INCOME(收入)、EXPENSE(支出)。\n"
|
||||
+ "可用的交易渠道:WECHAT(微信)、ALIPAY(支付宝)、MEITUAN(美团)、CMB(招商银行)、BOCOM(交通银行)、CASH(现金)。\n"
|
||||
+ "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
|
||||
+ "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
|
||||
+ "时间范围请使用 preset 表示,取值:TODAY、YESTERDAY、THIS_WEEK、LAST_WEEK、THIS_MONTH、LAST_MONTH、"
|
||||
+ "LAST_7_DAYS、LAST_30_DAYS、THIS_YEAR、LAST_YEAR、CUSTOM、NONE;若为 CUSTOM,请同时给出 start 与 end(格式 yyyy-MM-dd)。\n"
|
||||
+ "dimension 表示趋势统计的粒度,取值 day、week、month、quarter、year,无法判断为 null。\n"
|
||||
+ "statsScope 表示统计口径,取值 INCLUDED(仅计入统计)、ALL(全部)、EXCLUDED(仅不计入统计),无法判断为 null。\n"
|
||||
+ "未提及的字段请留空(数组用 [],其余用 null)。分类和标签只能使用上面给出的名称。\n"
|
||||
+ "今天是 " + today + "。\n"
|
||||
+ "输出格式:{\"summary\":\"简要说明识别到的统计意图\",\"types\":[],\"categoryNames\":[],\"tagNames\":[],"
|
||||
+ "\"channels\":[],\"dateRange\":{\"preset\":\"NONE\",\"start\":null,\"end\":null},\"minAmount\":null,"
|
||||
+ "\"maxAmount\":null,\"remark\":null,\"statsScope\":null,\"dimension\":null,\"unmatched\":[]}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模型返回的 JSON 并映射为统计查询参数。
|
||||
*
|
||||
* @param content 模型输出内容
|
||||
* @param categories 用户分类列表
|
||||
* @param tags 用户标签列表
|
||||
* @return 统计查询参数
|
||||
*/
|
||||
private AiStatisticsResultVO parseResult(String content, List<Category> categories, List<Tag> tags) {
|
||||
JsonNode root;
|
||||
try {
|
||||
root = objectMapper.readTree(stripCodeFence(content));
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new BusinessException("AI 返回内容不是有效 JSON,请重试");
|
||||
}
|
||||
|
||||
AiStatisticsResultVO vo = new AiStatisticsResultVO();
|
||||
vo.setSummary(root.path("summary").asText(""));
|
||||
vo.setTypes(filterValid(root.path("types"), VALID_TYPES));
|
||||
vo.setChannels(filterValid(root.path("channels"), VALID_CHANNELS));
|
||||
vo.setMinAmount(decimalOrNull(root.path("minAmount")));
|
||||
vo.setMaxAmount(decimalOrNull(root.path("maxAmount")));
|
||||
|
||||
String remark = root.path("remark").asText(null);
|
||||
vo.setRemark(isBlank(remark) ? null : remark.trim());
|
||||
|
||||
// 统计口径与维度
|
||||
vo.setStatsScope(filterSingle(root.path("statsScope"), VALID_STATS_SCOPES));
|
||||
String dimension = root.path("dimension").asText("").trim().toLowerCase();
|
||||
vo.setDimension(VALID_DIMENSIONS.contains(dimension) ? dimension : null);
|
||||
|
||||
// 分类名称映射为ID
|
||||
List<Long> categoryIds = new ArrayList<>();
|
||||
List<String> categoryNames = new ArrayList<>();
|
||||
JsonNode categoryNode = root.path("categoryNames");
|
||||
if (categoryNode.isArray()) {
|
||||
for (JsonNode node : categoryNode) {
|
||||
Category category = matchCategory(categories, node.asText(""));
|
||||
if (category != null && !categoryIds.contains(category.getId())) {
|
||||
categoryIds.add(category.getId());
|
||||
categoryNames.add(category.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
vo.setCategoryIds(categoryIds);
|
||||
vo.setCategoryNames(categoryNames);
|
||||
|
||||
// 标签名称映射为ID
|
||||
List<Long> tagIds = new ArrayList<>();
|
||||
List<String> tagNames = new ArrayList<>();
|
||||
JsonNode tagNode = root.path("tagNames");
|
||||
if (tagNode.isArray()) {
|
||||
for (JsonNode node : tagNode) {
|
||||
Tag tag = matchTag(tags, node.asText(""));
|
||||
if (tag != null && !tagIds.contains(tag.getId())) {
|
||||
tagIds.add(tag.getId());
|
||||
tagNames.add(tag.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
vo.setTagIds(tagIds);
|
||||
vo.setTagNames(tagNames);
|
||||
|
||||
// 时间范围推算
|
||||
String[] range = resolveDateRange(root.path("dateRange"));
|
||||
if (range != null) {
|
||||
vo.setStartTime(range[0]);
|
||||
vo.setEndTime(range[1]);
|
||||
}
|
||||
|
||||
// 未识别关键词
|
||||
List<String> unmatched = new ArrayList<>();
|
||||
JsonNode unmatchedNode = root.path("unmatched");
|
||||
if (unmatchedNode.isArray()) {
|
||||
for (JsonNode node : unmatchedNode) {
|
||||
String text = node.asText("");
|
||||
if (!isBlank(text)) {
|
||||
unmatched.add(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
vo.setUnmatched(unmatched);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将模型给出的日期 preset 推算为绝对时间范围。
|
||||
*
|
||||
* @param dateRange 模型输出的 dateRange 节点
|
||||
* @return 起止时间数组,无法识别时返回 null
|
||||
*/
|
||||
private String[] resolveDateRange(JsonNode dateRange) {
|
||||
if (dateRange == null || dateRange.isMissingNode() || dateRange.isNull()) {
|
||||
return null;
|
||||
}
|
||||
String preset = dateRange.path("preset").asText("NONE");
|
||||
if (isBlank(preset) || "NONE".equalsIgnoreCase(preset)) {
|
||||
return null;
|
||||
}
|
||||
LocalDate today = LocalDate.now();
|
||||
LocalDate start;
|
||||
LocalDate end;
|
||||
switch (preset.toUpperCase()) {
|
||||
case "TODAY":
|
||||
start = today;
|
||||
end = today;
|
||||
break;
|
||||
case "YESTERDAY":
|
||||
start = today.minusDays(1);
|
||||
end = start;
|
||||
break;
|
||||
case "THIS_WEEK":
|
||||
start = today.with(DayOfWeek.MONDAY);
|
||||
end = today;
|
||||
break;
|
||||
case "LAST_WEEK":
|
||||
start = today.with(DayOfWeek.MONDAY).minusWeeks(1);
|
||||
end = start.plusDays(6);
|
||||
break;
|
||||
case "THIS_MONTH":
|
||||
start = today.withDayOfMonth(1);
|
||||
end = today;
|
||||
break;
|
||||
case "LAST_MONTH":
|
||||
start = today.withDayOfMonth(1).minusMonths(1);
|
||||
end = start.withDayOfMonth(start.lengthOfMonth());
|
||||
break;
|
||||
case "LAST_7_DAYS":
|
||||
start = today.minusDays(6);
|
||||
end = today;
|
||||
break;
|
||||
case "LAST_30_DAYS":
|
||||
start = today.minusDays(29);
|
||||
end = today;
|
||||
break;
|
||||
case "THIS_YEAR":
|
||||
start = today.withDayOfYear(1);
|
||||
end = today;
|
||||
break;
|
||||
case "LAST_YEAR":
|
||||
start = today.withDayOfYear(1).minusYears(1);
|
||||
end = start.withDayOfYear(start.lengthOfYear());
|
||||
break;
|
||||
case "CUSTOM": {
|
||||
LocalDate customStart = parseDate(dateRange.path("start").asText(null));
|
||||
LocalDate customEnd = parseDate(dateRange.path("end").asText(null));
|
||||
if (customStart == null && customEnd == null) {
|
||||
return null;
|
||||
}
|
||||
start = customStart != null ? customStart : customEnd;
|
||||
end = customEnd != null ? customEnd : customStart;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return new String[]{
|
||||
start.atStartOfDay().format(DATETIME_FMT),
|
||||
end.atTime(23, 59, 59).format(DATETIME_FMT)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析日期文本,失败返回 null。
|
||||
*
|
||||
* @param text 日期文本
|
||||
* @return 日期对象
|
||||
*/
|
||||
private LocalDate parseDate(String text) {
|
||||
if (isBlank(text)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(text.trim().substring(0, 10), DATE_FMT);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按名称匹配分类:优先精确匹配,其次包含匹配。
|
||||
*
|
||||
* @param categories 分类列表
|
||||
* @param name 目标名称
|
||||
* @return 匹配到的分类,未命中返回 null
|
||||
*/
|
||||
private Category matchCategory(List<Category> categories, String name) {
|
||||
if (isBlank(name)) {
|
||||
return null;
|
||||
}
|
||||
String target = name.trim();
|
||||
for (Category category : categories) {
|
||||
if (category.getName().equalsIgnoreCase(target)) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
for (Category category : categories) {
|
||||
if (category.getName().contains(target) || target.contains(category.getName())) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按名称匹配标签:优先精确匹配,其次包含匹配。
|
||||
*
|
||||
* @param tags 标签列表
|
||||
* @param name 目标名称
|
||||
* @return 匹配到的标签,未命中返回 null
|
||||
*/
|
||||
private Tag matchTag(List<Tag> tags, String name) {
|
||||
if (isBlank(name)) {
|
||||
return null;
|
||||
}
|
||||
String target = name.trim();
|
||||
for (Tag tag : tags) {
|
||||
if (tag.getName().equalsIgnoreCase(target)) {
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
for (Tag tag : tags) {
|
||||
if (tag.getName().contains(target) || target.contains(tag.getName())) {
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤出合法且去重的枚举值。
|
||||
*
|
||||
* @param node 模型输出节点
|
||||
* @param valid 合法值列表
|
||||
* @return 过滤后的列表
|
||||
*/
|
||||
private List<String> filterValid(JsonNode node, List<String> valid) {
|
||||
List<String> result = new ArrayList<>();
|
||||
if (node != null && node.isArray()) {
|
||||
for (JsonNode item : node) {
|
||||
String value = item.asText("").toUpperCase();
|
||||
if (valid.contains(value) && !result.contains(value)) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤单个枚举值,非法时返回 null。
|
||||
*
|
||||
* @param node 模型输出节点
|
||||
* @param valid 合法值列表
|
||||
* @return 合法值或 null
|
||||
*/
|
||||
private String filterSingle(JsonNode node, List<String> valid) {
|
||||
if (node == null || node.isMissingNode() || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
String value = node.asText("").trim().toUpperCase();
|
||||
return valid.contains(value) ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将节点转换为金额,非数字时返回 null。
|
||||
*
|
||||
* @param node 模型输出节点
|
||||
* @return 金额
|
||||
*/
|
||||
private BigDecimal decimalOrNull(JsonNode node) {
|
||||
if (node == null || node.isMissingNode() || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return node.decimalValue();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 去除模型可能附加的 Markdown 代码块围栏。
|
||||
*
|
||||
* @param content 原始内容
|
||||
* @return 清理后的内容
|
||||
*/
|
||||
private String stripCodeFence(String content) {
|
||||
if (content == null) {
|
||||
return "";
|
||||
}
|
||||
String cleaned = content.trim();
|
||||
if (cleaned.startsWith("```")) {
|
||||
int firstLineEnd = cleaned.indexOf('\n');
|
||||
if (firstLineEnd > 0) {
|
||||
cleaned = cleaned.substring(firstLineEnd + 1);
|
||||
}
|
||||
if (cleaned.endsWith("```")) {
|
||||
cleaned = cleaned.substring(0, cleaned.length() - 3);
|
||||
}
|
||||
}
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否为空白。
|
||||
*
|
||||
* @param value 待判断字符串
|
||||
* @return 为 null 或全空白时返回 true
|
||||
*/
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
}
|
||||
@@ -55,9 +55,6 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
|
||||
/** 单次请求最多分析的记录总数(与前端最大页大小一致) */
|
||||
private static final int MAX_TOTAL_SIZE = 500;
|
||||
|
||||
/** 原始账单数据注入提示词时的最大长度 */
|
||||
private static final int RAW_DATA_LIMIT = 300;
|
||||
|
||||
/** 分类/标签名称最大长度 */
|
||||
private static final int NAME_MAX_LENGTH = 20;
|
||||
|
||||
@@ -71,7 +68,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
|
||||
private static final Pattern COLOR_PATTERN = Pattern.compile("^#[0-9a-fA-F]{6}$");
|
||||
|
||||
/** 严格模式系统提示词,仅允许使用现有分类与标签,供编辑弹窗单条建议使用 */
|
||||
private static final String STRICT_SYSTEM_PROMPT = "你是一个专业的个人记账助手。请根据每条交易记录的类型、金额、备注等信息,"
|
||||
private static final String STRICT_SYSTEM_PROMPT = "你是一个专业的个人记账助手。请根据每条交易记录的类型、备注、交易渠道等信息,"
|
||||
+ "从给定的分类列表和标签列表中,为该记录选择最合适的分类与标签。"
|
||||
+ "要求:1. 分类必须与记录的类型一致(INCOME 只能选 INCOME 分类,EXPENSE 只能选 EXPENSE 分类);"
|
||||
+ "2. 只能使用给定列表中的 ID,不要编造;3. 标签可多选,也可为空数组;"
|
||||
@@ -79,7 +76,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
|
||||
+ "输出格式:{\"suggestions\":[{\"id\":记录ID,\"categoryId\":分类ID,\"tagIds\":[标签ID],\"reason\":\"简要中文理由\"}]}";
|
||||
|
||||
/** 增强模式系统提示词,允许在现有候选都不合适时提出新建分类与标签 */
|
||||
private static final String NEW_SYSTEM_PROMPT = "你是一个专业的个人记账助手。请根据每条交易记录的类型、金额、备注等信息,"
|
||||
private static final String NEW_SYSTEM_PROMPT = "你是一个专业的个人记账助手。请根据每条交易记录的类型、备注、交易渠道等信息,"
|
||||
+ "为该记录选择合适的分类与标签。优先从给定的分类列表和标签列表中选择,如果列表中确实没有合适的,可以提出新建。"
|
||||
+ "要求:1. 分类必须与记录的类型一致(INCOME 只能选 INCOME 分类,EXPENSE 只能选 EXPENSE 分类);"
|
||||
+ "2. 优先使用给定列表中的 ID,不要编造列表之外的 ID;"
|
||||
@@ -121,7 +118,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 先按「类型 + 支付方式 + 备注」聚合重复记录,仅将每组代表送入模型,
|
||||
* 先按「类型 + 交易渠道 + 备注」聚合重复记录,仅将每组代表送入模型,
|
||||
* 再把建议套用到整组记录;代表数超过单批上限时自动分批调用并汇总结果。
|
||||
*/
|
||||
@Override
|
||||
@@ -163,7 +160,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
|
||||
List<Tag> tags = tagMapper.selectList(new LambdaQueryWrapper<Tag>()
|
||||
.eq(Tag::getUserId, userId));
|
||||
|
||||
// 按类型、支付方式与备注聚合,重复记录只保留一条代表参与分析
|
||||
// 按类型、交易渠道与备注聚合,重复记录只保留一条代表参与分析
|
||||
List<List<Transaction>> groups = groupTransactions(transactions);
|
||||
|
||||
List<AiSuggestionVO> result = new ArrayList<>();
|
||||
@@ -235,7 +232,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
|
||||
transaction.setUserId(userId);
|
||||
transaction.setType(request.getType());
|
||||
transaction.setAmount(request.getAmount());
|
||||
transaction.setPaymentMethod(request.getPaymentMethod());
|
||||
transaction.setChannel(request.getChannel());
|
||||
transaction.setRemark(request.getRemark());
|
||||
List<Transaction> single = List.of(transaction);
|
||||
|
||||
@@ -377,7 +374,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按「类型 + 支付方式 + 备注」聚合交易记录,并保持原有顺序。
|
||||
* 按「类型 + 交易渠道 + 备注」聚合交易记录,并保持原有顺序。
|
||||
*
|
||||
* <p>备注为空的记录不参与聚合,各自独立成组,避免无关记录被归为一类。</p>
|
||||
*
|
||||
@@ -393,7 +390,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
|
||||
key = "__single__" + transaction.getId();
|
||||
} else {
|
||||
key = transaction.getType() + "|"
|
||||
+ (transaction.getPaymentMethod() == null ? "" : transaction.getPaymentMethod())
|
||||
+ (transaction.getChannel() == null ? "" : transaction.getChannel())
|
||||
+ "|" + transaction.getRemark();
|
||||
}
|
||||
grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(transaction);
|
||||
@@ -599,11 +596,9 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
|
||||
Map<String, Object> node = new LinkedHashMap<>();
|
||||
node.put("id", transaction.getId());
|
||||
node.put("type", transaction.getType());
|
||||
node.put("amount", transaction.getAmount());
|
||||
node.put("time", transaction.getTransactionTime());
|
||||
node.put("paymentMethod", transaction.getPaymentMethod());
|
||||
node.put("channel", transaction.getChannel());
|
||||
node.put("remark", transaction.getRemark());
|
||||
node.put("extra", extractExtra(transaction.getRawData()));
|
||||
return node;
|
||||
}).collect(Collectors.toList()));
|
||||
|
||||
@@ -790,19 +785,6 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从原始账单数据中截取摘要,用于注入提示词。
|
||||
*
|
||||
* @param rawData 原始账单 JSON 字符串
|
||||
* @return 截断后的摘要,为空时返回 null
|
||||
*/
|
||||
private String extractExtra(String rawData) {
|
||||
if (isBlank(rawData)) {
|
||||
return null;
|
||||
}
|
||||
return rawData.length() <= RAW_DATA_LIMIT ? rawData : rawData.substring(0, RAW_DATA_LIMIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否为空白。
|
||||
*
|
||||
|
||||
@@ -20,6 +20,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import cn.rosercode.finance.common.BusinessException;
|
||||
import cn.rosercode.finance.config.app.AppConfigStore;
|
||||
import cn.rosercode.finance.converter.VoConverter;
|
||||
import cn.rosercode.finance.dto.BillImportRequest;
|
||||
import cn.rosercode.finance.entity.Category;
|
||||
@@ -62,6 +63,7 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
private final RuleService ruleService;
|
||||
private final VoConverter voConverter;
|
||||
private final BillArchiveExtractor archiveExtractor;
|
||||
private final AppConfigStore appConfigStore;
|
||||
|
||||
public BillImportServiceImpl(List<BillParser> parsers,
|
||||
TransactionMapper transactionMapper,
|
||||
@@ -70,7 +72,8 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
TransactionTagMapper transactionTagMapper,
|
||||
RuleService ruleService,
|
||||
VoConverter voConverter,
|
||||
BillArchiveExtractor archiveExtractor) {
|
||||
BillArchiveExtractor archiveExtractor,
|
||||
AppConfigStore appConfigStore) {
|
||||
// 按账单类型注册解析器,便于扩展新账单来源
|
||||
for (BillParser parser : parsers) {
|
||||
parserMap.put(parser.getBillType(), parser);
|
||||
@@ -82,6 +85,7 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
this.ruleService = ruleService;
|
||||
this.voConverter = voConverter;
|
||||
this.archiveExtractor = archiveExtractor;
|
||||
this.appConfigStore = appConfigStore;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,6 +109,9 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
|
||||
List<ParsedBillRow> rows = parser.parse(fileContent);
|
||||
|
||||
// 按账单来源的默认值覆盖「计入统计」标记
|
||||
applyBillSourceStatsDefault(rows, billType);
|
||||
|
||||
// 批量查询已存在的交易号,用于标记重复
|
||||
List<String> externalNos = rows.stream()
|
||||
.map(ParsedBillRow::getExternalNo)
|
||||
@@ -192,6 +199,9 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
.collect(Collectors.toList());
|
||||
List<RuleMatchResult> ruleMatches = ruleService.matchBatch(userId, ruleContexts);
|
||||
|
||||
// 请求未显式提供「计入统计」时,按账单来源默认值兜底
|
||||
boolean sourceStatsDefault = appConfigStore.load().resolveIncludeInStats(request.getBillType());
|
||||
|
||||
int skipped = 0;
|
||||
int failed = 0;
|
||||
List<String> messages = new ArrayList<>();
|
||||
@@ -237,11 +247,12 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
transaction.setCategoryId(categoryId);
|
||||
transaction.setType(item.getType());
|
||||
transaction.setAmount(item.getAmount());
|
||||
transaction.setPaymentMethod(resolvePaymentMethod(request.getBillType()));
|
||||
transaction.setChannel(resolveChannel(request.getBillType()));
|
||||
transaction.setRemark(truncate(buildRemark(item.getCounterparty(), item.getProduct()), 255));
|
||||
transaction.setExternalNo(item.getExternalNo());
|
||||
transaction.setTransactionTime(item.getTransactionTime());
|
||||
transaction.setIncludeInStats(item.getIncludeInStats() == null ? Boolean.TRUE : item.getIncludeInStats());
|
||||
transaction.setIncludeInStats(item.getIncludeInStats() == null
|
||||
? sourceStatsDefault : item.getIncludeInStats());
|
||||
transaction.setRawData(item.getRawData());
|
||||
pending.add(transaction);
|
||||
|
||||
@@ -476,12 +487,12 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据账单类型解析对应的支付方式编码
|
||||
* 根据账单类型解析对应的交易渠道编码
|
||||
*
|
||||
* @param billType 账单类型,如 ALIPAY、WECHAT
|
||||
* @return 支付方式编码,无法识别时返回 null
|
||||
* @return 交易渠道编码,无法识别时返回 null
|
||||
*/
|
||||
private String resolvePaymentMethod(String billType) {
|
||||
private String resolveChannel(String billType) {
|
||||
try {
|
||||
return BillType.valueOf(billType).name();
|
||||
} catch (IllegalArgumentException e) {
|
||||
@@ -489,6 +500,23 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按账单来源的「计入统计」默认值覆盖解析结果。
|
||||
*
|
||||
* <p>采用「解析器判定 && 来源默认」的语义:来源默认不计入时整批置为不计入;
|
||||
* 来源默认计入时保留解析器对「不计收支/中性」行的排除。</p>
|
||||
*
|
||||
* @param rows 解析结果
|
||||
* @param billType 账单来源
|
||||
*/
|
||||
private void applyBillSourceStatsDefault(List<ParsedBillRow> rows, BillType billType) {
|
||||
boolean sourceDefault = appConfigStore.load().resolveIncludeInStats(billType.name());
|
||||
for (ParsedBillRow row : rows) {
|
||||
boolean parserValue = row.getIncludeInStats() == null || row.getIncludeInStats();
|
||||
row.setIncludeInStats(parserValue && sourceDefault);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据交易对方与商品名称构造备注
|
||||
*
|
||||
|
||||
@@ -23,6 +23,7 @@ import cn.rosercode.finance.config.datasource.DatabaseConfig;
|
||||
import cn.rosercode.finance.config.datasource.DatabaseConfigStore;
|
||||
import cn.rosercode.finance.config.datasource.DynamicDataSource;
|
||||
import cn.rosercode.finance.config.datasource.SchemaInitializer;
|
||||
import cn.rosercode.finance.config.datasource.SchemaMigrator;
|
||||
import cn.rosercode.finance.dto.DatabaseConfigRequest;
|
||||
import cn.rosercode.finance.service.DatabaseService;
|
||||
import cn.rosercode.finance.vo.DatabaseStatusVO;
|
||||
@@ -47,16 +48,19 @@ public class DatabaseServiceImpl implements DatabaseService {
|
||||
private final DynamicDataSource dynamicDataSource;
|
||||
private final DatabaseConfigStore configStore;
|
||||
private final SchemaInitializer schemaInitializer;
|
||||
private final SchemaMigrator schemaMigrator;
|
||||
|
||||
/** 当前生效的数据库配置,未配置时为 null */
|
||||
private volatile DatabaseConfig currentConfig;
|
||||
|
||||
public DatabaseServiceImpl(DynamicDataSource dynamicDataSource,
|
||||
DatabaseConfigStore configStore,
|
||||
SchemaInitializer schemaInitializer) {
|
||||
SchemaInitializer schemaInitializer,
|
||||
SchemaMigrator schemaMigrator) {
|
||||
this.dynamicDataSource = dynamicDataSource;
|
||||
this.configStore = configStore;
|
||||
this.schemaInitializer = schemaInitializer;
|
||||
this.schemaMigrator = schemaMigrator;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,6 +108,7 @@ public class DatabaseServiceImpl implements DatabaseService {
|
||||
try {
|
||||
assertConnectable(newDataSource);
|
||||
schemaInitializer.initialize(newDataSource, config.getType());
|
||||
schemaMigrator.migrate(newDataSource, config.getType());
|
||||
// 先落盘再切换,避免切换成功但配置未保存导致重启后状态不一致
|
||||
configStore.save(config);
|
||||
dynamicDataSource.addAndSwitch(config.getType(), newDataSource);
|
||||
@@ -178,6 +183,7 @@ public class DatabaseServiceImpl implements DatabaseService {
|
||||
try {
|
||||
assertConnectable(dataSource);
|
||||
schemaInitializer.initialize(dataSource, config.getType());
|
||||
schemaMigrator.migrate(dataSource, config.getType());
|
||||
dynamicDataSource.addAndSwitch(config.getType(), dataSource);
|
||||
switched = true;
|
||||
currentConfig = config;
|
||||
|
||||
@@ -10,13 +10,16 @@ import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import cn.rosercode.finance.common.BusinessException;
|
||||
import cn.rosercode.finance.common.PageResult;
|
||||
import cn.rosercode.finance.converter.VoConverter;
|
||||
import cn.rosercode.finance.dto.RuleQuery;
|
||||
import cn.rosercode.finance.dto.RuleRequest;
|
||||
import cn.rosercode.finance.entity.Category;
|
||||
import cn.rosercode.finance.entity.Rule;
|
||||
@@ -73,10 +76,48 @@ public class RuleServiceImpl implements RuleService {
|
||||
.eq(Rule::getUserId, userId)
|
||||
.orderByAsc(Rule::getSortOrder)
|
||||
.orderByAsc(Rule::getId));
|
||||
return toVoList(rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 支持按名称/关键词、启用状态与收支类型筛选,按优先级排序。
|
||||
*/
|
||||
@Override
|
||||
public PageResult<RuleVO> page(Long userId, RuleQuery query) {
|
||||
LambdaQueryWrapper<Rule> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(Rule::getUserId, userId);
|
||||
if (StringUtils.hasText(query.getKeyword())) {
|
||||
String keyword = query.getKeyword().trim();
|
||||
wrapper.and(w -> w.like(Rule::getName, keyword).or().like(Rule::getKeyword, keyword));
|
||||
}
|
||||
if (query.getEnabled() != null) {
|
||||
wrapper.eq(Rule::getEnabled, query.getEnabled());
|
||||
}
|
||||
if (StringUtils.hasText(query.getType())) {
|
||||
wrapper.eq(Rule::getType, query.getType());
|
||||
}
|
||||
wrapper.orderByAsc(Rule::getSortOrder).orderByAsc(Rule::getId);
|
||||
|
||||
int pageNo = query.getPage() == null || query.getPage() < 1 ? 1 : query.getPage();
|
||||
int pageSize = query.getSize() == null || query.getSize() < 1 ? 20 : query.getSize();
|
||||
Page<Rule> page = new Page<>(pageNo, pageSize);
|
||||
Page<Rule> result = ruleMapper.selectPage(page, wrapper);
|
||||
return new PageResult<>(toVoList(result.getRecords()), result.getTotal(), result.getCurrent(),
|
||||
result.getSize());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将规则实体列表转换为视图对象,并批量填充分类名称与标签。
|
||||
*
|
||||
* @param rules 规则实体列表
|
||||
* @return 规则视图对象列表
|
||||
*/
|
||||
private List<RuleVO> toVoList(List<Rule> rules) {
|
||||
if (rules.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
// 批量查询关联分类
|
||||
List<Long> categoryIds = rules.stream()
|
||||
.map(Rule::getCategoryId)
|
||||
|
||||
@@ -264,9 +264,9 @@ public class StatisticsServiceImpl implements StatisticsService {
|
||||
} else if (uncategorized) {
|
||||
wrapper.isNull("category_id");
|
||||
}
|
||||
// 支付方式筛选(多选)
|
||||
if (query.getPaymentMethods() != null && !query.getPaymentMethods().isEmpty()) {
|
||||
wrapper.in("payment_method", query.getPaymentMethods());
|
||||
// 交易渠道筛选(多选)
|
||||
if (query.getChannels() != null && !query.getChannels().isEmpty()) {
|
||||
wrapper.in("channel", query.getChannels());
|
||||
}
|
||||
// 金额区间筛选
|
||||
if (query.getMinAmount() != null) {
|
||||
@@ -279,6 +279,25 @@ public class StatisticsServiceImpl implements StatisticsService {
|
||||
if (StringUtils.hasText(query.getRemark())) {
|
||||
wrapper.like("remark", query.getRemark());
|
||||
}
|
||||
// 标签筛选(多选);同时勾选「未打标签」时与其取并集
|
||||
boolean hasTagIds = query.getTagIds() != null && !query.getTagIds().isEmpty();
|
||||
boolean untagged = Boolean.TRUE.equals(query.getUntagged());
|
||||
if (hasTagIds || untagged) {
|
||||
wrapper.and(w -> {
|
||||
if (hasTagIds) {
|
||||
String ids = query.getTagIds().stream().map(String::valueOf)
|
||||
.collect(Collectors.joining(","));
|
||||
w.inSql("id", "SELECT transaction_id FROM t_transaction_tag WHERE tag_id IN (" + ids + ")");
|
||||
}
|
||||
if (untagged) {
|
||||
if (hasTagIds) {
|
||||
w.or();
|
||||
}
|
||||
w.apply("NOT EXISTS (SELECT 1 FROM t_transaction_tag tt "
|
||||
+ "WHERE tt.transaction_id = t_transaction.id)");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 统计口径:默认仅统计计入统计的记录(排除借款、账户间转账等)
|
||||
@@ -337,7 +356,7 @@ public class StatisticsServiceImpl implements StatisticsService {
|
||||
vo.setType(category != null ? category.getType()
|
||||
: (income.compareTo(expense) >= 0 ? "INCOME" : "EXPENSE"));
|
||||
}
|
||||
case PAYMENT_METHOD -> vo.setName(paymentLabel(key));
|
||||
case CHANNEL -> vo.setName(channelLabel(key));
|
||||
case TYPE -> {
|
||||
vo.setName("INCOME".equals(key) ? "收入" : "支出");
|
||||
vo.setType(key);
|
||||
@@ -365,7 +384,7 @@ public class StatisticsServiceImpl implements StatisticsService {
|
||||
case QUARTER -> "strftime('%Y', transaction_time) || '-Q' || "
|
||||
+ "((CAST(strftime('%m', transaction_time) AS INTEGER) + 2) / 3)";
|
||||
case YEAR -> "strftime('%Y', transaction_time)";
|
||||
case PAYMENT_METHOD -> "payment_method";
|
||||
case CHANNEL -> "channel";
|
||||
case CATEGORY -> "category_id";
|
||||
case TYPE -> "type";
|
||||
};
|
||||
@@ -376,7 +395,7 @@ public class StatisticsServiceImpl implements StatisticsService {
|
||||
case MONTH -> "DATE_FORMAT(transaction_time, '%Y-%m')";
|
||||
case QUARTER -> "CONCAT(YEAR(transaction_time), '-Q', QUARTER(transaction_time))";
|
||||
case YEAR -> "DATE_FORMAT(transaction_time, '%Y')";
|
||||
case PAYMENT_METHOD -> "payment_method";
|
||||
case CHANNEL -> "channel";
|
||||
case CATEGORY -> "category_id";
|
||||
case TYPE -> "type";
|
||||
};
|
||||
@@ -420,12 +439,12 @@ public class StatisticsServiceImpl implements StatisticsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将支付方式编码转换为中文名称
|
||||
* 将交易渠道编码转换为中文名称
|
||||
*
|
||||
* @param code 支付方式编码
|
||||
* @param code 交易渠道编码
|
||||
* @return 中文名称
|
||||
*/
|
||||
private String paymentLabel(String code) {
|
||||
private String channelLabel(String code) {
|
||||
if (!StringUtils.hasText(code)) {
|
||||
return "未填写";
|
||||
}
|
||||
|
||||
@@ -102,9 +102,9 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
wrapper.le(Transaction::getTransactionTime,
|
||||
LocalDateTime.parse(query.getEndTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
}
|
||||
// 按支付方式筛选(多选)
|
||||
if (query.getPaymentMethods() != null && !query.getPaymentMethods().isEmpty()) {
|
||||
wrapper.in(Transaction::getPaymentMethod, query.getPaymentMethods());
|
||||
// 按交易渠道筛选(多选)
|
||||
if (query.getChannels() != null && !query.getChannels().isEmpty()) {
|
||||
wrapper.in(Transaction::getChannel, query.getChannels());
|
||||
}
|
||||
// 按是否计入统计筛选
|
||||
if (query.getIncludeInStats() != null) {
|
||||
@@ -121,17 +121,27 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
if (StringUtils.hasText(query.getRemark())) {
|
||||
wrapper.like(Transaction::getRemark, query.getRemark());
|
||||
}
|
||||
// 按标签筛选:命中任一选中标签,或筛选无任何标签的记录
|
||||
// 按标签筛选:命中任一选中标签、有任意标签,或筛选无任何标签的记录(多条件取并集)
|
||||
boolean hasTagIds = query.getTagIds() != null && !query.getTagIds().isEmpty();
|
||||
boolean untagged = Boolean.TRUE.equals(query.getUntagged());
|
||||
if (hasTagIds || untagged) {
|
||||
boolean tagged = Boolean.TRUE.equals(query.getTagged());
|
||||
if (hasTagIds || untagged || tagged) {
|
||||
wrapper.and(w -> {
|
||||
boolean appended = false;
|
||||
if (hasTagIds) {
|
||||
w.inSql(Transaction::getId, "SELECT transaction_id FROM t_transaction_tag WHERE tag_id IN ("
|
||||
+ query.getTagIds().stream().map(String::valueOf).collect(Collectors.joining(",")) + ")");
|
||||
appended = true;
|
||||
}
|
||||
if (tagged) {
|
||||
if (appended) {
|
||||
w.or();
|
||||
}
|
||||
w.apply("EXISTS (SELECT 1 FROM t_transaction_tag tt WHERE tt.transaction_id = t_transaction.id)");
|
||||
appended = true;
|
||||
}
|
||||
if (untagged) {
|
||||
if (hasTagIds) {
|
||||
if (appended) {
|
||||
w.or();
|
||||
}
|
||||
w.apply("NOT EXISTS (SELECT 1 FROM t_transaction_tag tt WHERE tt.transaction_id = t_transaction.id)");
|
||||
@@ -187,7 +197,7 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
transaction.setCategoryId(request.getCategoryId());
|
||||
transaction.setType(request.getType());
|
||||
transaction.setAmount(request.getAmount());
|
||||
transaction.setPaymentMethod(request.getPaymentMethod());
|
||||
transaction.setChannel(request.getChannel());
|
||||
transaction.setRemark(request.getRemark());
|
||||
transaction.setTransactionTime(request.getTransactionTime());
|
||||
transaction.setIncludeInStats(request.getIncludeInStats() == null ? Boolean.TRUE : request.getIncludeInStats());
|
||||
@@ -223,7 +233,7 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
transaction.setCategoryId(request.getCategoryId());
|
||||
transaction.setType(request.getType());
|
||||
transaction.setAmount(request.getAmount());
|
||||
transaction.setPaymentMethod(request.getPaymentMethod());
|
||||
transaction.setChannel(request.getChannel());
|
||||
transaction.setRemark(request.getRemark());
|
||||
transaction.setTransactionTime(request.getTransactionTime());
|
||||
transaction.setIncludeInStats(request.getIncludeInStats() == null ? Boolean.TRUE : request.getIncludeInStats());
|
||||
@@ -588,9 +598,9 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
wrapper.le(Transaction::getTransactionTime,
|
||||
LocalDateTime.parse(query.getEndTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
}
|
||||
// 按支付方式筛选(多选)
|
||||
if (query.getPaymentMethods() != null && !query.getPaymentMethods().isEmpty()) {
|
||||
wrapper.in(Transaction::getPaymentMethod, query.getPaymentMethods());
|
||||
// 按交易渠道筛选(多选)
|
||||
if (query.getChannels() != null && !query.getChannels().isEmpty()) {
|
||||
wrapper.in(Transaction::getChannel, query.getChannels());
|
||||
}
|
||||
// 按是否计入统计筛选
|
||||
if (query.getIncludeInStats() != null) {
|
||||
@@ -607,17 +617,27 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
if (StringUtils.hasText(query.getRemark())) {
|
||||
wrapper.like(Transaction::getRemark, query.getRemark());
|
||||
}
|
||||
// 按标签筛选:命中任一选中标签,或筛选无任何标签的记录
|
||||
// 按标签筛选:命中任一选中标签、有任意标签,或筛选无任何标签的记录(多条件取并集)
|
||||
boolean hasTagIds = query.getTagIds() != null && !query.getTagIds().isEmpty();
|
||||
boolean untagged = Boolean.TRUE.equals(query.getUntagged());
|
||||
if (hasTagIds || untagged) {
|
||||
boolean tagged = Boolean.TRUE.equals(query.getTagged());
|
||||
if (hasTagIds || untagged || tagged) {
|
||||
wrapper.and(w -> {
|
||||
boolean appended = false;
|
||||
if (hasTagIds) {
|
||||
w.inSql(Transaction::getId, "SELECT transaction_id FROM t_transaction_tag WHERE tag_id IN ("
|
||||
+ query.getTagIds().stream().map(String::valueOf).collect(Collectors.joining(",")) + ")");
|
||||
appended = true;
|
||||
}
|
||||
if (tagged) {
|
||||
if (appended) {
|
||||
w.or();
|
||||
}
|
||||
w.apply("EXISTS (SELECT 1 FROM t_transaction_tag tt WHERE tt.transaction_id = t_transaction.id)");
|
||||
appended = true;
|
||||
}
|
||||
if (untagged) {
|
||||
if (hasTagIds) {
|
||||
if (appended) {
|
||||
w.or();
|
||||
}
|
||||
w.apply("NOT EXISTS (SELECT 1 FROM t_transaction_tag tt WHERE tt.transaction_id = t_transaction.id)");
|
||||
@@ -666,7 +686,7 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
}
|
||||
vo.setCategoryName(category != null ? category.getName() : "");
|
||||
vo.setAmount(String.format("%.2f", t.getAmount()));
|
||||
vo.setPaymentMethod(getPaymentMethodLabel(t.getPaymentMethod()));
|
||||
vo.setChannel(getChannelLabel(t.getChannel()));
|
||||
// 备注被标记为隐藏时,导出同样以占位符替代,避免泄露
|
||||
if (Boolean.TRUE.equals(t.getRemarkHidden())) {
|
||||
vo.setRemark(REMARK_HIDDEN_PLACEHOLDER);
|
||||
@@ -681,12 +701,12 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将支付方式编码转换为中文标签
|
||||
* 将交易渠道编码转换为中文标签
|
||||
*
|
||||
* @param code 支付方式编码
|
||||
* @param code 交易渠道编码
|
||||
* @return 中文标签
|
||||
*/
|
||||
private String getPaymentMethodLabel(String code) {
|
||||
private String getChannelLabel(String code) {
|
||||
if (code == null) return "";
|
||||
return switch (code) {
|
||||
case "WECHAT" -> "微信";
|
||||
@@ -703,16 +723,16 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 由账单来源枚举(支付宝、微信、美团、招商银行、交通银行)与人工记账支付方式
|
||||
* (现金)组成,账单来源在前,人工支付方式在后。
|
||||
* 由账单来源枚举(支付宝、微信、美团、招商银行、交通银行)与人工记账交易渠道
|
||||
* (现金)组成,账单来源在前,人工交易渠道在后。
|
||||
*/
|
||||
@Override
|
||||
public List<String> listPaymentMethods() {
|
||||
public List<String> listChannels() {
|
||||
List<String> methods = new ArrayList<>();
|
||||
for (BillType type : BillType.values()) {
|
||||
methods.add(type.name());
|
||||
}
|
||||
// 人工记账使用的支付方式,非账单来源
|
||||
// 人工记账使用的交易渠道,非账单来源
|
||||
methods.add("CASH");
|
||||
return methods;
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ public class AiQueryResultVO {
|
||||
/** 是否筛选未分类记录 */
|
||||
private Boolean uncategorized;
|
||||
|
||||
/** 支付方式列表 */
|
||||
private List<String> paymentMethods;
|
||||
/** 交易渠道列表 */
|
||||
private List<String> channels;
|
||||
|
||||
/** 开始时间(yyyy-MM-ddTHH:mm:ss) */
|
||||
private String startTime;
|
||||
|
||||
@@ -28,8 +28,8 @@ public class AiRecordResultVO {
|
||||
/** 命中的分类名称 */
|
||||
private String categoryName;
|
||||
|
||||
/** 支付方式 */
|
||||
private String paymentMethod;
|
||||
/** 交易渠道 */
|
||||
private String channel;
|
||||
|
||||
/** 交易时间(yyyy-MM-ddTHH:mm:ss) */
|
||||
private String transactionTime;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package cn.rosercode.finance.vo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* AI 生成规则建议结果视图对象。
|
||||
*
|
||||
* @date 2026-09-20 16:05:00
|
||||
*/
|
||||
@Data
|
||||
public class AiRuleSuggestResultVO {
|
||||
|
||||
/** 分析摘要 */
|
||||
private String summary;
|
||||
|
||||
/** 规则建议列表 */
|
||||
private List<AiRuleSuggestionVO> rules;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package cn.rosercode.finance.vo;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* AI 生成的分类与标签规则建议视图对象。
|
||||
*
|
||||
* @date 2026-09-20 16:05:00
|
||||
*/
|
||||
@Data
|
||||
public class AiRuleSuggestionVO {
|
||||
|
||||
/** 规则名称 */
|
||||
private String name;
|
||||
|
||||
/** 限定收支类型:INCOME/EXPENSE,null 表示不限 */
|
||||
private String type;
|
||||
|
||||
/** 匹配关键词(备注等),null 表示不限 */
|
||||
private String keyword;
|
||||
|
||||
/** 金额下限(含),null 表示不限 */
|
||||
private BigDecimal minAmount;
|
||||
|
||||
/** 金额上限(含),null 表示不限 */
|
||||
private BigDecimal maxAmount;
|
||||
|
||||
/** 命中的现有分类ID,null 表示未匹配到现有分类 */
|
||||
private Long categoryId;
|
||||
|
||||
/** 现有分类名称 */
|
||||
private String categoryName;
|
||||
|
||||
/** 建议新建的分类名称,null 表示无需新建 */
|
||||
private String newCategoryName;
|
||||
|
||||
/** 建议新建的分类图标 */
|
||||
private String newCategoryIcon;
|
||||
|
||||
/** 命中的现有标签ID列表 */
|
||||
private List<Long> tagIds;
|
||||
|
||||
/** 现有标签名称列表 */
|
||||
private List<String> tagNames;
|
||||
|
||||
/** 建议新建的标签列表 */
|
||||
private List<AiNewTagVO> newTags;
|
||||
|
||||
/** AI 生成理由 */
|
||||
private String reason;
|
||||
|
||||
/** 是否与已有规则(或本次建议)重复:重复时前端默认不勾选且禁止导入 */
|
||||
private Boolean duplicate = false;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package cn.rosercode.finance.vo;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* AI 自然语言统计查询解析结果视图对象。
|
||||
*
|
||||
* @date 2026-09-20 16:30:00
|
||||
*/
|
||||
@Data
|
||||
public class AiStatisticsResultVO {
|
||||
|
||||
/** 识别到的统计意图摘要 */
|
||||
private String summary;
|
||||
|
||||
/** 收支类型筛选 */
|
||||
private List<String> types;
|
||||
|
||||
/** 分类ID列表 */
|
||||
private List<Long> categoryIds;
|
||||
|
||||
/** 分类名称列表 */
|
||||
private List<String> categoryNames;
|
||||
|
||||
/** 标签ID列表 */
|
||||
private List<Long> tagIds;
|
||||
|
||||
/** 标签名称列表 */
|
||||
private List<String> tagNames;
|
||||
|
||||
/** 交易渠道列表 */
|
||||
private List<String> channels;
|
||||
|
||||
/** 起始时间(yyyy-MM-dd'T'HH:mm:ss) */
|
||||
private String startTime;
|
||||
|
||||
/** 结束时间(yyyy-MM-dd'T'HH:mm:ss) */
|
||||
private String endTime;
|
||||
|
||||
/** 最小金额(含) */
|
||||
private BigDecimal minAmount;
|
||||
|
||||
/** 最大金额(含) */
|
||||
private BigDecimal maxAmount;
|
||||
|
||||
/** 备注关键字 */
|
||||
private String remark;
|
||||
|
||||
/** 统计口径:INCLUDED/ALL/EXCLUDED,null 表示不限 */
|
||||
private String statsScope;
|
||||
|
||||
/** 统计维度(趋势粒度):day/week/month/quarter/year,null 表示不限 */
|
||||
private String dimension;
|
||||
|
||||
/** 未识别的关键词 */
|
||||
private List<String> unmatched;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ public class AiSuggestionVO {
|
||||
/** 代表交易记录ID */
|
||||
private Long transactionId;
|
||||
|
||||
/** 该建议适用的全部记录ID(按类型、备注、支付方式聚合后的一组记录) */
|
||||
/** 该建议适用的全部记录ID(按类型、备注、交易渠道聚合后的一组记录) */
|
||||
private List<Long> transactionIds;
|
||||
|
||||
/** 命中的记录数,等于 transactionIds 的大小 */
|
||||
|
||||
@@ -12,7 +12,7 @@ import lombok.Data;
|
||||
@Data
|
||||
public class StatisticsGroupVO {
|
||||
|
||||
/** 分组键(如分类ID、支付方式编码、日期标签) */
|
||||
/** 分组键(如分类ID、交易渠道编码、日期标签) */
|
||||
private String key;
|
||||
|
||||
/** 分组显示名称 */
|
||||
|
||||
@@ -22,9 +22,9 @@ public class TransactionExportVO {
|
||||
@ColumnWidth(12)
|
||||
private String amount;
|
||||
|
||||
@ExcelProperty("支付方式")
|
||||
@ExcelProperty("交易渠道")
|
||||
@ColumnWidth(12)
|
||||
private String paymentMethod;
|
||||
private String channel;
|
||||
|
||||
@ExcelProperty("备注")
|
||||
@ColumnWidth(30)
|
||||
|
||||
@@ -30,8 +30,8 @@ public class TransactionVO {
|
||||
/** 交易金额 */
|
||||
private BigDecimal amount;
|
||||
|
||||
/** 支付方式 */
|
||||
private String paymentMethod;
|
||||
/** 交易渠道 */
|
||||
private String channel;
|
||||
|
||||
/** 备注 */
|
||||
private String remark;
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
-->
|
||||
<insert id="insertBatch">
|
||||
INSERT INTO t_transaction
|
||||
(user_id, category_id, type, amount, payment_method, remark, remark_hidden,
|
||||
(user_id, category_id, type, amount, channel, remark, remark_hidden,
|
||||
external_no, transaction_time, include_in_stats, raw_data)
|
||||
VALUES
|
||||
<foreach collection="list" item="item" separator=",">
|
||||
(#{item.userId}, #{item.categoryId}, #{item.type}, #{item.amount}, #{item.paymentMethod},
|
||||
(#{item.userId}, #{item.categoryId}, #{item.type}, #{item.amount}, #{item.channel},
|
||||
#{item.remark}, IFNULL(#{item.remarkHidden}, 0), #{item.externalNo},
|
||||
#{item.transactionTime}, IFNULL(#{item.includeInStats}, 1), #{item.rawData})
|
||||
</foreach>
|
||||
|
||||
@@ -30,7 +30,7 @@ CREATE TABLE IF NOT EXISTS t_transaction (
|
||||
category_id BIGINT COMMENT '所属分类ID,可为空表示未分类',
|
||||
type VARCHAR(10) NOT NULL COMMENT '记录类型:INCOME-收入,EXPENSE-支出',
|
||||
amount DECIMAL(12, 2) NOT NULL COMMENT '金额,精确到分',
|
||||
payment_method VARCHAR(30) COMMENT '支付方式:WECHAT-微信,ALIPAY-支付宝,CASH-现金,BANK-银行卡',
|
||||
channel VARCHAR(30) COMMENT '交易渠道:WECHAT-微信,ALIPAY-支付宝,CASH-现金,BANK-银行卡',
|
||||
remark VARCHAR(255) COMMENT '备注说明',
|
||||
remark_hidden TINYINT(1) NOT NULL DEFAULT 0 COMMENT '备注是否隐藏:1-隐藏,0-显示',
|
||||
external_no VARCHAR(64) COMMENT '外部账单交易号,用于账单导入去重',
|
||||
|
||||
@@ -36,7 +36,7 @@ CREATE TABLE IF NOT EXISTS t_transaction (
|
||||
category_id INTEGER,
|
||||
type VARCHAR(10) NOT NULL,
|
||||
amount NUMERIC(12, 2) NOT NULL,
|
||||
payment_method VARCHAR(30),
|
||||
channel VARCHAR(30),
|
||||
remark VARCHAR(255),
|
||||
remark_hidden INTEGER NOT NULL DEFAULT 0,
|
||||
external_no VARCHAR(64),
|
||||
|
||||
Reference in New Issue
Block a user