前前后后折腾了一个多月,这套面向大学生场景的前后端分离智能消费记账系统,终于从原型变成了能直接部署上线的完整项目。技术栈就是很经典的 SpringBoot + Vue + MyBatis + MySQL,源码和部署教程都整理进了仓库。做这个项目不只是为了交差,更多是想把日常记账的痛点捋清楚,再用一套主流技术把它落地。这篇文章会把需求分析、架构选型、核心功能实现、部署步骤,还有我实际踩过的坑全部写出来,给正在做课程设计、毕业设计,或者单纯想提升全栈能力的开发者一个能直接参考的样例。
1. 项目背景与设计思路
1.1 为什么选“大学生智能消费记账”这个方向
市面上记账类产品不算少,但大部分都是面向泛人群的通用工具,对于大学生这个特殊群体,反而缺少针对性。大学生的收入来源相对固定,主要就是生活费、兼职工资、奖学金这几类,支出场景又高度集中在食堂、网购、交通、学习资料、娱乐社交这些分类里。通用记账软件的分类体系往往太复杂,记账门槛高,很多人坚持不了几天就放弃了。
这个项目就想解决三件事:第一,把记账这件事做得足够轻,用户添加一笔账单的时间控制在十秒以内;第二,通过智能分类减少手动选择分类的负担,系统根据商家描述和历史记录自动给出分类建议;第三,按月度做预算管理,生活费花到一定比例后给出提醒,避免“月初挥霍,月末吃土”的尴尬。
项目的定位是“智能消费记账系统”,核心不是把账记完就结束,而是要能反映消费结构、提示预算风险。所以在设计时重点放了两个模块:一个是对账单数据的统计分析和可视化,另一个是基于规则的智能分类引擎。
1.2 前后端分离架构怎么定下来的
选择前后端分离不是跟风,而是这个项目的实际需要。记账系统的业务逻辑虽然算不上特别复杂,但前端有大量的交互场景:账单列表需要筛选、统计页面需要各种图表、预算提醒需要实时反馈。如果还用传统模板引擎渲染,前后端代码会越写越纠缠,后期维护成本很高。
前后端分离最直接的好处是开发和部署可以完全独立。前端团队和后端团队只需要约定好接口文档,就能并行开发。对我个人来说,这种结构也方便在本地调试:前端用 Vite 代理解决跨域,后端用 Swagger 生成接口文档,两边互不阻塞。
另一个原因是部署灵活。最终部署方案里,前端构建成纯静态文件交给 Nginx 托管,后端打成 jar 包运行在 Java 环境里,两者通过 HTTP 通信。静态资源可以单独上 CDN,后端也可以随时扩容,结构很清爽。
2. 技术栈选型与工程结构
2.1 SpringBoot + MyBatis + MySQL 为什么够用
这个项目的后端没有选择 Spring Cloud 那套微服务体系,也没有引入 MongoDB、Redis 之类的重型组件,骨架就是 SpringBoot + MyBatis + MySQL。原因很简单:项目规模决定了技术复杂度应该控制在够用范围内。
| 技术栈 | 在这个项目里的职责 | 为什么这么选 |
|---|---|---|
| SpringBoot | Web 接口、事务管理、参数校验 | 生态成熟,自动化配置省去大量 XML |
| MyBatis | SQL 映射、动态 SQL | 团队对 SQL 更可控,复杂报表查询容易调优 |
| MySQL | 核心数据持久化 | 关系型结构适合账单流水,支持事务,免费易部署 |
MyBatis 在这类管理系统中其实比 JPA 更好用。账单统计经常需要按时间范围、分类、月份做动态条件查询,MyBatis 的<where>和<foreach>标签可以很灵活地拼 SQL,不用怕生成一堆冗余条件。后面做预算累计、分类占比这类聚合查询时,直接写 SQL 也直观得多。
2.2 后端工程结构与统一响应封装
后端采用经典的分层结构:
com.example.account ├── controller ├── service ├── mapper ├── entity ├── dto ├── vo ├── common ├── config └── utilsentity 对应数据库表结构,dto 接收前端请求参数,vo 返回前端展示数据,三者分开是为了避免把数据库实体直接暴露给前端。尤其是账单列表接口,前端需要用户名、分类名、标签等关联信息,直接在 VO 里组装比前端再做二次请求要高效得多。
统一响应结构是我比较坚持的一点。所有接口返回格式固定为:
public class Result<T> { private Integer code; private String message; private T data; }code 为 200 表示成功,其他为业务错误码。全局异常处理器会把参数校验异常、业务异常、未知异常统一转换成这个结构。这样做的好处是前端 axios 拦截器只需要处理一种错误格式,登录过期、参数错误、服务器异常全部走统一逻辑,不用每个接口单独判断。
2.3 前端 Vue 工程化与请求层设计
前端用的是 Vue3 + Vite + Vue Router + Pinia + Axios,组件库选了 Element Plus。这组合在 2024 年已经很常规,社区资料多,遇到问题基本都能搜到答案。
请求层是前端最容易写乱的地方。我在src/utils/request.js里封装了一个 axios 实例:
const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, error => { ElMessage.error(error.response?.data?.message || '网络异常') return Promise.reject(error) } )这个封装解决了两个高频问题:一是 token 统一注入,不用在每个接口手动带;二是后端返回的 Result 结构在响应拦截器里被自动解包,页面里拿到的直接就是 data,少一层嵌套。
2.4 数据库表设计
数据库是这个项目的地基。我一开始设计了八张表,后来精简到五张核心表:用户表、账单流水表、分类表、预算表、标签表。最核心的账单流水表设计如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| user_id | bigint | 用户ID |
| category_id | bigint | 分类ID |
| amount | decimal(10,2) | 金额 |
| type | tinyint | 支出/收入 |
| trade_time | datetime | 消费时间 |
| merchant | varchar | 商家或描述 |
| remark | varchar | 备注 |
| created_at | datetime | 创建时间 |
金额字段必须用 decimal 而不是 float 或 double,这是记账系统的基本常识。float 在二进制中无法精确表示大多数小数,累计几十笔账单后可能出现几分钱的误差。decimal(10,2) 保证了精度,Java 侧用 BigDecimal 接收。
索引设计上,账单表建了(user_id, trade_time)联合索引,因为最频繁的查询是“查某个人某段时间的账单”。分类表保持相对固定,预置了餐饮、交通、购物、学习、娱乐、医疗、居住、其他等分类。
3. 核心功能怎么实现
3.1 登录注册与 JWT 认证
登录认证用的是 JWT。用户注册时密码用 BCrypt 加密存储,登录成功后后端签发 token,前端把 token 存到 localStorage 并在后续请求中携带。
密码加密为什么不用 MD5?MD5 是摘要算法,撞库风险极高,同样密码能生成相同摘要,彩虹表一查就破。BCrypt 会随机生成 salt,同一个密码每次加密结果不同,而且计算成本可以调节,暴力破解成本高得多。代码如下:
// 注册时加密 String encodedPassword = BCrypt.hashpw(user.getPassword(), BCrypt.gensalt()); // 登录时校验 boolean matched = BCrypt.checkpw(rawPassword, user.getPassword());JWT 的 secret 放在了配置文件的jwt.secret字段,签发时设置 24 小时过期。后端用拦截器统一校验,白名单包含/api/auth/login、/api/auth/register和 Swagger 文档地址。后续版本我还想引入 Redis 做 token 黑名单,目前项目规模下 JWT 无状态方案已经够用。
3.2 智能分类:规则优先,留好扩展位
智能分类是项目的核心亮点。一开始我考虑过用机器学习方案,给每个分类打标签训练文本分类模型。但仔细评估后发现,大学生账单的商家描述普遍很短,比如“食堂”“美团”“地铁”,而且训练数据需要大量人工标注,维护成本远远大于收益。最终我改用“规则引擎 + 关键词匹配 + 用户反馈”的混合方案。
规则分三层:
- 第一层是硬规则:某些关键词直接命中分类,比如“食堂”“餐厅”“外卖”命中餐饮,“地铁”“公交”“打车”命中交通。
- 第二层是历史行为:如果用户在过去一个月内把“某某超市”归为购物,本次遇到相同商家描述就沿用上次分类。
- 第三层是默认分类:以上都没命中时,默认进入“其他”,让用户在保存前手动修改。
关键词匹配的实现并不复杂,用 Java 的contains循环判断即可,关键是要整理一份分层关键词表。我在代码里把关键词表放在单独的配置类中,每次新增关键词不需要改业务代码,维护性很好。
这个方案上线后的实测效果不错,餐饮和交通这两个高频分类准确率可以达到八成以上,因为大学生消费场景相对固定,高频分类集中在少数几个类目上。
3.3 统计报表与预算提醒
统计页面是另一个投入比较多的模块。前端用 ECharts 展示三块内容:月度支出趋势折线图、分类占比饼图、近六个月收支柱状图。
后端聚合查询用GROUP BY实现,比如查分类占比的核心 SQL:
SELECT c.category_name, SUM(b.amount) AS total_amount FROM bill b JOIN category c ON b.category_id = c.id WHERE b.user_id = #{userId} AND b.type = 0 AND b.trade_time BETWEEN #{startDate} AND #{endDate} GROUP BY b.category_id ORDER BY total_amount DESC注意这里没有直接取全部账单到内存再分组,那样数据量大了以后接口会越来越慢。MySQL 的 GROUP BY 聚合非常快,只要索引设计合理,支持万级数据量完全没问题。
预算提醒功能是在后端封装了一个BudgetService,每月 1 号初始化月度预算,之后每次查询账单时计算当月累计支出。如果支出超过预算的 80%,接口返回一个warningLevel字段,前端在首页弹出提示条;超过 100% 则显示红色警告。这种方式不需要额外引入消息队列或者定时任务,在项目规模下最务实。
4. 从源码到部署
4.1 环境准备
源码要在本地跑起来,必须先检查环境。我整理过一份依赖清单:
| 软件 | 版本要求 | 用途 |
|---|---|---|
| JDK | 1.8 以上,推荐 11 | 运行后端 |
| Maven | 3.6 以上 | 依赖管理 |
| MySQL | 5.7 或 8.0 | 数据存储 |
| Node.js | 16 以上 | 前端构建 |
| Nginx | 1.20 以上 | 部署静态资源并反向代理 |
我项目里用的 SpringBoot 版本是 2.7.x,对应 JDK 8 和 JDK 11 都兼容。如果电脑里已经装了 JDK 17,注意 SpringBoot 需要升级到 2.7 以上才完全支持,不然启动可能报模块化相关的错。
4.2 后端打包与配置
后端配置文件集中在application.yml,核心几个配置项:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/account_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.account.entity jwt: secret: your-secret-key expire: 86400这里最容易踩坑的是数据库连接地址里的serverTimezone参数。如果不配 Asia/Shanghai,数据库连接时会报空指针或者时间差 8 小时。另外 MySQL 8.0 的驱动类名是com.mysql.cj.jdbc.Driver,老版本驱动是com.mysql.jdbc.Driver,不要照旧代码抄。
打包直接用 Maven:
mvn clean package -DskipTests打出来的 jar 包在target/目录下,约 60MB 左右。运行:
java -jar account-backend-1.0.0.jar4.3 前端构建与 Nginx 转发
前端构建前先检查.env.production文件:
VITE_API_BASE_URL=/api生产环境的请求地址用相对路径/api,由 Nginx 转发到后端,这样不会产生跨域问题。然后执行:
npm install npm run build构建产物在dist/目录下,整个目录上传到服务器后,Nginx 配置如下:
server { listen 80; server_name your-domain.com; root /var/www/account/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { try_files $uri $uri/ /index.html; } }try_files那一行是必须的,否则前端路由使用 history 模式时,页面刷新会出现 404。把请求都指向index.html,让 Vue Router 自己处理路由就行。
4.4 部署为后台服务
服务器上直接用java -jar跑后端有一个问题,SSH 断开进程就退了。我习惯用 systemd 管理后端服务,新建/etc/systemd/system/account.service:
[Unit] Description=Account Backend Service After=network.target [Service] User=www WorkingDirectory=/opt/account ExecStart=/usr/bin/java -jar /opt/account/account-backend-1.0.0.jar Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target启动命令:
systemctl daemon-reload systemctl start account systemctl enable accountRestart=on-failure是常规操作里特别重要的一项,后端进程崩溃后能自动拉起,省心很多。
5. 常见问题与排查实录
5.1 跨域问题
前后端分离开发时,前端跑在 5173 端口,后端跑在 8080 端口,直接请求必然跨域。我前面提到生产环境用 Nginx 同源转发解决,开发环境有两种常见方案。
第一种是后端加@CrossOrigin或全局跨域配置:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowCredentials(true) .maxAge(3600); } }第二种是前端 Vite 配置代理:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }我实际开发时用的是第二种,好处是前端代码不用区分环境,请求路径始终是/api/xxx,切换到生产环境后配置不用改动。如果发现登录成功但请求带不上 Cookie,多半是跨域配置里的allowCredentials(true)和allowedOriginPatterns("*")冲突,需要把allowedOrigins写具体。
5.2 日期和金额的坑
日期问题是最隐蔽的一个坑。用户在某高校测试时反馈,明明 3 号记了一笔账,查月度账单时却显示在 2 号。排查后发现两个原因叠加:
- MySQL 连接串里没加
serverTimezone=Asia/Shanghai,驱动默认使用了服务器时区,本机和 MySQL 的时区不一致导致数据偏移。 - 前端通过时间戳传参,后端用
new Date(timestamp)解析后分别传入startDate和endDate,结果endDate是当天 00:00:00,把当天的账单排除掉了。
正确做法是日期范围查询时,结束日期 +1 天或者直接用< 次日零点的方式。SQL 里我写成:
trade_time >= #{startDate} AND trade_time < #{endDate}金额的坑是序列化问题。BigDecimal 在后端和前端之间传输时,如果直接用 JSON 数字格式,长位数的金额可能被前端 JS 解析出精度误差。我在后端配置里把 BigDecimal 统一序列化成字符串,保证金额在后端计算、前端展示两个环节都不丢精度。
5.3 刷新后 404
这个坑在前面 Nginx 部分提到过,凡是用了 Vue Router history 模式,部署到 Nginx 后一定要配try_files $uri $uri/ /index.html。如果忘了配,用户在首页跳转到账单管理页后按一下刷新,浏览器会按照/bill路径去服务器找文件,服务器找不到就返回 404。
有同事遇到过另一种情况是配了try_files但放在location /api里面,导致接口请求也被转发到 index.html,接口返回的是 HTML 而不是 JSON,前端整个白屏。排查时先看 Network 面板,如果接口响应头Content-Type是 text/html,基本就是这个原因。正确做法是try_files只放在前端页面根路径处理块里,接口转发块独立处理。
5.4 数据库连接池超时和连接泄漏
系统长时间运行后,偶尔出现“连接池获取连接超时”的报错。检查发现是代码里有一次查询用了Stream流式处理,但是没有在 finally 块里关闭资源,导致连接没归还。
项目用了 HikariCP 连接池,默认最大池大小是 10,一旦有连接泄漏,很快就占满。排查思路如下:
show processlist看 MySQL 当前连接数,如果大量 Sleep 连接,说明代码里资源泄漏。- 在后端日志里搜
Connection is not available关键字,定位报错时间点。 - 检查 MyBatis Mapper 方法是否有返回
List但查询内部手动创建了Statement,这类情况必须配套 try-with-resources。
修复后我还给 HikariCP 配了connection-timeout: 30000和maximum-pool-size: 20,降低峰值连接压力。这个坑给整个项目提了个醒,任何涉及 IO 的操作,资源释放必须放在 finally 或者 try-with-resources 里。
我做完整套项目之后的体会是,前后端分离记账系统真正难的不是某个框架怎么用,而是把业务流程理清楚之后,再把技术选型控制在一个合理的复杂度内。很多初学者容易一上来就堆技术栈,Redis 缓存、消息队列、微服务全都上,最后项目不仅没跑起来,维护成本反而把自己压垮了。这个项目用最基础的 SpringBoot + Vue + MyBatis + MySQL,照样把智能分类、预算提醒、数据可视化这些亮点功能都做出来了,说明技术服务于业务才是正路。希望这份源码和部署教程能帮到你,有问题欢迎在评论区交流。