简介:这是一份面向计算机专业本科生的毕业设计级家庭财务管理系统完整交付包,基于SpringBoot框架构建,解决个人或家庭日常收支记录、统计与可视化管理需求,适合作为课程设计、毕设选题及Java全栈开发能力训练项目。压缩包共807个文件,涵盖300余张界面GIF动图、108个前端JS交互脚本、89个XML配置与映射文件、85个编译后Class字节码、56个HTML页面模板、28个CSS样式文件及核心SQL建表语句等,完整呈现前后端分离架构下的开发成果,总大小22.64MB。已有1186人学习下载,资源包含可直接运行的IDEA工程(含MySQL数据库脚本、Tomcat9部署配置)、E-R图与数据流图等设计文档、以及结构清晰的论文.docx——从需求分析、系统设计到测试结论全部覆盖,特别适合需要快速上手、理解MVC分层实现与财务类业务逻辑的学生开发者。
1. 项目概述:为什么我们需要一个家庭财务管理系统?
最近几年,我身边越来越多的朋友开始跟我聊起“钱”的事儿。不是聊怎么赚大钱,而是聊钱都花哪儿去了。每个月工资一到手,感觉还没怎么花,月底一看余额就所剩无几。信用卡账单、花呗、各种会员订阅,钱像流水一样悄无声息地溜走。这种对财务状况的“失控感”,我相信很多人都有。你可能试过用Excel记账,但坚持不了几天就放弃了;也用过一些手机App,但总觉得功能要么太简单,要么太复杂,数据还不在自己手里,不太放心。
这正是我决定动手开发一个“家庭财务管理系统”的初衷。它不是一个简单的记账工具,而是一个集收入、支出、预算、资产、负债、报表分析于一体的综合性管理平台。更重要的是,它基于SpringBoot开发,部署在自己信任的服务器上,数据完全私有,功能可以按自家需求深度定制。想象一下,你能清晰地看到家庭资产的健康度,能预测下个月的现金流,能为孩子的教育基金或一次长途旅行制定科学的储蓄计划,这种掌控感,是任何现成软件都给不了的。
这个系统,适合所有对家庭财务状况有梳理、规划和优化需求的家庭。无论你是技术爱好者想自己部署一套,还是Java开发者想学习一个完整的SpringBoot实战项目,它都能提供从技术到业务层面的完整参考。接下来,我就把这个项目的设计思路、技术实现细节以及我踩过的坑,毫无保留地分享给你。
2. 系统核心功能与架构设计
2.1 功能模块全景图
一个完整的家庭财务管理系统,远不止“记一笔账”那么简单。它需要围绕“财务全景”来构建。我将核心功能拆解为以下六大模块:
- 账户与流水管理:这是系统的基石。你需要管理各类资金账户,如现金、储蓄卡、信用卡、支付宝、微信钱包、投资账户等。每一笔资金的流入(工资、理财收益、报销)和流出(消费、还款、投资)都需要被清晰记录,并关联到具体的账户,实时计算账户余额。
- 预算与控制:预算是财务健康的“方向盘”。系统支持按周期(月、年)、按类别(餐饮、交通、娱乐)设置预算。在记录支出时,能实时对比预算执行情况,当支出接近或超出预算时,给出预警。
- 资产与负债总览:这是你的“资产负债表”。资产端包括所有账户的现金总额、投资的市值、房产车辆的估值(需手动更新);负债端包括房贷、车贷、信用卡欠款等。系统自动计算家庭净资产(资产-负债),让你对家庭财富有整体认知。
- 统计分析与报表:数据只有被分析才有价值。系统需要提供多维度报表:月度收支趋势图、消费类别占比饼图、账户余额变化曲线、预算与实际对比柱状图等。可视化报表能直观揭示消费习惯和财务问题。
- 多成员与权限:家庭财务往往是夫妻共同管理。系统支持创建家庭成员账号,并分配权限。例如,可以设置只有“管理员”才能修改预算或删除流水,而“普通成员”只能查看和记录流水。
- 数据备份与安全:财务数据极其敏感。系统必须提供数据导出(Excel、CSV)功能,并鼓励定期备份。同时,从登录认证到数据传输存储,都需要有基本的安全考量。
2.2 技术架构选型:为什么是SpringBoot?
面对这样一个前后端交互复杂、业务逻辑清晰的中小型项目,SpringBoot几乎是毋庸置疑的最优解。它解决了传统Spring项目“配置地狱”的问题,让我们能专注于业务开发。
后端(Backend):
- 核心框架:SpringBoot 2.7.x(选择此版本是因为它长期支持且生态稳定,避免了最新版可能存在的未知坑)。它整合了Spring MVC、Spring Data JPA、Spring Security等,开箱即用。
- 数据持久层:Spring Data JPA + Hibernate。为什么不用MyBatis?对于家庭财务这种领域模型相对固定的系统,JPA的ORM能力能极大提升开发效率,基础的CRUD几乎不用写SQL。复杂的统计查询,我们通过JPA的
@Query注解写JPQL或原生SQL也能轻松应对。 - 数据库:MySQL 8.0。关系型数据库对交易流水、账户关系这类结构化数据的管理得天独厚,事务支持也完善。考虑到数据量不会特别大,MySQL完全够用,且运维简单。
- 安全框架:Spring Security。用它来处理用户登录、注销、权限验证(如区分管理员和成员)再合适不过。结合JWT(JSON Web Token)实现无状态的API认证,便于后续扩展移动端。
- 缓存:Spring Boot Cache + Redis。将一些频繁访问且变化不大的数据缓存起来,比如用户信息、枚举类字典(消费分类)、首页的聚合统计结果,能显著减轻数据库压力,提升响应速度。
- 任务调度:Spring Scheduler。用于执行一些定时任务,比如每月1号自动生成上月的财务报告摘要并邮件推送,或者每天凌晨核对账户余额是否与流水总和匹配。
前端(Frontend):
- 考虑到这是一个个人或家庭内部使用的系统,对界面美观度和跨平台性有要求,但不需要极致的用户体验。我选择了Vue 3 + Element Plus的组合。Vue框架上手快,组件化开发效率高;Element Plus提供了丰富的桌面端UI组件,能快速搭建出清晰、易用的管理后台界面。前后端通过RESTful API进行分离,部署灵活。
部署与运维:
- 传统部署可以直接打可执行JAR包,用
java -jar命令运行。 - 更推荐使用Docker容器化部署。将应用、MySQL、Redis都容器化,通过
docker-compose.yml一键编排启动,环境隔离,迁移和备份都非常方便。这也是现代应用部署的最佳实践。
- 传统部署可以直接打可执行JAR包,用
这个技术栈组合,在开发效率、性能、可维护性和学习成本之间取得了很好的平衡。下面,我们就深入核心模块,看看具体是怎么实现的。
3. 核心数据库设计与领域模型
数据库设计是系统的骨架,设计得好,后续开发事半功倍;设计得差,则举步维艰。我的核心设计围绕以下几个实体展开。
3.1 核心表结构解析
用户表 (sys_user):
CREATE TABLE `sys_user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录用户名', `password` varchar(100) NOT NULL COMMENT '加密后的密码', `nickname` varchar(50) DEFAULT NULL COMMENT '用户昵称', `family_id` bigint DEFAULT NULL COMMENT '所属家庭ID', `role` varchar(20) DEFAULT 'MEMBER' COMMENT '角色:ADMIN-家长/管理员, MEMBER-成员', `email` varchar(100) DEFAULT NULL, `avatar` varchar(255) DEFAULT NULL COMMENT '头像', `status` tinyint DEFAULT '1' COMMENT '状态:0-禁用,1-正常', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB COMMENT='系统用户表';设计心得:将
family_id作为外键,是实现“多家庭”隔离的基础。一个家庭一个family_id,所有财务数据都通过这个ID进行过滤,实现数据隔离。role字段用于简单的权限控制。账户表 (fin_account):
CREATE TABLE `fin_account` ( `id` bigint NOT NULL AUTO_INCREMENT, `family_id` bigint NOT NULL, `name` varchar(50) NOT NULL COMMENT '账户名称,如“招商银行工资卡”', `type` varchar(20) NOT NULL COMMENT '账户类型:CASH-现金, DEBIT_CARD-借记卡, CREDIT_CARD-信用卡, DIGITAL-数字钱包(支付宝/微信), INVESTMENT-投资账户, OTHER-其他', `balance` decimal(15,2) NOT NULL DEFAULT '0.00' COMMENT '当前余额', `initial_balance` decimal(15,2) DEFAULT '0.00' COMMENT '初始余额', `currency` varchar(10) DEFAULT 'CNY' COMMENT '货币', `include_in_total` tinyint DEFAULT '1' COMMENT '是否计入总资产:0-否,1-是', `remarks` varchar(255) DEFAULT NULL COMMENT '备注', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_family` (`family_id`) ) ENGINE=InnoDB COMMENT='资金账户表';踩坑记录:
balance(余额)字段的更新是核心也是易错点。绝对不能在应用层简单地newBalance = oldBalance + amount然后更新!在高并发下(虽然家庭系统并发低,但好习惯要养成)会产生更新丢失。正确做法是:在记录流水(fin_transaction)时,使用一条SQL原子性地更新账户余额:UPDATE fin_account SET balance = balance + ? WHERE id = ?。include_in_total字段很实用,比如你可能有某个不常用的银行卡,不想让它影响总资产视图,就可以将其排除。交易流水表 (fin_transaction)—— 这是最核心的表:
CREATE TABLE `fin_transaction` ( `id` bigint NOT NULL AUTO_INCREMENT, `family_id` bigint NOT NULL, `transaction_time` datetime NOT NULL COMMENT '交易时间', `type` varchar(10) NOT NULL COMMENT '交易类型:INCOME-收入, EXPENSE-支出, TRANSFER-转账', `amount` decimal(15,2) NOT NULL COMMENT '交易金额(永远为正数)', `category_id` bigint NOT NULL COMMENT '分类ID', `from_account_id` bigint DEFAULT NULL COMMENT '支出/转出账户ID', `to_account_id` bigint DEFAULT NULL COMMENT '收入/转入账户ID', `description` varchar(500) DEFAULT NULL COMMENT '描述/备注', `tags` varchar(255) DEFAULT NULL COMMENT '标签,多个用逗号分隔,如“聚餐,朋友”', `attachment` varchar(255) DEFAULT NULL COMMENT '附件(如发票照片)路径', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_family_time` (`family_id`,`transaction_time`), KEY `idx_account` (`from_account_id`), KEY `idx_category` (`category_id`) ) ENGINE=InnoDB COMMENT='交易流水表';核心逻辑解析:
amount字段永远存储正数。这样在统计和逻辑判断时更清晰。类型(type)和账户ID共同决定资金流向。- 收入:
type='INCOME',to_account_id不为空(钱进入哪个账户),from_account_id为空。 - 支出:
type='EXPENSE',from_account_id不为空(钱从哪个账户出),to_account_id为空。 - 转账:
type='TRANSFER',from_account_id和to_account_id都不为空。这里有一个关键点:转账本质上是一笔支出和一笔收入的组合,但为了不重复计算,在统计净收支时,转账交易应该被排除。我们只在报表中展示“转账”这个特殊类型,或者将其过滤掉。 tags字段用于灵活标记,比固定分类更细粒度,方便后期筛选,如“双十一”、“医疗报销”等。
分类表 (fin_category)和预算表 (fin_budget): 分类表是树形结构,支持多级分类(如:支出-餐饮-早餐)。预算表则关联
family_id、category_id、period(月度/年度)和amount(预算金额)。
3.2 JPA实体映射与关联关系
在Spring Data JPA中,我们将上述表结构映射为实体类。这里以Transaction(交易流水)实体为例,展示如何设计关联关系。
@Entity @Table(name = "fin_transaction") @Data @EqualsAndHashCode(callSuper = false) public class Transaction extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private Long familyId; // 家庭ID,用于数据隔离 @Column(nullable = false) private LocalDateTime transactionTime; // 交易时间 @Enumerated(EnumType.STRING) @Column(nullable = false, length = 10) private TransactionType type; // 枚举:INCOME, EXPENSE, TRANSFER @Column(nullable = false, precision = 15, scale = 2) private BigDecimal amount; @ManyToOne(fetch = FetchType.LAZY) // 多对一,懒加载 @JoinColumn(name = "category_id", nullable = false) private Category category; // 关联分类 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "from_account_id") private Account fromAccount; // 关联转出账户 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "to_account_id") private Account toAccount; // 关联转入账户 private String description; private String tags; // 业务逻辑方法:获取交易对手账户(用于前端显示) public Account getCounterAccount() { if (type == TransactionType.INCOME) { return toAccount; } else if (type == TransactionType.EXPENSE) { return fromAccount; } else { // TRANSFER // 转账时,通常显示“从A转到B” return null; // 或者根据需要返回一个包含双方信息的对象 } } }注意事项:所有关联关系都使用
FetchType.LAZY(懒加载)。这是JPA性能优化的黄金法则。只有在需要用到关联对象数据时(比如在Thymeleaf模板或Jackson序列化时),才会触发额外的查询。避免在查询列表时一次性拉取所有关联数据导致“N+1查询问题”。对于familyId这种用于过滤的字段,我直接使用了基本类型而非关联,因为它的查询频率极高,且不需要Family实体的其他信息,这样效率更高。
4. 关键业务逻辑与Service层实现
领域模型建立好后,核心业务逻辑集中在Service层。这里我挑两个最复杂也最重要的服务来讲:交易记录服务和报表统计服务。
4.1 交易记录服务:确保数据一致性
记录一笔交易,不仅仅是向fin_transaction表插入一条数据那么简单。它必须是一个原子性操作,至少包含以下步骤:
- 验证参数(账户是否存在、余额是否充足等)。
- 在
fin_transaction表中插入流水记录。 - 更新相关账户的
balance字段。
这必须在一个数据库事务中完成,否则可能出现流水记了但余额没变,或者反之的脏数据。Spring的@Transactional注解完美解决了这个问题。
@Service @Slf4j public class TransactionService { @Autowired private TransactionRepository transactionRepo; @Autowired private AccountRepository accountRepo; @Autowired private PlatformTransactionManager transactionManager; /** * 记录一笔支出 * @param dto 前端传入的数据传输对象 */ @Transactional(rollbackFor = Exception.class) // 声明式事务管理 public void recordExpense(TransactionDTO dto) { // 1. 参数校验 Account fromAccount = accountRepo.findByIdAndFamilyId(dto.getFromAccountId(), getCurrentFamilyId()) .orElseThrow(() -> new BusinessException("支出账户不存在或无权访问")); if (fromAccount.getBalance().compareTo(dto.getAmount()) < 0) { throw new BusinessException("账户余额不足"); } // 2. 创建流水实体 Transaction transaction = new Transaction(); // ... 属性填充 (使用MapStruct或BeanUtils进行DTO->Entity转换更好) transaction.setType(TransactionType.EXPENSE); transaction.setAmount(dto.getAmount()); transaction.setFromAccount(fromAccount); // ... 设置其他字段 // 3. 保存流水(此时还未提交事务) transactionRepo.save(transaction); // 4. 原子性更新账户余额(使用乐观锁或直接计算) int updatedRows = accountRepo.decreaseBalance(fromAccount.getId(), dto.getAmount()); if (updatedRows != 1) { // 更新失败,抛出异常触发回滚 throw new RuntimeException("更新账户余额失败,可能并发修改"); } // 5. 事务提交,所有操作生效 log.info("记录支出成功,流水ID: {}", transaction.getId()); } /** * 记录一笔转账(涉及两个账户) */ @Transactional(rollbackFor = Exception.class) public void recordTransfer(TransactionDTO dto) { // 校验转出账户和转入账户 Account fromAcc = accountRepo.findByIdAndFamilyId(dto.getFromAccountId(), getCurrentFamilyId())...; Account toAcc = accountRepo.findByIdAndFamilyId(dto.getToAccountId(), getCurrentFamilyId())...; if (fromAcc.getId().equals(toAcc.getId())) { throw new BusinessException("转出账户和转入账户不能相同"); } if (fromAcc.getBalance().compareTo(dto.getAmount()) < 0) { throw new BusinessException("转出账户余额不足"); } // 创建转账流水 Transaction transaction = new Transaction(); transaction.setType(TransactionType.TRANSFER); transaction.setAmount(dto.getAmount()); transaction.setFromAccount(fromAcc); transaction.setToAccount(toAcc); // ... transactionRepo.save(transaction); // 关键:同时更新两个账户的余额 int outUpdated = accountRepo.decreaseBalance(fromAcc.getId(), dto.getAmount()); int inUpdated = accountRepo.increaseBalance(toAcc.getId(), dto.getAmount()); if (outUpdated != 1 || inUpdated != 1) { throw new RuntimeException("转账过程中更新账户余额失败"); } log.info("转账成功,从[{}]到[{}],金额: {}", fromAcc.getName(), toAcc.getName(), dto.getAmount()); } }在AccountRepository中,我们定义原子更新的方法:
@Repository public interface AccountRepository extends JpaRepository<Account, Long> { @Modifying @Query("UPDATE Account a SET a.balance = a.balance - :amount WHERE a.id = :id AND a.familyId = :familyId") int decreaseBalance(@Param("id") Long id, @Param("amount") BigDecimal amount, @Param("familyId") Long familyId); @Modifying @Query("UPDATE Account a SET a.balance = a.balance + :amount WHERE a.id = :id AND a.familyId = :familyId") int increaseBalance(@Param("id") Long id, @Param("amount") BigDecimal amount, @Param("familyId") Long familyId); }实操心得:这里我使用了
@Query配合@Modifying来执行更新操作,并返回受影响的行数。在Service层判断返回值是否为1,是一种简单的乐观锁机制,可以防止在极少数并发情况下的数据不一致。虽然家庭系统并发极低,但养成这样的编程习惯对开发大型系统至关重要。
4.2 报表统计服务:复杂查询与性能优化
报表统计是系统的价值所在,但也是最容易产生性能瓶颈的地方。例如,“统计本年度每个月的收支情况”这个需求,如果处理不当,会对数据库造成巨大压力。
错误做法:在Java代码中循环查询每个月的数据。正确做法:尽量利用数据库的聚合计算能力,一条SQL搞定。
@Service public class ReportService { @Autowired private TransactionRepository transactionRepo; /** * 获取月度收支趋势数据(用于折线图) * @param year 年份 * @return 列表,包含每个月收入和支出的总和 */ public List<MonthlyTrendVO> getMonthlyTrend(int year, Long familyId) { // 使用原生SQL进行高效聚合查询 String sql = """ SELECT DATE_FORMAT(t.transaction_time, '%Y-%m') as month, SUM(CASE WHEN t.type = 'INCOME' THEN t.amount ELSE 0 END) as totalIncome, SUM(CASE WHEN t.type = 'EXPENSE' THEN t.amount ELSE 0 END) as totalExpense FROM fin_transaction t WHERE t.family_id = :familyId AND YEAR(t.transaction_time) = :year AND t.type IN ('INCOME', 'EXPENSE') -- 排除转账 GROUP BY DATE_FORMAT(t.transaction_time, '%Y-%m') ORDER BY month """; Query query = entityManager.createNativeQuery(sql); query.setParameter("familyId", familyId); query.setParameter("year", year); List<Object[]> resultList = query.getResultList(); List<MonthlyTrendVO> list = new ArrayList<>(); for (Object[] row : resultList) { MonthlyTrendVO vo = new MonthlyTrendVO(); vo.setMonth((String) row[0]); vo.setTotalIncome((BigDecimal) row[1]); vo.setTotalExpense((BigDecimal) row[2]); vo.setSurplus(vo.getTotalIncome().subtract(vo.getTotalExpense())); // 计算结余 list.add(vo); } // 补全没有数据的月份(数据为0) return fillMissingMonths(list, year); } /** * 获取消费类别占比(用于饼图) * 注意:这里只统计支出(EXPENSE) */ public List<CategoryStatVO> getCategoryStat(LocalDate startDate, LocalDate endDate, Long familyId) { // 使用JPQL,关联Category实体,更面向对象 String jpql = """ SELECT new com.yourproject.vo.CategoryStatVO( c.name, SUM(t.amount) ) FROM Transaction t JOIN t.category c WHERE t.familyId = :familyId AND t.type = 'EXPENSE' AND t.transactionTime BETWEEN :start AND :end GROUP BY c.id, c.name ORDER BY SUM(t.amount) DESC """; // ... 执行查询并返回 } }性能优化技巧:
- 为查询字段建立索引:
fin_transaction表上的(family_id, transaction_time)联合索引对按时间范围筛选的查询至关重要。(family_id, category_id)索引则对按分类统计有帮助。- 缓存聚合结果:首页的月度趋势、年度汇总等数据,变化频率不高(每天最多变一次)。我们可以使用Spring Cache,将
getMonthlyTrend方法的结果缓存到Redis中,设置TTL为12小时或24小时。这样能极大提升首页加载速度。- 分页查询:流水列表查询一定要支持分页,避免一次性拉取成千上万条记录。
- 使用DTO/VO投影:在JPA查询中,直接返回自定义的DTO或VO(通过
new com.example.XXXVO(...)),而不是返回完整的Transaction实体及其懒加载关联,能减少不必要的SQL查询和数据传输量。
5. 前端交互与API设计要点
前后端分离架构下,清晰、安全的API设计是协作的桥梁。我采用RESTful风格设计API,并使用JWT进行认证。
5.1 RESTful API设计示例
@RestController @RequestMapping("/api/transactions") public class TransactionController { @Autowired private TransactionService transactionService; // 获取流水列表(分页、筛选) @GetMapping public PageResult<TransactionVO> getTransactions( @RequestParam(required = false) LocalDate startDate, @RequestParam(required = false) LocalDate endDate, @RequestParam(required = false) TransactionType type, @RequestParam(required = false) Long accountId, @RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "20") Integer size) { // 构建查询条件,调用Service Pageable pageable = PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, "transactionTime")); Page<Transaction> transactionPage = transactionService.findByCriteria(..., pageable); // 转换为VO并返回 return PageResult.success(transactionPage.map(this::convertToVO)); } // 记录一笔支出 @PostMapping("/expense") public Result<Void> recordExpense(@Valid @RequestBody ExpenseDTO dto) { // @Valid 注解会自动进行参数校验(依靠DTO上的@NotNull, @Min等注解) transactionService.recordExpense(dto); return Result.success(); } // 记录一笔收入 @PostMapping("/income") public Result<Void> recordIncome(@Valid @RequestBody IncomeDTO dto) { ... } // 记录一笔转账 @PostMapping("/transfer") public Result<Void> recordTransfer(@Valid @RequestBody TransferDTO dto) { ... } // 删除一笔流水(需要谨慎,通常建议做逻辑删除) @DeleteMapping("/{id}") public Result<Void> deleteTransaction(@PathVariable Long id) { transactionService.deleteById(id); return Result.success(); } }设计规范:
- 统一的返回结构:所有API返回
Result<T>对象,包含code、message、data字段。成功时code=200,失败时有明确的错误码。- 使用DTO进行数据传输:
ExpenseDTO、IncomeDTO等与前端表单对应,只包含必要的字段,与数据库实体Transaction解耦。- 参数校验:在DTO字段上使用JSR-303注解(如
@NotNull、@DecimalMin("0.01"))进行声明式校验,结合@Valid注解在Controller层自动触发。- 分页标准化:列表查询统一使用
page和size参数,返回统一的分页结果对象PageResult。
5.2 前端Vue组件关键实现
前端使用Vue 3的Composition API和Element Plus。以“记录支出”的组件为例:
<template> <el-dialog title="记录支出" v-model="dialogVisible"> <el-form :model="form" :rules="rules" ref="formRef" label-width="80px"> <el-form-item label="账户" prop="fromAccountId"> <el-select v-model="form.fromAccountId" placeholder="请选择支出账户"> <el-option v-for="acc in accountList" :key="acc.id" :label="`${acc.name} (余额: ${acc.balance})`" :value="acc.id" /> </el-select> </el-form-item> <el-form-item label="分类" prop="categoryId"> <el-cascader v-model="form.categoryId" :options="categoryTree" :props="{ label: 'name', value: 'id', checkStrictly: true }" placeholder="请选择消费分类" /> </el-form-item> <el-form-item label="金额" prop="amount"> <el-input-number v-model="form.amount" :min="0.01" :precision="2" controls-position="right"/> </el-form-item> <el-form-item label="时间" prop="transactionTime"> <el-date-picker v-model="form.transactionTime" type="datetime" placeholder="选择日期时间" value-format="YYYY-MM-DD HH:mm:ss" /> </el-form-item> <el-form-item label="描述" prop="description"> <el-input v-model="form.description" type="textarea" :rows="2"/> </el-form-item> </el-form> <template #footer> <el-button @click="dialogVisible = false">取消</el-button> <el-button type="primary" @click="submitForm" :loading="submitting">确认</el-button> </template> </el-dialog> </template> <script setup> import { ref, reactive, computed } from 'vue'; import { ElMessage } from 'element-plus'; import { recordExpenseApi } from '@/api/transaction'; const props = defineProps({ // 传入账户列表和分类树数据 accountList: Array, categoryTree: Array }); const dialogVisible = ref(false); const submitting = ref(false); const formRef = ref(); const form = reactive({ fromAccountId: null, categoryId: null, amount: null, transactionTime: new Date(), // 默认当前时间 description: '' }); const rules = { fromAccountId: [{ required: true, message: '请选择支出账户', trigger: 'blur' }], categoryId: [{ required: true, message: '请选择分类', trigger: 'blur' }], amount: [ { required: true, message: '请输入金额', trigger: 'blur' }, { type: 'number', min: 0.01, message: '金额必须大于0', trigger: 'blur' } ] }; const submitForm = async () => { if (!formRef.value) return; const valid = await formRef.value.validate(); if (!valid) return; submitting.value = true; try { await recordExpenseApi(form); ElMessage.success('记录成功!'); dialogVisible.value = false; emit('success'); // 通知父组件刷新列表 resetForm(); } catch (error) { ElMessage.error(error.message || '记录失败'); } finally { submitting.value = false; } }; // 打开对话框的方法 const open = () => { dialogVisible.value = true; }; // 暴露方法给父组件 defineExpose({ open }); </script>前端开发心得:
- 表单校验是用户体验的关键:前端校验(Element Plus表单规则)可以快速给出反馈,但后端校验(Spring
@Valid)是数据安全的最后防线,两者缺一不可。- 状态管理:对于账户列表、分类树这种全局通用的数据,可以使用Pinia(Vue的状态管理库)进行集中管理和缓存,避免在不同组件中重复请求。
- 日期时间处理:前后端传递时间时,统一使用ISO 8601格式字符串(如
"2023-10-27T14:30:00")或时间戳。后端实体使用LocalDateTime,前端使用dayjs或moment库处理,能省去很多时区转换的麻烦。- 文件上传:如果支持上传发票附件,建议使用分片上传,并提供进度提示。后端接口接收
MultipartFile,保存到本地目录或对象存储(如MinIO),在数据库中只保存文件路径。
6. 部署、安全与后期维护实战
6.1 使用Docker Compose一键部署
将整个系统容器化部署,是保证环境一致性和简化运维的最佳方式。下面是一个简化的docker-compose.yml文件:
version: '3.8' services: mysql: image: mysql:8.0 container_name: family-finance-mysql environment: MYSQL_ROOT_PASSWORD: your_strong_root_password MYSQL_DATABASE: family_finance MYSQL_USER: ff_user MYSQL_PASSWORD: your_strong_user_password volumes: - ./mysql-data:/var/lib/mysql # 数据持久化 - ./init.sql:/docker-entrypoint-initdb.d/init.sql # 初始化脚本(可选) ports: - "3306:3306" networks: - ff-network restart: unless-stopped redis: image: redis:7-alpine container_name: family-finance-redis ports: - "6379:6379" volumes: - ./redis-data:/data networks: - ff-network restart: unless-stopped backend: build: ./backend # Dockerfile所在目录 container_name: family-finance-backend depends_on: - mysql - redis environment: - SPRING_PROFILES_ACTIVE=docker # 使用docker profile的配置 - DB_HOST=mysql - DB_PORT=3306 - REDIS_HOST=redis ports: - "8080:8080" networks: - ff-network restart: unless-stopped frontend: build: ./frontend # Dockerfile所在目录 container_name: family-finance-frontend depends_on: - backend ports: - "80:80" networks: - ff-network restart: unless-stopped networks: ff-network: driver: bridge在backend目录的application-docker.yml配置文件中,数据库和Redis的连接地址就使用服务名(mysql,redis):
spring: datasource: url: jdbc:mysql://mysql:3306/family_finance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: ff_user password: your_strong_user_password redis: host: redis port: 6379部署步骤:
- 在服务器上安装Docker和Docker Compose。
- 将项目代码(包含前后端Dockerfile和docker-compose.yml)上传至服务器。
- 执行
docker-compose up -d,所有服务就会在后台启动。- 通过
docker-compose logs -f backend查看后端日志,排查启动问题。
6.2 安全加固措施清单
家庭财务数据无小事,即使系统不大,安全底线也必须守住。
密码安全:
- 用户密码在数据库里绝对不能明文存储!使用Spring Security的
BCryptPasswordEncoder进行强哈希加密。 - 强制要求密码复杂度(至少8位,包含字母和数字)。
- 用户密码在数据库里绝对不能明文存储!使用Spring Security的
API安全:
- 所有API(登录注册除外)都必须通过JWT认证。在Spring Security配置中拦截请求。
- 使用HTTPS协议部署,防止数据在传输中被窃听。
- 对敏感操作(如删除流水、修改账户)可考虑增加二次确认或操作密码。
SQL注入与XSS防护:
- 使用JPA或MyBatis的参数化查询,绝对不要用字符串拼接SQL。
- 前端对用户输入进行转义,后端在输出到HTML前也进行转义(Thymeleaf默认已做)。如果前端是Vue/React,它们的数据绑定也默认有XSS防护。
数据备份:
- 定期(如每周)使用
mysqldump命令备份数据库。 - 将备份文件同步到其他存储(如另一台服务器、云存储)。
- 可以在
docker-compose.yml中增加一个cron服务容器,定时执行备份脚本。
- 定期(如每周)使用
6.3 常见问题排查与优化记录
在开发和维护过程中,我遇到并解决了一些典型问题,这里分享给你:
问题:首页加载缓慢,特别是打开年度报表时。
- 排查:打开数据库慢查询日志,发现统计年度收支的SQL虽然用了索引,但因为数据量积累(几年流水),
GROUP BY和SUM操作依然耗时。 - 解决:
- 缓存:为
ReportService.getMonthlyTrend等方法添加@Cacheable注解,将结果缓存到Redis,有效期设为1天。首页加载速度从2秒提升到200毫秒内。 - 预聚合:对于更复杂的统计,可以考虑在夜间通过定时任务,将聚合结果计算好存入一张
statistics_summary表,前端直接查汇总表。
- 缓存:为
- 排查:打开数据库慢查询日志,发现统计年度收支的SQL虽然用了索引,但因为数据量积累(几年流水),
问题:偶尔出现“账户余额不准”的情况。
- 排查:检查日志,发现是在记录一笔交易时,系统异常导致事务回滚,但前端已经提示用户“记录成功”(因为捕获异常不彻底)。
- 解决:
- 事务边界清晰:确保
@Transactional注解覆盖所有数据库写操作,并且rollbackFor = Exception.class。 - 前端状态同步:后端操作成功后,向前端返回明确的成功信号。前端在收到信号后,再更新本地状态(如刷新账户列表)。对于关键操作,可以提供一个“刷新数据”的按钮。
- 定期对账:编写一个定时任务,每天凌晨计算每个账户的“期初余额 + 所有流入 - 所有流出”,并与当前
balance字段对比。如果不一致,记录错误日志并报警(发邮件给自己)。这是一个最终一致性保障。
- 事务边界清晰:确保
问题:分类管理混乱,用户自己创建了很多重复或无效的分类。
- 解决:提供一套系统默认分类模板(如“食品酒水”、“居家物业”、“交通出行”等)。用户首次使用时,可以选择导入默认模板。同时,在用户删除某个已有流水的分类时,给予提示,并提供“合并到其他分类”的功能,避免数据断裂。
这个项目从构思到上线,我断断续续花了近两个月的时间。最大的体会是,开发一个给自己用的系统,驱动力和细心程度会完全不同。你会不自觉地思考更多细节:怎么录入最快?报表怎么看最直观?数据错了怎么方便地修改?这些思考最终都沉淀成了上面的设计和代码。技术本身(SpringBoot, Vue, Docker)只是工具,真正有价值的是你通过它们解决实际问题的过程。如果你正打算管理自己的家庭财务,或者想找一个有完整业务逻辑的SpringBoot项目来练手,希望这篇长文能给你提供一个扎实的起点和清晰的路线图。
本文还有配套的精品资源,点击获取