简介:一份基于Web的个人财务管理系统毕业设计项目,包含完整源码与项目说明文档,面向计算机相关专业学生、教师及开发者,适用于毕设、课程设计、大作业或初期项目演示。系统采用响应式设计,同时适配手机与电脑端:前台面向普通用户,提供用户注册登录、收支记账与明细检索、收支类别管理、年度月度统计、财务分析对比、预算报表、心愿单、备忘录、财务新闻及计算器等模块;后台面向管理员,支持用户信息管理、收支类别维护与财务新闻发布。资源包共1050个文件,约22.93MB,以Java/JSP后端代码、CSS/JS前端资源、PNG/GIF图标素材、JAR依赖库及XML配置为主,目录结构清晰,便于按功能模块检索。项目已通过功能验证,运行稳定可靠,已有333人学习下载,既可直接运行体验,也可二次拓展,是个人财务管理系统开发的实用参考。
1. 基于Web的个人财务管理系统:一个毕设压缩包里的硬骨头
收到一个标题为「毕业设计基于web的个人财务管理系统设计与实现源码+项目说明.zip」的压缩包时,许多人的第一反应是:这不就是个增删改查吗?诚然,把用户、收支记录、分类、预算四件套做下来,界面能跑、报表能出,确实算得上一个完整Web项目。但这个围绕"钱"打转的系统,比普通的管理系统多了三个硬门槛:金额精度不能丢、月度统计的日期边界不能错、用户之间的账目数据绝不能串。这三个点恰是答辩时被追问最狠的地方,也是我接过好几个类似毕设辅导后最想先说的开头。这篇就按"这个系统怎么做、参数怎么设、坑在哪里"来拆,适合拿到源码但还没搞懂内部逻辑的同学,也适合打算顺着这个方向从零写一遍的开发者。
2. 技术选型和工程骨架:Spring Boot + MySQL的常见搭法
2.1 两条技术路线,答辩友好度完全不同
个人财务管理系统常见的实现路线就两条:服务端渲染和前后端分离。服务端渲染用 Spring Boot + Thymeleaf,页面由后端模板直接生成;前后端分离则是 Vue + Spring Boot REST API。我的建议是:如果这是毕业设计的核心交付物,选服务端渲染更稳。
原因有三。第一,单个应用一个端口就能跑起来,演示时不用同时开着前端 DevServer 和后端两个进程,少一次环境变量配置失败的风险。第二,财务系统的操作路径很短,无非是登录、记账、看报表、管理分类,服务端渲染足够表达清楚,不需要复杂的前端状态管理。第三,答辩时老师主要追问的是业务逻辑和数据库设计,服务端渲染把注意力收拢在后端,不太会被追问跨域配置、Token 刷新、前端路由鉴权这些和"财务管理"无关的边角料。
前后端分离也不是不行,前提是你简历上想写"全栈开发"或者已经有现成的 Vue 工程基础。我不建议为了"看起来高级"而临时引入一个前端框架,演示时跨域报错、路由回退 404 这类问题足够让人手忙脚乱。两条路线的取舍用一张表就能看清:
| 对比维度 | 服务端渲染(Spring Boot + Thymeleaf) | 前后端分离(Vue + Spring Boot API) |
|---|---|---|
| 代码总量 | 少,模板和 Controller 集中 | 多一整个前端工程 |
| 演示准备工作 | 起一个后端服务即可 | 需同时起前端和后端,还要处理跨域 |
| 答辩提问重心 | 后端事务、SQL、表设计 | 可能延伸到部署、鉴权、打包 |
| 上手难度 | 低,Java 基础够就能改 | 需要额外熟悉 Node 和 Vue |
| 适合人群 | 求稳过毕设、快速跑通 | 想借项目展示全栈能力 |
我一般会先问拿到这个 zip 的同学:你是要快速把系统跑起来、把论文和答辩准备好,还是想借项目练一下前后端分离?前者我就带着把服务端渲染路线一条路走通,后者才建议考虑 Vue。别小看这个选择,工程结构一旦定下来,后面改造成本很高。
2.2 最小工程骨架:依赖和目录一次配齐
无论哪条路线,后端骨架都建议用 Spring Initializr 生成,再做减法。个人财务管理系统不涉及高并发、分布式和消息队列,依赖越少越好。一个能跑通的骨架只需要这些依赖:spring-boot-starter-web、mybatis-plus-spring-boot3-starter(或用 mybatis-spring-boot-starter)、mysql-connector-j、lombok、spring-boot-starter-validation。模板方面,选择服务端渲染就加上 spring-boot-starter-thymeleaf;选择前后端分离就不用加。
pom.xml 里核心依赖长这样:
<dependencies> <!-- Web 启动器,内嵌 Tomcat --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 服务端渲染模板,前后端分离可去掉 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- MyBatis-Plus,简化单表增删改查 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- Lombok 简化实体类 getter/setter --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- 参数校验,登录注册时用 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>几个依赖的作用要心里有数。mybatis-plus 是这里最省事的一个,单表的 insert、selectById、分页都是内置方法,不用为每一个实体写一遍 Mapper XML。对于财务系统这种以单表操作为主的场景,MyBatis-Plus 能把代码量砍掉四成。Lombok 不是必须的,但如果源码包里已经用了,就保留,否则一堆 getter/setter 会拖长代码。validation 是给登录注册和记账参数校验用的,答辩时老师问"参数校验怎么做的",直接指这个依赖。
工程目录我一般按 controller、service、mapper、entity、common 五层拆:
src/main/java/com/example/finance/ ├── controller/ # 接收请求,返回视图或 JSON │ ├── AuthController.java │ ├── TransactionController.java │ └── StatsController.java ├── service/ # 业务逻辑,事务边界在这里 │ ├── TransactionService.java │ └── StatsService.java ├── mapper/ # MyBatis-Plus 的 Mapper 接口 │ ├── UserMapper.java │ ├── TransactionMapper.java │ └── CategoryMapper.java ├── entity/ # 数据库表对应的实体类 │ ├── User.java │ ├── Transaction.java │ └── Category.java └── common/ # 登录拦截器、统一返回结果、全局异常 ├── LoginInterceptor.java ├── Result.java └── GlobalExceptionHandler.javacontroller 层只做参数接收和结果返回,不要在 controller 里写 SQL 或者直接操作 Mapper。这个分层习惯很多同学不注意,把所有代码堆在 controller 里,数据库操作也写在那,统计查询挤在一起,代码确实能跑,但论文里的"系统设计"章节很难写,答辩被问到"如何保证事务一致性"时也没法回答。
2.3 一份能直接启动的 application.yml
配置文件的坑主要集中在 MySQL 连接参数上。个人财务系统最常见的错误是漏掉时区参数,导致本地正常、部署到服务器后所有时间错位 8 小时。我的习惯是连接串里显式写上 serverTimezone=Asia/Shanghai,同时配置好字符集和 SQL 日志输出,方便排查问题。
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/finance_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你的数据库密码 driver-class-name: com.mysql.cj.jdbc.Driver thymeleaf: cache: false # 开发阶段关闭模板缓存,改完页面不用重启也能看到效果 jackson: time-zone: Asia/Shanghai mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0三个参数要重点说明。第一,characterEncoding=utf8 控制中文不乱码,缺了它,分类名"餐饮"存进去可能变成问号。第二,serverTimezone=Asia/Shanghai 配合 jackson.time-zone 一起出现,保证从 MySQL 读出来的时间、Java 里处理的时间、返回给前端的时间都在同一个时区。第三,StdOutImpl 会把每条 SQL 打印到控制台,这是排查"数据对不上"问题时最直接的工具,翻车时第一件事就是看控制台实际执行了哪条 SQL。上线前可以把 log-impl 注释掉,减少不必要的日志输出。
2.4 项目说明文档和源码对不上,是第一个坑
拿到这种带"项目说明"的 zip,我建议先别急着跑。先把压缩包里的说明文档打开,对照目录结构检查一遍:文档里说有三个角色,代码里有对应的权限校验吗?文档里说支持预算管理,数据库里有 budget 表吗?文档里画的 ER 图有几张表,脚本建出来是不是同样数量?我遇到过不止一次,说明文档写得非常完整,但代码实现缺了一半,最后被老师要求现场演示预算功能,项目里压根没写。
核对的方法是打开数据库脚本,列出所有表名,再对照文档里的功能列表逐条打勾。缺项如果不大,就补上;缺项太多,建议以代码实际功能为准,先跑通再反推修改说明文档。毕业设计答辩看的是代码和文档一致性,一个对不上的系统,比一个功能少但自洽的系统更容易被质疑。这一步属于"后悔药"——等打印好论文再发现不一致,改动成本就大了。
3. 数据库设计先行:五张核心表与金额精度的三个选择
3.1 五张表:从用户到流水的关系
个人财务管理系统的数据库设计,核心是回答一个问题:一笔钱从哪个账户进来,归类到什么方向,发生在什么时间。围绕这个问题,最少需要五张表:用户表 user、账户表 account、交易流水表 transaction、分类表 category、预算表 budget。有些项目还会加一张标签表,但毕设场景下五张表足够闭环。
表之间的关系不复杂:user 是一端,account、category、budget 都通过 user_id 归属到某个用户;transaction 关联 account 和 category,同时带 user_id。这里有一个常见的坏味道:transaction 表不直接放 user_id,而是通过 account 间接归属用户。表面看是规范的三范式,实际上查询时每条流水都要 join 一次 account 才能知道属于哪个用户,统计 SQL 也会绕一层。我的建议是 transaction 表冗余一个 user_id,查询加条件时直接 where user_id = ?,省掉 join,更重要的是不容易漏掉用户隔离条件。
各表的职责和关键字段,一张表说清楚:
| 表名 | 职责 | 关键字段 | 特殊说明 |
|---|---|---|---|
| user | 登录账号 | id, username, password, created_at | password 存 BCrypt 加密串,绝不能明文 |
| account | 资金账户 | id, user_id, account_name, balance | 余额是冗余字段,通过流水累计维护 |
| category | 收支分类 | id, user_id, name, type, parent_id | type 区分 income 和 expense;parent_id 支持两级分类 |
| transaction | 每一笔收支 | id, user_id, account_id, category_id, amount, trans_time, type, remark | amount 是 DECIMAL(10,2) |
| budget | 月度预算 | id, user_id, category_id, month, limit_amount | month 用字符串 'YYYY-MM',比 date 类型更直观 |
比较容易被忽略的是 category 表里的 parent_id。财务管理系统的分类通常分两级,比如"餐饮"下面有"早午餐""外卖",报表统计时需要支持"只看一级分类"和"看二级分类明细"。如果只设计成一张平铺的分类表,后面扩展就很别扭。我的做法是 parent_id 为 0 表示一级分类,非 0 表示挂到某个一级分类下。这个字段虽然增加了一点查询复杂度,但设计文档里能多写一段"支持分级分类"的功能亮点,性价比很高。
3.2 建表 SQL:字段级别的取舍
表结构设计完,落成 SQL 时有一个原则:所有和钱有关的、和时间有关的字段,都选择最保守的类型。钱用 DECIMAL,不用 float,也不用 double;时间用 datetime,不用 timestamp;状态用 tinyint,不用 varchar 存字符串。这三个"不用"背后都是血泪教训,尤其是金额,我后面单独讲。
下面是一份可以直接执行的建表脚本,去掉了一些冗余索引,保留最核心的约束:
-- 用户表 CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录名,唯一', `password` varchar(100) NOT NULL COMMENT 'BCrypt 加密后的密码', `nickname` varchar(50) DEFAULT NULL COMMENT '显示昵称,可空', `created_at` datetime NOT NULL 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 COMMENT '归属用户', `account_name` varchar(50) NOT NULL COMMENT '账户名,如"招商银行卡"', `balance` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '当前余额', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资金账户表'; -- 分类表 CREATE TABLE `category` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `name` varchar(50) NOT NULL COMMENT '分类名', `type` tinyint NOT NULL COMMENT '1=收入 2=支出', `parent_id` bigint NOT NULL DEFAULT '0' COMMENT '0表示一级分类', PRIMARY KEY (`id`), KEY `idx_user_type` (`user_id`, `type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收支分类表';这里把 user、account、category 三张表的建表语句展开了,transaction 和 budget 表及自增列也可以按同样风格补齐。注意 budget.month 字段,我建议用 varchar(7) 存 '2025-04' 这样的字符串,而不是 date 类型。原因是后端做月度统计、月度预算比对时,直接拿字符串比较和分组都很自然,避免月初月末的边界转换。
3.3 金额为什么不用 double:一个最容易翻车的选择
金额字段用 DECIMAL(10,2) 几乎是所有财务系统的共识,但很多从零开始写的同学,第一版都会用 double。原因很朴素:Java 里 double 就能表示 12.5,MySQL 里 float 也能存小数,看着没问题。真正的问题出在二进制表示上——0.1 在二进制里是一个无限循环小数,float 和 double 只能存近似值。当你把 19.9 加 0.1,得到的结果可能是 20.0,也可能是 20.000000000000004。单笔可能看不出来,但财务系统每一笔都要累加、汇总、生成报表,误差会越攒越明显。
我接手过一个同学的账目统计问题,某个月的支出总和比实际记账少了几毛钱,查了两个小时,最后发现是 transaction.amount 用了 float,SUM 之后出现精度截断。那是典型的"黑匣子"问题——表面看是统计 SQL 有 bug,实际上是数据类型错了。
正确做法有两条路。第一条是 Java 用 BigDecimal 对应数据库 DECIMAL(10,2),这也是最常见的方案。如果你用 MyBatis-Plus,把实体字段声明为 BigDecimal,自动映射就能处理好。第二条更激进,干脆数据库用 int 存"分",比如 12.34 元存成 1234,前端展示时再转成元。这种方案彻底绕开了浮点问题,但接口返回和前端展示都需要多做一步换算,适合有意愿改造代码的人。我的建议是第一条路,改动小、符合直觉,后续做统计报表也顺手。
3.4 时间字段与时区:datetime 和 LocalDateTime 的搭配
交易时间字段 trans_time 用 datetime 类型,Java 实体里用 LocalDateTime 接收,这是 Spring Boot 项目里最不容易出错的组合。不建议用 timestamp 类型,它内部存储的是 UTC 时间戳,展示时依赖数据库连接的时区设置,一旦服务器时区变了,读出来的时间就会整体偏移。datetime 没有这个问题,存的是什么就显示什么。
时区问题在"本地一切正常,部署到服务器后整体晚 8 小时"的案例里特别常见。原因往往是数据库连接串没写 serverTimezone,MySQL 驱动拿的是 JVM 默认时区,而某些云服务器的默认时区是 UTC。解决方案在第 2 章的 application.yml 里已经提过:连接串显式指定 serverTimezone=Asia/Shanghai,同时 JVM 启动参数加 -Duser.timezone=Asia/Shanghai。两个地方都配上,才算是双保险。
# 如果项目部署在 Linux 上,可以在启动命令单独指定时区 # java -Duser.timezone=Asia/Shanghai -jar finance-system.jar这是个很小的配置,但答辩时被问到"你的系统如何保证不同环境下的时间一致性",能把时区参数答清楚,是一个很好的加分项。很多同学没意识到时间是一个需要显式管理的东西,答不上来就会暴露出对生产环境不熟悉。
4. 记账和月度统计的落地写法:Service层、统计SQL与接口返回
4.1 记账不是单条 insert:事务里先写流水再动余额
记账功能看起来简单:前端提交金额、分类、时间,后端往 transaction 表插一条记录。但稍想一层就知道,这笔操作还应该更新对应 account 表的 balance 字段。这两条 SQL 要么都成功,要么都失败,否则就会出现流水记了钱、余额没变的情况。这里必须引入事务。
我见过很多同学把 @Transactional 加在 Controller 的方法上,这是能生效的,但是边界不好控制。Controller 的方法可能会被复用,也可能同时包含登录校验和参数校验,这些动作不应该被包进同一个事务。更规范的位置是放在 Service 层的实现类方法上,把"写流水 + 更新余额"这两步业务操作包在同一个方法里,事务边界和业务流程对齐。
@Service public class TransactionServiceImpl implements TransactionService { @Autowired private TransactionMapper transactionMapper; @Autowired private AccountMapper accountMapper; /** * 新增一笔收支记录 * 该方法内的所有数据库操作共享同一个事务 */ @Override @Transactional(rollbackFor = Exception.class) public void addTransaction(Transaction tx) { // 1. 插入流水记录 tx.setCreatedAt(LocalDateTime.now()); transactionMapper.insert(tx); // 2. 更新账户余额 Account account = accountMapper.selectById(tx.getAccountId()); if (account == null) { throw new RuntimeException("账户不存在"); } BigDecimal delta = "income".equals(tx.getType()) ? tx.getAmount() : tx.getAmount().negate(); account.setBalance(account.getBalance().add(delta)); accountMapper.updateById(account); } }这段代码有三个细节要说明。第一,rollbackFor = Exception.class 很关键,默认情况下 Spring 只在遇到 RuntimeException 时回滚,遇到受检异常不会回滚。加了 rollbackFor 之后,业务方法里抛出 Exception 及其子类都会触发回滚,避免因为异常类型没配对出现"只写了一半"的问题。第二,账户余额不是读出来再整体覆盖,而是在原值上 add,这样即使同一账户有多笔并发操作,也不会互相覆盖。毕设项目并发量不大,但这个习惯是好的。第三,account 查询时要确认它属于当前登录用户,我在代码里没写这一步是为了突出业务,实际项目中应该在 Service 层拿到当前登录用户 ID 并拼进查询条件。
4.2 月度收支持统计:一条 GROUP BY SQL 的正确边界
财务系统的核心价值都在统计页面上。最常见的统计是"最近 6 个月每个月的收入、支出总额",以及"本月支出按分类的占比"。
很多同学写月度统计时,习惯这样查:
// 反例:用 between 时容易把下个月月初的数据纳进来 SELECT DATE_FORMAT(trans_time, '%Y-%m') AS month, SUM(amount) AS total FROM transaction WHERE type = 'expense' AND trans_time BETWEEN '2025-01-01' AND '2025-01-31' GROUP BY month;这条 SQL 肉眼看着没问题,但它漏掉了一个边界:2025-01-31 23:59:59 之后到 2025-02-01 00:00:00 之间的数据呢?如果某个交易正好发生在 2025-02-01 00:00:00,由于 BETWEEN 是闭区间,它会落在查询范围之外——看起来没错,但如果你传的结束日期是 '2025-01-31',MySQL 会把它当成 2025-01-31 00:00:00 处理,导致 1 月 31 日当天 00:00:00 之后的所有流水都查不到。这个坑在"月初统计总少一天"的案例里反复出现。
正确的写法是使用半开区间,同时让数据库去格式化月份,而不是后端 Java 处理:
SELECT DATE_FORMAT(trans_time, '%Y-%m') AS month, SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) AS income, SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) AS expense FROM transaction WHERE user_id = #{userId} AND trans_time >= '2025-01-01' AND trans_time < '2025-03-01' -- 注意是下个月的月初 GROUP BY DATE_FORMAT(trans_time, '%Y-%m') ORDER BY month;这里用前半开区间[2025-01-01, 2025-03-01)把 1 月和 2 月都包进去了,之后按格式化后的月份分组,自动得到月度序列。如果你用 MyBatis-Plus,可以直接在 Mapper 接口上写 @Select 注解,把这条 SQL 放进去,避免去写 XML:
public interface StatsMapper { @Select("SELECT DATE_FORMAT(trans_time, '%Y-%m') AS month, " + "SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) AS income, " + "SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) AS expense " + "FROM transaction " + "WHERE user_id = #{userId} " + "AND trans_time >= #{start} " + "AND trans_time < #{end} " + "GROUP BY DATE_FORMAT(trans_time, '%Y-%m') " + "ORDER BY month") List<MonthStatsVO> selectMonthlyStats(@Param("userId") Long userId, @Param("start") String start, @Param("end") String end); }这段 SQL 的要点在 GROUP BY 的表达式和 SELECT 里的表达式保持一致。MySQL 允许 GROUP BY 使用别名,但某些数据库不允许,所以我在 GROUP BY 里写了完整的 DATE_FORMAT(trans_time, '%Y-%m'),而不是 month。这样在国产数据库或未来迁移到 PostgreSQL 时,SQL 依然能用。SUM 里的 CASE WHEN 是条件聚合,一条语句同时算出收入和支出,不需要拆成两条 SQL 再在 Java 里合并。
4.3 分类汇总返回给前端:BigDecimal不要直接toString
饼图数据需要"本月支出按分类汇总"。实现上比月度统计多一个 join:
SELECT c.name AS category_name, SUM(t.amount) AS total FROM transaction t LEFT JOIN category c ON t.category_id = c.id WHERE t.user_id = #{userId} AND t.type = 'expense' AND t.trans_time >= #{monthStart} AND t.trans_time < #{monthEnd} GROUP BY t.category_id, c.name ORDER BY total DESC;这个查询结果返回给前端时,有一个容易被忽略的点:total 字段是 DECIMAL 类型,用 MyBatis-Plus 查出来会映射为 BigDecimal。如果直接在 VO 类里把它声明成 Double,或者用 toString 传给前端精度就会受损。我习惯在 VO 里把金额声明成 BigDecimal,JSON 序列化时让 Jackson 使用 toString 输出,这样前端拿到的就是 "123.45" 这样的字符串,不会被 JavaScript 的浮点处理二次污染。
// 统计结果的 VO 对象 public class CategoryStatsVO { private String categoryName; private BigDecimal total; // 保持 BigDecimal,不要转 Double // getter / setter 省略 }这里还有一个更隐蔽的坑:如果某笔交易的 category_id 在分类表里被删了,LEFT JOIN 会让 category_name 变成 null,前端拿到 null 分类名可能会渲染出错。解决方式是在 SQL 里用 IFNULL(c.name, '未分类') 处理,或者在删除分类时限制必须先处理关联的流水。财务系统的数据完整性比普通系统重要得多,建议采用"有流水的分类不允许删除"的策略,后端删除分类时先 count 一下关联的流水数。
4.4 分页查询和用户隔离:每一处都要带上user_id
交易记录列表需要分页。MyBatis-Plus 自带分页插件,使用很简单,但真正需要小心的是查询条件里必须强制带上 user_id。很多同学写列表查询时这样写:
// 反例:没有拼 user_id,谁登录都能看到所有人的流水 QueryWrapper<Transaction> wrapper = new QueryWrapper<>(); wrapper.eq("category_id", categoryId); wrapper.orderByDesc("trans_time"); Page<Transaction> page = transactionMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);这条查询在只有一个测试用户时表现完美,一旦有第二个用户注册,数据就串了。这是一个"越权漏洞",属于答辩时最容易被老师点名的问题。正确写法是先从当前登录上下文拿到用户 ID,拼接进 QueryWrapper:
// 从登录上下文获取当前用户 ID Long currentUserId = LoginContext.getCurrentUserId(); QueryWrapper<Transaction> wrapper = new QueryWrapper<>(); wrapper.eq("user_id", currentUserId) // 用户隔离条件必须出现在每一条查询里 .eq(categoryId != null, "category_id", categoryId) .orderByDesc("trans_time"); Page<Transaction> page = transactionMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);这里的关键是把用户隔离条件当成"默认必须带上的条件",而不是可选项。后面第 6 章我会讲怎么用 ThreadLocal 做一个统一的获取当前用户上下文的工具,从机制上避免忘记拼 user_id。
5. 踩坑排查:金额丢失、日期边界、越权查询的五个真实翻车点
5.1 金额精度翻车:12.3变成12.300000000000001
现象:某同学的项目里,连续记几笔账后,账户余额变成了 123.45000000000002,统计报表里的数字也带了一长串小数。他怀疑是 BigDecimal 加法写错了,查了半天代码没问题。
原因:数据库表 transaction.amount 用的是 float 类型,实体类里对应的是 Float/Double。MySQL 的 float 是单精度近似存储,Java 的 Double 也是,两边都是近似值,经过几次累加,误差就出现在小数点后第 10 位左右。这不是业务代码的问题,是数据类型从一开始就选错了。
解决:把所有金额字段统一改成 DECIMAL(10,2),实体类字段改成 BigDecimal,MyBatis-Plus 会自动做映射;Java 侧的计算也全部用 BigDecimal 的 add/subtract,杜绝任何 double 之间的加减。改完后重启项目,用一整月的测试数据跑一遍统计,确认数字干净了再提交。
5.2 月初统计总少一天:BETWEEN 的日期边界
现象:月度统计报表里,每个月的 1 号当天的交易总是不在统计范围内,比如查看 3 月报表,3 月 1 日记录的餐饮支出没出现。
原因:统计 SQL 写的 BETWEEN '2025-03-01' AND '2025-03-31'。MySQL 会把 '2025-03-01' 解析成 2025-03-01 00:00:00,会把 '2025-03-31' 解析成 2025-03-31 00:00:00,所以 3 月 31 日 00:00:00 之后的记录全部被排除,而 3 月 1 日 00:00:00 整点之前的数据如果存在也会被漏掉。总结一句话:别用 BETWEEN 和月末日期做区间查询。
解决:统一改成半开区间。查询某个月的数据,用>= 月初AND< 下个月月初。比如查 2025-03,就写trans_time >= '2025-03-01' AND trans_time < '2025-04-01'。下个月月初直接在 Java 里用 LocalDate 的 plusMonths(1) 计算,避免手动传日期字符串时把 31 号、28 号数错。
5.3 登录A账户看到B的账目数据
现象:用 A 账号登录后打开交易列表,发现里面混着 B 账号的记录,用户 B 注册的测试数据出现在 A 的页面上。
原因:列表查询的 SQL 里没有 user_id 条件。这种情况在开发阶段最容易出现,因为本地数据库只有一条测试数据时,查不查 user_id 结果都一样,一旦多注册一个账号就暴露。
解决:三步走。第一,在交易记录查询、统计查询、分类查询里全部加上 user_id = 当前用户 ID;第二,新增记录时也要强制用当前登录用户 ID,不接受前端传的 user_id 参数;第三,做一个全局的登录上下文工具,从 Session 或 Token 里解析出当前用户,避免每个 Controller 重复从参数里取用户 ID。改完后测试时至少注册两个账号,互相插入几笔数据,验证隔离。
5.4 部署到服务器后时间全部差8小时
现象:本地跑一切正常,按说明文档部署到云服务器后,账单列表里的时间全部比实际时间晚了 8 小时,报表的月份划分也乱了。
原因:本地开发机的 JVM 默认时区是 Asia/Shanghai,而服务器默认时区可能是 UTC。数据库连接串里没写 serverTimezone,MySQL 驱动用 JVM 时区解析 datetime,导致时间偏移。这个问题在 Windows 本地很少出现,在 Linux 服务器上特别常见。
解决:在 application.yml 的 datasource url 加&serverTimezone=Asia/Shanghai,同时 JVM 启动参数加-Duser.timezone=Asia/Shanghai。如果用了 Jackson 序列化时间,spring.jackson.time-zone 也设成 Asia/Shanghai。改完部署后查一条交易记录的创建时间,和数据库里的原始值对比,确认一致。
5.5 @Transactional不起作用:写了注解但事务没生效
现象:新增交易时故意在更新余额之后抛出一个 RuntimeException,结果流水插进去了,余额也更新了,两条记录都保留,事务像没写一样。
原因:最常见的有三种。第一,@Transactional 加在了 private 方法上,Spring AOP 代理对 private 方法不生效。第二,加在了 Controller 方法上,但 Controller 的方法调用了另一个类的 Service 方法,事务边界不对。第三,最隐蔽的一种,同一个类里的方法自调用——Service 的 addA 方法调用同类里的 addB 方法,addB 上的 @Transactional 不生效,因为代理对象没有被绕过去。
解决:把 @Transactional(rollbackFor = Exception.class) 放在 Service 实现类的 public 方法上,所有需要一起成功或一起失败的数据库操作放进该方法体内。如果类内部有自调用,就把被调用的方法改成从外部注入自己,或者拆到另一个 Service 里。验证方式很简单:在方法里 insert 一条数据后抛出异常,看数据库里是否还有这条记录。
6. 让源码经得起答辩追问:事务收尾与一个数据权限习惯
6.1 用 ThreadLocal 做一个当前用户上下文
个人财务系统里每个功能都离不开当前登录用户 ID。最直接的做法是每个 Controller 方法都从 Session 里取 ID 再传给 Service,代码量不大但容易漏,尤其是新同学接手往下加功能时,复制一段旧代码就把条件带丢了。我习惯写一个 LoginContext 工具类,用 ThreadLocal 存当前登录用户,在拦截器里赋值,在 Service 里取用:
public class LoginContext { private static final ThreadLocal<Long> USER_HOLDER = new ThreadLocal<>(); public static void setCurrentUserId(Long userId) { USER_HOLDER.set(userId); } public static Long getCurrentUserId() { Long userId = USER_HOLDER.get(); if (userId == null) { throw new RuntimeException("当前用户未登录"); } return userId; } public static void clear() { USER_HOLDER.remove(); } }使用方式就是在登录拦截器里解析 Session 后 set 用户 ID,在 Controller 处理完请求后 clear,避免线程池复用导致用户数据串。Service 里需要用户 ID 时直接 LoginContext.getCurrentUserId()。这样做的好处是把"用户隔离"变成了一种机制,而不是依赖每个开发者的编码自觉。
6.2 预算超支的软提示,不要抛异常
预算模块通常会在"保存支出"时顺带检查当月同类支出的累计金额是否超过预算。这里我建议不要用异常打断记账行为——用户记一笔超支的外卖,账还是应该记下来,但接口返回里加一个 warning 字段提示"本月餐饮已超预算"。
实现上就是在事务提交后,单独查一次本月该分类的支出汇总,和 budget 表的 limit_amount 比较。这个查询不要和主事务混在一起,否则一次超支提示失败会影响记账本身。返回结构做成 Map 或者带 warn 字段的 VO,前端弹个提示即可。
6.3 答辩前的一个自测习惯
代码写完后,我会把应用跑起来,注册两个账号,分别录入一个月的数据,然后做三步自测:第一步核对每条流水和账户余额是否一致;第二步打开月度报表,核对本月 1 号当天的数据是否被统计进去;第三步用第二个账号登录,确认看不到第一个账号的任何记录。这三步全过,系统质量基本就稳了。
这个习惯源自一次真实的答辩事故:有个同学演示时用 A 账号录入的数据,切到 B 账号后列表还在,当场被老师质疑数据隔离问题。后来我接手检查,问题就是查询漏了 user_id。那之后我就定了规矩,凡是涉及用户数据的系统,测试必须用两个账号交叉验证,而且把控制台的 SQL 日志打开,肉眼确认每条查询都带了 user_id 条件。财务系统里没有哪一行数据是可以串的。希望这篇笔记能帮你在答辩前把最容易翻车的那几关稳稳跨过去,也顺带祝你的系统一次演示成功。
本文还有配套的精品资源,点击获取