SpringBoot+Vue记账系统实战:全栈开发与数据一致性保障

📅 发布时间:2026/9/11 22:22:15
SpringBoot+Vue记账系统实战:全栈开发与数据一致性保障
简介这是一套面向Java初学者与毕业设计学生的全栈记账系统实战项目基于SpringBootVue技术栈构建聚焦大学生日常消费管理场景助力课程设计、期末大作业及毕业设计快速落地。资源包共374个文件涵盖85个核心Java后端代码、46个Vue前端组件、161个SVG图标资源、17个JS逻辑脚本及1个完整MySQL建库脚本sql辅以bat部署脚本、yml配置、CSS/HTML页面与多格式图片素材总大小14.84MB结构清晰、注释详尽新手可直接理解业务逻辑与前后端交互流程。已有61人学习下载项目经严格调试支持一键部署后台访问localhost:8080/xxx/admin/dist/index.html前台对应front/index.html配套Navicat数据库工具与Tomcat 7.x/8.x环境建议显著降低环境适配门槛。1. 大学生记账系统不是“小玩具”而是 SpringBoot Vue 全栈能力的压缩包很多刚学完 Java Web 和 Vue 的同学看到“大学生消费记账系统”第一反应是这不就是个增删改查界面随便套个 Element Plus 就能交差。但真实项目里它恰恰是最能暴露知识断层的典型场景——后端要处理多用户隔离的账单归属、按月/类别/时间范围的聚合查询、余额实时校验与事务一致性前端要实现动态图表渲染比如环形图展示饮食/交通/娱乐占比、本地缓存优化列表滚动、离线状态下草稿暂存数据库设计稍有疏忽就会在“同一笔支出同时属于‘外卖’和‘零食’两个标签”这类业务需求前卡壳。这不是课程设计作业的简化版而是企业级财务类应用的最小可行切片。适合 Java 后端初学者巩固 MyBatis-Plus 多表关联与条件构造器也适合 Vue 前端验证 Composition API 下响应式状态管理与路由守卫的实际配合逻辑。本文所有代码均基于可运行的完整源码结构展开不跳步、不省略配置细节。2. 后端用 SpringBoot 搭建高内聚记账服务从实体建模到 REST 接口落地2.1 数据库设计必须支撑“消费行为”的语义完整性记账系统的核心不是“记”而是“可分析”。因此数据库不能只建account_record一张表。我们采用四表结构满足常见查询与扩展性表名主要字段设计要点userid,username,password,create_time密码必须 BCrypt 加密存储禁止明文或 MD5categoryid,name,type0收入/1支出,icontype字段为后续统计提供索引基础避免SELECT * FROM category WHERE name餐饮这类模糊查询account_recordid,user_id,category_id,amount,remark,record_time,is_deletedrecord_time类型为datetime非date保留精确到秒的时间戳支持“今日最后一笔消费”类查询user_balanceuser_id,balance,update_time单独维护余额表避免每次查账单都SUM(amount)聚合写多读少场景下性能提升显著提示account_record表中is_deleted采用逻辑删除值为 0/1而非物理DELETE。这样既保留数据可追溯性又避免外键约束失效问题。MyBatis-Plus 的TableLogic注解可自动拦截deleteById并转为UPDATE SET is_deleted1。2.2 SpringBoot 工程结构与关键依赖配置使用 Spring Boot 2.7.18兼容 JDK 8避免新手因版本过高导致spring-boot-starter-webflux冲突pom.xml中必须包含以下核心依赖dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency /dependenciesapplication.yml配置需显式声明 MyBatis-Plus 的逻辑删除字段与分页插件mybatis-plus: global-config: db-config: logic-delete-field: isDeleted # 实体类中布尔字段名 logic-delete-value: 1 # 删除时设为1 logic-not-delete-value: 0 # 未删除时为0 configuration: map-underscore-to-camel-case: true mapper-locations: classpath*:mapper/**/*.xml # 分页插件启用3.4 版本已内置 pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: countcountSql2.3 核心业务接口收支记录的原子化操作与余额同步记账最易出错的是“记一笔支出余额没扣”或“删记录余额没加回”。必须用事务保证account_record插入/删除与user_balance更新的强一致性。以新增支出为例Service Transactional(rollbackFor Exception.class) public class AccountRecordService { Autowired private AccountRecordMapper recordMapper; Autowired private UserBalanceMapper balanceMapper; public boolean addExpense(Long userId, Long categoryId, BigDecimal amount, String remark) { // 1. 查询当前余额判断是否足够 UserBalance balance balanceMapper.selectById(userId); if (balance null || balance.getBalance().compareTo(amount) 0) { throw new RuntimeException(余额不足无法完成支出); } // 2. 插入账单记录 AccountRecord record new AccountRecord(); record.setUserId(userId); record.setCategoryId(categoryId); record.setAmount(amount); record.setRemark(remark); record.setRecordTime(LocalDateTime.now()); record.setIsDeleted(false); recordMapper.insert(record); // 3. 更新余额注意此处是减法且必须用数据库行锁防止并发超支 UpdateWrapperUserBalance wrapper new UpdateWrapper(); wrapper.eq(user_id, userId) .setSql(balance balance - amount.toPlainString()); int updated balanceMapper.update(null, wrapper); return updated 1; } }注意第 3 步未使用balanceMapper.updateById()是因为该方法会先SELECT再UPDATE在高并发下存在“查到余额充足 → 别人抢先扣款 → 自己再扣款导致透支”的风险。直接setSql执行UPDATE ... SET balance balance - ?是原子操作配合Transactional可确保数据最终一致。3. 前端用 Vue 3 构建交互式记账界面从路由组织到图表集成3.1 Vue Router 路由设计需匹配用户操作流记账系统不是信息展示站而是任务驱动型应用。路由结构应围绕“用户动作”组织而非“页面分类”// router/index.js const routes [ { path: /, redirect: /dashboard }, { path: /login, name: Login, component: () import(/views/Login.vue), meta: { requiresAuth: false } }, { path: /dashboard, name: Dashboard, component: () import(/views/Dashboard.vue), meta: { requiresAuth: true }, children: [ { path: , redirect: overview }, { path: overview, name: Overview, component: () import(/views/dashboard/Overview.vue) }, { path: records, name: Records, component: () import(/views/dashboard/Records.vue) }, { path: categories, name: Categories, component: () import(/views/dashboard/Categories.vue) } ] } ]提示meta: { requiresAuth: true }是路由守卫的基础。在router.beforeEach中检查localStorage.getItem(token)是否存在不存在则重定向至/login。不要在每个组件mounted里重复校验这是典型的防御性编程冗余。3.2 使用 ECharts 实现消费结构环形图数据驱动而非 DOM 操作Vue 3 的响应式系统与 ECharts 的初始化机制存在冲突onMounted时 DOM 可能未就绪ref获取的容器元素为null。正确做法是监听容器ref的变化并在nextTick后初始化图表template div refchartRef classchart-container/div /template script setup import { ref, onMounted, onUnmounted, watch } from vue import * as echarts from echarts const chartRef ref(null) let chartInstance null // 模拟从 API 获取的分类支出数据 const categoryData ref([ { name: 餐饮, value: 1250 }, { name: 交通, value: 320 }, { name: 娱乐, value: 680 }, { name: 学习, value: 450 } ]) onMounted(() { initChart() }) onUnmounted(() { if (chartInstance) { chartInstance.dispose() } }) const initChart () { if (!chartRef.value) return chartInstance echarts.init(chartRef.value) const option { tooltip: { trigger: item }, series: [{ type: pie, radius: [40%, 70%], avoidLabelOverlap: false, itemStyle: { borderRadius: 10 }, label: { show: false }, emphasis: { label: { show: true, fontSize: 14 } }, data: categoryData.value.map(item ({ value: item.value, name: item.name })) }] } chartInstance.setOption(option) } // 当 categoryData 变化时重新渲染图表 watch(categoryData, () { if (chartInstance) { chartInstance.setOption({ series: [{ data: categoryData.value.map(item ({ value: item.value, name: item.name })) }] }) } }) /script3.3 表单提交与后端联调Axios 请求拦截与错误统一处理记账系统高频操作是表单提交新增/编辑记录。为避免每个api方法都写try/catch应在 Axios 实例中统一处理// utils/request.js import axios from axios const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截自动携带 token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截统一错误提示 request.interceptors.response.use( response response, error { const { response } error if (response?.status 401) { localStorage.removeItem(token) router.push(/login) ElMessage.error(登录已过期请重新登录) } else if (response?.data?.message) { ElMessage.error(response.data.message) } else { ElMessage.error(网络请求失败请检查网络) } return Promise.reject(error) } ) export default request在Records.vue中调用时只需关注业务逻辑const submitForm async () { try { await request.post(/account-record, formState.value) ElMessage.success(记账成功) resetForm() loadRecords() // 刷新列表 } catch (err) { // 错误已在拦截器中处理此处无需重复提示 } }4. 前后端联调与数据一致性验证三个必查环节4.1 检查跨域配置是否真正生效SpringBoot 默认不开启跨域Vue 开发服务器http://localhost:5173访问后端http://localhost:8080必然被浏览器拦截。仅在Configuration类中加CrossOrigin注解是不够的因为该注解只对标注的 Controller 生效。全局配置应使用WebMvcConfigurerConfiguration public class WebConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:5173) // 明确指定前端地址 .allowCredentials(true) // 允许携带 cookie/token .maxAge(3600) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS); } }注意allowedOrigins不能写*否则allowCredentialstrue会失效。开发阶段明确写http://localhost:5173上线后替换为 Nginx 反向代理的真实域名。4.2 验证数据库事务是否真正回滚手动触发一个必然失败的场景例如插入一条金额为负数的支出记录后端校验amount 0观察数据库状态在AccountRecordService.addExpense()方法开头打日志log.info(即将插入支出金额{}, amount);在throw new RuntimeException(金额不能为负)前加一行log.error(事务将回滚);启动应用用 Postman 发送请求POST /api/account-recordbody 为{ categoryId: 1, amount: -100, remark: 测试 }查看控制台日志顺序应先输出即将插入支出...再输出事务将回滚且数据库中account_record表无新记录user_balance表余额未变动若发现记录已插入但余额未更新说明Transactional未生效——常见原因是Service 方法被本类其他方法直接调用绕过 Spring AOP 代理或异常被try/catch吞掉未抛出。4.3 测试 Vue 端数据缓存与实时性平衡记账需要“所见即所得”但频繁拉取全量数据影响体验。我们在Records.vue中实现两级缓存策略缓存层级存储位置更新时机适用场景页面级缓存ref([])响应式数组onMounted时加载新增/编辑后push()或splice()局部更新列表滚动流畅避免整页刷新闪烁本地持久化localStorage每次addExpense成功后将新记录JSON.stringify()存入pendingRecordskey网络中断时用户仍可继续记账恢复后批量同步关键代码片段// 记录提交成功后存入本地待同步队列 const handleRecordSuccess (record) { const pending JSON.parse(localStorage.getItem(pendingRecords) || []) pending.push({ ...record, status: pending }) localStorage.setItem(pendingRecords, JSON.stringify(pending)) } // 组件挂载时检查是否有待同步记录 onMounted(async () { const pending JSON.parse(localStorage.getItem(pendingRecords) || []) if (pending.length 0) { for (const item of pending) { try { await request.post(/api/account-record, item) // 同步成功从本地队列移除 const updated pending.filter(p p.id ! item.id) localStorage.setItem(pendingRecords, JSON.stringify(updated)) } catch (e) { // 同步失败保留在队列中下次再试 break } } } loadRecords() // 加载服务器数据 })5. 部署前的三项硬性检查让系统真正“可用”而非“能跑”5.1 检查 application.yml 中的敏感信息是否已脱敏源码中绝不能出现明文数据库密码或 Redis 密码。正确做法是使用 Spring Boot 的外部化配置# application.yml仅配置占位符 spring: datasource: url: ${DB_URL:jdbc:mysql://localhost:3306/account_db?useSSLfalseserverTimezoneAsia/Shanghai} username: ${DB_USER:root} password: ${DB_PASS:123456} spring: redis: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} password: ${REDIS_PASS:}启动时通过 JVM 参数传入真实值java -jar account-system.jar \ --DB_URLjdbc:mysql://prod-db:3306/account_db \ --DB_USERapp_user \ --DB_PASSStrongPass!2024 \ --REDIS_HOSTredis-prod \ --REDIS_PASSRedis2024提示若使用 Docker应通过--env-file方式注入环境变量避免密码出现在命令行历史中。5.2 验证 Vue 打包后的静态资源路径是否正确vite.config.js中必须设置base选项否则部署到子路径如https://example.com/account/时JS/CSS 文件 404// vite.config.js export default defineConfig({ base: /account/, // 与 Nginx location 匹配 build: { outDir: dist } })Nginx 配置片段location /account/ { alias /var/www/account/dist/; try_files $uri $uri/ /account/index.html; }部署后访问https://example.com/account/打开浏览器开发者工具 → Network 标签页确认所有.js、.css请求返回 200且index.html中script标签的src属性为/account/assets/index.xxxxxx.js。5.3 压测单用户高频记账场景找出隐藏的性能瓶颈使用 Apache Benchab模拟单用户连续记账# 模拟 100 次新增支出请求每秒 10 个并发 ab -n 100 -c 10 -H Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... \ -p record.json -T application/json \ http://localhost:8080/api/account-record观察关键指标Time per request平均延迟应稳定在 50ms 以内本地 MySQL SSDFailed requests必须为 0任何失败都意味着事务或连接池配置错误Transfer rate吞吐量应 ≥ 150 KB/sec过低说明序列化/反序列化有开销若延迟飙升检查application.yml中 HikariCP 连接池配置spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000maximum-pool-size小于 10 是常见性能杀手尤其在Transactional方法中存在远程调用或文件 IO 时。记账系统上线前务必用真实手机访问/account/在 Chrome DevTools 的 Lighthouse 中跑一次性能审计确保移动端首屏加载时间 2.5 秒交互响应延迟 100ms。本文还有配套的精品资源点击获取