diff --git a/frontend/src/api/tag.ts b/frontend/src/api/tag.ts new file mode 100644 index 0000000..92a0ec1 --- /dev/null +++ b/frontend/src/api/tag.ts @@ -0,0 +1,22 @@ +// 标签相关 API 接口:标签的增删改查 +import request from './request' + +/** 获取标签列表 */ +export function getTagList() { + return request.get('/tag/list') +} + +/** 创建新标签 */ +export function createTag(data: { name: string; color?: string; sortOrder?: number }) { + return request.post('/tag', data) +} + +/** 更新指定标签 */ +export function updateTag(id: number, data: { name: string; color?: string; sortOrder?: number }) { + return request.put(`/tag/${id}`, data) +} + +/** 删除指定标签 */ +export function deleteTag(id: number) { + return request.delete(`/tag/${id}`) +} diff --git a/frontend/src/api/transaction.ts b/frontend/src/api/transaction.ts index 3670e61..40ee8ed 100644 --- a/frontend/src/api/transaction.ts +++ b/frontend/src/api/transaction.ts @@ -17,6 +17,9 @@ export function getTransactionPage(params: { minAmount?: number maxAmount?: number remark?: string + sortField?: string + sortOrder?: string + tagIds?: number[] }) { return request.get('/transaction/page', { params }) } @@ -30,6 +33,7 @@ export function createTransaction(data: { remark?: string transactionTime: string includeInStats?: boolean + tagIds?: number[] }) { return request.post('/transaction', data) } @@ -43,6 +47,7 @@ export function updateTransaction(id: number, data: { remark?: string transactionTime: string includeInStats?: boolean + tagIds?: number[] }) { return request.put(`/transaction/${id}`, data) } @@ -62,8 +67,8 @@ export function toggleRemarkHidden(id: number, remarkHidden: boolean) { return request.put(`/transaction/${id}/remark-hidden`, { remarkHidden }) } -/** 支持的账单类型,当前仅支付宝 */ -export type BillType = 'ALIPAY' +/** 支持的账单类型,当前支持支付宝与微信 */ +export type BillType = 'ALIPAY' | 'WECHAT' /** 账单解析结果行 */ export interface ParsedBillRow { @@ -80,6 +85,7 @@ export interface ParsedBillRow { importable: boolean selected: boolean duplicate: boolean + rawData: string | null error: string | null } @@ -92,6 +98,7 @@ export interface BillImportItem { counterparty: string product: string includeInStats: boolean + rawData: string | null } /** 解析账单文件(不落库),返回预览数据 */ @@ -117,10 +124,20 @@ export function batchUpdateCategory(data: { ids: number[] expenseCategoryId?: number | null incomeCategoryId?: number | null + includeInStats?: boolean | null }) { return request.put('/transaction/batch-category', data) } +/** 批量给交易记录添加/移除标签 */ +export function batchTags(data: { + ids: number[] + addTagIds?: number[] + removeTagIds?: number[] +}) { + return request.put('/transaction/batch-tags', data) +} + /** 导出交易记录为 Excel 文件,支持按条件筛选 */ export function exportTransaction(params: { types?: string[] @@ -133,6 +150,9 @@ export function exportTransaction(params: { minAmount?: number maxAmount?: number remark?: string + sortField?: string + sortOrder?: string + tagIds?: number[] }) { const userStore = useUserStore() return axios.get('/api/transaction/export', { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 474af32..9edcecb 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -36,6 +36,11 @@ const router = createRouter({ name: 'Categories', component: () => import('../views/CategoryManage.vue'), // 分类管理 }, + { + path: 'tags', + name: 'Tags', + component: () => import('../views/TagManage.vue'), // 标签管理 + }, ], }, ], diff --git a/frontend/src/views/BillImportModal.vue b/frontend/src/views/BillImportModal.vue index a962d8d..ab96d90 100644 --- a/frontend/src/views/BillImportModal.vue +++ b/frontend/src/views/BillImportModal.vue @@ -3,13 +3,14 @@ import { ref, computed, h, watch } from 'vue' import { useMessage, NModal, NSpace, NButton, NSelect, NUpload, NUploadDragger, NDataTable, - NCheckbox, NTag, NAlert, NText, NIcon, + NCheckbox, NTag, NAlert, NText, NIcon, NInput, NInputNumber, } from 'naive-ui' import type { DataTableColumns, UploadFileInfo } from 'naive-ui' import { CloudUploadOutline } from '@vicons/ionicons5' import { parseBill, confirmImport } from '../api/transaction' import type { BillType, ParsedBillRow, BillImportItem } from '../api/transaction' import { getCategoryList } from '../api/category' +import { getTagList } from '../api/tag' const props = defineProps<{ show: boolean }>() const emit = defineEmits<{ @@ -19,9 +20,47 @@ const emit = defineEmits<{ const message = useMessage() -// 账单类型选项,当前仅支持支付宝 -const billType = ref('ALIPAY') -const billTypeOptions = [{ label: '支付宝', value: 'ALIPAY' }] +// 账单类型偏好存储键 +const BILL_TYPE_PREFS_KEY = 'finance.billImport.billType' + +// 账单类型选项,当前支持支付宝与微信 +const billTypeOptions = [ + { label: '支付宝', value: 'ALIPAY' }, + { label: '微信', value: 'WECHAT' }, +] + +/** 读取上次选择的账单类型偏好,非法值回退为支付宝 */ +function loadBillTypePref(): BillType { + const saved = localStorage.getItem(BILL_TYPE_PREFS_KEY) + return saved === 'ALIPAY' || saved === 'WECHAT' ? saved : 'ALIPAY' +} + +// 当前账单类型,初始值取上次选择(个人偏好) +const billType = ref(loadBillTypePref()) + +// 各账单类型允许上传的文件扩展名 +const allowedExtensions: Record = { + ALIPAY: ['.csv'], + WECHAT: ['.xlsx'], +} + +// 各账单类型的账单导出路径提示 +const exportHints: Record = { + ALIPAY: '导出路径:支付宝 App → 我的 → 账单 → 开具交易流水证明 / 账单导出,选择 CSV 格式。', + WECHAT: '导出路径:微信 App → 我 → 服务 → 钱包 → 账单 → 常见问题 → 下载账单 → 用于个人对账,选择 xlsx 格式。', +} + +// 当前账单类型允许的扩展名,用于上传组件 accept 属性 +const acceptTypes = computed(() => (allowedExtensions[billType.value] || []).join(',')) + +// 当前账单类型的文件格式提示 +const fileHint = computed(() => { + const label = billTypeOptions.find((o) => o.value === billType.value)?.label || '' + return `仅支持${label}导出的账单文件(${(allowedExtensions[billType.value] || []).join('、')})` +}) + +// 当前账单类型的导出路径提示 +const exportHint = computed(() => exportHints[billType.value]) // 弹窗阶段:选择文件 -> 预览勾选 -> 导入结果 const stage = ref<'select' | 'preview' | 'result'>('select') @@ -35,25 +74,83 @@ const importing = ref(false) const rows = ref([]) const checkedRowNos = ref([]) +// 预览筛选条件 +const filterKeyword = ref('') +const filterType = ref(null) +const filterStatus = ref(null) +const filterMinAmount = ref(null) +const filterMaxAmount = ref(null) + +// 类型筛选选项 +const typeFilterOptions = [ + { label: '收入', value: 'INCOME' }, + { label: '支出', value: 'EXPENSE' }, + { label: '不计入', value: 'NEUTRAL' }, +] + +// 状态筛选选项 +const statusFilterOptions = [ + { label: '可导入', value: 'importable' }, + { label: '已存在', value: 'duplicate' }, + { label: '不可导入', value: 'error' }, +] + +/** 重置预览筛选条件 */ +function resetFilters() { + filterKeyword.value = '' + filterType.value = null + filterStatus.value = null + filterMinAmount.value = null + filterMaxAmount.value = null +} + // 默认分类 const expenseCategories = ref<{ label: string; value: number }[]>([]) const incomeCategories = ref<{ label: string; value: number }[]>([]) const expenseCategoryId = ref(null) const incomeCategoryId = ref(null) +// 默认标签 +const allTags = ref<{ label: string; value: number }[]>([]) +const importTagIds = ref([]) + // 导入结果 const result = ref<{ total: number; imported: number; skipped: number; failed: number; messages: string[] } | null>(null) -/** 可导入的行 */ -const selectableRows = computed(() => rows.value.filter((r) => r.importable)) +/** 按筛选条件过滤后的账单行 */ +const filteredRows = computed(() => { + const kw = filterKeyword.value.trim().toLowerCase() + return rows.value.filter((r) => { + if (kw) { + const text = `${r.counterparty || ''} ${r.product || ''} ${r.externalNo || ''}`.toLowerCase() + if (!text.includes(kw)) return false + } + if (filterType.value === 'NEUTRAL') { + if (r.includeInStats) return false + } else if (filterType.value && r.type !== filterType.value) { + return false + } + if (filterStatus.value === 'importable' && !r.importable) return false + if (filterStatus.value === 'duplicate' && !r.duplicate) return false + if (filterStatus.value === 'error' && !r.error) return false + if (filterMinAmount.value != null && Number(r.amount) < filterMinAmount.value) return false + if (filterMaxAmount.value != null && Number(r.amount) > filterMaxAmount.value) return false + return true + }) +}) +/** 可导入的行(受筛选影响) */ +const selectableRows = computed(() => filteredRows.value.filter((r) => r.importable)) /** 已勾选的行 */ const checkedRows = computed(() => rows.value.filter((r) => checkedRowNos.value.includes(r.rowNo))) -/** 是否全选 */ +/** 是否全选(基于当前筛选出的可导入行) */ const allChecked = computed( () => selectableRows.value.length > 0 && selectableRows.value.every((r) => checkedRowNos.value.includes(r.rowNo)), ) -/** 是否存在部分勾选 */ -const someChecked = computed(() => checkedRowNos.value.length > 0 && !allChecked.value) +/** 是否存在部分勾选(基于当前筛选出的可导入行) */ +const someChecked = computed(() => { + const checkedVisible = selectableRows.value.filter((r) => checkedRowNos.value.includes(r.rowNo)) + return checkedVisible.length > 0 && !allChecked.value +}) /** 表格列定义 */ const columns = computed>(() => [ @@ -136,10 +233,34 @@ async function loadCategories() { } } -/** 文件选择变化(支持点击或拖拽) */ +/** 加载标签选项 */ +async function loadTags() { + try { + const res: any = await getTagList() + allTags.value = (res.data || []).map((t: any) => ({ label: t.name, value: t.id })) + } catch (e: any) { + message.error(e.message || '加载标签失败') + } +} + +/** 判断文件名是否符合当前账单类型允许的扩展名 */ +function isAllowedFile(name: string) { + const exts = allowedExtensions[billType.value] || [] + const lower = name.toLowerCase() + return exts.length === 0 || exts.some((ext) => lower.endsWith(ext)) +} + +/** 文件选择变化(支持点击或拖拽),按账单类型校验扩展名,非法文件直接拒绝 */ function handleFileChange(options: { fileList: UploadFileInfo[] }) { - uploadFileList.value = options.fileList const last = options.fileList[options.fileList.length - 1] + if (last?.file && !isAllowedFile(last.name)) { + const label = billTypeOptions.find((o) => o.value === billType.value)?.label || billType.value + message.error(`账单类型「${label}」仅支持 ${allowedExtensions[billType.value].join('、')} 格式的文件`) + uploadFileList.value = [] + file.value = null + return + } + uploadFileList.value = options.fileList file.value = last?.file ?? null } @@ -155,6 +276,7 @@ async function handleParse() { rows.value = res.data.rows || [] // 默认勾选解析器建议勾选的行 checkedRowNos.value = rows.value.filter((r) => r.selected).map((r) => r.rowNo) + resetFilters() stage.value = 'preview' } catch (e: any) { message.error(e.message || '解析失败') @@ -193,6 +315,7 @@ async function handleConfirm() { counterparty: r.counterparty, product: r.product, includeInStats: r.includeInStats, + rawData: r.rawData, })) importing.value = true try { @@ -200,6 +323,7 @@ async function handleConfirm() { billType: billType.value, expenseCategoryId: expenseCategoryId.value, incomeCategoryId: incomeCategoryId.value, + tagIds: importTagIds.value, items, }) result.value = res.data @@ -222,6 +346,8 @@ function reset() { result.value = null expenseCategoryId.value = null incomeCategoryId.value = null + importTagIds.value = [] + resetFilters() } /** 关闭弹窗 */ @@ -229,6 +355,13 @@ function handleClose() { emit('update:show', false) } +// 切换账单类型时清空已选文件、记录偏好,避免文件格式与类型不匹配 +watch(billType, (value) => { + uploadFileList.value = [] + file.value = null + localStorage.setItem(BILL_TYPE_PREFS_KEY, value) +}) + // 打开弹窗时重置并加载分类 watch( () => props.show, @@ -236,6 +369,7 @@ watch( if (visible) { reset() loadCategories() + loadTags() } }, ) @@ -264,7 +398,7 @@ watch( :file-list="uploadFileList" :default-upload="false" :max="1" - accept=".csv" + :accept="acceptTypes" style="margin-top: 8px;" @change="handleFileChange" > @@ -275,7 +409,7 @@ watch(
点击或拖拽文件到此处
- 仅支持支付宝导出的交易记录明细 .csv 文件 + {{ fileHint }}
@@ -283,7 +417,7 @@ watch( - 导出路径:支付宝 App → 我的 → 账单 → 开具交易流水证明 / 账单导出,选择 CSV 格式。 + {{ exportHint }} @@ -315,12 +449,42 @@ watch( placeholder="未选则不计分类" style="width: 180px;" /> - 共 {{ rows.length }} 条,已勾选 {{ checkedRows.length }} 条 + 默认标签(可不选): + + + 全选 + + 共 {{ rows.length }} 条,筛选后 {{ filteredRows.length }} 条,已勾选 {{ checkedRows.length }} 条 + + + + + + + + + ~ + + 重置 { '/dashboard': 'dashboard', '/transactions': 'transactions', '/categories': 'categories', + '/tags': 'tags', } return map[route.path] || 'dashboard' }) @@ -31,6 +32,7 @@ const menuOptions: MenuOption[] = [ { label: '统计面板', key: 'dashboard', icon: renderIcon(BarChartOutline) }, { label: '收支记录', key: 'transactions', icon: renderIcon(ListOutline) }, { label: '分类管理', key: 'categories', icon: renderIcon(FolderOpenOutline) }, + { label: '标签管理', key: 'tags', icon: renderIcon(PricetagsOutline) }, ] /** 菜单点击事件:根据 key 导航到对应路由 */ diff --git a/frontend/src/views/TagManage.vue b/frontend/src/views/TagManage.vue new file mode 100644 index 0000000..221f932 --- /dev/null +++ b/frontend/src/views/TagManage.vue @@ -0,0 +1,152 @@ + + + diff --git a/frontend/src/views/TransactionList.vue b/frontend/src/views/TransactionList.vue index 3a09c41..c0a065e 100644 --- a/frontend/src/views/TransactionList.vue +++ b/frontend/src/views/TransactionList.vue @@ -1,11 +1,12 @@ @@ -553,15 +686,17 @@ onMounted(() => {

收支记录

- - 一键设置分类 + + 排序: + + 一键设置 导入账单 导出 Excel 新增记录 - + { { placeholder="分类" style="width: 200px;" /> - - - - ~ - { style="width: 180px;" @keyup.enter="handleSearch" /> - - + + + +
+
支付方式
+ +
+
+
标签
+ +
+
+
计入统计
+ +
+
+
金额区间
+ + + ~ + + +
+ + 清空 + +
+
查询 重置
@@ -686,6 +860,16 @@ onMounted(() => { + + + 关闭后不纳入月度统计(如借款、转账等) @@ -697,12 +881,17 @@ onMounted(() => { - - + + + + + + + - 将对当前表格展示的 {{ transactions.length }} 条记录按类型设置分类 + 将对当前表格展示的 {{ transactions.length }} 条记录设置分类与计入统计 @@ -725,6 +914,15 @@ onMounted(() => { placeholder="不设置收入分类" /> + + + + + + + + + 确定 @@ -743,4 +941,9 @@ onMounted(() => { text-overflow: ellipsis; white-space: nowrap; } +.filter-label { + margin-bottom: 4px; + color: #999; + font-size: 12px; +} diff --git a/src/main/java/com/example/finance/controller/TagController.java b/src/main/java/com/example/finance/controller/TagController.java new file mode 100644 index 0000000..1b96c1c --- /dev/null +++ b/src/main/java/com/example/finance/controller/TagController.java @@ -0,0 +1,102 @@ +package com.example.finance.controller; + +import com.example.finance.common.Result; +import com.example.finance.dto.TagRequest; +import com.example.finance.service.TagService; +import com.example.finance.vo.TagVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 标签控制器 + * + *

提供标签的增删改查接口,用于给交易记录打标签

+ * + * @date 2026-09-17 16:26:58 + */ +@RestController +@RequestMapping("/api/tag") +@Tag(name = "标签管理", description = "标签的增删改查相关接口") +public class TagController { + + private final TagService tagService; + + public TagController(TagService tagService) { + this.tagService = tagService; + } + + /** + * 获取标签列表 + * + * @param request HTTP请求,从中获取用户ID + * @return 标签列表 + */ + @GetMapping("/list") + @Operation(summary = "获取标签列表", description = "获取当前用户的全部标签") + public Result> list(HttpServletRequest request) { + Long userId = (Long) request.getAttribute("userId"); + return Result.success(tagService.list(userId)); + } + + /** + * 创建标签 + * + * @param request HTTP请求,从中获取用户ID + * @param tagRequest 标签请求参数 + * @return 操作结果 + */ + @PostMapping + @Operation(summary = "创建标签", description = "为当前用户创建新的标签") + public Result create(HttpServletRequest request, @RequestBody @Valid TagRequest tagRequest) { + Long userId = (Long) request.getAttribute("userId"); + tagService.create(userId, tagRequest); + return Result.success(); + } + + /** + * 更新标签 + * + * @param request HTTP请求,从中获取用户ID + * @param id 标签ID + * @param tagRequest 标签请求参数 + * @return 操作结果 + */ + @PutMapping("/{id}") + @Operation(summary = "更新标签", description = "根据标签ID更新标签信息") + public Result update(HttpServletRequest request, + @Parameter(description = "标签ID") @PathVariable Long id, + @RequestBody @Valid TagRequest tagRequest) { + Long userId = (Long) request.getAttribute("userId"); + tagService.update(userId, id, tagRequest); + return Result.success(); + } + + /** + * 删除标签 + * + * @param request HTTP请求,从中获取用户ID + * @param id 标签ID + * @return 操作结果 + */ + @DeleteMapping("/{id}") + @Operation(summary = "删除标签", description = "根据标签ID删除指定标签及其交易关联") + public Result delete(HttpServletRequest request, + @Parameter(description = "标签ID") @PathVariable Long id) { + Long userId = (Long) request.getAttribute("userId"); + tagService.delete(userId, id); + return Result.success(); + } +} diff --git a/src/main/java/com/example/finance/controller/TransactionController.java b/src/main/java/com/example/finance/controller/TransactionController.java index 57e0049..23ae253 100644 --- a/src/main/java/com/example/finance/controller/TransactionController.java +++ b/src/main/java/com/example/finance/controller/TransactionController.java @@ -4,6 +4,7 @@ import com.alibaba.excel.EasyExcel; import com.example.finance.common.PageResult; import com.example.finance.common.Result; import com.example.finance.dto.BatchCategoryRequest; +import com.example.finance.dto.BatchTagRequest; import com.example.finance.dto.TransactionQuery; import com.example.finance.dto.TransactionRequest; import com.example.finance.service.TransactionService; @@ -165,6 +166,21 @@ public class TransactionController { return Result.success(transactionService.batchUpdateCategory(userId, body)); } + /** + * 批量给交易记录添加/移除标签 + * + * @param request HTTP请求,从中获取用户ID + * @param body 批量标签请求参数 + * @return 涉及的记录数 + */ + @PutMapping("/batch-tags") + @Operation(summary = "批量设置标签", description = "为选中的交易记录添加或移除标签") + public Result batchTags(HttpServletRequest request, + @RequestBody @Valid BatchTagRequest body) { + Long userId = (Long) request.getAttribute("userId"); + return Result.success(transactionService.batchTags(userId, body)); + } + /** * 导出交易记录为 Excel 文件 * diff --git a/src/main/java/com/example/finance/dto/BatchCategoryRequest.java b/src/main/java/com/example/finance/dto/BatchCategoryRequest.java index 767810f..fe96416 100644 --- a/src/main/java/com/example/finance/dto/BatchCategoryRequest.java +++ b/src/main/java/com/example/finance/dto/BatchCategoryRequest.java @@ -22,4 +22,7 @@ public class BatchCategoryRequest { /** 收入默认分类ID,为空表示不更新收入记录 */ private Long incomeCategoryId; + + /** 是否计入统计:null-不修改,true-计入,false-不计入 */ + private Boolean includeInStats; } diff --git a/src/main/java/com/example/finance/dto/BatchTagRequest.java b/src/main/java/com/example/finance/dto/BatchTagRequest.java new file mode 100644 index 0000000..e694b4a --- /dev/null +++ b/src/main/java/com/example/finance/dto/BatchTagRequest.java @@ -0,0 +1,25 @@ +package com.example.finance.dto; + +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +/** + * 批量设置标签请求DTO,用于为多条交易记录添加或移除标签。 + * + * @date 2026-09-17 16:27:22 + */ +@Data +public class BatchTagRequest { + + /** 待操作的交易记录ID列表 */ + @NotEmpty(message = "请至少选择一条记录") + private List ids; + + /** 需要添加的标签ID列表,可选 */ + private List addTagIds; + + /** 需要移除的标签ID列表,可选 */ + private List removeTagIds; +} diff --git a/src/main/java/com/example/finance/dto/BillImportRequest.java b/src/main/java/com/example/finance/dto/BillImportRequest.java index e2084f2..517dd4d 100644 --- a/src/main/java/com/example/finance/dto/BillImportRequest.java +++ b/src/main/java/com/example/finance/dto/BillImportRequest.java @@ -25,6 +25,9 @@ public class BillImportRequest { /** 收入默认分类ID,适用于所有收入类型行 */ private Long incomeCategoryId; + /** 导入记录的默认标签ID列表,可选 */ + private List tagIds; + /** 勾选待导入的账单行列表 */ private List items; @@ -54,5 +57,8 @@ public class BillImportRequest { /** 是否计入统计 */ private Boolean includeInStats; + + /** 原始账单行的完整数据(结构化JSON,由解析阶段返回、确认导入时回传) */ + private String rawData; } } diff --git a/src/main/java/com/example/finance/dto/TagRequest.java b/src/main/java/com/example/finance/dto/TagRequest.java new file mode 100644 index 0000000..5a5fe41 --- /dev/null +++ b/src/main/java/com/example/finance/dto/TagRequest.java @@ -0,0 +1,23 @@ +package com.example.finance.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 标签请求DTO,用于接收创建或更新标签时提交的信息。 + * + * @date 2026-09-17 16:26:52 + */ +@Data +public class TagRequest { + + /** 标签名称,不能为空 */ + @NotBlank(message = "标签名称不能为空") + private String name; + + /** 标签颜色,可选 */ + private String color; + + /** 排序序号,可选 */ + private Integer sortOrder; +} diff --git a/src/main/java/com/example/finance/dto/TransactionQuery.java b/src/main/java/com/example/finance/dto/TransactionQuery.java index 309fcd0..4aacfb4 100644 --- a/src/main/java/com/example/finance/dto/TransactionQuery.java +++ b/src/main/java/com/example/finance/dto/TransactionQuery.java @@ -46,4 +46,13 @@ public class TransactionQuery { /** 备注关键字,模糊匹配 */ private String remark; + + /** 排序字段:transactionTime-交易时间(默认),amount-金额 */ + private String sortField; + + /** 排序方向:asc-升序,desc-降序(默认) */ + private String sortOrder; + + /** 标签ID筛选,支持多选 */ + private List tagIds; } diff --git a/src/main/java/com/example/finance/dto/TransactionRequest.java b/src/main/java/com/example/finance/dto/TransactionRequest.java index 90c3f3b..f70c1c5 100644 --- a/src/main/java/com/example/finance/dto/TransactionRequest.java +++ b/src/main/java/com/example/finance/dto/TransactionRequest.java @@ -7,6 +7,7 @@ import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.List; /** * 交易请求DTO,用于接收创建或更新交易记录时提交的信息 @@ -39,4 +40,7 @@ public class TransactionRequest { /** 是否计入统计,默认 true(借款、转账等可设为 false) */ private Boolean includeInStats; + + /** 标签ID列表,可选;更新时为空表示清空标签 */ + private List tagIds; } diff --git a/src/main/java/com/example/finance/entity/Tag.java b/src/main/java/com/example/finance/entity/Tag.java new file mode 100644 index 0000000..11c7b43 --- /dev/null +++ b/src/main/java/com/example/finance/entity/Tag.java @@ -0,0 +1,37 @@ +package com.example.finance.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 标签实体类,对应数据库表 t_tag,用于记录用户自定义标签。 + * + * @date 2026-09-17 16:26:50 + */ +@Data +@TableName("t_tag") +public class Tag { + + /** 标签ID,主键自增 */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 所属用户ID */ + private Long userId; + + /** 标签名称 */ + private String name; + + /** 标签颜色,用于前端展示 */ + private String color; + + /** 排序序号 */ + private Integer sortOrder; + + /** 创建时间 */ + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/example/finance/entity/Transaction.java b/src/main/java/com/example/finance/entity/Transaction.java index c1a7465..23daa42 100644 --- a/src/main/java/com/example/finance/entity/Transaction.java +++ b/src/main/java/com/example/finance/entity/Transaction.java @@ -49,6 +49,9 @@ public class Transaction { /** 是否计入统计:true-计入,false-不计入(如借款、账户间转账等) */ private Boolean includeInStats; + /** 账单导入的原始完整数据(结构化JSON),手动记录为空 */ + private String rawData; + /** 创建时间 */ private LocalDateTime createdAt; diff --git a/src/main/java/com/example/finance/entity/TransactionTag.java b/src/main/java/com/example/finance/entity/TransactionTag.java new file mode 100644 index 0000000..05245b4 --- /dev/null +++ b/src/main/java/com/example/finance/entity/TransactionTag.java @@ -0,0 +1,31 @@ +package com.example.finance.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 交易标签关联实体类,对应数据库表 t_transaction_tag,维护交易与标签的多对多关系。 + * + * @date 2026-09-17 16:26:50 + */ +@Data +@TableName("t_transaction_tag") +public class TransactionTag { + + /** 关联ID,主键自增 */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 交易记录ID */ + private Long transactionId; + + /** 标签ID */ + private Long tagId; + + /** 创建时间 */ + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/example/finance/enums/BillType.java b/src/main/java/com/example/finance/enums/BillType.java index da153c8..562c8b9 100644 --- a/src/main/java/com/example/finance/enums/BillType.java +++ b/src/main/java/com/example/finance/enums/BillType.java @@ -3,14 +3,17 @@ package com.example.finance.enums; /** * 账单类型枚举,标识支持的第三方账单来源。 * - *

当前仅支持支付宝,后续可扩展微信、银行等账单类型。

+ *

当前支持支付宝与微信支付账单,后续可扩展银行等账单类型。

* * @date 2026-09-17 09:49:43 */ public enum BillType { /** 支付宝账单 */ - ALIPAY("支付宝"); + ALIPAY("支付宝"), + + /** 微信支付账单 */ + WECHAT("微信"); /** 账单类型的中文名称 */ private final String label; diff --git a/src/main/java/com/example/finance/importer/AlipayBillParser.java b/src/main/java/com/example/finance/importer/AlipayBillParser.java index 1040ff5..1a04b38 100644 --- a/src/main/java/com/example/finance/importer/AlipayBillParser.java +++ b/src/main/java/com/example/finance/importer/AlipayBillParser.java @@ -1,6 +1,8 @@ package com.example.finance.importer; import com.example.finance.enums.BillType; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVParser; import org.apache.commons.csv.CSVRecord; @@ -18,7 +20,9 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** * 支付宝账单解析器。 @@ -48,6 +52,13 @@ public class AlipayBillParser implements BillParser { /** CSV 解析格式:逗号分隔并去除字段首尾空白 */ private static final CSVFormat CSV_FORMAT = CSVFormat.DEFAULT.builder().setTrim(true).build(); + /** JSON 序列化器,用于生成原始完整数据 */ + private final ObjectMapper objectMapper; + + public AlipayBillParser(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + @Override public BillType getBillType() { return BillType.ALIPAY; @@ -125,6 +136,7 @@ public class AlipayBillParser implements BillParser { private ParsedBillRow toRow(int rowNo, List header, List values) { ParsedBillRow row = new ParsedBillRow(); row.setRowNo(rowNo); + row.setRawData(buildRawData(header, values)); row.setExternalNo(get(header, values, COL_TRADE_NO)); row.setCounterparty(get(header, values, COL_COUNTERPARTY)); row.setProduct(get(header, values, COL_PRODUCT)); @@ -180,6 +192,31 @@ public class AlipayBillParser implements BillParser { return row; } + /** + * 将整行数据按表头顺序组装为结构化 JSON,保留全部列(含系统未使用的字段) + * + * @param header 表头字段列表 + * @param values 数据字段列表 + * @return JSON 字符串,序列化失败时返回 null + */ + private String buildRawData(List header, List values) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < header.size(); i++) { + String key = header.get(i); + if (key == null || key.isEmpty()) { + continue; + } + String value = i < values.size() ? values.get(i) : null; + map.put(key, value == null ? "" : value.trim()); + } + try { + return objectMapper.writeValueAsString(map); + } catch (JsonProcessingException e) { + // 序列化失败不影响正常导入,原始数据置空 + return null; + } + } + /** * 推断交易类型 * diff --git a/src/main/java/com/example/finance/importer/ParsedBillRow.java b/src/main/java/com/example/finance/importer/ParsedBillRow.java index cb5695b..efe8f89 100644 --- a/src/main/java/com/example/finance/importer/ParsedBillRow.java +++ b/src/main/java/com/example/finance/importer/ParsedBillRow.java @@ -54,6 +54,9 @@ public class ParsedBillRow { /** 是否与系统中已有记录重复(同一用户下交易号已存在) */ private boolean duplicate; + /** 原始账单行的完整数据(结构化JSON,保留全部列) */ + private String rawData; + /** 解析失败原因,正常时为 null */ private String error; } diff --git a/src/main/java/com/example/finance/importer/WechatBillParser.java b/src/main/java/com/example/finance/importer/WechatBillParser.java new file mode 100644 index 0000000..a650495 --- /dev/null +++ b/src/main/java/com/example/finance/importer/WechatBillParser.java @@ -0,0 +1,321 @@ +package com.example.finance.importer; + +import com.example.finance.enums.BillType; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 微信支付账单解析器。 + * + *

解析微信「支付账单明细」导出的 xlsx 文件。文件前若干行为昵称、起止时间、统计与注释, + * 之后为表头与数据行。交易时间以 Excel 日期序列号存储,金额为数值,其余列均为文本。

+ * + * @date 2026-09-17 13:39:08 + */ +@Component +public class WechatBillParser implements BillParser { + + /** 表头首列名称,用于定位表头行 */ + private static final String HEADER_FIRST_COLUMN = "交易时间"; + + /** 微信账单的列数 */ + private static final int COLUMN_COUNT = 11; + + /** Excel 日期序列号基准(1900 日期系统,已规避 1900 闰年问题) */ + private static final LocalDateTime EXCEL_EPOCH = LocalDateTime.of(1899, 12, 30, 0, 0); + + /** 微信账单中表示「无内容」的占位符 */ + private static final String PLACEHOLDER = "/"; + + /** 微信账单中的时间格式 */ + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + /** 兼容部分版本以文本形式导出的时间格式 */ + private static final List TEXT_TIME_FORMATTERS = List.of( + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), + DateTimeFormatter.ofPattern("yyyy/M/d H:mm:ss"), + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); + + /** JSON 序列化器,用于生成原始完整数据 */ + private final ObjectMapper objectMapper; + + public WechatBillParser(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public BillType getBillType() { + return BillType.WECHAT; + } + + /** + * {@inheritDoc} + * + * 定位表头后逐行读取数据,跳过缺少有效交易时间的空行。 + */ + @Override + public List parse(byte[] content) throws IOException { + try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(content))) { + Sheet sheet = workbook.getSheetAt(0); + int headerIndex = locateHeader(sheet); + if (headerIndex < 0) { + throw new IOException("未找到微信账单表头,请确认文件是否为微信导出的支付账单明细"); + } + List header = readHeader(sheet.getRow(headerIndex)); + + List rows = new ArrayList<>(); + int rowNo = 0; + for (int i = headerIndex + 1; i <= sheet.getLastRowNum(); i++) { + Row row = sheet.getRow(i); + LocalDateTime time = readTime(row); + // 空行(含被裁剪文件中的中间空行)直接跳过 + if (time == null) { + continue; + } + rowNo++; + rows.add(toRow(rowNo, header, row, time)); + } + return rows; + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new IOException("微信账单解析失败:" + e.getMessage(), e); + } + } + + /** + * 定位表头行:首个单元格以「交易时间」开头的行 + * + * @param sheet 工作表 + * @return 表头行索引,未找到时返回 -1 + */ + private int locateHeader(Sheet sheet) { + for (int i = sheet.getFirstRowNum(); i <= sheet.getLastRowNum(); i++) { + Row row = sheet.getRow(i); + if (row == null) { + continue; + } + String first = readString(row.getCell(0)); + if (first != null && first.startsWith(HEADER_FIRST_COLUMN)) { + return i; + } + } + return -1; + } + + /** + * 读取表头行的各列名称 + * + * @param row 表头行 + * @return 列名列表 + */ + private List readHeader(Row row) { + List header = new ArrayList<>(COLUMN_COUNT); + for (int c = 0; c < COLUMN_COUNT; c++) { + String value = readString(row.getCell(c)); + header.add(value == null ? "" : value); + } + return header; + } + + /** + * 将单行数据映射为账单行对象,并推断类型、是否计入统计及默认勾选状态 + * + * @param rowNo 行号 + * @param header 表头字段列表 + * @param row 数据行 + * @param time 交易时间 + * @return 解析后的账单行 + */ + private ParsedBillRow toRow(int rowNo, List header, Row row, LocalDateTime time) { + ParsedBillRow parsed = new ParsedBillRow(); + parsed.setRowNo(rowNo); + parsed.setTransactionTime(time); + parsed.setRawData(buildRawData(header, row, time)); + + parsed.setExternalNo(readString(row.getCell(8))); + parsed.setCounterparty(cleanText(readString(row.getCell(2)))); + parsed.setProduct(cleanText(readString(row.getCell(3)))); + parsed.setTradeStatus(cleanText(readString(row.getCell(7)))); + + String direction = cleanText(readString(row.getCell(4))); + parsed.setDirection(direction); + + BigDecimal amount = readAmount(row.getCell(5)); + parsed.setAmount(amount); + + // 无收/支方向(如充值、提现、还款等中性交易)默认按支出处理且不计入统计 + boolean neutral = direction == null; + parsed.setType(resolveType(direction)); + parsed.setIncludeInStats(!neutral); + + boolean importable = StringUtils.hasText(parsed.getExternalNo()) + && amount != null + && amount.compareTo(BigDecimal.ZERO) > 0; + parsed.setImportable(importable); + parsed.setSelected(importable && !neutral); + + if (!StringUtils.hasText(parsed.getExternalNo())) { + parsed.setError("缺少交易单号"); + } else if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + parsed.setError("金额格式无法解析"); + } + return parsed; + } + + /** + * 推断交易类型 + * + * @param direction 收/支标识 + * @return INCOME 或 EXPENSE + */ + private String resolveType(String direction) { + if ("收入".equals(direction)) { + return "INCOME"; + } + return "EXPENSE"; + } + + /** + * 读取交易时间:第一列为 Excel 日期序列号,转换为本地日期时间 + * + * @param row 数据行 + * @return 交易时间,非数值或无效时返回 null + */ + private LocalDateTime readTime(Row row) { + if (row == null) { + return null; + } + Cell cell = row.getCell(0); + if (cell == null) { + return null; + } + if (cell.getCellType() == CellType.NUMERIC) { + double serial = cell.getNumericCellValue(); + if (serial <= 0) { + return null; + } + long days = (long) Math.floor(serial); + long seconds = Math.round((serial - days) * 86400.0); + return EXCEL_EPOCH.plusDays(days).plusSeconds(seconds); + } + // 兼容以文本形式存储的时间 + String text = readString(cell); + if (!StringUtils.hasText(text)) { + return null; + } + for (DateTimeFormatter formatter : TEXT_TIME_FORMATTERS) { + try { + return LocalDateTime.parse(text, formatter); + } catch (DateTimeParseException ignored) { + // 尝试下一种格式 + } + } + return null; + } + + /** + * 读取金额:优先按数值解析,兼容文本形式 + * + * @param cell 金额单元格 + * @return 金额,无法解析时返回 null + */ + private BigDecimal readAmount(Cell cell) { + if (cell == null) { + return null; + } + if (cell.getCellType() == CellType.NUMERIC) { + return BigDecimal.valueOf(cell.getNumericCellValue()); + } + String text = readString(cell); + if (!StringUtils.hasText(text)) { + return null; + } + try { + return new BigDecimal(text.replace(",", "")); + } catch (NumberFormatException e) { + return null; + } + } + + /** + * 读取单元格的文本值 + * + * @param cell 单元格 + * @return 文本值,空单元格返回 null + */ + private String readString(Cell cell) { + if (cell == null) { + return null; + } + CellType type = cell.getCellType(); + if (type == CellType.STRING) { + return cell.getStringCellValue().trim(); + } + if (type == CellType.NUMERIC) { + return BigDecimal.valueOf(cell.getNumericCellValue()).stripTrailingZeros().toPlainString(); + } + if (type == CellType.BOOLEAN) { + return String.valueOf(cell.getBooleanCellValue()); + } + return null; + } + + /** + * 将整行数据按表头顺序组装为结构化 JSON,保留全部列(含系统未使用的字段) + * + * @param header 表头字段列表 + * @param row 数据行 + * @param time 交易时间,第一列以可读时间替代原始序列号 + * @return JSON 字符串,序列化失败时返回 null + */ + private String buildRawData(List header, Row row, LocalDateTime time) { + Map map = new LinkedHashMap<>(); + for (int c = 0; c < header.size(); c++) { + String key = header.get(c); + if (!StringUtils.hasText(key)) { + continue; + } + String value = c == 0 ? time.format(TIME_FORMATTER) : readString(row.getCell(c)); + map.put(key, value == null ? "" : value); + } + try { + return objectMapper.writeValueAsString(map); + } catch (JsonProcessingException e) { + // 序列化失败不影响正常导入,原始数据置空 + return null; + } + } + + /** + * 清洗文本:空白或占位符「/」视为无内容 + * + * @param value 原始文本 + * @return 清洗后的文本,无内容时返回 null + */ + private String cleanText(String value) { + if (!StringUtils.hasText(value) || PLACEHOLDER.equals(value)) { + return null; + } + return value; + } +} diff --git a/src/main/java/com/example/finance/mapper/TagMapper.java b/src/main/java/com/example/finance/mapper/TagMapper.java new file mode 100644 index 0000000..9aa8404 --- /dev/null +++ b/src/main/java/com/example/finance/mapper/TagMapper.java @@ -0,0 +1,14 @@ +package com.example.finance.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.example.finance.entity.Tag; +import org.apache.ibatis.annotations.Mapper; + +/** + * 标签Mapper接口,继承MyBatis-Plus的BaseMapper,提供标签表的基础CRUD操作。 + * + * @date 2026-09-17 16:26:51 + */ +@Mapper +public interface TagMapper extends BaseMapper { +} diff --git a/src/main/java/com/example/finance/mapper/TransactionTagMapper.java b/src/main/java/com/example/finance/mapper/TransactionTagMapper.java new file mode 100644 index 0000000..a77efa0 --- /dev/null +++ b/src/main/java/com/example/finance/mapper/TransactionTagMapper.java @@ -0,0 +1,14 @@ +package com.example.finance.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.example.finance.entity.TransactionTag; +import org.apache.ibatis.annotations.Mapper; + +/** + * 交易标签关联Mapper接口,继承MyBatis-Plus的BaseMapper,提供关联表的基础CRUD操作。 + * + * @date 2026-09-17 16:26:51 + */ +@Mapper +public interface TransactionTagMapper extends BaseMapper { +} diff --git a/src/main/java/com/example/finance/service/TagService.java b/src/main/java/com/example/finance/service/TagService.java new file mode 100644 index 0000000..853dbd1 --- /dev/null +++ b/src/main/java/com/example/finance/service/TagService.java @@ -0,0 +1,47 @@ +package com.example.finance.service; + +import com.example.finance.dto.TagRequest; +import com.example.finance.vo.TagVO; + +import java.util.List; + +/** + * 标签服务接口,提供标签的增删改查操作。 + * + * @date 2026-09-17 16:26:53 + */ +public interface TagService { + + /** + * 获取用户的所有标签列表 + * + * @param userId 用户ID + * @return 标签列表 + */ + List list(Long userId); + + /** + * 创建新标签 + * + * @param userId 用户ID + * @param request 标签请求参数(名称、颜色等) + */ + void create(Long userId, TagRequest request); + + /** + * 更新标签信息 + * + * @param userId 用户ID + * @param id 标签ID + * @param request 标签请求参数 + */ + void update(Long userId, Long id, TagRequest request); + + /** + * 删除标签,同时清理交易与标签的关联 + * + * @param userId 用户ID + * @param id 标签ID + */ + void delete(Long userId, Long id); +} diff --git a/src/main/java/com/example/finance/service/TransactionService.java b/src/main/java/com/example/finance/service/TransactionService.java index e8834dc..58f6ad9 100644 --- a/src/main/java/com/example/finance/service/TransactionService.java +++ b/src/main/java/com/example/finance/service/TransactionService.java @@ -2,6 +2,7 @@ package com.example.finance.service; import com.example.finance.common.PageResult; import com.example.finance.dto.BatchCategoryRequest; +import com.example.finance.dto.BatchTagRequest; import com.example.finance.dto.TransactionQuery; import com.example.finance.dto.TransactionRequest; import com.example.finance.vo.TransactionExportVO; @@ -83,4 +84,13 @@ public interface TransactionService { * @return 实际更新的记录数 */ int batchUpdateCategory(Long userId, BatchCategoryRequest request); + + /** + * 批量给交易记录添加/移除标签 + * + * @param userId 用户ID + * @param request 批量标签请求(记录ID列表、待添加/移除的标签) + * @return 涉及的记录数 + */ + int batchTags(Long userId, BatchTagRequest request); } diff --git a/src/main/java/com/example/finance/service/impl/BillImportServiceImpl.java b/src/main/java/com/example/finance/service/impl/BillImportServiceImpl.java index fd5a3fb..fc99e88 100644 --- a/src/main/java/com/example/finance/service/impl/BillImportServiceImpl.java +++ b/src/main/java/com/example/finance/service/impl/BillImportServiceImpl.java @@ -4,12 +4,16 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.example.finance.common.BusinessException; import com.example.finance.dto.BillImportRequest; import com.example.finance.entity.Category; +import com.example.finance.entity.Tag; import com.example.finance.entity.Transaction; +import com.example.finance.entity.TransactionTag; import com.example.finance.enums.BillType; import com.example.finance.importer.BillParser; import com.example.finance.importer.ParsedBillRow; import com.example.finance.mapper.CategoryMapper; +import com.example.finance.mapper.TagMapper; import com.example.finance.mapper.TransactionMapper; +import com.example.finance.mapper.TransactionTagMapper; import com.example.finance.service.BillImportService; import com.example.finance.vo.BillImportResultVO; import com.example.finance.vo.BillParseResultVO; @@ -41,16 +45,22 @@ public class BillImportServiceImpl implements BillImportService { private final Map parserMap = new EnumMap<>(BillType.class); private final TransactionMapper transactionMapper; private final CategoryMapper categoryMapper; + private final TagMapper tagMapper; + private final TransactionTagMapper transactionTagMapper; public BillImportServiceImpl(List parsers, TransactionMapper transactionMapper, - CategoryMapper categoryMapper) { + CategoryMapper categoryMapper, + TagMapper tagMapper, + TransactionTagMapper transactionTagMapper) { // 按账单类型注册解析器,便于扩展新账单来源 for (BillParser parser : parsers) { parserMap.put(parser.getBillType(), parser); } this.transactionMapper = transactionMapper; this.categoryMapper = categoryMapper; + this.tagMapper = tagMapper; + this.transactionTagMapper = transactionTagMapper; } /** @@ -107,6 +117,13 @@ public class BillImportServiceImpl implements BillImportService { Category incomeCategory = request.getIncomeCategoryId() == null ? null : requireCategory(userId, request.getIncomeCategoryId(), "INCOME"); + // 默认标签:仅保留属于当前用户的标签 + List tagIds = request.getTagIds() == null ? List.of() + : tagMapper.selectBatchIds(request.getTagIds()).stream() + .filter(tag -> tag.getUserId().equals(userId)) + .map(Tag::getId) + .collect(Collectors.toList()); + // 已存在交易号 + 本批次已插入交易号,共同用于去重 Set seen = findExistingExternalNos(userId, items.stream() .map(BillImportRequest.Item::getExternalNo) @@ -152,12 +169,20 @@ public class BillImportServiceImpl implements BillImportService { transaction.setCategoryId(category != null ? category.getId() : null); transaction.setType(item.getType()); transaction.setAmount(item.getAmount()); - transaction.setPaymentMethod("ALIPAY"); + transaction.setPaymentMethod(resolvePaymentMethod(request.getBillType())); transaction.setRemark(truncate(buildRemark(item.getCounterparty(), item.getProduct()), 255)); transaction.setExternalNo(item.getExternalNo()); transaction.setTransactionTime(item.getTransactionTime()); transaction.setIncludeInStats(item.getIncludeInStats() == null ? Boolean.TRUE : item.getIncludeInStats()); + transaction.setRawData(item.getRawData()); transactionMapper.insert(transaction); + // 关联默认标签 + for (Long tagId : tagIds) { + TransactionTag relation = new TransactionTag(); + relation.setTransactionId(transaction.getId()); + relation.setTagId(tagId); + transactionTagMapper.insert(relation); + } if (StringUtils.hasText(item.getExternalNo())) { seen.add(item.getExternalNo()); @@ -224,6 +249,20 @@ public class BillImportServiceImpl implements BillImportService { return category; } + /** + * 根据账单类型解析对应的支付方式编码 + * + * @param billType 账单类型,如 ALIPAY、WECHAT + * @return 支付方式编码,无法识别时返回 null + */ + private String resolvePaymentMethod(String billType) { + try { + return BillType.valueOf(billType).name(); + } catch (IllegalArgumentException e) { + return null; + } + } + /** * 根据交易对方与商品名称构造备注 * diff --git a/src/main/java/com/example/finance/service/impl/TagServiceImpl.java b/src/main/java/com/example/finance/service/impl/TagServiceImpl.java new file mode 100644 index 0000000..ea3e011 --- /dev/null +++ b/src/main/java/com/example/finance/service/impl/TagServiceImpl.java @@ -0,0 +1,130 @@ +package com.example.finance.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.example.finance.common.BusinessException; +import com.example.finance.dto.TagRequest; +import com.example.finance.entity.Tag; +import com.example.finance.entity.TransactionTag; +import com.example.finance.mapper.TagMapper; +import com.example.finance.mapper.TransactionTagMapper; +import com.example.finance.service.TagService; +import com.example.finance.vo.TagVO; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 标签服务实现类,处理标签的增删改查业务逻辑。 + * + * @date 2026-09-17 16:26:56 + */ +@Service +public class TagServiceImpl implements TagService { + + private final TagMapper tagMapper; + private final TransactionTagMapper transactionTagMapper; + + public TagServiceImpl(TagMapper tagMapper, TransactionTagMapper transactionTagMapper) { + this.tagMapper = tagMapper; + this.transactionTagMapper = transactionTagMapper; + } + + /** + * {@inheritDoc} + * + * 查询用户所有标签,按排序字段和ID升序排列。 + */ + @Override + public List list(Long userId) { + List tags = tagMapper.selectList(new LambdaQueryWrapper() + .eq(Tag::getUserId, userId) + .orderByAsc(Tag::getSortOrder) + .orderByAsc(Tag::getId)); + return tags.stream().map(this::toVO).collect(Collectors.toList()); + } + + /** + * {@inheritDoc} + * + * 创建标签前校验同一用户下标签名唯一。 + */ + @Override + @Transactional + public void create(Long userId, TagRequest request) { + Long count = tagMapper.selectCount(new LambdaQueryWrapper() + .eq(Tag::getUserId, userId) + .eq(Tag::getName, request.getName())); + if (count != null && count > 0) { + throw new BusinessException("标签名称已存在"); + } + Tag tag = new Tag(); + tag.setUserId(userId); + tag.setName(request.getName()); + tag.setColor(request.getColor()); + // 排序值未提供时默认为0 + tag.setSortOrder(request.getSortOrder() != null ? request.getSortOrder() : 0); + tagMapper.insert(tag); + } + + /** + * {@inheritDoc} + * + * 更新标签前校验归属当前用户,并校验改名后不与其它标签重名。 + */ + @Override + @Transactional + public void update(Long userId, Long id, TagRequest request) { + Tag tag = tagMapper.selectById(id); + if (tag == null || !tag.getUserId().equals(userId)) { + throw new BusinessException("标签不存在"); + } + Long count = tagMapper.selectCount(new LambdaQueryWrapper() + .eq(Tag::getUserId, userId) + .eq(Tag::getName, request.getName()) + .ne(Tag::getId, id)); + if (count != null && count > 0) { + throw new BusinessException("标签名称已存在"); + } + tag.setName(request.getName()); + tag.setColor(request.getColor()); + if (request.getSortOrder() != null) { + tag.setSortOrder(request.getSortOrder()); + } + tagMapper.updateById(tag); + } + + /** + * {@inheritDoc} + * + * 删除标签前先清理其与交易记录的关联,避免残留脏数据。 + */ + @Override + @Transactional + public void delete(Long userId, Long id) { + Tag tag = tagMapper.selectById(id); + if (tag == null || !tag.getUserId().equals(userId)) { + throw new BusinessException("标签不存在"); + } + transactionTagMapper.delete(new LambdaQueryWrapper() + .eq(TransactionTag::getTagId, id)); + tagMapper.deleteById(id); + } + + /** + * 将标签实体转换为视图对象 + * + * @param tag 标签实体 + * @return 标签视图对象 + */ + private TagVO toVO(Tag tag) { + TagVO vo = new TagVO(); + vo.setId(tag.getId()); + vo.setName(tag.getName()); + vo.setColor(tag.getColor()); + vo.setSortOrder(tag.getSortOrder()); + vo.setCreatedAt(tag.getCreatedAt()); + return vo; + } +} diff --git a/src/main/java/com/example/finance/service/impl/TransactionServiceImpl.java b/src/main/java/com/example/finance/service/impl/TransactionServiceImpl.java index 7d61f86..3c22116 100644 --- a/src/main/java/com/example/finance/service/impl/TransactionServiceImpl.java +++ b/src/main/java/com/example/finance/service/impl/TransactionServiceImpl.java @@ -6,13 +6,19 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.example.finance.common.BusinessException; import com.example.finance.common.PageResult; import com.example.finance.dto.BatchCategoryRequest; +import com.example.finance.dto.BatchTagRequest; import com.example.finance.dto.TransactionQuery; import com.example.finance.dto.TransactionRequest; import com.example.finance.entity.Category; +import com.example.finance.entity.Tag; import com.example.finance.entity.Transaction; +import com.example.finance.entity.TransactionTag; import com.example.finance.mapper.CategoryMapper; +import com.example.finance.mapper.TagMapper; import com.example.finance.mapper.TransactionMapper; +import com.example.finance.mapper.TransactionTagMapper; import com.example.finance.service.TransactionService; +import com.example.finance.vo.TagVO; import com.example.finance.vo.TransactionExportVO; import com.example.finance.vo.TransactionVO; import org.springframework.stereotype.Service; @@ -21,8 +27,11 @@ import org.springframework.util.StringUtils; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; /** @@ -36,10 +45,17 @@ public class TransactionServiceImpl implements TransactionService { private final TransactionMapper transactionMapper; private final CategoryMapper categoryMapper; + private final TagMapper tagMapper; + private final TransactionTagMapper transactionTagMapper; - public TransactionServiceImpl(TransactionMapper transactionMapper, CategoryMapper categoryMapper) { + public TransactionServiceImpl(TransactionMapper transactionMapper, + CategoryMapper categoryMapper, + TagMapper tagMapper, + TransactionTagMapper transactionTagMapper) { this.transactionMapper = transactionMapper; this.categoryMapper = categoryMapper; + this.tagMapper = tagMapper; + this.transactionTagMapper = transactionTagMapper; } /** @@ -97,9 +113,14 @@ public class TransactionServiceImpl implements TransactionService { if (StringUtils.hasText(query.getRemark())) { wrapper.like(Transaction::getRemark, query.getRemark()); } + // 按标签筛选:命中任一选中标签的记录 + if (query.getTagIds() != null && !query.getTagIds().isEmpty()) { + wrapper.inSql(Transaction::getId, "SELECT transaction_id FROM t_transaction_tag WHERE tag_id IN (" + + query.getTagIds().stream().map(String::valueOf).collect(Collectors.joining(",")) + ")"); + } - // 按交易时间降序排列 - wrapper.orderByDesc(Transaction::getTransactionTime); + // 应用排序:默认按交易时间降序,支持按交易时间/金额排序 + applySort(wrapper, query); Page page = new Page<>(query.getPage(), query.getSize()); Page result = transactionMapper.selectPage(page, wrapper); @@ -121,6 +142,8 @@ public class TransactionServiceImpl implements TransactionService { List voList = result.getRecords().stream() .map(t -> toVO(t, finalCategoryMap.get(t.getCategoryId()))) .collect(Collectors.toList()); + // 批量填充标签 + fillTags(voList); return new PageResult<>(voList, result.getTotal(), result.getCurrent(), result.getSize()); } @@ -149,6 +172,8 @@ public class TransactionServiceImpl implements TransactionService { transaction.setTransactionTime(request.getTransactionTime()); transaction.setIncludeInStats(request.getIncludeInStats() == null ? Boolean.TRUE : request.getIncludeInStats()); transactionMapper.insert(transaction); + // 同步标签关联 + saveTransactionTags(userId, transaction.getId(), request.getTagIds()); } /** @@ -179,6 +204,8 @@ public class TransactionServiceImpl implements TransactionService { transaction.setTransactionTime(request.getTransactionTime()); transaction.setIncludeInStats(request.getIncludeInStats() == null ? Boolean.TRUE : request.getIncludeInStats()); transactionMapper.updateById(transaction); + // 全量替换标签关联 + saveTransactionTags(userId, id, request.getTagIds()); } /** @@ -193,6 +220,9 @@ public class TransactionServiceImpl implements TransactionService { if (transaction == null || !transaction.getUserId().equals(userId)) { throw new BusinessException("记录不存在"); } + // 先清理标签关联,再删除记录 + transactionTagMapper.delete(new LambdaQueryWrapper() + .eq(TransactionTag::getTransactionId, id)); transactionMapper.deleteById(id); } @@ -244,13 +274,14 @@ public class TransactionServiceImpl implements TransactionService { throw new BusinessException("单次最多更新1000条记录"); } - // 分类为可选,仅在提供时校验归属与类型 + // 分类与计入统计均为可选,仅在提供分类时校验归属与类型 Category expenseCategory = request.getExpenseCategoryId() == null ? null : requireCategory(userId, request.getExpenseCategoryId(), "EXPENSE"); Category incomeCategory = request.getIncomeCategoryId() == null ? null : requireCategory(userId, request.getIncomeCategoryId(), "INCOME"); - if (expenseCategory == null && incomeCategory == null) { - throw new BusinessException("请至少选择一个分类"); + Boolean includeInStats = request.getIncludeInStats(); + if (expenseCategory == null && incomeCategory == null && includeInStats == null) { + throw new BusinessException("请至少选择分类或计入统计"); } // 查询待更新记录,按交易类型拆分ID,避免类型不匹配的更新 @@ -267,22 +298,197 @@ public class TransactionServiceImpl implements TransactionService { .map(Transaction::getId) .collect(Collectors.toList()); + // 按类型分组,同组内一次写入分类与计入统计,避免重复计数 int updated = 0; - if (expenseCategory != null && !expenseIds.isEmpty()) { - updated += transactionMapper.update(null, new LambdaUpdateWrapper() - .set(Transaction::getCategoryId, expenseCategory.getId()) - .eq(Transaction::getUserId, userId) - .in(Transaction::getId, expenseIds)); - } - if (incomeCategory != null && !incomeIds.isEmpty()) { - updated += transactionMapper.update(null, new LambdaUpdateWrapper() - .set(Transaction::getCategoryId, incomeCategory.getId()) - .eq(Transaction::getUserId, userId) - .in(Transaction::getId, incomeIds)); - } + updated += batchUpdate(userId, expenseIds, expenseCategory, includeInStats); + updated += batchUpdate(userId, incomeIds, incomeCategory, includeInStats); return updated; } + /** + * 对指定记录批量写入分类与计入统计标记 + * + * @param userId 用户ID + * @param ids 待更新记录ID列表 + * @param category 目标分类,为 null 时不修改分类 + * @param includeInStats 计入统计标记,为 null 时不修改 + * @return 实际更新的记录数 + */ + private int batchUpdate(Long userId, List ids, Category category, Boolean includeInStats) { + if (ids.isEmpty() || (category == null && includeInStats == null)) { + return 0; + } + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper() + .eq(Transaction::getUserId, userId) + .in(Transaction::getId, ids); + if (category != null) { + wrapper.set(Transaction::getCategoryId, category.getId()); + } + if (includeInStats != null) { + wrapper.set(Transaction::getIncludeInStats, includeInStats); + } + return transactionMapper.update(null, wrapper); + } + + /** + * {@inheritDoc} + * + * 仅对属于当前用户的记录生效;先移除指定标签,再补充尚未存在的标签。 + */ + @Override + @Transactional + public int batchTags(Long userId, BatchTagRequest request) { + List ids = request.getIds(); + if (ids == null || ids.isEmpty()) { + throw new BusinessException("请至少选择一条记录"); + } + List addTagIds = validTagIds(userId, request.getAddTagIds()); + List removeTagIds = validTagIds(userId, request.getRemoveTagIds()); + if (addTagIds.isEmpty() && removeTagIds.isEmpty()) { + throw new BusinessException("请至少选择要添加或移除的标签"); + } + + // 仅处理属于当前用户的记录 + List validIds = transactionMapper.selectList(new LambdaQueryWrapper() + .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() + .in(TransactionTag::getTransactionId, validIds) + .in(TransactionTag::getTagId, removeTagIds)); + } + // 添加标签关联,跳过已存在的组合 + if (!addTagIds.isEmpty()) { + List existing = transactionTagMapper.selectList(new LambdaQueryWrapper() + .in(TransactionTag::getTransactionId, validIds) + .in(TransactionTag::getTagId, addTagIds)); + Set 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); + } + } + } + return validIds.size(); + } + + /** + * 过滤出属于当前用户的标签ID + * + * @param userId 用户ID + * @param tagIds 待校验的标签ID列表 + * @return 有效的标签ID列表 + */ + private List validTagIds(Long userId, List 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 tagIds) { + transactionTagMapper.delete(new LambdaQueryWrapper() + .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 voList) { + if (voList.isEmpty()) { + return; + } + List transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList()); + List relations = transactionTagMapper.selectList(new LambdaQueryWrapper() + .in(TransactionTag::getTransactionId, transactionIds)); + if (relations.isEmpty()) { + return; + } + List tagIds = relations.stream().map(TransactionTag::getTagId).distinct().collect(Collectors.toList()); + Map tagMap = tagMapper.selectBatchIds(tagIds).stream() + .collect(Collectors.toMap(Tag::getId, tag -> tag)); + Map> 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(toTagVO(tag)); + } + for (TransactionVO vo : voList) { + vo.setTags(tagsByTransaction.getOrDefault(vo.getId(), List.of())); + } + } + + /** + * 将标签实体转换为视图对象 + * + * @param tag 标签实体 + * @return 标签视图对象 + */ + private TagVO toTagVO(Tag tag) { + TagVO vo = new TagVO(); + vo.setId(tag.getId()); + vo.setName(tag.getName()); + vo.setColor(tag.getColor()); + vo.setSortOrder(tag.getSortOrder()); + vo.setCreatedAt(tag.getCreatedAt()); + return vo; + } + + /** + * 应用排序条件:默认按交易时间降序,支持按交易时间或金额升/降序。 + * + *

排序字段与方向均采用白名单,非法值回退为交易时间降序,避免注入风险。

+ * + * @param wrapper 查询条件 + * @param query 查询参数 + */ + private void applySort(LambdaQueryWrapper 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); + } + } + /** * 校验分类是否存在、归属当前用户且类型匹配 * @@ -322,6 +528,7 @@ public class TransactionServiceImpl implements TransactionService { vo.setRemarkHidden(t.getRemarkHidden() == null ? Boolean.FALSE : t.getRemarkHidden()); vo.setTransactionTime(t.getTransactionTime()); vo.setIncludeInStats(t.getIncludeInStats() == null ? Boolean.TRUE : t.getIncludeInStats()); + vo.setRawData(t.getRawData()); vo.setCreatedAt(t.getCreatedAt()); return vo; } @@ -380,9 +587,14 @@ public class TransactionServiceImpl implements TransactionService { if (StringUtils.hasText(query.getRemark())) { wrapper.like(Transaction::getRemark, query.getRemark()); } + // 按标签筛选:命中任一选中标签的记录 + if (query.getTagIds() != null && !query.getTagIds().isEmpty()) { + wrapper.inSql(Transaction::getId, "SELECT transaction_id FROM t_transaction_tag WHERE tag_id IN (" + + query.getTagIds().stream().map(String::valueOf).collect(Collectors.joining(",")) + ")"); + } - // 按交易时间降序排列 - wrapper.orderByDesc(Transaction::getTransactionTime); + // 应用排序:默认按交易时间降序,支持按交易时间/金额排序 + applySort(wrapper, query); List transactions = transactionMapper.selectList(wrapper); diff --git a/src/main/java/com/example/finance/vo/TagVO.java b/src/main/java/com/example/finance/vo/TagVO.java new file mode 100644 index 0000000..bd7a1e8 --- /dev/null +++ b/src/main/java/com/example/finance/vo/TagVO.java @@ -0,0 +1,29 @@ +package com.example.finance.vo; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 标签视图对象,用于向前端返回标签信息。 + * + * @date 2026-09-17 16:26:52 + */ +@Data +public class TagVO { + + /** 标签ID */ + private Long id; + + /** 标签名称 */ + private String name; + + /** 标签颜色 */ + private String color; + + /** 排序序号 */ + private Integer sortOrder; + + /** 创建时间 */ + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/example/finance/vo/TransactionVO.java b/src/main/java/com/example/finance/vo/TransactionVO.java index 84001f3..9e4235c 100644 --- a/src/main/java/com/example/finance/vo/TransactionVO.java +++ b/src/main/java/com/example/finance/vo/TransactionVO.java @@ -4,6 +4,7 @@ import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.List; /** * 交易记录视图对象,用于向前端返回交易详情(包含关联的分类信息) @@ -44,6 +45,12 @@ public class TransactionVO { /** 是否计入统计 */ private Boolean includeInStats; + /** 账单导入的原始完整数据(结构化JSON),手动记录为空 */ + private String rawData; + /** 创建时间 */ private LocalDateTime createdAt; + + /** 标签列表 */ + private List tags; } diff --git a/src/main/resources/sql/init.sql b/src/main/resources/sql/init.sql index 36acc0c..c076d86 100644 --- a/src/main/resources/sql/init.sql +++ b/src/main/resources/sql/init.sql @@ -34,6 +34,7 @@ CREATE TABLE IF NOT EXISTS t_transaction ( external_no VARCHAR(64) COMMENT '外部账单交易号,用于账单导入去重', transaction_time DATETIME NOT NULL COMMENT '交易发生时间', include_in_stats TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否计入统计:1-计入,0-不计入(如借款、转账等)', + raw_data JSON COMMENT '账单导入的原始完整数据(结构化JSON),手动记录为空', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', INDEX idx_user_id (user_id) COMMENT '用户ID索引', @@ -42,3 +43,25 @@ CREATE TABLE IF NOT EXISTS t_transaction ( INDEX idx_user_time (user_id, transaction_time) COMMENT '用户ID+交易时间联合索引', UNIQUE INDEX uk_user_external (user_id, external_no) COMMENT '用户ID+外部交易号唯一索引,用于导入去重' ); + +-- 标签表:存储用户自定义标签,用于给交易打标签 +CREATE TABLE IF NOT EXISTS t_tag ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '标签ID,主键自增', + user_id BIGINT NOT NULL COMMENT '所属用户ID', + name VARCHAR(50) NOT NULL COMMENT '标签名称', + color VARCHAR(20) COMMENT '标签颜色,用于前端展示', + sort_order INT NOT NULL DEFAULT 0 COMMENT '排序序号,数值越小越靠前', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + INDEX idx_user_id (user_id) COMMENT '用户ID索引', + UNIQUE INDEX uk_user_name (user_id, name) COMMENT '用户ID+标签名唯一索引' +); + +-- 交易标签关联表:交易与标签的多对多关系 +CREATE TABLE IF NOT EXISTS t_transaction_tag ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID,主键自增', + transaction_id BIGINT NOT NULL COMMENT '交易记录ID', + tag_id BIGINT NOT NULL COMMENT '标签ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + UNIQUE INDEX uk_transaction_tag (transaction_id, tag_id) COMMENT '交易+标签唯一索引', + INDEX idx_tag_id (tag_id) COMMENT '标签ID索引' +); diff --git a/src/main/resources/sql/init_tags.sql b/src/main/resources/sql/init_tags.sql new file mode 100644 index 0000000..e4a5cc6 --- /dev/null +++ b/src/main/resources/sql/init_tags.sql @@ -0,0 +1,38 @@ +-- 初始化默认标签种子脚本 +-- 用途:为现有用户补齐一套通用标签 +-- 特性:幂等,按「用户ID + 标签名称」去重,可重复执行 +-- 编码:utf8mb4 + +CREATE TEMPORARY TABLE IF NOT EXISTS tmp_default_tag ( + name VARCHAR(50) NOT NULL, + color VARCHAR(20), + sort_order INT NOT NULL +); + +DELETE FROM tmp_default_tag; + +INSERT INTO tmp_default_tag (name, color, sort_order) VALUES + ('报销', '#2080f0', 10), + ('待报销', '#f0a020', 20), + ('旅行', '#18a058', 30), + ('家庭', '#8a2be2', 40), + ('工作', '#d03050', 50), + ('学习', '#0ea5e9', 60), + ('医疗', '#e11d48', 70), + ('大额消费', '#f97316', 80), + ('订阅', '#7c3aed', 90), + ('礼物', '#ec4899', 100), + ('投资', '#16a34a', 110), + ('宠物', '#a16207', 120), + ('电影', '#14b8a6', 130); + +INSERT INTO t_tag (user_id, name, color, sort_order) +SELECT u.id, d.name, d.color, d.sort_order +FROM t_user u +JOIN tmp_default_tag d +WHERE NOT EXISTS ( + SELECT 1 FROM t_tag t + WHERE t.user_id = u.id AND t.name = d.name +); + +DROP TEMPORARY TABLE IF EXISTS tmp_default_tag;