简介:基于Java语言开发的家庭理财系统完整源码,面向Java学习者、课程设计及个人财务管理工具开发者。系统采用前后端分离架构,前端通过HTML、CSS、JavaScript实现动态交互界面,后端以Java处理账户管理、收支记录、预算编制、财务报表等核心功能,可用于毕业设计、项目实训或二次开发。
压缩包共388个文件,大小约6.78MB,包含71个Java源文件、118个JavaScript文件、53个CSS样式表、19个HTML页面,以及XML、VM、字体、图片等配置与资源文件,覆盖业务逻辑、页面布局、交互脚本和项目配置等多个层面。已有252人学习浏览。通过源码可掌握家庭理财系统的模块划分、数据库设计思路及Maven项目管理方式,内容预览显示工程集成了zui、layui、ueditor等前端框架样式,便于界面重构与功能扩展。
1. 家庭理财系统这道 Java 题,先想清楚账怎么记
家庭理财系统的难点不在界面,而在账。你每天记的每一笔收支,背后要同时影响账户余额、分类汇总、月度预算三个维度;只要有一笔流水写错或漏了事务,月底对账就会对不上。基于 Java 语言来做这个系统,我建议直接把技术栈落在 Spring Boot + MyBatis-Plus + MySQL 这套主流组合上,它既能覆盖 Java 基础、SQL 设计和数据一致性这些常被面试官追问的点,又能让你拿到一份可运行的源码去完成课程设计或做二次开发。下面这套方案按“先定表结构,再写记账主流程,最后补报表和避坑清单”的顺序展开,新手能一步步跑通,熟手也能直接拿去做改造。
2. 技术栈与数据模型:先定表结构,再写业务代码
很多人把家庭理财系统当成纯 CRUD 项目来做,上来就写 Controller,等做到报表统计时才发现字段不够用,回头改表又要动一堆代码。做账本类系统,顺序必须是反过来的:先把数据模型定死,再写业务逻辑。因为记账的本质是“事实记录”,表结构一旦不合理,后面所有聚合查询都会跟着翻车。
2.1 技术选型:为什么是 Spring Boot + MyBatis-Plus,而不是 Swing 或 JSP
网上能搜到不少 Java 版家庭理财系统的老课设,技术栈还是 Swing + 文件存储,双击就能跑,不需要装数据库,演示方便。但它的表格渲染和报表能力很弱,账本逻辑一复杂,你大部分时间会花在 UI 布局上,而不是账本核心上。我做的这版选的是 Spring Boot 3.x + JDK 17 + MyBatis-Plus 3.5.x + MySQL 8,前端的页面用服务端渲染,把精力全压在记账和统计这条主线上。
选 MyBatis-Plus 而不是 Spring Data JPA,是因为家庭理财系统的核心查询是分类聚合、月度分组、余额对账,这类 SQL 手写更可控。特别是“账户余额变更”这种带条件的 UPDATE,手写 SQL 能精确控制影响行数,JPA 反而绕。另外 MyBatis-Plus 的代码生成器能直接根据表结构生成实体和 Mapper,省掉不少机械劳动。
这一版的启动前提是 JDK 17 和 Maven 环境变量配置就绪。如果还没配过,先花十分钟把JAVA_HOME和M2_HOME配好,再执行下面的命令把空项目跑起来:
mvn spring-boot:run启动后访问http://localhost:8080,能看到登录页说明基础环境没问题。
项目结构上我建议按controller / service / mapper / entity四层分,源码阅读顺序从 mapper 层开始,因为表的字段定义决定了业务代码怎么写。接下来我把六张核心表的建表 SQL 和字段边界讲清楚。
2.2 用户、账户、分类:三张主数据表
主数据表解决“谁在记账、钱放在哪、记成什么类目”这三个基本问题。第一张是家庭成员表,注意不要把密码存成明文,Spring Security 的 BCrypt 是常见做法;第二张是账户表,这里的账户不是登录账号,而是“钱放在哪”:现金、储蓄卡、信用卡、支付宝这类资金容器。
账户表里有一个字段容易忽略:initial_balance,也就是期初余额。家庭理财系统一般不需要从零开始记账,你换工作、搬家、换手机后接手一套账本,总得把之前的余额作为起点。没有这个字段,后面做对账时你永远不知道钱是本来就有的,还是记账记出来的。
CREATE TABLE `account` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '账户主键', `user_id` BIGINT NOT NULL COMMENT '归属家庭成员ID', `account_name` VARCHAR(50) NOT NULL COMMENT '账户名称:工资卡、现金、支付宝', `account_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1现金 2储蓄卡 3信用卡 4电子钱包', `initial_balance` BIGINT NOT NULL DEFAULT 0 COMMENT '期初余额,单位分', `current_balance` BIGINT NOT NULL DEFAULT 0 COMMENT '当前余额,单位分', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0停用', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户表';这里最关键的是一开始就把current_balance和initial_balance分开。很多简化版只保留一个“余额”字段,每笔收支直接改余额,看起来没问题,但一旦出现补录上个月的流水,或者要回看某个时间点的余额,数据就对不上了。
第三张主数据表是收支分类表。分类表建议预留parent_id做两级分类,一级是“餐饮、交通、居住、购物”这种大类,二级是“早餐、地铁、房租、数码”这种明细。分类还有一个容易踩的坑:删除分类时不要把物理行删掉,否则历史流水会失去分类信息,后面报表会丢数据。我在第 5 章的避坑记录里会展开说。
CREATE TABLE `category` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '0为系统预置分类,其他为用户自建', `parent_id` BIGINT NOT NULL DEFAULT 0 COMMENT '父分类ID,0表示一级分类', `name` VARCHAR(30) NOT NULL COMMENT '分类名称', `type` TINYINT NOT NULL COMMENT '1收入 2支出', `sort_order` INT NOT NULL DEFAULT 0, `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用,不物理删除', PRIMARY KEY (`id`), KEY `idx_user_type` (`user_id`, `type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收支分类表';这张表我用status做逻辑删除,而不是直接 DELETE。家庭成员自建的分类可以停用后隐藏,但历史账单里只要引用了这个 id,数据就还是完整的。
2.3 流水、预算、预算用量:三张业务表
主数据表定完,核心的业务表是流水表。流水表是整个系统的记账根基,它的字段设计直接决定月底能不能把账对平。这里有两个关键点:金额用 BIGINT 存“分”,不存浮点;同时要有一个biz_no业务幂等号,用来挡住页面重复点击造成的重复流水。
CREATE TABLE `transaction` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL COMMENT '所属家庭成员', `account_id` BIGINT NOT NULL COMMENT '关联账户', `category_id` BIGINT NOT NULL COMMENT '收支分类', `tx_type` TINYINT NOT NULL COMMENT '1收入 2支出', `amount` BIGINT NOT NULL COMMENT '金额,单位分,恒为正数', `biz_no` VARCHAR(64) NOT NULL COMMENT '客户端生成的幂等号', `note` VARCHAR(255) DEFAULT NULL COMMENT '备注', `tx_time` DATETIME NOT NULL COMMENT '业务发生时间,允许补录', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_biz_no` (`user_id`, `biz_no`), KEY `idx_account_time` (`account_id`, `tx_time`), KEY `idx_category_time` (`category_id`, `tx_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收支流水表';注意tx_time和created_at是两个字段:一个是这笔钱实际发生的时间(比如昨天晚饭),一个是这笔数据入库的时间(今天补录)。家庭记账里补录是常态,不能因为入库时间晚就把账记到错误的日子,跨月统计全指望这个字段。
流水表之后的业务表是预算表和预算使用量表。预算表存“我这个月在餐饮上打算花多少钱”,预算使用量表存“已经花了多少钱”。为什么要单独拆一张budget_usage,而不是每次统计流水表?因为流水表会越来越大,预算进度这个数据在首页要高频展示,每次实时 SUM 全表会越来越慢。预算用量在写入流水时同步累加,查询时 O(1) 就能拿到。
CREATE TABLE `budget` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `category_id` BIGINT NOT NULL, `budget_month` VARCHAR(7) NOT NULL COMMENT '格式:2024-11', `amount` BIGINT NOT NULL COMMENT '预算额度,单位分', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_category_month` (`user_id`, `category_id`, `budget_month`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='月度预算表'; CREATE TABLE `budget_usage` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `category_id` BIGINT NOT NULL, `budget_month` VARCHAR(7) NOT NULL COMMENT '格式:2024-11', `used_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '已用金额,单位分', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_category_month` (`user_id`, `category_id`, `budget_month`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预算使用量表';六张表的关系很清楚:用户拥有账户和分类,流水挂在账户和分类上,预算和预算用量按人和分类按月关联。表结构定到这里,业务代码的轮廓已经出来了,下一章直接进记账主流程。
3. 记账主流程实战:@Transactional + 乐观锁,一笔流水只生效一次
记账这个动作看起来只是插入一条流水,实际上它同时改变了三个地方:账户当前余额、流水明细、当月预算用量。只要有一个地方没写成功,账就平不了。很多 Java 面试题会问“事务怎么保证数据一致性”,这个createTransaction方法就是一份可以直接讲的答案;不看八股文,你也能把回滚边界讲清楚。
3.1 记账 Service 的核心代码:幂等检查、余额变更、流水入库
我先给出一个最小可运行的记账方法,所有关键步骤都写了注释。这是整个家庭理财系统里最值得反复读的一段代码,后面的报表和避坑都是从这段代码衍生出来的。
@Service public class TransactionService { @Resource private TransactionMapper txMapper; @Resource private AccountMapper accountMapper; @Resource private BudgetUsageMapper budgetUsageMapper; @Transactional(rollbackFor = Exception.class) public Long createTransaction(CreateTxCommand cmd) { // 1. 幂等检查:同一用户、同一业务号重复提交,直接返回旧流水 Transaction old = txMapper.findByBizNo(cmd.getUserId(), cmd.getBizNo()); if (old != null) { return old.getId(); } // 2. 变更账户余额,乐观锁条件写在 SQL 里 // 收入时 delta 为正,支出时 delta 为负 long delta = cmd.getTxType() == TxType.INCOME.getCode() ? cmd.getAmount() : -cmd.getAmount(); int influence = accountMapper.changeBalance( cmd.getUserId(), cmd.getAccountId(), delta, cmd.getVersion()); if (influence != 1) { throw new IllegalStateException("账户余额更新失败,请刷新后重试"); } // 3. 写入流水,金额一律存分 Transaction tx = Transaction.buildFrom(cmd); txMapper.insert(tx); // 4. 支出时同步累加当月预算用量 if (cmd.getTxType() == TxType.EXPENSE.getCode()) { budgetUsageMapper.increment(cmd.getUserId(), cmd.getCategoryId(), cmd.getTxTime(), cmd.getAmount()); } return tx.getId(); } }这段代码的逻辑顺序是固定的:先幂等检查,再改余额,再写流水,最后累加预算用量。改变余额和写流水必须放在同一个事务里,否则会出现流水写成功但余额没扣,或者余额扣了但流水没落库的情况。预算用量累加同理,不能单独提交。
cmd.getVersion()是从前端传来的账户版本号,提交前先查一次账户拿到当前 version,提交时放在 UPDATE 的条件里。这个参数是乐观锁的关键,稍后在 3.2 里展开。
3.2 余额变更的乐观锁 SQL 和参数说明
账户余额不能直接UPDATE account SET current_balance = current_balance + ?就完事。家庭里经常有多个人同时记账:丈夫在记一笔支出,妻子同时在另一台设备上记一笔收入,后提交的人会把先提交的覆盖掉。解决这个问题我用乐观锁,在 Mapper 里手写 SQL:
@Update(""" UPDATE account SET current_balance = current_balance + #{delta}, version = version + 1 WHERE id = #{accountId} AND user_id = #{userId} AND version = #{version} """) int changeBalance(@Param("userId") Long userId, @Param("accountId") Long accountId, @Param("delta") Long delta, @Param("version") Integer version);这个 UPDATE 的影响行数只有 0 或 1。为 1 说明版本号匹配,余额已经改成最新值;为 0 说明别人在这期间改过这条账户记录,此时直接抛异常让用户刷新重试。
这里有个细节:delta是 Long 类型,正数表示收入,负数表示支出。为什么不传txType和amount两个参数在 SQL 里用CASE WHEN判断?因为把符号判断放在 Java 层,SQL 的可读性更好,排查问题时也更容易直接拿数值去数据库里验证。version字段每次更新加 1,既是乐观锁,也是排查并发冲突的痕迹。
3.3 事务回滚的边界:为什么要显式指定 rollbackFor
很多初学者写@Transactional时不带参数,看起来一切正常,直到有一天记账时抛出了一个IOException,发现余额已经扣了,流水却没了。Spring 的@Transactional默认只对RuntimeException和Error回滚,对 checked 异常是不回滚的。所以我在代码里显式写了rollbackFor = Exception.class,明确告诉 Spring:“只要抛出任何异常,全部回滚”。
还有一个坑和事务相关:同一个 Service 类里,一个方法内部直接调用另一个带@Transactional的方法,事务是不生效的。因为 Spring 的事务是通过 AOP 代理实现的,只有外部调用才经过代理,this调用不经过。我一开始也在这里翻过车,后来养成了习惯:把需要事务的方法单独放到一个 Service 类里,Controller 只调用这个 Service 的 public 方法,绝不跨类调私有方法。
4. 报表统计与预算提醒:聚合 SQL 的可调参数和索引
家庭理财系统做到月底,报表是刚需:这个月餐饮花了多少、交通花了多少、收入来源有哪些、预算还剩多少。报表的核心是两条路:按时间聚合,按分类聚合。这一章讲清楚聚合 SQL 怎么写得既快又准。
4.1 按分类聚合的统计 SQL:月份区间怎么写才不丢数据
我按月、分类两个维度做统计,直接看每个分类每月的支出总和与笔数。这里最容易踩坑的是时间条件:统计 11 月,很多人会写BETWEEN '2024-11-01' AND '2024-11-30',但 11 月 30 日 23:59:59 之后的数据全丢了。正确写法是左闭右开区间:开始时间取当月 1 日 00:00:00,结束时间取下月 1 日 00:00:00。
@Select(""" SELECT DATE_FORMAT(t.tx_time, '%Y-%m') AS month, c.name AS categoryName, SUM(t.amount) AS totalAmount, COUNT(*) AS txCount FROM transaction t JOIN category c ON c.id = t.category_id WHERE t.user_id = #{userId} AND t.tx_type = 2 AND t.tx_time >= #{startTime} AND t.tx_time < #{endTime} GROUP BY month, c.name ORDER BY month DESC, totalAmount DESC """) List<MonthCategoryStat> statsByMonthCategory(@Param("userId") Long userId, @Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime);这段 SQL 里startTime和endTime由 Service 层算好传入,调用方负责保证“开始日期的零点”到“结束日期加一天的零点”。GROUP BY month, c.name把同一月份、同一分类的流水合并成一行,ORDER BY month DESC, totalAmount DESC让最近的月份和最花钱的分类排在最前面。
有个容易忽略的参数是tx_type = 2,这里只统计支出。收入和支出必须分开查,不要混在一张报表里做减法,收入和支出是两套语义,混在一起会让图表很难读。如果你要导出完整流水,把这个条件去掉即可。
4.2 预算超额判断:预计算 vs 实时 SUM
预算超额判断有两种做法,各有适用场景。第一种是实时 SUM,每次查首页时统计当月流水中某个分类的支出总和,代码最简单,但流水表到了几十万行后会越来越慢。第二种是预计算,就是我第 2 章里设计的budget_usage表,在写流水时同步累加用量,查询时只读一个字段。
我推荐第二种。家庭理财系统的记账频率远大于查询频率,把求和负担放在写入时,而不是每次展示时,长期来看收益更大。budget_usage.increment的 SQL 用ON DUPLICATE KEY UPDATE做累加,一条语句搞定“没有记录则插入,有记录则加金额”:
@Update(""" INSERT INTO budget_usage(user_id, category_id, budget_month, used_amount) VALUES (#{userId}, #{categoryId}, #{budgetMonth}, #{amount}) ON DUPLICATE KEY UPDATE used_amount = used_amount + #{amount} """) int increment(@Param("userId") Long userId, @Param("categoryId") Long categoryId, @Param("budgetMonth") String budgetMonth, @Param("amount") Long amount);budgetMonth的格式是2024-11,在 Service 层根据tx_time格式化出来。这个方法的优点是天然防并发:即使同一秒有两笔支出同时插入,InnoDB 的唯一键和行锁会排队,最终累加结果是准确的。预算进度条展示时,拿used_amount / budget.amount算百分比,一次查询搞定。
4.3 索引设计和性能边界:什么时候开始优化
流水表的索引设计有三个:uk_user_biz_no负责幂等去重,idx_account_time负责账户维度的余额对账,idx_category_time负责分类维度的报表聚合。这三个索引覆盖了家庭理财系统 90% 的查询路径。
关于报表查询,有一个玄学题是“为什么加了个 DATE_FORMAT 函数就变慢了”。AND t.tx_time >= #{startTime}这个写法能走索引,是因为条件列没有被函数包裹;如果你改成DATE_FORMAT(t.tx_time, '%Y-%m') = '2024-11',MySQL 没法直接拿这个条件和索引比较,就会放弃索引走全表扫描。性能边界大概在几十万行流水这个量级,超过之后建议再做按月分表,或者把聚合结果缓存到 Redis。对家庭记账这个场景,索引优化到位后基本不用考虑更复杂的方案。
5. 避坑记录:金额精度、跨月对账、越权查询的五个翻车现场
这一章写的五个问题,我在做这类账本项目时都实际翻过车。每条按“现象 → 原因 → 解决”的顺序记录,可以直接对照自己的代码排查。
5.1 double 存金额,月末合计对不上账
现象:记了 30 天的账,每笔金额都是两位小数,月底看账户余额和流水合计差了 0.1 元甚至几毛钱。
原因:Java 的double是二进制浮点数,无法精确表示 0.1、0.2 这类十进制小数。累加 30 次之后误差就显现出来了。把金额直接存成 double,是账本系统最常见的翻车点。
解决:数据库里所有金额字段用BIGINT,单位是“分”,Java 实体对应Long。页面传入的金额是字符串,用BigDecimal转换:
BigDecimal amount = new BigDecimal(amountText); long cents = amount.movePointRight(2).longValueExact();展示层再除以 100 并格式化两位小数。这样无论怎么累加,结果都是精确的。顺手检查一下 alert:如果接口里出现double类型的金额字段,一律改成Long。
5.2 分类删除后报表少了一整类
现象:删掉了“零食”这个分类,上个月的零食支出在报表里全部消失,月度总支出也变少了。
原因:流水表transaction用category_id关联分类表,分类被物理删除后关联行不复存在,JOIN 结果直接把历史流水过滤掉了。账本数据是事实记录,字典表变更是常态,但事实不能跟着字典一起消失。
解决:分类表只做逻辑删除,把status置为 0,不执行 DELETE。更稳妥的做法是给流水表冗余一个分类名称快照字段:
ALTER TABLE transaction ADD COLUMN category_name VARCHAR(30) NULL COMMENT '分类快照,写入流水时冗余';写入流水时把当时的分类名称一并存下来,报表聚合直接按category_name分组,不再依赖 JOIN 分类表。这种方式在账本系统里是标准的,能彻底避免字典表变更造成的报表失真。
5.3 checked 异常导致事务没有回滚
现象:记账方法里调用了外部汇率接口,接口抛出了IOException,程序提示记账失败,但数据库里账户余额已经扣了,流水没过,预算用量也没变。
原因:Spring@Transactional默认只回滚RuntimeException和Error,Exception下除 RuntimeException 外的 checked 异常不在回滚范围内。外部调用抛出的异常五花八门,不统一处理就破坏了事务。
解决:类或者方法上显式指定回滚规则:
@Transactional(rollbackFor = Exception.class)同时在 Service 方法内部把 checked 异常捕获后包装成RuntimeException抛出,确保事务感知到失败。我之前排查过好几次这种问题,最后都发现是这两个细节没做到位。
5.4 越权查询:漏了 user_id 过滤条件
现象:家庭成员 A 登录后,传入另一个用户的账户 id,能查到别人的账单和余额。
原因:SQL 只按主键查询,没有带上user_id条件。账户 id 和流水 id 都是全局自增的,遍历 id 就能摸到别人的数据。家庭理财系统虽然用户量不大,但权限问题不能省。
解决:所有涉及账户、流水、预算的查询,必须同时带user_id条件。以账户查询为例:
@Select("SELECT * FROM account WHERE id = #{accountId} AND user_id = #{userId}") Account findOwnedById(@Param("accountId") Long accountId, @Param("userId") Long userId);我自己的做法是写一个 BaseService,所有查询方法强制要求传入当前登录用户 id,从根上杜绝不带user_id的查询方法。另一个细节是给表加idx_user索引,防止按用户过滤时扫全表。
5.5 this 调用让 @Transactional 变成摆设
现象:在一个 Service 类里,methodA()内部直接调用同类中的@Transactional方法methodB(),结果 methodB 里的多条 SQL 没有在同一个事务里,中途异常后前面的更新没回滚。
原因:Spring 的事务是通过代理对象实现的,外部调用 Controller → Service 时经过代理,事务注解才生效;this.methodB()是直接调用当前对象的方法,绕过了代理层,注解被忽略。
解决:把事务方法独立放到另一个 Service 类,或者注入自身代理:
@Autowired private TransactionService selfProxy;Controller 中注入的是代理对象,事务方法之间互相调用时要经过代理。我现在写代码时干脆规定:带@Transactional的方法只允许被 Controller 或另一个 Service 调用,类内部禁止this调用。血泪经验,省得后面排查到怀疑人生。
6. 进阶技巧:CSV 导出和日终对账,收尾前再补一刀
页面里展示流水和报表还远远不够,做家庭理财系统至少要有一个“导出数据”的出口。 CSV 文件不依赖任何第三方组件,Excel 和 WPS 直接能打开,是我最常用的导出格式。
6.1 用分页流式导出 CSV,不撑爆内存
导出全量账单时不能一次性查全部数据,几十万条流水直接List<Transaction>全量加载,内存压力很大。我用分页查询加流式写入,每批 500 条,边查边写:
public void exportCsv(Long userId, LocalDate startDate, LocalDate endDate, Writer out) throws IOException { out.write('\ufeff'); // BOM,防止 Excel 打开 UTF-8 文件乱码 out.write("日期,分类,类型,金额(元),备注\n"); int pageNo = 1; int pageSize = 500; while (true) { List<Transaction> list = txMapper.pageByTime( userId, startDate.atStartOfDay(), endDate.plusDays(1).atStartOfDay(), pageNo, pageSize); if (list == null || list.isEmpty()) { break; } for (Transaction t : list) { out.write(String.format("%s,%s,%s,%.2f,%s%n", t.getTxTime().toLocalDate(), t.getCategoryName(), t.getTxType() == 1 ? "收入" : "支出", t.getAmount() / 100.0, t.getNote() == null ? "" : t.getNote())); } if (list.size() < pageSize) { break; } pageNo++; } out.flush(); }两个细节值得注意:文件开头写入\ufeff,能避免 Excel 打开 UTF-8 编码文件时中文乱码;金额用t.getAmount() / 100.0转成元再格式化,避免导出后显示成一大串数字。备注为空时输出空字符串,不要输出null,否则表格里会莫名多出几列。
6.2 日终对账:让系统自己校验账平不平
对账是家庭理财系统最后一环:账户的current_balance必须等于“期初余额 + 所有收入 - 所有支出”。对账逻辑不复杂,难的是坚持每天跑。我把它做成了一个单独的调度任务:
@Select(""" SELECT account_id, SUM(CASE WHEN tx_type = 1 THEN amount ELSE -amount END) AS delta FROM transaction WHERE user_id = #{userId} AND tx_time >= #{startTime} AND tx_time < #{endTime} GROUP BY account_id """) List<AccountDelta> sumDeltaByAccount(@Param("userId") Long userId, @Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime);拿到每个账户的 delta 后,用“期初余额 + delta”和current_balance做比对,不一致的账户进入异常列表。我现在的习惯是每个月月底把 CSV 导出来做一次人工对账,这个习惯救过我很多次,有一次是信用卡分期入账重复,有一次是退款单被记成了支出。把对账做成系统里的一个固定按钮,比用脑子记靠谱得多。
这套方案从表结构到记账事务,再到报表和对账,逻辑是通的。你按这个思路去写,跑通只是时间问题;按自己的使用习惯去调分类和预算逻辑,这个项目才能真正落地。希望帮到你。
本文还有配套的精品资源,点击获取