refactor: 拆分交易记录前后端巨型文件并优化批量与导出性能

将 TransactionServiceImpl(922 行)与 TransactionList.vue(2811 行)按职责拆分为多个协作组件,对外接口与既有行为保持兼容,并在拆分后的组件中修复批量创建 N+1 查询与导出全量加载的内存风险。

变更内容:

- 后端拆分:TransactionServiceImpl 作为门面保留 TransactionService 契约,抽出 TransactionQuerySupport(查询条件构建)、TransactionVoEnricher(视图富化)、TransactionTagWriter(标签写入)、TransactionBatchService(批量操作)与 exporter.TransactionExportAssembler(导出组装)
- 前端拆分:TransactionList.vue 抽出 AiRecordModal、AiSuggestModal、TransactionBatchModal 三个弹窗组件与 utils/datetime 工具,并将本地 AI 启用状态迁移到共享 useAiEnabled
- 性能:batchCreate 预加载分类消除循环内 N+1 并改为批量写入标签关联;export 增加 1 万条上限保护,超限提示缩小筛选范围
This commit is contained in:
2026-10-09 14:29:00 +08:00
parent 4735f0bf85
commit 863e221220
11 changed files with 1867 additions and 1439 deletions
+562
View File
@@ -0,0 +1,562 @@
<script setup lang="ts">
// AI 智能记账弹窗:支持文字记账与图片记账(SSE 实时进度),识别结果可编辑后批量导入
import { ref, computed, watch, onBeforeUnmount, h } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage, NButton, NSpace, NDataTable, NTag, NModal, NInput, NInputNumber, NSelect, NDatePicker, NAlert, NUpload, NUploadDragger, NTabs, NTabPane, NIcon } from 'naive-ui'
import { SparklesOutline, RefreshOutline, CloudUploadOutline, CheckmarkCircleOutline } from '@vicons/ionicons5'
import type { DataTableColumns, UploadFileInfo } from 'naive-ui'
import { parseRecordByAi } from '../api/ai'
import type { AiRecordResult, AiRecordImageResult } from '../api/ai'
import { batchCreateTransactions } from '../api/transaction'
import { renderCategoryLabel } from '../utils/render'
import { openSse } from '../utils/sse'
import { useStringPreference } from '../composables/usePreference'
import { STORAGE_KEYS } from '../utils/storageKeys'
import { formatLocalDateTime, formatProgressTime } from '../utils/datetime'
const props = defineProps<{
show: boolean
categories: { label: string; value: number; type: string; icon: string }[]
}>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
(e: 'imported'): void
(e: 'parsed', record: AiRecordResult): void
}>()
const message = useMessage()
const { t } = useI18n()
/** 图片记账结果编辑行 */
interface AiRecordEditRow {
key: number
type: string
amount: number | null
categoryId: number | null
channel: string
remark: string
transactionTime: number
includeInStats: boolean
tagIds: number[]
rawData: string | null
imageIndex: number | null
imageId: number | null
duplicate: boolean
}
/** 图片记账单次最多上传的图片数量 */
const MAX_AI_RECORD_IMAGES = 5
const showModel = computed({
get: () => props.show,
set: (value: boolean) => emit('update:show', value),
})
const showResultModal = ref(false) // 图片记账结果弹窗显示状态
const showRawModal = ref(false) // 原始账单数据弹窗显示状态
const rawDataText = ref('') // 格式化后的原始账单数据文本
const aiRecordText = ref('') // 文字记账输入
const aiRecording = ref(false) // 文字记账解析状态
const aiRecordTab = useStringPreference(STORAGE_KEYS.transactionAiRecordTab, 'text', {
validate: (value) => value === 'text' || value === 'image',
})
const aiRecordFileList = ref<UploadFileInfo[]>([]) // 上传文件列表
const aiRecordFiles = ref<File[]>([]) // 待识别的图片文件
const aiRecordHint = ref('') // 图片识别补充说明
const aiRecordingImage = ref(false) // 图片识别状态
const aiRecordSummary = ref('') // 图片识别摘要
const aiRecordRows = ref<AiRecordEditRow[]>([]) // 图片识别结果行(可编辑)
const checkedAiRecordKeys = ref<number[]>([]) // 结果勾选的行
const aiRecordImporting = ref(false) // 导入状态
const aiRecordProgress = ref<{ time: string; text: string }[]>([]) // 识别进度日志
let aiRecordStreamController: AbortController | null = null // SSE 连接控制器
const typeOptions = computed(() => [
{ label: t('common.types.expense'), value: 'EXPENSE' },
{ label: t('common.types.income'), value: 'INCOME' },
])
/** 打开弹窗时重置状态并中断上次识别 */
watch(() => props.show, (visible) => {
if (!visible) return
abortAiRecordStream()
aiRecordText.value = ''
aiRecordFileList.value = []
aiRecordFiles.value = []
aiRecordHint.value = ''
aiRecordProgress.value = []
})
/** 解析自然语言记账并预填新增表单 */
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
emit('parsed', r)
showModel.value = false
} catch (e: any) {
message.error(e.message || t('ai.record.failed'))
} finally {
aiRecording.value = false
}
}
/** 设置待识别的图片文件列表(最多 MAX_AI_RECORD_IMAGES 张) */
function setAiRecordFiles(files: File[]) {
const limited = files.slice(0, MAX_AI_RECORD_IMAGES)
aiRecordFiles.value = limited
aiRecordFileList.value = limited.map((file, index) => ({
id: `${Date.now()}-${index}`,
name: file.name || `clipboard-${index + 1}.png`,
status: 'finished',
file,
} as UploadFileInfo))
}
/** 处理上传文件变化,最多保留 MAX_AI_RECORD_IMAGES 张 */
function handleAiRecordFileChange(options: { fileList: UploadFileInfo[] }) {
const files = options.fileList
.map((item) => item.file)
.filter((file): file is File => !!file)
setAiRecordFiles(files)
}
/** 追加一张图片(用于多次粘贴累加) */
function appendAiRecordFile(file: File) {
if (aiRecordFiles.value.length >= MAX_AI_RECORD_IMAGES) {
message.warning(t('ai.record.maxImages', { count: MAX_AI_RECORD_IMAGES }))
return
}
setAiRecordFiles([...aiRecordFiles.value, file])
}
/** 监听粘贴事件,从剪贴板读取图片(Ctrl+V / ⌘V),支持多次粘贴累加 */
function handleAiRecordPaste(e: ClipboardEvent) {
if (!props.show) return
const dt = e.clipboardData
if (!dt) return
for (let i = 0; i < dt.items.length; i++) {
const item = dt.items[i]
if (item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile()
if (file) {
e.preventDefault()
appendAiRecordFile(file)
aiRecordTab.value = 'image'
message.success(t('ai.record.pasteSuccess'))
}
return
}
}
}
/** 应用识别结果并打开结果预览 */
function applyAiRecordImageResult(result: AiRecordImageResult) {
const records = result.records || []
if (records.length === 0) {
message.warning(t('ai.record.noRecord'))
return
}
aiRecordSummary.value = result.summary || ''
aiRecordRows.value = records.map((r, index) => ({
key: index,
type: r.type || 'EXPENSE',
amount: r.amount ?? null,
categoryId: r.categoryId ?? null,
channel: r.channel || '',
remark: r.remark || '',
transactionTime: r.transactionTime ? new Date(r.transactionTime).getTime() : Date.now(),
includeInStats: r.includeInStats ?? true,
tagIds: r.tagIds || [],
rawData: r.rawData ?? null,
imageIndex: r.imageIndex ?? null,
imageId: r.imageId ?? null,
duplicate: r.duplicate ?? false,
}))
// 疑似重复的记录默认不勾选,用户可手动勾选强制导入
checkedAiRecordKeys.value = aiRecordRows.value.filter((row) => !row.duplicate).map((row) => row.key)
showModel.value = false
showResultModal.value = true
}
/** 中断进行中的识别连接 */
function abortAiRecordStream() {
if (aiRecordStreamController) {
aiRecordStreamController.abort()
aiRecordStreamController = null
}
}
/** 通过 SSE 调用视觉模型识别,实时展示进度 */
function handleAiRecordParseImage() {
if (aiRecordFiles.value.length === 0) {
message.warning(t('ai.record.emptyInput'))
return
}
abortAiRecordStream()
aiRecordProgress.value = []
aiRecordingImage.value = true
const formData = new FormData()
for (const file of aiRecordFiles.value) {
formData.append('files', file)
}
const hint = aiRecordHint.value.trim()
if (hint) formData.append('hint', hint)
aiRecordStreamController = openSse('/api/ai/record/image/stream', {
method: 'POST',
body: formData,
onEvent: (event, data) => {
if (event === 'progress') {
aiRecordProgress.value.push({ time: formatProgressTime(new Date()), text: data })
} else if (event === 'done') {
try {
applyAiRecordImageResult(JSON.parse(data) as AiRecordImageResult)
} catch (e) {
message.error(t('ai.record.imageFailed'))
}
} else if (event === 'failed') {
let reason = ''
try {
reason = JSON.parse(data)?.message || ''
} catch (e) {
reason = data
}
aiRecordProgress.value.push({
time: formatProgressTime(new Date()),
text: reason ? `${t('ai.record.imageFailed')}:${reason}` : t('ai.record.imageFailed'),
})
message.error(reason || t('ai.record.imageFailed'))
}
},
onError: (e: any) => {
message.error(e?.message || t('ai.record.imageFailed'))
},
onClose: () => {
aiRecordingImage.value = false
aiRecordStreamController = null
},
})
}
/** 关闭弹窗,中断进行中的识别 */
function handleAiRecordCancel() {
abortAiRecordStream()
aiRecordingImage.value = false
showModel.value = false
}
/** 导入勾选的结果行 */
async function handleAiRecordImport() {
const selected = aiRecordRows.value.filter((row) => checkedAiRecordKeys.value.includes(row.key))
if (selected.length === 0) {
message.warning(t('ai.record.selectAtLeastOne'))
return
}
if (selected.some((row) => !row.amount || row.amount <= 0)) {
message.warning(t('transaction.validAmount'))
return
}
aiRecordImporting.value = true
try {
const res: any = await batchCreateTransactions({
records: selected.map((row) => ({
categoryId: row.categoryId,
type: row.type,
amount: row.amount!,
channel: row.channel || undefined,
remark: row.remark || undefined,
transactionTime: formatLocalDateTime(row.transactionTime),
includeInStats: row.includeInStats,
tagIds: row.tagIds,
rawData: row.rawData || undefined,
imageId: row.imageId ?? undefined,
})),
})
message.success(t('ai.record.importSuccess', { count: res.data ?? selected.length }))
showResultModal.value = false
emit('imported')
} catch (e: any) {
message.error(e.message || t('ai.record.importFailed'))
} finally {
aiRecordImporting.value = false
}
}
/** 查看结果行的原始数据(格式化为缩进 JSON) */
function handleViewAiRecordRaw(row: AiRecordEditRow) {
if (!row.rawData) return
try {
rawDataText.value = JSON.stringify(JSON.parse(row.rawData), null, 2)
} catch (e) {
rawDataText.value = row.rawData
}
showRawModal.value = true
}
/** 图片记账结果表格列(类型、金额、分类、时间、备注可编辑) */
const aiRecordColumns = computed<DataTableColumns<AiRecordEditRow>>(() => [
{ type: 'selection' },
{
title: t('ai.record.col.source'),
key: 'imageIndex',
width: 80,
render: (row) => (row.imageIndex ? t('ai.record.sourceImageLabel', { index: row.imageIndex }) : '-'),
},
{
title: t('ai.record.col.duplicate'),
key: 'duplicate',
width: 90,
render: (row) => (row.duplicate
? h(NTag, { type: 'warning', size: 'small' }, { default: () => t('ai.record.duplicate') })
: '-'),
},
{
title: t('ai.record.col.type'),
key: 'type',
width: 100,
render: (row) => h(NSelect, {
value: row.type,
options: typeOptions.value,
size: 'small',
onUpdateValue: (value: string) => { row.type = value; row.categoryId = null },
}),
},
{
title: t('ai.record.col.amount'),
key: 'amount',
width: 120,
render: (row) => h(NInputNumber, {
value: row.amount,
min: 0.01,
precision: 2,
size: 'small',
onUpdateValue: (value: number | null) => { row.amount = value },
}),
},
{
title: t('ai.record.col.category'),
key: 'categoryId',
width: 170,
render: (row) => h(NSelect, {
value: row.categoryId,
options: props.categories.filter((c) => c.type === row.type),
renderLabel: renderCategoryLabel,
filterable: true,
clearable: true,
size: 'small',
onUpdateValue: (value: number | null) => { row.categoryId = value },
}),
},
{
title: t('ai.record.col.time'),
key: 'transactionTime',
width: 180,
render: (row) => h(NDatePicker, {
value: row.transactionTime,
type: 'datetime',
size: 'small',
onUpdateValue: (value: number | null) => { row.transactionTime = value ?? row.transactionTime },
}),
},
{
title: t('ai.record.col.remark'),
key: 'remark',
render: (row) => h(NInput, {
value: row.remark,
size: 'small',
onUpdateValue: (value: string) => { row.remark = value },
}),
},
{
title: t('ai.record.col.rawData'),
key: 'rawData',
width: 110,
render: (row) => (row.rawData
? h(NButton, { size: 'small', quaternary: true, type: 'primary', onClick: () => handleViewAiRecordRaw(row) },
{ default: () => t('transaction.rawData') })
: '-'),
},
])
watch(() => props.show, (visible) => {
if (visible) window.addEventListener('paste', handleAiRecordPaste)
else window.removeEventListener('paste', handleAiRecordPaste)
})
onBeforeUnmount(() => {
window.removeEventListener('paste', handleAiRecordPaste)
})
</script>
<template>
<div>
<!-- AI 智能记账弹窗(文字 / 图片) -->
<NModal v-model:show="showModel" preset="card" :title="t('ai.record.title')" style="width: 560px; max-width: 92vw;">
<NTabs v-model:value="aiRecordTab" type="line" animated>
<NTabPane name="text" :tab="t('ai.record.textTab')">
<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="showModel = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="aiRecording" @click="handleAiRecordParse">{{ t('ai.record.parse') }}</NButton>
</NSpace>
</NSpace>
</NTabPane>
<NTabPane name="image" :tab="t('ai.record.imageTab')">
<NSpace vertical :size="12">
<NAlert type="info" :show-icon="true">{{ t('ai.record.imageHint') }}</NAlert>
<NUpload
:file-list="aiRecordFileList"
:default-upload="false"
:max="MAX_AI_RECORD_IMAGES"
multiple
accept="image/jpeg,image/png,image/gif,image/webp"
@change="handleAiRecordFileChange"
>
<NUploadDragger>
<NIcon size="36" :component="CloudUploadOutline" />
<div style="margin-top: 8px;">{{ t('ai.record.upload') }}</div>
<div style="margin-top: 4px; font-size: 12px; color: #999;">{{ t('ai.record.uploadTip') }}</div>
<div style="margin-top: 4px; font-size: 12px; color: #999;">{{ t('ai.record.pasteHint') }}</div>
</NUploadDragger>
</NUpload>
<NInput v-model:value="aiRecordHint" :placeholder="t('ai.record.placeholder')" />
<!-- 识别进度实时日志(SSE 推送) -->
<div v-if="aiRecordProgress.length > 0" class="ai-progress">
<div class="ai-progress-title">
<NIcon :component="SparklesOutline" />
<span>{{ t('ai.record.progressTitle') }}</span>
</div>
<div class="ai-progress-list">
<div
v-for="(item, index) in aiRecordProgress"
:key="index"
class="ai-progress-item"
:class="{ 'is-running': index === aiRecordProgress.length - 1 && aiRecordingImage }"
>
<NIcon
class="ai-progress-icon"
:component="index === aiRecordProgress.length - 1 && aiRecordingImage ? RefreshOutline : CheckmarkCircleOutline"
/>
<span class="ai-progress-time">{{ item.time }}</span>
<span class="ai-progress-text">{{ item.text }}</span>
</div>
</div>
</div>
<NSpace justify="end">
<NButton @click="handleAiRecordCancel">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="aiRecordingImage" :disabled="aiRecordFiles.length === 0 || aiRecordingImage" @click="handleAiRecordParseImage">
{{ t('ai.record.parseImage') }}
</NButton>
</NSpace>
</NSpace>
</NTabPane>
</NTabs>
</NModal>
<!-- 图片记账结果弹窗 -->
<NModal v-model:show="showResultModal" preset="card" :title="t('ai.record.resultTitle')" style="width: 960px; max-width: 96vw;">
<NSpace vertical :size="12">
<NAlert type="info" :show-icon="true">
{{ aiRecordSummary || t('ai.record.resultHint') }}
</NAlert>
<NDataTable
v-model:checked-row-keys="checkedAiRecordKeys"
:columns="aiRecordColumns"
:data="aiRecordRows"
:row-key="(row) => row.key"
:max-height="420"
:scroll-x="900"
size="small"
:bordered="false"
/>
<NSpace justify="end">
<NButton @click="showResultModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="aiRecordImporting" :disabled="checkedAiRecordKeys.length === 0" @click="handleAiRecordImport">
{{ t('ai.record.importSelected', { count: checkedAiRecordKeys.length }) }}
</NButton>
</NSpace>
</NSpace>
</NModal>
<!-- 原始账单数据弹窗 -->
<NModal v-model:show="showRawModal" preset="card" :title="t('transaction.rawDataTitle')" style="width: 640px; max-width: 92vw;">
<NInput :value="rawDataText" type="textarea" readonly :autosize="{ minRows: 8, maxRows: 20 }" />
</NModal>
</div>
</template>
<style scoped>
/* 图片识别进度:浅色卡片 + 步骤列表 */
.ai-progress {
background: #f7f9fc;
border: 1px solid #eef1f6;
border-radius: 8px;
padding: 10px 12px;
}
.ai-progress-title {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
color: #666;
}
.ai-progress-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 180px;
overflow-y: auto;
}
.ai-progress-item {
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 13px;
line-height: 1.5;
color: #555;
}
.ai-progress-icon {
margin-top: 2px;
flex-shrink: 0;
color: #18a058;
}
/* 最新一条处理中时用蓝色旋转图标 */
.ai-progress-item.is-running .ai-progress-icon {
color: #2080f0;
animation: ai-progress-spin 1s linear infinite;
}
.ai-progress-time {
flex-shrink: 0;
margin-top: 1px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
color: #a0a4ab;
}
.ai-progress-text {
word-break: break-all;
}
@keyframes ai-progress-spin {
to {
transform: rotate(360deg);
}
}
</style>
+235
View File
@@ -0,0 +1,235 @@
<script setup lang="ts">
// AI 建议弹窗:展示批量分类/标签建议,勾选后一键应用(含新建分类/标签确认)
import { ref, computed, watch, h } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NAlert } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { applyAiSuggestions } from '../api/ai'
import type { AiSuggestion } from '../api/ai'
/** 建议表格展示所需的交易记录精简字段 */
interface TransactionLite {
id: number
transactionTime: string
type: string
amount: number
remark: string
}
const props = defineProps<{
show: boolean
suggestions: AiSuggestion[]
transactions: TransactionLite[]
scope: string
}>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
(e: 'applied'): void
}>()
const message = useMessage()
const dialog = useDialog()
const { t } = useI18n()
const showModel = computed({
get: () => props.show,
set: (value: boolean) => emit('update:show', value),
})
const checkedAiRowKeys = ref<number[]>([]) // 已勾选建议的代表记录ID
const aiApplying = ref(false) // 应用状态
// 打开时默认全选,用户可取消不需要的建议
watch(() => props.show, (visible) => {
if (visible) {
checkedAiRowKeys.value = props.suggestions.map((s) => s.transactionId)
}
})
/** 建议行:合并代表交易记录信息用于展示 */
const aiRows = computed(() =>
props.suggestions.map((s) => ({
...s,
transaction: props.transactions.find((tx) => tx.id === s.transactionId) || null,
})),
)
/** 已勾选建议覆盖的记录总数(聚合组按组内记录数累加) */
const aiTotalCount = computed(() => {
const selected = new Set(checkedAiRowKeys.value)
return props.suggestions
.filter((s) => selected.has(s.transactionId))
.reduce((sum, s) => sum + (s.transactionIds?.length || 1), 0)
})
/** 勾选建议中需要新建的分类名称(去重) */
const aiNewCategoryNames = computed(() => {
const selected = new Set(checkedAiRowKeys.value)
const names = new Set<string>()
props.suggestions.forEach((s) => {
if (selected.has(s.transactionId) && s.newCategoryName) {
names.add(s.newCategoryName)
}
})
return Array.from(names)
})
/** 勾选建议中需要新建的标签名称(去重) */
const aiNewTagNames = computed(() => {
const selected = new Set(checkedAiRowKeys.value)
const names = new Set<string>()
props.suggestions.forEach((s) => {
if (selected.has(s.transactionId) && s.newTags) {
s.newTags.forEach((tag) => names.add(tag.name))
}
})
return Array.from(names)
})
/** 渲染建议的分类单元格,新建分类带「新建」标记 */
function renderCategoryCell(row: AiSuggestion) {
if (row.newCategoryName) {
return h(NSpace, { size: 4, align: 'center', wrap: false }, {
default: () => [
h(NTag, { size: 'small', type: 'info' }, { default: () => t('ai.newBadge') }),
h('span', null, `${row.newCategoryIcon || ''}${row.newCategoryName}`),
],
})
}
return row.categoryName || '-'
}
/** 渲染建议的标签单元格,新建标签带「新建」标记并使用建议颜色 */
function renderTagsCell(row: AiSuggestion) {
const items: any[] = []
if (row.tagNames && row.tagNames.length > 0) {
items.push(...row.tagNames.map((name) => h(NTag, { size: 'small' }, { default: () => name })))
}
if (row.newTags && row.newTags.length > 0) {
items.push(...row.newTags.map((tag) =>
h(NTag, { size: 'small', color: { color: tag.color, textColor: '#fff' } }, {
default: () => h(NSpace, { size: 2, align: 'center', wrap: false }, {
default: () => [
h('span', null, t('ai.newBadge')),
h('span', null, tag.name),
],
}),
}),
))
}
return items.length > 0 ? h(NSpace, { size: 4, wrap: true }, { default: () => items }) : '-'
}
/** 建议表格列定义(随语言与建议范围响应式更新) */
const aiColumns = computed<DataTableColumns<any>>(() => {
const columns: DataTableColumns<any> = [
{ type: 'selection' },
{
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.count'), key: 'count', width: 90, render: (row) => row.count ?? 1 },
]
if (props.scope !== 'TAG') {
columns.push({
title: t('ai.column.category'), key: 'category', minWidth: 150,
render: (row) => renderCategoryCell(row),
})
}
if (props.scope !== 'CATEGORY') {
columns.push({
title: t('ai.column.tags'), key: 'tags', minWidth: 180,
render: (row) => renderTagsCell(row),
})
}
columns.push({ title: t('ai.column.reason'), key: 'reason', minWidth: 180, ellipsis: { tooltip: true } })
return columns
})
/** 一键应用全部 AI 建议:覆盖分类并追加标签 */
async function handleApply() {
const selected = new Set(checkedAiRowKeys.value)
const chosen = props.suggestions.filter((s) => selected.has(s.transactionId))
if (chosen.length === 0) {
message.warning(t('ai.applyNone'))
return
}
// 勾选中包含新建分类/标签时,先经用户确认再落库
if (aiNewCategoryNames.value.length > 0 || aiNewTagNames.value.length > 0) {
const confirmed = await new Promise<boolean>((resolve) => {
dialog.warning({
title: t('ai.applyNewTitle'),
content: t('ai.applyNewConfirm', {
categories: aiNewCategoryNames.value.length,
tags: aiNewTagNames.value.length,
}),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
onPositiveClick: () => resolve(true),
onNegativeClick: () => resolve(false),
onClose: () => resolve(false),
})
})
if (!confirmed) {
return
}
}
aiApplying.value = true
try {
// 仅提交勾选的建议,聚合建议按组展开为逐条记录
const payload = chosen.flatMap((s) => {
const ids = s.transactionIds?.length ? s.transactionIds : [s.transactionId]
return ids.map((id) => ({
transactionId: id,
categoryId: s.categoryId,
newCategoryName: s.newCategoryName,
newCategoryIcon: s.newCategoryIcon,
tagIds: s.tagIds || [],
newTags: s.newTags || [],
}))
})
const res: any = await applyAiSuggestions(payload)
message.success(t('ai.applySuccess', { count: res.data }))
showModel.value = false
emit('applied')
} catch (e: any) {
message.error(e.message || t('ai.applyFailed'))
} finally {
aiApplying.value = false
}
}
</script>
<template>
<NModal v-model:show="showModel" 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
v-model:checked-row-keys="checkedAiRowKeys"
:columns="aiColumns"
:data="aiRows"
:max-height="420"
:scroll-x="880"
:row-key="(row) => row.transactionId"
size="small"
:bordered="false"
/>
<NSpace justify="end">
<NButton @click="showModel = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="aiApplying" :disabled="checkedAiRowKeys.length === 0" @click="handleApply">
{{ t('ai.applyAll', { count: aiTotalCount }) }}
</NButton>
</NSpace>
</NSpace>
</NModal>
</template>
@@ -0,0 +1,159 @@
<script setup lang="ts">
// 一键设置弹窗:对目标记录批量设置分类/计入统计/标签,并可触发 AI 建议
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage, NButton, NModal, NForm, NFormItem, NSelect } from 'naive-ui'
import { batchUpdateCategory, batchTags } from '../api/transaction'
import { renderCategoryLabel } from '../utils/render'
/** 批量操作目标记录的精简字段 */
interface TargetLite {
id: number
type: string
}
const props = defineProps<{
show: boolean
targets: TargetLite[]
fromSelection: boolean
categories: { label: string; value: number; type: string; icon: string }[]
tagOptions: { label: string; value: number }[]
aiEnabled: boolean
aiScope: string
aiScopeOptions: { label: string; value: string }[]
aiLoading: boolean
}>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
(e: 'update:aiScope', value: string): void
(e: 'suggest'): void
(e: 'done'): void
}>()
const message = useMessage()
const { t } = useI18n()
const showModel = computed({
get: () => props.show,
set: (value: boolean) => emit('update:show', value),
})
const batchExpenseCategoryId = ref<number | null>(null)
const batchIncomeCategoryId = ref<number | null>(null)
const batchIncludeInStats = ref<'null' | 'true' | 'false'>('null')
const batchAddTagIds = ref<number[]>([])
const batchRemoveTagIds = ref<number[]>([])
const batching = ref(false)
const batchTargetIds = computed(() => props.targets.map((tx) => tx.id))
const hasIncomeRecords = computed(() => props.targets.some((tx) => tx.type === 'INCOME'))
const hasExpenseRecords = computed(() => props.targets.some((tx) => tx.type === 'EXPENSE'))
const batchStatsOptions = computed(() => [
{ label: t('transaction.batchStats.keep'), value: 'null' },
{ label: t('transaction.batchStats.include'), value: 'true' },
{ label: t('transaction.batchStats.exclude'), value: 'false' },
])
// 打开时重置表单
watch(() => props.show, (visible) => {
if (!visible) return
batchExpenseCategoryId.value = null
batchIncomeCategoryId.value = null
batchIncludeInStats.value = 'null'
batchAddTagIds.value = []
batchRemoveTagIds.value = []
})
/** 提交批量设置 */
async function handleSubmit() {
const hasCategoryOrStats = !!(batchExpenseCategoryId.value || batchIncomeCategoryId.value || batchIncludeInStats.value !== 'null')
const hasTags = batchAddTagIds.value.length > 0 || batchRemoveTagIds.value.length > 0
if (!hasCategoryOrStats && !hasTags) {
message.warning(t('transaction.batchNothingSelected'))
return
}
batching.value = true
try {
const ids = batchTargetIds.value
if (hasCategoryOrStats) {
await batchUpdateCategory({
ids,
expenseCategoryId: batchExpenseCategoryId.value,
incomeCategoryId: batchIncomeCategoryId.value,
includeInStats: batchIncludeInStats.value === 'true' ? true : batchIncludeInStats.value === 'false' ? false : null,
})
}
if (hasTags) {
await batchTags({
ids,
addTagIds: batchAddTagIds.value,
removeTagIds: batchRemoveTagIds.value,
})
}
message.success(t('transaction.batchUpdated', { count: ids.length }))
showModel.value = false
emit('done')
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
batching.value = false
}
}
</script>
<template>
<NModal v-model:show="showModel" preset="card" :title="t('transaction.batchTitle')" style="width: 480px; max-width: 92vw;">
<NForm>
<NFormItem :label="t('transaction.batchScope')">
<span style="color: #999; font-size: 12px;">
{{ props.fromSelection
? t('transaction.batchScopeHintSelected', { count: props.targets.length })
: t('transaction.batchScopeHintPage', { count: props.targets.length }) }}
</span>
</NFormItem>
<NFormItem v-if="props.aiEnabled" :label="t('ai.suggestButton')">
<div style="display: flex; gap: 8px; width: 100%;">
<NSelect
:value="props.aiScope"
:options="props.aiScopeOptions"
style="flex: 1;"
@update:value="(v: string) => emit('update:aiScope', v)"
/>
<NButton :loading="props.aiLoading" @click="emit('suggest')">{{ t('ai.suggestButton') }}</NButton>
</div>
</NFormItem>
<NFormItem v-if="hasExpenseRecords" :label="t('transaction.expenseCategory')">
<NSelect
v-model:value="batchExpenseCategoryId"
:options="props.categories.filter(c => c.type === 'EXPENSE')"
:render-label="renderCategoryLabel"
filterable
clearable
:placeholder="t('transaction.notSetExpense')"
/>
</NFormItem>
<NFormItem v-if="hasIncomeRecords" :label="t('transaction.incomeCategory')">
<NSelect
v-model:value="batchIncomeCategoryId"
:options="props.categories.filter(c => c.type === 'INCOME')"
:render-label="renderCategoryLabel"
filterable
clearable
:placeholder="t('transaction.notSetIncome')"
/>
</NFormItem>
<NFormItem :label="t('transaction.includeInStats')">
<NSelect v-model:value="batchIncludeInStats" :options="batchStatsOptions" />
</NFormItem>
<NFormItem :label="t('transaction.addTag')">
<NSelect v-model:value="batchAddTagIds" :options="props.tagOptions" multiple clearable filterable :placeholder="t('transaction.notAddTag')" />
</NFormItem>
<NFormItem :label="t('transaction.removeTag')">
<NSelect v-model:value="batchRemoveTagIds" :options="props.tagOptions" multiple clearable filterable :placeholder="t('transaction.notRemoveTag')" />
</NFormItem>
<NButton type="primary" block :loading="batching" @click="handleSubmit">{{ t('common.confirm') }}</NButton>
</NForm>
</NModal>
</template>
+30
View File
@@ -0,0 +1,30 @@
// 日期时间格式化工具:统一交易记录页的时间展示与接口参数格式
/** 将时间戳格式化为查询边界(YYYY-MM-DDTHH:mm:ss),isEnd=true 时取当天 23:59:59 */
export function formatIsoBoundary(ts: number, isEnd = false): string {
const d = new Date(ts)
const pad = (n: number) => String(n).padStart(2, '0')
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const time = isEnd ? '23:59:59' : '00:00:00'
return `${date}T${time}`
}
/** 将时间戳格式化为本地日期时间(YYYY-MM-DDTHH:mm:ss),避免 UTC 转换导致时区偏移 */
export function formatLocalDateTime(ts: number): string {
const d = new Date(ts)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
/** 将时间戳格式化为 yyyy-MM-dd */
export function formatDateLabel(ts: number): string {
const d = new Date(ts)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
/** 将时间格式化为 HH:mm:ss,用于进度日志前缀 */
export function formatProgressTime(date: Date): string {
const pad = (value: number) => String(value).padStart(2, '0')
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
package cn.rosercode.ledgerly.exporter;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import org.springframework.stereotype.Component;
import cn.rosercode.ledgerly.entity.Category;
import cn.rosercode.ledgerly.entity.Transaction;
import cn.rosercode.ledgerly.enums.ChannelType;
import cn.rosercode.ledgerly.enums.TransactionType;
import cn.rosercode.ledgerly.vo.TransactionExportVO;
/**
* 交易记录导出视图组装器。
*
* <p>将交易实体与关联分类转换为导出视图对象,并处理类型文案、金额格式化、
* 渠道本地化与隐藏备注占位。</p>
*
* @date 2026-10-09 10:55:53
*/
@Component
public class TransactionExportAssembler {
/** 备注被隐藏时在导出文件中使用的占位符 */
private static final String REMARK_HIDDEN_PLACEHOLDER = "******";
/**
* 将交易实体和关联分类转换为导出视图对象。
*
* @param t 交易实体
* @param category 关联的分类实体(可为 null)
* @param channelNames 自定义渠道「编码 → 名称」映射
* @return 交易导出视图对象
*/
public TransactionExportVO toExportVO(Transaction t, Category category, Map<String, String> channelNames) {
TransactionExportVO vo = new TransactionExportVO();
Boolean include = t.getIncludeInStats() == null ? Boolean.TRUE : t.getIncludeInStats();
if (include) {
vo.setType(TransactionType.labelOf(t.getType()));
} else {
vo.setType("不计入统计");
}
vo.setCategoryName(category != null ? category.getName() : "");
vo.setAmount(String.format("%.2f", t.getAmount()));
vo.setChannel(resolveChannelLabel(t.getChannel(), channelNames));
// 备注被标记为隐藏时,导出同样以占位符替代,避免泄露
if (Boolean.TRUE.equals(t.getRemarkHidden())) {
vo.setRemark(REMARK_HIDDEN_PLACEHOLDER);
} else {
vo.setRemark(t.getRemark() != null ? t.getRemark() : "");
}
vo.setTransactionTime(t.getTransactionTime() != null
? t.getTransactionTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
: "");
vo.setIncludeInStats(include ? "是" : "否");
return vo;
}
/**
* 将交易渠道编码转换为中文标签,优先取用户自定义渠道名称。
*
* @param code 交易渠道编码
* @param channelNames 自定义渠道「编码 → 名称」映射
* @return 中文标签
*/
private String resolveChannelLabel(String code, Map<String, String> channelNames) {
if (code == null) {
return "";
}
String custom = channelNames.get(code);
if (custom != null) {
return custom;
}
// 兼容历史 BANK 渠道
if ("BANK".equals(code)) {
return "银行卡";
}
return ChannelType.labelOf(code);
}
}
@@ -0,0 +1,237 @@
package cn.rosercode.ledgerly.service.impl;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import cn.rosercode.ledgerly.common.BusinessException;
import cn.rosercode.ledgerly.dto.BatchCategoryRequest;
import cn.rosercode.ledgerly.dto.BatchTagRequest;
import cn.rosercode.ledgerly.entity.Category;
import cn.rosercode.ledgerly.entity.Transaction;
import cn.rosercode.ledgerly.entity.TransactionTag;
import cn.rosercode.ledgerly.enums.TransactionType;
import cn.rosercode.ledgerly.mapper.CategoryMapper;
import cn.rosercode.ledgerly.mapper.TransactionMapper;
import cn.rosercode.ledgerly.mapper.TransactionTagMapper;
import cn.rosercode.ledgerly.service.TransactionGroupService;
/**
* 交易记录批量操作组件。
*
* <p>承载批量删除、批量设置分类/计入统计与批量设置标签,均按当前用户隔离。</p>
*
* @date 2026-10-09 10:55:53
*/
@Service
@Slf4j
public class TransactionBatchService {
private final TransactionMapper transactionMapper;
private final CategoryMapper categoryMapper;
private final TransactionTagMapper transactionTagMapper;
private final TransactionGroupService transactionGroupService;
private final TransactionTagWriter tagWriter;
public TransactionBatchService(TransactionMapper transactionMapper,
CategoryMapper categoryMapper,
TransactionTagMapper transactionTagMapper,
TransactionGroupService transactionGroupService,
TransactionTagWriter tagWriter) {
this.transactionMapper = transactionMapper;
this.categoryMapper = categoryMapper;
this.transactionTagMapper = transactionTagMapper;
this.transactionGroupService = transactionGroupService;
this.tagWriter = tagWriter;
}
/**
* 仅删除归属当前用户的记录,先清理标签关联再批量删除,返回实际删除数。
*
* @param userId 用户ID
* @param ids 待删除记录ID列表
* @return 实际删除的记录数
*/
@Transactional
public int batchDelete(Long userId, List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return 0;
}
// 仅保留属于当前用户的记录ID
List<Long> ownedIds = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.select(Transaction::getId)
.eq(Transaction::getUserId, userId)
.in(Transaction::getId, ids))
.stream()
.map(Transaction::getId)
.collect(Collectors.toList());
if (ownedIds.isEmpty()) {
return 0;
}
// 先清理标签关联,再批量删除记录
tagWriter.deleteByTransactionIds(ownedIds);
transactionGroupService.removeTransactionFromGroups(userId, ownedIds);
int deleted = transactionMapper.deleteByIds(ownedIds);
log.info("批量删除交易记录: userId={}, 请求={}, 实际={}", userId, ids.size(), deleted);
return deleted;
}
/**
* 按交易类型分别批量设置分类:仅更新与所选分类类型一致、且属于当前用户的记录。
*
* @param userId 用户ID
* @param request 批量设置请求
* @return 实际更新的记录数
*/
@Transactional
public int batchUpdateCategory(Long userId, BatchCategoryRequest request) {
List<Long> ids = request.getIds();
if (ids == null || ids.isEmpty()) {
throw new BusinessException("请至少选择一条记录");
}
if (ids.size() > 1000) {
throw new BusinessException("单次最多更新1000条记录");
}
// 分类与计入统计均为可选,仅在提供分类时校验归属与类型
Category expenseCategory = request.getExpenseCategoryId() == null ? null
: requireCategory(userId, request.getExpenseCategoryId(), TransactionType.EXPENSE.name());
Category incomeCategory = request.getIncomeCategoryId() == null ? null
: requireCategory(userId, request.getIncomeCategoryId(), TransactionType.INCOME.name());
Boolean includeInStats = request.getIncludeInStats();
if (expenseCategory == null && incomeCategory == null && includeInStats == null) {
throw new BusinessException("请至少选择分类或计入统计");
}
// 查询待更新记录,按交易类型拆分ID,避免类型不匹配的更新
List<Transaction> transactions = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.select(Transaction::getId, Transaction::getType)
.eq(Transaction::getUserId, userId)
.in(Transaction::getId, ids));
List<Long> expenseIds = transactions.stream()
.filter(t -> TransactionType.EXPENSE.name().equals(t.getType()))
.map(Transaction::getId)
.collect(Collectors.toList());
List<Long> incomeIds = transactions.stream()
.filter(t -> TransactionType.INCOME.name().equals(t.getType()))
.map(Transaction::getId)
.collect(Collectors.toList());
// 按类型分组,同组内一次写入分类与计入统计,避免重复计数
int updated = 0;
updated += batchUpdate(userId, expenseIds, expenseCategory, includeInStats);
updated += batchUpdate(userId, incomeIds, incomeCategory, includeInStats);
log.info("批量设置分类: userId={}, 请求={}, 实际={}", userId, ids.size(), updated);
return updated;
}
/**
* 仅对属于当前用户的记录生效;先移除指定标签,再补充尚未存在的标签。
*
* @param userId 用户ID
* @param request 批量设置标签请求
* @return 实际处理的记录数
*/
@Transactional
public int batchTags(Long userId, BatchTagRequest request) {
List<Long> ids = request.getIds();
if (ids == null || ids.isEmpty()) {
throw new BusinessException("请至少选择一条记录");
}
List<Long> addTagIds = tagWriter.validTagIds(userId, request.getAddTagIds());
List<Long> removeTagIds = tagWriter.validTagIds(userId, request.getRemoveTagIds());
if (addTagIds.isEmpty() && removeTagIds.isEmpty()) {
throw new BusinessException("请至少选择要添加或移除的标签");
}
// 仅处理属于当前用户的记录
List<Long> validIds = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.select(Transaction::getId)
.eq(Transaction::getUserId, userId)
.in(Transaction::getId, ids))
.stream().map(Transaction::getId).collect(Collectors.toList());
if (validIds.isEmpty()) {
return 0;
}
// 移除标签关联
if (!removeTagIds.isEmpty()) {
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
.in(TransactionTag::getTransactionId, validIds)
.in(TransactionTag::getTagId, removeTagIds));
}
// 添加标签关联,跳过已存在的组合
if (!addTagIds.isEmpty()) {
List<TransactionTag> existing = transactionTagMapper.selectList(new LambdaQueryWrapper<TransactionTag>()
.in(TransactionTag::getTransactionId, validIds)
.in(TransactionTag::getTagId, addTagIds));
Set<String> existingKeys = existing.stream()
.map(rel -> rel.getTransactionId() + ":" + rel.getTagId())
.collect(Collectors.toSet());
for (Long transactionId : validIds) {
for (Long tagId : addTagIds) {
if (existingKeys.contains(transactionId + ":" + tagId)) {
continue;
}
TransactionTag relation = new TransactionTag();
relation.setTransactionId(transactionId);
relation.setTagId(tagId);
transactionTagMapper.insert(relation);
}
}
}
log.info("批量设置标签: userId={}, 记录={}, 添加={}, 移除={}",
userId, validIds.size(), addTagIds.size(), removeTagIds.size());
return validIds.size();
}
/**
* 对指定记录批量写入分类与计入统计标记。
*
* @param userId 用户ID
* @param ids 待更新记录ID列表
* @param category 目标分类,为 null 时不修改分类
* @param includeInStats 计入统计标记,为 null 时不修改
* @return 实际更新的记录数
*/
private int batchUpdate(Long userId, List<Long> ids, Category category, Boolean includeInStats) {
if (ids.isEmpty() || (category == null && includeInStats == null)) {
return 0;
}
LambdaUpdateWrapper<Transaction> wrapper = new LambdaUpdateWrapper<Transaction>()
.eq(Transaction::getUserId, userId)
.in(Transaction::getId, ids);
if (category != null) {
wrapper.set(Transaction::getCategoryId, category.getId());
}
if (includeInStats != null) {
wrapper.set(Transaction::getIncludeInStats, includeInStats);
}
return transactionMapper.update(null, wrapper);
}
/**
* 校验分类是否存在、归属当前用户且类型匹配。
*
* @param userId 用户ID
* @param categoryId 分类ID
* @param type 期望的分类类型
* @return 分类实体
*/
private Category requireCategory(Long userId, Long categoryId, String type) {
Category category = categoryMapper.selectById(categoryId);
if (category == null || !category.getUserId().equals(userId)) {
throw new BusinessException("分类不存在");
}
if (!type.equals(category.getType())) {
throw new BusinessException("分类与交易类型不匹配");
}
return category;
}
}
@@ -0,0 +1,141 @@
package cn.rosercode.ledgerly.service.impl;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.stream.Collectors;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import cn.rosercode.ledgerly.dto.TransactionQuery;
import cn.rosercode.ledgerly.entity.Transaction;
import cn.rosercode.ledgerly.service.UserPreferenceService;
/**
* 交易记录查询条件构建组件。
*
* <p>将分页查询与导出共用的筛选条件拼装逻辑抽取于此,避免两处重复维护:
* 包含收支类型、分类(含未分类)、时间范围、渠道、计入统计、金额区间、
* 备注关键字、备注隐藏偏好与标签(含标签/无标签/未打标签)。</p>
*
* @date 2026-10-09 10:55:53
*/
@Component
public class TransactionQuerySupport {
private final UserPreferenceService userPreferenceService;
public TransactionQuerySupport(UserPreferenceService userPreferenceService) {
this.userPreferenceService = userPreferenceService;
}
/**
* 根据查询参数构建交易记录的筛选条件。
*
* @param userId 用户ID
* @param query 查询参数
* @return 查询条件包装器
*/
public LambdaQueryWrapper<Transaction> buildQueryWrapper(Long userId, TransactionQuery query) {
LambdaQueryWrapper<Transaction> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Transaction::getUserId, userId);
// 按交易类型筛选(多选)
if (query.getTypes() != null && !query.getTypes().isEmpty()) {
wrapper.in(Transaction::getType, query.getTypes());
}
// 按分类ID筛选(多选);同时勾选「未分类」时与其取并集
boolean hasCategoryIds = query.getCategoryIds() != null && !query.getCategoryIds().isEmpty();
boolean uncategorized = Boolean.TRUE.equals(query.getUncategorized());
if (hasCategoryIds && uncategorized) {
wrapper.and(w -> w.in(Transaction::getCategoryId, query.getCategoryIds())
.or().isNull(Transaction::getCategoryId));
} else if (hasCategoryIds) {
wrapper.in(Transaction::getCategoryId, query.getCategoryIds());
} else if (uncategorized) {
wrapper.isNull(Transaction::getCategoryId);
}
// 按起始时间筛选
if (StringUtils.hasText(query.getStartTime())) {
wrapper.ge(Transaction::getTransactionTime,
LocalDateTime.parse(query.getStartTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME));
}
// 按结束时间筛选
if (StringUtils.hasText(query.getEndTime())) {
wrapper.le(Transaction::getTransactionTime,
LocalDateTime.parse(query.getEndTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME));
}
// 按交易渠道筛选(多选)
if (query.getChannels() != null && !query.getChannels().isEmpty()) {
wrapper.in(Transaction::getChannel, query.getChannels());
}
// 按是否计入统计筛选
if (query.getIncludeInStats() != null) {
wrapper.eq(Transaction::getIncludeInStats, query.getIncludeInStats());
}
// 按金额区间筛选
if (query.getMinAmount() != null) {
wrapper.ge(Transaction::getAmount, query.getMinAmount());
}
if (query.getMaxAmount() != null) {
wrapper.le(Transaction::getAmount, query.getMaxAmount());
}
// 按备注关键字模糊匹配
if (StringUtils.hasText(query.getRemark())) {
wrapper.like(Transaction::getRemark, query.getRemark());
}
// 是否显示已隐藏备注的记录由用户偏好控制,默认不显示
if (!userPreferenceService.getShowHiddenData(userId)) {
wrapper.and(w -> w.isNull(Transaction::getRemarkHidden)
.or().eq(Transaction::getRemarkHidden, false));
}
// 按标签筛选:命中任一选中标签、有任意标签,或筛选无任何标签的记录(多条件取并集)
boolean hasTagIds = query.getTagIds() != null && !query.getTagIds().isEmpty();
boolean untagged = Boolean.TRUE.equals(query.getUntagged());
boolean tagged = Boolean.TRUE.equals(query.getTagged());
if (hasTagIds || untagged || tagged) {
wrapper.and(w -> {
boolean appended = false;
if (hasTagIds) {
w.inSql(Transaction::getId, "SELECT transaction_id FROM t_transaction_tag WHERE tag_id IN ("
+ query.getTagIds().stream().map(String::valueOf).collect(Collectors.joining(",")) + ")");
appended = true;
}
if (tagged) {
if (appended) {
w.or();
}
w.apply("EXISTS (SELECT 1 FROM t_transaction_tag tt WHERE tt.transaction_id = t_transaction.id)");
appended = true;
}
if (untagged) {
if (appended) {
w.or();
}
w.apply("NOT EXISTS (SELECT 1 FROM t_transaction_tag tt WHERE tt.transaction_id = t_transaction.id)");
}
});
}
return wrapper;
}
/**
* 应用排序条件:默认按交易时间降序,支持按交易时间或金额升/降序。
*
* <p>排序字段与方向均采用白名单,非法值回退为交易时间降序,避免注入风险。</p>
*
* @param wrapper 查询条件
* @param query 查询参数
*/
public void applySort(LambdaQueryWrapper<Transaction> wrapper, TransactionQuery query) {
boolean asc = "asc".equalsIgnoreCase(query.getSortOrder());
if ("amount".equals(query.getSortField())) {
wrapper.orderBy(true, asc, Transaction::getAmount);
} else if ("transactionTime".equals(query.getSortField())) {
wrapper.orderBy(true, asc, Transaction::getTransactionTime);
} else {
wrapper.orderByDesc(Transaction::getTransactionTime);
}
}
}
@@ -1,23 +1,20 @@
package cn.rosercode.ledgerly.service.impl; package cn.rosercode.ledgerly.service.impl;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import cn.rosercode.ledgerly.common.BusinessException; import cn.rosercode.ledgerly.common.BusinessException;
import cn.rosercode.ledgerly.common.PageResult; import cn.rosercode.ledgerly.common.PageResult;
@@ -30,72 +27,69 @@ import cn.rosercode.ledgerly.dto.TransactionRequest;
import cn.rosercode.ledgerly.entity.AiImage; import cn.rosercode.ledgerly.entity.AiImage;
import cn.rosercode.ledgerly.entity.AiImageTransaction; import cn.rosercode.ledgerly.entity.AiImageTransaction;
import cn.rosercode.ledgerly.entity.Category; import cn.rosercode.ledgerly.entity.Category;
import cn.rosercode.ledgerly.entity.Tag;
import cn.rosercode.ledgerly.entity.Transaction; import cn.rosercode.ledgerly.entity.Transaction;
import cn.rosercode.ledgerly.entity.TransactionGroupItem; import cn.rosercode.ledgerly.exporter.TransactionExportAssembler;
import cn.rosercode.ledgerly.entity.TransactionTag;
import cn.rosercode.ledgerly.enums.ChannelType;
import cn.rosercode.ledgerly.enums.TransactionType;
import cn.rosercode.ledgerly.mapper.AiImageMapper; import cn.rosercode.ledgerly.mapper.AiImageMapper;
import cn.rosercode.ledgerly.mapper.AiImageTransactionMapper; import cn.rosercode.ledgerly.mapper.AiImageTransactionMapper;
import cn.rosercode.ledgerly.mapper.CategoryMapper; import cn.rosercode.ledgerly.mapper.CategoryMapper;
import cn.rosercode.ledgerly.mapper.TagMapper;
import cn.rosercode.ledgerly.mapper.TransactionGroupItemMapper;
import cn.rosercode.ledgerly.mapper.TransactionMapper; import cn.rosercode.ledgerly.mapper.TransactionMapper;
import cn.rosercode.ledgerly.mapper.TransactionTagMapper;
import cn.rosercode.ledgerly.service.ChannelService; import cn.rosercode.ledgerly.service.ChannelService;
import cn.rosercode.ledgerly.service.TransactionGroupService; import cn.rosercode.ledgerly.service.TransactionGroupService;
import cn.rosercode.ledgerly.service.TransactionService; import cn.rosercode.ledgerly.service.TransactionService;
import cn.rosercode.ledgerly.service.UserPreferenceService;
import cn.rosercode.ledgerly.util.TransactionDedupUtils; import cn.rosercode.ledgerly.util.TransactionDedupUtils;
import cn.rosercode.ledgerly.vo.TagVO;
import cn.rosercode.ledgerly.vo.TransactionExportVO; import cn.rosercode.ledgerly.vo.TransactionExportVO;
import cn.rosercode.ledgerly.vo.TransactionVO; import cn.rosercode.ledgerly.vo.TransactionVO;
/** /**
* 交易记录服务实现类,处理收支记录的分页查询、创建、更新和删除等业务逻辑。 * 交易记录服务实现类,处理收支记录的分页查询、创建、更新和删除等业务逻辑。
*
* <p>作为 {@link TransactionService} 的门面,内部委托查询构建、视图富化、标签写入、
* 批量操作与导出组装等协作组件,保持对外接口不变。</p>
*/ */
@Service @Service
@Slf4j @Slf4j
public class TransactionServiceImpl implements TransactionService { public class TransactionServiceImpl implements TransactionService {
/** 备注被隐藏时在导出文件中使用的占位符 */ /** 导出记录数上限,超过时提示缩小筛选范围,避免全量加载导致 OOM */
private static final String REMARK_HIDDEN_PLACEHOLDER = "******"; private static final int EXPORT_MAX_ROWS = 10000;
private final TransactionMapper transactionMapper; private final TransactionMapper transactionMapper;
private final CategoryMapper categoryMapper; private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
private final TransactionTagMapper transactionTagMapper;
private final AiImageMapper aiImageMapper; private final AiImageMapper aiImageMapper;
private final AiImageTransactionMapper aiImageTransactionMapper; private final AiImageTransactionMapper aiImageTransactionMapper;
private final TransactionGroupItemMapper transactionGroupItemMapper;
private final ChannelService channelService; private final ChannelService channelService;
private final TransactionGroupService transactionGroupService; private final TransactionGroupService transactionGroupService;
private final UserPreferenceService userPreferenceService;
private final VoConverter voConverter; private final VoConverter voConverter;
private final TransactionQuerySupport querySupport;
private final TransactionVoEnricher enricher;
private final TransactionTagWriter tagWriter;
private final TransactionBatchService batchService;
private final TransactionExportAssembler exportAssembler;
public TransactionServiceImpl(TransactionMapper transactionMapper, public TransactionServiceImpl(TransactionMapper transactionMapper,
CategoryMapper categoryMapper, CategoryMapper categoryMapper,
TagMapper tagMapper,
TransactionTagMapper transactionTagMapper,
AiImageMapper aiImageMapper, AiImageMapper aiImageMapper,
AiImageTransactionMapper aiImageTransactionMapper, AiImageTransactionMapper aiImageTransactionMapper,
TransactionGroupItemMapper transactionGroupItemMapper,
ChannelService channelService, ChannelService channelService,
TransactionGroupService transactionGroupService, TransactionGroupService transactionGroupService,
UserPreferenceService userPreferenceService, VoConverter voConverter,
VoConverter voConverter) { TransactionQuerySupport querySupport,
TransactionVoEnricher enricher,
TransactionTagWriter tagWriter,
TransactionBatchService batchService,
TransactionExportAssembler exportAssembler) {
this.transactionMapper = transactionMapper; this.transactionMapper = transactionMapper;
this.categoryMapper = categoryMapper; this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
this.transactionTagMapper = transactionTagMapper;
this.aiImageMapper = aiImageMapper; this.aiImageMapper = aiImageMapper;
this.aiImageTransactionMapper = aiImageTransactionMapper; this.aiImageTransactionMapper = aiImageTransactionMapper;
this.transactionGroupItemMapper = transactionGroupItemMapper;
this.channelService = channelService; this.channelService = channelService;
this.transactionGroupService = transactionGroupService; this.transactionGroupService = transactionGroupService;
this.userPreferenceService = userPreferenceService;
this.voConverter = voConverter; this.voConverter = voConverter;
this.querySupport = querySupport;
this.enricher = enricher;
this.tagWriter = tagWriter;
this.batchService = batchService;
this.exportAssembler = exportAssembler;
} }
/** /**
@@ -106,113 +100,19 @@ public class TransactionServiceImpl implements TransactionService {
*/ */
@Override @Override
public PageResult<TransactionVO> page(Long userId, TransactionQuery query) { public PageResult<TransactionVO> page(Long userId, TransactionQuery query) {
LambdaQueryWrapper<Transaction> wrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<Transaction> wrapper = querySupport.buildQueryWrapper(userId, query);
wrapper.eq(Transaction::getUserId, userId); querySupport.applySort(wrapper, query);
// 按交易类型筛选(多选)
if (query.getTypes() != null && !query.getTypes().isEmpty()) {
wrapper.in(Transaction::getType, query.getTypes());
}
// 按分类ID筛选(多选);同时勾选「未分类」时与其取并集
boolean hasCategoryIds = query.getCategoryIds() != null && !query.getCategoryIds().isEmpty();
boolean uncategorized = Boolean.TRUE.equals(query.getUncategorized());
if (hasCategoryIds && uncategorized) {
wrapper.and(w -> w.in(Transaction::getCategoryId, query.getCategoryIds())
.or().isNull(Transaction::getCategoryId));
} else if (hasCategoryIds) {
wrapper.in(Transaction::getCategoryId, query.getCategoryIds());
} else if (uncategorized) {
wrapper.isNull(Transaction::getCategoryId);
}
// 按起始时间筛选
if (StringUtils.hasText(query.getStartTime())) {
wrapper.ge(Transaction::getTransactionTime,
LocalDateTime.parse(query.getStartTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME));
}
// 按结束时间筛选
if (StringUtils.hasText(query.getEndTime())) {
wrapper.le(Transaction::getTransactionTime,
LocalDateTime.parse(query.getEndTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME));
}
// 按交易渠道筛选(多选)
if (query.getChannels() != null && !query.getChannels().isEmpty()) {
wrapper.in(Transaction::getChannel, query.getChannels());
}
// 按是否计入统计筛选
if (query.getIncludeInStats() != null) {
wrapper.eq(Transaction::getIncludeInStats, query.getIncludeInStats());
}
// 按金额区间筛选
if (query.getMinAmount() != null) {
wrapper.ge(Transaction::getAmount, query.getMinAmount());
}
if (query.getMaxAmount() != null) {
wrapper.le(Transaction::getAmount, query.getMaxAmount());
}
// 按备注关键字模糊匹配
if (StringUtils.hasText(query.getRemark())) {
wrapper.like(Transaction::getRemark, query.getRemark());
}
// 是否显示已隐藏备注的记录由用户偏好控制,默认不显示
if (!userPreferenceService.getShowHiddenData(userId)) {
wrapper.and(w -> w.isNull(Transaction::getRemarkHidden)
.or().eq(Transaction::getRemarkHidden, false));
}
// 按标签筛选:命中任一选中标签、有任意标签,或筛选无任何标签的记录(多条件取并集)
boolean hasTagIds = query.getTagIds() != null && !query.getTagIds().isEmpty();
boolean untagged = Boolean.TRUE.equals(query.getUntagged());
boolean tagged = Boolean.TRUE.equals(query.getTagged());
if (hasTagIds || untagged || tagged) {
wrapper.and(w -> {
boolean appended = false;
if (hasTagIds) {
w.inSql(Transaction::getId, "SELECT transaction_id FROM t_transaction_tag WHERE tag_id IN ("
+ query.getTagIds().stream().map(String::valueOf).collect(Collectors.joining(",")) + ")");
appended = true;
}
if (tagged) {
if (appended) {
w.or();
}
w.apply("EXISTS (SELECT 1 FROM t_transaction_tag tt WHERE tt.transaction_id = t_transaction.id)");
appended = true;
}
if (untagged) {
if (appended) {
w.or();
}
w.apply("NOT EXISTS (SELECT 1 FROM t_transaction_tag tt WHERE tt.transaction_id = t_transaction.id)");
}
});
}
// 应用排序:默认按交易时间降序,支持按交易时间/金额排序
applySort(wrapper, query);
Page<Transaction> page = new Page<>(query.getPage(), query.getSize()); Page<Transaction> page = new Page<>(query.getPage(), query.getSize());
Page<Transaction> result = transactionMapper.selectPage(page, wrapper); Page<Transaction> result = transactionMapper.selectPage(page, wrapper);
// 批量查询关联的分类信息,避免 N+1 查询问题 // 批量查询关联的分类信息,避免 N+1 查询问题
List<Long> categoryIds = result.getRecords().stream() Map<Long, Category> categoryMap = enricher.loadCategoryMap(result.getRecords());
.map(Transaction::getCategoryId)
.distinct()
.collect(Collectors.toList());
Map<Long, Category> categoryMap = Map.of();
if (!categoryIds.isEmpty()) {
List<Category> categories = categoryMapper.selectBatchIds(categoryIds);
categoryMap = categories.stream().collect(Collectors.toMap(Category::getId, c -> c));
}
// 将交易记录转换为视图对象,并填充分类名称和图标
Map<Long, Category> finalCategoryMap = categoryMap;
List<TransactionVO> voList = result.getRecords().stream() List<TransactionVO> voList = result.getRecords().stream()
.map(t -> voConverter.toTransactionVO(t, finalCategoryMap.get(t.getCategoryId()))) .map(t -> voConverter.toTransactionVO(t, categoryMap.get(t.getCategoryId())))
.collect(Collectors.toList()); .collect(Collectors.toList());
// 批量填充标签与来源图片 // 批量填充标签、来源图片与关系组数量
fillTags(voList); enricher.enrich(voList);
fillImages(voList);
fillRelationGroupCount(voList);
return new PageResult<>(voList, result.getTotal(), result.getCurrent(), result.getSize()); return new PageResult<>(voList, result.getTotal(), result.getCurrent(), result.getSize());
} }
@@ -242,7 +142,7 @@ public class TransactionServiceImpl implements TransactionService {
transaction.setIncludeInStats(request.getIncludeInStats() == null ? Boolean.TRUE : request.getIncludeInStats()); transaction.setIncludeInStats(request.getIncludeInStats() == null ? Boolean.TRUE : request.getIncludeInStats());
transactionMapper.insert(transaction); transactionMapper.insert(transaction);
// 同步标签关联 // 同步标签关联
saveTransactionTags(userId, transaction.getId(), request.getTagIds()); tagWriter.saveTransactionTags(userId, transaction.getId(), request.getTagIds());
// 金额属敏感信息,日志仅记录类型,不记录金额 // 金额属敏感信息,日志仅记录类型,不记录金额
log.info("新增交易记录: userId={}, id={}, type={}", userId, transaction.getId(), transaction.getType()); log.info("新增交易记录: userId={}, id={}, type={}", userId, transaction.getId(), transaction.getType());
} }
@@ -302,8 +202,18 @@ public class TransactionServiceImpl implements TransactionService {
} }
} }
// 预加载分类,避免循环内逐条查库(N+1)
Set<Long> categoryIds = records.stream()
.map(TransactionRequest::getCategoryId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, Category> categoryMap = categoryIds.isEmpty() ? Map.of()
: categoryMapper.selectBatchIds(categoryIds).stream()
.collect(Collectors.toMap(Category::getId, c -> c));
List<Long> createdIds = new ArrayList<>(); List<Long> createdIds = new ArrayList<>();
List<Long> createdImageIds = new ArrayList<>(); List<Long> createdImageIds = new ArrayList<>();
List<List<Long>> createdTagIds = new ArrayList<>();
Set<String> seen = new HashSet<>(); Set<String> seen = new HashSet<>();
int skipped = 0; int skipped = 0;
for (int i = 0; i < records.size(); i++) { for (int i = 0; i < records.size(); i++) {
@@ -317,7 +227,7 @@ public class TransactionServiceImpl implements TransactionService {
} }
// 分类可选:仅在指定分类时校验归属 // 分类可选:仅在指定分类时校验归属
if (record.getCategoryId() != null) { if (record.getCategoryId() != null) {
Category category = categoryMapper.selectById(record.getCategoryId()); Category category = categoryMap.get(record.getCategoryId());
if (category == null || !category.getUserId().equals(userId)) { if (category == null || !category.getUserId().equals(userId)) {
throw new BusinessException("分类不存在"); throw new BusinessException("分类不存在");
} }
@@ -334,11 +244,14 @@ public class TransactionServiceImpl implements TransactionService {
: record.getIncludeInStats()); : record.getIncludeInStats());
transaction.setRawData(record.getRawData()); transaction.setRawData(record.getRawData());
transactionMapper.insert(transaction); transactionMapper.insert(transaction);
saveTransactionTags(userId, transaction.getId(), record.getTagIds());
createdIds.add(transaction.getId()); createdIds.add(transaction.getId());
createdImageIds.add(recordImageIds.get(i)); createdImageIds.add(recordImageIds.get(i));
createdTagIds.add(record.getTagIds());
} }
// 批量建立标签关联,避免逐条插入
tagWriter.insertTags(userId, createdIds, createdTagIds);
// 按每条记录的来源图片建立图片关联 // 按每条记录的来源图片建立图片关联
for (int i = 0; i < createdIds.size(); i++) { for (int i = 0; i < createdIds.size(); i++) {
Long imageId = createdImageIds.get(i); Long imageId = createdImageIds.get(i);
@@ -387,7 +300,7 @@ public class TransactionServiceImpl implements TransactionService {
transaction.setIncludeInStats(request.getIncludeInStats() == null ? Boolean.TRUE : request.getIncludeInStats()); transaction.setIncludeInStats(request.getIncludeInStats() == null ? Boolean.TRUE : request.getIncludeInStats());
transactionMapper.updateById(transaction); transactionMapper.updateById(transaction);
// 全量替换标签关联 // 全量替换标签关联
saveTransactionTags(userId, id, request.getTagIds()); tagWriter.saveTransactionTags(userId, id, request.getTagIds());
log.info("更新交易记录: userId={}, id={}", userId, id); log.info("更新交易记录: userId={}, id={}", userId, id);
} }
@@ -404,8 +317,7 @@ public class TransactionServiceImpl implements TransactionService {
throw new BusinessException("记录不存在"); throw new BusinessException("记录不存在");
} }
// 先清理标签关联,再删除记录 // 先清理标签关联,再删除记录
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>() tagWriter.deleteByTransactionIds(List.of(id));
.eq(TransactionTag::getTransactionId, id));
transactionGroupService.removeTransactionFromGroups(userId, List.of(id)); transactionGroupService.removeTransactionFromGroups(userId, List.of(id));
transactionMapper.deleteById(id); transactionMapper.deleteById(id);
log.info("删除交易记录: userId={}, id={}", userId, id); log.info("删除交易记录: userId={}, id={}", userId, id);
@@ -413,33 +325,10 @@ public class TransactionServiceImpl implements TransactionService {
/** /**
* {@inheritDoc} * {@inheritDoc}
*
* 仅删除归属当前用户的记录,先清理标签关联再批量删除,返回实际删除数。
*/ */
@Override @Override
@Transactional
public int batchDelete(Long userId, List<Long> ids) { public int batchDelete(Long userId, List<Long> ids) {
if (ids == null || ids.isEmpty()) { return batchService.batchDelete(userId, ids);
return 0;
}
// 仅保留属于当前用户的记录ID
List<Long> ownedIds = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.select(Transaction::getId)
.eq(Transaction::getUserId, userId)
.in(Transaction::getId, ids))
.stream()
.map(Transaction::getId)
.collect(Collectors.toList());
if (ownedIds.isEmpty()) {
return 0;
}
// 先清理标签关联,再批量删除记录
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
.in(TransactionTag::getTransactionId, ownedIds));
transactionGroupService.removeTransactionFromGroups(userId, ownedIds);
int deleted = transactionMapper.deleteByIds(ownedIds);
log.info("批量删除交易记录: userId={}, 请求={}, 实际={}", userId, ids.size(), deleted);
return deleted;
} }
/** /**
@@ -478,445 +367,39 @@ public class TransactionServiceImpl implements TransactionService {
/** /**
* {@inheritDoc} * {@inheritDoc}
*
* 按交易类型分别批量设置分类:仅更新与所选分类类型一致、且属于当前用户的记录。
*/ */
@Override @Override
@Transactional
public int batchUpdateCategory(Long userId, BatchCategoryRequest request) { public int batchUpdateCategory(Long userId, BatchCategoryRequest request) {
List<Long> ids = request.getIds(); return batchService.batchUpdateCategory(userId, request);
if (ids == null || ids.isEmpty()) {
throw new BusinessException("请至少选择一条记录");
}
if (ids.size() > 1000) {
throw new BusinessException("单次最多更新1000条记录");
}
// 分类与计入统计均为可选,仅在提供分类时校验归属与类型
Category expenseCategory = request.getExpenseCategoryId() == null ? null
: requireCategory(userId, request.getExpenseCategoryId(), TransactionType.EXPENSE.name());
Category incomeCategory = request.getIncomeCategoryId() == null ? null
: requireCategory(userId, request.getIncomeCategoryId(), TransactionType.INCOME.name());
Boolean includeInStats = request.getIncludeInStats();
if (expenseCategory == null && incomeCategory == null && includeInStats == null) {
throw new BusinessException("请至少选择分类或计入统计");
}
// 查询待更新记录,按交易类型拆分ID,避免类型不匹配的更新
List<Transaction> transactions = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.select(Transaction::getId, Transaction::getType)
.eq(Transaction::getUserId, userId)
.in(Transaction::getId, ids));
List<Long> expenseIds = transactions.stream()
.filter(t -> TransactionType.EXPENSE.name().equals(t.getType()))
.map(Transaction::getId)
.collect(Collectors.toList());
List<Long> incomeIds = transactions.stream()
.filter(t -> TransactionType.INCOME.name().equals(t.getType()))
.map(Transaction::getId)
.collect(Collectors.toList());
// 按类型分组,同组内一次写入分类与计入统计,避免重复计数
int updated = 0;
updated += batchUpdate(userId, expenseIds, expenseCategory, includeInStats);
updated += batchUpdate(userId, incomeIds, incomeCategory, includeInStats);
log.info("批量设置分类: userId={}, 请求={}, 实际={}", userId, ids.size(), updated);
return updated;
}
/**
* 对指定记录批量写入分类与计入统计标记
*
* @param userId 用户ID
* @param ids 待更新记录ID列表
* @param category 目标分类,为 null 时不修改分类
* @param includeInStats 计入统计标记,为 null 时不修改
* @return 实际更新的记录数
*/
private int batchUpdate(Long userId, List<Long> ids, Category category, Boolean includeInStats) {
if (ids.isEmpty() || (category == null && includeInStats == null)) {
return 0;
}
LambdaUpdateWrapper<Transaction> wrapper = new LambdaUpdateWrapper<Transaction>()
.eq(Transaction::getUserId, userId)
.in(Transaction::getId, ids);
if (category != null) {
wrapper.set(Transaction::getCategoryId, category.getId());
}
if (includeInStats != null) {
wrapper.set(Transaction::getIncludeInStats, includeInStats);
}
return transactionMapper.update(null, wrapper);
} }
/** /**
* {@inheritDoc} * {@inheritDoc}
*
* 仅对属于当前用户的记录生效;先移除指定标签,再补充尚未存在的标签。
*/ */
@Override @Override
@Transactional
public int batchTags(Long userId, BatchTagRequest request) { public int batchTags(Long userId, BatchTagRequest request) {
List<Long> ids = request.getIds(); return batchService.batchTags(userId, request);
if (ids == null || ids.isEmpty()) {
throw new BusinessException("请至少选择一条记录");
}
List<Long> addTagIds = validTagIds(userId, request.getAddTagIds());
List<Long> removeTagIds = validTagIds(userId, request.getRemoveTagIds());
if (addTagIds.isEmpty() && removeTagIds.isEmpty()) {
throw new BusinessException("请至少选择要添加或移除的标签");
}
// 仅处理属于当前用户的记录
List<Long> validIds = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.select(Transaction::getId)
.eq(Transaction::getUserId, userId)
.in(Transaction::getId, ids))
.stream().map(Transaction::getId).collect(Collectors.toList());
if (validIds.isEmpty()) {
return 0;
}
// 移除标签关联
if (!removeTagIds.isEmpty()) {
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
.in(TransactionTag::getTransactionId, validIds)
.in(TransactionTag::getTagId, removeTagIds));
}
// 添加标签关联,跳过已存在的组合
if (!addTagIds.isEmpty()) {
List<TransactionTag> existing = transactionTagMapper.selectList(new LambdaQueryWrapper<TransactionTag>()
.in(TransactionTag::getTransactionId, validIds)
.in(TransactionTag::getTagId, addTagIds));
Set<String> existingKeys = existing.stream()
.map(rel -> rel.getTransactionId() + ":" + rel.getTagId())
.collect(Collectors.toSet());
for (Long transactionId : validIds) {
for (Long tagId : addTagIds) {
if (existingKeys.contains(transactionId + ":" + tagId)) {
continue;
}
TransactionTag relation = new TransactionTag();
relation.setTransactionId(transactionId);
relation.setTagId(tagId);
transactionTagMapper.insert(relation);
}
}
}
log.info("批量设置标签: userId={}, 记录={}, 添加={}, 移除={}",
userId, validIds.size(), addTagIds.size(), removeTagIds.size());
return validIds.size();
}
/**
* 过滤出属于当前用户的标签ID
*
* @param userId 用户ID
* @param tagIds 待校验的标签ID列表
* @return 有效的标签ID列表
*/
private List<Long> validTagIds(Long userId, List<Long> tagIds) {
if (tagIds == null || tagIds.isEmpty()) {
return List.of();
}
return tagMapper.selectBatchIds(tagIds).stream()
.filter(tag -> tag.getUserId().equals(userId))
.map(Tag::getId)
.collect(Collectors.toList());
}
/**
* 全量替换某条交易记录的标签关联
*
* @param userId 用户ID
* @param transactionId 交易记录ID
* @param tagIds 目标标签ID列表,为空表示清空
*/
private void saveTransactionTags(Long userId, Long transactionId, List<Long> tagIds) {
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
.eq(TransactionTag::getTransactionId, transactionId));
for (Long tagId : validTagIds(userId, tagIds)) {
TransactionTag relation = new TransactionTag();
relation.setTransactionId(transactionId);
relation.setTagId(tagId);
transactionTagMapper.insert(relation);
}
}
/**
* 批量填充交易视图对象的标签列表
*
* @param voList 交易视图对象列表
*/
private void fillTags(List<TransactionVO> voList) {
if (voList.isEmpty()) {
return;
}
List<Long> transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList());
List<TransactionTag> relations = transactionTagMapper.selectList(new LambdaQueryWrapper<TransactionTag>()
.in(TransactionTag::getTransactionId, transactionIds));
if (relations.isEmpty()) {
return;
}
List<Long> tagIds = relations.stream().map(TransactionTag::getTagId).distinct().collect(Collectors.toList());
Map<Long, Tag> tagMap = tagMapper.selectBatchIds(tagIds).stream()
.collect(Collectors.toMap(Tag::getId, tag -> tag));
Map<Long, List<TagVO>> tagsByTransaction = new HashMap<>();
for (TransactionTag relation : relations) {
Tag tag = tagMap.get(relation.getTagId());
if (tag == null) {
continue;
}
tagsByTransaction.computeIfAbsent(relation.getTransactionId(), key -> new ArrayList<>())
.add(voConverter.toTagVO(tag));
}
for (TransactionVO vo : voList) {
vo.setTags(tagsByTransaction.getOrDefault(vo.getId(), List.of()));
}
}
/**
* 批量填充交易视图对象的来源图片ID列表
*
* @param voList 交易视图对象列表
*/
private void fillImages(List<TransactionVO> voList) {
if (voList.isEmpty()) {
return;
}
List<Long> transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList());
List<AiImageTransaction> relations = aiImageTransactionMapper.selectList(
new LambdaQueryWrapper<AiImageTransaction>()
.in(AiImageTransaction::getTransactionId, transactionIds));
if (relations.isEmpty()) {
return;
}
Map<Long, List<Long>> imagesByTransaction = new HashMap<>();
for (AiImageTransaction relation : relations) {
imagesByTransaction.computeIfAbsent(relation.getTransactionId(), key -> new ArrayList<>())
.add(relation.getImageId());
}
for (TransactionVO vo : voList) {
vo.setImageIds(imagesByTransaction.getOrDefault(vo.getId(), List.of()));
}
}
/**
* 批量填充交易视图对象所属的关系组数量
*
* @param voList 交易视图对象列表
*/
private void fillRelationGroupCount(List<TransactionVO> voList) {
if (voList.isEmpty()) {
return;
}
List<Long> transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList());
List<TransactionGroupItem> items = transactionGroupItemMapper.selectList(
new LambdaQueryWrapper<TransactionGroupItem>()
.in(TransactionGroupItem::getTransactionId, transactionIds));
Map<Long, Long> counts = items.stream()
.collect(Collectors.groupingBy(TransactionGroupItem::getTransactionId, Collectors.counting()));
for (TransactionVO vo : voList) {
vo.setRelationGroupCount(counts.getOrDefault(vo.getId(), 0L).intValue());
}
}
/**
* 应用排序条件:默认按交易时间降序,支持按交易时间或金额升/降序。
*
* <p>排序字段与方向均采用白名单,非法值回退为交易时间降序,避免注入风险。</p>
*
* @param wrapper 查询条件
* @param query 查询参数
*/
private void applySort(LambdaQueryWrapper<Transaction> wrapper, TransactionQuery query) {
boolean asc = "asc".equalsIgnoreCase(query.getSortOrder());
if ("amount".equals(query.getSortField())) {
wrapper.orderBy(true, asc, Transaction::getAmount);
} else if ("transactionTime".equals(query.getSortField())) {
wrapper.orderBy(true, asc, Transaction::getTransactionTime);
} else {
wrapper.orderByDesc(Transaction::getTransactionTime);
}
}
/**
* 校验分类是否存在、归属当前用户且类型匹配
*
* @param userId 用户ID
* @param categoryId 分类ID
* @param type 期望的分类类型
* @return 分类实体
*/
private Category requireCategory(Long userId, Long categoryId, String type) {
Category category = categoryMapper.selectById(categoryId);
if (category == null || !category.getUserId().equals(userId)) {
throw new BusinessException("分类不存在");
}
if (!type.equals(category.getType())) {
throw new BusinessException("分类与交易类型不匹配");
}
return category;
} }
/** /**
* {@inheritDoc} * {@inheritDoc}
* *
* 根据查询条件导出交易记录,不分页,返回所有匹配的记录。 * 根据查询条件导出交易记录,不分页;超过上限时拒绝以避免内存溢出。
*/ */
@Override @Override
public List<TransactionExportVO> export(Long userId, TransactionQuery query) { public List<TransactionExportVO> export(Long userId, TransactionQuery query) {
LambdaQueryWrapper<Transaction> wrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<Transaction> wrapper = querySupport.buildQueryWrapper(userId, query);
wrapper.eq(Transaction::getUserId, userId); querySupport.applySort(wrapper, query);
// 按交易类型筛选(多选)
if (query.getTypes() != null && !query.getTypes().isEmpty()) {
wrapper.in(Transaction::getType, query.getTypes());
}
// 按分类ID筛选(多选);同时勾选「未分类」时与其取并集
boolean hasCategoryIds = query.getCategoryIds() != null && !query.getCategoryIds().isEmpty();
boolean uncategorized = Boolean.TRUE.equals(query.getUncategorized());
if (hasCategoryIds && uncategorized) {
wrapper.and(w -> w.in(Transaction::getCategoryId, query.getCategoryIds())
.or().isNull(Transaction::getCategoryId));
} else if (hasCategoryIds) {
wrapper.in(Transaction::getCategoryId, query.getCategoryIds());
} else if (uncategorized) {
wrapper.isNull(Transaction::getCategoryId);
}
// 按起始时间筛选
if (StringUtils.hasText(query.getStartTime())) {
wrapper.ge(Transaction::getTransactionTime,
LocalDateTime.parse(query.getStartTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME));
}
// 按结束时间筛选
if (StringUtils.hasText(query.getEndTime())) {
wrapper.le(Transaction::getTransactionTime,
LocalDateTime.parse(query.getEndTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME));
}
// 按交易渠道筛选(多选)
if (query.getChannels() != null && !query.getChannels().isEmpty()) {
wrapper.in(Transaction::getChannel, query.getChannels());
}
// 按是否计入统计筛选
if (query.getIncludeInStats() != null) {
wrapper.eq(Transaction::getIncludeInStats, query.getIncludeInStats());
}
// 按金额区间筛选
if (query.getMinAmount() != null) {
wrapper.ge(Transaction::getAmount, query.getMinAmount());
}
if (query.getMaxAmount() != null) {
wrapper.le(Transaction::getAmount, query.getMaxAmount());
}
// 按备注关键字模糊匹配
if (StringUtils.hasText(query.getRemark())) {
wrapper.like(Transaction::getRemark, query.getRemark());
}
// 是否显示已隐藏备注的记录由用户偏好控制,默认不显示
if (!userPreferenceService.getShowHiddenData(userId)) {
wrapper.and(w -> w.isNull(Transaction::getRemarkHidden)
.or().eq(Transaction::getRemarkHidden, false));
}
// 按标签筛选:命中任一选中标签、有任意标签,或筛选无任何标签的记录(多条件取并集)
boolean hasTagIds = query.getTagIds() != null && !query.getTagIds().isEmpty();
boolean untagged = Boolean.TRUE.equals(query.getUntagged());
boolean tagged = Boolean.TRUE.equals(query.getTagged());
if (hasTagIds || untagged || tagged) {
wrapper.and(w -> {
boolean appended = false;
if (hasTagIds) {
w.inSql(Transaction::getId, "SELECT transaction_id FROM t_transaction_tag WHERE tag_id IN ("
+ query.getTagIds().stream().map(String::valueOf).collect(Collectors.joining(",")) + ")");
appended = true;
}
if (tagged) {
if (appended) {
w.or();
}
w.apply("EXISTS (SELECT 1 FROM t_transaction_tag tt WHERE tt.transaction_id = t_transaction.id)");
appended = true;
}
if (untagged) {
if (appended) {
w.or();
}
w.apply("NOT EXISTS (SELECT 1 FROM t_transaction_tag tt WHERE tt.transaction_id = t_transaction.id)");
}
});
}
// 应用排序:默认按交易时间降序,支持按交易时间/金额排序
applySort(wrapper, query);
List<Transaction> transactions = transactionMapper.selectList(wrapper); List<Transaction> transactions = transactionMapper.selectList(wrapper);
if (transactions.size() > EXPORT_MAX_ROWS) {
// 批量查询关联的分类信息 throw new BusinessException("导出数据量过大,请缩小筛选范围后重试(上限" + EXPORT_MAX_ROWS + "条)");
List<Long> categoryIds = transactions.stream()
.map(Transaction::getCategoryId)
.distinct()
.collect(Collectors.toList());
Map<Long, Category> categoryMap = Map.of();
if (!categoryIds.isEmpty()) {
List<Category> categories = categoryMapper.selectBatchIds(categoryIds);
categoryMap = categories.stream().collect(Collectors.toMap(Category::getId, c -> c));
} }
// 转换为导出视图对象,自定义渠道名称按当前用户解析 Map<Long, Category> categoryMap = enricher.loadCategoryMap(transactions);
Map<Long, Category> finalCategoryMap = categoryMap;
Map<String, String> channelNames = channelService.nameMap(userId); Map<String, String> channelNames = channelService.nameMap(userId);
return transactions.stream() return transactions.stream()
.map(t -> toExportVO(t, finalCategoryMap.get(t.getCategoryId()), channelNames)) .map(t -> exportAssembler.toExportVO(t, categoryMap.get(t.getCategoryId()), channelNames))
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
/**
* 将交易实体和关联分类转换为导出视图对象
*
* @param t 交易实体
* @param category 关联的分类实体(可为 null)
* @param channelNames 自定义渠道「编码 → 名称」映射
* @return 交易导出视图对象
*/
private TransactionExportVO toExportVO(Transaction t, Category category, Map<String, String> channelNames) {
TransactionExportVO vo = new TransactionExportVO();
Boolean include = t.getIncludeInStats() == null ? Boolean.TRUE : t.getIncludeInStats();
if (include) {
vo.setType(TransactionType.labelOf(t.getType()));
} else {
vo.setType("不计入统计");
}
vo.setCategoryName(category != null ? category.getName() : "");
vo.setAmount(String.format("%.2f", t.getAmount()));
vo.setChannel(resolveChannelLabel(t.getChannel(), channelNames));
// 备注被标记为隐藏时,导出同样以占位符替代,避免泄露
if (Boolean.TRUE.equals(t.getRemarkHidden())) {
vo.setRemark(REMARK_HIDDEN_PLACEHOLDER);
} else {
vo.setRemark(t.getRemark() != null ? t.getRemark() : "");
}
vo.setTransactionTime(t.getTransactionTime() != null
? t.getTransactionTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
: "");
vo.setIncludeInStats(include ? "是" : "否");
return vo;
}
/**
* 将交易渠道编码转换为中文标签,优先取用户自定义渠道名称
*
* @param code 交易渠道编码
* @param channelNames 自定义渠道「编码 → 名称」映射
* @return 中文标签
*/
private String resolveChannelLabel(String code, Map<String, String> channelNames) {
if (code == null) return "";
String custom = channelNames.get(code);
if (custom != null) return custom;
// 兼容历史 BANK 渠道
if ("BANK".equals(code)) return "银行卡";
return ChannelType.labelOf(code);
}
} }
@@ -0,0 +1,137 @@
package cn.rosercode.ledgerly.service.impl;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.stereotype.Service;
import cn.rosercode.ledgerly.entity.Tag;
import cn.rosercode.ledgerly.entity.TransactionTag;
import cn.rosercode.ledgerly.mapper.TagMapper;
import cn.rosercode.ledgerly.mapper.TransactionTagMapper;
/**
* 交易与标签关联的写入组件。
*
* <p>统一处理标签归属校验、单条全量替换、批量插入与关联删除,插入统一分批。</p>
*
* @date 2026-10-09 10:55:53
*/
@Service
public class TransactionTagWriter {
/** 批量插入的分批大小 */
private static final int BATCH_SIZE = 500;
private final TagMapper tagMapper;
private final TransactionTagMapper transactionTagMapper;
public TransactionTagWriter(TagMapper tagMapper, TransactionTagMapper transactionTagMapper) {
this.tagMapper = tagMapper;
this.transactionTagMapper = transactionTagMapper;
}
/**
* 过滤出属于当前用户的标签ID。
*
* @param userId 用户ID
* @param tagIds 待校验的标签ID列表
* @return 有效的标签ID列表
*/
public List<Long> validTagIds(Long userId, List<Long> tagIds) {
if (tagIds == null || tagIds.isEmpty()) {
return List.of();
}
return tagMapper.selectBatchIds(tagIds).stream()
.filter(tag -> tag.getUserId().equals(userId))
.map(Tag::getId)
.collect(Collectors.toList());
}
/**
* 全量替换某条交易记录的标签关联。
*
* @param userId 用户ID
* @param transactionId 交易记录ID
* @param tagIds 目标标签ID列表,为空表示清空
*/
public void saveTransactionTags(Long userId, Long transactionId, List<Long> tagIds) {
deleteByTransactionIds(List.of(transactionId));
List<Long> validIds = validTagIds(userId, tagIds);
if (validIds.isEmpty()) {
return;
}
List<TransactionTag> relations = validIds.stream().map(tagId -> {
TransactionTag relation = new TransactionTag();
relation.setTransactionId(transactionId);
relation.setTagId(tagId);
return relation;
}).collect(Collectors.toList());
insertBatch(relations);
}
/**
* 为多条新建交易批量建立标签关联。
*
* <p>标签列表按交易顺序一一对应,先统一校验标签归属再批量插入,避免逐条写库。</p>
*
* @param userId 用户ID
* @param transactionIds 交易记录ID列表
* @param tagIdsList 各交易对应的目标标签ID列表,下标与 transactionIds 对应
*/
public void insertTags(Long userId, List<Long> transactionIds, List<List<Long>> tagIdsList) {
Set<Long> allTagIds = new LinkedHashSet<>();
for (List<Long> ids : tagIdsList) {
if (ids != null) {
allTagIds.addAll(ids);
}
}
Set<Long> validIds = new LinkedHashSet<>(validTagIds(userId, new ArrayList<>(allTagIds)));
List<TransactionTag> relations = new ArrayList<>();
for (int i = 0; i < transactionIds.size(); i++) {
List<Long> ids = tagIdsList.get(i);
if (ids == null) {
continue;
}
for (Long tagId : ids) {
if (!validIds.contains(tagId)) {
continue;
}
TransactionTag relation = new TransactionTag();
relation.setTransactionId(transactionIds.get(i));
relation.setTagId(tagId);
relations.add(relation);
}
}
insertBatch(relations);
}
/**
* 按交易记录ID批量删除标签关联。
*
* @param transactionIds 交易记录ID列表
*/
public void deleteByTransactionIds(List<Long> transactionIds) {
if (transactionIds == null || transactionIds.isEmpty()) {
return;
}
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
.in(TransactionTag::getTransactionId, transactionIds));
}
/**
* 分批插入标签关联。
*
* @param relations 待插入的关联列表
*/
private void insertBatch(List<TransactionTag> relations) {
for (int i = 0; i < relations.size(); i += BATCH_SIZE) {
List<TransactionTag> batch = relations.subList(i, Math.min(i + BATCH_SIZE, relations.size()));
transactionTagMapper.insertBatch(batch);
}
}
}
@@ -0,0 +1,165 @@
package cn.rosercode.ledgerly.service.impl;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.stereotype.Service;
import cn.rosercode.ledgerly.converter.VoConverter;
import cn.rosercode.ledgerly.entity.AiImageTransaction;
import cn.rosercode.ledgerly.entity.Category;
import cn.rosercode.ledgerly.entity.Tag;
import cn.rosercode.ledgerly.entity.Transaction;
import cn.rosercode.ledgerly.entity.TransactionGroupItem;
import cn.rosercode.ledgerly.entity.TransactionTag;
import cn.rosercode.ledgerly.mapper.AiImageTransactionMapper;
import cn.rosercode.ledgerly.mapper.CategoryMapper;
import cn.rosercode.ledgerly.mapper.TagMapper;
import cn.rosercode.ledgerly.mapper.TransactionGroupItemMapper;
import cn.rosercode.ledgerly.mapper.TransactionTagMapper;
import cn.rosercode.ledgerly.vo.TagVO;
import cn.rosercode.ledgerly.vo.TransactionVO;
/**
* 交易视图对象富化组件。
*
* <p>为交易列表视图对象批量补充关联数据:分类映射、标签、来源图片与关系组数量,
* 均采用批量查询以避免 N+1。</p>
*
* @date 2026-10-09 10:55:53
*/
@Service
public class TransactionVoEnricher {
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
private final TransactionTagMapper transactionTagMapper;
private final AiImageTransactionMapper aiImageTransactionMapper;
private final TransactionGroupItemMapper transactionGroupItemMapper;
private final VoConverter voConverter;
public TransactionVoEnricher(CategoryMapper categoryMapper,
TagMapper tagMapper,
TransactionTagMapper transactionTagMapper,
AiImageTransactionMapper aiImageTransactionMapper,
TransactionGroupItemMapper transactionGroupItemMapper,
VoConverter voConverter) {
this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
this.transactionTagMapper = transactionTagMapper;
this.aiImageTransactionMapper = aiImageTransactionMapper;
this.transactionGroupItemMapper = transactionGroupItemMapper;
this.voConverter = voConverter;
}
/**
* 批量加载交易关联的分类映射。
*
* @param transactions 交易实体列表
* @return 「分类ID → 分类实体」映射
*/
public Map<Long, Category> loadCategoryMap(List<Transaction> transactions) {
List<Long> categoryIds = transactions.stream()
.map(Transaction::getCategoryId)
.distinct()
.collect(Collectors.toList());
if (categoryIds.isEmpty()) {
return Map.of();
}
return categoryMapper.selectBatchIds(categoryIds).stream()
.collect(Collectors.toMap(Category::getId, c -> c));
}
/**
* 批量填充交易视图对象的标签、来源图片与关系组数量。
*
* @param voList 交易视图对象列表
*/
public void enrich(List<TransactionVO> voList) {
fillTags(voList);
fillImages(voList);
fillRelationGroupCount(voList);
}
/**
* 批量填充交易视图对象的标签列表。
*
* @param voList 交易视图对象列表
*/
private void fillTags(List<TransactionVO> voList) {
if (voList.isEmpty()) {
return;
}
List<Long> transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList());
List<TransactionTag> relations = transactionTagMapper.selectList(new LambdaQueryWrapper<TransactionTag>()
.in(TransactionTag::getTransactionId, transactionIds));
if (relations.isEmpty()) {
return;
}
List<Long> tagIds = relations.stream().map(TransactionTag::getTagId).distinct().collect(Collectors.toList());
Map<Long, Tag> tagMap = tagMapper.selectBatchIds(tagIds).stream()
.collect(Collectors.toMap(Tag::getId, tag -> tag));
Map<Long, List<TagVO>> tagsByTransaction = new HashMap<>();
for (TransactionTag relation : relations) {
Tag tag = tagMap.get(relation.getTagId());
if (tag == null) {
continue;
}
tagsByTransaction.computeIfAbsent(relation.getTransactionId(), key -> new ArrayList<>())
.add(voConverter.toTagVO(tag));
}
for (TransactionVO vo : voList) {
vo.setTags(tagsByTransaction.getOrDefault(vo.getId(), List.of()));
}
}
/**
* 批量填充交易视图对象的来源图片ID列表。
*
* @param voList 交易视图对象列表
*/
private void fillImages(List<TransactionVO> voList) {
if (voList.isEmpty()) {
return;
}
List<Long> transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList());
List<AiImageTransaction> relations = aiImageTransactionMapper.selectList(
new LambdaQueryWrapper<AiImageTransaction>()
.in(AiImageTransaction::getTransactionId, transactionIds));
if (relations.isEmpty()) {
return;
}
Map<Long, List<Long>> imagesByTransaction = new HashMap<>();
for (AiImageTransaction relation : relations) {
imagesByTransaction.computeIfAbsent(relation.getTransactionId(), key -> new ArrayList<>())
.add(relation.getImageId());
}
for (TransactionVO vo : voList) {
vo.setImageIds(imagesByTransaction.getOrDefault(vo.getId(), List.of()));
}
}
/**
* 批量填充交易视图对象所属的关系组数量。
*
* @param voList 交易视图对象列表
*/
private void fillRelationGroupCount(List<TransactionVO> voList) {
if (voList.isEmpty()) {
return;
}
List<Long> transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList());
List<TransactionGroupItem> items = transactionGroupItemMapper.selectList(
new LambdaQueryWrapper<TransactionGroupItem>()
.in(TransactionGroupItem::getTransactionId, transactionIds));
Map<Long, Long> counts = items.stream()
.collect(Collectors.groupingBy(TransactionGroupItem::getTransactionId, Collectors.counting()));
for (TransactionVO vo : voList) {
vo.setRelationGroupCount(counts.getOrDefault(vo.getId(), 0L).intValue());
}
}
}