“在家把账记明白,比理财本身更费劲。”这句话我以前当调侃,直到自己动手做了这套个人理财系统,才发现一套Java SpringBoot+Vue3+MyBatis前后端分离的源码里,藏着大量从需求到落地的门道。这套系统不追求花哨,目标就是让一个人把日常收支、预算规划和统计报表全部握在自己手里,数据存进MySQL,接口由SpringBoot提供,页面由Vue3渲染。
这篇文章我会从需求拆解讲到技术选型、数据库设计、后端接口、Vue3前端页面,再落到底层部署与常见坑位排查,尽量还原我自己实现时的思考过程。适合有Java基础、想完整走一遍全栈项目流程的读者,也适合拿来当毕业设计或简历项目的基础骨架。全文所有配置和代码都基于实际跑通的项目,照着搭就能起步。
1. 项目拆解:个人理财系统的核心需求与整体设计
1.1 记账、预算、统计:个人理财系统的三个核心模块
个人理财系统最核心的从来不是技术,而是想清楚用户进来到底要干什么。我的理解浓缩成三个动作:记录、规划、分析。记录对应收入和支出流水,每笔交易要有分类、金额、账户、备注和实际消费时间;规划对应预算管理,在月初给某类支出设上限,月底红线在哪一目了然;分析对应统计报表,按分类汇总、按月对比、看结余趋势。这三个动作做扎实,系统就有实用价值。
不要一上来就写注册登录,那是基础设施,不是业务核心。很多个人项目死在精力分配上——页面样式调得天花乱坠,真正负责记账的CRUD反而漏洞百出。我开发时的优先级很明确:交易流水第一,预算和统计第二,用户体系最后。顺着这个顺序排开发计划,每阶段都有能演示的成果,心态上也会轻松很多。
设计时的细节决定了系统能不能日常使用。金额不能为负数,删除分类前要检查有没有关联流水,预算按自然月结算,跨月后自动重置。这些“不算功能但必须处理”的点,恰恰是demo和可用系统之间的分水岭。我在整套源码里把这类逻辑全部放在Service层统一处理,避免了同一个规则在多个接口里分散实现。
1.2 前后端分离的数据流转:一次请求走完全链路
前后端分离的本质是职责划分,不是简单分成两个项目。Vue3负责页面渲染和用户交互,通过HTTP请求调用SpringBoot提供的RESTful API;MyBatis负责把Java对象映射成SQL操作;MySQL负责最终落盘。一次完整记账请求的链路是这样的:前端表单收集数据后,Axios发起POST请求到/api/transactions,携带JSON体;后端Controller接收后调Service做业务校验——金额大于零、分类属于当前用户、账户状态正常;Service再调Mapper写入数据库,成功后返回新增记录;前端拿到响应后刷新表格区并重新拉取统计图数据。
这条链路看着简单,每一层都有各自的坑。JSON字段用驼峰还是下划线、日期格式传字符串还是时间戳、接口返回结构是否统一,都会影响前后端协作效率。我在项目里统一用一个Result对象包装响应,三字段结构——code/message/data,前端在响应拦截器里统一处理,而不是每个接口各自判断成功失败。这套约定一旦定下来,后续加接口基本零思考成本。
2. 技术选型硬核拆解:SpringBoot、Vue3、MyBatis、MySQL各自解决什么问题
2.1 SpringBoot:把后端开发的配置成本打下来
SpringBoot能成为Java Web项目的默认选项,不是因为它技术多花哨,而是它把Spring生态里大量重复配置固化成了约定。对个人理财系统这种典型CRUD加少量业务规则的项目来说,SpringBoot的自动配置能省掉绝大部分XML配置,一个starter依赖就能引入Web、MyBatis、数据库连接池等一整条链路。专注业务本身,这就是它最大的价值。
这里必须提一个我踩过的认知误区:很多人喜欢追新,直接上SpringBoot 3.x。3.x默认要求JDK17起步,部分第三方starter的兼容性也确实不如2.x成熟。如果读者还在用JDK8,或者项目要部署在比较老旧的服务器上,SpringBoot 2.7.x反而是更稳的选择。个人理财项目对性能没有极端要求,选2.7不会带来任何遗憾,反而能在依赖选型上少踩一堆坑。
2.2 Vue3+Element Plus:中后台前端的成熟组合
Vue3相比Vue2最大的变化是组合式API和响应式原理重构。对个人项目来说,用Composition API组织代码可以按业务维度聚拢逻辑——记账页面的数据请求、表单校验、表格刷新全放在同一个函数里,不用像Vue2的Options API那样把代码零散分散到data、methods、computed里。配合Vite使用后,开发时的热更新速度是肉眼可见的提升,启动项目基本秒开。
UI组件我选了Element Plus,看中的是它在中后台场景的高度成熟。表格、表单、弹窗、日期选择器这些轮子直接拿来用,能给开发省下大量手写CSS的时间。个人理财系统的界面不需要多炫酷,布局清晰、按钮位置合理、统计图表直观,就已经能带来良好的使用体验。组件库选对,视觉下限就有了保障。
2.3 MyBatis+MySQL:SQL可控与数据落地的务实搭档
新人在选ORM时通常纠结JPA还是MyBatis。我的倾向非常明确:像理财系统这种业务有一定复杂度、需要写多表聚合查询的场景,MyBatis更合适。它虽然要手写XML或注解SQL,但换来的是SQL执行的绝对可控。按月分组统计收支的SQL、分类汇总的近六个月趋势查询,都能在XML里精确定义,后续调优时也能直接看到完整SQL。
MySQL在单用户级别下,一台低配云服务器甚至本机就能扛住。这个项目的性能瓶颈几乎不会出现在数据库上,真正要关注的是编码、时区、事务这些基础设置。表统一用InnoDB引擎,保证事务支持和行级锁;金额字段用DECIMAL而不是float/double;时间字段建议datetime类型,时区统一Asia/Shanghai。这些约定越早定下来,后期返工越少。
3. 核心表结构设计:五张表撑起一套理财系统
3.1 表结构设计:用户、分类、流水、预算、账户怎么拆
我设计的五张核心表,分别是用户表、分类表、交易流水表、预算表、账户表。分类表和预算表是理财系统区别于普通CRUD demo的关键,缺少它们就只能叫记账本,而不是理财系统。
用户表保留最简字段:id、username、password、nickname、create_time。密码必须用BCrypt加盐哈希存储,绝对不能拿MD5直接应付,这是安全底线。分类表的结构比较有意思,核心字段是user_id加type,用type区分收入类和支出类,前端用颜色区分,后端统计时用case when拆开处理。交易流水表是整库的核心,id、user_id、category_id、type、amount、account_id、note、trade_time、create_time缺一不可。我要特别强调trade_time和create_time必须分两个字段——一个是实际消费时间,一个是记录创建时间,补录旧账时如果混在一起,统计报表会彻底失真。
预算表按user_id加category_id加month做唯一约束,month直接用YYYY-MM字符串,避免用时间类型带来的月份格式化问题。账户表的设计服务于多账户场景,用户可以有现金、工资卡、余额宝等多个账户,流水表通过account_id关联。这个设计在后续扩展转账功能时非常关键。
3.2 金额字段为什么必须用DECIMAL:浮点精度坑实录
这个坑几乎每个做财务相关项目的人都会踩一遍。float和double在Java和MySQL里都是二进制浮点表示,0.1加0.2在小数位数多时会出现肉眼可见的精度误差,结余计算越算越偏。个人理财系统虽然不是银行核心系统,但账目对不上会让人瞬间失去信任。
MySQL里金额字段统一用DECIMAL(10,2),Java实体对应BigDecimal,SpringBoot返回JSON时默认也能处理成字符串形式,前端拿到的就是精确的“12.34”而不是“12.3400000001”。这里有一个很隐蔽的坑:BigDecimal比较要用compareTo而不是equals。我用equals比较两个数值相同的BigDecimal时,因为两者的精度位数不同,返回了false,导致数据校验一直报错,排查了快一个小时才定位到问题。建议在项目里封装一个金额工具类,把加法、减法、比较和格式化的逻辑集中管理。
4. 后端实战:SpringBoot+MyBatis从零搭建核心接口
4.1 项目初始化与四层结构:一个清爽的起步姿势
我习惯直接用Maven骨架起项目,不太喜欢依赖IDEA默认模板,因为模板里塞了很多用不到的依赖。最终的pom.xml非常精简:spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-j、jjwt做登录令牌、lombok生成样板代码。个人项目依赖精简化,好处是全项目依赖冲突的概率大幅下降,构建速度也更快。
分层结构是经典的四层:controller处理接口和参数校验,service承载业务逻辑和事务,mapper做数据库访问,entity/dto承载数据结构。我强烈建议把请求参数封装成DTO,不要直接把用户传的JSON映射到数据库实体,否则调用方可以在请求体里塞多余字段,MyBatis动态SQL一不留神就会把脏数据带进数据库。
application.yml里的配置有一个容易忽略的开关:map-underscore-to-camel-case必须设为true。数据库字段是category_id,Java实体属性是categoryId,如果不开启驼峰映射,查询结果会全部变成null。这个配置项一句话就能搞定,不然后续每次写resultMap会写到怀疑人生。
4.2 JWT登录认证:用户体系的安全边界
个人理财系统保存着用户的私密财务数据,接口不能裸奔。我用JWT做无状态认证,流程是:登录成功后服务端生成带过期时间的token返回前端,前端存localStorage,每次请求在Authorization头带上,后端拦截器统一校验。
拦截器里的逻辑不复杂:先放行/api/auth开头的接口,再解析其余请求的token,把用户ID塞进请求上下文,解析失败或过期则返回401。这个机制有个容易被忽视的坑:拦截器的执行顺序必须排在CORS跨域配置之后,否则浏览器的预检请求会被拦截器提前挡掉,前端看到的报错会非常难懂,甚至让你误判为跨域配置问题。
密码处理别省事。注册时用BCrypt加密,登录时用matches校验,哪怕后端只面向一个用户也要按规范来。这套源码里用户相关的密码永远不会以明文形式落库或传输,这是对用户数据最基本的尊重。
4.3 记账接口实现:事务、校验与账户同步
记账接口是整个系统的业务核心。我定义POST /api/transactions,请求参数包括categoryId、amount、type、accountId、note、tradeTime。Service层按顺序处理四件事:校验金额大于零;确认分类属于当前用户;写入交易流水表;同步更新对应账户的余额。
第四步必须在同一个事务里完成,否则账户余额变了流水没写,或者流水写了账户没变,账目对不上只是时间问题。事务我用@Transactional注解,默认遇到RuntimeException就回滚。需要特别注意的是,MyBatis执行写操作后要拿到自增主键,在insert标签里加useGeneratedKeys="true" keyProperty="id",否则后续业务逻辑拿不到新记录ID。
多条件查询是流水列表的高频需求。筛选条件包括日期范围、分类、类型、账户,我全部用MyBatis XML动态SQL实现,where标签加if标签拼出安全条件。分页直接手写limit参数,个人项目完全不必要引入PageHelper插件,少一个依赖就少一份版本冲突的风险。
4.4 统计报表接口:按月分类汇总的SQL写法
统计是理财系统最能体现价值的地方。我实现两个核心统计接口:按月分类汇总、按月份结余趋势。前者回答“钱花哪了”,后者回答“存下了多少”。
按月分类汇总SQL的核心是group by category_id和month(trade_time),配合sum聚合和case when区分收支。近六个月收支趋势的数据结构是[{month: '2026-01', income: 12000, expense: 8600}],后端一次查询把两个聚合结果都返回给前端,直接喂给ECharts出图。接口返回的key一定要想清楚再定,前端要什么格式后端就给什么格式,别让前端在JavaScript里做一堆无意义的map转换。
统计接口的数据量级在个人场景下很小,不需要缓存,也不需要额外优化。但SQL里要注意索引设计,流水表建议在user_id和trade_time上建联合索引,否则随着记录增多,按月筛选的查询会越来越慢。这是整套源码里极少数需要未雨绸缪的性能点。
5. 前端实战:Vue3从脚手架到页面开发
5.1 脚手架搭建:用Vite初始化Vue3项目并规划目录
前端用npm create vite@latest初始化项目,模板选择Vue3加JavaScript。个人项目我不建议一开始上TypeScript,先把业务架构跑通,心智负担低很多。等整体结构稳定后,再迁移类型定义也不迟。
目录规划遵循中后台项目的通用范式:src/api放接口封装,src/stores放Pinia状态,src/router放路由配置,src/views按业务页面划分,src/components放可复用组件。这套结构放到任何Vue3项目里都能直接用,养成习惯后迁移成本极低。
vite.config.js里的开发代理配置是前后端分离的第一步配置。我写server.proxy把/api开头的请求代理到http://localhost:8080,同时设置changeOrigin。这个配置同时解决了开发环境的跨域问题,也避免了在业务代码里写死接口地址的坏味道。之后部署到生产环境,只需要改代理目标,前端代码一行都不用动。
5.2 Pinia与Axios封装:状态和请求的统一入口
Pinia是Vue3官方推荐的持久化状态管理方案。我的使用原则是一切从简:只把登录用户信息和token放Pinia,账目数据不往全局放。流水数据需要频繁更新,放在页面组件里反而更容易控制刷新时机,全局状态只放跨页面共享的部分。
Axios封装是前端工程化最关键的一环。我在src/api/request.js里统一做几件事:设置baseURL指向/api,请求拦截器自动带Authorization头,响应拦截器统一判断code字段——200直接返回data,401清理登录状态并跳转登录页,其他code弹出ElMessage提示。做完这层封装后,每个业务接口文件只需要几行代码就能定义清楚,接口变更时的改动范围也会大幅缩小。
5.3 账单流水页面:表格、筛选、分页三件套
账单流水页面是用户使用频率最高的页面。我的布局是“筛选区加表格区加分页区”三段式:筛选区包含日期范围、分类下拉、类型单选;表格区展示时间、分类、账户、金额、备注,金额用颜色区分收入和支出;分页统一由后端完成,前端只负责传页码和页大小。
筛选条件变化时自动重新拉取数据,这里需要厘清Vue3响应式细节。用reactive创建查询对象时,如果直接修改属性并依赖监听,要确保监听的是具体路径。我习惯用一个query对象整体替换来触发更新,避免监听不到的变化,这会让代码更直白。
金额列统一用格式化函数展示后端返回的BigDecimal字符串,前端不做任何浮点运算。分类列直接用后端JOIN查询得到分类名,不需要在前端拿分类ID去查表。这类数据聚合交给后端处理,页面逻辑会干净很多,出错的概率也低。
5.4 ECharts统计图:收支报表的直观呈现
统计页面是理财系统的门面,我用ECharts做可视化,两张核心图:分类支出饼图和月度收支柱状图。饼图展示某月支出在分类间的分布,柱状图对比近六个月的收入支出变化。
ECharts数据更新时有一个特别容易踩的坑:直接把响应式对象赋值给option可能导致图表刷新异常。我的做法是用watch监听数据源,深拷贝一份再赋给option,然后调用chart实例的setOption,这样ECharts内部对option的修改就不会被Vue响应式代理干扰。
图表容器一定要显式设置宽高。如果初始化时容器宽高为0,图表渲染出来就是空白。我的习惯是在组件里固定容器尺寸,页面布局变化时调用chart.resize()重新调整。还有一个细节值得注意:统计页面的接口请求最好集中在onMounted里统一发起,多个图表数据并行加载,避免页面出现长时间空白。
6. 前后端联调与部署经验:跨域、打包、上线一步到位
6.1 开发环境的Vite代理与生产环境的Nginx转发
跨域问题吓退了很多初次接触前后端分离的开发者,其实处理思路很清晰:开发环境用Vite代理,生产环境用Nginx反向代理或同源托管。
开发环境因为前端跑在5173端口,后端跑在8080端口,天然产生了跨域。Vite的proxy配置解决了这个问题,前端请求/api开头的地址全部转发到后端。有的同学喜欢在SpringBoot里加全局CORS配置来绕过跨域,我建议开发环境尽量别这么做,全局开放跨域等于降低安全门槛。生产环境更没必要讨论跨域,只要前端页面和后端接口最终落在同一个域名或者通过Nginx转发,浏览器根本感知不到跨域的存在。
6.2 两种打包部署方案:Vue3放进SpringBoot的实操
部署方案有两种,我分别试过。第一种是纯粹分离式:前端npm run build生成dist目录,放到Nginx指定路径,后端jar包单独跑在另一个端口,Nginx配置/api转发到后端。第二种是把前端构建产物复制到SpringBoot的src/main/resources/static目录,让后端自己托管静态文件,最终打成一个jar包。
个人理财系统这种单用户项目,我强烈推荐第二种方案:省服务器资源,运维成本低,一个进程全包。实操上只需要修改Vite的build.outDir指向后端static目录,打包时先后端再前端即可。这里还有一个路由模式的坑:Vue Router如果用history模式,后端需要处理路由回退,否则直接访问某条子路径会404。最简单的方式是用hash模式,URL里带#虽然不美观,但不增加后端复杂度。如果坚持history模式,后端需要加一个controller把所有非api请求转发到index.html。
7. 实战中踩过的坑:排查记录与避坑指南
7.1 数据库时区差8小时:连接串里的隐藏问题
我第一版接口插入数据库的时间比本地时间少了整整8小时,排查后发现是MySQL连接串没指定serverTimezone,JDBC默认取了UTC时区。解决办法是jdbc url里加serverTimezone=Asia/Shanghai,同时补上useUnicode=true和characterEncoding=utf8解决中文乱码。
时间字段的类型选择也要考虑。我统一使用datetime而不是timestamp,原因是datetime不参与时区转换,存的是什么取出来就是什么,个人理财系统根本不需要跨时区能力。Java 8的LocalDateTime映射datetime类型没有任何兼容性问题,整套流程用下来非常顺畅。
7.2 MyBatis驼峰映射与动态SQL:别在字段名上翻车
MyBatis最常见的坑就是字段映射。数据库category_id对应实体categoryId,不开启map-underscore-to-camel-case,查询结果就全是null。我通篇坚持下划线字段名,全局开启驼峰映射,XML里写resultType就能直接接收,不需要维护一堆resultMap。
动态SQL的where标签用法值得多说一句。多条件筛选时,用where加if拼条件可以避免and开头引发的SQL语法错误。我每次写完一个动态查询都会强制测两种场景:一个带满条件的请求,一个不带条件的请求,确认SQL都不会出错后才算通过。这个习惯帮我挡住了不少低级错误。
7.3 Vue3响应式数据丢失:reactive与ref要分清
Vue3响应式丢失是我前端开发中印象最深的一个坑。用reactive创建数组,拿到新数据后直接整体赋值,页面死活不更新。原因在于reactive包装的引用类型对象,整体替换会失去原有代理的响应性。
规避方法很简单:列表这种需要整体替换的数据,一律用ref声明,赋值用.value;表单这种细粒度更新的对象,用reactive。两个API各管一摊,之后我再也没遇到过页面不刷新的诡异问题。新手如果发现数据变了页面没反应,优先检查是不是reactive整体赋值。
7.4 LocalDateTime序列化:前后端日期格式对齐
SpringBoot默认把LocalDateTime序列化成类似“2026-01-05T12:00:00”的格式,前端日期组件拿到后完全无法直接显示。解决方式是全局配置Jackson的日期格式化规则,把LocalDateTime格式化成“yyyy-MM-dd HH:mm:ss”,LocalDate格式化成“yyyy-MM-dd”。
前端查询日期范围时,我习惯传startDate和endDate两个字符串参数,后端用@DateTimeFormat按指定格式解析。这种方式接口语义直观,也不容易出现时区偏移和格式拼接的问题。如果你的项目里有人为了省事传时间戳,再被我看到,我会建议他立刻改成字符串格式。
最后再分享一句我自己的体会。把整套系统写完回头看,技术栈其实非常常见,真正的成长在于你把一个真实需求从头到尾捋顺的过程:表设计里多考虑一个字段,统计SQL怎么写才能让前端少做一次转换,事务边界画在哪里,部署时怎么少维护一个进程——这些都是教程里不会逐字教你的东西,只能靠动手做一遍才能真正变成经验。这个项目的边界也完全开放,后续可以接定时账单提醒、Excel账单导出、多账户转账流水,每一步都是很自然的延伸。希望我踩过的这些坑,能帮你把第一版做得比我更稳。