做Java后端开发的人,大概率都攒过几个类似的练手项目:一个Spring Boot后端,连上MySQL,再套个前端模板,凑出一套能增删改查的系统。家庭理财系统就是其中非常经典的一类。今天这篇把Spring Boot家庭理财系统从需求到源码实现完整拆一遍,包括数据库怎么设计、记账流程怎么走、预算和报表怎么做、源码拿到手之后怎么跑起来,以及我在实际调试中踩过的一些坑。这套项目虽然叫“家庭理财”,但结构上就是一个标准的业务系统样板:用户体系、核心交易流水、分类统计、权限控制,全是后端开发的高频场景,拿来练手或者做课程设计都很合适,源码参考价值比单纯的CRUD强得多。
我最早接触这类项目的时候,也以为它就是个“记账本”,无非是把金额、日期、备注存进表里。真正动手做才发现,里头的门道不少:多账户怎么算余额、家庭成员之间数据怎么隔离、预算提醒的边界条件怎么处理、统计图表的聚合查询怎么写才不出错。把这些捋清楚了,你基本就掌握了一套通用信息管理系统的开发范式,换一个业务领域也能很快上手。
1. 项目核心定位与设计拆解
1.1 为什么这类项目总爱选Spring Boot
家庭理财系统的核心是处理结构化数据,而且数据量级不会太大,既不需要分布式中间件,也不需要复杂的消息队列。Spring Boot在这种场景下的优势非常明显:自带Tomcat,内嵌启动方式让本地开发和后期部署都少了很多配置;配合Spring MVC,接口写起来很直接;再加上Spring家族对事务管理、数据源的成熟封装,做账目这种对数据一致性敏感的模块,心里会踏实很多。
账目数据的核心特征是:一笔支出可能同时影响账户余额、分类统计、预算剩余额度,任何一步出问题都会导致账不平。Spring Boot提供的声明式事务恰好解决了这个问题,一个@Transactional注解就能保证一组数据库操作要么全成功、要么全回滚。这一点是很多新手容易忽略的,也是我反复强调“不要只写增删改查”的原因所在。
1.2 家庭理财系统的需求边界到底在哪里
动手之前必须先做需求划分。家庭理财系统跟个人记账App最大的区别在于“家庭”两个字,意味着数据不是一个人的,而是多个成员共享的。
常规的功能模块通常包含这几个方面:
- 用户与家庭:用户注册登录、家庭成员关联、登录状态保持;
- 账户管理:现金账户、银行卡、电子钱包等,每个账户有独立余额;
- 收支流水:记录每一笔收入或支出,关联账户、分类、家庭成员、备注等信息;
- 分类管理:收入分类和支出分类分开维护,支持自定义增删改;
- 预算管理:按月份、按分类设置预算额度,实际支出超过预算后触发提醒;
- 统计报表:按月看收支走势、按分类看占比、按账户看余额变化。
这个范围设计是有逻辑考虑的。记账类应用如果只有流水没有账户,用户对“钱去了哪里”缺乏直观感知;如果只有流水没有预算,那只是一个记录工具,谈不上“理财”;如果只有统计没有多用户隔离,家庭成员之间看同一份数据会很混乱。把这几块拼起来,系统才算是闭环。
1.3 技术栈选型与理由
不同项目源码的技术栈会有差异,但这套系统比较常见的选择是:
后端采用Spring Boot 2.x版本,持久层用MyBatis-Plus,数据库用MySQL,前端使用Thymeleaf模板加Bootstrap或者Layui,图表展示用ECharts。有时候也会看到前后端分离版本,用Vue加Axios,后端只出接口。两者各有取舍:模板渲染模式写起来快,适合单机小项目;前后端分离模式更贴近现在的团队开发方式,接口和页面解耦,也方便蓝牙小程序之类的其他端复用同一个后端。
数据访问层我比较推荐MyBatis-Plus。它既保留了MyBatis写SQL的灵活性,又提供了IService、LambdaQueryWrapper这类简洁的封装。家庭理财系统的统计SQL往往包含复杂的聚合和日期分组,直接用注解在Mapper里写SQL很方便,而普通的按条件查询用封装方法就能搞定,不用每个接口都手写XML。
2. 数据库设计与核心表结构
2.1 用户与账户的模型怎么设计
数据库设计是这类系统里最值得细看的部分。第一张核心表是用户表,字段一般包含用户名、密码、昵称、手机号、创建时间之类的基础信息,密码必须存加密后的密文。用户表之上还要有一张家庭表或者成员关系表,用来把多个用户关联到同一个家庭空间下,后续所有流水和预算查询都以家庭维度过滤。有些简化版本可能会直接给用户表加一个family_id字段,数据量不大的情况下这样也能凑合用,但正规一点的做法还是独立一张家庭表。
第二张核心表是账户表,字段包括账户名称、账户类型、初始余额、当前余额、所属用户或家庭、状态等。这里有一个容易踩坑的点:账户余额不应该直接由程序计算得出,而是要在每次记账时同步更新。如果所有余额都靠SUM聚合流水得到,那么流水一多、出现删除或修改的历史数据时,余额很容易对不上。反之,如果你用独立的余额字段,每次记一笔就加加减减,实时性很好,但也要保证事务一致,避免出现只更新流水没更新余额的情况。
2.2 收支流水表与预算表的字段要点
流水表是最核心的数据载体,字段大致包括:用户ID、账户ID、分类ID、收支类型、金额、收支日期、备注、创建时间。这里有三个细节值得注意:金额字段建议用DECIMAL(10,2)而不是FLOAT或DOUBLE,因为浮点数在数据库里的精度不可控,算总账时容易出现0.01之类的误差;收支类型字段可以用TINYINT或者VARCHAR加枚举区分,个人习惯用TINYINT,0表示支出、1表示收入,后台用枚举类做映射;收支日期和创建时间要分开存,收支出日期是业务时间,创建时间是系统时间,统计报表按业务日期归集才是准的。
预算表的核心字段是预算月份、分类ID或账本ID、预算金额、已用金额。这类表的设计有一个进阶考量:预算是在指定月份内生效的,所以预算表里千万不要只放一个年月字段加一个总金额,还要考虑每个分类分别限额的情况。我的做法是每条预算记录绑定一个分类,预算月份用VARCHAR存“2025-01”这种格式,这样统计某月是否超支时,一条SQL就能把预算金额和实际支出对比出来。
2.3 建表SQL与关键约束
给出一份简化但结构完整的核心建表参考:
CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT '密码密文', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `family_id` bigint DEFAULT NULL COMMENT '家庭ID', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE `account` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `account_name` varchar(50) NOT NULL, `account_type` tinyint DEFAULT 0 COMMENT '0现金 1银行卡 2电子钱包', `balance` decimal(10,2) DEFAULT 0.00, `status` tinyint DEFAULT 1, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户表'; CREATE TABLE `bill_record` ( `id` bigint NOT NULL AUTO_INCREMENT, `family_id` bigint DEFAULT NULL, `user_id` bigint NOT NULL, `account_id` bigint NOT NULL, `category_id` bigint NOT NULL, `type` tinyint NOT NULL COMMENT '0支出 1收入', `amount` decimal(10,2) NOT NULL, `record_date` date NOT NULL, `remark` varchar(200) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_family_date` (`family_id`, `record_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收支流水表'; CREATE TABLE `budget` ( `id` bigint NOT NULL AUTO_INCREMENT, `family_id` bigint DEFAULT NULL, `category_id` bigint DEFAULT NULL, `budget_month` varchar(7) NOT NULL COMMENT '如2025-01', `amount` decimal(10,2) NOT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_month_category` (`budget_month`, `category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预算表';所有表都使用InnoDB引擎和utf8mb4字符集,这是两个基本约定。InnoDB支持事务,这是账目操作的前提;utf8mb4保证表情符号和生僻字不会变成问号,我自己就遇到过一次用户备注里带了一个emoji,结果整个页面乱码的情况,后来排查才发现是表字符集不是utf8mb4导致的。
3. 从源码到可运行:环境准备与启动流程
3.1 运行环境清单与版本选择
源码拿到手,先别急着改代码,第一步是把运行环境对齐。家庭理财系统是典型的Spring Boot单体项目,版本兼容问题多半出现在这里。
必备环境大致是:
- JDK 1.8或更高版本
- Maven 3.6+
- MySQL 5.7或8.0均可
- 开发工具:主流IDE即可,导入Maven项目即可
如果你用的是Spring Boot 2.x,那么JDK 8完全够用;如果源码升级到了Spring Boot 3.x,JDK要切到17。这是很多人一开始就翻车的地方:项目能正常编译,但一运行就报UnsupportedClassVersionError,基本就是JDK版本低于编译版本。
MySQL方面,家庭理财系统一般不会用到太新的特性,5.7和8.0都行。但要注意连接驱动版本,如果是MySQL 8.0,驱动类名应该是com.mysql.cj.jdbc.Driver,8.0之前的版本才是com.mysql.jdbc.Driver。很多基于Spring Boot 1.x的老项目会沿用旧驱动,导入到新环境里启动时就报错。
3.2 源码目录结构怎么读
拿到源码后,先用IDE打开,确认是一个Maven工程,检查一下整体结构。一套标准的Spring Boot业务代码目录是这样的:
src/main/java ├── controller ├── service │ └── impl ├── mapper ├── entity ├── config ├── common │ ├── result │ ├── exception │ └── utils src/main/resources ├── mapper ├── static └── templates读取这种结构有个顺序:先看common包里的统一返回体Result类,里面无非是code、message、data三个字段,这是判断整个项目接口规范的基础;再看config包,确认是否配置了拦截器、跨域、密码加密Bean;接着看mapper包和resources下的XML,理解数据库操作是怎么封的;最后才看controller,把接口清单过一遍,心里就有了全局认识。
3.3 配置修改与启动验证
实际启动之前需要改配置文件,核心是数据源。找到src/main/resources下的application.yml,修改以下几处:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/family_finance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImplurl处有三个关键参数:useUnicode=true和characterEncoding=utf8保证中文不乱码,serverTimezone=Asia/Shanghai是MySQL 8的时区要求,不设置的话通常会报Server returns invalid timezone错误。
数据库建好后,把源码里自带的SQL脚本执行一遍,一般文件名会是init.sql、db.sql或者schema.sql。执行顺序要注意:先建库,再建表,最后再看是否初始化了测试账号。初始化数据里的默认账号密码往往是admin/admin123这种,登录进去就能看到系统预设的分类数据和演示流水。
启动方法没有特别之处:等Maven下载完依赖,直接运行主启动类,看到Spring Boot的启动横幅和Tomcat started on port 8080,就说明成功了。访问http://localhost:8080,能看到登录页就是正常状态。如果端口被占用,改一下server.port即可。
4. 核心功能模块的实现思路与代码解读
4.1 用户登录与权限控制
用户模块一般包含注册和登录两块。注册时密码必须加密存储,常见的是BCrypt算法,也就是Spring Security提供的BCryptPasswordEncoder。BCrypt的特点是每次加密结果都不同,而且自带随机盐,比MD5加盐再拼接的方式安全得多。
登录接口的核心逻辑是:按用户名查记录,把数据库里的密文和用户输入的明文放到matches方法里做校验,而不是把数据库密文解密回明文再去比对。BCrypt没有解密方法,它通过对同一明文重新计算并比对摘要来完成验证。校验通过后生成一个token,常见做法是用JWT工具类生成令牌,返回给前端,前端保存在本地,后续请求统一在请求头里带上。后端写一个拦截器校验token,解析成功就放行,失败就返回401。
一个完整的登录控制器大致是这样的:
@PostMapping("/login") public Result login(@RequestBody LoginDTO dto) { User user = userService.lambdaQuery() .eq(User::getUsername, dto.getUsername()) .one(); if (user == null) { return Result.error("用户不存在"); } if (!passwordEncoder.matches(dto.getPassword(), user.getPassword())) { return Result.error("用户名或密码错误"); } String token = JwtUtil.createToken(user.getId(), user.getUsername()); user.setPassword(null); return Result.success(token, user); }这里的细节在于:查询对象或返回对象中一律不要把密码字段传给前端,最稳妥的做法是在返回前把密码字段置空。很多项目就是因为忘了这一步,导致接口数据里把密码哈希一起泄露出去,虽然反向破解难度大,但这不是好习惯。
4.2 记账与分类管理的实现细节
记账是整个系统使用频率最高的操作。前端提交的数据包括账户ID、分类ID、类型、金额、日期、备注,后端要做两件事:插入流水记录;同步更新账户余额。支出时账户余额减少,收入时账户余额增加。
这个操作必须开启事务。因为插入流水和更新余额是两个独立的数据库写操作,中间任何一个失败都会导致账目不平。Spring Boot里实现事务很简单,在service方法上加@Transactional注解即可。我用MyBatis-Plus时习惯把同一账户的更新包在单独的方法里,再从这个方法外面调,这样代理机制能正常生效,避免自调用导致事务失效的经典坑。
@Transactional(rollbackFor = Exception.class) public boolean addRecord(BillRecord record) { boolean saved = this.save(record); if (!saved) { return false; } Account account = accountService.getById(record.getAccountId()); if (account == null) { throw new BizException("账户不存在"); } BigDecimal change = record.getType() == 0 ? record.getAmount().negate() : record.getAmount(); account.setBalance(account.getBalance().add(change)); return accountService.updateById(account); }注意事务的回滚条件要写成rollbackFor = Exception.class,因为Spring默认只回滚RuntimeException,而业务异常往往是自定义受检异常的子类,不确定类型时统一指定更省心。
分类管理相对简单,一张分类表,按类型区分收入和支出,前端在记账页做成下拉选项。这里有一个隐藏需求:分类数据最好支持用户自定义,因为每个家庭的消费习惯不一样,除了默认的餐饮、交通、购物等固定分类,用户可能想加一个“宠物开支”之类的自定义项。所以分类表要留user_id字段,允许为空表示系统默认分类,不为空表示用户自定义。
4.3 统计报表与图表展示
报表模块是家庭理财系统里最能拉开档次的部分。常规统计包括月度收支对比、分类占比、账户余额分布。后端只需要提供几个聚合查询接口,前端用ECharts渲染图表即可。
月度收支统计的核心SQL是分组聚合:
SELECT DATE_FORMAT(record_date, '%Y-%m') AS month, SUM(CASE WHEN type = 0 THEN amount ELSE 0 END) AS total_expense, SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS total_income FROM bill_record WHERE family_id = #{familyId} AND record_date BETWEEN #{startDate} AND #{endDate} GROUP BY DATE_FORMAT(record_date, '%Y-%m') ORDER BY month;这段SQL的要点在DATE_FORMAT函数。它能把date类型的日期按年月归组,直接输出像“2025-01”这样的月份字符串,前端直接用做X轴坐标。CASE WHEN配合SUM的写法,可以避免先查支出再查收入、然后在内存里合并数据的笨办法。两次查询在数据量大时不仅慢,还容易因为分组逻辑不一致导致数据对不上。
分类占比统计稍微复杂一些,需要关联分类表:
SELECT c.name AS category_name, SUM(r.amount) AS total FROM bill_record r LEFT JOIN category c ON r.category_id = c.id WHERE r.family_id = #{familyId} AND r.type = 0 AND r.record_date BETWEEN #{startDate} AND #{endDate} GROUP BY c.name ORDER BY total DESC;这里用LEFT JOIN而不是INNER JOIN,是为了防止分类被删除后历史流水在统计里凭空消失。实务中删除分类时往往会检查是否有流水引用,但历史流水还是应该保留原分类名称或者其他兜底方案,避免报表数据出现空缺。
4.4 预算管理与超支提醒
预算模块的逻辑可以归结为三件事:设预算、算花费、出提醒。
设预算接口通常接收月份、分类ID、金额三个参数,保存到预算表。因为每月的每个分类只能有一条预算记录,所以建表时给了唯一索引。前端提交重复数据时,后端要捕获DuplicateKeyException或者先查后插,我个人偏好先用lambdaQuery检查是否已存在,存在的话提示用户修改页面数据,而不是静默覆盖。
算花费的时机有两种思路。一种是在用户访问预算页面时实时统计,另一种是每次记账成功后触发一次预算更新。实时统计实现简单,但预算页面每次刷新都要跑一次全表聚合;实时更新逻辑复杂,还要考虑删除流水的情况。家庭理财系统的数据量根本不需要担心性能,所以我选择实时统计,理由很简单:代码易维护,而且删除、修改、补记历史账目后,预算数据永远是正确的。
超支提醒不是主动推消息,而是在查询预算列表接口里返回一个超支标志位。后端在组装返回数据时,把预算金额和实际支出对比,如果实际支出大于预算金额,就在结果里标记超支,前端展示时对超支项加红显示并给出文案,这个交互已经足够直观。
4.5 家庭成员之间的数据隔离
多人共用一套系统时,数据隔离是必须考虑的问题。核心方案是给所有业务表都加family_id字段,每次查询都带上这个条件。用户在登录时拿到自己的family_id,后续查询统一从当前登录用户上下文中获取,而不是依赖前端传参。这一点容易出漏洞,有些实践里把family_id放在请求参数里,用户改个参数就能看到别的家庭的数据。
拦截器里解析token后,可以把用户和家庭信息放到ThreadLocal上下文中,Service层从上下文取。这样业务代码里不需要每个方法都重复传递用户ID,代码干净很多,也降低了漏掉条件的概率。安全上宁可一开始就严格,也不要在上线后再补,因为账目数据一旦泄露,整个系统的信任感就没了。
5. 常见问题排查与避坑经验
5.1 启动与依赖相关经典问题
把源码从不熟悉的环境跑起来,最容易遇到的就是环境类错误,这里整理一个速查表。
| 现象 | 原因 | 解决方式 |
|---|---|---|
| 启动时报UnsupportedClassVersionError | 本地JDK版本低于项目编译版本 | 切换JDK版本或重新编译 |
| 启动时报Server returns invalid timezone | MySQL 8连接串缺少时区参数 | url加serverTimezone=Asia/Shanghai |
| 启动时报ClassNotFoundException | Maven依赖没有完全导入 | 反复reload或clean install |
| 启动失败提示端口被占用 | 8080端口被其他进程占用 | 换server.port或关掉占用进程 |
| 访问出现404,但项目启动正常 | 前端静态资源和模板路径不匹配 | 检查templates和static目录位置 |
有一个经常被忽略的知识点:Spring Boot默认只能扫描启动类所在包及其子包下的@Component、@Service、@Mapper等注解。很多人把Mapper接口放在主包之外,结果启动时不报错,真正访问接口时才报Invalid bound statement,就是扫描不到的典型症状。解决办法是把mapper接口放在主包之下,或者用@MapperScan指定路径。
5.2 数据库与中文乱码问题
中文乱码问题的根子几乎都在字符集上。如果MySQL建库时用的是默认latin1,那么即使应用层配置了UTF-8,中文字符到库中还是可能变成问号。
建库时建议显式指定:
CREATE DATABASE family_finance DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;同时要确认MySQL服务端配置文件里的character_set_server,修改后需要重启MySQL服务。
还有一种乱码情况只发生在页面端而数据库正常,通常是Response没有设置正确的Content-Type。Spring Boot默认的StringHttpMessageConverter会把文本转成ISO-8859-1编码,如果是用字符串直接写回响应就会乱码。不过一般都没这个问题,因为大家都会配置统一的JSON返回体。处理这类问题时,我习惯先把数据链路切成四段排查:页面到Controller、Controller到Service、Service到数据库、数据库存储。每一段单独验证,很快就能定位乱码出现在哪一层。
5.3 上线部署前的优化清单
源码在本地跑通后,如果打算部署到服务器,有几个地方必须调整,否则体验会很糟。
第一是配置文件按环境拆分。本地用application-dev.yml,生产环境用application-prod.yml,启动时用spring.profiles.active指定环境。不要在生产环境里使用本地数据库连接信息,这是底线。
第二是关闭MyBatis-Plus的SQL日志。开发阶段依赖StdOutImpl打印SQL很方便,放到生产环境会让控制台疯狂刷日志,而且SQL里全是用户数据,既不安全也不环保。办法是把log-impl配置删掉,或者在yml里用logback配置控制日志级别。
第三是静态资源缓存和压缩。Spring Boot可以启用HTTP响应压缩,在配置里加一段server.compression配置,对JSON和静态资源都有明显效果。家庭理财系统这类页面较多的小项目,静态资源本身不大,但加上压缩和缓存策略会让首页加载快不少。
第四是密码和密钥的硬编码问题。JWT签名密钥、数据库密码这类敏感信息,不要写在代码里提交到仓库,建议通过环境变量或者配置中心注入。我见过不止一次项目源码泄露导致数据库被撞的事件,源头就是配置文件里写了真实的root密码。
5.4 我建议你在源码上做二次改造的方向
如果只是把源码下载跑起来,收获有限。我建议拿到源码后做三件事:
第一是加入一个“账单导出”功能,把流水按月份导出成Excel文件。这个功能虽然看起来简单,但牵扯到POI或EasyExcel的依赖引入、日期格式处理、大数据量内存控制,是很完整的实战练习。
第二是给统计模块加一个“年度收支对比”视图,把今年和去年的同月数据放到一张折线图上。这个功能的关键点在于SQL里要按月份做两次聚合再合并,比单年度的统计更考验对数据结构的理解。
第三是把登录流程从简单的JWT改成带刷新机制的双token方案。这个改造会涉及到拦截器的调整、token过期策略的设计,比单纯调通项目更有深度。
这些改造都不难,但每一样都能把Spring Boot里的一个重要知识点串起来。测评一个项目是否真正掌握了,标准不是代码能不能跑,而是你敢不敢在源码上动刀。
最后分享一点我自己的体会。做这种带源码的项目,最忌讳的就是打开即跑、跑通即弃。家庭理财系统的业务逻辑不复杂,但它是少有的能把事务、聚合查询、权限控制、状态管理全部揉在一起的完整案例。如果时间允许,建议把它当成一个真实产品去打磨:先跑通,再逐行读一遍核心代码,最后根据我上面提的方向做一两个改造。这个过程走完,你对Spring Boot整体的认知会比只看文档来得扎实得多。