简介:基于SpringBoot的大学生智能消费记账系统,是一套前后端分离的完整毕业设计/课程设计源码包,面向Java学习者、高校学生以及需要快速搭建记账类管理系统的开发者。系统分为管理员与用户两个视角:管理员可管理用户信息、预算及租赁信息,并与用户在线交流;用户则可查看消费信息、预算和管理员回复,辅助理性消费。采用SpringBoot+MyBatis+MySQL 5.7技术栈,配备Maven构建环境,并支持Eclipse/IDEA导入直接运行,便于二次开发。压缩包共375个文件,约15.6MB,含85个Java后端源码、46个Vue前端组件、161个SVG图标,以及SQL数据库脚本、XML配置、JS/CSS、bat启动脚本、doc设计文档和演示音视频等,资源类型覆盖开发、部署到说明的完整链路。已有61人学习下载,特别适合作为课程设计或毕业设计的参考模板,也可帮助读者高效掌握前后端分离项目的结构设计与部署细节。
1. 像“基于 Spring Boot 的大学生智能消费记账系统”这类题目,表面是给单表 CRUD 套一层网页壳,实际动手后多数人会在两个节点上返工:一个是统计口径——删除的记录有没有算进月报,跨月消费按哪天统计,前端后端各算各的,报表永远对不上;另一个是前后端分离项目实战里的联调——日期格式、token 头、端口跨域,每一项都能让接口直接报 500 或 401。“智能”在这类系统里不等于算法,它体现在自动分类、超支提醒、月度报表这些贴近使用场景的功能上。完整前后端加 MySQL 的立项重点,不在代码厚度,而在统计和联调两条链路是否稳定。下文按表结构设计、Spring Boot 接口、Vue 联调、本地部署排错的顺序推进,代码片段可以直接抄进自己的工程。
2. 表结构决定统计口径:大学生消费场景的 6 张表怎么拆
系统设计里最容易被低估的是删除策略。很多实现把删除做成物理 DELETE,统计时再靠一堆条件过滤,等要对账或回溯时才发现历史数据没了,月报数字也随之改变。常见做法是加一个deleted软删除标志,同时把月报汇总独立成一张冗余表,让统计结果固化下来,而不是每次现算;自动分类规则再单放一张配置表。这 6 张表的分工定下来,后面接口怎么拆就都顺了。
2.1 用户、分类、消费记录三张主表的字段设计
用户表不需要做出花来,id、用户名、密码哈希、昵称、创建时间就够,最多再加一个月预算字段,首页展示生活费剩余时少一次查询:
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL UNIQUE, password_hash VARCHAR(128) NOT NULL, nickname VARCHAR(32) DEFAULT '', month_budget DECIMAL(10,2) DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;密码字段保存 bcrypt 加密结果,长度给到 128 是为了兼容后续换加密算法。month_budget 对大学生场景很实用,设置每月生活费后,前端首页可以直接显示“本月剩余”,比单独写一个预算管理的页面成本低得多。金额用 DECIMAL(10,2) 存元方便展示,但代码层必须用 BigDecimal 运算,不能拿 double 传参做累加,否则报表会出现 0.1 + 0.2 不等于 0.3 的问题。
消费记录表要区分两个时间字段,这是统计口径的第一个关键点:
CREATE TABLE consume_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, category_id BIGINT NOT NULL, merchant VARCHAR(128) DEFAULT '', note VARCHAR(255) DEFAULT '', consume_time DATETIME NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, deleted TINYINT DEFAULT 0, KEY idx_user_time (user_id, consume_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;create_time 是记录落库的时间,consume_time 是用户选择实际消费的时间,月度报表只能以 consume_time 为基准。这个口径要在 Service 层统一约束,前端不能为了“修正”跨月记录去按创建时间匹配,否则两个月报表一对比就漏账。索引直接用(user_id, consume_time)联合索引,正好覆盖列表页最常出现的WHERE user_id=? AND consume_time BETWEEN ? AND ?查询模式。
分类表要支持支出、收入、不计入统计三态。很多新手只做收入和支出,遇到退款、押金、转账就会卡住:
CREATE TABLE consume_category ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT DEFAULT 0, parent_id BIGINT DEFAULT 0, name VARCHAR(32) NOT NULL, type TINYINT DEFAULT 1 COMMENT '1支出 2收入 0不计入统计', icon VARCHAR(64) DEFAULT '', sort_order INT DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;type 字段决定月报里金额是加还是减,比在查询 SQL 里到处写 CASE WHEN 可靠。user_id=0 表示系统预置分类,比如餐饮、购物、交通、水电、娱乐、兼职收入;用户自己新增分类时 user_id 用当前用户的 id。这里有个实际容易犯的错:如果同一个用户建了两个同名分类,前端下拉框和报表都会乱,保存分类前需要先做一次 name 去重。
2.2 月度汇总冗余表:把统计结果固化而不是每次现算
报表页每次都用 GROUP BY 聚合,数据量小时看不出来,但接口耗时随记录数线性上涨,而且环比、分类占比这类计算逻辑会在多个接口里重复出现。更稳的设计是加一张月度汇总表,把统计结果提前算好:
CREATE TABLE monthly_summary ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, summary_year INT NOT NULL, summary_month INT NOT NULL, total_expense DECIMAL(12,2) DEFAULT 0, total_income DECIMAL(12,2) DEFAULT 0, category_stats_json TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_year_month (user_id, summary_year, summary_month) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;category_stats_json 里存一段 JSON 数组:[{"name":"餐饮","amount":1234.5},{"name":"交通","amount":200}]。前端打开月报时只查这一行,分类占比和总额都能一次拿到。MySQL 5.7 以上支持 JSON 类型,也可以用 TEXT 类型,不影响功能。更新时机放在记账的 Service 层,插入、删除、修改三条链路绑同一个事务,不能放到 Controller 里离散调用,否则明细写成功、汇总写失败,数据就是脏的。
2.3 实现“智能”的自动分类规则表与索引选择
标题里的“智能”落在大学生场景里,最容易被感知的就是自动分类。用户在输入商户名“瑞幸”后,分类自动落到“咖啡/饮品”,省去手动选分类的动作。规则表设计如下:
CREATE TABLE category_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, category_id BIGINT NOT NULL, keyword VARCHAR(64) NOT NULL, priority INT DEFAULT 0, user_id BIGINT DEFAULT 0, UNIQUE KEY uk_user_keyword (user_id, keyword) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;匹配逻辑很简单:插入消费记录时,把 merchant 和 note 拼接成一个字符串,按优先级从高到低判断 keyword 是否包含,命中第一条就停止,把 category_id 回填到记录上。全局规则用 user_id=0,用户自定义规则用当前登录用户 id,个人规则优先。
| 优先级 | 匹配范围 | 示例 |
|---|---|---|
| 用户自定义规则 | user_id = 当前用户 | 关键词“瑞幸”归入“咖啡” |
| 系统默认规则 | user_id = 0 | 关键词“食堂”归入“餐饮” |
匹配时统一转小写,避免“瑞幸”和“瑞幸咖啡”因为全半角不一致而漏判。关键词别设太长,5 个字以内命中率更高。索引方面,前面三张主表已经覆盖主要查询;monthly_summary 的唯一键(user_id, summary_year, summary_month)本身就是查询条件,B+ 树一次就能定位到对应行。
3. Spring Boot 后端把删除、事务和统计接口写对,接口层就稳了
后端用 Spring Boot 3 还是 2.7,取决于手头 JDK 版本。Spring Boot 3 要求 JDK 17,servlet 相关的包名从 javax 换成了 jakarta,网上很多老教程抄过来会报“程序包 javax.servlet 不存在”。Spring Boot 版本太高不用怕,在 IDEA 创建 Spring Boot 项目时选对 Java 版本,比事后改 pom 省事。下面按 Spring Boot 3.x 演示,差异点会单独说明。
3.1 工程结构与依赖选择
工程按 controller、service、mapper 三层切分,加 config 包放拦截器和跨域配置:
src/main/java/com/example/ledger/ ├── controller/ # 登录、记账、报表接口 ├── service/ # 业务校验、事务、统计逻辑 ├── mapper/ # MyBatis-Plus Mapper ├── entity/ # 表实体 ├── config/ # WebConfig、AuthInterceptor └── util/ # JwtUtil、DateUtilpom.xml 里最小依赖配置如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>选 MyBatis-Plus 而不是 JPA,是因为这个项目的查询条件变化多,但表关系简单。MyBatis-Plus 的动态条件构造顺手,统计类的复杂 SQL 又可以用@Select注解手写,两种模式混用不冲突。连接池不用额外引 druid,Spring Boot 默认的 HikariCP 对这个体量已经够用。
3.2 JWT 登录与请求拦截器的实现细节
只做登录和登录态校验,不需要引入 Spring Security。自己写拦截器更容易看清链路。JWT 生成代码:
public class JwtUtil { private static final SecretKey KEY = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); public static String generateToken(Long userId) { return Jwts.builder() .setSubject(String.valueOf(userId)) .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 3600_000L)) .signWith(KEY) .compact(); } public static Long parseUserId(String token) { Claims claims = Jwts.parserBuilder() .setSigningKey(KEY) .build() .parseClaimsJws(token) .getBody(); return Long.valueOf(claims.getSubject()); } }token 有效期大学生记账系统给 7 到 30 天都合理,太短的话写作业写到一半就要重新登录。secret 放在 application.yml 里,不要写死在代码中。secret至少 32 字节,密钥太短启动时就会报错。
拦截器配置的常见错误是漏掉排除路径:
registry.addInterceptor(authInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/auth/login", "/api/auth/register");如果登录接口也被拦截,前端请求永远拿不到 token,控制台还会看到 401。拦截器里解析不到 token 时统一返回{ "code": 401 },不要把异常继续往上抛,否则响应结构可能和全局异常处理不一致。
3.3 记账、软删除与月度汇总的原子更新
新增消费记录时,入参用 DTO,不要直接接收实体,因为 createTime、userId 这类字段只能由服务端决定。核心逻辑是:解析 token 拿 userId,校验金额大于 0,查分类是否存在,插入明细,最后更新月度汇总。
@Transactional public Long addRecord(RecordCreateDTO dto, Long userId) { ConsumeRecord record = new ConsumeRecord(); record.setUserId(userId); record.setAmount(dto.getAmount()); record.setCategoryId(dto.getCategoryId()); record.setConsumeTime(dto.getConsumeTime()); record.setMerchant(StringUtils.trimToNull(dto.getMerchant())); record.setNote(StringUtils.trimToNull(dto.getNote())); consumeRecordMapper.insert(record); if (categoryTypeService.isIncome(dto.getCategoryId())) { monthlySummaryMapper.upsert(userId, yearMonth, dto.getAmount(), 0); } else { monthlySummaryMapper.upsert(userId, yearMonth, 0, dto.getAmount()); } return record.getId(); }upsert 采用 MySQL 的原生语法,比先 SELECT 再 UPDATE 更安全,不会因为两个请求同时操作同一行而丢更新:
INSERT INTO monthly_summary (user_id, summary_year, summary_month, total_expense, total_income) VALUES (#{userId}, #{year}, #{month}, #{expense}, #{income}) ON DUPLICATE KEY UPDATE total_expense = total_expense + VALUES(total_expense), total_income = total_income + VALUES(total_income);删除操作同样要带 userId 条件,防止用户拿别人的记录 id 删数据:
@Update("UPDATE consume_record SET deleted=1 WHERE id=#{id} AND user_id=#{userId}") int softDelete(@Param("id") Long id, @Param("userId") Long userId);软删除记录后,月度汇总要重新生成。最简单的做法是重算当月所有记录:SELECT category_id, SUM(amount) FROM consume_record WHERE user_id=? AND consume_time BETWEEN ? AND ? AND deleted=0 GROUP BY category_id,把结果写回 category_stats_json。月度数据量小,重算成本可以忽略。
3.4 月度统计与环比的接口实现
月报接口返回当月总额、收入、支出、分类占比、环比比例。环比在 Java 里算,避免依赖 MySQL 8.0 才支持的窗口函数:
MonthlySummary current = summaryMapper.selectByYearMonth(userId, yearMonth); MonthlySummary last = summaryMapper.selectByYearMonth(userId, yearMonth.minusMonths(1)); BigDecimal currentExpense = current == null ? BigDecimal.ZERO : current.getTotalExpense(); BigDecimal lastExpense = last == null ? BigDecimal.ZERO : last.getTotalExpense(); BigDecimal rate = lastExpense.signum() == 0 ? BigDecimal.ZERO : currentExpense.subtract(lastExpense) .divide(lastExpense, 4, RoundingMode.HALF_UP);divide时必须指定小数位和舍入模式,否则除不尽会直接抛ArithmeticException,这个坑在面试和线上都很常见。分类占比从 category_stats_json 解析后返回;如果 JSON 数据缺失,临时跑一次聚合查询兜底,再把结果写回汇总表。
4. 前端 Vue 与后端联调:token 注入、日期传参和图表数据格式
前端用 Vue 3 + Vite,Element Plus 组件库,图表用 ECharts。页面结构包括登录、记账表单、流水列表、月报看板四个核心视图。路由用 hash 模式,部署到 Tomcat 或 Nginx 下刷新不会 404。前后端联调阶段最常出问题的不是业务逻辑,而是 axios 封装和日期传参。
4.1 路由分组与页面模块划分
路由设计成两级:未登录只允许访问 /login,登录后进入 /dashboard。用路由守卫判断 localStorage 里有没有 token。记账表单单独拆成RecordForm.vue组件,流水页和首页弹窗都要复用,组件内部负责拉取分类树。分类树接口返回children嵌套结构,前端展开成平面列表,传给 ECharts 饼图时再映射一次。
页面之间不引入 Vuex/Pinia 这类状态管理库,跨页共享的用户信息就一个 token,存在 localStorage 足够;页面内部数据用组件的 ref 维护,刷新后重新请求即可。
4.2 axios 封装里两个必须处理的坑
第一个坑是 token 注入。请求拦截器里从 localStorage 取 token,拼成Authorization: Bearer <token>再发出去:
service.interceptors.request.use(config => { const token = localStorage.getItem('ledger_token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; });第二个坑是 401 响应时必须跳登录页。这里用window.location.hash而不是router.push,因为响应拦截器里导入 router 实例容易产生循环依赖,打包时经常报奇怪的 warning:
service.interceptors.response.use( response => response.data, error => { if (error.response?.status === 401) { localStorage.removeItem('ledger_token'); window.location.hash = '#/login'; } return Promise.reject(error); } );这类 vue 前后端分离请求 token 处理,本质上就是两件事:请求时保证带,响应时保证跳,中间不要掺入业务判断。
4.3 日期范围查询:从组件到后端的时间口径统一
日期范围选择组件用value-format="YYYY-MM-DD",v-model 拿到的就是字符串而不是 Date 对象,JSON 序列化后不会变成时间戳:
<el-date-picker v-model="queryDateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" />后端接收时用@DateTimeFormat解析,结束日期统一加一天再比较:
@GetMapping("/records") public Result<PageResult<RecordDTO>> list( @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate, @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate, @RequestParam(defaultValue = "1") Integer page) { LocalDateTime start = startDate.atStartOfDay(); LocalDateTime end = endDate.plusDays(1).atStartOfDay(); return Result.ok(recordService.pageQuery(userId, start, end, page)); }SQL 里写成consume_time >= #{start} AND consume_time < #{end},让结束日期包含当天 23:59:59 的记录。如果前端组件没配 value-format,Date 对象默认序列化成带时区的 ISO 字符串,比如2024-05-31T16:00:00.000Z,解析后比预期多一天,这是联调阶段必踩的坑。
4.4 ECharts 图表与后端字段格式匹配
月报饼图需要分类名称和金额两个数组,后端返回的 categoryStats 是对象数组,前端做一次映射:
const pieData = categoryStats.map(item => ({ name: item.categoryName.split('>').pop(), value: Number(item.amount) }));分类名可能带层级前缀,比如“餐饮>早餐”,饼图直接展示太宽,取>后面的子分类更清晰。Number(item.amount)比 parseFloat 严格,能避免 DECIMAL 序列化成字符串后前端做加法时变成拼接。
前后端返回类型对应关系可以按下面这张表对齐:
| 前端字段 | 后端类型 | 序列化结果 |
|---|---|---|
| amount | BigDecimal | 字符串“12.50”,前端转 Number |
| consumeTime | LocalDateTime | 按yyyy-MM-dd HH:mm:ss输出 |
| categoryStats | List | 对象数组 |
5. 从源代码包把系统拉起来:启动顺序、配置要点与报错表
5.1 启动的最小顺序
源代码包解压后,一般按 db → backend → frontend 的顺序执行。先建库再导入脚本,避免后端启动时连不上库:
mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS ledger DEFAULT CHARACTER SET utf8mb4;" mysql -u root -p ledger < db/init.sql然后改后端application.yml里的数据源配置。如果用 IDEA 打开工程,Maven 会自动下载依赖,等进度条跑完再启动。JDK 版本如果比项目声明的版本高,把 pom.xml 里的<java.version>改成和本机一致,否则编译会报invalid target release。
5.2 三个值得调的 Spring Boot 配置项
数据源连接池参数,本地体验不追求并发,给一个保守值就行:
spring: datasource: hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000Jackson 全局时间格式配置成yyyy-MM-dd HH:mm:ss,否则后端返回的 LocalDateTime 会变成数组,前端组件没法直接赋值。第三项是 MyBatis-Plus 的 map-underscore-to-camel-case,默认已开启,如果手写 SQL 里用了下划线别名,确认这个配置没被关掉。
5.3 本地调试高频报错表
| 现象 | 根因 | 处理 |
|---|---|---|
Access denied for user 'root'@'localhost' | 数据库账号密码不对 | 改 application.yml 实际密码 |
Unknown database 'ledger' | 建库语句没执行 | 先执行 5.1 的建库命令 |
| 前端请求一直 401 | 登录接口被拦截器拦住 | 拦截器排除路径加 auth 相关 |
| 浏览器跨域报错 | 后端 CORS 没配前端端口 | WebMvcConfigurer 里加 Vite 地址白名单 |
| 日期查询结果少一天 | 结束日期没做 plusDays(1),或前端时区转换 | 组件用 value-format,后端加一天比较 |
| 端口被占用 | 8080 或 5173 已被其他进程占用 | 改 server.port 或 npm run dev 的 --port |
Unknown column 'deleted' in 'where clause' | 导入的不是最新 init.sql | 删除库重新导入项目里最新的 db 文件 |
排错时最好用的一个技巧是打开 MySQL general_log,看后端到底发了什么 SQL,而不是在前端浏览器 network 面板里反复猜测。执行SET GLOBAL general_log = 'ON'后,MyBatis 生成的 SQL 和参数最后取值都会打出来,日期、时区、软删除条件一眼就能定位。调试完记得SET GLOBAL general_log = 'OFF',避免 MySQL 持续写日志拖慢本机性能。
本文还有配套的精品资源,点击获取