简介:基于Java实现的小小记账本项目是一套完整的个人财务管理应用源码,面向Java初学者及需要GUI与数据库集成实践的开发者。项目采用J2SE技术栈,利用Swing构建交互界面,SQLite负责数据存储,实现了收支记录、分类管理与统计展示等核心功能,适合作为课程设计或入门练手项目。资源包含173个文件,压缩包约29.45MB,主要文件类型有java源码、class编译文件、jar依赖库,以及png图标、xml配置和db数据库文件,便于直接运行与二次开发。目前已有744人浏览学习,项目注释较完整,可通过源码学习Swing组件与数据库操作的结合、用户输入验证及异常处理方式。此外,资源还展示了将项目打包成可执行jar文件的方法,体现Java跨平台特性;核心DAO类、面板类与工具类的组织方式,也能帮助读者掌握分层设计与事件监听等实用技能。
1. 基于java实现的小小记账本项目:看起来简单,为什么很多新手照样翻车
打开招聘网站的Java岗位要求,十条里有八条写着“有项目经验”。可对刚学完Java基础的人来说,第一个能完整跑起来的项目选什么最合适?我的答案一直是记账本。它不像电商那样涉及分布式、消息队列,业务规则简单到一天能捋完,但它的技术覆盖面恰好卡在“会写CRUD”和“能独立做小工具”的分界线上。用Java实现一个记账本,既能验证你对面向对象编程的掌握,又能练习JDBC、MyBatis或Spring Boot等真实开发环境里的常用库函数。本文就拿这样一个小项目,从数据库设计讲到避坑,再讲到怎么把它变成面试作品或参加比如蓝桥杯一类比赛的练手项目。
2. 先把地基打牢:技术选型与数据模型设计的3个决策点
2.1 用Spring Boot还是纯Servlet:两条路线的取舍
我见过不少人用Servlet + JSP写记账本,理由是“这样更能理解底层原理”。这话对了一半。如果你准备面试,纯Servlet确实能让面试官确认你懂HTTP请求的生命周期、Filter和Session;但从投入产出比看,Spring Boot + MyBatis Plus才是java工程师日常工作的主流配置,而且热词里经常出现“spring boot + mybatis 的 java 开源多商户跨境商城源码下载”这类需求,说明市场对这套组合的认可度很高。
我的建议是分情况。如果是课程设计,选纯Servlet写,容易讲清楚原理;如果是自己练手或准备面试,直接上Spring Boot。但无论哪条路线,架子都别搭太复杂。记账本的核心就三件事:记录一笔收支、查历史账单、按分类或月份统计。不要一上来就引入Redis、MQ,那是把项目做歪了。
如果你选Spring Boot,版本用2.x稳定线即可,3.x也不是不行,但Spring Boot 3要求JDK 17,不少人的机器上还跑着JDK 8,环境这关就卡住了。我一般用Spring Boot 2.7 + JDK 8的组合,兼容性最好,排查问题的资料也最全。
2.2 数据模型:收支分类与金额字段的选型
先想清楚记账本的表结构。最基本的是一张账单表,但我不建议只有一张表,而是加一张分类表。想象一下:你记了一笔“早餐 - 15元”,如果直接用字符串存分类名,等你想统计“这个月早餐花了多少”时,SQL里就得写死字符串匹配。加个分类表,把“早午晚餐”“交通”“购物”“工资”等收入支出类型统一管理,统计和扩展都轻松。
类别表结构大概是这样:
CREATE TABLE t_category ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', type TINYINT NOT NULL COMMENT '类型:1-支出,2-收入', name VARCHAR(50) NOT NULL COMMENT '分类名称', user_id BIGINT NOT NULL COMMENT '所属用户' ) COMMENT '分类表';注意这里有个小细节:分类要冗余一个user_id。虽然demo阶段可能只有一个用户,但加上这个字段后,整套代码天然支持多用户,后面接登录逻辑时不用改表。账单表:
CREATE TABLE t_bill ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', category_id BIGINT NOT NULL COMMENT '分类ID', amount DECIMAL(10,2) NOT NULL COMMENT '金额', remark VARCHAR(255) COMMENT '备注', user_id BIGINT NOT NULL COMMENT '所属用户', bill_date DATE NOT NULL COMMENT '记账日期', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' ) COMMENT '账单表';金额字段用DECIMAL(10,2),这一点必须养成习惯。Java里float和double在二进制下无法精确表示小数,哪怕你只是在做记账本,长期累计也会出现0.999999这种诡异结果。项目里的金额、余额、统计汇总,全部用BigDecimal对应数据库的DECIMAL。
2.3 实体的定义与MyBatis Plus的约定
Java实体类直接用MyBatis Plus风格写。表名和字段都带下划线,实体类用驼峰,MyBatis Plus默认开启了下划线转驼峰映射,省去一堆@TableField注解。一个常见做法是定义BaseEntity,把主键、创建时间、更新时间抽出来:
@Data public class BaseEntity { @TableId(type = IdType.AUTO) private Long id; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }对应的Bill实体:
@Data @EqualsAndHashCode(callSuper = true) @TableName("t_bill") public class Bill extends BaseEntity { private Long categoryId; private BigDecimal amount; private String remark; private Long userId; private LocalDate billDate; }这里createTime和updateTime用自动填充,需要实现MyBatis Plus的MetaObjectHandler。如果你没配这个类,insert时会发现时间字段永远是null。这是新手常踩的坑,后面避坑章还会细说。
MyBatis Plus的Mapper层比传统MyBatis省事很多,基础CRUD不需要手写XML:
public interface BillMapper extends BaseMapper<Bill> { }到此,一个可运行的最小数据模型就算立住了。不要急着写业务代码,先拿一个测试类验证Mapper能不能正常insert、select,确认环境没问题再往前走。
3. 核心功能实现:记账、分页查询与统计聚合
3.1 记账流程:从Controller到Mapper的一条完整链路
记账本最核心的功能是“记一笔”。Controller接收前端传来的分类ID、金额、备注、记账日期,组装成Bill对象后调Service落库。这里不要直接在Controller里写业务逻辑,哪怕再简单也走Service层,这是java项目里最基本的约定。
@RestController @RequestMapping("/api/bill") public class BillController { @Resource private BillService billService; @PostMapping("/add") public Result<String> add(@RequestBody BillAddDTO dto) { billService.addBill(dto); return Result.success("记账成功"); } }DTO和实体分离的意义在于:DTO只需要接收前端必填字段,而实体的userId、createTime等字段不应该由前端指定。Service层做校验、补齐字段:
@Service public class BillServiceImpl implements BillService { @Resource private BillMapper billMapper; @Resource private CategoryMapper categoryMapper; @Override public void addBill(BillAddDTO dto) { // 校验分类是否存在 Category category = categoryMapper.selectById(dto.getCategoryId()); if (category == null) { throw new BizException("分类不存在"); } Bill bill = new Bill(); bill.setCategoryId(dto.getCategoryId()); bill.setAmount(dto.getAmount()); bill.setRemark(dto.getRemark()); bill.setBillDate(dto.getBillDate()); bill.setUserId(1L); // 当前登录用户 billMapper.insert(bill); } }这段逻辑的关键点在于:Service层做业务校验、实体字段填充、以及最终落库。Controller保持轻量,Mapper只负责SQL。
3.2 分页查询:MyBatis Plus分页插件与排序
账单会越记越多,一次性查全量列表会让页面越来越慢。MyBatis Plus的分页插件是PaginationInnerInterceptor,需要先配一个配置类:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }没有这个配置,你用Page对象查询会发现SQL里根本没有LIMIT,照样全表捞数据。配置完成后,分页查询这样写:
@Override public PageResult<BillVO> pageQuery(Integer pageNum, Integer pageSize, Long categoryId, LocalDate startDate, LocalDate endDate) { LambdaQueryWrapper<Bill> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(categoryId != null, Bill::getCategoryId, categoryId) .ge(startDate != null, Bill::getBillDate, startDate) .le(endDate != null, Bill::getBillDate, endDate) .orderByDesc(Bill::getBillDate) .orderByDesc(Bill::getId); Page<Bill> page = billMapper.selectPage(new Page<>(pageNum, pageSize), wrapper); // 把实体转成VO,拼接分类名称等展示字段 return PageResult.of(page); }很多新人会踩两个坑:一是条件构造用字符串硬拼,虽然能跑但容易SQL注入;二是排序不稳定,同一天有多笔账单时,如果不加id作为第二排序,翻页时数据顺序可能会乱。
把实体转成VO这一步容易被忽略。实体里的categoryId只是数字,前端展示需要分类名称,所以查询后要批量查出分类名再组装。不要逐条查分类,那样N+1条SQL会把接口拖慢。
3.3 分组统计与汇总:从Java代码到SQL的取舍
记账本最常见的统计需求就是“这个月花了多少钱、花在哪些分类”,常见写法是查询全部账单,在Java内存里分组汇总。这个做法在小数据量时没问题,但账单上万条之后,内存和响应速度都不好看。直接交给SQL聚合更合理。
@Override public List<CategoryStatVO> sumByCategory(Long userId, LocalDate monthStart, LocalDate monthEnd) { // 直接使用Mapper层自定义SQL return billMapper.sumByCategory(userId, monthStart, monthEnd); }对应的Mapper方法不需要写XML,用注解就行。如果是复杂SQL我建议写XML更清晰。这里给个常见做法:
<select id="sumByCategory" resultType="com.example.vo.CategoryStatVO"> SELECT c.name AS categoryName, SUM(b.amount) AS totalAmount, COUNT(*) AS billCount FROM t_bill b INNER JOIN t_category c ON b.category_id = c.id WHERE b.user_id = #{userId} AND b.bill_date BETWEEN #{startDate} AND #{endDate} GROUP BY c.id, c.name ORDER BY totalAmount DESC </select>执行统计时碰到的第一个坑是时间范围。前端传“2024-06-01 到 2024-06-30”,SQL里的BETWEEN在日期类型上没问题,但如果bill_date字段含时分秒,就要注意右边界匹配问题。这里将bill_date设置成date类型,BETWEEN可以直接用;如果不是,建议endDate加一天再查,用b.bill_date >= #{startDate} AND b.bill_date < #{endDate}这种左闭右开的写法,避免丢数据。
4. 把项目跑起来:前端页面、环境变量配置与数据库切换
4.1 用Thymeleaf+ Bootstrap搭一个最小页面
很多Java开发只写后端接口,前端丢给同事或直接不做。但一个记账本至少要有个能录入、能查看的页面,否则面试时不好演示。用Thymeleaf模板,不搞前后端分离,是最快的路线。
页面主要就两个:记账表单页和账单列表页。记账页拿到分类列表渲染下拉框,提交时用jQuery的Ajax调接口:
$("#addForm").on("submit", function(e) { e.preventDefault(); $.ajax({ url: "/api/bill/add", method: "POST", contentType: "application/json", data: JSON.stringify({ categoryId: $("#categoryId").val(), amount: $("#amount").val(), remark: $("#remark").val(), billDate: $("#billDate").val() }), success: function(res) { if (res.code === 200) { alert("记账成功"); location.reload(); } else { alert(res.msg); } } }); });Thymeleaf的好处是Controller返回字符串时能直接渲染模板,不需要跨域配置,也不用启动前端工程。注意别把接口路径写错,Controller前缀加的是/api,页面请求路径也要带上。
4.2 金额校验、日期选择与前端拦截
金额输入框是个玄学重灾区。用户什么都能给你填进来,负数、小数点后三位、字符串。前端要做校验,后端更不能裸奔。前端用简单的JS校验:
var amount = $("#amount").val(); if (!/^[0-9]+(\.[0-9]{1,2})?$/.test(amount)) { alert("金额格式不正确,最多两位小数"); return; }后端同样要校验,因为绕过前端直接调接口这种事太常见了。用Spring的Validation注解最省事。DTO里这样标:
public class BillAddDTO { @NotNull(message = "分类不能为空") private Long categoryId; @NotNull(message = "金额不能为空") @DecimalMin(value = "0.01", message = "金额必须大于0") @Digits(integer = 8, fraction = 2, message = "金额最多两位小数") private BigDecimal amount; @NotBlank(message = "备注不能为空") @Size(max = 100, message = "备注不能超过100字") private String remark; @NotNull(message = "记账日期不能为空") @JsonFormat(pattern = "yyyy-MM-dd") private LocalDate billDate; }金额类型在Java里对应BigDecimal,序列化时要注意如果前端传来的是字符串,Jackson通常能自动转。日期字段一般用LocalDate,如果前端传的是"2024-06-01",符合ISO标准,可以顺利转成LocalDate。注意如果Controller的入参没加@Valid注解,这些校验规则不会生效。
日期选择器我一般直接改input标签的type为date,浏览器原生就能弹日历。这个在移动端可能有点不一样,但做demo够用了。
4.3 java环境变量配置与H2切换MySQL的方法
许多人卡在这个项目的第一个问题不是代码,而是java环境变量配置。千言万语汇成三步:安装JDK,设置JAVA_HOME指向JDK目录,把%JAVA_HOME%\bin加入PATH。难住很多人的是装了多个JDK版本,比如同时有JDK 8和17,命令行里java -version显示的版本和自己预期的不一样。把Path里靠前的java.exe路径去掉,或者直接删掉系统变量里其他版本的JAVA_HOME,就能消除这个混乱。
数据库方面,为了零配置跑通,application.yml里先配H2内存数据库:
spring: datasource: driver-class-name: org.h2.Driver url: jdbc:h2:mem:bookdb;DB_CLOSE_DELAY=-1 username: sa password: h2: console: enabled: trueH2的好处是下载依赖就能跑,不需要装MySQL,非常适合起步。但发展到需要长期保存、积累几百条真实账单时,H2内存库就扛不住了。这时平滑切到MySQL,主要是三处改动:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/bookdb?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你的密码切换后最大的问题通常是时区。不写serverTimezone参数,MySQL 8的驱动会拿不到正确的连接。而装了MySQL 5.x的人可能碰到驱动名不一样的问题,MySQL 5要用com.mysql.jdbc.Driver,MySQL 8用com.mysql.cj.jdbc.Driver。先确认本机版本再填,多数人卡在这个小地方半天。建表SQL用之前设计的t_category和t_bill建一遍,再跑一次插入,验证配置无误。
5. 避坑指南:记账本项目最常见的5个坑与排查办法
5.1 时间字段填充失败,insert后create_time为null
现象:调用billMapper.insert()插入成功,但数据库里create_time字段是空的。
原因:多数情况下是忘了配置MetaObjectHandler,或者配置了但@TableField里的fill属性没写对。MyBatis Plus只会在fill值为INSERT或INSERT_UPDATE时触发自动填充。
解决:写一个组件,实现MetaObjectHandler接口:
@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }注意如果实体类里字段名和数据库字段名不一样,比如Java里叫createTime,数据库叫create_time,MyBatis Plus能自动映射,但strictInsertFill的第一个参数必须写实体字段名createTime,不是数据库列名。这个细节很容易出错,建议核对一遍。
5.2 BigDecimal直接接收前端的字符串导致精度异常
现象:前端传15.10,后端拿到的金额变成了15.1,网页上显示“15.1”,跟原始输入不一致。或者更糟,传0.1+0.2保存后查询变成0.30000000000000004。
原因:虽然BigDecimal能精确表示小数,但如果用构造函数传double,例如new BigDecimal(0.1),得到的其实是二进制近似值;用new BigDecimal("0.1")才是精确的。JSON反序列化时,Jackson通常会用字符串构造,所以问题主要出在手动new BigDecimal的场景。
解决:所有从外部传入的金额,统一走字符串。如果从DTO直接绑定到BigDecimal字段,Jackson会自己处理成字符串构造。需要做运算时调用add、subtract这些方法,不要用+号。
5.3 MySQL时区问题:配置了serverTimezone依然报错
现象:应用启动时报Could not create connection to database server,错误信息里出现Public Key Retrieval is not allowed或时区相关字样。
原因:MySQL 8驱动默认要求建立安全连接,而本机连接串里没指定allowPublicKeyRetrieval,或者服务器时区未设置。
解决:常见的做法是把连接URL补全:
jdbc:mysql://localhost:3306/bookdb?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true还有一个容易忽略的点:本地MySQL如果装在Windows上,系统时区本身如果是UTC,也会出现查询结果和实际时间差8小时。把serverTimezone显式写成Asia/Shanghai,是治本的办法。
5.4 MyBatis Plus分页失效:SQL里没有LIMIT
现象:上一章写了分页插件的@Configuration配置类,但项目启动后分页查询查出了所有数据,控制台打印SQL发现没有LIMIT关键字。
原因:分页插件没被加载。常见的是Spring Boot 3 + MyBatis Plus 3.4以下版本组合不兼容,或者MybatisPlusConfig类没被扫描到。另一个新手常见的错误是建了配置类但没加@Configuration注解,方法上的@Bean也不会生效。
解决:检查启动类所在包与配置类所在包的关系,确保配置类在启动类扫描范围内。再确认MyBatis Plus的版本与Spring Boot版本兼容。3.5.3以上的MyBatis Plus对Spring Boot 2.x支持稳定,Spring Boot 3建议使用3.5.3及以上版本。
5.5 分类表SQL主键策略导致插入失败
现象:t_category插入数据报主键冲突,或者插入成功后ID不是自增的。
原因:MyBatis Plus默认的主键生成策略是ASSIGN_ID,跟数据库自增主键不搭。尤其当你有代码里手动设置ID的习惯时,会互相干扰。
解决:实体上直接在@TableId注解明确指定自增策略:
@TableId(type = IdType.AUTO) private Long id;这样无论在MySQL还是H2中都能正确获取自增主键。如果不指定,MyBatis Plus会用雪花算法生成ID,数据库主键类型如果设置为BIGINT AUTO_INCREMENT倒是也能插入,但ID变成一堆数字,调试时很不舒服。
5.6 忘记写@Valid导致后端校验全失效
现象:明明在BillAddDTO上加了@NotNull、@DecimalMin注解,但传空值时后端没有报错,照样插入了数据库里。
原因:Controller方法入参需要加@Valid注解,校验注解才会生效。Spring Boot不会自动启用校验。
解决:把Controller方法的入参改成@RequestBody @Valid BillAddDTO dto。常见的写法是:
@PostMapping("/add") public Result<String> add(@RequestBody @Valid BillAddDTO dto) { billService.addBill(dto); return Result.success("记账成功"); }之后如果校验还是没生效,检查pom.xml里是否引入了spring-boot-starter-validation依赖。Spring Boot 2.7以上这个依赖不再由web starter自动带,要显式添加。
6. 把记账本升级成拿得出手的作品:导出CSV与部署检查清单
基础的增删改查做完以后,记账本充其量是个练习册。想让它进入“作品”的行列,至少要再补两件事:一是导出功能,二是打包部署。导出CSV是最快见效的,因为记账数据本质上是表格,用户想把账单导出到Excel里二次处理是高频需求。
导出接口用HttpServletResponse写文件流,记得设置响应头:
@GetMapping("/export") public void export(HttpServletResponse response, Long userId, LocalDate startDate, LocalDate endDate) throws IOException { List<BillVO> list = billService.queryList(userId, startDate, endDate); response.setContentType("text/csv;charset=utf-8"); response.setCharacterEncoding("UTF-8"); String fileName = URLEncoder.encode("账单导出.csv", "UTF-8"); response.setHeader("Content-Disposition", "attachment;filename=" + fileName); // 写表头和每行数据 StringBuilder sb = new StringBuilder(); sb.append("分类,金额,备注,日期\n"); for (BillVO vo : list) { sb.append(vo.getCategoryName()).append(",") .append(vo.getAmount()).append(",") .append(vo.getRemark()).append(",") .append(vo.getBillDate()).append("\n"); } response.getWriter().write(sb.toString()); response.getWriter().flush(); }这个实现里藏着三个容易翻车的点:一是文件名中文乱码,需要URLEncoder编码;二是CSV里的金额如果直接拼接BigDecimal,Excel打开时会变成科学计数法,可以先把金额转成字符串再用;三是备注里如果本身有逗号,CSV会被破坏,需要给备注字段加引号转义。常见做法是把字段值用双引号包起来,内部的双引号再转义成两个双引号。
关于项目的安全加固,可以顺手做一个针对防爬虫的Controller层防护——比如简单判断User-Agent或者加一个请求频率限制的拦截器,这个作为加分项写进README里,能让面试官眼前一亮。
再来说部署。项目打成jar包,命令行直接java -jar bookkeeping.jar启动,前提是确保环境变量配置正确。之前提到的java环境变量配置如果没搞好,这里就会启动失败。Linux服务器上部署我一般打成tar包,把jar、启动脚本、配置文件放在一起:
tar -czf bookkeeping.tar.gz bookkeeping.jar start.sh application.ymlstart.sh里先检查JAVA_HOME是否存在,再启动Java进程:
#!/bin/bash export JAVA_HOME=/usr/local/jdk1.8 export PATH=$JAVA_HOME/bin:$PATH nohup java -Xms256m -Xmx512m -jar bookkeeping.jar --spring.config.location=application.yml > nohup.out 2>&1 &启动后先看端口是否监听,再访问接口。如果服务没起来,多半是配置文件的数据库连接串写错了,登录服务器排错的命令基本是先看进程、再看磁盘和内存、最后看日志。这恰恰是Java程序员的基本功,远比代码本身更值钱。
我自己的教训是,一直在本地跑H2内存库,从不连MySQL,到了真机上才发现连接串和时区问题一大堆,折腾到半夜。后来养成了一个习惯:真机部署前一定先本机连MySQL跑一遍全流程。这个习惯帮我避开了很多次翻车。希望帮到你。
本文还有配套的精品资源,点击获取