feat: 接入 DeepSeek,新增 AI 分类建议、自然语言查询与智能记账
- 后端新增 AI 模块:DeepSeek 客户端、用户级配置表 t_ai_config、用量记录表 t_ai_usage - 批量设置支持 AI 推荐分类与标签,并可一键应用 - 交易列表支持自然语言查询,解析为筛选条件确认后应用 - 支持自然语言记账,解析后预填新增表单 - 新增 AI 设置页:启用开关、API Key、接口地址、模型、连通性测试与用量统计 - 交易列表重排:AI 查询区、已激活筛选标签、备注与查询历史缓存 - 账单导入默认分类下拉补充分类图标 - 顶部用户入口改为下拉菜单(AI 设置 / 退出登录)
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
// AI 智能建议相关 API 接口:配置读写、连通性测试、建议生成与应用
|
||||
import request from './request'
|
||||
|
||||
/** AI 配置数据结构(API Key 已脱敏) */
|
||||
export interface AiConfig {
|
||||
enabled: boolean
|
||||
apiKey: string | null
|
||||
hasApiKey: boolean
|
||||
baseUrl: string
|
||||
model: string
|
||||
}
|
||||
|
||||
/** 单条 AI 建议 */
|
||||
export interface AiSuggestion {
|
||||
transactionId: number
|
||||
categoryId: number | null
|
||||
categoryName: string | null
|
||||
tagIds: number[]
|
||||
tagNames: string[]
|
||||
reason: string
|
||||
}
|
||||
|
||||
/** 获取当前用户的 AI 配置 */
|
||||
export function getAiConfig() {
|
||||
return request.get('/ai/config')
|
||||
}
|
||||
|
||||
/** 保存 AI 配置,apiKey 留空表示不修改原值 */
|
||||
export function saveAiConfig(data: { enabled: boolean; apiKey?: string; baseUrl?: string; model?: string }) {
|
||||
return request.put('/ai/config', data)
|
||||
}
|
||||
|
||||
/** 测试 DeepSeek 连通性(耗时较长,单独放宽超时) */
|
||||
export function testAiConfig() {
|
||||
return request.post('/ai/config/test', {}, { timeout: 60000 })
|
||||
}
|
||||
|
||||
/** 生成分类与标签建议(调用大模型,超时放宽) */
|
||||
export function suggestByAi(ids: number[]) {
|
||||
return request.post('/ai/suggest', { ids }, { timeout: 120000 })
|
||||
}
|
||||
|
||||
/** 一键应用建议:覆盖分类并追加标签 */
|
||||
export function applyAiSuggestions(suggestions: { transactionId: number; categoryId: number | null; tagIds: number[] }[]) {
|
||||
return request.post('/ai/apply', { suggestions })
|
||||
}
|
||||
|
||||
/** 自然语言查询解析结果 */
|
||||
export interface AiQueryResult {
|
||||
summary: string
|
||||
types: string[]
|
||||
categoryIds: number[]
|
||||
categoryNames: string[]
|
||||
uncategorized: boolean
|
||||
paymentMethods: string[]
|
||||
startTime: string | null
|
||||
endTime: string | null
|
||||
includeInStats: boolean | null
|
||||
minAmount: number | null
|
||||
maxAmount: number | null
|
||||
remark: string | null
|
||||
tagIds: number[]
|
||||
tagNames: string[]
|
||||
unmatched: string[]
|
||||
}
|
||||
|
||||
/** 将自然语言查询解析为筛选条件 */
|
||||
export function queryByAi(query: string) {
|
||||
return request.post('/ai/query', { query }, { timeout: 120000 })
|
||||
}
|
||||
|
||||
/** 自然语言记账解析结果 */
|
||||
export interface AiRecordResult {
|
||||
summary: string
|
||||
type: string
|
||||
amount: number | null
|
||||
categoryId: number | null
|
||||
categoryName: string | null
|
||||
paymentMethod: string | null
|
||||
transactionTime: string | null
|
||||
remark: string | null
|
||||
includeInStats: boolean | null
|
||||
tagIds: number[]
|
||||
tagNames: string[]
|
||||
unmatched: string[]
|
||||
}
|
||||
|
||||
/** 将自然语言记账内容解析为交易记录 */
|
||||
export function parseRecordByAi(text: string) {
|
||||
return request.post('/ai/record', { text }, { timeout: 120000 })
|
||||
}
|
||||
|
||||
/** AI 使用累计汇总 */
|
||||
export interface AiUsageSummary {
|
||||
totalCalls: number
|
||||
successCalls: number
|
||||
failedCalls: number
|
||||
promptTokens: number
|
||||
completionTokens: number
|
||||
totalTokens: number
|
||||
cachedTokens: number
|
||||
}
|
||||
|
||||
/** 单条 AI 使用明细 */
|
||||
export interface AiUsageItem {
|
||||
id: number
|
||||
type: string
|
||||
model: string
|
||||
transactionCount: number
|
||||
promptTokens: number
|
||||
completionTokens: number
|
||||
totalTokens: number
|
||||
cachedTokens: number
|
||||
success: boolean
|
||||
errorMessage: string | null
|
||||
durationMs: number | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** AI 使用情况(累计汇总 + 最近明细) */
|
||||
export interface AiUsageResult {
|
||||
summary: AiUsageSummary
|
||||
recent: AiUsageItem[]
|
||||
}
|
||||
|
||||
/** 获取 AI 使用情况 */
|
||||
export function getAiUsage(limit = 20) {
|
||||
return request.get('/ai/usage', { params: { limit } })
|
||||
}
|
||||
@@ -81,6 +81,7 @@ export default {
|
||||
transactions: { zh: '收支记录', en: 'Transactions' },
|
||||
categories: { zh: '分类管理', en: 'Categories' },
|
||||
tags: { zh: '标签管理', en: 'Tags' },
|
||||
aiSettings: { zh: 'AI 设置', en: 'AI Settings' },
|
||||
user: { zh: '用户', en: 'User' },
|
||||
logout: { zh: '退出', en: 'Sign Out' },
|
||||
},
|
||||
@@ -141,6 +142,9 @@ export default {
|
||||
amountRange: { zh: '金额区间', en: 'Amount Range' },
|
||||
search: { zh: '查询', en: 'Search' },
|
||||
reset: { zh: '重置', en: 'Reset' },
|
||||
activeFilters: { zh: '已激活:', en: 'Active:' },
|
||||
clearAll: { zh: '清空全部', en: 'Clear all' },
|
||||
exampleLabel: { zh: '示例', en: 'Examples' },
|
||||
summary: { zh: '总计 {total} 条 · 第 {page} / {pages} 页 · 每页 {size} 条', en: 'Total {total} · Page {page} / {pages} · {size} per page' },
|
||||
pageExpense: { zh: '本页支出', en: 'Page Expense' },
|
||||
pageIncome: { zh: '本页收入', en: 'Page Income' },
|
||||
@@ -193,6 +197,7 @@ export default {
|
||||
amountPlaceholder: { zh: '请输入金额', en: 'Enter amount' },
|
||||
paymentPlaceholder: { zh: '选择支付方式', en: 'Select payment method' },
|
||||
remarkPlaceholder: { zh: '可选备注说明', en: 'Optional remark' },
|
||||
remarkHistoryClear: { zh: '清空历史', en: 'Clear history' },
|
||||
tagPlaceholder: { zh: '选择标签', en: 'Select tags' },
|
||||
noRawData: { zh: '该记录没有原始账单数据', en: 'This record has no raw bill data' },
|
||||
remarkHiddenSuccess: { zh: '已隐藏备注', en: 'Remark hidden' },
|
||||
@@ -299,4 +304,104 @@ export default {
|
||||
loadCategoriesFailed: { zh: '加载分类失败', en: 'Failed to load categories' },
|
||||
loadTagsFailed: { zh: '加载标签失败', en: 'Failed to load tags' },
|
||||
},
|
||||
|
||||
// AI 智能建议
|
||||
ai: {
|
||||
title: { zh: 'AI 智能建议', en: 'AI Suggestions' },
|
||||
intro: { zh: '配置 DeepSeek 后,可在收支记录页的「一键设置」中让 AI 根据记录内容推荐分类与标签。', en: 'After configuring DeepSeek, you can let AI recommend categories and tags for records in the "Batch Set" dialog on the Transactions page.' },
|
||||
configTitle: { zh: '模型配置', en: 'Model Configuration' },
|
||||
enabledOn: { zh: '已启用', en: 'Enabled' },
|
||||
enabledOff: { zh: '未启用', en: 'Disabled' },
|
||||
enabled: { zh: '启用 AI 建议', en: 'Enable AI suggestions' },
|
||||
enabledHint: { zh: '关闭后不会发起任何 AI 请求', en: 'No AI requests are sent when disabled' },
|
||||
apiKey: { zh: 'API Key', en: 'API Key' },
|
||||
apiKeyPlaceholder: { zh: '请输入 DeepSeek API Key', en: 'Enter your DeepSeek API Key' },
|
||||
apiKeyKeep: { zh: '已配置,留空表示不修改', en: 'Configured; leave blank to keep unchanged' },
|
||||
baseUrl: { zh: '接口地址', en: 'Base URL' },
|
||||
model: { zh: '模型', en: 'Model' },
|
||||
save: { zh: '保存', en: 'Save' },
|
||||
test: { zh: '测试连接', en: 'Test connection' },
|
||||
saveSuccess: { zh: '保存成功', en: 'Saved successfully' },
|
||||
loadFailed: { zh: '加载 AI 配置失败', en: 'Failed to load AI config' },
|
||||
testSuccess: { zh: '连接成功', en: 'Connection succeeded' },
|
||||
testFailed: { zh: '连接失败', en: 'Connection failed' },
|
||||
suggestButton: { zh: 'AI 智能建议', en: 'AI suggestions' },
|
||||
suggestTitle: { zh: 'AI 分类与标签建议', en: 'AI category & tag suggestions' },
|
||||
suggesting: { zh: '正在分析,请稍候…', en: 'Analyzing, please wait…' },
|
||||
suggestFailed: { zh: 'AI 建议生成失败', en: 'Failed to generate AI suggestions' },
|
||||
noSuggestion: { zh: 'AI 未返回有效建议', en: 'AI returned no valid suggestions' },
|
||||
applyAll: { zh: '全部应用({count})', en: 'Apply all ({count})' },
|
||||
applySuccess: { zh: '已应用 {count} 条建议', en: 'Applied {count} suggestions' },
|
||||
applyFailed: { zh: '应用建议失败', en: 'Failed to apply suggestions' },
|
||||
applyHint: { zh: '将覆盖分类并追加标签,确认后立即写入', en: 'Categories will be overwritten and tags appended; changes are written immediately.' },
|
||||
needConfig: { zh: 'AI 未启用,请先在「AI 设置」中配置', en: 'AI is not enabled. Please configure it in AI Settings first.' },
|
||||
query: {
|
||||
placeholder: { zh: '用自然语言查询,如:上个月餐饮花了多少', en: 'Ask in natural language, e.g. dining expenses last month' },
|
||||
examples: { zh: '上个月餐饮|超过500的支出|待报销的支出|微信支付', en: 'Dining last month|Expenses over 500|Pending reimbursement|Paid by WeChat' },
|
||||
button: { zh: 'AI 查询', en: 'AI Query' },
|
||||
emptyInput: { zh: '请输入查询内容', en: 'Please enter a query' },
|
||||
clearHistory: { zh: '清空历史', en: 'Clear history' },
|
||||
failed: { zh: 'AI 查询解析失败', en: 'Failed to parse the query' },
|
||||
resultTitle: { zh: 'AI 解析结果', en: 'AI Query Result' },
|
||||
apply: { zh: '应用筛选', en: 'Apply filters' },
|
||||
applied: { zh: '已应用 AI 查询筛选条件', en: 'AI query filters applied' },
|
||||
noCondition: { zh: '未识别到可用的筛选条件,请换一种说法', en: 'No usable filter conditions recognized, please rephrase' },
|
||||
type: { zh: '类型', en: 'Type' },
|
||||
category: { zh: '分类', en: 'Category' },
|
||||
tag: { zh: '标签', en: 'Tag' },
|
||||
payment: { zh: '支付方式', en: 'Payment' },
|
||||
dateRange: { zh: '时间范围', en: 'Date range' },
|
||||
amountRange: { zh: '金额范围', en: 'Amount range' },
|
||||
remark: { zh: '备注', en: 'Remark' },
|
||||
includeInStats: { zh: '计入统计', en: 'In stats' },
|
||||
includeYes: { zh: '计入', en: 'Included' },
|
||||
includeNo: { zh: '不计入', en: 'Excluded' },
|
||||
unmatched: { zh: '未识别', en: 'Unmatched' },
|
||||
},
|
||||
record: {
|
||||
button: { zh: '智能记账', en: 'Smart Entry' },
|
||||
title: { zh: 'AI 智能记账', en: 'AI Smart Entry' },
|
||||
hint: { zh: '用一句话描述这笔收支,AI 会帮你填好表单,确认后再保存。', en: 'Describe the transaction in one sentence; AI fills the form for you to confirm.' },
|
||||
placeholder: { zh: '例如:昨天中午吃饭 35 支付宝', en: 'e.g. Lunch yesterday 35 Alipay' },
|
||||
parse: { zh: '解析', en: 'Parse' },
|
||||
emptyInput: { zh: '请输入记账内容', en: 'Please enter the transaction description' },
|
||||
failed: { zh: 'AI 记账解析失败', en: 'Failed to parse the transaction' },
|
||||
},
|
||||
column: {
|
||||
record: { zh: '记录', en: 'Record' },
|
||||
category: { zh: '建议分类', en: 'Suggested category' },
|
||||
tags: { zh: '建议标签', en: 'Suggested tags' },
|
||||
reason: { zh: '理由', en: 'Reason' },
|
||||
},
|
||||
usage: {
|
||||
title: { zh: '使用情况', en: 'Usage' },
|
||||
recentTitle: { zh: '最近调用记录', en: 'Recent calls' },
|
||||
refresh: { zh: '刷新', en: 'Refresh' },
|
||||
totalCalls: { zh: '调用次数', en: 'Calls' },
|
||||
successCalls: { zh: '成功', en: 'Succeeded' },
|
||||
failedCalls: { zh: '失败', en: 'Failed' },
|
||||
promptTokens: { zh: '输入 Token', en: 'Prompt tokens' },
|
||||
completionTokens: { zh: '输出 Token', en: 'Completion tokens' },
|
||||
totalTokens: { zh: '总 Token', en: 'Total tokens' },
|
||||
cachedTokens: { zh: '缓存命中', en: 'Cached tokens' },
|
||||
success: { zh: '成功', en: 'Success' },
|
||||
failed: { zh: '失败', en: 'Failed' },
|
||||
loadFailed: { zh: '加载使用情况失败', en: 'Failed to load usage' },
|
||||
empty: { zh: '暂无调用记录', en: 'No usage records yet' },
|
||||
type: {
|
||||
suggest: { zh: '生成建议', en: 'Suggest' },
|
||||
test: { zh: '测试连接', en: 'Test' },
|
||||
},
|
||||
column: {
|
||||
time: { zh: '时间', en: 'Time' },
|
||||
type: { zh: '类型', en: 'Type' },
|
||||
count: { zh: '分析条数', en: 'Records' },
|
||||
promptTokens: { zh: '输入', en: 'Prompt' },
|
||||
completionTokens: { zh: '输出', en: 'Completion' },
|
||||
totalTokens: { zh: '总计', en: 'Total' },
|
||||
duration: { zh: '耗时', en: 'Duration' },
|
||||
status: { zh: '状态', en: 'Status' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -41,6 +41,11 @@ const router = createRouter({
|
||||
name: 'Tags',
|
||||
component: () => import('../views/TagManage.vue'), // 标签管理
|
||||
},
|
||||
{
|
||||
path: 'ai-settings',
|
||||
name: 'AiSettings',
|
||||
component: () => import('../views/AiSettings.vue'), // AI 设置
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
<script setup lang="ts">
|
||||
// AI 设置页:配置 DeepSeek 的启用开关、API Key、基础地址与模型,并展示 AI 使用情况
|
||||
import { ref, onMounted, computed, h } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
useMessage, NCard, NForm, NFormItem, NInput, NSwitch, NButton, NSpace, NAlert, NText,
|
||||
NStatistic, NDataTable, NTag, NGrid, NGridItem, NIcon, NEmpty,
|
||||
} from 'naive-ui'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import {
|
||||
PulseOutline, CheckmarkCircleOutline, CloseCircleOutline, StatsChartOutline,
|
||||
CloudUploadOutline, CloudDownloadOutline, FlashOutline, TimeOutline,
|
||||
SettingsOutline, KeyOutline, LinkOutline, CubeOutline, SaveOutline,
|
||||
} from '@vicons/ionicons5'
|
||||
import { getAiConfig, saveAiConfig, testAiConfig, getAiUsage } from '../api/ai'
|
||||
import type { AiUsageResult } from '../api/ai'
|
||||
|
||||
const message = useMessage() // 消息提示
|
||||
const { t } = useI18n() // 国际化
|
||||
|
||||
const saving = ref(false) // 保存状态
|
||||
const testing = ref(false) // 测试连接状态
|
||||
const hasApiKey = ref(false) // 是否已配置过 API Key
|
||||
const usage = ref<AiUsageResult | null>(null) // AI 使用情况
|
||||
const usageLoading = ref(false) // 使用情况加载状态
|
||||
|
||||
// 表单数据
|
||||
const form = ref({ enabled: false, apiKey: '', baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat' })
|
||||
|
||||
/** 加载当前用户的 AI 配置 */
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const res: any = await getAiConfig()
|
||||
const data = res.data
|
||||
form.value = {
|
||||
enabled: !!data.enabled,
|
||||
apiKey: '',
|
||||
baseUrl: data.baseUrl || 'https://api.deepseek.com',
|
||||
model: data.model || 'deepseek-chat',
|
||||
}
|
||||
hasApiKey.value = !!data.hasApiKey
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('ai.loadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载 AI 使用情况(累计汇总 + 最近明细) */
|
||||
async function loadUsage() {
|
||||
usageLoading.value = true
|
||||
try {
|
||||
const res: any = await getAiUsage()
|
||||
usage.value = res.data
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('ai.usage.loadFailed'))
|
||||
} finally {
|
||||
usageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交表单数据,apiKey 为空时不覆盖已保存的 Key */
|
||||
function buildPayload() {
|
||||
return {
|
||||
enabled: form.value.enabled,
|
||||
apiKey: form.value.apiKey || undefined,
|
||||
baseUrl: form.value.baseUrl,
|
||||
model: form.value.model,
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存配置 */
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
await saveAiConfig(buildPayload())
|
||||
message.success(t('ai.saveSuccess'))
|
||||
await loadConfig()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('common.operationFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 测试连接:先保存当前表单,再向 DeepSeek 发起测试请求 */
|
||||
async function handleTest() {
|
||||
testing.value = true
|
||||
try {
|
||||
await saveAiConfig(buildPayload())
|
||||
await testAiConfig()
|
||||
message.success(t('ai.testSuccess'))
|
||||
await loadConfig()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('ai.testFailed'))
|
||||
} finally {
|
||||
testing.value = false
|
||||
// 测试连接也会产生使用记录,刷新使用情况
|
||||
await loadUsage()
|
||||
}
|
||||
}
|
||||
|
||||
/** 数字千分位格式化 */
|
||||
function formatNumber(value: number | null | undefined) {
|
||||
return value == null ? '0' : Number(value).toLocaleString()
|
||||
}
|
||||
|
||||
/** 使用概览卡片配置 */
|
||||
const overviewStats = computed(() => [
|
||||
{ key: 'calls', label: t('ai.usage.totalCalls'), value: usage.value?.summary.totalCalls ?? 0, icon: PulseOutline, color: '#2080f0' },
|
||||
{ key: 'success', label: t('ai.usage.successCalls'), value: usage.value?.summary.successCalls ?? 0, icon: CheckmarkCircleOutline, color: '#18a058' },
|
||||
{ key: 'failed', label: t('ai.usage.failedCalls'), value: usage.value?.summary.failedCalls ?? 0, icon: CloseCircleOutline, color: '#d03050' },
|
||||
{ key: 'total', label: t('ai.usage.totalTokens'), value: usage.value?.summary.totalTokens ?? 0, icon: StatsChartOutline, color: '#7c3aed' },
|
||||
])
|
||||
|
||||
/** Token 明细卡片配置 */
|
||||
const tokenStats = computed(() => [
|
||||
{ key: 'prompt', label: t('ai.usage.promptTokens'), value: usage.value?.summary.promptTokens ?? 0, icon: CloudUploadOutline, color: '#2080f0' },
|
||||
{ key: 'completion', label: t('ai.usage.completionTokens'), value: usage.value?.summary.completionTokens ?? 0, icon: CloudDownloadOutline, color: '#f0a020' },
|
||||
{ key: 'cached', label: t('ai.usage.cachedTokens'), value: usage.value?.summary.cachedTokens ?? 0, icon: FlashOutline, color: '#18a058' },
|
||||
])
|
||||
|
||||
/** 使用明细表格列定义(随语言切换响应式更新) */
|
||||
const usageColumns = computed<DataTableColumns<any>>(() => [
|
||||
{
|
||||
title: t('ai.usage.column.time'), key: 'createdAt', width: 160,
|
||||
render: (row) => {
|
||||
if (!row.createdAt) return '-'
|
||||
const [date, time] = String(row.createdAt).replace('T', ' ').split(' ')
|
||||
return h('div', { class: 'cell-time' }, [
|
||||
h('div', null, date),
|
||||
h('div', { class: 'cell-time-sub' }, (time || '').substring(0, 8)),
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('ai.usage.column.type'), key: 'type', width: 120,
|
||||
render: (row) => h(NTag, { type: row.type === 'SUGGEST' ? 'info' : 'default', size: 'small', bordered: false },
|
||||
{ default: () => t(`ai.usage.type.${String(row.type).toLowerCase()}`) }),
|
||||
},
|
||||
{
|
||||
title: t('ai.usage.column.count'), key: 'transactionCount', width: 100, align: 'right', titleAlign: 'right',
|
||||
render: (row) => formatNumber(row.transactionCount),
|
||||
},
|
||||
{
|
||||
title: t('ai.usage.column.promptTokens'), key: 'promptTokens', width: 100, align: 'right', titleAlign: 'right',
|
||||
render: (row) => h('span', { class: 'cell-num' }, formatNumber(row.promptTokens)),
|
||||
},
|
||||
{
|
||||
title: t('ai.usage.column.completionTokens'), key: 'completionTokens', width: 100, align: 'right', titleAlign: 'right',
|
||||
render: (row) => h('span', { class: 'cell-num' }, formatNumber(row.completionTokens)),
|
||||
},
|
||||
{
|
||||
title: t('ai.usage.column.totalTokens'), key: 'totalTokens', width: 100, align: 'right', titleAlign: 'right',
|
||||
render: (row) => h('span', { class: 'cell-num cell-total' }, formatNumber(row.totalTokens)),
|
||||
},
|
||||
{
|
||||
title: t('ai.usage.column.duration'), key: 'durationMs', width: 100, align: 'right', titleAlign: 'right',
|
||||
render: (row) => (row.durationMs != null ? `${formatNumber(row.durationMs)} ms` : '-'),
|
||||
},
|
||||
{
|
||||
title: t('ai.usage.column.status'), key: 'success', width: 110, align: 'center', titleAlign: 'center',
|
||||
render: (row) => (row.success
|
||||
? h(NTag, { type: 'success', size: 'small', bordered: false }, { default: () => t('ai.usage.success') })
|
||||
: h('span', { title: row.errorMessage || '' },
|
||||
h(NTag, { type: 'error', size: 'small', bordered: false }, { default: () => t('ai.usage.failed') }))),
|
||||
},
|
||||
])
|
||||
|
||||
// 页面挂载时加载配置与使用情况
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
loadUsage()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2 style="margin: 0 0 16px;">{{ t('ai.title') }}</h2>
|
||||
|
||||
<!-- 模型配置 -->
|
||||
<NCard :bordered="false" class="config-card">
|
||||
<template #header>
|
||||
<NSpace align="center" :size="8">
|
||||
<NIcon :component="SettingsOutline" />
|
||||
<span>{{ t('ai.configTitle') }}</span>
|
||||
</NSpace>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<NTag :type="form.enabled ? 'success' : 'default'" size="small" :bordered="false">
|
||||
{{ form.enabled ? t('ai.enabledOn') : t('ai.enabledOff') }}
|
||||
</NTag>
|
||||
</template>
|
||||
|
||||
<NAlert type="info" :show-icon="true" style="margin-bottom: 20px;">
|
||||
{{ t('ai.intro') }}
|
||||
</NAlert>
|
||||
|
||||
<NForm label-placement="left" label-width="130">
|
||||
<NFormItem :label="t('ai.enabled')">
|
||||
<div class="switch-row">
|
||||
<NSwitch v-model:value="form.enabled" />
|
||||
<NText depth="3" style="font-size: 12px;">{{ t('ai.enabledHint') }}</NText>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('ai.apiKey')">
|
||||
<NInput
|
||||
v-model:value="form.apiKey"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="hasApiKey ? t('ai.apiKeyKeep') : t('ai.apiKeyPlaceholder')"
|
||||
>
|
||||
<template #prefix>
|
||||
<NIcon :component="KeyOutline" />
|
||||
</template>
|
||||
</NInput>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('ai.baseUrl')">
|
||||
<NInput v-model:value="form.baseUrl" placeholder="https://api.deepseek.com">
|
||||
<template #prefix>
|
||||
<NIcon :component="LinkOutline" />
|
||||
</template>
|
||||
</NInput>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('ai.model')">
|
||||
<NInput v-model:value="form.model" placeholder="deepseek-chat">
|
||||
<template #prefix>
|
||||
<NIcon :component="CubeOutline" />
|
||||
</template>
|
||||
</NInput>
|
||||
</NFormItem>
|
||||
<NFormItem>
|
||||
<NSpace>
|
||||
<NButton type="primary" :loading="saving" @click="handleSave">
|
||||
<template #icon>
|
||||
<NIcon :component="SaveOutline" />
|
||||
</template>
|
||||
{{ t('ai.save') }}
|
||||
</NButton>
|
||||
<NButton :loading="testing" :disabled="!form.enabled" @click="handleTest">
|
||||
<template #icon>
|
||||
<NIcon :component="FlashOutline" />
|
||||
</template>
|
||||
{{ t('ai.test') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
</NCard>
|
||||
|
||||
<!-- 使用情况:累计汇总 + 最近调用明细 -->
|
||||
<NCard :bordered="false" class="usage-card">
|
||||
<template #header>
|
||||
<NSpace justify="space-between" align="center" style="width: 100%;">
|
||||
<NSpace align="center" :size="8">
|
||||
<NIcon :component="StatsChartOutline" />
|
||||
<span>{{ t('ai.usage.title') }}</span>
|
||||
</NSpace>
|
||||
<NButton size="small" :loading="usageLoading" @click="loadUsage">
|
||||
<template #icon>
|
||||
<NIcon :component="TimeOutline" />
|
||||
</template>
|
||||
{{ t('ai.usage.refresh') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<!-- 调用概览 -->
|
||||
<NGrid cols="2 m:4" :x-gap="12" :y-gap="12">
|
||||
<NGridItem v-for="item in overviewStats" :key="item.key">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" :style="{ background: item.color + '1a', color: item.color }">
|
||||
<NIcon size="20"><component :is="item.icon" /></NIcon>
|
||||
</div>
|
||||
<NStatistic
|
||||
:label="item.label"
|
||||
:value="formatNumber(item.value)"
|
||||
:value-style="{ color: item.color, fontSize: '20px', fontWeight: 600 }"
|
||||
/>
|
||||
</div>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
|
||||
<!-- Token 明细 -->
|
||||
<NGrid cols="1 s:3" :x-gap="12" :y-gap="12" style="margin-top: 12px;">
|
||||
<NGridItem v-for="item in tokenStats" :key="item.key">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" :style="{ background: item.color + '1a', color: item.color }">
|
||||
<NIcon size="20"><component :is="item.icon" /></NIcon>
|
||||
</div>
|
||||
<NStatistic
|
||||
:label="item.label"
|
||||
:value="formatNumber(item.value)"
|
||||
:value-style="{ color: item.color, fontSize: '20px', fontWeight: 600 }"
|
||||
/>
|
||||
</div>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
|
||||
<!-- 最近调用明细 -->
|
||||
<div class="table-title">{{ t('ai.usage.recentTitle') }}</div>
|
||||
<NDataTable
|
||||
:columns="usageColumns"
|
||||
:data="usage?.recent ?? []"
|
||||
:loading="usageLoading"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
:striped="true"
|
||||
class="usage-table"
|
||||
>
|
||||
<template #empty>
|
||||
<NEmpty :description="t('ai.usage.empty')" />
|
||||
</template>
|
||||
</NDataTable>
|
||||
</NCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.config-card {
|
||||
max-width: 720px;
|
||||
}
|
||||
.usage-card {
|
||||
max-width: 960px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.table-title {
|
||||
margin: 22px 0 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.stat-card:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.stat-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cell-time {
|
||||
line-height: 1.35;
|
||||
}
|
||||
.cell-time-sub {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cell-num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.cell-total {
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 表头与行视觉优化 */
|
||||
.usage-table :deep(.n-data-table-th) {
|
||||
background-color: rgba(0, 0, 0, 0.025);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
.usage-table :deep(.n-data-table-td) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -104,8 +104,8 @@ function resetFilters() {
|
||||
}
|
||||
|
||||
// 默认分类
|
||||
const expenseCategories = ref<{ label: string; value: number }[]>([])
|
||||
const incomeCategories = ref<{ label: string; value: number }[]>([])
|
||||
const expenseCategories = ref<{ label: string; value: number; icon: string }[]>([])
|
||||
const incomeCategories = ref<{ label: string; value: number; icon: string }[]>([])
|
||||
const expenseCategoryId = ref<number | null>(null)
|
||||
const incomeCategoryId = ref<number | null>(null)
|
||||
|
||||
@@ -225,13 +225,21 @@ const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const [expenseRes, incomeRes]: any = await Promise.all([getCategoryList('EXPENSE'), getCategoryList('INCOME')])
|
||||
expenseCategories.value = (expenseRes.data || []).map((c: any) => ({ label: c.name, value: c.id }))
|
||||
incomeCategories.value = (incomeRes.data || []).map((c: any) => ({ label: c.name, value: c.id }))
|
||||
expenseCategories.value = (expenseRes.data || []).map((c: any) => ({ label: c.name, value: c.id, icon: c.icon || '' }))
|
||||
incomeCategories.value = (incomeRes.data || []).map((c: any) => ({ label: c.name, value: c.id, icon: c.icon || '' }))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('import.loadCategoriesFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 分类下拉项渲染:图标 + 名称 */
|
||||
function renderCategoryLabel(option: any) {
|
||||
return h('span', null, [
|
||||
option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null,
|
||||
option.label,
|
||||
])
|
||||
}
|
||||
|
||||
/** 加载标签选项 */
|
||||
async function loadTags() {
|
||||
try {
|
||||
@@ -435,6 +443,7 @@ watch(
|
||||
<NSelect
|
||||
v-model:value="expenseCategoryId"
|
||||
:options="expenseCategories"
|
||||
:render-label="renderCategoryLabel"
|
||||
filterable
|
||||
clearable
|
||||
:placeholder="t('import.notSetCategory')"
|
||||
@@ -444,6 +453,7 @@ watch(
|
||||
<NSelect
|
||||
v-model:value="incomeCategoryId"
|
||||
:options="incomeCategories"
|
||||
:render-label="renderCategoryLabel"
|
||||
filterable
|
||||
clearable
|
||||
:placeholder="t('import.notSetCategory')"
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { h, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NButton, NSpace, NIcon } from 'naive-ui'
|
||||
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown } from 'naive-ui'
|
||||
import type { MenuOption } from 'naive-ui'
|
||||
import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, LogOutOutline } from '@vicons/ionicons5'
|
||||
import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, SparklesOutline, LogOutOutline, ChevronDownOutline } from '@vicons/ionicons5'
|
||||
import { useUserStore } from '../store/user'
|
||||
import LocaleSwitch from '../components/LocaleSwitch.vue'
|
||||
|
||||
@@ -43,6 +43,21 @@ function handleMenuUpdate(key: string) {
|
||||
router.push(`/${key}`)
|
||||
}
|
||||
|
||||
// 顶部用户下拉菜单配置项(随语言切换响应式更新)
|
||||
const userMenuOptions = computed(() => [
|
||||
{ label: t('layout.aiSettings'), key: 'ai-settings', icon: renderIcon(SparklesOutline) },
|
||||
{ label: t('layout.logout'), key: 'logout', icon: renderIcon(LogOutOutline) },
|
||||
])
|
||||
|
||||
/** 用户下拉菜单选择事件:跳转 AI 设置或退出登录 */
|
||||
function handleUserMenuSelect(key: string) {
|
||||
if (key === 'ai-settings') {
|
||||
router.push('/ai-settings')
|
||||
} else if (key === 'logout') {
|
||||
handleLogout()
|
||||
}
|
||||
}
|
||||
|
||||
/** 退出登录:清空用户状态并跳转到登录页 */
|
||||
function handleLogout() {
|
||||
userStore.logout()
|
||||
@@ -70,17 +85,16 @@ function handleLogout() {
|
||||
/>
|
||||
</NLayoutSider>
|
||||
<NLayout>
|
||||
<!-- 顶部栏:显示用户名和退出按钮 -->
|
||||
<NLayoutHeader bordered style="padding: 12px 24px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-size: 16px; font-weight: 500;">{{ userStore.nickname || t('layout.user') }}</span>
|
||||
<NSpace align="center">
|
||||
<!-- 顶部栏:用户下拉菜单与语言切换 -->
|
||||
<NLayoutHeader bordered style="padding: 12px 24px; display: flex; justify-content: flex-end; align-items: center;">
|
||||
<NSpace align="center" :size="16">
|
||||
<LocaleSwitch />
|
||||
<NButton quaternary size="small" @click="handleLogout">
|
||||
<template #icon>
|
||||
<NIcon :component="LogOutOutline" />
|
||||
</template>
|
||||
{{ t('layout.logout') }}
|
||||
</NButton>
|
||||
<NDropdown :options="userMenuOptions" @select="handleUserMenuSelect">
|
||||
<span style="font-size: 16px; font-weight: 500; cursor: pointer; display: inline-flex; align-items: center;">
|
||||
{{ userStore.nickname || t('layout.user') }}
|
||||
<NIcon :component="ChevronDownOutline" size="16" style="margin-left: 4px;" />
|
||||
</span>
|
||||
</NDropdown>
|
||||
</NSpace>
|
||||
</NLayoutHeader>
|
||||
<!-- 子路由内容渲染区域 -->
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
// 收支记录管理组件:展示交易记录列表(分页),支持新增、编辑、删除、多条件筛选
|
||||
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 } from 'naive-ui'
|
||||
import { EyeOutline, EyeOffOutline } from '@vicons/ionicons5'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDescriptions, NDescriptionsItem, NDivider, NDropdown } from 'naive-ui'
|
||||
import { EyeOutline, EyeOffOutline, CloseCircleOutline, SparklesOutline, BulbOutline } from '@vicons/ionicons5'
|
||||
import type { DataTableColumns, AutoCompleteOption } from 'naive-ui'
|
||||
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags } from '../api/transaction'
|
||||
import { getAiConfig, suggestByAi, applyAiSuggestions, queryByAi, parseRecordByAi } from '../api/ai'
|
||||
import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai'
|
||||
import { getCategoryList } from '../api/category'
|
||||
import { getTagList } from '../api/tag'
|
||||
import BillImportModal from './BillImportModal.vue'
|
||||
@@ -78,6 +80,24 @@ const loading = ref(false) // 列表加载状态
|
||||
const showModal = ref(false) // 新增/编辑弹窗显示状态
|
||||
const showImportModal = ref(false) // 账单导入弹窗显示状态
|
||||
const showBatchModal = ref(false) // 批量设置分类弹窗显示状态
|
||||
const showAiModal = ref(false) // AI 建议弹窗显示状态
|
||||
const aiEnabled = ref(false) // AI 建议是否启用
|
||||
const aiLoading = ref(false) // AI 建议生成状态
|
||||
const aiApplying = ref(false) // AI 建议应用状态
|
||||
const aiSuggestions = ref<AiSuggestion[]>([]) // AI 建议列表
|
||||
const aiQueryText = ref('') // AI 自然语言查询输入
|
||||
const aiQuerying = ref(false) // AI 查询解析状态
|
||||
const showAiQueryModal = ref(false) // AI 查询结果弹窗显示状态
|
||||
const aiQueryResult = ref<AiQueryResult | null>(null) // AI 查询解析结果
|
||||
// AI 查询历史存储键与上限
|
||||
const AI_QUERY_HISTORY_KEY = 'finance.transaction.aiQueryHistory'
|
||||
const AI_QUERY_HISTORY_MAX = 10
|
||||
// 下拉中“清空历史”选项的哨兵值
|
||||
const CLEAR_AI_QUERY_HISTORY = '__clear_ai_query_history__'
|
||||
const aiQueryHistory = ref<string[]>([]) // AI 查询历史(最近使用,最新在前)
|
||||
const showAiRecordModal = ref(false) // AI 记账弹窗显示状态
|
||||
const aiRecordText = ref('') // AI 记账输入
|
||||
const aiRecording = ref(false) // AI 记账解析状态
|
||||
const showRawModal = ref(false) // 原始账单数据弹窗显示状态
|
||||
const rawDataText = ref('') // 格式化后的原始账单数据文本
|
||||
const batchExpenseCategoryId = ref<number | null>(null) // 批量设置的支出分类
|
||||
@@ -131,6 +151,12 @@ const filterPaymentMethod = ref<string[]>([]) // 支付方式筛选(多选)
|
||||
const filterDateRange = ref<[number, number] | null>(null) // 日期范围筛选(起止时间戳)
|
||||
const filterIncludeInStats = ref<string | null>(null) // 计入统计筛选:null-全部,'true'-计入,'false'-不计入
|
||||
const filterRemark = ref('') // 备注关键字搜索
|
||||
// 备注关键词历史存储键与上限
|
||||
const REMARK_HISTORY_KEY = 'finance.transaction.remarkHistory'
|
||||
const REMARK_HISTORY_MAX = 10
|
||||
// 下拉中“清空历史”选项的哨兵值
|
||||
const CLEAR_REMARK_HISTORY = '__clear_remark_history__'
|
||||
const remarkHistory = ref<string[]>([]) // 备注关键词历史(最近输入,最新在前)
|
||||
const filterMinAmount = ref<number | null>(null) // 最小金额筛选
|
||||
const filterMaxAmount = ref<number | null>(null) // 最大金额筛选
|
||||
const filterTag = ref<number[]>([]) // 标签筛选(多选)
|
||||
@@ -232,6 +258,103 @@ watch(
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
/** 从 localStorage 读取备注关键词历史,忽略损坏数据 */
|
||||
function loadRemarkHistory() {
|
||||
try {
|
||||
const raw = localStorage.getItem(REMARK_HISTORY_KEY)
|
||||
const parsed = raw ? JSON.parse(raw) : []
|
||||
remarkHistory.value = Array.isArray(parsed)
|
||||
? parsed.filter((item) => typeof item === 'string' && item.trim()).slice(0, REMARK_HISTORY_MAX)
|
||||
: []
|
||||
} catch (e) {
|
||||
remarkHistory.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/** 记录一次备注关键词:去重(忽略大小写)、最新在前、上限 10 条 */
|
||||
function recordRemarkKeyword() {
|
||||
const value = filterRemark.value.trim()
|
||||
if (!value) return
|
||||
const rest = remarkHistory.value.filter((item) => item.toLowerCase() !== value.toLowerCase())
|
||||
remarkHistory.value = [value, ...rest].slice(0, REMARK_HISTORY_MAX)
|
||||
try {
|
||||
localStorage.setItem(REMARK_HISTORY_KEY, JSON.stringify(remarkHistory.value))
|
||||
} catch (e) {
|
||||
// 忽略写入失败
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空备注关键词历史 */
|
||||
function clearRemarkHistory() {
|
||||
remarkHistory.value = []
|
||||
try {
|
||||
localStorage.removeItem(REMARK_HISTORY_KEY)
|
||||
} catch (e) {
|
||||
// 忽略删除失败
|
||||
}
|
||||
}
|
||||
|
||||
// 备注历史下拉选项:按当前输入过滤历史项(忽略大小写),历史非空时末尾追加“清空历史”
|
||||
const remarkOptions = computed<AutoCompleteOption[]>(() => {
|
||||
const keyword = filterRemark.value.trim().toLowerCase()
|
||||
const options: AutoCompleteOption[] = remarkHistory.value
|
||||
.filter((item) => item.toLowerCase().includes(keyword))
|
||||
.map((item) => ({ label: item, value: item }))
|
||||
if (remarkHistory.value.length > 0) {
|
||||
options.push({ label: CLEAR_REMARK_HISTORY, value: CLEAR_REMARK_HISTORY })
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
/** 输入同步:清空项执行清空,其余写入输入框(NAutoComplete 回调传入的是选项 label) */
|
||||
function handleRemarkInput(value: string | null) {
|
||||
if (value === CLEAR_REMARK_HISTORY) {
|
||||
clearRemarkHistory()
|
||||
return
|
||||
}
|
||||
filterRemark.value = value || ''
|
||||
}
|
||||
|
||||
/** 选中历史项:填入并立即搜索 */
|
||||
function handleRemarkSelect(value: string) {
|
||||
if (value === CLEAR_REMARK_HISTORY) {
|
||||
clearRemarkHistory()
|
||||
return
|
||||
}
|
||||
filterRemark.value = value
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
/** 失焦时记录当前备注关键词 */
|
||||
function handleRemarkBlur() {
|
||||
recordRemarkKeyword()
|
||||
}
|
||||
|
||||
/** 回车时记录关键词并搜索(阻止 NAutoComplete 内部选中建议项,保持原回车搜索语义) */
|
||||
function handleRemarkKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation()
|
||||
recordRemarkKeyword()
|
||||
handleSearch()
|
||||
}
|
||||
}
|
||||
|
||||
/** 聚焦即展示历史下拉(NAutoComplete 默认仅在输入非空时展示) */
|
||||
function showRemarkMenu() {
|
||||
return true
|
||||
}
|
||||
|
||||
/** 渲染下拉选项,清空项使用弱化样式与图标 */
|
||||
function renderRemarkLabel(option: AutoCompleteOption) {
|
||||
if (String(option.value) === CLEAR_REMARK_HISTORY) {
|
||||
return h('span', { style: { display: 'flex', alignItems: 'center', gap: '4px', color: '#999' } }, [
|
||||
h(NIcon, { size: 16 }, { default: () => h(CloseCircleOutline) }),
|
||||
t('transaction.remarkHistoryClear'),
|
||||
])
|
||||
}
|
||||
return option.label as string
|
||||
}
|
||||
|
||||
/** 将时间戳格式化为 ISO 格式的日期时间字符串(YYYY-MM-DDTHH:mm:ss) */
|
||||
function formatTimestamp(ts: number, isEnd = false): string {
|
||||
const d = new Date(ts)
|
||||
@@ -593,6 +716,396 @@ async function handleToggleStats(row: TransactionItem, value: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载 AI 启用状态,用于控制批量设置中的入口显示 */
|
||||
async function loadAiConfig() {
|
||||
try {
|
||||
const res: any = await getAiConfig()
|
||||
aiEnabled.value = !!res.data.enabled
|
||||
} catch (e) {
|
||||
// 静默处理,AI 入口保持隐藏
|
||||
aiEnabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 请求 AI 建议:对当前表格展示的记录生成分类与标签建议 */
|
||||
async function handleAiSuggest() {
|
||||
if (transactions.value.length === 0) {
|
||||
message.warning(t('transaction.noRecordsToSet'))
|
||||
return
|
||||
}
|
||||
aiLoading.value = true
|
||||
try {
|
||||
const ids = transactions.value.map((t) => t.id)
|
||||
const res: any = await suggestByAi(ids)
|
||||
aiSuggestions.value = res.data || []
|
||||
if (aiSuggestions.value.length === 0) {
|
||||
message.warning(t('ai.noSuggestion'))
|
||||
return
|
||||
}
|
||||
showBatchModal.value = false
|
||||
showAiModal.value = true
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('ai.suggestFailed'))
|
||||
} finally {
|
||||
aiLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 一键应用全部 AI 建议:覆盖分类并追加标签 */
|
||||
async function handleAiApply() {
|
||||
if (aiSuggestions.value.length === 0) {
|
||||
return
|
||||
}
|
||||
aiApplying.value = true
|
||||
try {
|
||||
const payload = aiSuggestions.value.map((s) => ({
|
||||
transactionId: s.transactionId,
|
||||
categoryId: s.categoryId,
|
||||
tagIds: s.tagIds || [],
|
||||
}))
|
||||
const res: any = await applyAiSuggestions(payload)
|
||||
message.success(t('ai.applySuccess', { count: res.data }))
|
||||
showAiModal.value = false
|
||||
await fetchData()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('ai.applyFailed'))
|
||||
} finally {
|
||||
aiApplying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开 AI 记账弹窗 */
|
||||
function handleAiRecordOpen() {
|
||||
if (!aiEnabled.value) {
|
||||
message.warning(t('ai.needConfig'))
|
||||
return
|
||||
}
|
||||
aiRecordText.value = ''
|
||||
showAiRecordModal.value = true
|
||||
}
|
||||
|
||||
/** 解析自然语言记账并预填新增表单 */
|
||||
async function handleAiRecordParse() {
|
||||
const text = aiRecordText.value.trim()
|
||||
if (!text) {
|
||||
message.warning(t('ai.record.emptyInput'))
|
||||
return
|
||||
}
|
||||
aiRecording.value = true
|
||||
try {
|
||||
const res: any = await parseRecordByAi(text)
|
||||
const r: AiRecordResult = res.data
|
||||
editingId.value = null
|
||||
form.value = {
|
||||
categoryId: r.categoryId ?? null,
|
||||
type: r.type || 'EXPENSE',
|
||||
amount: r.amount ?? null,
|
||||
paymentMethod: r.paymentMethod || '',
|
||||
remark: r.remark || '',
|
||||
transactionTime: r.transactionTime ? new Date(r.transactionTime).getTime() : Date.now(),
|
||||
includeInStats: r.includeInStats ?? true,
|
||||
tagIds: r.tagIds || [],
|
||||
}
|
||||
showAiRecordModal.value = false
|
||||
showModal.value = true
|
||||
if (r.summary) {
|
||||
message.info(r.summary)
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('ai.record.failed'))
|
||||
} finally {
|
||||
aiRecording.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 请求 AI 解析自然语言查询 */
|
||||
async function handleAiQuery() {
|
||||
if (!aiEnabled.value) {
|
||||
message.warning(t('ai.needConfig'))
|
||||
return
|
||||
}
|
||||
const text = aiQueryText.value.trim()
|
||||
if (!text) {
|
||||
message.warning(t('ai.query.emptyInput'))
|
||||
return
|
||||
}
|
||||
recordAiQueryHistory()
|
||||
aiQuerying.value = true
|
||||
try {
|
||||
const res: any = await queryByAi(text)
|
||||
aiQueryResult.value = res.data
|
||||
showAiQueryModal.value = true
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('ai.query.failed'))
|
||||
} finally {
|
||||
aiQuerying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 将 AI 解析结果应用到筛选条件并查询 */
|
||||
function applyAiQuery() {
|
||||
const r = aiQueryResult.value
|
||||
if (!r) return
|
||||
filterType.value = r.types || []
|
||||
const categoryIds = [...(r.categoryIds || [])]
|
||||
if (r.uncategorized) categoryIds.push(0)
|
||||
filterCategory.value = categoryIds
|
||||
filterPaymentMethod.value = r.paymentMethods || []
|
||||
filterDateRange.value = r.startTime && r.endTime
|
||||
? [new Date(r.startTime).getTime(), new Date(r.endTime).getTime()]
|
||||
: null
|
||||
filterIncludeInStats.value = r.includeInStats == null ? null : (r.includeInStats ? 'true' : 'false')
|
||||
filterMinAmount.value = r.minAmount ?? null
|
||||
filterMaxAmount.value = r.maxAmount ?? null
|
||||
filterRemark.value = r.remark || ''
|
||||
filterTag.value = r.tagIds || []
|
||||
showAiQueryModal.value = false
|
||||
handleSearch()
|
||||
message.success(t('ai.query.applied'))
|
||||
}
|
||||
|
||||
/** 解析结果是否为空(无任何可用筛选条件) */
|
||||
const aiQueryEmpty = computed(() => {
|
||||
const r = aiQueryResult.value
|
||||
if (!r) return true
|
||||
return (r.types?.length ?? 0) === 0
|
||||
&& (r.categoryIds?.length ?? 0) === 0
|
||||
&& !r.uncategorized
|
||||
&& (r.tagIds?.length ?? 0) === 0
|
||||
&& (r.paymentMethods?.length ?? 0) === 0
|
||||
&& !r.startTime
|
||||
&& r.minAmount == null
|
||||
&& r.maxAmount == null
|
||||
&& !r.remark
|
||||
&& r.includeInStats == null
|
||||
})
|
||||
|
||||
/** 格式化解析结果中的时间用于展示 */
|
||||
function formatAiDate(value: string | null | undefined) {
|
||||
return value ? value.replace('T', ' ').substring(0, 16) : ''
|
||||
}
|
||||
|
||||
/** AI 查询示例(从 i18n 拆分为数组) */
|
||||
const queryExamples = computed(() => t('ai.query.examples').split('|'))
|
||||
|
||||
/** AI 查询示例下拉选项 */
|
||||
const exampleOptions = computed(() => queryExamples.value.map((example) => ({ label: example, key: example })))
|
||||
|
||||
/** 选择示例后填入输入框 */
|
||||
function handleExampleSelect(key: string) {
|
||||
aiQueryText.value = key
|
||||
}
|
||||
|
||||
/** 从 localStorage 读取 AI 查询历史,忽略损坏数据 */
|
||||
function loadAiQueryHistory() {
|
||||
try {
|
||||
const raw = localStorage.getItem(AI_QUERY_HISTORY_KEY)
|
||||
const parsed = raw ? JSON.parse(raw) : []
|
||||
aiQueryHistory.value = Array.isArray(parsed)
|
||||
? parsed.filter((item) => typeof item === 'string' && item.trim()).slice(0, AI_QUERY_HISTORY_MAX)
|
||||
: []
|
||||
} catch (e) {
|
||||
aiQueryHistory.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/** 记录一次 AI 查询文本:去重(忽略大小写)、最新在前、上限 10 条 */
|
||||
function recordAiQueryHistory() {
|
||||
const value = aiQueryText.value.trim()
|
||||
if (!value) return
|
||||
const rest = aiQueryHistory.value.filter((item) => item.toLowerCase() !== value.toLowerCase())
|
||||
aiQueryHistory.value = [value, ...rest].slice(0, AI_QUERY_HISTORY_MAX)
|
||||
try {
|
||||
localStorage.setItem(AI_QUERY_HISTORY_KEY, JSON.stringify(aiQueryHistory.value))
|
||||
} catch (e) {
|
||||
// 忽略写入失败
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空 AI 查询历史 */
|
||||
function clearAiQueryHistory() {
|
||||
aiQueryHistory.value = []
|
||||
try {
|
||||
localStorage.removeItem(AI_QUERY_HISTORY_KEY)
|
||||
} catch (e) {
|
||||
// 忽略删除失败
|
||||
}
|
||||
}
|
||||
|
||||
// AI 查询历史下拉选项:按当前输入过滤历史项(忽略大小写),历史非空时末尾追加“清空历史”
|
||||
const aiQueryOptions = computed<AutoCompleteOption[]>(() => {
|
||||
const keyword = aiQueryText.value.trim().toLowerCase()
|
||||
const options: AutoCompleteOption[] = aiQueryHistory.value
|
||||
.filter((item) => item.toLowerCase().includes(keyword))
|
||||
.map((item) => ({ label: item, value: item }))
|
||||
if (aiQueryHistory.value.length > 0) {
|
||||
options.push({ label: CLEAR_AI_QUERY_HISTORY, value: CLEAR_AI_QUERY_HISTORY })
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
/** 输入同步:清空项执行清空,其余写入输入框(NAutoComplete 回调传入的是选项 label) */
|
||||
function handleAiQueryInput(value: string | null) {
|
||||
if (value === CLEAR_AI_QUERY_HISTORY) {
|
||||
clearAiQueryHistory()
|
||||
return
|
||||
}
|
||||
aiQueryText.value = value || ''
|
||||
}
|
||||
|
||||
/** 选中历史项:填入并立即发起查询 */
|
||||
function handleAiQuerySelect(value: string) {
|
||||
if (value === CLEAR_AI_QUERY_HISTORY) {
|
||||
clearAiQueryHistory()
|
||||
return
|
||||
}
|
||||
aiQueryText.value = value
|
||||
handleAiQuery()
|
||||
}
|
||||
|
||||
/** 回车时发起查询(阻止 NAutoComplete 内部选中建议项) */
|
||||
function handleAiQueryKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation()
|
||||
handleAiQuery()
|
||||
}
|
||||
}
|
||||
|
||||
/** 聚焦即展示历史下拉 */
|
||||
function showAiQueryMenu() {
|
||||
return true
|
||||
}
|
||||
|
||||
/** 渲染下拉选项,清空项使用弱化样式与图标 */
|
||||
function renderAiQueryLabel(option: AutoCompleteOption) {
|
||||
if (String(option.value) === CLEAR_AI_QUERY_HISTORY) {
|
||||
return h('span', { style: { display: 'flex', alignItems: 'center', gap: '4px', color: '#999' } }, [
|
||||
h(NIcon, { size: 16 }, { default: () => h(CloseCircleOutline) }),
|
||||
t('ai.query.clearHistory'),
|
||||
])
|
||||
}
|
||||
return option.label as string
|
||||
}
|
||||
|
||||
/** 将时间戳格式化为 yyyy-MM-dd */
|
||||
function formatDateLabel(ts: number) {
|
||||
const d = new Date(ts)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
/** 已激活的筛选条件标签(可单独移除) */
|
||||
const activeFilterTags = computed(() => {
|
||||
const tags: { key: string; label: string; clear: () => void }[] = []
|
||||
|
||||
filterType.value.forEach((v) => {
|
||||
tags.push({
|
||||
key: `type:${v}`,
|
||||
label: v === 'INCOME' ? t('common.types.income') : t('common.types.expense'),
|
||||
clear: () => { filterType.value = filterType.value.filter((x) => x !== v) },
|
||||
})
|
||||
})
|
||||
|
||||
filterCategory.value.forEach((id) => {
|
||||
const name = id === 0
|
||||
? t('transaction.uncategorized')
|
||||
: categories.value.find((c) => c.value === id)?.label || String(id)
|
||||
tags.push({
|
||||
key: `category:${id}`,
|
||||
label: name,
|
||||
clear: () => { filterCategory.value = filterCategory.value.filter((x) => x !== id) },
|
||||
})
|
||||
})
|
||||
|
||||
if (filterDateRange.value) {
|
||||
tags.push({
|
||||
key: 'date',
|
||||
label: `${formatDateLabel(filterDateRange.value[0])} ~ ${formatDateLabel(filterDateRange.value[1])}`,
|
||||
clear: () => { filterDateRange.value = null },
|
||||
})
|
||||
}
|
||||
|
||||
if (filterRemark.value.trim()) {
|
||||
tags.push({
|
||||
key: 'remark',
|
||||
label: `${t('transaction.column.remark')}: ${filterRemark.value.trim()}`,
|
||||
clear: () => { filterRemark.value = '' },
|
||||
})
|
||||
}
|
||||
|
||||
filterPaymentMethod.value.forEach((v) => {
|
||||
tags.push({
|
||||
key: `payment:${v}`,
|
||||
label: getPaymentLabel(v),
|
||||
clear: () => { filterPaymentMethod.value = filterPaymentMethod.value.filter((x) => x !== v) },
|
||||
})
|
||||
})
|
||||
|
||||
filterTag.value.forEach((id) => {
|
||||
const name = allTags.value.find((tag) => tag.id === id)?.name || String(id)
|
||||
tags.push({
|
||||
key: `tag:${id}`,
|
||||
label: name,
|
||||
clear: () => { filterTag.value = filterTag.value.filter((x) => x !== id) },
|
||||
})
|
||||
})
|
||||
|
||||
if (filterIncludeInStats.value !== null) {
|
||||
tags.push({
|
||||
key: 'includeInStats',
|
||||
label: filterIncludeInStats.value === 'true' ? t('transaction.includeFilter.yes') : t('transaction.includeFilter.no'),
|
||||
clear: () => { filterIncludeInStats.value = null },
|
||||
})
|
||||
}
|
||||
|
||||
if (filterMinAmount.value != null || filterMaxAmount.value != null) {
|
||||
tags.push({
|
||||
key: 'amount',
|
||||
label: `${t('transaction.amountRange')}: ${filterMinAmount.value ?? 0} ~ ${filterMaxAmount.value ?? '∞'}`,
|
||||
clear: () => { filterMinAmount.value = null; filterMaxAmount.value = null },
|
||||
})
|
||||
}
|
||||
|
||||
return tags
|
||||
})
|
||||
|
||||
/** 移除单个筛选条件后自动重查 */
|
||||
function applyTagClear(tag: { clear: () => void }) {
|
||||
tag.clear()
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
/** AI 建议行:合并交易记录信息用于展示 */
|
||||
const aiRows = computed(() =>
|
||||
aiSuggestions.value.map((s) => ({
|
||||
...s,
|
||||
transaction: transactions.value.find((t) => t.id === s.transactionId) || null,
|
||||
})),
|
||||
)
|
||||
|
||||
/** AI 建议表格列定义(随语言切换响应式更新) */
|
||||
const aiColumns = computed<DataTableColumns<any>>(() => [
|
||||
{
|
||||
title: t('ai.column.record'), key: 'record', minWidth: 200,
|
||||
render: (row) => {
|
||||
const tx = row.transaction
|
||||
if (!tx) return `#${row.transactionId}`
|
||||
const time = tx.transactionTime?.replace('T', ' ').substring(0, 16) || '-'
|
||||
const sign = tx.type === 'INCOME' ? '+' : '-'
|
||||
return h('div', null, [
|
||||
h('div', null, `${time} ${sign}${tx.amount.toFixed(2)}`),
|
||||
h('div', { style: { color: '#999', fontSize: '12px' } }, tx.remark || '-'),
|
||||
])
|
||||
},
|
||||
},
|
||||
{ title: t('ai.column.category'), key: 'category', width: 130, render: (row) => row.categoryName || '-' },
|
||||
{
|
||||
title: t('ai.column.tags'), key: 'tags', minWidth: 150,
|
||||
render: (row) => (row.tagNames && row.tagNames.length > 0 ? row.tagNames.join('、') : '-'),
|
||||
},
|
||||
{ title: t('ai.column.reason'), key: 'reason', minWidth: 180, ellipsis: { tooltip: true } },
|
||||
])
|
||||
|
||||
/** 打开批量设置分类弹窗 */
|
||||
function handleBatchOpen() {
|
||||
if (transactions.value.length === 0) {
|
||||
@@ -678,6 +1191,9 @@ async function handleExport() {
|
||||
// 组件挂载时加载分类和交易记录
|
||||
onMounted(() => {
|
||||
loadFilterPrefs()
|
||||
loadRemarkHistory()
|
||||
loadAiQueryHistory()
|
||||
loadAiConfig()
|
||||
fetchCategories()
|
||||
fetchTags()
|
||||
fetchData()
|
||||
@@ -686,22 +1202,63 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 顶部:标题和新增按钮 -->
|
||||
<NSpace justify="space-between" style="margin-bottom: 16px;">
|
||||
<!-- 顶部:标题和操作按钮 -->
|
||||
<NSpace justify="space-between" align="center" style="margin-bottom: 16px;" :wrap="true">
|
||||
<h2 style="margin: 0;">{{ t('transaction.title') }}</h2>
|
||||
<NSpace align="center">
|
||||
<NSpace align="center" :wrap="true">
|
||||
<span style="color: #999; font-size: 13px;">{{ t('transaction.sortLabel') }}</span>
|
||||
<NSelect v-model:value="sortValue" :options="sortOptions" style="width: 170px;" />
|
||||
<NDivider vertical style="margin: 0 4px;" />
|
||||
<NButton @click="handleBatchOpen">{{ t('transaction.batchSet') }}</NButton>
|
||||
<NButton @click="showImportModal = true">{{ t('transaction.importBill') }}</NButton>
|
||||
<NButton @click="handleExport">{{ t('transaction.exportExcel') }}</NButton>
|
||||
<NButton v-if="aiEnabled" @click="handleAiRecordOpen">
|
||||
<template #icon>
|
||||
<NIcon :component="SparklesOutline" />
|
||||
</template>
|
||||
{{ t('ai.record.button') }}
|
||||
</NButton>
|
||||
<NButton type="primary" @click="handleAdd">{{ t('transaction.addRecord') }}</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<!-- 筛选条件区域:常用条件常驻,其余收进「更多筛选」 -->
|
||||
<!-- 查询与筛选 -->
|
||||
<NCard :bordered="false" style="margin-bottom: 16px;">
|
||||
<NSpace align="center" :wrap="true" :size="[12, 12]">
|
||||
<!-- AI 智能查询(仅启用时显示) -->
|
||||
<template v-if="aiEnabled">
|
||||
<NSpace align="center" :wrap="true" :size="[12, 8]">
|
||||
<NAutoComplete
|
||||
:value="aiQueryText"
|
||||
:options="aiQueryOptions"
|
||||
:get-show="showAiQueryMenu"
|
||||
:render-label="renderAiQueryLabel"
|
||||
:input-props="{ onKeydown: handleAiQueryKeydown }"
|
||||
clearable
|
||||
:placeholder="t('ai.query.placeholder')"
|
||||
style="width: 360px;"
|
||||
@update:value="handleAiQueryInput"
|
||||
@select="handleAiQuerySelect"
|
||||
>
|
||||
<template #prefix>
|
||||
<NIcon :component="SparklesOutline" />
|
||||
</template>
|
||||
</NAutoComplete>
|
||||
<NButton type="primary" :loading="aiQuerying" @click="handleAiQuery">{{ t('ai.query.button') }}</NButton>
|
||||
<NDropdown trigger="click" :options="exampleOptions" @select="handleExampleSelect">
|
||||
<NButton text type="primary" size="small">
|
||||
<template #icon>
|
||||
<NIcon :component="BulbOutline" />
|
||||
</template>
|
||||
{{ t('transaction.exampleLabel') }}
|
||||
</NButton>
|
||||
</NDropdown>
|
||||
</NSpace>
|
||||
|
||||
<NDivider style="margin: 12px 0;" />
|
||||
</template>
|
||||
|
||||
<!-- 常用筛选条件 -->
|
||||
<NSpace align="center" :wrap="true" :size="[12, 8]">
|
||||
<NSelect
|
||||
v-model:value="filterType"
|
||||
:options="typeOptions"
|
||||
@@ -729,12 +1286,18 @@ onMounted(() => {
|
||||
clearable
|
||||
style="width: 260px;"
|
||||
/>
|
||||
<NInput
|
||||
v-model:value="filterRemark"
|
||||
<NAutoComplete
|
||||
:value="filterRemark"
|
||||
:options="remarkOptions"
|
||||
:get-show="showRemarkMenu"
|
||||
:render-label="renderRemarkLabel"
|
||||
:input-props="{ onKeydown: handleRemarkKeydown }"
|
||||
clearable
|
||||
:placeholder="t('transaction.column.remark')"
|
||||
style="width: 180px;"
|
||||
@keyup.enter="handleSearch"
|
||||
@update:value="handleRemarkInput"
|
||||
@select="handleRemarkSelect"
|
||||
@blur="handleRemarkBlur"
|
||||
/>
|
||||
<NPopover v-model:show="showMoreFilter" trigger="click" placement="bottom-start">
|
||||
<template #trigger>
|
||||
@@ -811,6 +1374,23 @@ onMounted(() => {
|
||||
<NButton type="primary" @click="handleSearch">{{ t('transaction.search') }}</NButton>
|
||||
<NButton @click="handleReset">{{ t('transaction.reset') }}</NButton>
|
||||
</NSpace>
|
||||
|
||||
<!-- 已激活的筛选条件 -->
|
||||
<div v-if="activeFilterTags.length > 0" class="active-filters">
|
||||
<NSpace align="center" :wrap="true" :size="[8, 8]">
|
||||
<span style="color: #999; font-size: 12px;">{{ t('transaction.activeFilters') }}</span>
|
||||
<NTag
|
||||
v-for="tag in activeFilterTags"
|
||||
:key="tag.key"
|
||||
size="small"
|
||||
closable
|
||||
@close="applyTagClear(tag)"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</NTag>
|
||||
<NButton text size="tiny" type="primary" @click="handleReset">{{ t('transaction.clearAll') }}</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
<!-- 表格信息栏(右上角) -->
|
||||
@@ -897,6 +1477,9 @@ onMounted(() => {
|
||||
{{ t('transaction.batchScopeHint', { count: transactions.length }) }}
|
||||
</span>
|
||||
</NFormItem>
|
||||
<NFormItem v-if="aiEnabled" :label="t('ai.suggestButton')">
|
||||
<NButton :loading="aiLoading" @click="handleAiSuggest">{{ t('ai.suggestButton') }}</NButton>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('transaction.expenseCategory')">
|
||||
<NSelect
|
||||
v-model:value="batchExpenseCategoryId"
|
||||
@@ -929,6 +1512,99 @@ onMounted(() => {
|
||||
<NButton type="primary" block :loading="batching" @click="handleBatchSubmit">{{ t('common.confirm') }}</NButton>
|
||||
</NForm>
|
||||
</NModal>
|
||||
|
||||
<!-- AI 建议弹窗(一键应用分类与标签建议) -->
|
||||
<NModal v-model:show="showAiModal" preset="card" :title="t('ai.suggestTitle')" style="width: 860px; max-width: 92vw;">
|
||||
<NSpace vertical :size="12">
|
||||
<NAlert type="warning" :show-icon="true">{{ t('ai.applyHint') }}</NAlert>
|
||||
<NDataTable
|
||||
:columns="aiColumns"
|
||||
:data="aiRows"
|
||||
:max-height="420"
|
||||
:row-key="(row) => row.transactionId"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
/>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="showAiModal = false">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="aiApplying" :disabled="aiRows.length === 0" @click="handleAiApply">
|
||||
{{ t('ai.applyAll', { count: aiRows.length }) }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NModal>
|
||||
|
||||
<!-- AI 自然语言查询解析结果弹窗 -->
|
||||
<NModal v-model:show="showAiQueryModal" preset="card" :title="t('ai.query.resultTitle')" style="width: 560px; max-width: 92vw;">
|
||||
<NSpace vertical :size="12">
|
||||
<NAlert v-if="aiQueryResult?.summary" type="info" :show-icon="true">{{ aiQueryResult.summary }}</NAlert>
|
||||
<NText v-if="aiQueryEmpty" depth="3">{{ t('ai.query.noCondition') }}</NText>
|
||||
<NDescriptions v-else :column="1" label-placement="left" bordered size="small">
|
||||
<NDescriptionsItem v-if="aiQueryResult?.types?.length" :label="t('ai.query.type')">
|
||||
<NSpace :size="6">
|
||||
<NTag v-for="tp in aiQueryResult.types" :key="tp" size="small" :type="tp === 'INCOME' ? 'success' : 'error'">
|
||||
{{ tp === 'INCOME' ? t('common.types.income') : t('common.types.expense') }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem v-if="aiQueryResult?.categoryNames?.length || aiQueryResult?.uncategorized" :label="t('ai.query.category')">
|
||||
<NSpace :size="6">
|
||||
<NTag v-for="c in aiQueryResult?.categoryNames || []" :key="c" size="small">{{ c }}</NTag>
|
||||
<NTag v-if="aiQueryResult?.uncategorized" size="small">{{ t('transaction.uncategorized') }}</NTag>
|
||||
</NSpace>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem v-if="aiQueryResult?.tagNames?.length" :label="t('ai.query.tag')">
|
||||
<NSpace :size="6">
|
||||
<NTag v-for="tg in aiQueryResult.tagNames" :key="tg" size="small">{{ tg }}</NTag>
|
||||
</NSpace>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem v-if="aiQueryResult?.paymentMethods?.length" :label="t('ai.query.payment')">
|
||||
<NSpace :size="6">
|
||||
<NTag v-for="pm in aiQueryResult.paymentMethods" :key="pm" size="small">{{ getPaymentLabel(pm) }}</NTag>
|
||||
</NSpace>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem v-if="aiQueryResult?.startTime && aiQueryResult?.endTime" :label="t('ai.query.dateRange')">
|
||||
{{ formatAiDate(aiQueryResult.startTime) }} ~ {{ formatAiDate(aiQueryResult.endTime) }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem
|
||||
v-if="aiQueryResult && (aiQueryResult.minAmount != null || aiQueryResult.maxAmount != null)"
|
||||
:label="t('ai.query.amountRange')"
|
||||
>
|
||||
{{ aiQueryResult.minAmount ?? 0 }} ~ {{ aiQueryResult.maxAmount ?? '∞' }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem v-if="aiQueryResult?.remark" :label="t('ai.query.remark')">{{ aiQueryResult.remark }}</NDescriptionsItem>
|
||||
<NDescriptionsItem v-if="aiQueryResult && aiQueryResult.includeInStats != null" :label="t('ai.query.includeInStats')">
|
||||
{{ aiQueryResult.includeInStats ? t('ai.query.includeYes') : t('ai.query.includeNo') }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem v-if="aiQueryResult?.unmatched?.length" :label="t('ai.query.unmatched')">
|
||||
<NSpace :size="6">
|
||||
<NTag v-for="u in aiQueryResult.unmatched" :key="u" size="small" type="warning">{{ u }}</NTag>
|
||||
</NSpace>
|
||||
</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="showAiQueryModal = false">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :disabled="aiQueryEmpty" @click="applyAiQuery">{{ t('ai.query.apply') }}</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NModal>
|
||||
|
||||
<!-- AI 智能记账弹窗 -->
|
||||
<NModal v-model:show="showAiRecordModal" preset="card" :title="t('ai.record.title')" style="width: 520px; max-width: 92vw;">
|
||||
<NSpace vertical :size="12">
|
||||
<NAlert type="info" :show-icon="true">{{ t('ai.record.hint') }}</NAlert>
|
||||
<NInput
|
||||
v-model:value="aiRecordText"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 6 }"
|
||||
:placeholder="t('ai.record.placeholder')"
|
||||
/>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="showAiRecordModal = false">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="aiRecording" @click="handleAiRecordParse">{{ t('ai.record.parse') }}</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -949,4 +1625,9 @@ onMounted(() => {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
.active-filters {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package cn.rosercode.finance.client;
|
||||
|
||||
import cn.rosercode.finance.common.BusinessException;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* DeepSeek 客户端,封装 Chat Completions 接口的 HTTP 调用与响应解析。
|
||||
*
|
||||
* @date 2026-09-17 17:52:17
|
||||
*/
|
||||
@Component
|
||||
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 ERROR_BODY_LIMIT = 300;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 Chat Completions 接口,返回模型输出的文本内容。
|
||||
*
|
||||
* @param baseUrl API 基础地址
|
||||
* @param apiKey API Key
|
||||
* @param model 模型名称
|
||||
* @param systemPrompt 系统提示词
|
||||
* @param userPrompt 用户提示词
|
||||
* @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";
|
||||
|
||||
// 构造请求体,强制返回 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) {
|
||||
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))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response;
|
||||
try {
|
||||
response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
} catch (IOException e) {
|
||||
throw new BusinessException("调用 DeepSeek 失败: " + e.getMessage());
|
||||
} catch (InterruptedException e) {
|
||||
// 恢复中断标记,避免吞掉中断信号
|
||||
Thread.currentThread().interrupt();
|
||||
throw new BusinessException("调用 DeepSeek 被中断");
|
||||
}
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
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()) {
|
||||
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));
|
||||
return result;
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new BusinessException("解析 DeepSeek 响应失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断过长的响应体,避免错误信息过长。
|
||||
*
|
||||
* @param body 响应体
|
||||
* @return 截断后的字符串
|
||||
*/
|
||||
private String truncate(String body) {
|
||||
if (body == null) {
|
||||
return "";
|
||||
}
|
||||
return body.length() <= ERROR_BODY_LIMIT ? body : body.substring(0, ERROR_BODY_LIMIT) + "...";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package cn.rosercode.finance.client;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* DeepSeek 调用结果,包含模型输出内容与 token 用量。
|
||||
*
|
||||
* @date 2026-09-17 18:02:08
|
||||
*/
|
||||
@Data
|
||||
public class DeepSeekResponse {
|
||||
|
||||
/** 模型输出的文本内容 */
|
||||
private String content;
|
||||
|
||||
/** 输入 token 数 */
|
||||
private int promptTokens;
|
||||
|
||||
/** 输出 token 数 */
|
||||
private int completionTokens;
|
||||
|
||||
/** 总 token 数 */
|
||||
private int totalTokens;
|
||||
|
||||
/** 缓存命中的输入 token 数 */
|
||||
private int cachedTokens;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package cn.rosercode.finance.controller;
|
||||
|
||||
import cn.rosercode.finance.common.Result;
|
||||
import cn.rosercode.finance.dto.AiApplyRequest;
|
||||
import cn.rosercode.finance.dto.AiConfigRequest;
|
||||
import cn.rosercode.finance.dto.AiQueryRequest;
|
||||
import cn.rosercode.finance.dto.AiRecordRequest;
|
||||
import cn.rosercode.finance.dto.AiSuggestRequest;
|
||||
import cn.rosercode.finance.service.AiConfigService;
|
||||
import cn.rosercode.finance.service.AiQueryService;
|
||||
import cn.rosercode.finance.service.AiRecordService;
|
||||
import cn.rosercode.finance.service.AiSuggestionService;
|
||||
import cn.rosercode.finance.service.AiUsageService;
|
||||
import cn.rosercode.finance.vo.AiConfigVO;
|
||||
import cn.rosercode.finance.vo.AiQueryResultVO;
|
||||
import cn.rosercode.finance.vo.AiRecordResultVO;
|
||||
import cn.rosercode.finance.vo.AiSuggestionVO;
|
||||
import cn.rosercode.finance.vo.AiUsageResultVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AI 控制器
|
||||
*
|
||||
* <p>提供 AI 配置的读写、连通性测试以及交易记录分类与标签智能建议接口</p>
|
||||
*
|
||||
* @date 2026-09-17 17:52:40
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/ai")
|
||||
@Tag(name = "AI 智能建议", description = "DeepSeek 配置与交易分类标签建议相关接口")
|
||||
public class AiController {
|
||||
|
||||
private final AiConfigService aiConfigService;
|
||||
private final AiSuggestionService aiSuggestionService;
|
||||
private final AiUsageService aiUsageService;
|
||||
private final AiQueryService aiQueryService;
|
||||
private final AiRecordService aiRecordService;
|
||||
|
||||
public AiController(AiConfigService aiConfigService, AiSuggestionService aiSuggestionService,
|
||||
AiUsageService aiUsageService, AiQueryService aiQueryService,
|
||||
AiRecordService aiRecordService) {
|
||||
this.aiConfigService = aiConfigService;
|
||||
this.aiSuggestionService = aiSuggestionService;
|
||||
this.aiUsageService = aiUsageService;
|
||||
this.aiQueryService = aiQueryService;
|
||||
this.aiRecordService = aiRecordService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的 AI 配置
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @return AI 配置(API Key 脱敏)
|
||||
*/
|
||||
@GetMapping("/config")
|
||||
@Operation(summary = "获取 AI 配置", description = "获取当前用户的 AI 智能建议配置,API Key 已脱敏")
|
||||
public Result<AiConfigVO> getConfig(HttpServletRequest request) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(aiConfigService.getConfig(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存当前用户的 AI 配置
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param configRequest 配置保存请求
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PutMapping("/config")
|
||||
@Operation(summary = "保存 AI 配置", description = "更新当前用户的 AI 配置,API Key 留空表示不修改")
|
||||
public Result<Void> saveConfig(HttpServletRequest request, @RequestBody AiConfigRequest configRequest) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
aiConfigService.saveConfig(userId, configRequest);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 DeepSeek 连通性
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PostMapping("/config/test")
|
||||
@Operation(summary = "测试连接", description = "使用当前配置向 DeepSeek 发送一条测试请求")
|
||||
public Result<Void> testConfig(HttpServletRequest request) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
aiConfigService.testConnection(userId);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成分类与标签建议
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param suggestRequest 建议请求,携带记录ID列表
|
||||
* @return 逐条建议列表
|
||||
*/
|
||||
@PostMapping("/suggest")
|
||||
@Operation(summary = "生成 AI 建议", description = "根据交易记录内容生成分类与标签建议")
|
||||
public Result<List<AiSuggestionVO>> suggest(HttpServletRequest request,
|
||||
@RequestBody @Valid AiSuggestRequest suggestRequest) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(aiSuggestionService.suggest(userId, suggestRequest.getIds()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键应用 AI 建议
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param applyRequest 应用请求,携带逐条建议
|
||||
* @return 实际发生变更的记录数
|
||||
*/
|
||||
@PostMapping("/apply")
|
||||
@Operation(summary = "应用 AI 建议", description = "覆盖分类并追加标签,一键写入建议")
|
||||
public Result<Integer> apply(HttpServletRequest request,
|
||||
@RequestBody @Valid AiApplyRequest applyRequest) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(aiSuggestionService.apply(userId, applyRequest));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 AI 使用情况
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param limit 最近明细条数,默认 20
|
||||
* @return 累计汇总与最近调用明细
|
||||
*/
|
||||
@GetMapping("/usage")
|
||||
@Operation(summary = "获取 AI 使用情况", description = "获取当前用户 AI 调用的累计汇总与最近调用明细")
|
||||
public Result<AiUsageResultVO> usage(HttpServletRequest request,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(aiUsageService.getUsage(userId, limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析自然语言查询为筛选条件
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param queryRequest 查询请求,携带自然语言语句
|
||||
* @return 结构化筛选条件
|
||||
*/
|
||||
@PostMapping("/query")
|
||||
@Operation(summary = "自然语言查询解析", description = "将自然语言查询解析为交易筛选条件,供用户确认后应用")
|
||||
public Result<AiQueryResultVO> query(HttpServletRequest request,
|
||||
@RequestBody @Valid AiQueryRequest queryRequest) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(aiQueryService.parse(userId, queryRequest.getQuery()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析自然语言记账内容为交易记录
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param recordRequest 记账请求,携带自然语言语句
|
||||
* @return 结构化交易记录
|
||||
*/
|
||||
@PostMapping("/record")
|
||||
@Operation(summary = "自然语言记账解析", description = "将自然语言记账内容解析为交易记录,供用户确认后保存")
|
||||
public Result<AiRecordResultVO> record(HttpServletRequest request,
|
||||
@RequestBody @Valid AiRecordRequest recordRequest) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(aiRecordService.parse(userId, recordRequest.getText()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package cn.rosercode.finance.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AI 建议应用请求DTO,携带逐条记录的建议分类与标签,用于一键写入。
|
||||
*
|
||||
* @date 2026-09-17 17:52:00
|
||||
*/
|
||||
@Data
|
||||
public class AiApplyRequest {
|
||||
|
||||
/** 待应用的建议列表 */
|
||||
@NotEmpty(message = "请至少提供一条建议")
|
||||
@Valid
|
||||
private List<Item> suggestions;
|
||||
|
||||
/**
|
||||
* 单条建议项,描述某条交易记录的目标分类与待追加标签。
|
||||
*/
|
||||
@Data
|
||||
public static class Item {
|
||||
|
||||
/** 交易记录ID */
|
||||
@NotNull(message = "记录ID不能为空")
|
||||
private Long transactionId;
|
||||
|
||||
/** 建议分类ID,为 null 表示不修改分类 */
|
||||
private Long categoryId;
|
||||
|
||||
/** 建议追加的标签ID列表,可为空表示不追加标签 */
|
||||
private List<Long> tagIds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package cn.rosercode.finance.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* AI 配置保存请求DTO,用于更新用户的 AI 智能建议配置。
|
||||
*
|
||||
* @date 2026-09-17 17:51:58
|
||||
*/
|
||||
@Data
|
||||
public class AiConfigRequest {
|
||||
|
||||
/** 是否启用 AI 智能建议 */
|
||||
private Boolean enabled;
|
||||
|
||||
/** DeepSeek API Key,为空或 null 时保留原值(不修改) */
|
||||
private String apiKey;
|
||||
|
||||
/** API 基础地址,为空时使用默认值 */
|
||||
private String baseUrl;
|
||||
|
||||
/** 模型名称,为空时使用默认值 */
|
||||
private String model;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package cn.rosercode.finance.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* AI 自然语言查询请求DTO,携带用户输入的查询语句。
|
||||
*
|
||||
* @date 2026-09-17 18:13:06
|
||||
*/
|
||||
@Data
|
||||
public class AiQueryRequest {
|
||||
|
||||
/** 用户输入的自然语言查询语句 */
|
||||
@NotBlank(message = "请输入查询内容")
|
||||
private String query;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package cn.rosercode.finance.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* AI 自然语言记账请求DTO,携带用户输入的记账语句。
|
||||
*
|
||||
* @date 2026-09-17 18:23:10
|
||||
*/
|
||||
@Data
|
||||
public class AiRecordRequest {
|
||||
|
||||
/** 用户输入的自然语言记账内容 */
|
||||
@NotBlank(message = "请输入记账内容")
|
||||
private String text;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package cn.rosercode.finance.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AI 建议请求DTO,携带需要分析分类与标签的交易记录ID列表。
|
||||
*
|
||||
* @date 2026-09-17 17:51:59
|
||||
*/
|
||||
@Data
|
||||
public class AiSuggestRequest {
|
||||
|
||||
/** 待分析建议的交易记录ID列表 */
|
||||
@NotEmpty(message = "请至少选择一条记录")
|
||||
private List<Long> ids;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package cn.rosercode.finance.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* AI 配置实体类,对应数据库表 t_ai_config,用于记录用户的 AI 智能建议配置。
|
||||
*
|
||||
* @date 2026-09-17 17:51:57
|
||||
*/
|
||||
@Data
|
||||
@TableName("t_ai_config")
|
||||
public class AiConfig {
|
||||
|
||||
/** 配置ID,主键自增 */
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 所属用户ID */
|
||||
private Long userId;
|
||||
|
||||
/** 是否启用 AI 智能建议 */
|
||||
private Boolean enabled;
|
||||
|
||||
/** DeepSeek API Key(明文存储) */
|
||||
private String apiKey;
|
||||
|
||||
/** API 基础地址 */
|
||||
private String baseUrl;
|
||||
|
||||
/** 使用的模型名称 */
|
||||
private String model;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** 更新时间 */
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package cn.rosercode.finance.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* AI 使用记录实体类,对应数据库表 t_ai_usage,记录每次调用 DeepSeek 的用量与结果。
|
||||
*
|
||||
* @date 2026-09-17 18:02:05
|
||||
*/
|
||||
@Data
|
||||
@TableName("t_ai_usage")
|
||||
public class AiUsage {
|
||||
|
||||
/** 记录ID,主键自增 */
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 所属用户ID */
|
||||
private Long userId;
|
||||
|
||||
/** 调用类型:SUGGEST-生成建议,TEST-测试连接 */
|
||||
private String type;
|
||||
|
||||
/** 模型名称 */
|
||||
private String model;
|
||||
|
||||
/** 本次分析的记录数 */
|
||||
private Integer transactionCount;
|
||||
|
||||
/** 输入 token 数 */
|
||||
private Integer promptTokens;
|
||||
|
||||
/** 输出 token 数 */
|
||||
private Integer completionTokens;
|
||||
|
||||
/** 总 token 数 */
|
||||
private Integer totalTokens;
|
||||
|
||||
/** 缓存命中的输入 token 数 */
|
||||
private Integer cachedTokens;
|
||||
|
||||
/** 是否成功 */
|
||||
private Boolean success;
|
||||
|
||||
/** 失败原因 */
|
||||
private String errorMessage;
|
||||
|
||||
/** 调用耗时(毫秒) */
|
||||
private Long durationMs;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package cn.rosercode.finance.enums;
|
||||
|
||||
/**
|
||||
* AI 调用类型枚举,标识使用记录的来源。
|
||||
*
|
||||
* @date 2026-09-17 18:02:04
|
||||
*/
|
||||
public enum AiCallType {
|
||||
|
||||
/** 生成分类与标签建议 */
|
||||
SUGGEST,
|
||||
|
||||
/** 测试连接 */
|
||||
TEST,
|
||||
|
||||
/** 自然语言查询解析 */
|
||||
QUERY,
|
||||
|
||||
/** 自然语言记账解析 */
|
||||
RECORD
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package cn.rosercode.finance.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import cn.rosercode.finance.entity.AiConfig;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* AI 配置Mapper接口,继承MyBatis-Plus的BaseMapper,提供AI配置表的基础CRUD操作。
|
||||
*
|
||||
* @date 2026-09-17 17:51:58
|
||||
*/
|
||||
@Mapper
|
||||
public interface AiConfigMapper extends BaseMapper<AiConfig> {
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cn.rosercode.finance.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import cn.rosercode.finance.entity.AiUsage;
|
||||
import cn.rosercode.finance.vo.AiUsageSummaryVO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
/**
|
||||
* AI 使用记录Mapper接口,继承MyBatis-Plus的BaseMapper,并提供汇总统计查询。
|
||||
*
|
||||
* @date 2026-09-17 18:02:08
|
||||
*/
|
||||
@Mapper
|
||||
public interface AiUsageMapper extends BaseMapper<AiUsage> {
|
||||
|
||||
/**
|
||||
* 统计指定用户的累计使用情况(调用次数与 token 用量)。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 汇总结果,无记录时各项为 0
|
||||
*/
|
||||
@Select("SELECT COUNT(*) AS totalCalls, "
|
||||
+ "COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) AS successCalls, "
|
||||
+ "COALESCE(SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END), 0) AS failedCalls, "
|
||||
+ "COALESCE(SUM(prompt_tokens), 0) AS promptTokens, "
|
||||
+ "COALESCE(SUM(completion_tokens), 0) AS completionTokens, "
|
||||
+ "COALESCE(SUM(total_tokens), 0) AS totalTokens, "
|
||||
+ "COALESCE(SUM(cached_tokens), 0) AS cachedTokens "
|
||||
+ "FROM t_ai_usage WHERE user_id = #{userId}")
|
||||
AiUsageSummaryVO selectSummary(@Param("userId") Long userId);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package cn.rosercode.finance.service;
|
||||
|
||||
import cn.rosercode.finance.dto.AiConfigRequest;
|
||||
import cn.rosercode.finance.entity.AiConfig;
|
||||
import cn.rosercode.finance.vo.AiConfigVO;
|
||||
|
||||
/**
|
||||
* AI 配置服务接口,提供用户级 AI 配置的读取、保存与连通性测试。
|
||||
*
|
||||
* @date 2026-09-17 17:52:14
|
||||
*/
|
||||
public interface AiConfigService {
|
||||
|
||||
/**
|
||||
* 获取当前用户的 AI 配置(API Key 脱敏),未配置时返回默认值。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 脱敏后的配置视图对象
|
||||
*/
|
||||
AiConfigVO getConfig(Long userId);
|
||||
|
||||
/**
|
||||
* 保存当前用户的 AI 配置。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param request 配置保存请求
|
||||
*/
|
||||
void saveConfig(Long userId, AiConfigRequest request);
|
||||
|
||||
/**
|
||||
* 获取原始配置实体,供内部调用(含明文 API Key)。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 配置实体,不存在时返回 null
|
||||
*/
|
||||
AiConfig getRawConfig(Long userId);
|
||||
|
||||
/**
|
||||
* 测试当前用户配置的 DeepSeek 连通性。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
void testConnection(Long userId);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package cn.rosercode.finance.service;
|
||||
|
||||
import cn.rosercode.finance.vo.AiQueryResultVO;
|
||||
|
||||
/**
|
||||
* AI 自然语言查询服务接口,将自然语言解析为交易筛选条件。
|
||||
*
|
||||
* @date 2026-09-17 18:13:18
|
||||
*/
|
||||
public interface AiQueryService {
|
||||
|
||||
/**
|
||||
* 解析自然语言查询。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param query 查询语句
|
||||
* @return 结构化筛选条件
|
||||
*/
|
||||
AiQueryResultVO parse(Long userId, String query);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package cn.rosercode.finance.service;
|
||||
|
||||
import cn.rosercode.finance.vo.AiRecordResultVO;
|
||||
|
||||
/**
|
||||
* AI 自然语言记账服务接口,将自然语言解析为一条交易记录。
|
||||
*
|
||||
* @date 2026-09-17 18:23:17
|
||||
*/
|
||||
public interface AiRecordService {
|
||||
|
||||
/**
|
||||
* 解析自然语言记账内容。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param text 记账语句
|
||||
* @return 结构化交易记录
|
||||
*/
|
||||
AiRecordResultVO parse(Long userId, String text);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package cn.rosercode.finance.service;
|
||||
|
||||
import cn.rosercode.finance.dto.AiApplyRequest;
|
||||
import cn.rosercode.finance.vo.AiSuggestionVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AI 建议服务接口,提供交易记录的分类与标签智能建议及应用能力。
|
||||
*
|
||||
* @date 2026-09-17 17:52:23
|
||||
*/
|
||||
public interface AiSuggestionService {
|
||||
|
||||
/**
|
||||
* 根据交易记录内容调用 DeepSeek 生成分类与标签建议。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param ids 待分析的建议记录ID列表
|
||||
* @return 逐条建议列表
|
||||
*/
|
||||
List<AiSuggestionVO> suggest(Long userId, List<Long> ids);
|
||||
|
||||
/**
|
||||
* 一键应用建议:覆盖分类、追加标签。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param request 建议应用请求
|
||||
* @return 实际发生变更的记录数
|
||||
*/
|
||||
int apply(Long userId, AiApplyRequest request);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package cn.rosercode.finance.service;
|
||||
|
||||
import cn.rosercode.finance.client.DeepSeekResponse;
|
||||
import cn.rosercode.finance.enums.AiCallType;
|
||||
import cn.rosercode.finance.vo.AiUsageResultVO;
|
||||
|
||||
/**
|
||||
* AI 使用记录服务接口,提供调用用量的记录与查询能力。
|
||||
*
|
||||
* @date 2026-09-17 18:02:15
|
||||
*/
|
||||
public interface AiUsageService {
|
||||
|
||||
/**
|
||||
* 记录一次成功的 AI 调用。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param type 调用类型
|
||||
* @param model 模型名称
|
||||
* @param transactionCount 本次分析的记录数
|
||||
* @param response 模型返回结果(含 token 用量)
|
||||
* @param durationMs 调用耗时(毫秒)
|
||||
*/
|
||||
void recordSuccess(Long userId, AiCallType type, String model, int transactionCount,
|
||||
DeepSeekResponse response, long durationMs);
|
||||
|
||||
/**
|
||||
* 记录一次失败的 AI 调用。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param type 调用类型
|
||||
* @param model 模型名称
|
||||
* @param transactionCount 本次分析的记录数
|
||||
* @param durationMs 调用耗时(毫秒)
|
||||
* @param errorMessage 失败原因
|
||||
*/
|
||||
void recordFailure(Long userId, AiCallType type, String model, int transactionCount,
|
||||
long durationMs, String errorMessage);
|
||||
|
||||
/**
|
||||
* 查询指定用户的使用情况(累计汇总 + 最近明细)。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param recentLimit 最近明细条数
|
||||
* @return 使用情况视图对象
|
||||
*/
|
||||
AiUsageResultVO getUsage(Long userId, int recentLimit);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package cn.rosercode.finance.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import cn.rosercode.finance.client.DeepSeekClient;
|
||||
import cn.rosercode.finance.client.DeepSeekResponse;
|
||||
import cn.rosercode.finance.common.BusinessException;
|
||||
import cn.rosercode.finance.dto.AiConfigRequest;
|
||||
import cn.rosercode.finance.entity.AiConfig;
|
||||
import cn.rosercode.finance.enums.AiCallType;
|
||||
import cn.rosercode.finance.mapper.AiConfigMapper;
|
||||
import cn.rosercode.finance.service.AiConfigService;
|
||||
import cn.rosercode.finance.service.AiUsageService;
|
||||
import cn.rosercode.finance.vo.AiConfigVO;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* AI 配置服务实现类,处理用户级 AI 配置的读写与连通性测试。
|
||||
*
|
||||
* @date 2026-09-17 17:52:22
|
||||
*/
|
||||
@Service
|
||||
public class AiConfigServiceImpl implements AiConfigService {
|
||||
|
||||
/** 默认 API 基础地址 */
|
||||
private static final String DEFAULT_BASE_URL = "https://api.deepseek.com";
|
||||
|
||||
/** 默认模型名称 */
|
||||
private static final String DEFAULT_MODEL = "deepseek-chat";
|
||||
|
||||
private final AiConfigMapper aiConfigMapper;
|
||||
private final DeepSeekClient deepSeekClient;
|
||||
private final AiUsageService aiUsageService;
|
||||
|
||||
public AiConfigServiceImpl(AiConfigMapper aiConfigMapper, DeepSeekClient deepSeekClient,
|
||||
AiUsageService aiUsageService) {
|
||||
this.aiConfigMapper = aiConfigMapper;
|
||||
this.deepSeekClient = deepSeekClient;
|
||||
this.aiUsageService = aiUsageService;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 未配置时返回关闭状态与默认地址、模型。
|
||||
*/
|
||||
@Override
|
||||
public AiConfigVO getConfig(Long userId) {
|
||||
AiConfig config = getRawConfig(userId);
|
||||
AiConfigVO vo = new AiConfigVO();
|
||||
if (config == null) {
|
||||
vo.setEnabled(false);
|
||||
vo.setHasApiKey(false);
|
||||
vo.setBaseUrl(DEFAULT_BASE_URL);
|
||||
vo.setModel(DEFAULT_MODEL);
|
||||
return vo;
|
||||
}
|
||||
vo.setEnabled(Boolean.TRUE.equals(config.getEnabled()));
|
||||
vo.setHasApiKey(config.getApiKey() != null && !config.getApiKey().isBlank());
|
||||
vo.setApiKey(maskApiKey(config.getApiKey()));
|
||||
vo.setBaseUrl(config.getBaseUrl());
|
||||
vo.setModel(config.getModel());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* API Key 为空时保留原值,地址与模型为空时回退默认值。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void saveConfig(Long userId, AiConfigRequest request) {
|
||||
AiConfig config = getRawConfig(userId);
|
||||
if (config == null) {
|
||||
config = new AiConfig();
|
||||
config.setUserId(userId);
|
||||
}
|
||||
config.setEnabled(request.getEnabled() != null ? request.getEnabled() : Boolean.FALSE);
|
||||
// API Key 为空表示不修改,保留原值
|
||||
if (request.getApiKey() != null && !request.getApiKey().isBlank()) {
|
||||
config.setApiKey(request.getApiKey().trim());
|
||||
}
|
||||
config.setBaseUrl(isBlank(request.getBaseUrl()) ? DEFAULT_BASE_URL : request.getBaseUrl().trim());
|
||||
config.setModel(isBlank(request.getModel()) ? DEFAULT_MODEL : request.getModel().trim());
|
||||
|
||||
if (config.getId() == null) {
|
||||
aiConfigMapper.insert(config);
|
||||
} else {
|
||||
aiConfigMapper.updateById(config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public AiConfig getRawConfig(Long userId) {
|
||||
return aiConfigMapper.selectOne(new LambdaQueryWrapper<AiConfig>()
|
||||
.eq(AiConfig::getUserId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 发送一条极简请求验证 Key 与地址是否可用。
|
||||
*/
|
||||
@Override
|
||||
public void testConnection(Long userId) {
|
||||
AiConfig config = getRawConfig(userId);
|
||||
if (config == null || isBlank(config.getApiKey())) {
|
||||
throw new BusinessException("请先配置 DeepSeek API Key");
|
||||
}
|
||||
long start = System.currentTimeMillis();
|
||||
DeepSeekResponse response;
|
||||
try {
|
||||
response = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
|
||||
"你是一个测试助手,请只输出 JSON。",
|
||||
"请返回 {\"ok\":true}");
|
||||
} catch (RuntimeException e) {
|
||||
// 失败也记录一次使用,便于排查问题
|
||||
aiUsageService.recordFailure(userId, AiCallType.TEST, config.getModel(), 0,
|
||||
System.currentTimeMillis() - start, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
aiUsageService.recordSuccess(userId, AiCallType.TEST, config.getModel(), 0,
|
||||
response, System.currentTimeMillis() - start);
|
||||
if (isBlank(response.getContent())) {
|
||||
throw new BusinessException("连接成功但未返回内容");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 API Key 进行脱敏,仅保留前缀与后缀。
|
||||
*
|
||||
* @param apiKey 原始 API Key
|
||||
* @return 脱敏后的字符串,未配置时返回 null
|
||||
*/
|
||||
private String maskApiKey(String apiKey) {
|
||||
if (isBlank(apiKey)) {
|
||||
return null;
|
||||
}
|
||||
if (apiKey.length() <= 7) {
|
||||
return "****";
|
||||
}
|
||||
return apiKey.substring(0, 3) + "****" + apiKey.substring(apiKey.length() - 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否为空白。
|
||||
*
|
||||
* @param value 待判断字符串
|
||||
* @return 为 null 或全空白时返回 true
|
||||
*/
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package cn.rosercode.finance.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
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.mapper.CategoryMapper;
|
||||
import cn.rosercode.finance.mapper.TagMapper;
|
||||
import cn.rosercode.finance.service.AiConfigService;
|
||||
import cn.rosercode.finance.service.AiQueryService;
|
||||
import cn.rosercode.finance.service.AiUsageService;
|
||||
import cn.rosercode.finance.vo.AiQueryResultVO;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* AI 自然语言查询服务实现类,负责提示词组装、日期推算与名称映射。
|
||||
*
|
||||
* @date 2026-09-17 18:13:28
|
||||
*/
|
||||
@Service
|
||||
public class AiQueryServiceImpl implements AiQueryService {
|
||||
|
||||
/** 日期格式 */
|
||||
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_PAYMENTS = List.of("WECHAT", "ALIPAY", "CASH", "BANK");
|
||||
|
||||
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 AiQueryServiceImpl(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 AiQueryResultVO 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.QUERY, config.getModel(), 0,
|
||||
System.currentTimeMillis() - start, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
aiUsageService.recordSuccess(userId, AiCallType.QUERY, 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(支付宝)、CASH(现金)、BANK(银行卡)。\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"
|
||||
+ "未提及的字段请留空(数组用 [],其余用 null)。分类和标签只能使用上面给出的名称。\n"
|
||||
+ "今天是 " + today + "。\n"
|
||||
+ "输出格式:{\"summary\":\"简要说明识别到的查询意图\",\"types\":[],\"categoryNames\":[],\"tagNames\":[],"
|
||||
+ "\"paymentMethods\":[],\"dateRange\":{\"preset\":\"NONE\",\"start\":null,\"end\":null},\"minAmount\":null,"
|
||||
+ "\"maxAmount\":null,\"remark\":null,\"includeInStats\":null,\"uncategorized\":false,\"unmatched\":[]}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模型返回的 JSON 并映射为筛选条件。
|
||||
*
|
||||
* @param content 模型输出内容
|
||||
* @param categories 用户分类列表
|
||||
* @param tags 用户标签列表
|
||||
* @return 结构化筛选条件
|
||||
*/
|
||||
private AiQueryResultVO 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,请重试");
|
||||
}
|
||||
|
||||
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.setUncategorized(root.path("uncategorized").asBoolean(false));
|
||||
|
||||
JsonNode statsNode = root.path("includeInStats");
|
||||
if (!statsNode.isMissingNode() && !statsNode.isNull()) {
|
||||
vo.setIncludeInStats(statsNode.asBoolean());
|
||||
}
|
||||
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());
|
||||
|
||||
// 分类名称映射为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 模型输出节点
|
||||
* @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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package cn.rosercode.finance.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
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.mapper.CategoryMapper;
|
||||
import cn.rosercode.finance.mapper.TagMapper;
|
||||
import cn.rosercode.finance.service.AiConfigService;
|
||||
import cn.rosercode.finance.service.AiRecordService;
|
||||
import cn.rosercode.finance.service.AiUsageService;
|
||||
import cn.rosercode.finance.vo.AiRecordResultVO;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* AI 自然语言记账服务实现类,负责提示词组装、时间推算与名称映射。
|
||||
*
|
||||
* @date 2026-09-17 18:23:25
|
||||
*/
|
||||
@Service
|
||||
public class AiRecordServiceImpl implements AiRecordService {
|
||||
|
||||
/** 日期时间格式,与前端表单保持一致 */
|
||||
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_PAYMENTS = List.of("WECHAT", "ALIPAY", "CASH", "BANK");
|
||||
|
||||
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 AiRecordServiceImpl(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 AiRecordResultVO parse(Long userId, String text) {
|
||||
if (text == null || text.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, text);
|
||||
} catch (RuntimeException e) {
|
||||
// 失败也记录一次使用,便于排查问题
|
||||
aiUsageService.recordFailure(userId, AiCallType.RECORD, config.getModel(), 0,
|
||||
System.currentTimeMillis() - start, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
aiUsageService.recordSuccess(userId, AiCallType.RECORD, 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 now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||
return "你是一个记账助手。请把用户的自然语言记账内容解析为一条结构化交易记录,只输出 JSON,不要输出任何多余内容。\n"
|
||||
+ "可用的收支类型:INCOME(收入)、EXPENSE(支出)。\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;"
|
||||
+ "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\","
|
||||
+ "\"transactionTime\":\"" + LocalDateTime.now().format(DATETIME_FMT) + "\",\"remark\":\"午餐\","
|
||||
+ "\"includeInStats\":true,\"unmatched\":[]}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模型返回的 JSON 并映射为交易记录字段。
|
||||
*
|
||||
* @param content 模型输出内容
|
||||
* @param categories 用户分类列表
|
||||
* @param tags 用户标签列表
|
||||
* @return 结构化交易记录
|
||||
*/
|
||||
private AiRecordResultVO 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,请重试");
|
||||
}
|
||||
|
||||
AiRecordResultVO vo = new AiRecordResultVO();
|
||||
vo.setSummary(root.path("summary").asText(""));
|
||||
|
||||
// 类型:仅接受合法值,默认支出
|
||||
String type = root.path("type").asText("EXPENSE").toUpperCase();
|
||||
vo.setType(VALID_TYPES.contains(type) ? type : "EXPENSE");
|
||||
|
||||
// 金额
|
||||
vo.setAmount(decimalOrNull(root.path("amount")));
|
||||
if (vo.getAmount() == null || vo.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new BusinessException("未能识别到有效金额,请补充金额后重试");
|
||||
}
|
||||
|
||||
// 分类:取第一个匹配项
|
||||
JsonNode categoryNode = root.path("categoryNames");
|
||||
if (categoryNode.isArray()) {
|
||||
for (JsonNode node : categoryNode) {
|
||||
Category category = matchCategory(categories, node.asText(""));
|
||||
if (category != null) {
|
||||
vo.setCategoryId(category.getId());
|
||||
vo.setCategoryName(category.getName());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 标签
|
||||
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 payment = root.path("paymentMethod").asText("").toUpperCase();
|
||||
if (VALID_PAYMENTS.contains(payment)) {
|
||||
vo.setPaymentMethod(payment);
|
||||
}
|
||||
|
||||
// 交易时间
|
||||
vo.setTransactionTime(resolveTransactionTime(root.path("transactionTime")));
|
||||
|
||||
String remark = root.path("remark").asText(null);
|
||||
vo.setRemark(isBlank(remark) ? null : remark.trim());
|
||||
|
||||
JsonNode statsNode = root.path("includeInStats");
|
||||
vo.setIncludeInStats(statsNode.isMissingNode() || statsNode.isNull() ? Boolean.TRUE : statsNode.asBoolean(true));
|
||||
|
||||
// 未识别关键词
|
||||
List<String> unmatched = new ArrayList<>();
|
||||
JsonNode unmatchedNode = root.path("unmatched");
|
||||
if (unmatchedNode.isArray()) {
|
||||
for (JsonNode node : unmatchedNode) {
|
||||
String value = node.asText("");
|
||||
if (!isBlank(value)) {
|
||||
unmatched.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
vo.setUnmatched(unmatched);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模型给出的交易时间,无法解析时回退当前时间。
|
||||
*
|
||||
* @param node 模型输出的时间节点
|
||||
* @return 格式化后的时间字符串
|
||||
*/
|
||||
private String resolveTransactionTime(JsonNode node) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String text = node == null || node.isMissingNode() || node.isNull() ? null : node.asText(null);
|
||||
if (isBlank(text)) {
|
||||
return now.format(DATETIME_FMT);
|
||||
}
|
||||
String value = text.trim().replace(' ', 'T');
|
||||
try {
|
||||
// 完整日期时间
|
||||
return LocalDateTime.parse(value.substring(0, Math.min(value.length(), 19))).format(DATETIME_FMT);
|
||||
} catch (Exception e) {
|
||||
// 仅日期:补齐当前时分秒
|
||||
try {
|
||||
LocalDate date = LocalDate.parse(value.substring(0, 10));
|
||||
return date.atTime(now.getHour(), now.getMinute(), now.getSecond()).format(DATETIME_FMT);
|
||||
} catch (Exception ex) {
|
||||
return now.format(DATETIME_FMT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按名称匹配分类:优先精确匹配,其次包含匹配。
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将节点转换为金额,非数字时返回 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package cn.rosercode.finance.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import cn.rosercode.finance.client.DeepSeekClient;
|
||||
import cn.rosercode.finance.client.DeepSeekResponse;
|
||||
import cn.rosercode.finance.common.BusinessException;
|
||||
import cn.rosercode.finance.dto.AiApplyRequest;
|
||||
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.AiSuggestionService;
|
||||
import cn.rosercode.finance.service.AiUsageService;
|
||||
import cn.rosercode.finance.vo.AiSuggestionVO;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* AI 建议服务实现类,负责组装提示词、调用 DeepSeek、校验并应用建议。
|
||||
*
|
||||
* @date 2026-09-17 17:52:35
|
||||
*/
|
||||
@Service
|
||||
public class AiSuggestionServiceImpl implements AiSuggestionService {
|
||||
|
||||
/** 单次最多分析的记录数 */
|
||||
private static final int MAX_SUGGEST_SIZE = 100;
|
||||
|
||||
/** 原始账单数据注入提示词时的最大长度 */
|
||||
private static final int RAW_DATA_LIMIT = 300;
|
||||
|
||||
/** 系统提示词,要求模型严格返回 JSON */
|
||||
private static final String SYSTEM_PROMPT = "你是一个专业的个人记账助手。请根据每条交易记录的类型、金额、备注等信息,"
|
||||
+ "从给定的分类列表和标签列表中,为该记录选择最合适的分类与标签。"
|
||||
+ "要求:1. 分类必须与记录的类型一致(INCOME 只能选 INCOME 分类,EXPENSE 只能选 EXPENSE 分类);"
|
||||
+ "2. 只能使用给定列表中的 ID,不要编造;3. 标签可多选,也可为空数组;"
|
||||
+ "4. 只输出 JSON,不要输出任何多余内容。"
|
||||
+ "输出格式:{\"suggestions\":[{\"id\":记录ID,\"categoryId\":分类ID,\"tagIds\":[标签ID],\"reason\":\"简要中文理由\"}]}";
|
||||
|
||||
private final AiConfigService aiConfigService;
|
||||
private final TransactionMapper transactionMapper;
|
||||
private final CategoryMapper categoryMapper;
|
||||
private final TagMapper tagMapper;
|
||||
private final TransactionTagMapper transactionTagMapper;
|
||||
private final DeepSeekClient deepSeekClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AiUsageService aiUsageService;
|
||||
|
||||
public AiSuggestionServiceImpl(AiConfigService aiConfigService,
|
||||
TransactionMapper transactionMapper,
|
||||
CategoryMapper categoryMapper,
|
||||
TagMapper tagMapper,
|
||||
TransactionTagMapper transactionTagMapper,
|
||||
DeepSeekClient deepSeekClient,
|
||||
ObjectMapper objectMapper,
|
||||
AiUsageService aiUsageService) {
|
||||
this.aiConfigService = aiConfigService;
|
||||
this.transactionMapper = transactionMapper;
|
||||
this.categoryMapper = categoryMapper;
|
||||
this.tagMapper = tagMapper;
|
||||
this.transactionTagMapper = transactionTagMapper;
|
||||
this.deepSeekClient = deepSeekClient;
|
||||
this.objectMapper = objectMapper;
|
||||
this.aiUsageService = aiUsageService;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 校验配置与数量后,将分类、标签与交易记录注入提示词,调用模型并解析校验结果。
|
||||
*/
|
||||
@Override
|
||||
public List<AiSuggestionVO> suggest(Long userId, List<Long> ids) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
throw new BusinessException("请至少选择一条记录");
|
||||
}
|
||||
if (ids.size() > MAX_SUGGEST_SIZE) {
|
||||
throw new BusinessException("单次最多分析 " + MAX_SUGGEST_SIZE + " 条记录,请缩小范围后重试");
|
||||
}
|
||||
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<Transaction> transactions = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
|
||||
.eq(Transaction::getUserId, userId)
|
||||
.in(Transaction::getId, ids));
|
||||
if (transactions.isEmpty()) {
|
||||
throw new BusinessException("未找到可分析的交易记录");
|
||||
}
|
||||
|
||||
List<Category> categories = categoryMapper.selectList(new LambdaQueryWrapper<Category>()
|
||||
.eq(Category::getUserId, userId));
|
||||
if (categories.isEmpty()) {
|
||||
throw new BusinessException("请先创建分类后再使用 AI 建议");
|
||||
}
|
||||
List<Tag> tags = tagMapper.selectList(new LambdaQueryWrapper<Tag>()
|
||||
.eq(Tag::getUserId, userId));
|
||||
|
||||
String userPrompt = buildUserPrompt(categories, tags, transactions);
|
||||
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.SUGGEST, config.getModel(), transactions.size(),
|
||||
System.currentTimeMillis() - start, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
aiUsageService.recordSuccess(userId, AiCallType.SUGGEST, config.getModel(), transactions.size(),
|
||||
aiResponse, System.currentTimeMillis() - start);
|
||||
|
||||
List<AiSuggestionVO> result = parseSuggestions(aiResponse.getContent(), transactions, categories, tags);
|
||||
if (result.isEmpty()) {
|
||||
throw new BusinessException("AI 未返回有效建议,请重试");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 分类覆盖写入,标签在保留原有基础上追加。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public int apply(Long userId, AiApplyRequest request) {
|
||||
List<AiApplyRequest.Item> items = request.getSuggestions();
|
||||
List<Long> transactionIds = items.stream()
|
||||
.map(AiApplyRequest.Item::getTransactionId)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 仅处理属于当前用户的记录
|
||||
Map<Long, Transaction> transactionMap = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
|
||||
.eq(Transaction::getUserId, userId)
|
||||
.in(Transaction::getId, transactionIds))
|
||||
.stream().collect(Collectors.toMap(Transaction::getId, Function.identity()));
|
||||
if (transactionMap.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
Map<Long, Category> categoryMap = categoryMapper.selectList(new LambdaQueryWrapper<Category>()
|
||||
.eq(Category::getUserId, userId))
|
||||
.stream().collect(Collectors.toMap(Category::getId, Function.identity()));
|
||||
Map<Long, Tag> tagMap = tagMapper.selectList(new LambdaQueryWrapper<Tag>()
|
||||
.eq(Tag::getUserId, userId))
|
||||
.stream().collect(Collectors.toMap(Tag::getId, Function.identity()));
|
||||
|
||||
int applied = 0;
|
||||
for (AiApplyRequest.Item item : items) {
|
||||
Transaction transaction = transactionMap.get(item.getTransactionId());
|
||||
if (transaction == null) {
|
||||
continue;
|
||||
}
|
||||
boolean changed = false;
|
||||
|
||||
// 分类:仅接受类型匹配且确属当前用户的分类
|
||||
if (item.getCategoryId() != null) {
|
||||
Category category = categoryMap.get(item.getCategoryId());
|
||||
if (category != null
|
||||
&& category.getType().equals(transaction.getType())
|
||||
&& !category.getId().equals(transaction.getCategoryId())) {
|
||||
transactionMapper.update(null, new LambdaUpdateWrapper<Transaction>()
|
||||
.eq(Transaction::getId, transaction.getId())
|
||||
.eq(Transaction::getUserId, userId)
|
||||
.set(Transaction::getCategoryId, category.getId()));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 标签:追加尚未关联的有效标签
|
||||
List<Long> validTagIds = item.getTagIds() == null ? List.of() : item.getTagIds().stream()
|
||||
.filter(tagMap::containsKey)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
if (!validTagIds.isEmpty()) {
|
||||
Set<Long> existingTagIds = transactionTagMapper.selectList(new LambdaQueryWrapper<TransactionTag>()
|
||||
.eq(TransactionTag::getTransactionId, transaction.getId()))
|
||||
.stream().map(TransactionTag::getTagId).collect(Collectors.toSet());
|
||||
for (Long tagId : validTagIds) {
|
||||
if (existingTagIds.contains(tagId)) {
|
||||
continue;
|
||||
}
|
||||
TransactionTag relation = new TransactionTag();
|
||||
relation.setTransactionId(transaction.getId());
|
||||
relation.setTagId(tagId);
|
||||
transactionTagMapper.insert(relation);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
applied++;
|
||||
}
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建用户提示词,将分类、标签与交易记录序列化为 JSON。
|
||||
*
|
||||
* @param categories 用户分类列表
|
||||
* @param tags 用户标签列表
|
||||
* @param transactions 待分析记录列表
|
||||
* @return JSON 格式的用户提示词
|
||||
*/
|
||||
private String buildUserPrompt(List<Category> categories, List<Tag> tags, List<Transaction> transactions) {
|
||||
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()));
|
||||
payload.put("transactions", transactions.stream().map(transaction -> {
|
||||
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("remark", transaction.getRemark());
|
||||
node.put("extra", extractExtra(transaction.getRawData()));
|
||||
return node;
|
||||
}).collect(Collectors.toList()));
|
||||
|
||||
try {
|
||||
return objectMapper.writeValueAsString(payload);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new BusinessException("构建 AI 提示词失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模型返回的 JSON,并校验分类与标签的合法性。
|
||||
*
|
||||
* @param content 模型输出内容
|
||||
* @param transactions 参与分析的记录列表
|
||||
* @param categories 用户分类列表
|
||||
* @param tags 用户标签列表
|
||||
* @return 有效建议列表
|
||||
*/
|
||||
private List<AiSuggestionVO> parseSuggestions(String content,
|
||||
List<Transaction> transactions,
|
||||
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("suggestions");
|
||||
if (!array.isArray()) {
|
||||
throw new BusinessException("AI 返回格式不正确,请重试");
|
||||
}
|
||||
|
||||
Map<Long, Transaction> transactionMap = transactions.stream()
|
||||
.collect(Collectors.toMap(Transaction::getId, Function.identity()));
|
||||
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<AiSuggestionVO> result = new ArrayList<>();
|
||||
for (JsonNode node : array) {
|
||||
long id = node.path("id").asLong(0);
|
||||
Transaction transaction = transactionMap.get(id);
|
||||
if (transaction == null) {
|
||||
continue;
|
||||
}
|
||||
AiSuggestionVO vo = new AiSuggestionVO();
|
||||
vo.setTransactionId(id);
|
||||
|
||||
// 分类必须存在且与记录类型一致
|
||||
long categoryId = node.path("categoryId").asLong(0);
|
||||
Category category = categoryMap.get(categoryId);
|
||||
if (category != null && category.getType().equals(transaction.getType())) {
|
||||
vo.setCategoryId(category.getId());
|
||||
vo.setCategoryName(category.getName());
|
||||
}
|
||||
|
||||
// 标签过滤为属于当前用户的标签
|
||||
List<Long> tagIds = new ArrayList<>();
|
||||
List<String> tagNames = new ArrayList<>();
|
||||
JsonNode tagNode = node.path("tagIds");
|
||||
if (tagNode.isArray()) {
|
||||
for (JsonNode item : tagNode) {
|
||||
Tag tag = tagMap.get(item.asLong(0));
|
||||
if (tag != null && !tagIds.contains(tag.getId())) {
|
||||
tagIds.add(tag.getId());
|
||||
tagNames.add(tag.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
vo.setTagIds(tagIds);
|
||||
vo.setTagNames(tagNames);
|
||||
vo.setReason(node.path("reason").asText(""));
|
||||
|
||||
// 至少存在一项有效建议才返回
|
||||
if (vo.getCategoryId() != null || !tagIds.isEmpty()) {
|
||||
result.add(vo);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 去除模型可能附加的 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 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否为空白。
|
||||
*
|
||||
* @param value 待判断字符串
|
||||
* @return 为 null 或全空白时返回 true
|
||||
*/
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package cn.rosercode.finance.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import cn.rosercode.finance.client.DeepSeekResponse;
|
||||
import cn.rosercode.finance.entity.AiUsage;
|
||||
import cn.rosercode.finance.enums.AiCallType;
|
||||
import cn.rosercode.finance.mapper.AiUsageMapper;
|
||||
import cn.rosercode.finance.service.AiUsageService;
|
||||
import cn.rosercode.finance.vo.AiUsageResultVO;
|
||||
import cn.rosercode.finance.vo.AiUsageSummaryVO;
|
||||
import cn.rosercode.finance.vo.AiUsageVO;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* AI 使用记录服务实现类,负责写入调用明细并汇总查询。
|
||||
*
|
||||
* @date 2026-09-17 18:02:23
|
||||
*/
|
||||
@Service
|
||||
public class AiUsageServiceImpl implements AiUsageService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AiUsageServiceImpl.class);
|
||||
|
||||
/** 最近明细条数上限 */
|
||||
private static final int MAX_RECENT_LIMIT = 100;
|
||||
|
||||
/** 失败原因最大长度 */
|
||||
private static final int ERROR_MESSAGE_LIMIT = 500;
|
||||
|
||||
private final AiUsageMapper aiUsageMapper;
|
||||
|
||||
public AiUsageServiceImpl(AiUsageMapper aiUsageMapper) {
|
||||
this.aiUsageMapper = aiUsageMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 记录写入失败仅打印告警,不影响主流程。
|
||||
*/
|
||||
@Override
|
||||
public void recordSuccess(Long userId, AiCallType type, String model, int transactionCount,
|
||||
DeepSeekResponse response, long durationMs) {
|
||||
AiUsage usage = new AiUsage();
|
||||
usage.setUserId(userId);
|
||||
usage.setType(type.name());
|
||||
usage.setModel(model);
|
||||
usage.setTransactionCount(transactionCount);
|
||||
usage.setPromptTokens(response != null ? response.getPromptTokens() : 0);
|
||||
usage.setCompletionTokens(response != null ? response.getCompletionTokens() : 0);
|
||||
usage.setTotalTokens(response != null ? response.getTotalTokens() : 0);
|
||||
usage.setCachedTokens(response != null ? response.getCachedTokens() : 0);
|
||||
usage.setSuccess(true);
|
||||
usage.setDurationMs(durationMs);
|
||||
saveQuietly(usage);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public void recordFailure(Long userId, AiCallType type, String model, int transactionCount,
|
||||
long durationMs, String errorMessage) {
|
||||
AiUsage usage = new AiUsage();
|
||||
usage.setUserId(userId);
|
||||
usage.setType(type.name());
|
||||
usage.setModel(model);
|
||||
usage.setTransactionCount(transactionCount);
|
||||
usage.setPromptTokens(0);
|
||||
usage.setCompletionTokens(0);
|
||||
usage.setTotalTokens(0);
|
||||
usage.setCachedTokens(0);
|
||||
usage.setSuccess(false);
|
||||
usage.setErrorMessage(truncate(errorMessage));
|
||||
usage.setDurationMs(durationMs);
|
||||
saveQuietly(usage);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public AiUsageResultVO getUsage(Long userId, int recentLimit) {
|
||||
int limit = Math.min(Math.max(recentLimit, 1), MAX_RECENT_LIMIT);
|
||||
AiUsageSummaryVO summary = aiUsageMapper.selectSummary(userId);
|
||||
if (summary == null) {
|
||||
summary = emptySummary();
|
||||
}
|
||||
|
||||
List<AiUsageVO> recent = aiUsageMapper.selectList(new LambdaQueryWrapper<AiUsage>()
|
||||
.eq(AiUsage::getUserId, userId)
|
||||
.orderByDesc(AiUsage::getCreatedAt)
|
||||
.orderByDesc(AiUsage::getId)
|
||||
.last("LIMIT " + limit))
|
||||
.stream().map(this::toVO).collect(Collectors.toList());
|
||||
|
||||
AiUsageResultVO result = new AiUsageResultVO();
|
||||
result.setSummary(summary);
|
||||
result.setRecent(recent);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安静地保存使用记录,失败时仅打印告警。
|
||||
*
|
||||
* @param usage 使用记录实体
|
||||
*/
|
||||
private void saveQuietly(AiUsage usage) {
|
||||
try {
|
||||
aiUsageMapper.insert(usage);
|
||||
} catch (Exception e) {
|
||||
log.warn("记录 AI 使用情况失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造全 0 的汇总对象。
|
||||
*
|
||||
* @return 空的汇总对象
|
||||
*/
|
||||
private AiUsageSummaryVO emptySummary() {
|
||||
AiUsageSummaryVO summary = new AiUsageSummaryVO();
|
||||
summary.setTotalCalls(0L);
|
||||
summary.setSuccessCalls(0L);
|
||||
summary.setFailedCalls(0L);
|
||||
summary.setPromptTokens(0L);
|
||||
summary.setCompletionTokens(0L);
|
||||
summary.setTotalTokens(0L);
|
||||
summary.setCachedTokens(0L);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将使用记录实体转换为视图对象。
|
||||
*
|
||||
* @param usage 使用记录实体
|
||||
* @return 使用明细视图对象
|
||||
*/
|
||||
private AiUsageVO toVO(AiUsage usage) {
|
||||
AiUsageVO vo = new AiUsageVO();
|
||||
vo.setId(usage.getId());
|
||||
vo.setType(usage.getType());
|
||||
vo.setModel(usage.getModel());
|
||||
vo.setTransactionCount(usage.getTransactionCount());
|
||||
vo.setPromptTokens(usage.getPromptTokens());
|
||||
vo.setCompletionTokens(usage.getCompletionTokens());
|
||||
vo.setTotalTokens(usage.getTotalTokens());
|
||||
vo.setCachedTokens(usage.getCachedTokens());
|
||||
vo.setSuccess(usage.getSuccess());
|
||||
vo.setErrorMessage(usage.getErrorMessage());
|
||||
vo.setDurationMs(usage.getDurationMs());
|
||||
vo.setCreatedAt(usage.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断过长的失败原因。
|
||||
*
|
||||
* @param value 原始文本
|
||||
* @return 截断后的文本
|
||||
*/
|
||||
private String truncate(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return value.length() <= ERROR_MESSAGE_LIMIT ? value : value.substring(0, ERROR_MESSAGE_LIMIT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package cn.rosercode.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* AI 配置视图对象,用于向前端返回配置信息(API Key 脱敏)。
|
||||
*
|
||||
* @date 2026-09-17 17:52:00
|
||||
*/
|
||||
@Data
|
||||
public class AiConfigVO {
|
||||
|
||||
/** 是否启用 AI 智能建议 */
|
||||
private Boolean enabled;
|
||||
|
||||
/** 脱敏后的 API Key,未配置时为 null */
|
||||
private String apiKey;
|
||||
|
||||
/** 是否已配置 API Key */
|
||||
private Boolean hasApiKey;
|
||||
|
||||
/** API 基础地址 */
|
||||
private String baseUrl;
|
||||
|
||||
/** 使用的模型名称 */
|
||||
private String model;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package cn.rosercode.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AI 自然语言查询解析结果视图对象,字段与交易查询条件对应。
|
||||
*
|
||||
* @date 2026-09-17 18:13:07
|
||||
*/
|
||||
@Data
|
||||
public class AiQueryResultVO {
|
||||
|
||||
/** 解析摘要,用于向用户说明识别到的查询意图 */
|
||||
private String summary;
|
||||
|
||||
/** 收支类型:INCOME / EXPENSE */
|
||||
private List<String> types;
|
||||
|
||||
/** 命中的分类ID列表 */
|
||||
private List<Long> categoryIds;
|
||||
|
||||
/** 命中的分类名称列表 */
|
||||
private List<String> categoryNames;
|
||||
|
||||
/** 是否筛选未分类记录 */
|
||||
private Boolean uncategorized;
|
||||
|
||||
/** 支付方式列表 */
|
||||
private List<String> paymentMethods;
|
||||
|
||||
/** 开始时间(yyyy-MM-ddTHH:mm:ss) */
|
||||
private String startTime;
|
||||
|
||||
/** 结束时间(yyyy-MM-ddTHH:mm:ss) */
|
||||
private String endTime;
|
||||
|
||||
/** 是否计入统计 */
|
||||
private Boolean includeInStats;
|
||||
|
||||
/** 最小金额 */
|
||||
private BigDecimal minAmount;
|
||||
|
||||
/** 最大金额 */
|
||||
private BigDecimal maxAmount;
|
||||
|
||||
/** 备注关键字 */
|
||||
private String remark;
|
||||
|
||||
/** 命中的标签ID列表 */
|
||||
private List<Long> tagIds;
|
||||
|
||||
/** 命中的标签名称列表 */
|
||||
private List<String> tagNames;
|
||||
|
||||
/** 未能识别的关键词,供用户参考 */
|
||||
private List<String> unmatched;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package cn.rosercode.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AI 自然语言记账解析结果视图对象,字段与交易记录表单对应。
|
||||
*
|
||||
* @date 2026-09-17 18:23:11
|
||||
*/
|
||||
@Data
|
||||
public class AiRecordResultVO {
|
||||
|
||||
/** 解析摘要,用于向用户说明识别到的记账内容 */
|
||||
private String summary;
|
||||
|
||||
/** 收支类型:INCOME / EXPENSE */
|
||||
private String type;
|
||||
|
||||
/** 金额 */
|
||||
private BigDecimal amount;
|
||||
|
||||
/** 命中的分类ID,未命中为 null */
|
||||
private Long categoryId;
|
||||
|
||||
/** 命中的分类名称 */
|
||||
private String categoryName;
|
||||
|
||||
/** 支付方式 */
|
||||
private String paymentMethod;
|
||||
|
||||
/** 交易时间(yyyy-MM-ddTHH:mm:ss) */
|
||||
private String transactionTime;
|
||||
|
||||
/** 备注 */
|
||||
private String remark;
|
||||
|
||||
/** 是否计入统计 */
|
||||
private Boolean includeInStats;
|
||||
|
||||
/** 命中的标签ID列表 */
|
||||
private List<Long> tagIds;
|
||||
|
||||
/** 命中的标签名称列表 */
|
||||
private List<String> tagNames;
|
||||
|
||||
/** 未能识别的关键词 */
|
||||
private List<String> unmatched;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package cn.rosercode.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AI 建议视图对象,描述某条交易记录的分类与标签建议。
|
||||
*
|
||||
* @date 2026-09-17 17:52:01
|
||||
*/
|
||||
@Data
|
||||
public class AiSuggestionVO {
|
||||
|
||||
/** 交易记录ID */
|
||||
private Long transactionId;
|
||||
|
||||
/** 建议分类ID,为 null 表示无分类建议 */
|
||||
private Long categoryId;
|
||||
|
||||
/** 建议分类名称 */
|
||||
private String categoryName;
|
||||
|
||||
/** 建议标签ID列表 */
|
||||
private List<Long> tagIds;
|
||||
|
||||
/** 建议标签名称列表 */
|
||||
private List<String> tagNames;
|
||||
|
||||
/** 建议理由 */
|
||||
private String reason;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package cn.rosercode.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AI 使用情况视图对象,包含累计汇总与最近调用明细。
|
||||
*
|
||||
* @date 2026-09-17 18:02:14
|
||||
*/
|
||||
@Data
|
||||
public class AiUsageResultVO {
|
||||
|
||||
/** 累计汇总 */
|
||||
private AiUsageSummaryVO summary;
|
||||
|
||||
/** 最近调用明细(按时间倒序) */
|
||||
private List<AiUsageVO> recent;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cn.rosercode.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* AI 使用汇总视图对象,展示累计调用次数与 token 用量。
|
||||
*
|
||||
* @date 2026-09-17 18:02:13
|
||||
*/
|
||||
@Data
|
||||
public class AiUsageSummaryVO {
|
||||
|
||||
/** 累计调用次数 */
|
||||
private Long totalCalls;
|
||||
|
||||
/** 成功次数 */
|
||||
private Long successCalls;
|
||||
|
||||
/** 失败次数 */
|
||||
private Long failedCalls;
|
||||
|
||||
/** 累计输入 token 数 */
|
||||
private Long promptTokens;
|
||||
|
||||
/** 累计输出 token 数 */
|
||||
private Long completionTokens;
|
||||
|
||||
/** 累计总 token 数 */
|
||||
private Long totalTokens;
|
||||
|
||||
/** 累计缓存命中 token 数 */
|
||||
private Long cachedTokens;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package cn.rosercode.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* AI 使用明细视图对象,描述一次调用的用量与结果。
|
||||
*
|
||||
* @date 2026-09-17 18:02:13
|
||||
*/
|
||||
@Data
|
||||
public class AiUsageVO {
|
||||
|
||||
/** 记录ID */
|
||||
private Long id;
|
||||
|
||||
/** 调用类型:SUGGEST-生成建议,TEST-测试连接 */
|
||||
private String type;
|
||||
|
||||
/** 模型名称 */
|
||||
private String model;
|
||||
|
||||
/** 本次分析的记录数 */
|
||||
private Integer transactionCount;
|
||||
|
||||
/** 输入 token 数 */
|
||||
private Integer promptTokens;
|
||||
|
||||
/** 输出 token 数 */
|
||||
private Integer completionTokens;
|
||||
|
||||
/** 总 token 数 */
|
||||
private Integer totalTokens;
|
||||
|
||||
/** 缓存命中的输入 token 数 */
|
||||
private Integer cachedTokens;
|
||||
|
||||
/** 是否成功 */
|
||||
private Boolean success;
|
||||
|
||||
/** 失败原因 */
|
||||
private String errorMessage;
|
||||
|
||||
/** 调用耗时(毫秒) */
|
||||
private Long durationMs;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -65,3 +65,34 @@ CREATE TABLE IF NOT EXISTS t_transaction_tag (
|
||||
UNIQUE INDEX uk_transaction_tag (transaction_id, tag_id) COMMENT '交易+标签唯一索引',
|
||||
INDEX idx_tag_id (tag_id) COMMENT '标签ID索引'
|
||||
);
|
||||
|
||||
-- AI 配置表:存储每个用户的 AI 智能建议相关配置
|
||||
CREATE TABLE IF NOT EXISTS t_ai_config (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '配置ID,主键自增',
|
||||
user_id BIGINT NOT NULL COMMENT '所属用户ID',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用AI智能建议:1-启用,0-关闭',
|
||||
api_key VARCHAR(255) COMMENT 'DeepSeek API Key(明文存储)',
|
||||
base_url VARCHAR(255) NOT NULL DEFAULT 'https://api.deepseek.com' COMMENT 'API 基础地址',
|
||||
model VARCHAR(50) NOT NULL DEFAULT 'deepseek-chat' COMMENT '使用的模型名称',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE INDEX uk_user_id (user_id) COMMENT '用户ID唯一索引,每个用户一条配置'
|
||||
);
|
||||
|
||||
-- AI 使用记录表:记录每次调用 DeepSeek 的用量与结果
|
||||
CREATE TABLE IF NOT EXISTS t_ai_usage (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID,主键自增',
|
||||
user_id BIGINT NOT NULL COMMENT '所属用户ID',
|
||||
type VARCHAR(20) NOT NULL COMMENT '调用类型:SUGGEST-生成建议,TEST-测试连接',
|
||||
model VARCHAR(50) COMMENT '模型名称',
|
||||
transaction_count INT NOT NULL DEFAULT 0 COMMENT '本次分析的记录数',
|
||||
prompt_tokens INT NOT NULL DEFAULT 0 COMMENT '输入 token 数',
|
||||
completion_tokens INT NOT NULL DEFAULT 0 COMMENT '输出 token 数',
|
||||
total_tokens INT NOT NULL DEFAULT 0 COMMENT '总 token 数',
|
||||
cached_tokens INT NOT NULL DEFAULT 0 COMMENT '缓存命中的输入 token 数',
|
||||
success TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否成功:1-成功,0-失败',
|
||||
error_message VARCHAR(500) COMMENT '失败原因',
|
||||
duration_ms BIGINT COMMENT '调用耗时(毫秒)',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
INDEX idx_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引'
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user