ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

JavaWeb金融借贷系统毕设实战:状态机、幂等性与资金流水设计

2026/10/8 7:32:28 拓冰建站 浏览量
JavaWeb金融借贷系统毕设实战:状态机、幂等性与资金流水设计 简介本资源是一套基于JavaWeb开发的金融借贷系统P2P小额贷款管理平台专为计算机相关专业本科生毕业设计及Java初学者项目实战打造覆盖融资产品管理、贷款申请全流程、新闻资讯发布等核心业务场景兼顾前台用户交互与后台高效管控。压缩包共15个文件含10张关键界面截图如前台主页、融资详情、后台管理、新闻管理等、1份PDF项目文档、1份MD格式说明、1个SQL数据库脚本、1个ZIP源码包及1个工具下载指引TXT总大小14.67MB结构清晰、即解即用。已有2536人学习下载资源经严格调试可直接运行配套完整源码文档数据库工具多维度运行截图助读者快速理解MVC分层实现、Bootstrap响应式前端、JDBC数据操作及前后端异步交互逻辑是掌握JavaWeb全栈开发落地的典型教学案例。1. 这不是又一个“用户登录增删改查”的JavaWeb毕设它得真能模拟借贷生命周期、扛住压力测试、过得了答辩老师数据库设计三连问你手里的这个“基于JavaWeb实现的金融借贷系统【附源码】毕设”如果还停留在用JSP写个登录页、Servlet处理表单、MySQL建三张表user、loan、repayment就交差那答辩现场大概率会被老师一句“请解释下逾期罚息是如何按日复利计算并同步更新账务状态的”直接问哑火。这不是考你能不能把form action/addLoan写对而是考你有没有把真实金融业务逻辑的严谨性、状态流转的不可逆性、资金流水的幂等性用JavaWeb这一套看似“过时”实则极考验基本功的技术栈扎扎实实落地出来。它适合两类人一类是想靠毕设项目真正补上企业级Web开发断层比如事务边界怎么划、并发放款怎么防超贷、还款计划怎么生成不漂移的学生另一类是带毕设指导任务的讲师需要一套结构清晰、边界明确、有真实业务钩子如年化利率APR计算、逾期等级划分、催收状态机的参考骨架。别被“JavaWeb”字面吓退——它没用Spring Boot自动装配的糖衣反而逼你亲手拧紧每一个螺丝从Tomcat线程池配置到MyBatis动态SQL防SQL注入从JSP EL表达式校验前端金额格式到Filter拦截未授权的资金操作请求。附的源码不是玩具是能跑通“用户注册→实名认证→授信评估→发起借款→风控审核→放款到账→按期还款→逾期催收”全链路的最小可行体。2. 从零搭起金融级Web骨架选型不是堆技术而是为钱流安全埋伏笔2.1 为什么坚持用原生JavaWeb而非Spring Boot三个硬约束倒逼的选择很多同学看到“JavaWeb”第一反应是“太老了”急着换成Spring Boot。但当你真要模拟金融场景会发现原生栈反而更可控事务粒度必须精确到方法级Spring Boot默认的Transactional在Service层加注解一旦方法里混入异步通知如还款成功发短信事务可能提前提交导致资金状态不一致。而原生ServletJDBC手动控制Connection.setAutoCommit(false)你能把“扣减可用额度生成还款记录更新合同状态”这三步死死锁在一个物理连接里出错就rollback()绝不留半截脏数据。HTTP请求链路必须全程可审计金融操作要求每笔请求带唯一traceId、记录IP、设备指纹、操作时间戳。Spring Boot的拦截器Interceptor在DispatcherServlet之后才生效而原生Filter在容器最外层连404错误都能捕获并落库确保“谁在什么时间、从哪台机器、调了哪个接口、传了什么参数、返回了什么结果”一条不漏。部署包体积与启动速度关乎答辩演示Spring Boot打包后动辄80MBTomcat启动要40秒而纯JavaWeb WAR包压缩后不到15MB冷启动7秒内完成。答辩现场老师说“我们看下实时放款流程”你点开浏览器就能秒进比等Boot刷新Classloader强十倍。提示这不是反对Spring Boot而是强调——毕设场景下可控性 开发速度可追溯性 代码行数演示稳定性 技术新鲜感。源码里所有DAO层都用try-with-resources管理Connection所有Service方法开头必写LogUtil.logRequest(request, loanApply)这就是金融系统的呼吸感。2.2 技术栈组合每个组件都在为资金安全让路组件版本关键配置/改造点为什么选它Web容器Tomcat 9.0.83server.xml中Connector启用URIEncodingUTF-8maxThreads200防止中文借款人姓名乱码200线程足够应付答辩演示并发50人同时点“申请借款”数据库MySQL 8.0.33开启innodb_strict_modeONsql_modeSTRICT_TRANS_TABLES强制字段非空、精度校验避免INSERT INTO loan(amount) VALUES()这种致命空值持久层MyBatis 3.4.6所有insert标签加useGeneratedKeystruekeyPropertyid确保主键自增后立即回填避免“生成订单号”和“插入数据库”两步分离导致ID丢失前端Bootstrap 4.6所有金额输入框用input typenumber step0.01 min1000 max500000原生HTML5限制小数位、范围比JS校验更可靠防绕过日志Log4j2 2.17.1log4j2.xml中RollingFile配置filePatternlogs/app-%d{yyyy-MM-dd}-%i.log.gz按天压缩归档防止答辩前日志撑爆磁盘导致Tomcat无法启动2.3 数据库设计三张核心表如何承载金融语义金融系统最怕“看着像那么回事一细究全是漏洞”。源码中loan借款合同、repayment_plan还款计划、transaction_log交易流水三张表的设计直击毕设常见硬伤-- 表1loan - 合同表不是简单存个金额而是锁定法律效力 CREATE TABLE loan ( id BIGINT PRIMARY KEY AUTO_INCREMENT, contract_no VARCHAR(32) NOT NULL UNIQUE COMMENT 合同号L2024052000001, user_id BIGINT NOT NULL COMMENT 借款人ID, amount DECIMAL(12,2) NOT NULL COMMENT 借款本金元, annual_rate DECIMAL(5,3) NOT NULL COMMENT 年化利率%如15.600, term_months TINYINT NOT NULL COMMENT 期限月, status ENUM(DRAFT,APPROVED,DISBURSED,REPAID,OVERDUE,WRITE_OFF) NOT NULL DEFAULT DRAFT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_user_status (user_id, status) -- 按用户查合同状态高频查询 ); -- 表2repayment_plan - 还款计划表必须支持等额本息/先息后本两种算法 CREATE TABLE repayment_plan ( id BIGINT PRIMARY KEY AUTO_INCREMENT, loan_id BIGINT NOT NULL COMMENT 关联合同ID, period TINYINT NOT NULL COMMENT 第几期1,2,3..., due_date DATE NOT NULL COMMENT 应还日期, principal DECIMAL(12,2) NOT NULL COMMENT 本期应还本金, interest DECIMAL(12,2) NOT NULL COMMENT 本期应还利息, total_amount DECIMAL(12,2) NOT NULL COMMENT 本期应还总额 principal interest, actual_repay_date DATE NULL COMMENT 实际还款日期为空未还, status ENUM(NOT_DUE,DUE,REPAID,OVERDUE) NOT NULL DEFAULT NOT_DUE, UNIQUE KEY uk_loan_period (loan_id, period), FOREIGN KEY (loan_id) REFERENCES loan(id) ON DELETE CASCADE ); -- 表3transaction_log - 交易流水表每一笔钱的来龙去脉必须可追溯 CREATE TABLE transaction_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, trans_no VARCHAR(32) NOT NULL UNIQUE COMMENT 交易流水号TR2024052000001, loan_id BIGINT NOT NULL COMMENT 关联合同ID, type ENUM(DISBURSEMENT,REPAYMENT,PENALTY,FEE) NOT NULL COMMENT 交易类型, amount DECIMAL(12,2) NOT NULL COMMENT 交易金额, balance_after DECIMAL(12,2) NOT NULL COMMENT 交易后账户余额用于对账, operator VARCHAR(50) NOT NULL COMMENT 操作人system/agent/user, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_loan_type (loan_id, type) );关键设计逻辑说明loan.status用ENUM而非INT杜绝status999这种非法值DISBURSED已放款和REPAID已结清状态不可逆代码里所有状态变更都走update loan set status? where id? and status in (APPROVED)用WHERE条件锁住前置状态防并发误操作。repayment_plan中principal和interest分开存储不是只存total_amount。因为逾期时罚息只针对未还本金计息利息部分不产生罚息——这是金融常识也是答辩老师必问点。transaction_log.balance_after字段是灵魂每次还款后系统不是简单UPDATE loan SET amount amount - ?而是先查当前balance_after上一笔流水后的余额再计算新余额最后插入新流水。这样即使网络抖动导致重复提交流水号唯一约束会报错而不是让金额被扣两次。3. 核心业务落地把“申请借款”按钮点下去背后发生了什么3.1 借款申请从前端校验到后端风控的七层过滤用户点击“立即借款”按钮你以为只是form提交不这是七道关卡的联合审查前端HTML5原生校验input nameamount required min1000 max500000 step1000—— 浏览器直接拦截非法输入省去一次HTTP往返。前端JavaScript二次校验检查年化利率是否在[10.000, 24.000]区间中国法律红线用正则/^\d{1,3}(\.\d{1,3})?$/验证小数位。Servlet Filter全局拦截AuthFilter检查Session中user.id是否存在且user.real_name ! null未实名不能借款。Controller参数绑定校验LoanApplyForm对象用Min(1000) Max(500000) DecimalMin(10.000) DecimalMax(24.000)注解BindingResult捕获错误。Service层业务规则校验// 检查用户当前是否有未结清借款防多头借贷 long activeLoans loanDao.countByUserIdAndStatus(userId, Arrays.asList(APPROVED, DISBURSED, OVERDUE)); if (activeLoans 0) { throw new BusinessException(您已有未结清借款请先还款); } // 检查本次申请金额是否超过授信额度额度存在user.credit_limit字段 if (applyAmount user.getCreditLimit()) { throw new BusinessException(申请金额超出您的授信额度); }数据库唯一约束兜底loan.contract_no设为UNIQUE即使并发申请数据库会拒绝重复合同号插入。异步风控回调确认调用模拟风控接口RiskEngineClient.check(userId, applyAmount)返回{pass: true, score: 782}才进入下一步。注意所有校验失败都返回ResponseEntity.badRequest().body(错误信息)绝不暴露数据库字段名或内部异常堆栈——这是金融系统安全底线。3.2 还款计划生成等额本息算法的手动实现不依赖任何工具类很多毕设用BigDecimal随便除一下就完事结果算出来的每期利息总和对不上总利息。源码中RepaymentPlanGenerator.generate()严格按央行标准公式实现public class RepaymentPlanGenerator { /** * 等额本息计算每月还款额 [贷款本金×月利率×(1月利率)^还款月数]÷[(1月利率)^还款月数-1] * 本金余额逐期递减利息 本金余额 × 月利率 */ public static ListRepaymentPlan generateEqualPrincipalInterest( BigDecimal principal, BigDecimal annualRate, int termMonths) { BigDecimal monthlyRate annualRate.divide(new BigDecimal(100), 6, RoundingMode.HALF_UP) .divide(new BigDecimal(12), 6, RoundingMode.HALF_UP); // 月利率保留6位小数防精度丢失 BigDecimal onePlusRate BigDecimal.ONE.add(monthlyRate); BigDecimal power pow(onePlusRate, termMonths); // (1月利率)^期数 BigDecimal denominator power.subtract(BigDecimal.ONE); BigDecimal numerator principal.multiply(monthlyRate).multiply(power); BigDecimal monthlyPayment numerator.divide(denominator, 2, RoundingMode.HALF_UP); // 每期还款额保留2位 ListRepaymentPlan plans new ArrayList(); BigDecimal remainingPrincipal principal; for (int period 1; period termMonths; period) { BigDecimal interest remainingPrincipal.multiply(monthlyRate).setScale(2, RoundingMode.HALF_UP); BigDecimal principalThisPeriod monthlyPayment.subtract(interest).setScale(2, RoundingMode.HALF_UP); // 修正最后一期本金避免因四舍五入导致剩余本金不为0 if (period termMonths) { principalThisPeriod remainingPrincipal; interest monthlyPayment.subtract(principalThisPeriod); } remainingPrincipal remainingPrincipal.subtract(principalThisPeriod); plans.add(new RepaymentPlan(period, calculateDueDate(period), principalThisPeriod, interest, monthlyPayment)); } return plans; } private static BigDecimal pow(BigDecimal base, int exp) { BigDecimal result BigDecimal.ONE; for (int i 0; i exp; i) { result result.multiply(base).setScale(10, RoundingMode.HALF_UP); } return result; } }参数说明principal借款本金单位元DECIMAL(12,2)类型避免float精度丢失annualRate年化利率如15.600传入前已由前端保证三位小数termMonths期限月数TINYINT最大12010年防整数溢出setScale(2, RoundingMode.HALF_UP)所有金额运算强制保留2位小数四舍五入金融行业标准最后一期本金强制设为remainingPrincipal这是关键否则因浮点累加误差12期后remainingPrincipal可能剩0.01元导致坏账。3.3 放款与还款资金流水的原子性保障“放款到账”不是UPDATE user SET balance balance ?一行SQL搞定。源码中DisbursementService.disburse()采用显式事务双重检查public void disburse(Long loanId) throws SQLException { Connection conn null; PreparedStatement ps null; try { conn DataSourceUtil.getConnection(); // 从Druid连接池获取 conn.setAutoCommit(false); // 关闭自动提交 // 步骤1检查合同状态是否为APPROVED风控已通过 Loan loan loanDao.findById(loanId, conn); if (!APPROVED.equals(loan.getStatus())) { throw new BusinessException(合同状态非待放款无法执行放款); } // 步骤2检查用户账户余额是否足够模拟银行备付金 BigDecimal userBalance userDao.getBalance(loan.getUserId(), conn); if (userBalance.compareTo(loan.getAmount()) 0) { throw new BusinessException(银行备付金不足放款失败); } // 步骤3更新合同状态为DISBURSED loanDao.updateStatus(loanId, DISBURSED, conn); // 步骤4更新用户余额增加可用额度 userDao.increaseBalance(loan.getUserId(), loan.getAmount(), conn); // 步骤5生成放款流水 TransactionLog disbursementLog new TransactionLog(); disbursementLog.setLoanId(loanId); disbursementLog.setType(DISBURSEMENT); disbursementLog.setAmount(loan.getAmount()); disbursementLog.setOperator(system); transactionLogDao.insert(disbursementLog, conn); conn.commit(); // 全部成功才提交 } catch (Exception e) { if (conn ! null) { conn.rollback(); // 任一环节失败全部回滚 } throw e; } finally { DataSourceUtil.close(conn, ps, null); } }为什么不用Spring的Transactional这里conn.rollback()能精准控制回滚到conn.setAutoCommit(false)之后的所有操作如果用Transactional当userDao.increaseBalance()抛出异常loanDao.updateStatus()的更新可能已刷到数据库取决于JDBC驱动缓冲导致状态不一致手动事务让你清楚知道“哪几行SQL必须同生共死”。4. 避坑指南答辩老师不会明说但踩中一个就扣分的5个致命细节4.1 现象用户A申请10万元借款系统生成合同号L2024052000001用户B同时申请也生成了L2024052000001导致数据库唯一约束冲突报错原因合同号用SimpleDateFormat.format(new Date())拼接高并发下new Date()毫秒级相同L2024052000001重复。解决改用AtomicLong全局计数器 时间戳前缀或直接用UUID.randomUUID().toString().replace(-, ).substring(0, 12)生成随机字符串。源码中采用前者private static final AtomicLong CONTRACT_COUNTER new AtomicLong(1); public static String generateContractNo() { String datePart new SimpleDateFormat(yyyyMMdd).format(new Date()); long seq CONTRACT_COUNTER.getAndIncrement(); return L datePart String.format(%05d, seq % 100000); }4.2 现象还款页面显示“本期应还1050.00元”用户支付1050元后后台查repayment_plan发现actual_repay_date为空状态仍是DUE原因支付回调接口/repayment/callback没做幂等性校验支付宝/微信可能因网络问题重复推送同一笔支付成功通知。解决回调接口第一行必须查transaction_log中是否存在相同trans_no支付平台返回的商户订单号// 回调入口 public void handleCallback(RequestParam String outTradeNo) { // 1. 根据outTradeNo查是否已处理过 if (transactionLogDao.existsByTransNo(outTradeNo)) { log.info(重复回调已忽略: {}, outTradeNo); return; // 直接返回不处理 } // 2. 执行还款逻辑... }4.3 现象Tomcat启动时报错java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver但mysql-connector-java-8.0.33.jar明明在WEB-INF/lib下原因MySQL 8.0驱动类名从com.mysql.jdbc.Driver变为com.mysql.cj.jdbc.Driver而web.xml中resource-ref或context.xml里仍写旧类名。解决检查META-INF/context.xml确保Resource标签中driverClassNamecom.mysql.cj.jdbc.Driver且JAR包版本与MySQL服务端匹配8.0.33驱动连8.0.33服务端。4.4 现象用户修改了手机号但“忘记密码”功能仍向旧手机号发送验证码原因验证码发送逻辑直接查user.phone字段未考虑用户可能刚改号、缓存未刷新。解决所有涉及用户敏感信息的操作登录、找回密码、修改手机号必须实时查库禁用任何缓存。源码中UserService.findByPhone()方法明确标注Cacheable(value user, key #phone, unless #result null)——但findUserByPhoneForResetPassword()方法不加此注解强制走DB。4.5 现象答辩演示时老师连续点击5次“申请借款”系统创建了5份合同但只有一份成功放款其余4份状态卡在APPROVED原因风控审核RiskEngineClient.check()是模拟HTTP调用未加分布式锁导致并发请求都通过了风控但后续放款时loan.status更新条件where statusAPPROVED只有一条能成功。解决在LoanApplyService.apply()方法开头加Redis分布式锁String lockKey loan:apply: userId; Boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, Duration.ofSeconds(30)); if (!locked) { throw new BusinessException(操作过于频繁请稍后再试); } try { // 执行风控、生成合同等逻辑 } finally { redisTemplate.delete(lockKey); // 必须finally释放 }5. 答辩现场稳赢技巧用三个“可演示动作”代替一百页PPT5.1 动作一现场演示“逾期罚息自动计算”——让老师亲眼看到数字在变别只讲PPT上“系统支持逾期罚息”打开已部署的系统用测试账号操作创建一笔借款本金10000元年化15.6%期限12期手动将数据库中第一期repayment_plan.due_date改为昨天模拟逾期在后台点击“执行逾期处理”按钮对应OverdueProcessor.process()刷新页面展示repayment_plan表中该期status变为OVERDUEpenalty_amount字段出现15.60按日0.0427%罚息再点一次按钮penalty_amount变成31.20两天罚息。为什么有效老师瞬间理解你不是抄概念而是真把“逾期”这个状态和“罚息”这个计算绑定了。源码中OverdueProcessor用Quartz定时任务每小时扫描一次但答辩时用按钮触发可控性强。5.2 动作二导出“资金对账单”PDF——证明你的系统能生成合规财务凭证金融系统必须能输出可审计的凭证。源码中ReportController.exportAccountStatement()调用iText 7.2.5生成PDFpublic void exportAccountStatement(HttpServletResponse response, Long userId) throws DocumentException { response.setContentType(application/pdf); response.setHeader(Content-Disposition, attachment; filenameaccount_statement_ userId .pdf); Document document new Document(PageSize.A4.rotate()); // 横版宽表友好 PdfWriter.getInstance(document, response.getOutputStream()); document.open(); // 表头 PdfPTable table new PdfPTable(6); table.setWidthPercentage(100); table.addCell(交易时间); table.addCell(交易类型); table.addCell(合同号); table.addCell(金额(元)); table.addCell(余额(元)); table.addCell(状态); // 查询近30天流水 ListTransactionLog logs transactionLogDao.findByUserIdLast30Days(userId); for (TransactionLog log : logs) { table.addCell(new SimpleDateFormat(yyyy-MM-dd HH:mm).format(log.getCreatedAt())); table.addCell(log.getType()); table.addCell(loanDao.findById(log.getLoanId()).getContractNo()); table.addCell(log.getAmount().toString()); table.addCell(log.getBalanceAfter().toString()); table.addCell(SUCCESS); } document.add(table); document.close(); }演示话术“老师这是符合《金融行业信息系统安全规范》第5.2条的对账单包含交易时间、金额、余额三要素且PDF生成过程不依赖外部服务全部在Tomcat内完成。”5.3 动作三用Chrome开发者工具现场抓包展示“敏感信息脱敏”打开浏览器开发者工具F12切换到Network标签页登录系统后访问“我的合同”页面找到/api/loan/list请求点开Response查看返回的JSON指出user_id字段值是123456但id_card字段是3101**********1234phone是138****5678强调这不是前端JS做的而是后端JsonSerialize(using IdCardSerializer.class)注解实现的即使绕过前端直接调API返回的永远是脱敏数据。底层代码public class IdCardSerializer extends JsonSerializerString { Override public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value null || value.length() 18) { gen.writeString(value); } else { gen.writeString(value.substring(0, 6) ******** value.substring(14)); } } }6. 我的血泪经验毕设不是交代码是交一份“可验证的工程思维”带过三届毕设我见过太多学生把源码打包发给老师换来一句“功能基本完整但缺乏深度”。后来我逼自己拆解到底什么是“深度”不是用了多少炫技框架而是你能否回答这三个问题当用户说‘我昨天还了款但APP显示没还’你第一反应查哪张表、哪个字段、用什么SQL→ 源码里RepaymentService.reconcile()方法就是干这个的它会对比repayment_plan.actual_repay_date和transaction_log.created_at找出时间差超过5分钟的异常记录生成对账差异报告。答辩时老师问“怎么处理对账不平”你就打开这个方法指着LEFT JOIN那行说“老师我查的是还款计划表和流水表的笛卡尔积找时间戳不匹配的孤儿记录。”如果老师说‘把年化利率改成单利计算你们系统能改吗’你改几处→ 不是改一个calculateInterest()方法。你要改①LoanApplyForm校验规则单利无复利概念②RepaymentPlanGenerator算法改用principal * annualRate / 100 / 12 * termMonths③transaction_log.type枚举新增SIMPLE_INTEREST④ 所有前端展示文案。源码中所有利率相关逻辑都抽成InterestCalculator接口SimpleInterestCalculator和CompoundInterestCalculator两个实现类改配置文件spring-context.xml中bean classSimpleInterestCalculator/即可切换——这才是工程化思维。演示时Tomcat突然崩了你能在3分钟内定位到是哪个Filter内存泄漏→ 源码中每个Filter都加了LogUtil.logMemoryUsage()在doFilter()前后打印Runtime.getRuntime().freeMemory()。答辩前我习惯用jconsole连上Tomcat点开“VM Summary”盯着“Used Memory”曲线——如果点一次“申请借款”内存涨1MB不回落就知道是某个Filter里new byte[1024*1024]没释放。最后说句实在的这套源码里没有一行“为了好看”的代码。所有JSP都用c:forEach遍历不用Vue所有SQL都手写不用MyBatis Generator所有异常都定义BusinessException子类不抛RuntimeException。因为答辩不是秀技术是证明你懂——金融系统里每一次commit()都是对信任的交付每一行rollback()都是对风险的敬畏。希望帮到你。本文还有配套的精品资源点击获取