ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java财务系统实战:MVC分层+HikariCP+BigDecimal精度控制

Java财务系统实战:MVC分层+HikariCP+BigDecimal精度控制 简介本资源是一套完整可用的Java个人财务管理系统源码专为计算机相关专业学生设计适用于毕业设计、课程大作业及项目实践等场景帮助初学者掌握桌面应用开发、数据库交互与MVC结构实现。压缩包共31个文件含4个核心Java源文件如MoneyManager.java、Account.java、10个编译后class文件、9个XML配置文件支撑界面布局与数据映射、4个文本说明文件含账户信息与密码模板整体仅35KB轻量易读结构清晰便于理解与二次开发。已有456人学习下载代码经实测可正常运行功能完整覆盖收支记录、账户管理、数据持久化等核心模块。读者可直接部署运行快速掌握Swing界面开发与文件存储逻辑亦可基于现有结构拓展图表统计、多用户登录或SQLite集成等功能是入门级Java项目实践的理想参考范例。1. 这不是记账App而是一套可调试、可扩展、能进简历的Java财务数据闭环系统很多同学交期末大作业时把“个人财务管理系统”做成带GUI界面的记账本点按钮新增收支、下拉选类别、表格里显示几条记录——功能看似完整但一运行就报NullPointerException改完又出SQLException数据库连不上、日期格式错、余额算不准。其实问题不在代码量而在架构缺位没有分层设计意识DAO硬编码SQLService混着UI逻辑Controller直接操作JDBC连接。这个标题里的“基于Java实现的个人财务管理系统”本质是考察你能否用标准Java EE分层思想Model-View-Controller DAO Service把用户一笔收入/支出从界面输入→参数校验→事务写入→余额实时更新→报表聚合全程可控、可测、可调试。它适合刚学完JDBC、Servlet、MySQL和基础面向对象的同学也适合想补足工程化能力的转行者——因为源码里藏着真实项目才有的细节金额精度用BigDecimal而非double日期用LocalDateTime而非Date事务边界用try-catch-finally手动控制而非依赖框架连数据库连接池都用HikariCP而非DriverManager直连。下面我们就从零还原这套系统最核心的四层结构。2. 用MVC分层HikariCP连接池搭建可运行的最小财务数据骨架2.1 为什么必须分层不写DAO层的后果比你想象的更严重常见错误是把所有逻辑堆在Servlet里用户提交表单后Servlet里直接拼SQL、执行executeUpdate()、再手动计算余额、最后跳转回页面。这种写法导致三个致命问题第一数据库密码明文写在Java文件里打包成jar后一解压就泄露第二每次请求都新建Connection高并发下MySQL报Too many connections第三修改查询条件比如加个时间范围筛选要改5个地方改漏一处就数据不一致。MVC分层不是为了炫技而是让每层只做一件事Controller只管接收参数和跳转Service只管业务规则如“支出不能超余额”DAO只管SQL执行Model只管字段定义。这样改需求时前端改HTML、逻辑改Service、查库改DAO互不影响。提示不要用java.sql.Date处理日期。它已废弃且时区处理混乱。所有时间字段统一用java.time.LocalDateTime数据库对应DATETIME类型插入时用PreparedStatement.setObject(?, localDateTime)避免setTimestamp()引发的时区偏移。2.2 用HikariCP替代DriverManager3步配出生产级连接池HikariCP是当前Java生态最快的连接池比Druid轻量、比DBCP稳定。配置它不是复制粘贴关键参数必须按实际场景调// DataSourceConfig.java public class DataSourceConfig { private static HikariDataSource dataSource; static { HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/finance_db?useSSLfalseserverTimezoneAsia/Shanghai); config.setUsername(root); config.setPassword(your_password); // 生产环境务必从配置文件读取 config.setDriverClassName(com.mysql.cj.jdbc.Driver); // 核心三参数根据你的MySQL最大连接数设 config.setMaximumPoolSize(10); // 不要超过MySQL的max_connections默认151 config.setMinimumIdle(2); // 空闲连接保底数防冷启动慢 config.setConnectionTimeout(30000); // 获取连接超时单位毫秒 // 必加健康检查否则坏连接会卡死线程 config.setConnectionTestQuery(SELECT 1); config.setValidationTimeout(3000); dataSource new HikariDataSource(config); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }2.2.1 参数调优对照表你的机器该设多少参数名推荐值为什么这么设常见误设后果maximumPoolSizeMySQLmax_connections× 0.6预留空间给其他应用或备份任务设为100但MySQL只允许50连接 → 启动失败minimumIdlemaximumPoolSize × 0.2避免突发流量时创建连接耗时设为0 → 首次请求慢300ms以上connectionTimeout10000~30000ms网络抖动时给重试机会设为1000 → 用户点击就报错“连接超时”2.3 Model层设计用BigDecimal存金额用枚举管类型拒绝浮点误差财务系统最怕精度丢失。用double存199.99加减后变成199.99000000000002报表对不上。必须用BigDecimal且构造时用字符串不用double// Transaction.java public class Transaction { private Long id; private BigDecimal amount; // 关键用BigDecimal private TransactionType type; // 枚举非String private LocalDateTime createTime; private String description; // 构造方法强制校验 public Transaction(BigDecimal amount, TransactionType type, String description) { if (amount null || amount.compareTo(BigDecimal.ZERO) 0) { throw new IllegalArgumentException(金额必须大于0); } this.amount amount.setScale(2, RoundingMode.HALF_UP); // 统一保留2位小数 this.type type; this.description description; this.createTime LocalDateTime.now(); } } // TransactionType.java public enum TransactionType { INCOME(收入), EXPENSE(支出); private final String desc; TransactionType(String desc) { this.desc desc; } public String getDesc() { return desc; } }2.3.1 为什么setScale(2, RoundingMode.HALF_UP)不可省略MySQL的DECIMAL(10,2)字段只存2位小数。如果Java端传入new BigDecimal(199.995)不调setScale()直接入库MySQL会截断为199.99非四舍五入导致用户看到“我输199.995系统记成199.99”。HALF_UP是银行标准四舍五入模式199.995→200.00。3. 实现收支核心业务事务控制余额实时计算多条件查询3.1 Service层事务管理手写try-catch-finally比SpringTransactional更可控学生项目常忽略事务。比如用户提交一笔200元支出系统要①扣余额 ②插入支出记录。若①成功②失败用户钱没了但没记录无法追查。必须用事务保证原子性。这里不用Spring用原生JDBC手动控制// TransactionService.java public class TransactionService { public boolean addTransaction(Transaction transaction) { Connection conn null; PreparedStatement ps null; try { conn DataSourceConfig.getConnection(); conn.setAutoCommit(false); // 关键关闭自动提交 // 步骤1更新账户余额先查再更新防超支 BigDecimal currentBalance getCurrentBalance(conn); if (transaction.getType() TransactionType.EXPENSE currentBalance.compareTo(transaction.getAmount()) 0) { throw new RuntimeException(余额不足无法支出); } // 步骤2插入交易记录 String sql INSERT INTO transactions (amount, type, description, create_time) VALUES (?, ?, ?, ?); ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); ps.setBigDecimal(1, transaction.getAmount()); ps.setString(2, transaction.getType().name()); // 存枚举名非desc ps.setString(3, transaction.getDescription()); ps.setObject(4, transaction.getCreateTime()); int rows ps.executeUpdate(); if (rows ! 1) { throw new RuntimeException(交易记录插入失败); } // 步骤3提交事务 conn.commit(); return true; } catch (SQLException e) { // 回滚并打印具体错误方便调试 if (conn ! null) { try { conn.rollback(); } catch (SQLException ignored) {} } System.err.println(事务执行失败: e.getMessage()); return false; } finally { // 必须关闭资源否则连接泄漏 closeResources(ps, conn); } } private void closeResources(PreparedStatement ps, Connection conn) { if (ps ! null) try { ps.close(); } catch (SQLException ignored) {} if (conn ! null) try { conn.close(); } catch (SQLException ignored) {} } }3.1.1 为什么不用Transactional两个现实原因环境限制期末作业通常要求纯JavaServlet不引入Spring框架避免额外配置复杂度教学目的手动写commit()/rollback()能让学生看清事务边界在哪理解ACID中“I隔离性”如何通过锁实现——比如getCurrentBalance()里用SELECT ... FOR UPDATE锁住余额行防止并发支出超支。3.2 DAO层SQL编写用预编译防注入用LIMIT分页防OOM财务数据可能上千条一次性查全量会导致内存溢出。DAO层必须支持分页且SQL用?占位符// TransactionDAO.java public class TransactionDAO { // 分页查询offset从0开始limit是每页数量 public ListTransaction listTransactions(int offset, int limit) { String sql SELECT id, amount, type, description, create_time FROM transactions ORDER BY create_time DESC LIMIT ? OFFSET ?; ListTransaction list new ArrayList(); try (Connection conn DataSourceConfig.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, limit); // 第一个?是limit ps.setInt(2, offset); // 第二个?是offset ResultSet rs ps.executeQuery(); while (rs.next()) { Transaction t new Transaction( rs.getBigDecimal(amount), TransactionType.valueOf(rs.getString(type)), rs.getString(description) ); t.setId(rs.getLong(id)); t.setCreateTime(rs.getObject(create_time, LocalDateTime.class)); list.add(t); } } catch (SQLException e) { e.printStackTrace(); } return list; } // 按类型和时间范围查询典型业务场景 public ListTransaction searchByTypeAndDate(TransactionType type, LocalDateTime start, LocalDateTime end) { String sql SELECT * FROM transactions WHERE type ? AND create_time BETWEEN ? AND ?; // ... 执行逻辑同上注意setObject传LocalDateTime } }3.2.1 分页参数安全校验防恶意offset攻击用户URL传?page1000000size100offset100000000会导致MySQL扫描百万行。在Service层加校验// TransactionService.java public ListTransaction getTransactions(int page, int size) { if (page 1 || size 1 || size 100) { // 单页最多100条 throw new IllegalArgumentException(分页参数非法); } int offset (page - 1) * size; if (offset 100000) { // 总数不超过10万条 throw new IllegalArgumentException(页码超出范围); } return transactionDAO.listTransactions(offset, size); }4. 前端交互与数据验证用Servlet接收表单用JS校验金额格式4.1 Servlet接收与转发用request.setAttribute传递数据拒绝JSON乱码学生常犯错误用response.getWriter().print(json)返回数据结果中文变问号。正确做法是用JSP渲染Servlet只做逻辑// AddTransactionServlet.java WebServlet(/add-transaction) public class AddTransactionServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 获取参数注意getParameter默认ISO-8859-1需转UTF-8 String amountStr new String(request.getParameter(amount).getBytes(ISO-8859-1), UTF-8); String typeStr new String(request.getParameter(type).getBytes(ISO-8859-1), UTF-8); String desc new String(request.getParameter(description).getBytes(ISO-8859-1), UTF-8); try { BigDecimal amount new BigDecimal(amountStr); TransactionType type TransactionType.valueOf(typeStr); Transaction transaction new Transaction(amount, type, desc); boolean success new TransactionService().addTransaction(transaction); request.setAttribute(message, success ? 添加成功 : 添加失败请检查余额); } catch (NumberFormatException e) { request.setAttribute(message, 金额格式错误请输入数字); } catch (IllegalArgumentException e) { request.setAttribute(message, e.getMessage()); } // 2. 转发到首页保持URL不变 request.getRequestDispatcher(index.jsp).forward(request, response); } }4.1.1 为什么用request.getRequestDispatcher().forward()而不是response.sendRedirect()forward()是服务器内部跳转URL栏不变用户刷新不会重复提交表单sendRedirect()是客户端重定向URL变成/index.jsp但用户刷新会再次触发doPost造成重复记账。4.2 前端金额校验用正则限制输入防用户输“100.00.00”JSP里用HTML5input typenumber不够用户仍可粘贴非法字符。必须加JS实时校验!-- index.jsp -- input typetext idamount nameamount placeholder请输入金额 oninputvalidateAmount(this) script function validateAmount(input) { // 只允许整数、小数点、最多2位小数如 100 | 100.00 | .5 → 转成0.5 let value input.value.replace(/[^0-9.]/g, ); // 删非数字和点 let parts value.split(.); if (parts.length 2) { value parts[0] . parts.slice(1).join(); // 多个小数点只留第一个 } if (parts.length 2 parts[1].length 2) { value parts[0] . parts[1].substring(0, 2); // 小数超2位截断 } input.value value; } /script4.2.1 后端二次校验为何不可省两个真实案例案例1用户禁用JS直接抓包发amount100.00000000000001→ 后端new BigDecimal(100.00000000000001)存入MySQL自动截断为100.00但用户以为存了100.00000000000001案例2用户用Postman发amountabc→ 前端JS不触发后端NumberFormatException捕获并提示体验比白屏好。5. 数据一致性保障与性能优化余额缓存策略与批量导入实现5.1 余额实时计算的两种方案查库 vs 缓存何时该用哪种每次页面加载都执行SELECT SUM(amount) FROM transactions当数据量过万响应从200ms涨到2s。必须优化方案1小数据量直接查库用索引加速CREATE INDEX idx_type_time ON transactions(type, create_time);适合5000条记录开发期够用。方案2大数据量用Redis缓存余额但必须解决双写一致性// 添加交易后更新Redis Jedis jedis new Jedis(localhost); jedis.incrBy(balance, transaction.getAmount().multiply( transaction.getType() TransactionType.INCOME ? BigDecimal.ONE : new BigDecimal(-1) ).longValue()); // 注意BigDecimal转long会丢精度此处仅示意但BigDecimal精度问题导致Redis值不准真实项目应改用Lua脚本保证原子性-- update_balance.lua local amount tonumber(ARGV[1]) local type ARGV[2] if type INCOME then redis.call(INCRBYFLOAT, balance, amount) else redis.call(INCRBYFLOAT, balance, -amount) end return redis.call(GET, balance)注意Redis方案需额外部署Redis服务期末作业若无要求优先用方案1索引优化避免引入新组件增加复杂度。5.2 批量导入CSV用OpenCSV解析1000条数据3秒内完成用户要导入历史Excel数据不能一条条手输。用OpenCSV库Maven坐标com.opencsv:opencsv:5.7.1// ImportService.java public int importFromCsv(File csvFile) { int count 0; try (CSVReader reader new CSVReader(new FileReader(csvFile))) { String[] line; // 跳过标题行 reader.readNext(); while ((line reader.readNext()) ! null) { if (line.length 4) { BigDecimal amount new BigDecimal(line[0]); TransactionType type TransactionType.valueOf(line[1]); String desc line[2]; LocalDateTime time LocalDateTime.parse(line[3], DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss)); Transaction t new Transaction(amount, type, desc); t.setCreateTime(time); new TransactionService().addTransaction(t); count; } } } catch (Exception e) { e.printStackTrace(); } return count; }5.2.1 CSV解析必加的容错处理空行跳过if (line null || line.length 0) continue;金额为空转0amount line[0].trim().isEmpty() ? BigDecimal.ZERO : new BigDecimal(line[0]);日期格式兼容用DateTimeFormatterBuilder支持多种格式DateTimeFormatter formatter new DateTimeFormatterBuilder() .parseCaseInsensitive() .appendOptional(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss)) .appendOptional(DateTimeFormatter.ofPattern(yyyy/MM/dd HH:mm:ss)) .toFormatter();5.3 报表导出用Apache POI生成Excel避免内存溢出导出全部数据到Excel不用XSSFWorkbook内存版改用SXSSFWorkbook流式版// ReportExport.java public void exportToExcel(HttpServletResponse response) throws IOException { // SXSSFWorkbook只保留100行在内存其余刷到磁盘 SXSSFWorkbook workbook new SXSSFWorkbook(100); Sheet sheet workbook.createSheet(财务报表); // 写表头 Row headerRow sheet.createRow(0); String[] headers {ID, 金额, 类型, 描述, 时间}; for (int i 0; i headers.length; i) { headerRow.createCell(i).setCellValue(headers[i]); } // 分批查库写入每批1000条 int offset 0; int batchSize 1000; ListTransaction batch; do { batch new TransactionDAO().listTransactions(offset, batchSize); for (int i 0; i batch.size(); i) { Row row sheet.createRow(offset i 1); Transaction t batch.get(i); row.createCell(0).setCellValue(t.getId()); row.createCell(1).setCellValue(t.getAmount().doubleValue()); row.createCell(2).setCellValue(t.getType().getDesc()); row.createCell(3).setCellValue(t.getDescription()); row.createCell(4).setCellValue(t.getCreateTime().toString()); } offset batchSize; } while (!batch.isEmpty()); // 输出到响应流 response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filenamefinance_report.xlsx); workbook.write(response.getOutputStream()); workbook.dispose(); // 关键释放临时文件 }5.3.1SXSSFWorkbook的三个生死参数参数作用不设的后果构造函数new SXSSFWorkbook(100)内存保留100行超出行刷磁盘设为0 → 全刷磁盘速度慢10倍workbook.dispose()删除临时磁盘文件不调用 → 服务器磁盘被占满row.createCell(i).setCellValue(...)单元格内容不能超32767字符超限抛异常需提前截断本文还有配套的精品资源点击获取
返回列表