ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于Spring Boot和Vue的大学生智能消费记账系统开发实战

基于Spring Boot和Vue的大学生智能消费记账系统开发实战 简介基于SpringBoot的大学生智能消费记账系统是一套前后端分离的完整毕业设计/课程设计源码包面向Java学习者、高校学生以及需要快速搭建记账类管理系统的开发者。系统分为管理员与用户两个视角管理员可管理用户信息、预算及租赁信息并与用户在线交流用户则可查看消费信息、预算和管理员回复辅助理性消费。采用SpringBootMyBatisMySQL 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;密码字段保存 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;type 字段决定月报里金额是加还是减比在查询 SQL 里到处写 CASE WHEN 可靠。user_id0 表示系统预置分类比如餐饮、购物、交通、水电、娱乐、兼职收入用户自己新增分类时 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;匹配逻辑很简单插入消费记录时把 merchant 和 note 拼接成一个字符串按优先级从高到低判断 keyword 是否包含命中第一条就停止把 category_id 回填到记录上。全局规则用 user_id0用户自定义规则用当前登录用户 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 17servlet 相关的包名从 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency选 MyBatis-Plus 而不是 JPA是因为这个项目的查询条件变化多但表关系简单。MyBatis-Plus 的动态条件构造顺手统计类的复杂 SQL 又可以用Select注解手写两种模式混用不冲突。连接池不用额外引 druidSpring 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 deleted1 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 deleted0 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 ViteElement 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 实例容易产生循环依赖打包时经常报奇怪的 warningservice.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-formatYYYY-MM-DDv-model 拿到的就是字符串而不是 Date 对象JSON 序列化后不会变成时间戳el-date-picker v-modelqueryDateRange typedaterange value-formatYYYY-MM-DD start-placeholder开始日期 end-placeholder结束日期 /后端接收时用DateTimeFormat解析结束日期统一加一天再比较GetMapping(/records) public ResultPageResultRecordDTO 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-formatDate 对象默认序列化成带时区的 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 序列化成字符串后前端做加法时变成拼接。前后端返回类型对应关系可以按下面这张表对齐前端字段后端类型序列化结果amountBigDecimal字符串“12.50”前端转 NumberconsumeTimeLocalDateTime按yyyy-MM-dd HH:mm:ss输出categoryStatsList对象数组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 rootlocalhost数据库账号密码不对改 application.yml 实际密码Unknown database ledger建库语句没执行先执行 5.1 的建库命令前端请求一直 401登录接口被拦截器拦住拦截器排除路径加 auth 相关浏览器跨域报错后端 CORS 没配前端端口WebMvcConfigurer 里加 Vite 地址白名单日期查询结果少一天结束日期没做 plusDays(1)或前端时区转换组件用 value-format后端加一天比较端口被占用8080 或 5173 已被其他进程占用改 server.port 或 npm run dev 的 --portUnknown 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 持续写日志拖慢本机性能。本文还有配套的精品资源点击获取
返回列表