news 2026/10/22 19:48:44

基于Spring Boot的个人财务管理系统:金额精度、时区与数据隔离实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Spring Boot的个人财务管理系统:金额精度、时区与数据隔离实战解析

简介:一份基于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.java

controller 层只做参数接收和结果返回,不要在 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_atpassword 存 BCrypt 加密串,绝不能明文
account资金账户id, user_id, account_name, balance余额是冗余字段,通过流水累计维护
category收支分类id, user_id, name, type, parent_idtype 区分 income 和 expense;parent_id 支持两级分类
transaction每一笔收支id, user_id, account_id, category_id, amount, trans_time, type, remarkamount 是 DECIMAL(10,2)
budget月度预算id, user_id, category_id, month, limit_amountmonth 用字符串 '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 条件。财务系统里没有哪一行数据是可以串的。希望这篇笔记能帮你在答辩前把最容易翻车的那几关稳稳跨过去,也顺带祝你的系统一次演示成功。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 19:48:19

SpringBoot集成TOTP:实现双因素认证与防重放校验的完整指南

1. 为什么给SpringBoot项目加一道TOTP验证做过后台管理系统或者涉及资金、敏感操作的服务端开发&#xff0c;多半会有这样的经历&#xff1a;用户拿了一套账号密码登录进去&#xff0c;结果因为密码太弱或者在其他平台泄露&#xff0c;被人批量撞库&#xff0c;然后一串投诉电话…

作者头像 李华
网站建设 2026/10/22 19:44:07

QUIC落地实践:资讯App弱网优化与传输协议升级全记录

先说一个我印象极深的场景&#xff1a;地铁电梯里信号一格&#xff0c;手机上资讯App刷不动&#xff0c;用户疯狂下拉重试&#xff0c;客户端在上行链路里堆了一堆排队请求。等电梯门一开&#xff0c;信号恢复&#xff0c;这些卡片一次性全蹦出来&#xff0c;然后埋点上报瞬间打…

作者头像 李华
网站建设 2026/10/22 19:43:14

JavaEE+MySQL酒店管理系统毕业设计:模块设计、SQL与避坑指南

简介&#xff1a;基于JavaEEMySQL的酒店管理系统设计与实现资源&#xff0c;包含完整项目源码、数据库SQL脚本、毕业论文、答辩PPT及操作演示视频&#xff0c;适合毕业设计、课程设计、大作业或工程实训&#xff0c;也适合Java初学者与进阶学习者通过完整项目案例理解系统开发全…

作者头像 李华
网站建设 2026/10/22 19:41:26

IMS放音故障排查:183/UPDATE/200 OK信令链路实战拆解

先交代一下背景&#xff1a;我在某运营商的IMS专项里遇到过一种很尴尬的故障——用户投诉“打电话听不到彩铃&#xff0c;只能听到嘟嘟声”&#xff0c;底下的同事抓了一周包也没定位&#xff0c;最后发现不是核心网的问题&#xff0c;而是主叫侧终端在183到来之前先收到了180&…

作者头像 李华
网站建设 2026/10/22 19:39:13

2026年第40周技术趋势周报:开发工具链与数据管道项目深度解析

1. 这份周报到底在解决什么问题每周五晚上&#xff0c;我习惯性打开趋势榜单&#xff0c;把这一周冒头的项目从头翻到尾。这个动作坚持了快四年&#xff0c;中间踩过不少坑&#xff1a;早期是看到 star 涨得猛就 clone&#xff0c;结果一半是营销号刷出来的空壳仓库&#xff1b…

作者头像 李华
网站建设 2026/10/22 19:38:52

SpringBoot接口加密实战:RSA+AES混合加密自动解密方案

1. 项目背景与方案选型1.1 为什么组合 RSA AES&#xff1f;先说说我踩过的单加密方案的坑接手这个项目之前&#xff0c;我第一个想到的问题是&#xff1a;接口数据要加密&#xff0c;直接用 RSA 不就行了吗&#xff1f;真正动手之后才发现&#xff0c;RSA 虽然安全&#xff0c…

作者头像 李华