Java家庭理财系统实战:Spring Boot+MyBatis从零搭建

发布时间:2026/10/30 0:45:23
Java家庭理财系统实战:Spring Boot+MyBatis从零搭建 简介本资源是一套基于Java开发的家庭理财系统完整源码面向Java初学者与Web全栈学习者解决家庭收支管理、预算编制与财务分析等实际场景中的软件实现问题。压缩包共388个文件大小6.78MB涵盖71个Java后端核心类、118个JavaScript前端交互逻辑、53个CSS样式与19个HTML页面辅以XML配置、VM模板及多格式字体/图片资源体现典型的前后端分离架构设计。已有252人下载学习适合通过真实项目掌握Maven构建、Eclipse工程配置、数据库业务建模如账户管理、收支记录、报表生成及用户界面响应式开发全流程。源码中zui.css、layui.css、ueditor.css等预览文件表明其采用主流UI框架集成富文本编辑与数据可视化能力结构清晰、模块完整是理解中小型Java Web系统工程化落地的优质实践样本。1. 为什么一个“家庭理财系统”值得用 Java 从零写一遍不是为了造轮子而是把账本逻辑、数据边界和并发安全全刻进肌肉记忆你手头可能已经有 Excel 表格记账三年、有微信账单截图堆成山、甚至装过三款理财 App——但它们都解决不了一个最朴素的问题当老婆问“上个月房贷物业水电到底花了多少”你得翻五六个入口、手动加总、再心虚地报个数。这不是懒是工具没对齐真实场景家庭财务不是企业级流水它高频、低金额、多角色夫妻/父母/孩子、强时间敏感月底催缴、季度报税、弱专业门槛不能要求用户懂 ACID。Java 做这个系统不是因为它“高大上”恰恰是因为它够稳、够直、够可控——JVM 的内存模型让你能亲手管住每一分钱的生命周期Spring Boot 的自动装配省掉 80% 的胶水代码而 MyBatis 的 SQL 显式控制让你在“查上月餐饮支出”这种查询里一眼看清 WHERE 条件漏没漏AND status PAID。这不是给 CTO 看的架构图是给刚学完 ArrayList 的应届生也能跑通、改出、debug 的真实业务闭环。如果你正卡在课程设计交期前夜、想用项目证明自己真会“用 Java 写东西”而非“背八股文”或者想给家里搭个不依赖云服务、数据全在本地硬盘的记账底座——这篇就是你该抄的作业。2. 用 Spring Boot MyBatis 搭建最小可行系统三张表、两个接口、一条 SQL 就跑通记账流家庭理财系统的核心不是炫技是让“记一笔”这件事在 3 秒内完成。我们砍掉所有花哨功能报表图表、多币种、投资组合先让最核心的“收入/支出记录→分类统计→余额校验”闭环在线上跑起来。技术选型明确Spring Boot 2.7.x兼容 JDK 8避免新手被 Spring Boot 3 的 Jakarta EE 9 语法劝退MyBatis 3.4.x不选 JPA因为家庭账目字段少、关联简单手写 SQL 更直观且方便后续加审计日志H2 Database开发阶段嵌入式数据库零配置启动.mv.db文件直接存项目根目录关机即备份。2.1 数据库设计三张表撑起全部业务字段命名直白到像在写中文注释家庭账目本质是“事件流”每一笔都是带时间戳、金额、类型、分类的动作。拒绝过度设计三张表足够表名字段含类型与约束说明t_accountid BIGINT PK,name VARCHAR(50) NOT NULL,balance DECIMAL(12,2) DEFAULT 0.00,created_time DATETIME账户表现金、支付宝、银行卡等。balance是实时余额不是计算值每次记账后主动更新避免高并发时 sum() 出错t_categoryid TINYINT PK,name VARCHAR(30) NOT NULL,type TINYINT NOT NULL COMMENT 0-收入,1-支出分类表餐饮、交通、工资、奖金等。type强制区分收支防止“红包”被误记为支出t_transactionid BIGINT PK,account_id BIGINT NOT NULL,category_id TINYINT NOT NULL,amount DECIMAL(12,2) NOT NULL,description VARCHAR(200),trans_time DATETIME NOT NULL,status TINYINT DEFAULT 1 COMMENT 0-删除,1-有效,created_time DATETIME流水表核心业务表。status软删除保证历史统计不因误删失真trans_time精确到秒支持“同一分钟内多笔消费”的排序提示H2 启动时自动建表只需在src/main/resources/application.yml中配好spring: datasource: url: jdbc:h2:file:./data/family-finance;DB_CLOSE_ON_EXITFALSE;AUTO_SERVERTRUE driver-class-name: org.h2.Driver username: sa password: h2: console: enabled: true path: /h2-console访问http://localhost:8080/h2-console即可可视化操作数据库比敲 SQL 更快验证数据。2.2 核心业务逻辑两条 SQL 定义记账本质——插入流水 更新账户余额记一笔的本质是什么不是“往数据库塞一行”而是原子性地完成两件事1在流水表新增记录2将对应账户余额 金额收入或 - 金额支出。如果分开执行网络抖动或程序崩溃会导致“钱记了但余额没变”账就对不上。MyBatis 的update标签天然支持多语句我们用一条 XML 实现原子操作!-- src/main/resources/mapper/TransactionMapper.xml -- update idinsertWithBalanceUpdate parameterTypecom.example.finance.model.Transaction INSERT INTO t_transaction (account_id, category_id, amount, description, trans_time, created_time) VALUES (#{accountId}, #{categoryId}, #{amount}, #{description}, #{transTime}, NOW()); UPDATE t_account SET balance balance CASE WHEN (SELECT type FROM t_category WHERE id #{categoryId}) 0 THEN #{amount} ELSE -#{amount} END WHERE id #{accountId}; /update逻辑说明第一条INSERT插入流水trans_time由前端传入非NOW()确保用户能修改历史日期第二条UPDATE用子查询动态判断分类类型若type0收入余额 amount否则- amount关键参数#{accountId}和#{categoryId}必须严格校验存在性后文 Service 层做否则子查询返回空导致balance变NULL整个账户报废为什么不用事务注解因为 H2 的AUTOCOMMITTRUE默认行为下多语句 update 天然原子换成 MySQL 需显式Transactional但此处先保最简路径。2.3 Controller 层一个 POST 接口接收 JSON三行代码完成记账前端哪怕只是 curl发来一个 JSON后端解析、校验、调用 DAO全程无胶水代码// src/main/java/com/example/finance/controller/TransactionController.java RestController RequestMapping(/api/transactions) public class TransactionController { Autowired private TransactionService transactionService; PostMapping public ResponseEntityString createTransaction(RequestBody TransactionRequest request) { try { transactionService.create(request); return ResponseEntity.ok(记账成功); } catch (IllegalArgumentException e) { return ResponseEntity.badRequest().body(参数错误: e.getMessage()); } } }TransactionRequest定义直白到无需解释public class TransactionRequest { private Long accountId; // 账户ID如 1现金 private Byte categoryId; // 分类ID如 5餐饮 private BigDecimal amount; // 金额必须 0 private String description; // 备注可为空 private LocalDateTime transTime; // 交易时间格式 2023-10-05T14:30:00 }测试命令复制即用curl -X POST http://localhost:8080/api/transactions \ -H Content-Type: application/json \ -d { accountId: 1, categoryId: 5, amount: 42.50, description: 海底捞晚餐, transTime: 2023-10-05T19:20:00 }返回记账成功且 H2 控制台中t_transaction新增一行、t_account的balance减去 42.50 —— 最小闭环达成。3. 把“家庭”二字落到实处多账户隔离、收支分类树、软删除审计三个硬需求的 Java 实现系统能记账只是起点“家庭”场景带来三个绕不开的硬需求多账户必须物理隔离不能让老公的信用卡余额显示在老婆的支付宝页面分类要支持父子层级“餐饮”下分“外卖”“堂食”“买菜”所有删除操作必须留痕避免误删后无法追溯。这些不是锦上添花是家庭财务的信任基石。Java 的面向对象特性在这里直接转化为生产力——用 POJO 封装规则用 Service 方法封装策略不碰框架黑匣子。3.1 多账户隔离用 ThreadLocal 自定义注解实现“当前家庭成员”上下文家庭成员共用一套系统但数据必须按人隔离。常见误区是加user_id字段全局过滤但家庭场景下更自然的是“登录即切换账户组”。我们采用轻量级方案登录后将当前成员绑定的账户 ID 列表存入ThreadLocal所有 DAO 查询自动追加account_id IN (...)条件。// 定义上下文持有者 Component public class AccountContextHolder { private static final ThreadLocalListLong ACCOUNT_IDS_HOLDER ThreadLocal.withInitial(ArrayList::new); public static void setAccountIds(ListLong accountIds) { ACCOUNT_IDS_HOLDER.set(accountIds); } public static ListLong getAccountIds() { return ACCOUNT_IDS_HOLDER.get(); } public static void clear() { ACCOUNT_IDS_HOLDER.remove(); } } // 自定义注解标记需要账户隔离的方法 Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface WithAccountFilter {} // AOP 切面自动注入账户ID条件 Aspect Component public class AccountFilterAspect { Around(annotation(withAccountFilter)) public Object doAround(ProceedingJoinPoint joinPoint, WithAccountFilter withAccountFilter) throws Throwable { ListLong accountIds AccountContextHolder.getAccountIds(); if (accountIds null || accountIds.isEmpty()) { throw new IllegalStateException(未设置当前成员账户列表); } // 执行前设置 MyBatis 的动态条件通过 ThreadLocal 传递 return joinPoint.proceed(); } }DAO 层动态 SQL 示例MyBatisselect idselectByMember resultTypeTransaction SELECT * FROM t_transaction WHERE status 1 if testaccountIds ! null and !accountIds.isEmpty() AND account_id IN foreach itemid collectionaccountIds open( separator, close) #{id} /foreach /if ORDER BY trans_time DESC /select关键点accountIds从AccountContextHolder获取无需 Controller 层透传彻底解耦。3.2 收支分类树用递归 POJO 一次查询构建完整分类结构家庭分类天然有层级“生活缴费” → “电费”、“水费”、“燃气费”。但 MyBatis 不支持原生递归查询我们用 Java 递归组装// 分类实体含子分类列表 public class Category { private Byte id; private String name; private Byte type; // 0-收入, 1-支出 private Byte parentId; // 父IDnull表示根节点 private ListCategory children new ArrayList(); // getter/setter 略 } // Service 层一次查出所有分类Java 递归组装树 Service public class CategoryService { Autowired private CategoryMapper categoryMapper; public ListCategory getCategoryTree() { ListCategory allCategories categoryMapper.selectAll(); // 查所有 MapByte, Category categoryMap allCategories.stream() .collect(Collectors.toMap(Category::getId, Function.identity())); ListCategory rootList new ArrayList(); for (Category cat : allCategories) { if (cat.getParentId() null) { rootList.add(cat); } else { Category parent categoryMap.get(cat.getParentId()); if (parent ! null) { parent.getChildren().add(cat); } } } return rootList; } }前端拿到的就是一棵树[{ name:生活缴费, children:[{ name:电费 }, { name:水费 }] }]渲染成下拉树形菜单用户选“水费”时后端只认categoryId7完全屏蔽层级复杂度。3.3 软删除审计用 MyBatis 插件拦截 DELETE自动记录操作人与时间家庭成员误删一笔账必须能找回。我们禁用物理DELETE所有删除走UPDATE t_transaction SET status0并用 MyBatis 插件自动补全审计字段// 审计插件拦截所有 UPDATE 语句若涉及 status 字段则注入操作人 Intercepts({ Signature(type Executor.class, method update, args {MappedStatement.class, Object.class}) }) Component public class AuditPlugin implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { Object[] args invocation.getArgs(); MappedStatement ms (MappedStatement) args[0]; Object parameter args[1]; // 只处理 t_transaction 表的 UPDATE if (ms.getSqlCommandType() SqlCommandType.UPDATE ms.getBoundSql(parameter).getSql().contains(t_transaction)) { if (parameter instanceof Transaction) { Transaction trans (Transaction) parameter; if (trans.getStatus() 0) { // 软删除 trans.setDeletedBy(getCurrentUserId()); // 从上下文取当前用户ID trans.setDeletedTime(LocalDateTime.now()); } } } return invocation.proceed(); } }效果调用transactionMapper.updateStatus(id, 0)时插件自动为deleted_by和deleted_time赋值无需每个 Service 方法手动设。4. 避坑指南家庭理财系统上线前必踩的 4 个坑血泪经验换来的后悔药这系统看似简单但我在带三个学生做课程设计时90% 的失败都卡在这几个点上。不是代码写错而是对“家庭场景”的理解偏差。以下全是真实翻车现场附解决方案4.1 现象H2 数据库重启后所有数据消失原因H2 默认使用内存模式jdbc:h2:mem:testdb进程退出即清空。很多教程没强调文件模式的连接字符串写法学生复制粘贴后以为数据持久化了。解决强制使用文件模式连接字符串必须含file:前缀且路径为绝对路径或项目相对路径如./data/family-finance。检查application.yml中url是否含file:并在项目根目录确认生成了.mv.db文件。4.2 现象记一笔“工资 8000 元”账户余额变成NULL原因t_category表中该分类的type字段为空NULL导致UPDATE语句中的CASE WHEN (SELECT type...) 0返回NULLbalance balance NULL结果为NULL。解决建表时type字段加NOT NULL DEFAULT 0约束初始化数据脚本schema.sql中确保所有分类type明确设为 0 或 1DAO 层插入前校验categoryId对应的type存在且非空。4.3 现象多用户同时记账账户余额计算错误如两笔 100 元支出余额只减 100原因H2 默认隔离级别为READ_COMMITTED但UPDATE t_account SET balance balance ...是“读-改-写”非原子操作。并发时线程 A 读到balance1000线程 B 也读到1000A 更新为900B 更新为900丢失一次扣减。解决改用SELECT ... FOR UPDATE加行锁。在 MyBatis XML 中update idupdateBalanceWithLock SELECT balance FROM t_account WHERE id #{accountId} FOR UPDATE; UPDATE t_account SET balance balance #{delta} WHERE id #{accountId}; /update注意H2 的FOR UPDATE需开启DB_CLOSE_ON_EXITFALSE否则锁失效。4.4 现象分类树前端显示为空但数据库里有数据原因Category实体类中parentId字段类型为Byte但数据库中parent_id是TINYINTMySQL 的TINYINT映射到 Java 默认为Integer导致cat.getParentId()返回IntegercategoryMap.get(cat.getParentId())查不到Byte键。解决统一字段类型parentId在 Java 中声明为Integer或数据库改为SMALLINT更稳妥的是在categoryMapper.xml中用resultMap显式指定类型转换resultMap idCategoryResultMap typeCategory id propertyid columnid/ result propertyparentId columnparent_id javaTypejava.lang.Byte/ /resultMap5. 进阶技巧用 Java 8 时间 API 做精准周期统计三步写出“上月餐饮支出”查询家庭理财最常问的问题不是“总花了多少”而是“上个月吃饭花了多少”“今年水电费趋势如何”。这类查询的坑在于时间范围边界模糊“上个月”指 6 月 1 日 00:00 到 6 月 30 日 23:59:59还是到 7 月 1 日 00:00、时区混乱服务器在东京用户在北京、夏令时跳变某些地区 3 月第二个周日时钟拨快一小时。Java 8 的java.timeAPI 就是为此而生——它用LocalDate/LocalDateTime明确区分“无时区日期”和“有时区时间”用TemporalAdjusters精准计算周期。5.1 定义“上个月”的精确范围用 TemporalAdjusters 拿到毫秒级边界别再手算yearMonth-01和yearMonth-lastDayTemporalAdjusters提供工业级精度public class DateRangeUtils { // 获取上个月的第一天 00:00:00.000 public static LocalDateTime firstDayOfLastMonth() { return LocalDate.now().with(TemporalAdjusters.firstDayOfPreviousMonth()) .atStartOfDay(); // 转为 LocalDateTime时间部分为 00:00:00 } // 获取上个月的最后一天 23:59:59.999 public static LocalDateTime lastDayOfLastMonth() { return LocalDate.now().with(TemporalAdjusters.lastDayOfPreviousMonth()) .atTime(23, 59, 59, 999_000_000); // 纳秒级精度避免跨天误差 } }验证firstDayOfLastMonth()在 2023-10-05 运行返回2023-09-01T00:00lastDayOfLastMonth()返回2023-09-30T23:59:59.999。完美覆盖整月。5.2 MyBatis 动态 SQL用LocalDateTime参数生成安全 WHERE 条件前端传periodLAST_MONTH后端解析为时间范围MyBatis 自动生成带边界的 SQLselect idselectByCategoryAndPeriod resultTypeTransaction SELECT * FROM t_transaction WHERE status 1 AND category_id #{categoryId} AND trans_time #{startTime} AND trans_time #{endTime} ORDER BY trans_time DESC /selectService 层调用public ListTransaction getExpensesByCategory(Byte categoryId, String period) { LocalDateTime startTime, endTime; switch (period) { case LAST_MONTH: startTime DateRangeUtils.firstDayOfLastMonth(); endTime DateRangeUtils.lastDayOfLastMonth(); break; case THIS_YEAR: startTime LocalDate.now().with(TemporalAdjusters.firstDayOfYear()).atStartOfDay(); endTime LocalDate.now().with(TemporalAdjusters.lastDayOfYear()).atTime(23, 59, 59, 999_000_000); break; default: throw new IllegalArgumentException(不支持的周期: period); } return transactionMapper.selectByCategoryAndPeriod(categoryId, startTime, endTime); }关键优势LocalDateTime参数传入 MyBatis 后JDBC 驱动自动转为 SQL 的TIMESTAMP无字符串拼接风险防 SQL 注入。5.3 生成月度汇总报表用 Stream API 聚合一行代码出结果用户要的不是原始流水是“9月餐饮¥2,345.60交通¥890.00”。用 Java Stream 做内存聚合比写复杂 SQL 更易维护public MapString, BigDecimal getMonthlySummary(LocalDate month) { LocalDateTime start month.with(TemporalAdjusters.firstDayOfMonth()).atStartOfDay(); LocalDateTime end month.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59, 999_000_000); ListTransaction transactions transactionMapper.selectByTimeRange(start, end); return transactions.stream() .filter(t - t.getCategoryId() ! null) // 过滤无分类 .collect(Collectors.groupingBy( t - categoryService.getCategoryName(t.getCategoryId()), // 分类名称 Collectors.mapping(Transaction::getAmount, Collectors.reducing( BigDecimal.ZERO, BigDecimal::add)) // 求和 )); }返回示例{餐饮: 2345.60, 交通: 890.00, 购物: 1250.30}前端直接渲染为饼图数据。我坚持在所有家庭理财项目里用LocalDateTime而非Date因为前者明确告诉团队“这个时间不涉及时区就是用户看到的日历上的日子”。曾有个学生用new Date()算“上月”结果服务器时区是 UTC他在中国大陆查 8 月数据却得到 7 月结果调试三天才发现Date的getMonth()返回 0-11 且受时区影响。Java 8 时间 API 不是炫技是把时间这个最易错的维度变成可预测、可测试、可交付的确定性模块。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询