Springboot+Vue智能记账系统:从搭建到部署的课设全流程实战
简介这是一套面向大学生用户及毕业设计开发者的智能消费记账系统源码案例基于SpringbootVue前后端分离架构包含后端Java接口、前端Vue页面、数据库脚本与可运行配置重点展示账单管理、预算统计与消费数据可视化等完整功能流程。压缩包共352个文件整体约22.93MB文件以java、vue、js及svg为主搭配sql数据库、xml/yml配置、bat启动脚本和mp4演示录屏png/jpg截图便于界面预览目录分类清楚可直接按模块学习或二次开发。系统面向真实校园消费场景设计能帮助学生理解从需求分析、表结构设计到前后端联调、图表展示的软件工程闭环对毕业设计或项目实训具有较强的参考价值。配套安装与启动脚本、说明文档和报表样例方便快速跑通项目目前已有136人学习下载完整度与实用性兼顾。1. 从“月底吃土”到“每笔钱都有去处”这个记账系统到底在解决什么问题大学校园里最典型的财务困境不是收入低而是现金流完全失控月初聚餐、游戏充值、网购剁手一条龙月底靠泡面度日。市面上现成的记账 App 不少但要么广告多要么分类逻辑是给上班族设计的要么数据存在别人服务器上。用一个基于 SpringbootVue 的智能消费记账系统把记账这件事做成课程设计或者个人项目本质上是给自己建一套“消费数据私有化 分类自动化 预算预警”的小工具。它解决的不只是“记一笔”的问题而是让每笔开销自动归类、每月预算可视化、超支有提醒。这套东西适合两类人来搞一是计算机专业学生拿它做毕业设计或课设二是想认真练一遍前后端分离开发、又缺一个真实业务场景的自学者。技术栈覆盖 Springboot、MyBatis-Plus、Vue3、ECharts从数据库设计到接口联调再到图表展示一圈走下来前后端该踩的坑基本都能踩一遍。2. 项目骨架怎么搭从 Springboot 后端到 Vue 前端的工程结构设计2.1 为什么选 Springboot Vue而不选别的组合先说结论这两个框架组合起来是当前国内高校课设和中小型管理系统里最“稳”的选择没有之一。Springboot 解决了传统 SSM 项目里大量 XML 配置的繁琐问题内嵌 Tomcat打 jar 包就能跑对新手极度友好Vue 则把前端从 DOM 操作里解放出来数据驱动视图配合 Element Plus 组件库两天能写出一个像模像样的管理后台。如果换用 Django React当然也能做但在校园场景里答辩老师、课程要求、参考代码的生态都偏向 Springboot Vue 这条线。另外这两个框架的招聘市场认可度也高做完这个项目简历上写“熟悉 Springboot 微服务开发、Vue 前端工程化”比写“了解某小众框架”有说服力得多。选型时还要看部署环境。课设项目通常只要在本地跑通、能用浏览器访问就行Springboot 内置 Tomcat 天然满足Vue 打包后是纯静态文件可以直接扔进 Springboot 的 static 目录也可以用 Nginx 转发。我一般推荐前后端完全分离后端跑 8080 端口前端跑 5173Vite 默认端口联调时通过代理转发这样开发体验最好。2.2 初始化 Springboot 工程从零到能启动的完整命令序列创建一个 Springboot 项目最常见的方式是直接用 IDEA 的 Spring Initializr或者去 Spring Initializr 官网生成后导入。我习惯用命令行的方式生成这样能顺便看到整个依赖树curl -G https://start.spring.io/starter.zip \ -d dependenciesweb,mybatis-plus,mysql,validation,lombok \ -d namesmart-ledger \ -d artifactIdsmart-ledger \ -d packageNamecom.example.ledger \ -d javaVersion17 \ -o smart-ledger.zip unzip smart-ledger.zip参数说明dependencies里的web引入 Spring MVC 和内置 Tomcatmybatis-plus是 MyBatis 增强版提供BaseMapper自动 CRUD省掉大量 XMLmysql是 JDBC 驱动validation用于后端参数校验lombok用注解简化实体类的 Getter/Setter。生成后打开pom.xml确认依赖没问题写一个测试接口启动看看这一步能提前暴露依赖冲突或端口占用问题。2.3 后端目录结构与数据库设计的对应关系后端包结构直接按业务划分我一般开五个包controller放接口层service放业务逻辑mapper放数据库操作entity放数据库表对应的实体类config放跨域、分页等配置。对应地数据库表最少需要四张user用户、category消费分类、record消费记录、budget预算。record表是关键字段至少包含id、user_id、category_id、amount小数精确到分、consume_date、note、create_time。这里有个设计细节金额字段用decimal(10,2)而不是float或double因为浮点数在计算汇总时会出现 0.1 0.2 ! 0.3 的尴尬涉及钱的数字必须精确。建表脚本直接用 Navicat 或 MySQL 命令行执行都可以实体类不要手写用 MyBatis-X 插件一键生成。但要注意生成的实体类默认字段名驼峰映射没问题但consume_date这种下划线字段在 MyBatis-Plus 里需要开启map-underscore-to-camel-case配置默认是开启的如果之前有老项目配置改错过记得检查一下application.yml。spring: datasource: url: jdbc:mysql://localhost:3306/ledger_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这段配置里有两个容易忽略的坑一是serverTimezone必须显式设置否则 MySQL 8 默认时区会让日期差 8 小时二是逻辑删除配置deleted字段建议每张表都加记账数据一旦物理删除就找不回来了逻辑删除相当于留了后悔药。2.4 Vue 前端初始化Vite 比 Vue CLI 快在哪里前端我用 Vite 而不是 Vue CLI核心原因是启动速度。Vue CLI 基于 Webpack冷启动要 10 秒甚至更久Vite 基于 ES Module 按需编译秒开。创建命令npm create vitelatest frontend -- --template vue cd frontend npm install npm install axios vue-router4 element-plus element-plus/icons-vue echarts参数说明axios是 HTTP 请求库vue-router4是 Vue3 对应的路由版本跟 Vue2 的路由写法差异很大别装错element-plus是组件库表单、表格、弹窗都靠它echarts用于消费趋势图和分类饼图。装完之后在vite.config.js里配置代理把前端请求转发到后端 8080// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })代理的意义在于开发环境下前后端端口不同如果不配置代理前端请求localhost:5173/api/xxx会被 Vite 直接返回 404而不是转发给后端。changeOrigin设为 true 是因为后端接口对请求来源比较敏感虽然我们本地开发不需要拦 Origin但加上这个参数能避免某些浏览器跨域策略的诡异问题。3. 把“智能”两个字落到实处自动分类、预算预警与账单 API 设计3.1 自动分类怎么做规则引擎 vs 简单匹配“智能消费记账”最核心的卖点就是不要太手动。用户记一笔“瑞幸咖啡 15 元”系统要能自动归到“餐饮”或“饮品”分类里去。最简单的实现思路是关键词映射在category表里给每个分类配一组关键词例如餐饮类配“食堂、外卖、火锅、奶茶、咖啡”交通配“地铁、公交、滴滴、打车”。后端拿到note文字后遍历关键词做contains匹配命中就自动挂上该分类没命中则落到“其他”。这种方案的准确率大概在 70% 左右大学生日常消费场景够用了。想要更高准确率可以上简单的 TF-IDF 文本分类但需要预训练数据和分词库复杂度会上升一个档次。对于课设项目规则匹配完全够答辩时能讲清楚“基于关键词规则的分类策略为什么简单有效”就行——因为它可解释、零成本、易扩展用户加一个关键词就能修正一条误分类记录。3.2 后端接口怎么写记账、预算、统计三个核心接口后端接口我按 RESTful 风格设计重点是记账接口和月度统计接口。记账接口接收前端 POST 过来的 JSON内部做分类匹配后落库PostMapping(/api/record) public Result addRecord(RequestBody RecordDTO dto) { // 参数校验金额必填大于0且最多两位小数 if (dto.getAmount() null || dto.getAmount().compareTo(BigDecimal.ZERO) 0) { return Result.error(金额必须大于0); } // 调用分类匹配服务若前端没传 categoryId 则自动匹配 if (dto.getCategoryId() null) { Long categoryId categoryService.matchByNote(dto.getNote()); dto.setCategoryId(categoryId); } recordService.save(dto); return Result.success(); }逻辑说明RecordDTO是前端传参的数据传输对象不直接使用实体类避免前端传入deleted或userId造成越权。categoryService.matchByNote是自动分类的核心内部实现就是把 note 切词后和关键词表比对。注意金额比较用了compareTo而不是这是 BigDecimal 比较的规范写法直接用比较的是引用地址会出大问题。月度统计接口则是一条 SQL 搞定GetMapping(/api/analysis/monthly) public Result monthlyStats(RequestParam Long userId, RequestParam String month) { // month 格式: 2025-05后端按该月做分组汇总 ListMapString, Object list recordService .selectMaps(SELECT c.name AS categoryName, SUM(r.amount) AS total FROM record r LEFT JOIN category c ON r.category_id c.id WHERE r.user_id userId AND DATE_FORMAT(r.consume_date, %Y-%m) month GROUP BY c.id ORDER BY total DESC); return Result.success(list); }这段 SQL 用DATE_FORMAT把日期格式化成年-月再比对简单直接。但注意这里用了字符串拼接实际项目必须改成 MyBatis-Plus 的QueryWrapper或Param参数绑定否则存在 SQL 注入风险。课设项目的法官会盯着这个问答不上来会扣分。正确写法是用QueryWrapperQueryWrapperRecord wrapper new QueryWrapper(); wrapper.select(category_id, SUM(amount) as total) .eq(user_id, userId) .apply(DATE_FORMAT(consume_date, %Y-%m) {0}, month) .groupBy(category_id) .orderByDesc(total);apply方法是拼接条件的安全写法{0}会被框架转成预编译参数这才是标准做法。3.3 预算预警怎么实现后端定时任务还是前端查询时判断预算预警有两种做法。一是后端每天跑一个定时任务检查各分类支出是否超预算超过则给用户推送通知二是前端每次查询消费列表时顺带计算本月已支出和预算的差值用进度条展示是否超支。对于课设项目第二种更现实因为不需要引入消息队列或 WebSocket实现成本低。具体做法是在 Budget 表里存月度预算金额写一个接口返回预算金额 / 已消费金额 / 剩余金额 / 超支状态四件套前端拿到后渲染成进度条超支时进度条变红并显示“已超支 XX 元”。这个方案虽然不够“智能”但它是所有方案里最能稳定复现的。有一个 trick超支预警不只是等超了才报警可以在预算使用达到 80% 时前端弹出弱提示“本月餐饮预算仅剩 20%”这个体验比单纯等到超支再提醒好得多。后端接口就多传一个threshold参数GetMapping(/api/budget/status) public Result budgetStatus(RequestParam Long userId) { // 查询本月所有分类的预算使用率使用率80%的标记为warning100%标记为danger return Result.success(budgetService.checkStatus(userId, 0.8, 1.0)); }参数说明0.8表示警告阈值1.0表示超支阈值这两个值定成常量放在配置类里前端也可以拿到做进度条底色切换。4. Vue 前端怎么做从路由守卫到 ECharts 图表展示4.1 页面路由怎么设计登录页、记账页、统计页、设置页前端页面最少四个主页面登录、记账、统计、预算设置。路由配置用 createRouter// src/router/index.js import { createRouter, createWebHistory } from vue-router import Login from ../views/Login.vue import RecordPage from ../views/RecordPage.vue import AnalysisPage from ../views/AnalysisPage.vue import BudgetPage from ../views/BudgetPage.vue const router createRouter({ history: createWebHistory(), routes: [ { path: /login, component: Login }, { path: /, component: RecordPage, meta: { requiresAuth: true } }, { path: /analysis, component: AnalysisPage, meta: { requiresAuth: true } }, { path: /budget, component: BudgetPage, meta: { requiresAuth: true } }, { path: /:pathMatch(.*)*, redirect: / } ] })路由的关键是meta.requiresAuth配合beforeEach没有登录态就跳去/login。Vue3 的createWebHistory是 HTML5 History 模式URL 里没有#好看。但要记住部署到 Nginx 时必须配 fallback否则刷新二级路由页面会 404。4.2 记账页面怎么交互表单校验与分类选择联动记账页是核心操作页要照顾手机端。Enter 键提交、按分类选择按钮自动带上分类名这些细节决定了好不好用。表单部分用 Element Plus 的el-formel-form :modelform :rulesrules refformRef label-width70px el-form-item label金额 propamount el-input v-modelform.amount placeholder0.00 keyup.entersubmitRecord template #prefix¥/template /el-input /el-form-item el-form-item label分类 propcategoryId el-radio-group v-modelform.categoryId el-radio-button v-forc in categories :keyc.id :valuec.id {{ c.name }} /el-radio-button /el-radio-group /el-form-item el-form-item label备注 el-input v-modelform.note placeholder比如食堂午饭 / 地铁充值 / /el-form-item /el-form表单校验规则里amount用自定义校验函数必须是数字、最多两位小数、大于 0。分类为必选但也可以加一个“自动分类”按钮点击后只填备注和金额由后端返回分类结果这个交互正好呼应了自动分类这个功能点。注意el-radio-button的value属性在 Element Plus 版本里必须是:value老版本是label版本差异会让选中态失效。4.3 统计页图表怎么选型和渲染统计页放两张图月度趋势折线图按天汇总消费额分类占比饼图按分类汇总金额。用 ECharts 的init实例化然后setOption填数据。一个常见的坑是图表容器初始化时宽度为 0导致图表显示空白。解决方法是给容器一个固定高度例如400px并且用nextTick等 DOM 渲染完再初始化// 统计页图表初始化 import * as echarts from echarts import { onMounted, nextTick } from vue const trendChart ref(null) const pieChart ref(null) async function loadCharts() { const res await axios.get(/api/analysis/monthly, { params: { userId: store.userId, month: currentMonth } }) // 等DOM渲染完成再初始化容器宽度才能正确获取 await nextTick() // 折线图横坐标天数纵坐标当日消费总额 const chart1 echarts.init(trendChart.value) chart1.setOption({ xAxis: { type: category, data: res.data.days }, yAxis: { type: value, name: 金额(元) }, series: [{ type: line, data: res.data.totals, smooth: true }] }) // 饼图分类名 消费总额 const chart2 echarts.init(pieChart.value) chart2.setOption({ series: [{ type: pie, radius: [40%, 70%], data: res.data.categories }] }) }这里radius: [40%, 70%]是环形饼图的参数内径 40% 外径 70%比实心饼图好看中间还可以放一个总消费额。注意页面切换了路由再切回来时ECharts 实例还在原来已经销毁的 DOM 上会报错所以必须在onUnmounted里调用chart.dispose()销毁实例。4.4 前端请求封装axios 拦截器里藏着的三个坑axios 封装是前端工程质量的分水岭。不加拦截器每个页面都重复写 loading、错误处理代码会迅速失控。我的做法是在src/utils/request.js里创建实例并加两个拦截器import axios from axios import { ElMessage } from element-plus import router from ../router/index const request axios.create({ baseURL: /api, timeout: 5000 }) // 请求拦截器自动携带token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) config.headers.Authorization token return config }) // 响应拦截器统一处理业务码和HTTP错误 request.interceptors.response.use( res { // 后端包装结构 { code, message, data } if (res.data.code 200) return res.data.data ElMessage.error(res.data.message || 请求失败) return Promise.reject(new Error(res.data.message)) }, err { if (err.response err.response.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(err.message || 网络错误) return Promise.reject(err) } )三个坑一是baseURL配了/api那么后端 controller 的 RequestMapping 也要带/api前缀不然前端请求路径对不上二是拦截器里错误提示重复弹出——后端返回业务错误时你ElMessage.error了一次外部 catch 里如果又提示一次会出现两个弹窗三是 token 过期跳转 /login 时要判断当前是否已经在 /login否则会死循环。5. 避坑与排查跨域、时区、精度、分页这几个坑我全踩过5.1 跨域请求失败前端能打开但接口全红现象Chrome 控制台报Access to XMLHttpRequest at http://localhost:8080/api/xxx from origin http://localhost:5173 has been blocked by CORS policyGET 请求也失败POST 请求多出一次 OPTIONS 预检请求。原因前后端端口不同属于跨域。浏览器安全策略先发 OPTIONS 预检后端没有处理 OPTIONS 请求或没有返回正确的响应头。解决配置全局 CORS。写一个 WebMvcConfigurer 配置类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意allowedOriginPatterns和allowCredentials必须这样组合——allowedOrigins(*)在 allowCredentials(true) 时会被浏览器拒绝Springboot 会直接提示不允许使用通配符。5.2 控制台乱码中文显示问号现象后端日志打中文正常但数据库存进去的中文变成??。原因数据库连接字符串没指定characterEncodingutf8。解决回到application.yml的 JDBC URL确认useUnicodetrue和characterEncodingutf8都在。另外建表时注意DEFAULT CHARSETutf8mb4utf8mb4 支持 emoji 表情学生群体记账备注里打 emoji 很常见用纯 utf8 会报错或存成乱码。5.3 金额计算总差几分钱现象月度汇总显示 245.39但逐条相加是 245.40。原因数据库字段用了floatMySQL 的 float 是单精度存储 12.34 时会变成 12.339999。更隐蔽的情况是 Java 后端用double接收。解决数据库字段decimal(10,2)Java 实体类用BigDecimalMyBatis-Plus 插入时不许用setAmount(double)必须从 DTO 接收 BigDecimal 再赋值。前端传回金额时也要注意el-input拿到的是字符串12.34用new BigDecimal(value)构造直接Double.parseDouble再赋值同样会精度丢失。5.4 MyBatis-Plus 分页不生效现象用了Page对象查询但返回的 total 是 0或者分页查询到的数据条数等于全部数据。原因MyBatis-Plus 3.x 需要显式配置分页插件不配置的话limit语句不会自动拼上。解决写一个 MybatisPlusConfig 配置类Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }DbType.MYSQL必须指定数据库类型。不指定的情况下部分版本也能跑但方言自动识别偶尔会出问题尤其当连接池里有多个数据源时。分页接口还要注意Page对象的 current 默认从 1 开始前端传的分页参数不要从 0 开始否则第一页查不到数据。5.5 前端联调时接口通但页面数据不刷新现象记账成功后返回列表页但新记录没出现在表格里。刷新页面数据又出现了。原因列表页在onMounted里加载数据但记账完成后返回列表页时组件实例被复用或缓存没有重新触发onMounted。这通常发生在 Vue Router 的 keep-alive 缓存场景或跳转新路由后再返回时组件复用。解决最稳妥的做法是记账成功后等后端返回再调用列表页的刷新函数。如果列表页和记账页是不同路由可以用事件总线例如 mitt通知列表页重新请求数据如果列表页本身就是当前页下弹窗记账那就直接调loadRecords()。避免过度依赖 keep-alive课设项目没必要上缓存。6. 把项目跑起来之后的进阶收尾一个隐藏功能让整套代码提升一个档次项目基本跑通之后真正拉开差距的是数据可视化的维度。ECharts 折线图和饼图只展示了分类占比和每日趋势但有一个功能描述里写了、学生群体又特别刚需——月度收支日历热力图也就是 GitHub 那种绿色格子图。日历热力图能一眼看出哪几天花钱最多、哪几天是“吃土日”比折线图直观得多而且实现起来不复杂后端返回一个月内每天的消费总额数组前端用 ECharts 的calendar坐标系统绘制。视觉冲击力很强答辩时这块是最能撑场面的。// 日历热力图核心配置 const chart echarts.init(calendarRef.value) chart.setOption({ calendar: { top: 30, left: 40, right: 20, cellSize: [auto, 16], range: currentMonth, splitLine: { show: false } }, visualMap: { min: 0, max: 200, type: piecewise, orient: horizontal, left: center, // 颜色按消费金额从浅到深超过200元显示最深色 pieces: [ { gt: 0, lte: 50, color: #c8e6c9 }, { gt: 50, lte: 100, color: #81c784 }, { gt: 100, lte: 200, color: #388e3c }, { gt: 200, color: #1b5e20 } ] }, series: [{ type: heatmap, coordinateSystem: calendar, data: dailyTotals }] })日历热力图的数据格式是[日期, 金额]的二维数组后端接口把consume_date按天 group by 就行。这里有个小坑——按月请求的数据如果某一天没有消费记录前端补齐 0 值才能让格子显示成浅色否则会漏格。补齐逻辑可以放在前端循环也可以让后端返回 0 补位我推荐后端做前端少一步处理。做完这个功能还建议把项目的README.md写完整技术栈版本、数据库初始化脚本、启动步骤、默认账号密码。这不仅是课设答辩的加分项也是你半年后回头维护时的救命文档。我自己的习惯是每改一次代码就更新 README哪怕只是改了一个端口号不然下次启动时又要查半天配置。整套系统从决策到跑通大概一周时间两天搭后端、一天搭前端框架、一天做图表和交互、两天联调和修坑。中途最容易放弃的点不是技术难题而是 CORS 跨域、时区乱码、ECharts 容器空白这种“单个问题卡一晚上”的玄学故障。遇到问题先看控制台报错再查配置最后才怀疑代码逻辑带着这个排查顺序走基本都能兜回来。希望这个方向能帮你在课设或项目里少走一圈弯路把精力省下来真正吃透前后端交互的那套思路。本文还有配套的精品资源点击获取