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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
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 com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import cn.rosercode.ledgerly.common.BusinessException;
|
||||
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.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.enums.ChannelType;
|
||||
import cn.rosercode.ledgerly.enums.TransactionType;
|
||||
import cn.rosercode.ledgerly.exporter.TransactionExportAssembler;
|
||||
import cn.rosercode.ledgerly.mapper.AiImageMapper;
|
||||
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.TransactionMapper;
|
||||
import cn.rosercode.ledgerly.mapper.TransactionTagMapper;
|
||||
import cn.rosercode.ledgerly.service.ChannelService;
|
||||
import cn.rosercode.ledgerly.service.TransactionGroupService;
|
||||
import cn.rosercode.ledgerly.service.TransactionService;
|
||||
import cn.rosercode.ledgerly.service.UserPreferenceService;
|
||||
import cn.rosercode.ledgerly.util.TransactionDedupUtils;
|
||||
import cn.rosercode.ledgerly.vo.TagVO;
|
||||
import cn.rosercode.ledgerly.vo.TransactionExportVO;
|
||||
import cn.rosercode.ledgerly.vo.TransactionVO;
|
||||
|
||||
/**
|
||||
* 交易记录服务实现类,处理收支记录的分页查询、创建、更新和删除等业务逻辑。
|
||||
*
|
||||
* <p>作为 {@link TransactionService} 的门面,内部委托查询构建、视图富化、标签写入、
|
||||
* 批量操作与导出组装等协作组件,保持对外接口不变。</p>
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class TransactionServiceImpl implements TransactionService {
|
||||
|
||||
/** 备注被隐藏时在导出文件中使用的占位符 */
|
||||
private static final String REMARK_HIDDEN_PLACEHOLDER = "******";
|
||||
/** 导出记录数上限,超过时提示缩小筛选范围,避免全量加载导致 OOM */
|
||||
private static final int EXPORT_MAX_ROWS = 10000;
|
||||
|
||||
private final TransactionMapper transactionMapper;
|
||||
private final CategoryMapper categoryMapper;
|
||||
private final TagMapper tagMapper;
|
||||
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 UserPreferenceService userPreferenceService;
|
||||
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,
|
||||
CategoryMapper categoryMapper,
|
||||
TagMapper tagMapper,
|
||||
TransactionTagMapper transactionTagMapper,
|
||||
AiImageMapper aiImageMapper,
|
||||
AiImageTransactionMapper aiImageTransactionMapper,
|
||||
TransactionGroupItemMapper transactionGroupItemMapper,
|
||||
ChannelService channelService,
|
||||
TransactionGroupService transactionGroupService,
|
||||
UserPreferenceService userPreferenceService,
|
||||
VoConverter voConverter) {
|
||||
VoConverter voConverter,
|
||||
TransactionQuerySupport querySupport,
|
||||
TransactionVoEnricher enricher,
|
||||
TransactionTagWriter tagWriter,
|
||||
TransactionBatchService batchService,
|
||||
TransactionExportAssembler exportAssembler) {
|
||||
this.transactionMapper = transactionMapper;
|
||||
this.categoryMapper = categoryMapper;
|
||||
this.tagMapper = tagMapper;
|
||||
this.transactionTagMapper = transactionTagMapper;
|
||||
this.aiImageMapper = aiImageMapper;
|
||||
this.aiImageTransactionMapper = aiImageTransactionMapper;
|
||||
this.transactionGroupItemMapper = transactionGroupItemMapper;
|
||||
this.channelService = channelService;
|
||||
this.transactionGroupService = transactionGroupService;
|
||||
this.userPreferenceService = userPreferenceService;
|
||||
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
|
||||
public PageResult<TransactionVO> page(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)");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 应用排序:默认按交易时间降序,支持按交易时间/金额排序
|
||||
applySort(wrapper, query);
|
||||
LambdaQueryWrapper<Transaction> wrapper = querySupport.buildQueryWrapper(userId, query);
|
||||
querySupport.applySort(wrapper, query);
|
||||
|
||||
Page<Transaction> page = new Page<>(query.getPage(), query.getSize());
|
||||
Page<Transaction> result = transactionMapper.selectPage(page, wrapper);
|
||||
|
||||
// 批量查询关联的分类信息,避免 N+1 查询问题
|
||||
List<Long> categoryIds = result.getRecords().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> finalCategoryMap = categoryMap;
|
||||
Map<Long, Category> categoryMap = enricher.loadCategoryMap(result.getRecords());
|
||||
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());
|
||||
// 批量填充标签与来源图片
|
||||
fillTags(voList);
|
||||
fillImages(voList);
|
||||
fillRelationGroupCount(voList);
|
||||
// 批量填充标签、来源图片与关系组数量
|
||||
enricher.enrich(voList);
|
||||
|
||||
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());
|
||||
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());
|
||||
}
|
||||
@@ -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> createdImageIds = new ArrayList<>();
|
||||
List<List<Long>> createdTagIds = new ArrayList<>();
|
||||
Set<String> seen = new HashSet<>();
|
||||
int skipped = 0;
|
||||
for (int i = 0; i < records.size(); i++) {
|
||||
@@ -317,7 +227,7 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
}
|
||||
// 分类可选:仅在指定分类时校验归属
|
||||
if (record.getCategoryId() != null) {
|
||||
Category category = categoryMapper.selectById(record.getCategoryId());
|
||||
Category category = categoryMap.get(record.getCategoryId());
|
||||
if (category == null || !category.getUserId().equals(userId)) {
|
||||
throw new BusinessException("分类不存在");
|
||||
}
|
||||
@@ -334,11 +244,14 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
: record.getIncludeInStats());
|
||||
transaction.setRawData(record.getRawData());
|
||||
transactionMapper.insert(transaction);
|
||||
saveTransactionTags(userId, transaction.getId(), record.getTagIds());
|
||||
createdIds.add(transaction.getId());
|
||||
createdImageIds.add(recordImageIds.get(i));
|
||||
createdTagIds.add(record.getTagIds());
|
||||
}
|
||||
|
||||
// 批量建立标签关联,避免逐条插入
|
||||
tagWriter.insertTags(userId, createdIds, createdTagIds);
|
||||
|
||||
// 按每条记录的来源图片建立图片关联
|
||||
for (int i = 0; i < createdIds.size(); i++) {
|
||||
Long imageId = createdImageIds.get(i);
|
||||
@@ -387,7 +300,7 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
transaction.setIncludeInStats(request.getIncludeInStats() == null ? Boolean.TRUE : request.getIncludeInStats());
|
||||
transactionMapper.updateById(transaction);
|
||||
// 全量替换标签关联
|
||||
saveTransactionTags(userId, id, request.getTagIds());
|
||||
tagWriter.saveTransactionTags(userId, id, request.getTagIds());
|
||||
log.info("更新交易记录: userId={}, id={}", userId, id);
|
||||
}
|
||||
|
||||
@@ -404,8 +317,7 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
throw new BusinessException("记录不存在");
|
||||
}
|
||||
// 先清理标签关联,再删除记录
|
||||
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
|
||||
.eq(TransactionTag::getTransactionId, id));
|
||||
tagWriter.deleteByTransactionIds(List.of(id));
|
||||
transactionGroupService.removeTransactionFromGroups(userId, List.of(id));
|
||||
transactionMapper.deleteById(id);
|
||||
log.info("删除交易记录: userId={}, id={}", userId, id);
|
||||
@@ -413,33 +325,10 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 仅删除归属当前用户的记录,先清理标签关联再批量删除,返回实际删除数。
|
||||
*/
|
||||
@Override
|
||||
@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;
|
||||
}
|
||||
// 先清理标签关联,再批量删除记录
|
||||
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;
|
||||
return batchService.batchDelete(userId, ids);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -478,445 +367,39 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 按交易类型分别批量设置分类:仅更新与所选分类类型一致、且属于当前用户的记录。
|
||||
*/
|
||||
@Override
|
||||
@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 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);
|
||||
return batchService.batchUpdateCategory(userId, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 仅对属于当前用户的记录生效;先移除指定标签,再补充尚未存在的标签。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public int batchTags(Long userId, BatchTagRequest request) {
|
||||
List<Long> ids = request.getIds();
|
||||
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;
|
||||
return batchService.batchTags(userId, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 根据查询条件导出交易记录,不分页,返回所有匹配的记录。
|
||||
* 根据查询条件导出交易记录,不分页;超过上限时拒绝以避免内存溢出。
|
||||
*/
|
||||
@Override
|
||||
public List<TransactionExportVO> export(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)");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 应用排序:默认按交易时间降序,支持按交易时间/金额排序
|
||||
applySort(wrapper, query);
|
||||
LambdaQueryWrapper<Transaction> wrapper = querySupport.buildQueryWrapper(userId, query);
|
||||
querySupport.applySort(wrapper, query);
|
||||
|
||||
List<Transaction> transactions = transactionMapper.selectList(wrapper);
|
||||
|
||||
// 批量查询关联的分类信息
|
||||
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));
|
||||
if (transactions.size() > EXPORT_MAX_ROWS) {
|
||||
throw new BusinessException("导出数据量过大,请缩小筛选范围后重试(上限" + EXPORT_MAX_ROWS + "条)");
|
||||
}
|
||||
|
||||
// 转换为导出视图对象,自定义渠道名称按当前用户解析
|
||||
Map<Long, Category> finalCategoryMap = categoryMap;
|
||||
Map<Long, Category> categoryMap = enricher.loadCategoryMap(transactions);
|
||||
Map<String, String> channelNames = channelService.nameMap(userId);
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将交易实体和关联分类转换为导出视图对象
|
||||
*
|
||||
* @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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user