
简介一份基于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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 服务端渲染模板前后端分离可去掉 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency !-- MyBatis-Plus简化单表增删改查 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId /dependency !-- MySQL 驱动 -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency !-- Lombok 简化实体类 getter/setter -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency !-- 参数校验登录注册时用 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-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 小时。我的习惯是连接串里显式写上 serverTimezoneAsia/Shanghai同时配置好字符集和 SQL 日志输出方便排查问题。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/finance_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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三个参数要重点说明。第一characterEncodingutf8 控制中文不乱码缺了它分类名餐饮存进去可能变成问号。第二serverTimezoneAsia/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 和 expenseparent_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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT收支分类表;这里把 user、account、category 三张表的建表语句展开了transaction 和 budget 表及自增列也可以按同样风格补齐。注意 budget.month 字段我建议用 varchar(7) 存 2025-04 这样的字符串而不是 date 类型。原因是后端做月度统计、月度预算比对时直接拿字符串比较和分组都很自然避免月初月末的边界转换。3.3 金额为什么不用 double一个最容易翻车的选择金额字段用 DECIMAL(10,2) 几乎是所有财务系统的共识但很多从零开始写的同学第一版都会用 double。原因很朴素Java 里 double 就能表示 12.5MySQL 里 float 也能存小数看着没问题。真正的问题出在二进制表示上——0.1 在二进制里是一个无限循环小数float 和 double 只能存近似值。当你把 19.9 加 0.1得到的结果可能是 20.0也可能是 20.000000000000004。单笔可能看不出来但财务系统每一笔都要累加、汇总、生成报表误差会越攒越明显。我接手过一个同学的账目统计问题某个月的支出总和比实际记账少了几毛钱查了两个小时最后发现是 transaction.amount 用了 floatSUM 之后出现精度截断。那是典型的黑匣子问题——表面看是统计 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 小时的案例里特别常见。原因往往是数据库连接串没写 serverTimezoneMySQL 驱动拿的是 JVM 默认时区而某些云服务器的默认时区是 UTC。解决方案在第 2 章的 application.yml 里已经提过连接串显式指定 serverTimezoneAsia/Shanghai同时 JVM 启动参数加 -Duser.timezoneAsia/Shanghai。两个地方都配上才算是双保险。# 如果项目部署在 Linux 上可以在启动命令单独指定时区 # java -Duser.timezoneAsia/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-31MySQL 会把它当成 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 放进去避免去写 XMLpublic 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) ListMonthStatsVO 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饼图数据需要本月支出按分类汇总。实现上比月度统计多一个 joinSELECT 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 里把金额声明成 BigDecimalJSON 序列化时让 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谁登录都能看到所有人的流水 QueryWrapperTransaction wrapper new QueryWrapper(); wrapper.eq(category_id, categoryId); wrapper.orderByDesc(trans_time); PageTransaction page transactionMapper.selectPage(new Page(pageNum, pageSize), wrapper);这条查询在只有一个测试用户时表现完美一旦有第二个用户注册数据就串了。这是一个越权漏洞属于答辩时最容易被老师点名的问题。正确写法是先从当前登录上下文拿到用户 ID拼接进 QueryWrapper// 从登录上下文获取当前用户 ID Long currentUserId LoginContext.getCurrentUserId(); QueryWrapperTransaction wrapper new QueryWrapper(); wrapper.eq(user_id, currentUserId) // 用户隔离条件必须出现在每一条查询里 .eq(categoryId ! null, category_id, categoryId) .orderByDesc(trans_time); PageTransaction 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)实体类字段改成 BigDecimalMyBatis-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。数据库连接串里没写 serverTimezoneMySQL 驱动用 JVM 时区解析 datetime导致时间偏移。这个问题在 Windows 本地很少出现在 Linux 服务器上特别常见。解决在 application.yml 的 datasource url 加serverTimezoneAsia/Shanghai同时 JVM 启动参数加-Duser.timezoneAsia/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 ThreadLocalLong 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 条件。财务系统里没有哪一行数据是可以串的。希望这篇笔记能帮你在答辩前把最容易翻车的那几关稳稳跨过去也顺带祝你的系统一次演示成功。本文还有配套的精品资源点击获取