diff --git a/frontend/src/api/ai.ts b/frontend/src/api/ai.ts index 726124c..3a43562 100644 --- a/frontend/src/api/ai.ts +++ b/frontend/src/api/ai.ts @@ -91,6 +91,21 @@ export function parseChannelByAi(text: string) { return request.post('/ai/channel', { text }, { timeout: 60000 }) } +/** AI 生成的账单关系名称与备注建议 */ +export interface AiRelationResult { + name: string + remark: string + reason: string +} + +/** 根据关系类型与成员账单特征生成关系名称与备注(调用大模型,超时放宽) */ +export function parseRelationByAi(data: { + type: string + members: { type: string; channel?: string; categoryName?: string; remark?: string }[] +}) { + return request.post('/ai/relation', data, { timeout: 60000 }) +} + /** 根据表单当前内容生成单条分类与标签建议(调用大模型,超时放宽) */ export function suggestOneByAi(data: { type: string; amount?: number | null; channel?: string; remark?: string }) { return request.post('/ai/suggest-one', data, { timeout: 60000 }) diff --git a/frontend/src/api/transactionGroup.ts b/frontend/src/api/transactionGroup.ts new file mode 100644 index 0000000..786ccfe --- /dev/null +++ b/frontend/src/api/transactionGroup.ts @@ -0,0 +1,86 @@ +// 账单关系相关 API 接口:账单之间父子/同级关系的查询与维护 +import request from './request' + +/** 关系组内角色:PARENT-父账单,CHILD-子账单,MEMBER-成员 */ +export type TransactionGroupRole = 'PARENT' | 'CHILD' | 'MEMBER' + +/** 关系类型:PARENT_CHILD-父子,SIBLING-同级 */ +export type TransactionRelationType = 'PARENT_CHILD' | 'SIBLING' + +/** 关系组成员(含关联账单的展示快照) */ +export interface TransactionGroupItem { + transactionId: number + role: TransactionGroupRole + sortOrder: number + type: string + amount: number + channel: string + remark: string + remarkHidden: boolean + transactionTime: string + categoryName: string + categoryIcon: string +} + +/** 账单关系组 */ +export interface TransactionGroup { + id: number + type: TransactionRelationType + name: string + remark: string + createdAt: string + updatedAt: string + items: TransactionGroupItem[] +} + +/** 关系组成员提交项 */ +export interface TransactionGroupItemPayload { + transactionId: number + role?: TransactionGroupRole + sortOrder?: number +} + +/** 关系组成员草稿(前端编辑用,含账单展示快照) */ +export interface RelationMemberDraft { + transactionId: number + type: string + amount: number + channel: string + remark: string + transactionTime: string + categoryName: string + categoryIcon: string +} + +/** 关系组提交数据 */ +export interface TransactionGroupPayload { + type: TransactionRelationType + name?: string + remark?: string + items: TransactionGroupItemPayload[] +} + +/** 查询某笔账单所属的全部关系组 */ +export function getGroupsByTransaction(transactionId: number) { + return request.get(`/transaction/group/by-transaction/${transactionId}`) +} + +/** 查询关系名称历史(可选按关系类型过滤),返回按最近使用排序的名称列表 */ +export function getGroupNames(type?: string) { + return request.get('/transaction/group/names', { params: type ? { type } : {} }) +} + +/** 创建账单关系组,返回新建关系组ID */ +export function createTransactionGroup(data: TransactionGroupPayload) { + return request.post('/transaction/group', data) +} + +/** 更新账单关系组 */ +export function updateTransactionGroup(id: number, data: TransactionGroupPayload) { + return request.put(`/transaction/group/${id}`, data) +} + +/** 删除账单关系组(仅解除关联,不删除账单) */ +export function deleteTransactionGroup(id: number) { + return request.delete(`/transaction/group/${id}`) +} diff --git a/frontend/src/components/TransactionDetailModal.vue b/frontend/src/components/TransactionDetailModal.vue new file mode 100644 index 0000000..a9777e6 --- /dev/null +++ b/frontend/src/components/TransactionDetailModal.vue @@ -0,0 +1,260 @@ + + + + + diff --git a/frontend/src/components/TransactionRelationEditorModal.vue b/frontend/src/components/TransactionRelationEditorModal.vue new file mode 100644 index 0000000..fc45049 --- /dev/null +++ b/frontend/src/components/TransactionRelationEditorModal.vue @@ -0,0 +1,443 @@ + + + diff --git a/frontend/src/components/TransactionRelationModal.vue b/frontend/src/components/TransactionRelationModal.vue new file mode 100644 index 0000000..0d7d027 --- /dev/null +++ b/frontend/src/components/TransactionRelationModal.vue @@ -0,0 +1,47 @@ + + + diff --git a/frontend/src/components/TransactionRelationPanel.vue b/frontend/src/components/TransactionRelationPanel.vue new file mode 100644 index 0000000..263f8ee --- /dev/null +++ b/frontend/src/components/TransactionRelationPanel.vue @@ -0,0 +1,472 @@ + + + + + diff --git a/frontend/src/locales/messages.ts b/frontend/src/locales/messages.ts index ec242fc..a3066d8 100644 --- a/frontend/src/locales/messages.ts +++ b/frontend/src/locales/messages.ts @@ -369,6 +369,8 @@ export default { amountAsc: { zh: '金额(低→高)', en: 'Amount (Low to High)', ja: '金額(低い順)' }, }, batchSet: { zh: '一键设置', en: 'Batch Set', ja: '一括設定' }, + batchMode: { zh: '批量操作', en: 'Batch', ja: '一括操作' }, + exitBatchMode: { zh: '退出批量', en: 'Exit Batch', ja: '一括終了' }, importBill: { zh: '导入账单', en: 'Import Bill', ja: '明細をインポート' }, exportExcel: { zh: '导出 Excel', en: 'Export Excel', ja: 'Excel をエクスポート' }, addRecord: { zh: '新增记录', en: 'Add Record', ja: '記録を追加' }, @@ -462,6 +464,49 @@ export default { yes: { zh: '计入', en: 'Included', ja: '含める' }, no: { zh: '不计入', en: 'Excluded', ja: '含めない' }, }, + relation: { + column: { zh: '关系', en: 'Relation', ja: '関連' }, + viewCount: { zh: '{count} 个关系', en: '{count} relation(s)', ja: '{count} 件の関連' }, + none: { zh: '-', en: '-', ja: '-' }, + createFromSelection: { zh: '建立关系', en: 'Create Relation', ja: '関連を作成' }, + selectAtLeastTwo: { zh: '请至少勾选两条账单', en: 'Please select at least two bills', ja: '明細を2件以上選択してください' }, + title: { zh: '账单关系', en: 'Bill Relations', ja: '明細の関連' }, + create: { zh: '新建关系', en: 'New Relation', ja: '関連を作成' }, + createTitle: { zh: '新建账单关系', en: 'New Bill Relation', ja: '明細の関連を作成' }, + editTitle: { zh: '编辑账单关系', en: 'Edit Bill Relation', ja: '明細の関連を編集' }, + empty: { zh: '暂无关联账单', en: 'No related bills', ja: '関連する明細はありません' }, + type: { zh: '关系类型', en: 'Relation Type', ja: '関連タイプ' }, + typeParentChild: { zh: '父子关系', en: 'Parent-Child', ja: '親子関係' }, + typeSibling: { zh: '同级关系', en: 'Sibling', ja: '同級関係' }, + name: { zh: '关系名称', en: 'Relation Name', ja: '関連名' }, + namePlaceholder: { zh: '可选,如「交通卡充值」', en: 'Optional, e.g. "Transit card top-up"', ja: '任意(例:交通カードのチャージ)' }, + remark: { zh: '备注', en: 'Remark', ja: '備考' }, + members: { zh: '关联账单', en: 'Related Bills', ja: '関連する明細' }, + addMember: { zh: '添加账单', en: 'Add Bill', ja: '明細を追加' }, + pickTitle: { zh: '选择关联账单', en: 'Select Bills', ja: '明細を選択' }, + roleParent: { zh: '父账单', en: 'Parent', ja: '親' }, + roleChild: { zh: '子账单', en: 'Child', ja: '子' }, + roleMember: { zh: '成员', en: 'Member', ja: 'メンバー' }, + edgeLabel: { zh: '关联', en: 'Linked', ja: '関連' }, + minTwo: { zh: '关系组至少包含两条账单', en: 'A relation group needs at least two bills', ja: '関連グループには最低2件の明細が必要です' }, + needParent: { zh: '请指定一条父账单', en: 'Please select one parent bill', ja: '親の明細を1件指定してください' }, + deleteConfirm: { zh: '确定删除该关系组?仅解除关联,不会删除账单', en: 'Delete this relation group? Only the relation is removed, not the bills', ja: 'この関連グループを削除しますか?明細は削除されません' }, + saveFailed: { zh: '保存账单关系失败', en: 'Failed to save the relation', ja: '関連の保存に失敗しました' }, + loadFailed: { zh: '加载账单关系失败', en: 'Failed to load relations', ja: '関連の読み込みに失敗しました' }, + deleteFailed: { zh: '删除账单关系失败', en: 'Failed to delete the relation', ja: '関連の削除に失敗しました' }, + }, + detail: { + button: { zh: '详情', en: 'Details', ja: '詳細' }, + title: { zh: '账单详情', en: 'Bill Details', ja: '明細の詳細' }, + basicInfo: { zh: '基本信息', en: 'Basic Info', ja: '基本情報' }, + relations: { zh: '账单关系', en: 'Bill Relations', ja: '明細の関連' }, + images: { zh: '来源图片', en: 'Source Images', ja: '元の画像' }, + rawData: { zh: '原始数据', en: 'Raw Data', ja: '元データ' }, + createdAt: { zh: '创建时间', en: 'Created', ja: '作成日時' }, + noTags: { zh: '无标签', en: 'No tags', ja: 'タグなし' }, + noImages: { zh: '无来源图片', en: 'No source images', ja: '元の画像はありません' }, + noRawData: { zh: '无原始数据', en: 'No raw data', ja: '元データはありません' }, + }, }, // 分类管理 @@ -735,6 +780,11 @@ export default { needInfo: { zh: '请先填写备注或金额,便于生成标签', en: 'Please fill in a remark or amount first', ja: 'タグ生成のため、先に備考または金額を入力してください' }, failed: { zh: 'AI 生成标签失败', en: 'Failed to generate tag', ja: 'AI によるタグ生成に失敗しました' }, }, + relation: { + button: { zh: 'AI 生成', en: 'AI Generate', ja: 'AI 生成' }, + needMembers: { zh: '请先选择至少两条关联账单', en: 'Please select at least two related bills first', ja: '関連する明細を2件以上選択してください' }, + failed: { zh: 'AI 未能生成关系名称,请重试', en: 'AI failed to generate a relation name', ja: 'AI が関連名を生成できませんでした' }, + }, query: { placeholder: { zh: '用自然语言查询,如:上个月餐饮花了多少', en: 'Ask in natural language, e.g. dining expenses last month', ja: '自然言語で検索(例:先月の食費はいくら)' }, examples: { zh: '上个月餐饮|超过500的支出|待报销的支出|微信支付', en: 'Dining last month|Expenses over 500|Pending reimbursement|Paid by WeChat', ja: '先月の食費|500を超える支出|精算待ちの支出|WeChat 払い' }, @@ -830,7 +880,15 @@ export default { suggest: { zh: '生成建议', en: 'Suggest', ja: '提案生成' }, decorate: { zh: '图标配色', en: 'Icon & Color', ja: 'アイコンと配色' }, category: { zh: '生成分类', en: 'Category', ja: 'カテゴリ生成' }, + tag: { zh: '生成标签', en: 'Tag', ja: 'タグ生成' }, + channel: { zh: '生成渠道', en: 'Channel', ja: 'チャネル生成' }, test: { zh: '测试连接', en: 'Test', ja: '接続テスト' }, + query: { zh: '智能查询', en: 'Query', ja: 'スマート検索' }, + record: { zh: '智能记账', en: 'Record', ja: 'スマート記帳' }, + record_image: { zh: '图片记账', en: 'Image Record', ja: '画像記帳' }, + rule: { zh: '生成规则', en: 'Rule', ja: 'ルール生成' }, + statistics: { zh: '统计搜索', en: 'Statistics', ja: '統計検索' }, + relation: { zh: '生成关系', en: 'Relation', ja: '関連生成' }, }, column: { time: { zh: '时间', en: 'Time', ja: '日時' }, diff --git a/frontend/src/views/TransactionList.vue b/frontend/src/views/TransactionList.vue index 32b34c0..3cde0bd 100644 --- a/frontend/src/views/TransactionList.vue +++ b/frontend/src/views/TransactionList.vue @@ -3,11 +3,11 @@ import { ref, onMounted, onBeforeUnmount, computed, h, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' -import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NDropdown, NUpload, NUploadDragger, NTabs, NTabPane, NSpin } from 'naive-ui' +import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NDropdown, NUpload, NUploadDragger, NTabs, NTabPane } from 'naive-ui' import { EyeOutline, EyeOffOutline, SparklesOutline, BulbOutline, RefreshOutline, CloudUploadOutline, CheckmarkCircleOutline } from '@vicons/ionicons5' import type { DataTableColumns, UploadFileInfo } from 'naive-ui' import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getChannels, batchDeleteTransactions, batchCreateTransactions } from '../api/transaction' -import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi, parseChannelByAi, getAiImageBlob } from '../api/ai' +import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi, parseChannelByAi } from '../api/ai' import { createChannel } from '../api/channel' import type { AiSuggestion, AiQueryResult, AiRecordResult, AiRecordImageResult } from '../api/ai' import { getExcludedDisplayStyle } from '../api/setting' @@ -18,6 +18,10 @@ import { renderCategoryLabel } from '../utils/render' import { useKeywordHistory } from '../composables/useKeywordHistory' import { openSse } from '../utils/sse' import BillImportModal from './BillImportModal.vue' +import TransactionDetailModal from '../components/TransactionDetailModal.vue' +import TransactionRelationEditorModal from '../components/TransactionRelationEditorModal.vue' +import TransactionRelationModal from '../components/TransactionRelationModal.vue' +import type { RelationMemberDraft } from '../api/transactionGroup' const message = useMessage() // 消息提示 const dialog = useDialog() // 对话框 @@ -41,6 +45,8 @@ interface TransactionItem { tags: TagOption[] /** 来源图片ID列表(图片记账生成时存在) */ imageIds: number[] + /** 所属关系组数量(父子/同级关系) */ + relationGroupCount: number createdAt: string } @@ -125,6 +131,12 @@ const excludedDisplayStyle = ref<'strikethrough' | 'muted'>('strikethrough') const submitting = ref(false) // 表单提交状态 const showModal = ref(false) // 新增/编辑弹窗显示状态 const showImportModal = ref(false) // 账单导入弹窗显示状态 +const showDetailModal = ref(false) // 账单详情弹窗显示状态 +const detailTransaction = ref(null) // 当前查看详情的账单 +const showRelationEditor = ref(false) // 勾选账单建立关系弹窗显示状态 +const relationEditorMembers = ref([]) // 建立关系时的预填成员 +const showRelationModal = ref(false) // 关系查看弹窗显示状态 +const relationTransaction = ref(null) // 当前查看关系的账单 const showBatchModal = ref(false) // 批量设置分类弹窗显示状态 const showAiModal = ref(false) // AI 建议弹窗显示状态 const aiEnabled = ref(false) // AI 建议是否启用 @@ -226,18 +238,6 @@ const checkedAiRecordKeys = ref([]) // 图片识别结果勾选的行 const aiRecordImporting = ref(false) // 图片记账导入状态 const aiRecordProgress = ref<{ time: string; text: string }[]>([]) // 图片识别进度日志(含接收时间) let aiRecordStreamController: AbortController | null = null // 图片识别 SSE 连接控制器 -const showImageViewModal = ref(false) // 来源图片查看弹窗显示状态 - -/** 来源图片查看项:默认加载缩略图,用户可按需切换为原图 */ -interface ImageViewItem { - id: number // 图片ID - url: string // 当前展示的 objectURL - isOriginal: boolean // 是否已加载原图 - loading: boolean // 原图加载状态 -} - -const imageViewItems = ref([]) // 来源图片列表 -const imageViewLoading = ref(false) // 来源图片加载状态 const showRawModal = ref(false) // 原始账单数据弹窗显示状态 const rawDataText = ref('') // 格式化后的原始账单数据文本 const batchExpenseCategoryId = ref(null) // 批量设置的支出分类 @@ -261,6 +261,7 @@ const categories = ref([]) // 分类选项列表 const allTags = ref([]) // 全部标签(用于筛选、编辑、批量) const revealedRemarkIds = ref([]) // 临时展开查看原文的备注行 ID(不持久,刷新/翻页后重置) const checkedTransactionIds = ref([]) // 表格多选选中的记录ID(用于批量删除) +const selectionMode = ref(false) // 批量选择模式:开启后才显示多选列 // 表单数据(新增/编辑共用) const form = ref({ @@ -509,8 +510,8 @@ function createColumns(): DataTableColumns { // 读取展示样式,使列配置随样式偏好变化而重新计算 const muted = excludedDisplayStyle.value === 'muted' return [ - // 多选列:用于批量操作(如批量删除) - { type: 'selection' }, + // 多选列:仅在批量选择模式下显示,默认不渲染复选框 + ...(selectionMode.value ? [{ type: 'selection' as const }] : []), { // 序号列:按分页计算全局序号 title: t('transaction.column.index'), key: 'index', width: 70, align: 'center', @@ -601,13 +602,13 @@ function createColumns(): DataTableColumns { }, }, { - title: t('transaction.sourceImages'), key: 'imageIds', width: 110, align: 'center', + title: t('transaction.relation.column'), key: 'relationGroupCount', width: 110, align: 'center', render(row) { - if (!row.imageIds || row.imageIds.length === 0) { - return h('span', { style: { color: '#ccc' } }, '-') + if (!row.relationGroupCount) { + return h('span', { style: { color: '#ccc' } }, t('transaction.relation.none')) } - return h(NButton, { size: 'small', quaternary: true, type: 'primary', onClick: () => openImageView(row.imageIds) }, - { default: () => t('transaction.viewImages', { count: row.imageIds.length }) }) + return h(NButton, { size: 'small', quaternary: true, type: 'info', onClick: () => openRelation(row) }, + { default: () => t('transaction.relation.viewCount', { count: row.relationGroupCount }) }) }, }, { @@ -615,8 +616,8 @@ function createColumns(): DataTableColumns { render(row) { return h(NSpace, null, { default: () => [ + h(NButton, { size: 'small', onClick: () => openDetail(row) }, { default: () => t('transaction.detail.button') }), h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }), - h(NButton, { size: 'small', disabled: !row.rawData, onClick: () => handleViewRaw(row) }, { default: () => t('transaction.rawData') }), h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => t('common.delete') }), ], }) @@ -711,18 +712,49 @@ watch(sortValue, () => { fetchData() }) -/** 查看某条记录的原始账单数据(格式化为缩进 JSON) */ -function handleViewRaw(row: TransactionItem) { - if (!row.rawData) { - message.warning(t('transaction.noRawData')) +/** 打开账单详情弹窗 */ +function openDetail(row: TransactionItem) { + detailTransaction.value = row + showDetailModal.value = true +} + +/** 打开账单关系弹窗 */ +function openRelation(row: TransactionItem) { + relationTransaction.value = row + showRelationModal.value = true +} + +/** 从详情弹窗进入编辑 */ +function handleDetailEdit() { + if (detailTransaction.value) { + showDetailModal.value = false + handleEdit(detailTransaction.value) + } +} + +/** 以表格勾选的账单为成员,打开建立关系弹窗 */ +function handleCreateRelation() { + if (checkedTransactionIds.value.length < 2) { + message.warning(t('transaction.relation.selectAtLeastTwo')) return } - try { - rawDataText.value = JSON.stringify(JSON.parse(row.rawData), null, 2) - } catch (e) { - rawDataText.value = row.rawData - } - showRawModal.value = true + const selected = transactions.value.filter((row) => checkedTransactionIds.value.includes(row.id)) + relationEditorMembers.value = selected.map((row) => ({ + transactionId: row.id, + type: row.type, + amount: row.amount, + channel: row.channel, + remark: row.remark, + transactionTime: row.transactionTime, + categoryName: row.categoryName, + categoryIcon: row.categoryIcon, + })) + showRelationEditor.value = true +} + +/** 建立关系保存成功后刷新列表(选择随刷新清空) */ +function onRelationEditorSaved() { + fetchData() } /** 查看图片记账结果行的原始数据(格式化为缩进 JSON) */ @@ -736,57 +768,6 @@ function handleViewAiRecordRaw(row: AiRecordEditRow) { showRawModal.value = true } -/** 从接口响应中提取图片 Blob(blob 响应经拦截器后返回完整 axios response,需取其 data) */ -function extractBlob(res: any): Blob { - return res?.data instanceof Blob ? res.data : (res instanceof Blob ? res : new Blob([res])) -} - -/** 查看交易记录的来源图片(默认加载缩略图,避免大图拖慢打开速度) */ -async function openImageView(imageIds: number[]) { - if (!imageIds || imageIds.length === 0) return - closeImageView() - showImageViewModal.value = true - imageViewLoading.value = true - try { - const items: ImageViewItem[] = [] - for (const id of imageIds) { - const res: any = await getAiImageBlob(id, true) - items.push({ id, url: URL.createObjectURL(extractBlob(res)), isOriginal: false, loading: false }) - } - imageViewItems.value = items - } catch (e: any) { - message.error(e.message || t('common.loadFailed')) - } finally { - imageViewLoading.value = false - } -} - -/** 将指定来源图片切换为原图(替换并释放旧缩略图 objectURL) */ -async function loadOriginalImage(item: ImageViewItem) { - if (item.isOriginal || item.loading) return - item.loading = true - try { - const res: any = await getAiImageBlob(item.id) - const url = URL.createObjectURL(extractBlob(res)) - URL.revokeObjectURL(item.url) - item.url = url - item.isOriginal = true - } catch (e: any) { - message.error(e.message || t('common.loadFailed')) - } finally { - item.loading = false - } -} - -/** 关闭来源图片弹窗并释放 objectURL */ -function closeImageView() { - for (const item of imageViewItems.value) { - URL.revokeObjectURL(item.url) - } - imageViewItems.value = [] - showImageViewModal.value = false -} - /** 打开新增记录弹窗,重置表单 */ function handleAdd() { editingId.value = null @@ -899,6 +880,17 @@ function handleBatchDelete() { }) } +/** 进入批量选择模式(显示多选列) */ +function enterSelectionMode() { + selectionMode.value = true +} + +/** 退出批量选择模式并清空已选 */ +function exitSelectionMode() { + selectionMode.value = false + checkedTransactionIds.value = [] +} + /** 翻页事件处理 */ function handlePageChange(p: number) { page.value = p @@ -2062,10 +2054,15 @@ onBeforeUnmount(() => { {{ t('transaction.sortLabel') }} + {{ t('transaction.batchMode') }} + {{ t('transaction.exitBatchMode') }} {{ t('transaction.batchSet') }} - + {{ t('transaction.batchDelete') }} + + {{ t('transaction.relation.createFromSelection') }} + {{ t('transaction.importBill') }} {{ t('channel.manageEntry') }} {{ t('transaction.exportExcel') }} @@ -2342,28 +2339,35 @@ onBeforeUnmount(() => { + + + + + + + + + - - - - -
- source image -
- {{ t('transaction.originalImage') }} - - {{ t('transaction.loadOriginal') }} - -
-
-
-
-
- @@ -2602,15 +2606,6 @@ onBeforeUnmount(() => { text-decoration: line-through; } /* 图片识别进度:浅色卡片 + 步骤列表 */ -.image-view-item { - display: flex; - flex-direction: column; - gap: 6px; -} -.image-view-actions { - display: flex; - justify-content: flex-end; -} .ai-progress { background: #f7f9fc; border: 1px solid #eef1f6; diff --git a/src/main/java/cn/rosercode/finance/controller/AiController.java b/src/main/java/cn/rosercode/finance/controller/AiController.java index 1fb1833..593e89e 100644 --- a/src/main/java/cn/rosercode/finance/controller/AiController.java +++ b/src/main/java/cn/rosercode/finance/controller/AiController.java @@ -28,6 +28,7 @@ import cn.rosercode.finance.dto.AiConfigRequest; import cn.rosercode.finance.dto.AiDecorateRequest; import cn.rosercode.finance.dto.AiQueryRequest; import cn.rosercode.finance.dto.AiRecordRequest; +import cn.rosercode.finance.dto.AiRelationRequest; import cn.rosercode.finance.dto.AiRuleApplyRequest; import cn.rosercode.finance.dto.AiRuleSuggestRequest; import cn.rosercode.finance.dto.AiSuggestOneRequest; @@ -44,6 +45,7 @@ import cn.rosercode.finance.service.AiImageRecordStreamService; import cn.rosercode.finance.service.AiImageStorage; import cn.rosercode.finance.service.AiQueryService; import cn.rosercode.finance.service.AiRecordService; +import cn.rosercode.finance.service.AiRelationService; import cn.rosercode.finance.service.AiRuleService; import cn.rosercode.finance.service.AiStatisticsService; import cn.rosercode.finance.service.AiSuggestionService; @@ -57,6 +59,7 @@ import cn.rosercode.finance.vo.AiConfigVO; import cn.rosercode.finance.vo.AiQueryResultVO; import cn.rosercode.finance.vo.AiRecordImageResultVO; import cn.rosercode.finance.vo.AiRecordResultVO; +import cn.rosercode.finance.vo.AiRelationResultVO; import cn.rosercode.finance.vo.AiRuleSuggestResultVO; import cn.rosercode.finance.vo.AiStatisticsResultVO; import cn.rosercode.finance.vo.AiSuggestionVO; @@ -91,6 +94,7 @@ public class AiController { private final AiStatisticsService aiStatisticsService; private final AiImageRecordService aiImageRecordService; private final AiImageRecordStreamService aiImageRecordStreamService; + private final AiRelationService aiRelationService; private final AiImageMapper aiImageMapper; private final AiImageStorage aiImageStorage; @@ -102,6 +106,7 @@ public class AiController { AiRuleService aiRuleService, AiStatisticsService aiStatisticsService, AiImageRecordService aiImageRecordService, AiImageRecordStreamService aiImageRecordStreamService, + AiRelationService aiRelationService, AiImageMapper aiImageMapper, AiImageStorage aiImageStorage) { this.aiConfigService = aiConfigService; this.aiSuggestionService = aiSuggestionService; @@ -116,6 +121,7 @@ public class AiController { this.aiStatisticsService = aiStatisticsService; this.aiImageRecordService = aiImageRecordService; this.aiImageRecordStreamService = aiImageRecordStreamService; + this.aiRelationService = aiRelationService; this.aiImageMapper = aiImageMapper; this.aiImageStorage = aiImageStorage; } @@ -360,6 +366,19 @@ public class AiController { return Result.success(aiChannelService.parse(userId, channelRequest.getText())); } + /** + * 根据关系类型与成员账单特征生成关系名称与备注 + * + * @param relationRequest 生成请求,携带关系类型与成员账单特征 + * @return 关系名称与备注建议 + */ + @PostMapping("/relation") + @Operation(summary = "AI 生成账单关系", description = "根据关系类型与成员账单特征生成关系名称与备注") + public Result generateRelation(@RequestBody @Valid AiRelationRequest relationRequest) { + Long userId = UserContext.getUserId(); + return Result.success(aiRelationService.parse(userId, relationRequest)); + } + /** * 根据分类名称推荐 Emoji 图标 * diff --git a/src/main/java/cn/rosercode/finance/controller/TransactionGroupController.java b/src/main/java/cn/rosercode/finance/controller/TransactionGroupController.java new file mode 100644 index 0000000..839acfc --- /dev/null +++ b/src/main/java/cn/rosercode/finance/controller/TransactionGroupController.java @@ -0,0 +1,108 @@ +package cn.rosercode.finance.controller; + +import java.util.List; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import cn.rosercode.finance.common.Result; +import cn.rosercode.finance.dto.TransactionGroupRequest; +import cn.rosercode.finance.service.TransactionGroupService; +import cn.rosercode.finance.util.UserContext; +import cn.rosercode.finance.vo.TransactionGroupVO; + +/** + * 账单关系组控制器 + * + *

提供账单之间父子/同级关系的查询、创建、更新与删除接口

+ * + * @date 2026-09-21 20:32:41 + */ +@RestController +@RequestMapping("/api/transaction/group") +@Tag(name = "账单关系管理", description = "账单之间父子/同级关系的查询与维护接口") +public class TransactionGroupController { + + private final TransactionGroupService transactionGroupService; + + public TransactionGroupController(TransactionGroupService transactionGroupService) { + this.transactionGroupService = transactionGroupService; + } + + /** + * 查询某笔账单所属的全部关系组 + * + * @param transactionId 交易记录ID + * @return 关系组列表 + */ + @GetMapping("/by-transaction/{transactionId}") + @Operation(summary = "查询账单关系", description = "返回指定账单所属的父子/同级关系组及成员账单") + public Result> listByTransaction( + @Parameter(description = "交易记录ID") @PathVariable Long transactionId) { + return Result.success(transactionGroupService.listByTransaction(UserContext.getUserId(), transactionId)); + } + + /** + * 查询当前用户使用过的关系名称历史 + * + * @param type 关系类型过滤,可选 + * @return 关系名称列表 + */ + @GetMapping("/names") + @Operation(summary = "查询关系名称历史", description = "返回当前用户使用过的账单关系名称,按最近使用排序,可按关系类型过滤") + public Result> names( + @Parameter(description = "关系类型过滤,可选") @RequestParam(value = "type", required = false) String type) { + return Result.success(transactionGroupService.listNames(UserContext.getUserId(), type)); + } + + /** + * 创建关系组 + * + * @param request 关系组请求参数 + * @return 新建关系组ID + */ + @PostMapping + @Operation(summary = "创建账单关系", description = "将多笔账单按父子或同级关系聚合为一个关系组") + public Result create(@RequestBody @Valid TransactionGroupRequest request) { + return Result.success(transactionGroupService.create(UserContext.getUserId(), request)); + } + + /** + * 更新关系组 + * + * @param id 关系组ID + * @param request 关系组请求参数 + * @return 操作结果 + */ + @PutMapping("/{id}") + @Operation(summary = "更新账单关系", description = "更新关系组的名称、备注、成员与角色") + public Result update(@Parameter(description = "关系组ID") @PathVariable Long id, + @RequestBody @Valid TransactionGroupRequest request) { + transactionGroupService.update(UserContext.getUserId(), id, request); + return Result.success(); + } + + /** + * 删除关系组 + * + * @param id 关系组ID + * @return 操作结果 + */ + @DeleteMapping("/{id}") + @Operation(summary = "删除账单关系", description = "删除关系组,仅解除账单关联,不删除账单") + public Result delete(@Parameter(description = "关系组ID") @PathVariable Long id) { + transactionGroupService.delete(UserContext.getUserId(), id); + return Result.success(); + } +} diff --git a/src/main/java/cn/rosercode/finance/converter/VoConverter.java b/src/main/java/cn/rosercode/finance/converter/VoConverter.java index b910981..1b36bf5 100644 --- a/src/main/java/cn/rosercode/finance/converter/VoConverter.java +++ b/src/main/java/cn/rosercode/finance/converter/VoConverter.java @@ -18,6 +18,8 @@ import cn.rosercode.finance.entity.NotificationLog; import cn.rosercode.finance.entity.Rule; import cn.rosercode.finance.entity.Tag; import cn.rosercode.finance.entity.Transaction; +import cn.rosercode.finance.entity.TransactionGroup; +import cn.rosercode.finance.entity.TransactionGroupItem; import cn.rosercode.finance.entity.User; import cn.rosercode.finance.vo.AiUsageVO; import cn.rosercode.finance.vo.CategoryVO; @@ -30,6 +32,8 @@ import cn.rosercode.finance.vo.NotificationChannelVO; import cn.rosercode.finance.vo.NotificationLogVO; import cn.rosercode.finance.vo.RuleVO; import cn.rosercode.finance.vo.TagVO; +import cn.rosercode.finance.vo.TransactionGroupItemVO; +import cn.rosercode.finance.vo.TransactionGroupVO; import cn.rosercode.finance.vo.TransactionVO; import cn.rosercode.finance.vo.UserVO; @@ -129,6 +133,27 @@ public interface VoConverter { @Mapping(target = "tags", ignore = true) TransactionVO toTransactionVO(Transaction transaction, Category category); + /** + * 账单关系组实体转视图对象。 + * + *

成员列表由调用方另行填充。

+ * + * @param group 关系组实体 + * @return 关系组视图对象 + */ + @Mapping(target = "items", ignore = true) + TransactionGroupVO toTransactionGroupVO(TransactionGroup group); + + /** + * 账单关系组成员实体转视图对象。 + * + *

仅映射成员角色与排序,关联账单的展示快照由调用方另行填充。

+ * + * @param item 关系组成员实体 + * @return 关系组成员视图对象 + */ + TransactionGroupItemVO toTransactionGroupItemVO(TransactionGroupItem item); + /** * 通知渠道实体转视图对象。 * diff --git a/src/main/java/cn/rosercode/finance/dto/AiRelationMemberRequest.java b/src/main/java/cn/rosercode/finance/dto/AiRelationMemberRequest.java new file mode 100644 index 0000000..22e5907 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/dto/AiRelationMemberRequest.java @@ -0,0 +1,26 @@ +package cn.rosercode.finance.dto; + +import lombok.Data; + +/** + * AI 生成账单关系请求的成员账单特征 DTO。 + * + *

仅承载收支类型、交易渠道、分类名称与备注,禁止携带金额、交易号、原始数据等敏感字段。

+ * + * @date 2026-09-21 21:08:21 + */ +@Data +public class AiRelationMemberRequest { + + /** 收支类型:INCOME-收入,EXPENSE-支出 */ + private String type; + + /** 交易渠道 */ + private String channel; + + /** 分类名称 */ + private String categoryName; + + /** 备注 */ + private String remark; +} diff --git a/src/main/java/cn/rosercode/finance/dto/AiRelationRequest.java b/src/main/java/cn/rosercode/finance/dto/AiRelationRequest.java new file mode 100644 index 0000000..b200497 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/dto/AiRelationRequest.java @@ -0,0 +1,26 @@ +package cn.rosercode.finance.dto; + +import java.util.List; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +/** + * AI 生成账单关系名称与备注请求 DTO,携带关系类型与成员账单特征。 + * + * @date 2026-09-21 21:08:21 + */ +@Data +public class AiRelationRequest { + + /** 关系类型:PARENT_CHILD-父子,SIBLING-同级 */ + @NotBlank(message = "关系类型不能为空") + private String type; + + /** 成员账单特征列表,至少两条 */ + @NotEmpty(message = "请先选择关联账单") + @Valid + private List members; +} diff --git a/src/main/java/cn/rosercode/finance/dto/TransactionGroupItemRequest.java b/src/main/java/cn/rosercode/finance/dto/TransactionGroupItemRequest.java new file mode 100644 index 0000000..06c2a4f --- /dev/null +++ b/src/main/java/cn/rosercode/finance/dto/TransactionGroupItemRequest.java @@ -0,0 +1,23 @@ +package cn.rosercode.finance.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 账单关系组成员请求DTO,用于指定关系组中的一笔账单及其角色。 + * + * @date 2026-09-21 20:32:41 + */ +@Data +public class TransactionGroupItemRequest { + + /** 交易记录ID,不能为空 */ + @NotNull(message = "关联账单不能为空") + private Long transactionId; + + /** 组内角色:PARENT-父,CHILD-子,MEMBER-成员 */ + private String role; + + /** 排序序号,用于同级链路顺序 */ + private Integer sortOrder; +} diff --git a/src/main/java/cn/rosercode/finance/dto/TransactionGroupRequest.java b/src/main/java/cn/rosercode/finance/dto/TransactionGroupRequest.java new file mode 100644 index 0000000..44df13e --- /dev/null +++ b/src/main/java/cn/rosercode/finance/dto/TransactionGroupRequest.java @@ -0,0 +1,35 @@ +package cn.rosercode.finance.dto; + +import java.util.List; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 账单关系组请求DTO,用于创建或更新一个关系组及其成员。 + * + * @date 2026-09-21 20:32:41 + */ +@Data +public class TransactionGroupRequest { + + /** 关系类型:PARENT_CHILD-父子,SIBLING-同级 */ + @NotBlank(message = "关系类型不能为空") + private String type; + + /** 关系组名称,可选 */ + @Size(max = 100, message = "关系组名称不能超过100个字符") + private String name; + + /** 备注,可选 */ + @Size(max = 255, message = "备注不能超过255个字符") + private String remark; + + /** 关系组成员列表,至少两条 */ + @NotEmpty(message = "关系组至少包含两条账单") + @Valid + private List items; +} diff --git a/src/main/java/cn/rosercode/finance/entity/TransactionGroup.java b/src/main/java/cn/rosercode/finance/entity/TransactionGroup.java new file mode 100644 index 0000000..4a9573b --- /dev/null +++ b/src/main/java/cn/rosercode/finance/entity/TransactionGroup.java @@ -0,0 +1,40 @@ +package cn.rosercode.finance.entity; + +import java.time.LocalDateTime; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +/** + * 账单关系组实体类,对应数据库表 t_transaction_group,将多笔账单按父子或同级关系聚合。 + * + * @date 2026-09-21 20:32:41 + */ +@Data +@TableName("t_transaction_group") +public class TransactionGroup { + + /** 关系组ID,主键自增 */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 创建时间 */ + private LocalDateTime createdAt; + + /** 更新时间 */ + private LocalDateTime updatedAt; + + /** 所属用户ID */ + private Long userId; + + /** 关系类型:PARENT_CHILD-父子,SIBLING-同级 */ + private String type; + + /** 关系组名称 */ + private String name; + + /** 备注 */ + private String remark; +} diff --git a/src/main/java/cn/rosercode/finance/entity/TransactionGroupItem.java b/src/main/java/cn/rosercode/finance/entity/TransactionGroupItem.java new file mode 100644 index 0000000..ed4c20a --- /dev/null +++ b/src/main/java/cn/rosercode/finance/entity/TransactionGroupItem.java @@ -0,0 +1,37 @@ +package cn.rosercode.finance.entity; + +import java.time.LocalDateTime; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +/** + * 账单关系组成员实体类,对应数据库表 t_transaction_group_item,维护关系组与账单的关联及组内角色。 + * + * @date 2026-09-21 20:32:41 + */ +@Data +@TableName("t_transaction_group_item") +public class TransactionGroupItem { + + /** 关联ID,主键自增 */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 创建时间 */ + private LocalDateTime createdAt; + + /** 关系组ID */ + private Long groupId; + + /** 交易记录ID */ + private Long transactionId; + + /** 组内角色:PARENT-父,CHILD-子,MEMBER-成员 */ + private String role; + + /** 排序序号,用于同级链路顺序与展示顺序 */ + private Integer sortOrder; +} diff --git a/src/main/java/cn/rosercode/finance/enums/AiCallType.java b/src/main/java/cn/rosercode/finance/enums/AiCallType.java index 6cc918a..43acbe4 100644 --- a/src/main/java/cn/rosercode/finance/enums/AiCallType.java +++ b/src/main/java/cn/rosercode/finance/enums/AiCallType.java @@ -38,5 +38,8 @@ public enum AiCallType { RULE, /** 自然语言统计查询解析 */ - STATISTICS + STATISTICS, + + /** 生成账单关系名称与备注 */ + RELATION } diff --git a/src/main/java/cn/rosercode/finance/enums/TransactionGroupRole.java b/src/main/java/cn/rosercode/finance/enums/TransactionGroupRole.java new file mode 100644 index 0000000..8216728 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/enums/TransactionGroupRole.java @@ -0,0 +1,57 @@ +package cn.rosercode.finance.enums; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 账单关系组内角色枚举,统一维护成员在关系组中的角色编码与中文名称。 + * + * @date 2026-09-21 20:32:41 + */ +public enum TransactionGroupRole { + + /** 父账单:父子关系中作为资金池的一方(如充值) */ + PARENT("父账单"), + + /** 子账单:父子关系中从资金池消费的一方 */ + CHILD("子账单"), + + /** 成员:同级关系中地位平等的账单 */ + MEMBER("成员"); + + /** 角色中文名称 */ + private final String label; + + TransactionGroupRole(String label) { + this.label = label; + } + + /** + * 获取角色中文名称。 + * + * @return 中文名称 + */ + public String getLabel() { + return label; + } + + /** + * 获取全部合法角色编码列表,顺序与枚举声明一致。 + * + * @return 角色编码列表 + */ + public static List codes() { + return Arrays.stream(values()).map(Enum::name).collect(Collectors.toList()); + } + + /** + * 判断是否为合法角色编码。 + * + * @param code 角色编码 + * @return 合法返回 true + */ + public static boolean isValid(String code) { + return code != null && Arrays.stream(values()).anyMatch(role -> role.name().equals(code)); + } +} diff --git a/src/main/java/cn/rosercode/finance/enums/TransactionRelationType.java b/src/main/java/cn/rosercode/finance/enums/TransactionRelationType.java new file mode 100644 index 0000000..0bffabc --- /dev/null +++ b/src/main/java/cn/rosercode/finance/enums/TransactionRelationType.java @@ -0,0 +1,72 @@ +package cn.rosercode.finance.enums; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 账单关系类型枚举,统一维护关系组的类型编码与中文名称。 + * + * @date 2026-09-21 20:32:41 + */ +public enum TransactionRelationType { + + /** 父子关系:一个父账单(如充值)对应多个子账单(如消费) */ + PARENT_CHILD("父子关系"), + + /** 同级关系:多笔账单地位平等(如支付链路上的多条记录) */ + SIBLING("同级关系"); + + /** 类型中文名称 */ + private final String label; + + TransactionRelationType(String label) { + this.label = label; + } + + /** + * 获取类型中文名称。 + * + * @return 中文名称 + */ + public String getLabel() { + return label; + } + + /** + * 获取全部合法类型编码列表,顺序与枚举声明一致。 + * + * @return 类型编码列表 + */ + public static List codes() { + return Arrays.stream(values()).map(Enum::name).collect(Collectors.toList()); + } + + /** + * 判断是否为合法类型编码。 + * + * @param code 类型编码 + * @return 合法返回 true + */ + public static boolean isValid(String code) { + return code != null && Arrays.stream(values()).anyMatch(type -> type.name().equals(code)); + } + + /** + * 将类型编码转换为中文名称,未知编码原样返回。 + * + * @param code 类型编码 + * @return 中文名称 + */ + public static String labelOf(String code) { + if (code == null) { + return ""; + } + for (TransactionRelationType type : values()) { + if (type.name().equals(code)) { + return type.label; + } + } + return code; + } +} diff --git a/src/main/java/cn/rosercode/finance/mapper/TransactionGroupItemMapper.java b/src/main/java/cn/rosercode/finance/mapper/TransactionGroupItemMapper.java new file mode 100644 index 0000000..36fb892 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/mapper/TransactionGroupItemMapper.java @@ -0,0 +1,15 @@ +package cn.rosercode.finance.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +import cn.rosercode.finance.entity.TransactionGroupItem; + +/** + * 账单关系组成员Mapper接口,继承MyBatis-Plus的BaseMapper,提供关系组成员表的基础CRUD操作。 + * + * @date 2026-09-21 20:32:41 + */ +@Mapper +public interface TransactionGroupItemMapper extends BaseMapper { +} diff --git a/src/main/java/cn/rosercode/finance/mapper/TransactionGroupMapper.java b/src/main/java/cn/rosercode/finance/mapper/TransactionGroupMapper.java new file mode 100644 index 0000000..74eb9ee --- /dev/null +++ b/src/main/java/cn/rosercode/finance/mapper/TransactionGroupMapper.java @@ -0,0 +1,15 @@ +package cn.rosercode.finance.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +import cn.rosercode.finance.entity.TransactionGroup; + +/** + * 账单关系组Mapper接口,继承MyBatis-Plus的BaseMapper,提供关系组的基础CRUD操作。 + * + * @date 2026-09-21 20:32:41 + */ +@Mapper +public interface TransactionGroupMapper extends BaseMapper { +} diff --git a/src/main/java/cn/rosercode/finance/prompt/PromptTemplateService.java b/src/main/java/cn/rosercode/finance/prompt/PromptTemplateService.java index b46b809..95ee956 100644 --- a/src/main/java/cn/rosercode/finance/prompt/PromptTemplateService.java +++ b/src/main/java/cn/rosercode/finance/prompt/PromptTemplateService.java @@ -152,6 +152,15 @@ public class PromptTemplateService { return render("ai-channel-system", null); } + /** + * 渲染生成账单关系名称与备注的系统提示词(模板 {@code ai-relation-system})。 + * + * @return 系统提示词 + */ + public String relationSystemPrompt() { + return render("ai-relation-system", null); + } + /** * 渲染 Emoji 图标建议的系统提示词(模板 {@code ai-decoration-emoji-system})。 * diff --git a/src/main/java/cn/rosercode/finance/service/AiRelationService.java b/src/main/java/cn/rosercode/finance/service/AiRelationService.java new file mode 100644 index 0000000..2b86da1 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/service/AiRelationService.java @@ -0,0 +1,21 @@ +package cn.rosercode.finance.service; + +import cn.rosercode.finance.dto.AiRelationRequest; +import cn.rosercode.finance.vo.AiRelationResultVO; + +/** + * AI 生成账单关系服务接口,根据关系类型与成员账单特征生成关系名称与备注。 + * + * @date 2026-09-21 21:08:21 + */ +public interface AiRelationService { + + /** + * 根据关系类型与成员账单特征生成关系名称与备注。 + * + * @param userId 用户ID + * @param request 关系类型与成员账单特征 + * @return 关系名称与备注建议 + */ + AiRelationResultVO parse(Long userId, AiRelationRequest request); +} diff --git a/src/main/java/cn/rosercode/finance/service/TransactionGroupService.java b/src/main/java/cn/rosercode/finance/service/TransactionGroupService.java new file mode 100644 index 0000000..ecd774f --- /dev/null +++ b/src/main/java/cn/rosercode/finance/service/TransactionGroupService.java @@ -0,0 +1,66 @@ +package cn.rosercode.finance.service; + +import java.util.List; + +import cn.rosercode.finance.dto.TransactionGroupRequest; +import cn.rosercode.finance.vo.TransactionGroupVO; + +/** + * 账单关系组服务接口,提供关系组的查询、创建、更新、删除与删除账单时的级联清理。 + * + * @date 2026-09-21 20:32:41 + */ +public interface TransactionGroupService { + + /** + * 查询某笔账单所属的全部关系组。 + * + * @param userId 用户ID + * @param transactionId 交易记录ID + * @return 关系组列表(含成员账单快照) + */ + List listByTransaction(Long userId, Long transactionId); + + /** + * 查询当前用户使用过的关系名称历史。 + * + * @param userId 用户ID + * @param type 关系类型过滤,为空时返回全部类型 + * @return 关系名称列表(按最近使用排序,去重) + */ + List listNames(Long userId, String type); + + /** + * 创建关系组。 + * + * @param userId 用户ID + * @param request 关系组请求参数 + * @return 新建关系组ID + */ + Long create(Long userId, TransactionGroupRequest request); + + /** + * 更新关系组(名称、备注、成员与角色全量替换)。 + * + * @param userId 用户ID + * @param id 关系组ID + * @param request 关系组请求参数 + */ + void update(Long userId, Long id, TransactionGroupRequest request); + + /** + * 删除关系组(仅解除账单关联,不删除账单)。 + * + * @param userId 用户ID + * @param id 关系组ID + */ + void delete(Long userId, Long id); + + /** + * 删除账单后清理其关系关联:移除成员,组内不足或父子组失去父账单时删除整个关系组。 + * + * @param userId 用户ID + * @param transactionIds 被删除的交易记录ID列表 + */ + void removeTransactionFromGroups(Long userId, List transactionIds); +} diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiRelationServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/AiRelationServiceImpl.java new file mode 100644 index 0000000..7283cf7 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/service/impl/AiRelationServiceImpl.java @@ -0,0 +1,211 @@ +package cn.rosercode.finance.service.impl; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; + +import cn.rosercode.finance.client.DeepSeekClient; +import cn.rosercode.finance.client.DeepSeekResponse; +import cn.rosercode.finance.common.BusinessException; +import cn.rosercode.finance.dto.AiRelationMemberRequest; +import cn.rosercode.finance.dto.AiRelationRequest; +import cn.rosercode.finance.entity.AiConfig; +import cn.rosercode.finance.enums.AiCallType; +import cn.rosercode.finance.enums.TransactionRelationType; +import cn.rosercode.finance.enums.TransactionType; +import cn.rosercode.finance.prompt.PromptTemplateService; +import cn.rosercode.finance.service.AiConfigService; +import cn.rosercode.finance.service.AiRelationService; +import cn.rosercode.finance.service.AiUsageService; +import cn.rosercode.finance.vo.AiRelationResultVO; + +/** + * AI 生成账单关系服务实现类,根据关系类型与成员账单特征生成关系名称与备注。 + * + *

用户提示词在服务端集中构造,仅包含收支类型、交易渠道、分类名称与截断后的备注, + * 不发送金额、交易号、原始数据等敏感字段。

+ * + * @date 2026-09-21 21:08:21 + */ +@Service +public class AiRelationServiceImpl implements AiRelationService { + + /** 关系名称最大长度 */ + private static final int NAME_MAX_LENGTH = 50; + + /** 关系备注最大长度 */ + private static final int REMARK_MAX_LENGTH = 200; + + /** 单次最多发送的成员账单数量 */ + private static final int MEMBER_LIMIT = 20; + + /** 单条备注截断长度 */ + private static final int REMARK_TRUNCATE = 30; + + private final AiConfigService aiConfigService; + private final DeepSeekClient deepSeekClient; + private final AiUsageService aiUsageService; + private final ObjectMapper objectMapper; + private final PromptTemplateService promptTemplateService; + + public AiRelationServiceImpl(AiConfigService aiConfigService, + DeepSeekClient deepSeekClient, + AiUsageService aiUsageService, + ObjectMapper objectMapper, + PromptTemplateService promptTemplateService) { + this.aiConfigService = aiConfigService; + this.deepSeekClient = deepSeekClient; + this.aiUsageService = aiUsageService; + this.objectMapper = objectMapper; + this.promptTemplateService = promptTemplateService; + } + + /** + * {@inheritDoc} + */ + @Override + public AiRelationResultVO parse(Long userId, AiRelationRequest request) { + if (request == null || request.getMembers() == null || request.getMembers().isEmpty()) { + throw new BusinessException("请先选择关联账单"); + } + AiConfig config = requireConfig(userId); + String userPrompt = buildUserPrompt(request); + + long start = System.currentTimeMillis(); + DeepSeekResponse aiResponse; + try { + aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(), + promptTemplateService.relationSystemPrompt(), userPrompt); + } catch (RuntimeException e) { + // 失败也记录一次使用,便于排查问题 + aiUsageService.recordFailure(userId, AiCallType.RELATION, config.getModel(), + request.getMembers().size(), System.currentTimeMillis() - start, e.getMessage()); + throw e; + } + aiUsageService.recordSuccess(userId, AiCallType.RELATION, config.getModel(), + request.getMembers().size(), aiResponse, System.currentTimeMillis() - start); + + return parseResult(aiResponse.getContent()); + } + + /** + * 构造用户提示词,仅包含白名单字段。 + * + * @param request 请求参数 + * @return 用户提示词 + */ + private String buildUserPrompt(AiRelationRequest request) { + StringBuilder builder = new StringBuilder(); + builder.append("关系类型:").append(TransactionRelationType.labelOf(request.getType())).append('\n'); + builder.append("成员账单:").append('\n'); + int index = 1; + for (AiRelationMemberRequest member : request.getMembers()) { + if (member == null) { + continue; + } + if (index > MEMBER_LIMIT) { + break; + } + builder.append(index).append(". ") + .append(TransactionType.labelOf(member.getType())).append(" | ") + .append(trim(member.getChannel())).append(" | ") + .append(trim(member.getCategoryName())).append(" | ") + .append(truncate(member.getRemark(), REMARK_TRUNCATE)) + .append('\n'); + index++; + } + return builder.toString(); + } + + /** + * 解析模型返回的 JSON 并校验字段合法性。 + * + * @param content 模型输出内容 + * @return 关系名称与备注建议 + */ + private AiRelationResultVO parseResult(String content) { + JsonNode root; + try { + root = objectMapper.readTree(stripCodeFence(content)); + } catch (JsonProcessingException e) { + throw new BusinessException("AI 返回内容不是有效 JSON,请重试"); + } + + String name = truncate(root.path("name").asText(""), NAME_MAX_LENGTH); + if (name.isEmpty()) { + throw new BusinessException("AI 未返回有效的关系名称,请重试"); + } + + AiRelationResultVO vo = new AiRelationResultVO(); + vo.setName(name); + vo.setRemark(truncate(root.path("remark").asText(""), REMARK_MAX_LENGTH)); + vo.setReason(root.path("reason").asText("")); + return vo; + } + + /** + * 获取并校验当前用户的 AI 配置。 + * + * @param userId 用户ID + * @return 有效的 AI 配置 + */ + private AiConfig requireConfig(Long userId) { + AiConfig config = aiConfigService.getRawConfig(userId); + if (config == null || !Boolean.TRUE.equals(config.getEnabled())) { + throw new BusinessException("AI 功能未启用,请先在 AI 设置中开启"); + } + if (config.getApiKey() == null || config.getApiKey().isBlank()) { + throw new BusinessException("请先配置 DeepSeek API Key"); + } + return config; + } + + /** + * 去除模型可能附加的 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 转为空字符串 + */ + private String trim(String value) { + return value == null ? "" : value.trim(); + } + + /** + * 截断字符串到指定长度。 + * + * @param value 原值 + * @param max 最大长度 + * @return 截断后的值 + */ + private String truncate(String value, int max) { + if (value == null) { + return ""; + } + String trimmed = value.trim(); + return trimmed.length() <= max ? trimmed : trimmed.substring(0, max); + } +} diff --git a/src/main/java/cn/rosercode/finance/service/impl/TransactionGroupServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/TransactionGroupServiceImpl.java new file mode 100644 index 0000000..312dae3 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/service/impl/TransactionGroupServiceImpl.java @@ -0,0 +1,452 @@ +package cn.rosercode.finance.service.impl; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import cn.rosercode.finance.common.BusinessException; +import cn.rosercode.finance.converter.VoConverter; +import cn.rosercode.finance.dto.TransactionGroupItemRequest; +import cn.rosercode.finance.dto.TransactionGroupRequest; +import cn.rosercode.finance.entity.Category; +import cn.rosercode.finance.entity.Transaction; +import cn.rosercode.finance.entity.TransactionGroup; +import cn.rosercode.finance.entity.TransactionGroupItem; +import cn.rosercode.finance.enums.TransactionGroupRole; +import cn.rosercode.finance.enums.TransactionRelationType; +import cn.rosercode.finance.mapper.CategoryMapper; +import cn.rosercode.finance.mapper.TransactionGroupItemMapper; +import cn.rosercode.finance.mapper.TransactionGroupMapper; +import cn.rosercode.finance.mapper.TransactionMapper; +import cn.rosercode.finance.service.TransactionGroupService; +import cn.rosercode.finance.vo.TransactionGroupItemVO; +import cn.rosercode.finance.vo.TransactionGroupVO; + +/** + * 账单关系组服务实现类,处理关系组的查询、创建、更新、删除与删除账单时的级联清理。 + * + * @date 2026-09-21 20:32:41 + */ +@Service +@Slf4j +public class TransactionGroupServiceImpl implements TransactionGroupService { + + /** 单个关系组成员数量上限 */ + private static final int MAX_ITEMS = 50; + + /** 关系名称历史返回条数上限 */ + private static final int NAME_HISTORY_LIMIT = 50; + + private final TransactionGroupMapper transactionGroupMapper; + private final TransactionGroupItemMapper transactionGroupItemMapper; + private final TransactionMapper transactionMapper; + private final CategoryMapper categoryMapper; + private final VoConverter voConverter; + + public TransactionGroupServiceImpl(TransactionGroupMapper transactionGroupMapper, + TransactionGroupItemMapper transactionGroupItemMapper, + TransactionMapper transactionMapper, + CategoryMapper categoryMapper, + VoConverter voConverter) { + this.transactionGroupMapper = transactionGroupMapper; + this.transactionGroupItemMapper = transactionGroupItemMapper; + this.transactionMapper = transactionMapper; + this.categoryMapper = categoryMapper; + this.voConverter = voConverter; + } + + /** + * {@inheritDoc} + */ + @Override + public List listByTransaction(Long userId, Long transactionId) { + // 校验账单归属当前用户 + Transaction transaction = transactionMapper.selectById(transactionId); + if (transaction == null || !transaction.getUserId().equals(userId)) { + throw new BusinessException("记录不存在"); + } + List memberships = transactionGroupItemMapper.selectList( + new LambdaQueryWrapper() + .eq(TransactionGroupItem::getTransactionId, transactionId)); + if (memberships.isEmpty()) { + return List.of(); + } + Set groupIds = memberships.stream() + .map(TransactionGroupItem::getGroupId) + .collect(Collectors.toCollection(LinkedHashSet::new)); + List groups = transactionGroupMapper.selectList(new LambdaQueryWrapper() + .eq(TransactionGroup::getUserId, userId) + .in(TransactionGroup::getId, groupIds)); + if (groups.isEmpty()) { + return List.of(); + } + return toGroupVOs(groups); + } + + /** + * {@inheritDoc} + */ + @Override + public List listNames(Long userId, String type) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .select(TransactionGroup::getName, TransactionGroup::getUpdatedAt) + .eq(TransactionGroup::getUserId, userId) + .isNotNull(TransactionGroup::getName) + .ne(TransactionGroup::getName, ""); + // 指定合法类型时仅返回该类型的历史名称 + if (TransactionRelationType.isValid(type)) { + wrapper.eq(TransactionGroup::getType, type); + } + List groups = transactionGroupMapper.selectList(wrapper); + // 按更新时间倒序(最近使用优先),去重后截断 + return groups.stream() + .sorted(Comparator.comparing(TransactionGroup::getUpdatedAt, + Comparator.nullsLast(Comparator.reverseOrder()))) + .map(group -> group.getName().trim()) + .filter(name -> !name.isEmpty()) + .distinct() + .limit(NAME_HISTORY_LIMIT) + .collect(Collectors.toList()); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional + public Long create(Long userId, TransactionGroupRequest request) { + String type = requireType(request.getType()); + List items = normalizeItems(type, request.getItems()); + List transactionIds = transactionIds(items); + validateTransactions(userId, transactionIds); + validateNotInSameTypeGroup(userId, type, transactionIds, null); + + TransactionGroup group = new TransactionGroup(); + group.setUserId(userId); + group.setType(type); + group.setName(request.getName()); + group.setRemark(request.getRemark()); + transactionGroupMapper.insert(group); + + insertItems(group.getId(), items); + log.info("新增账单关系组: userId={}, groupId={}, type={}, 成员={}", + userId, group.getId(), type, items.size()); + return group.getId(); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional + public void update(Long userId, Long id, TransactionGroupRequest request) { + requireGroup(userId, id); + String type = requireType(request.getType()); + List items = normalizeItems(type, request.getItems()); + List transactionIds = transactionIds(items); + validateTransactions(userId, transactionIds); + // 同类型唯一性校验时排除当前组自身 + validateNotInSameTypeGroup(userId, type, transactionIds, id); + + TransactionGroup group = new TransactionGroup(); + group.setId(id); + group.setType(type); + group.setName(request.getName()); + group.setRemark(request.getRemark()); + transactionGroupMapper.updateById(group); + + transactionGroupItemMapper.delete(new LambdaQueryWrapper() + .eq(TransactionGroupItem::getGroupId, id)); + insertItems(id, items); + log.info("更新账单关系组: userId={}, groupId={}, type={}, 成员={}", userId, id, type, items.size()); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional + public void delete(Long userId, Long id) { + requireGroup(userId, id); + transactionGroupItemMapper.delete(new LambdaQueryWrapper() + .eq(TransactionGroupItem::getGroupId, id)); + transactionGroupMapper.deleteById(id); + log.info("删除账单关系组: userId={}, groupId={}", userId, id); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional + public void removeTransactionFromGroups(Long userId, List transactionIds) { + if (transactionIds == null || transactionIds.isEmpty()) { + return; + } + List memberships = transactionGroupItemMapper.selectList( + new LambdaQueryWrapper() + .in(TransactionGroupItem::getTransactionId, transactionIds)); + if (memberships.isEmpty()) { + return; + } + Set groupIds = memberships.stream() + .map(TransactionGroupItem::getGroupId) + .collect(Collectors.toCollection(LinkedHashSet::new)); + // 仅处理属于当前用户的关系组 + List groups = transactionGroupMapper.selectList(new LambdaQueryWrapper() + .eq(TransactionGroup::getUserId, userId) + .in(TransactionGroup::getId, groupIds)); + if (groups.isEmpty()) { + return; + } + // 移除被删账单在这些组中的成员记录 + transactionGroupItemMapper.delete(new LambdaQueryWrapper() + .in(TransactionGroupItem::getGroupId, groupIds) + .in(TransactionGroupItem::getTransactionId, transactionIds)); + + // 逐个检查组是否仍有效:不足两条,或父子组失去父账单时整组移除 + for (TransactionGroup group : groups) { + List remaining = transactionGroupItemMapper.selectList( + new LambdaQueryWrapper() + .eq(TransactionGroupItem::getGroupId, group.getId())); + boolean valid = remaining.size() >= 2; + if (valid && TransactionRelationType.PARENT_CHILD.name().equals(group.getType())) { + valid = remaining.stream() + .anyMatch(item -> TransactionGroupRole.PARENT.name().equals(item.getRole())); + } + if (!valid) { + transactionGroupItemMapper.delete(new LambdaQueryWrapper() + .eq(TransactionGroupItem::getGroupId, group.getId())); + transactionGroupMapper.deleteById(group.getId()); + log.info("账单删除后关系组失效已移除: userId={}, groupId={}", userId, group.getId()); + } + } + log.info("账单删除后清理关系: userId={}, 账单={}, 涉及组={}", userId, transactionIds.size(), groups.size()); + } + + /** + * 校验并返回合法的关系类型编码。 + * + * @param type 关系类型编码 + * @return 合法的关系类型编码 + */ + private String requireType(String type) { + if (!TransactionRelationType.isValid(type)) { + throw new BusinessException("关系类型不合法"); + } + return type; + } + + /** + * 校验关系组归属并返回实体。 + * + * @param userId 用户ID + * @param id 关系组ID + * @return 关系组实体 + */ + private TransactionGroup requireGroup(Long userId, Long id) { + TransactionGroup group = transactionGroupMapper.selectById(id); + if (group == null || !group.getUserId().equals(userId)) { + throw new BusinessException("关系组不存在"); + } + return group; + } + + /** + * 归一化成员列表:去重、校验数量,并按关系类型规范角色与排序。 + * + * @param type 关系类型 + * @param items 原始成员请求列表 + * @return 归一化后的成员列表 + */ + private List normalizeItems(String type, List items) { + if (items == null || items.isEmpty()) { + throw new BusinessException("关系组至少包含两条账单"); + } + // 按交易ID去重并保留输入顺序 + Map unique = new LinkedHashMap<>(); + for (TransactionGroupItemRequest item : items) { + if (item == null || item.getTransactionId() == null) { + throw new BusinessException("关联账单不能为空"); + } + unique.putIfAbsent(item.getTransactionId(), item); + } + if (unique.size() < 2) { + throw new BusinessException("关系组至少包含两条账单"); + } + if (unique.size() > MAX_ITEMS) { + throw new BusinessException("单个关系组最多包含" + MAX_ITEMS + "条账单"); + } + + boolean parentChild = TransactionRelationType.PARENT_CHILD.name().equals(type); + if (parentChild) { + long parentCount = unique.values().stream() + .filter(item -> TransactionGroupRole.PARENT.name().equals(item.getRole())) + .count(); + if (parentCount != 1) { + throw new BusinessException("父子关系必须且只能指定一条父账单"); + } + } + + List normalized = new ArrayList<>(unique.size()); + int index = 0; + for (TransactionGroupItemRequest item : unique.values()) { + if (parentChild) { + item.setRole(TransactionGroupRole.PARENT.name().equals(item.getRole()) + ? TransactionGroupRole.PARENT.name() : TransactionGroupRole.CHILD.name()); + } else { + item.setRole(TransactionGroupRole.MEMBER.name()); + } + if (item.getSortOrder() == null) { + item.setSortOrder(index); + } + index++; + normalized.add(item); + } + return normalized; + } + + /** + * 提取成员请求中的交易记录ID列表。 + * + * @param items 成员请求列表 + * @return 交易记录ID列表 + */ + private List transactionIds(List items) { + return items.stream().map(TransactionGroupItemRequest::getTransactionId).collect(Collectors.toList()); + } + + /** + * 校验成员账单均存在且归属当前用户。 + * + * @param userId 用户ID + * @param transactionIds 交易记录ID列表 + */ + private void validateTransactions(Long userId, List transactionIds) { + List transactions = transactionMapper.selectList(new LambdaQueryWrapper() + .select(Transaction::getId) + .eq(Transaction::getUserId, userId) + .in(Transaction::getId, transactionIds)); + if (transactions.size() != transactionIds.size()) { + throw new BusinessException("存在无效的关联账单"); + } + } + + /** + * 校验成员账单未加入同类型的其它关系组。 + * + * @param userId 用户ID + * @param type 关系类型 + * @param transactionIds 交易记录ID列表 + * @param excludeGroupId 需排除的关系组ID(更新场景为当前组),可为 null + */ + private void validateNotInSameTypeGroup(Long userId, String type, List transactionIds, + Long excludeGroupId) { + List sameTypeGroups = transactionGroupMapper.selectList( + new LambdaQueryWrapper() + .select(TransactionGroup::getId) + .eq(TransactionGroup::getUserId, userId) + .eq(TransactionGroup::getType, type)); + if (sameTypeGroups.isEmpty()) { + return; + } + List groupIds = sameTypeGroups.stream().map(TransactionGroup::getId).collect(Collectors.toList()); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .in(TransactionGroupItem::getGroupId, groupIds) + .in(TransactionGroupItem::getTransactionId, transactionIds); + if (excludeGroupId != null) { + wrapper.ne(TransactionGroupItem::getGroupId, excludeGroupId); + } + if (!transactionGroupItemMapper.selectList(wrapper).isEmpty()) { + throw new BusinessException("存在已加入同类型关系的账单,请先解除原关系"); + } + } + + /** + * 批量写入关系组成员。 + * + * @param groupId 关系组ID + * @param items 归一化后的成员列表 + */ + private void insertItems(Long groupId, List items) { + for (TransactionGroupItemRequest item : items) { + TransactionGroupItem entity = new TransactionGroupItem(); + entity.setGroupId(groupId); + entity.setTransactionId(item.getTransactionId()); + entity.setRole(item.getRole()); + entity.setSortOrder(item.getSortOrder() == null ? 0 : item.getSortOrder()); + transactionGroupItemMapper.insert(entity); + } + } + + /** + * 将关系组实体列表转换为视图对象,并批量填充成员账单快照。 + * + * @param groups 关系组实体列表 + * @return 关系组视图对象列表 + */ + private List toGroupVOs(List groups) { + List groupIds = groups.stream().map(TransactionGroup::getId).collect(Collectors.toList()); + List items = transactionGroupItemMapper.selectList( + new LambdaQueryWrapper() + .in(TransactionGroupItem::getGroupId, groupIds) + .orderByAsc(TransactionGroupItem::getSortOrder) + .orderByAsc(TransactionGroupItem::getId)); + + // 批量加载成员账单与分类,避免 N+1 查询 + List transactionIds = items.stream() + .map(TransactionGroupItem::getTransactionId) + .distinct() + .collect(Collectors.toList()); + Map transactionMap = transactionIds.isEmpty() ? Map.of() + : transactionMapper.selectBatchIds(transactionIds).stream() + .collect(Collectors.toMap(Transaction::getId, transaction -> transaction)); + List categoryIds = transactionMap.values().stream() + .map(Transaction::getCategoryId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + Map categoryMap = categoryIds.isEmpty() ? Map.of() + : categoryMapper.selectBatchIds(categoryIds).stream() + .collect(Collectors.toMap(Category::getId, category -> category)); + + Map> itemsByGroup = new HashMap<>(); + for (TransactionGroupItem item : items) { + TransactionGroupItemVO itemVO = voConverter.toTransactionGroupItemVO(item); + Transaction transaction = transactionMap.get(item.getTransactionId()); + if (transaction != null) { + itemVO.setType(transaction.getType()); + itemVO.setAmount(transaction.getAmount()); + itemVO.setChannel(transaction.getChannel()); + itemVO.setRemark(transaction.getRemark()); + itemVO.setRemarkHidden(Boolean.TRUE.equals(transaction.getRemarkHidden())); + itemVO.setTransactionTime(transaction.getTransactionTime()); + Category category = transaction.getCategoryId() == null + ? null : categoryMap.get(transaction.getCategoryId()); + itemVO.setCategoryName(category == null ? "" : category.getName()); + itemVO.setCategoryIcon(category == null ? "" : category.getIcon()); + } + itemsByGroup.computeIfAbsent(item.getGroupId(), key -> new ArrayList<>()).add(itemVO); + } + + List result = new ArrayList<>(groups.size()); + for (TransactionGroup group : groups) { + TransactionGroupVO vo = voConverter.toTransactionGroupVO(group); + vo.setItems(itemsByGroup.getOrDefault(group.getId(), List.of())); + result.add(vo); + } + return result; + } +} diff --git a/src/main/java/cn/rosercode/finance/service/impl/TransactionServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/TransactionServiceImpl.java index e3dfc04..4f6be7b 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/TransactionServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/TransactionServiceImpl.java @@ -32,6 +32,7 @@ import cn.rosercode.finance.entity.AiImageTransaction; import cn.rosercode.finance.entity.Category; import cn.rosercode.finance.entity.Tag; import cn.rosercode.finance.entity.Transaction; +import cn.rosercode.finance.entity.TransactionGroupItem; import cn.rosercode.finance.entity.TransactionTag; import cn.rosercode.finance.enums.ChannelType; import cn.rosercode.finance.enums.TransactionType; @@ -39,9 +40,11 @@ import cn.rosercode.finance.mapper.AiImageMapper; import cn.rosercode.finance.mapper.AiImageTransactionMapper; import cn.rosercode.finance.mapper.CategoryMapper; import cn.rosercode.finance.mapper.TagMapper; +import cn.rosercode.finance.mapper.TransactionGroupItemMapper; import cn.rosercode.finance.mapper.TransactionMapper; import cn.rosercode.finance.mapper.TransactionTagMapper; import cn.rosercode.finance.service.ChannelService; +import cn.rosercode.finance.service.TransactionGroupService; import cn.rosercode.finance.service.TransactionService; import cn.rosercode.finance.util.TransactionDedupUtils; import cn.rosercode.finance.vo.TagVO; @@ -64,7 +67,9 @@ public class TransactionServiceImpl implements TransactionService { private final TransactionTagMapper transactionTagMapper; private final AiImageMapper aiImageMapper; private final AiImageTransactionMapper aiImageTransactionMapper; + private final TransactionGroupItemMapper transactionGroupItemMapper; private final ChannelService channelService; + private final TransactionGroupService transactionGroupService; private final VoConverter voConverter; public TransactionServiceImpl(TransactionMapper transactionMapper, @@ -73,7 +78,9 @@ public class TransactionServiceImpl implements TransactionService { TransactionTagMapper transactionTagMapper, AiImageMapper aiImageMapper, AiImageTransactionMapper aiImageTransactionMapper, + TransactionGroupItemMapper transactionGroupItemMapper, ChannelService channelService, + TransactionGroupService transactionGroupService, VoConverter voConverter) { this.transactionMapper = transactionMapper; this.categoryMapper = categoryMapper; @@ -81,7 +88,9 @@ public class TransactionServiceImpl implements TransactionService { this.transactionTagMapper = transactionTagMapper; this.aiImageMapper = aiImageMapper; this.aiImageTransactionMapper = aiImageTransactionMapper; + this.transactionGroupItemMapper = transactionGroupItemMapper; this.channelService = channelService; + this.transactionGroupService = transactionGroupService; this.voConverter = voConverter; } @@ -194,6 +203,7 @@ public class TransactionServiceImpl implements TransactionService { // 批量填充标签与来源图片 fillTags(voList); fillImages(voList); + fillRelationGroupCount(voList); return new PageResult<>(voList, result.getTotal(), result.getCurrent(), result.getSize()); } @@ -387,6 +397,7 @@ public class TransactionServiceImpl implements TransactionService { // 先清理标签关联,再删除记录 transactionTagMapper.delete(new LambdaQueryWrapper() .eq(TransactionTag::getTransactionId, id)); + transactionGroupService.removeTransactionFromGroups(userId, List.of(id)); transactionMapper.deleteById(id); log.info("删除交易记录: userId={}, id={}", userId, id); } @@ -416,6 +427,7 @@ public class TransactionServiceImpl implements TransactionService { // 先清理标签关联,再批量删除记录 transactionTagMapper.delete(new LambdaQueryWrapper() .in(TransactionTag::getTransactionId, ownedIds)); + transactionGroupService.removeTransactionFromGroups(userId, ownedIds); int deleted = transactionMapper.deleteByIds(ownedIds); log.info("批量删除交易记录: userId={}, 请求={}, 实际={}", userId, ids.size(), deleted); return deleted; @@ -680,6 +692,26 @@ public class TransactionServiceImpl implements TransactionService { } } + /** + * 批量填充交易视图对象所属的关系组数量 + * + * @param voList 交易视图对象列表 + */ + private void fillRelationGroupCount(List voList) { + if (voList.isEmpty()) { + return; + } + List transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList()); + List items = transactionGroupItemMapper.selectList( + new LambdaQueryWrapper() + .in(TransactionGroupItem::getTransactionId, transactionIds)); + Map counts = items.stream() + .collect(Collectors.groupingBy(TransactionGroupItem::getTransactionId, Collectors.counting())); + for (TransactionVO vo : voList) { + vo.setRelationGroupCount(counts.getOrDefault(vo.getId(), 0L).intValue()); + } + } + /** * 应用排序条件:默认按交易时间降序,支持按交易时间或金额升/降序。 * diff --git a/src/main/java/cn/rosercode/finance/vo/AiRelationResultVO.java b/src/main/java/cn/rosercode/finance/vo/AiRelationResultVO.java new file mode 100644 index 0000000..251f499 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/vo/AiRelationResultVO.java @@ -0,0 +1,21 @@ +package cn.rosercode.finance.vo; + +import lombok.Data; + +/** + * AI 生成账单关系结果视图对象,字段与关系组表单对应。 + * + * @date 2026-09-21 21:08:21 + */ +@Data +public class AiRelationResultVO { + + /** 建议的关系名称 */ + private String name; + + /** 建议的关系备注 */ + private String remark; + + /** 生成理由,用于向用户说明 */ + private String reason; +} diff --git a/src/main/java/cn/rosercode/finance/vo/TransactionGroupItemVO.java b/src/main/java/cn/rosercode/finance/vo/TransactionGroupItemVO.java new file mode 100644 index 0000000..7a96db7 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/vo/TransactionGroupItemVO.java @@ -0,0 +1,48 @@ +package cn.rosercode.finance.vo; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import lombok.Data; + +/** + * 账单关系组成员视图对象,包含成员角色与所关联账单的展示快照。 + * + * @date 2026-09-21 20:32:41 + */ +@Data +public class TransactionGroupItemVO { + + /** 交易记录ID */ + private Long transactionId; + + /** 组内角色:PARENT-父,CHILD-子,MEMBER-成员 */ + private String role; + + /** 排序序号,用于同级链路顺序与展示顺序 */ + private Integer sortOrder; + + /** 交易类型(收入/支出) */ + private String type; + + /** 交易金额 */ + private BigDecimal amount; + + /** 交易渠道 */ + private String channel; + + /** 备注 */ + private String remark; + + /** 备注是否隐藏 */ + private Boolean remarkHidden; + + /** 交易时间 */ + private LocalDateTime transactionTime; + + /** 分类名称 */ + private String categoryName; + + /** 分类图标 */ + private String categoryIcon; +} diff --git a/src/main/java/cn/rosercode/finance/vo/TransactionGroupVO.java b/src/main/java/cn/rosercode/finance/vo/TransactionGroupVO.java new file mode 100644 index 0000000..4e12a81 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/vo/TransactionGroupVO.java @@ -0,0 +1,36 @@ +package cn.rosercode.finance.vo; + +import java.time.LocalDateTime; +import java.util.List; + +import lombok.Data; + +/** + * 账单关系组视图对象,用于向前端返回关系组及其成员账单。 + * + * @date 2026-09-21 20:32:41 + */ +@Data +public class TransactionGroupVO { + + /** 关系组ID */ + private Long id; + + /** 关系类型:PARENT_CHILD-父子,SIBLING-同级 */ + private String type; + + /** 关系组名称 */ + private String name; + + /** 备注 */ + private String remark; + + /** 创建时间 */ + private LocalDateTime createdAt; + + /** 更新时间 */ + private LocalDateTime updatedAt; + + /** 关系组成员列表 */ + private List items; +} diff --git a/src/main/java/cn/rosercode/finance/vo/TransactionVO.java b/src/main/java/cn/rosercode/finance/vo/TransactionVO.java index 8517284..062cdbe 100644 --- a/src/main/java/cn/rosercode/finance/vo/TransactionVO.java +++ b/src/main/java/cn/rosercode/finance/vo/TransactionVO.java @@ -56,4 +56,7 @@ public class TransactionVO { /** 来源图片ID列表(图片记账生成时存在,手动记账为空) */ private List imageIds; + + /** 所属关系组数量(父子/同级关系),无关系时为 0 */ + private Integer relationGroupCount; } diff --git a/src/main/resources/prompts/ai-relation-system.tpl b/src/main/resources/prompts/ai-relation-system.tpl new file mode 100644 index 0000000..8c89298 --- /dev/null +++ b/src/main/resources/prompts/ai-relation-system.tpl @@ -0,0 +1,6 @@ +你是个人记账应用的账单关系助手。用户会给出一组账单的关系类型与成员账单特征,请为这组关系生成一个简洁的名称和一句备注。要求: +1. name 为 4-12 个汉字的名称,概括这组账单的关系(如「交通卡充值」「淘宝购物支付链」「借款给朋友」); +2. remark 为一句话说明(不超过 40 个汉字),点明成员账单之间的关联含义(如充值后分次扣减、同一笔消费的多来源记录、借出后对方归还); +3. 借贷场景:当成员为同一交易对方(可结合备注判断)、发生在不同时间,且「支出合计」与「收入合计」大致相等(方向相反,可含多笔)时,应判定为借贷往来(借出—归还);若一笔借出对应多笔还款,说明为分次归还; +4. 不要提及或推算任何金额; +5. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"交通卡充值","remark":"充值后分次乘车扣减","reason":"简要中文理由"} diff --git a/src/main/resources/sql/init.sql b/src/main/resources/sql/init.sql index e80ab6d..fb11e53 100644 --- a/src/main/resources/sql/init.sql +++ b/src/main/resources/sql/init.sql @@ -66,6 +66,30 @@ CREATE TABLE IF NOT EXISTS t_transaction_tag ( INDEX idx_tag_id (tag_id) COMMENT '标签ID索引' ); +-- 账单关系组表:将多笔账单按父子或同级关系聚合为一个关系组 +CREATE TABLE IF NOT EXISTS t_transaction_group ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关系组ID,主键自增', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + user_id BIGINT NOT NULL COMMENT '所属用户ID', + type VARCHAR(20) NOT NULL COMMENT '关系类型:PARENT_CHILD-父子,SIBLING-同级', + name VARCHAR(100) COMMENT '关系组名称', + remark VARCHAR(255) COMMENT '备注', + INDEX idx_user_id (user_id) COMMENT '用户ID索引' +); + +-- 账单关系组成员表:关系组与账单的关联及组内角色 +CREATE TABLE IF NOT EXISTS t_transaction_group_item ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID,主键自增', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + group_id BIGINT NOT NULL COMMENT '关系组ID', + transaction_id BIGINT NOT NULL COMMENT '交易记录ID', + role VARCHAR(10) NOT NULL COMMENT '组内角色:PARENT-父,CHILD-子,MEMBER-成员', + sort_order INT NOT NULL DEFAULT 0 COMMENT '排序序号,用于同级链路顺序', + UNIQUE INDEX uk_group_transaction (group_id, transaction_id) COMMENT '关系组+交易唯一索引', + INDEX idx_transaction_id (transaction_id) COMMENT '交易记录ID索引' +); + -- AI 配置表:存储每个用户的 AI 智能建议相关配置 CREATE TABLE IF NOT EXISTS t_ai_config ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '配置ID,主键自增', diff --git a/src/main/resources/sql/schema-sqlite.sql b/src/main/resources/sql/schema-sqlite.sql index ff9e166..160bdef 100644 --- a/src/main/resources/sql/schema-sqlite.sql +++ b/src/main/resources/sql/schema-sqlite.sql @@ -70,6 +70,28 @@ CREATE TABLE IF NOT EXISTS t_transaction_tag ( CREATE UNIQUE INDEX IF NOT EXISTS uk_transaction_tag ON t_transaction_tag (transaction_id, tag_id);; CREATE INDEX IF NOT EXISTS idx_transaction_tag_tag_id ON t_transaction_tag (tag_id);; +CREATE TABLE IF NOT EXISTS t_transaction_group ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), + updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), + user_id INTEGER NOT NULL, + type VARCHAR(20) NOT NULL, + name VARCHAR(100), + remark VARCHAR(255) +);; +CREATE INDEX IF NOT EXISTS idx_transaction_group_user_id ON t_transaction_group (user_id);; + +CREATE TABLE IF NOT EXISTS t_transaction_group_item ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), + group_id INTEGER NOT NULL, + transaction_id INTEGER NOT NULL, + role VARCHAR(10) NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 +);; +CREATE UNIQUE INDEX IF NOT EXISTS uk_transaction_group_item ON t_transaction_group_item (group_id, transaction_id);; +CREATE INDEX IF NOT EXISTS idx_transaction_group_item_transaction_id ON t_transaction_group_item (transaction_id);; + CREATE TABLE IF NOT EXISTS t_ai_config ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')), @@ -143,6 +165,14 @@ BEGIN UPDATE t_transaction SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id; END;; +CREATE TRIGGER IF NOT EXISTS trg_transaction_group_updated_at +AFTER UPDATE ON t_transaction_group +FOR EACH ROW +WHEN NEW.updated_at = OLD.updated_at +BEGIN + UPDATE t_transaction_group SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id; +END;; + CREATE TRIGGER IF NOT EXISTS trg_ai_config_updated_at AFTER UPDATE ON t_ai_config FOR EACH ROW