简介:本资源是一套完整的毕业设计项目——基于SpringBoot的理财管理系统,面向Java初学者与高校计算机专业学生,聚焦个人财务管理场景,提供从需求分析、系统开发到测试验证的全流程实践方案。压缩包共718个文件,包含237个Java源码文件(涵盖用户管理、资金流水、基金产品等核心模块)、177个编译后class文件、151个MyBatis映射XML配置、70个HTML前端页面及配套JS/CSS资源,整体体积仅3.96MB,结构紧凑、便于学习与二次开发。目前已有681人下载学习,资源代码规范、分层清晰,含完整DAO/Service/Controller三层结构及大量Example生成类(如UserExample、FundProductExample等),可直接运行调试,帮助读者深入理解Spring+MyBatis整合开发、权限控制实现与数据库CRUD操作实践。
1. 毕业设计选题不是堆功能,而是用 SpringBoot 把「理财」这件事做稳、做实、做可验证
很多同学拿到“基于 SpringBoot 的理财管理系统”这个毕设题目,第一反应是狂加模块:用户注册、余额查询、基金定投、股票模拟、风险测评、报表导出……结果两周写完登录页,一个月卡在 Redis 缓存穿透上,答辩前还在改 MyBatis-Plus 的@TableField注解。其实,真正的毕业设计价值不在于界面多炫、功能多全,而在于能否用 SpringBoot 的工程能力,把“钱”这个敏感对象的生命周期管住——从用户身份可信(JWT 鉴权)、资金操作原子性(事务传播行为)、余额变更可追溯(操作日志+快照)、到关键数据防误删(逻辑删除+软删除校验)。它适合那些已经学过 Java 基础、Spring MVC、MySQL 和简单前端(Vue 或 Thymeleaf),但还没在真实业务场景里跑通“资金类操作闭环”的同学。本文不讲如何画高保真原型图,只聚焦 SpringBoot 工程中最常被忽略却直接决定答辩是否通过的 4 个硬核环节:账户余额一致性保障、交易流水幂等设计、敏感字段加密落库、以及本地可复现的定时对账脚本。
2. 用 SpringBoot 事务管理器 + 数据库行锁,守住每一笔余额变更的原子性边界
理财系统最核心的不是页面,而是“余额”这个数字的每一次变化都必须满足 ACID。常见错误是只用@Transactional包裹 Service 方法,却忽略底层数据库隔离级别和锁机制的配合。比如转账操作:A 减 100,B 加 100,若仅靠 Spring 事务,当并发请求同时操作同一账户时,可能因 MySQL 默认的REPEATABLE READ隔离级别导致幻读,或因未显式加锁引发余额超扣。
2.1 为什么@Transactional单独不够?看一个真实并发场景
假设用户 A 当前余额为 500 元,两个线程同时发起“转出 300 元”请求。若 Service 方法仅标注@Transactional:
@Transactional public void withdraw(Long accountId, BigDecimal amount) { Account account = accountMapper.selectById(accountId); if (account.getBalance().compareTo(amount) < 0) { throw new InsufficientBalanceException(); } account.setBalance(account.getBalance().subtract(amount)); accountMapper.updateById(account); // 此处 update 可能被并发覆盖 }问题在于:selectById读取的是快照,updateById执行时可能已过期。两个线程都通过余额校验,最终只有一笔生效,另一笔覆盖写入,造成实际扣款 600 元但数据库只减 300 元。
提示:这不是 SpringBoot 的 Bug,而是开发者未理解“事务边界”与“数据库锁粒度”的协同关系。Spring 的
@Transactional控制的是逻辑事务范围,而真正保证数据一致的是数据库的行级锁(如SELECT ... FOR UPDATE)。
2.2 正确做法:在 DAO 层显式使用SELECT FOR UPDATE获取行锁
MyBatis-Plus 本身不直接支持FOR UPDATE,需自定义 XML SQL 或使用QueryWrapper的last()方法拼接:
<!-- AccountMapper.xml --> <select id="selectForUpdate" resultType="com.example.account.entity.Account"> SELECT * FROM account WHERE id = #{id} FOR UPDATE </select>对应 Mapper 接口:
public interface AccountMapper extends BaseMapper<Account> { @Select("SELECT * FROM account WHERE id = #{id} FOR UPDATE") Account selectForUpdate(@Param("id") Long id); }Service 层调用:
@Transactional(rollbackFor = Exception.class) public void withdraw(Long accountId, BigDecimal amount) { // 关键:先加锁再查,阻塞其他并发请求 Account account = accountMapper.selectForUpdate(accountId); if (account == null || account.getBalance().compareTo(amount) < 0) { throw new InsufficientBalanceException("余额不足"); } // 此时 account 记录已被当前事务锁定,其他事务无法修改 account.setBalance(account.getBalance().subtract(amount)); accountMapper.updateById(account); // 更新必成功,因锁已持有 }参数说明与调试验证点:
@Transactional(rollbackFor = Exception.class):确保所有异常(包括运行时异常)触发回滚,避免RuntimeException被吞没;SELECT ... FOR UPDATE:在 InnoDB 引擎下,会对匹配的主键行加排他锁(X 锁),其他事务对该行的UPDATE/DELETE/SELECT FOR UPDATE将阻塞,直到当前事务提交或回滚;- 验证方法:用 JMeter 启动 10 个线程并发调用同一账户的
withdraw接口,观察数据库information_schema.INNODB_TRX表,应看到多个TRX_STATE = 'LOCK WAIT'的记录,且最终余额变更严格等于请求次数 × 单次金额。
2.3 进阶:结合乐观锁避免长事务阻塞,适用于低频大额操作
对于“修改用户风险等级”这类非高频、但需强一致性的操作,可改用乐观锁减少锁竞争:
@TableField(version = true) private Integer version; // 在 Account 实体中添加 version 字段Mapper 中使用UpdateWrapper带版本号更新:
LambdaUpdateWrapper<Account> wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(Account::getId, accountId) .eq(Account::getVersion, oldVersion) // 断言版本未变 .set(Account::getRiskLevel, newLevel) .set(Account::getVersion, oldVersion + 1); int updated = accountMapper.update(null, wrapper); if (updated == 0) { throw new OptimisticLockException("账户版本冲突,请刷新后重试"); }此方案不阻塞读操作,适合读多写少场景,但需前端配合处理OptimisticLockException并提示用户刷新页面。
3. 用唯一业务单号 + 数据库唯一索引,实现交易流水的天然幂等性
理财系统中,“重复提交”是高频故障源:用户连点两次“购买基金”,前端防抖失效,后端若无幂等设计,就会生成两笔相同金额的流水,导致资金多扣。很多同学用 Redis Set 去重,但 Redis 故障时幂等失效,且增加运维复杂度。更可靠的做法是:把幂等性下沉到数据库层,用唯一约束兜底。
3.1 设计幂等关键字段:biz_order_no与联合唯一索引
在交易流水表trade_record中,除主键外,必须包含:
| 字段名 | 类型 | 说明 |
|---|---|---|
id | BIGINT PK | 主键 |
biz_order_no | VARCHAR(64) | 业务单号,由上游系统生成(如前端 UUID + 时间戳哈希) |
account_id | BIGINT | 账户 ID |
amount | DECIMAL(18,2) | 交易金额 |
trade_type | TINYINT | 交易类型(1=充值,2=提现,3=购买) |
status | TINYINT | 状态(0=待处理,1=成功,2=失败) |
关键约束:在biz_order_no上建立唯一索引(非主键):
ALTER TABLE trade_record ADD UNIQUE INDEX uk_biz_order_no (biz_order_no);注意:不要用
account_id + trade_type + amount等组合字段做唯一索引,因为同一用户同类型交易金额可能重复(如连续两笔 100 元充值),唯一性必须由全局业务单号保证。
3.2 Controller 层接收并校验业务单号,Service 层执行插入
Controller 接收请求时,强制要求biz_order_no不为空:
@PostMapping("/trade/buy") public Result<?> buyFund(@RequestBody @Valid BuyFundRequest request) { // 校验业务单号格式(如 UUID 标准格式) if (!request.getBizOrderNo().matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")) { return Result.fail("业务单号格式错误"); } tradeService.buyFund(request); return Result.success(); }Service 层直接插入,依赖数据库唯一索引拦截重复:
@Transactional(rollbackFor = Exception.class) public void buyFund(BuyFundRequest request) { TradeRecord record = new TradeRecord(); record.setBizOrderNo(request.getBizOrderNo()); record.setAccountId(request.getAccountId()); record.setAmount(request.getAmount()); record.setTradeType(TradeType.BUY_FUND.getCode()); record.setStatus(TradeStatus.PENDING.getCode()); try { tradeRecordMapper.insert(record); // 若 biz_order_no 已存在,抛出 DuplicateKeyException } catch (DuplicateKeyException e) { // 捕获唯一索引冲突,视为幂等成功 log.warn("重复业务单号: {}", request.getBizOrderNo()); return; // 不抛异常,不回滚,前端感知为“已处理” } // 后续资金操作(如扣减余额)... }异常处理与日志规范:
DuplicateKeyException是 Spring 对数据库唯一约束冲突的统一包装,无需捕获具体 MySQL 错误码;- 日志必须记录
biz_order_no,便于后续审计时快速定位重复请求来源(是前端重复发包?还是用户刷新页面?); - 绝不在 catch 块中尝试
select by biz_order_no再返回结果——这会引入新的竞态条件,正确姿势是“插入失败即幂等完成”。
3.3 前端配合:生成防碰撞业务单号的实践方案
推荐使用UUID.randomUUID().toString().replace("-", "")生成 32 位字符串,而非时间戳+序列号(易被预测)。若需更高可读性,可用 Snowflake ID(但需部署独立 ID 生成服务,毕设项目不推荐):
// Vue 组件中生成业务单号 data() { return { bizOrderNo: '' } }, created() { this.bizOrderNo = 'ORD_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); }生成后存入 Vuex 或 localStorage,提交失败时复用同一单号重试,确保幂等链路完整。
4. 用 Jasypt 加密配置 + 自定义 TypeHandler,实现敏感字段(如身份证号、银行卡号)的透明加解密
理财系统必然涉及用户敏感信息存储:身份证号、银行卡号、手机号。毕设中若明文存库,不仅违反《个人信息保护法》基本精神,更会在答辩时被评委直接质疑工程素养。SpringBoot 生态中,Jasypt 提供了零侵入的字段级加密方案,比自己手写 AES 工具类更安全、更易维护。
4.1 引入 Jasypt 依赖并配置加密密钥
pom.xml添加:
<dependency> <groupId>com.github.ulisesbocchio</groupId> <artifactId>jasypt-spring-boot-starter</artifactId> <version>3.0.5</version> </dependency>application.yml中配置加密密钥(严禁硬编码!):
jasypt: encryptor: password: ${JASYPT_ENCRYPTOR_PASSWORD:dev_key_for_demo} # 从环境变量读取 algorithm: PBEWithMD5AndDES iv-generator-classname: org.jasypt.iv.RandomIvGenerator提示:
JASYPT_ENCRYPTOR_PASSWORD必须通过-Djasypt.encryptor.password=xxx或系统环境变量传入,禁止写死在配置文件中。本地开发可用dev_key_for_demo,但答辩演示时务必更换为随机字符串。
4.2 对实体类敏感字段添加@Encrypt注解
在User实体中标识需加密字段:
import com.ulisesbocchio.jasyptspringboot.annotation.Encrypt; public class User { private Long id; private String username; @Encrypt private String idCard; // 身份证号 @Encrypt private String bankCardNo; // 银行卡号 @Encrypt private String phone; // 手机号 // getter/setter... }Jasypt 会自动在 MyBatis 插入/查询时加解密,无需修改 DAO 层代码。
4.3 进阶:自定义 TypeHandler 实现字段级可控加密(适配 MyBatis-Plus)
若使用 MyBatis-Plus 且需对特定字段(如idCard)启用加密,但又不想全局扫描@Encrypt,可编写 TypeHandler:
@MappedTypes(String.class) public class EncryptedStringTypeHandler extends BaseTypeHandler<String> { private final TextEncryptor encryptor = Encryptors.text( System.getProperty("jasypt.encryptor.password", "dev_key"), "1234567890123456" ); @Override public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, encryptor.encrypt(parameter)); } @Override public String getNullableResult(ResultSet rs, String columnName) throws SQLException { String encrypted = rs.getString(columnName); return encrypted != null ? encryptor.decrypt(encrypted) : null; } // ... 其他重写方法 }在User实体中指定:
@TableField(typeHandler = EncryptedStringTypeHandler.class) private String idCard;安全边界说明:
- Jasypt 使用对称加密(PBE),密钥丢失则数据不可恢复,故密钥必须严格保管;
- 加密后字段长度会膨胀(DES 加密后约增长 30%),建表时
VARCHAR长度需预留空间(如身份证号原 18 位,建议设为VARCHAR(32)); - 加密仅作用于数据库存储层,内存中仍为明文,若需内存加密,需额外集成 JVM 级加密库(毕设不推荐)。
5. 用 SpringBoot 定时任务 + 内存快照比对,构建本地可验证的资金对账脚本
毕业设计答辩时,评委最常问:“如果用户说‘我明明只转了 100,怎么扣了 200’,你怎么查?”——这本质是资金对账问题。与其现场手写 SQL 查表,不如在 SpringBoot 中内置一个轻量级对账模块,每天凌晨自动运行,将“账户余额”与“交易流水汇总”做差值校验,并输出 HTML 报告。
5.1 定义对账任务:计算每个账户的理论余额与实际余额偏差
创建ReconciliationTask:
@Component @RequiredArgsConstructor public class ReconciliationTask { private final AccountMapper accountMapper; private final TradeRecordMapper tradeRecordMapper; @Scheduled(cron = "0 0 2 * * ?") // 每天凌晨 2 点执行 public void runDailyReconciliation() { List<Account> allAccounts = accountMapper.selectList(null); List<ReconciliationResult> results = new ArrayList<>(); for (Account account : allAccounts) { // 1. 获取账户当前余额(实际余额) BigDecimal actualBalance = account.getBalance(); // 2. 计算理论余额:初始余额 + 所有成功流水净额 BigDecimal theoreticalBalance = calculateTheoreticalBalance(account.getId()); // 3. 比对偏差 BigDecimal diff = actualBalance.subtract(theoreticalBalance); if (diff.abs().compareTo(new BigDecimal("0.01")) > 0) { // 允许 0.01 元浮点误差 results.add(new ReconciliationResult( account.getId(), account.getUsername(), actualBalance, theoreticalBalance, diff )); } } // 4. 生成 HTML 报告 generateHtmlReport(results); } private BigDecimal calculateTheoreticalBalance(Long accountId) { // 查询该账户所有 status=1 的流水,按 type 分组求和 return tradeRecordMapper.selectList( new QueryWrapper<TradeRecord>() .eq("account_id", accountId) .eq("status", TradeStatus.SUCCESS.getCode()) ).stream() .map(r -> r.getTradeType() == 1 ? r.getAmount() : r.getAmount().negate()) // 1=充值(+),其他(-) .reduce(BigDecimal.ZERO, BigDecimal::add); } private void generateHtmlReport(List<ReconciliationResult> results) { // 使用 Thymeleaf 渲染模板,输出到 /reconciliation/reports/20240520.html // 模板中展示:账户名、实际余额、理论余额、偏差、操作建议(如“请检查流水表是否有未更新状态的记录”) } }5.2 开启定时任务并配置线程池,避免阻塞主线程
application.yml中启用:
spring: task: scheduling: pool: size: 5 # 设置线程池大小,避免大量账户对账时阻塞 main: allow-bean-definition-overriding: true启动类添加注解:
@SpringBootApplication @EnableScheduling // 启用定时任务 public class FinanceApplication { public static void main(String[] args) { SpringApplication.run(FinanceApplication.class, args); } }5.3 本地快速验证对账逻辑:手动触发 + 内存断点调试
为方便答辩演示,添加一个@RestController手动触发接口:
@RestController @RequestMapping("/debug") @RequiredArgsConstructor public class DebugController { private final ReconciliationTask reconciliationTask; @GetMapping("/reconcile-now") public String triggerReconciliation() { reconciliationTask.runDailyReconciliation(); return "对账任务已执行,报告生成于 /reconciliation/reports/"; } }启动应用后访问http://localhost:8080/debug/reconcile-now,立即生成报告。在calculateTheoreticalBalance方法内打内存断点,观察tradeRecordMapper.selectList(...)返回的流水列表是否完整、reduce计算过程是否符合预期——这是评委最认可的“可验证性”体现。
提示:对账任务必须设置
@Transactional(propagation = Propagation.NOT_SUPPORTED),避免长事务占用数据库连接;若数据量大,可分页查询流水,但毕设数据量小,直接selectList更直观。
6. 用 Actuator + 自定义 Endpoint 暴露关键健康指标,让答辩时的系统状态一目了然
答辩现场,当评委问“你的系统现在运行是否正常?数据库连得上吗?余额计算有没有延迟?”时,翻日志或敲命令太慢。SpringBoot Actuator 提供了标准化的健康检查端点,但默认只返回UP/DOWN。我们需扩展它,暴露理财系统特有的业务健康指标:最近一笔交易时间、账户余额校验通过率、加密密钥加载状态。
6.1 自定义 HealthIndicator 返回业务级健康信号
@Component public class FinanceHealthIndicator implements HealthIndicator { private final TradeRecordMapper tradeRecordMapper; private final JasyptEncryptor encryptor; public FinanceHealthIndicator(TradeRecordMapper tradeRecordMapper, @Qualifier("jasyptStringEncryptor") TextEncryptor encryptor) { this.tradeRecordMapper = tradeRecordMapper; this.encryptor = encryptor; } @Override public Health health() { Health.Builder builder = Health.up(); // 1. 检查最近交易时间(确保系统在处理真实业务) try { LocalDateTime lastTradeTime = tradeRecordMapper.selectOne( new QueryWrapper<TradeRecord>().orderByDesc("create_time").last("LIMIT 1") ).getCreateTime(); Duration duration = Duration.between(lastTradeTime, LocalDateTime.now()); if (duration.toHours() > 24) { builder.down().withDetail("lastTradeStale", "超过24小时无新交易"); } } catch (Exception e) { builder.down().withDetail("tradeQueryFailed", e.getMessage()); } // 2. 检查余额校验通过率(抽样 100 笔,看理论余额与实际余额偏差是否在阈值内) try { List<Account> sampleAccounts = accountMapper.selectList( new QueryWrapper<Account>().last("ORDER BY RAND() LIMIT 100") ); long validCount = sampleAccounts.stream() .filter(this::isBalanceConsistent) .count(); double rate = (double) validCount / sampleAccounts.size(); builder.withDetail("balanceConsistencyRate", String.format("%.2f%%", rate * 100)); if (rate < 0.95) { builder.down().withDetail("lowConsistencyRate", "余额一致性低于95%"); } } catch (Exception e) { builder.down().withDetail("balanceCheckFailed", e.getMessage()); } // 3. 检查加密器是否可用 try { encryptor.encrypt("test"); } catch (Exception e) { builder.down().withDetail("encryptorUnhealthy", "Jasypt密钥加载失败"); } return builder.build(); } private boolean isBalanceConsistent(Account account) { BigDecimal actual = account.getBalance(); BigDecimal theoretical = calculateTheoreticalBalance(account.getId()); return actual.subtract(theoretical).abs().compareTo(new BigDecimal("0.01")) <= 0; } }6.2 配置 Actuator 暴露端点并设置权限(仅开发环境开放)
application-dev.yml:
management: endpoints: web: exposure: include: health,info,metrics,prometheus endpoint: health: show-details: always # 显示详细健康信息 server: port: 8081 # 健康端口与主应用分离生产环境(application-prod.yml)中关闭health详细信息:
management: endpoint: health: show-details: never6.3 答辩现场演示:一键查看系统业务健康度
启动应用后,访问http://localhost:8081/actuator/health,返回 JSON:
{ "status": "UP", "components": { "diskSpace": { "status": "UP", "details": { "total": 500000000000, "free": 200000000000 } }, "db": { "status": "UP", "details": { "database": "MySQL", "validationQuery": "isValid()" } }, "finance": { "status": "UP", "details": { "lastTradeStale": false, "balanceConsistencyRate": "99.87%", "encryptorUnhealthy": false } } } }评委只需看finance节点下的balanceConsistencyRate和lastTradeStale,就能立刻判断系统资金逻辑是否稳定运行——这才是毕业设计该有的工程质感。
本文还有配套的精品资源,点击获取