ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot + Vue图书管理系统全栈开发实战:从数据库到前后端联调

SpringBoot + Vue图书管理系统全栈开发实战:从数据库到前后端联调 看到这个标题我第一反应是又是一个典型的课程设计/毕业设计项目。说实话SpringBoot Vue这套组合在Java全栈开发里已经快成“标准答案”了网上相关的源码、教程数不胜数但真正能把来龙去脉讲清楚、能让人照着做出来的内容并不多。很多人下载了一套源码跑不起来或者跑起来了不知道怎么改、不知道怎么答辨最后只能干瞪眼。这篇博客我就以图书管理系统为例把从数据库设计到前后端联调这条线完整地拆开揉碎讲一遍重点是那些代码注释里不会写的东西希望你能真正把它变成自己的东西。这个项目能解决什么问题说白了就两件事一是把图书的入库、检索、借阅、归还这些日常业务从Excel和手工记录里解放出来二是给你一个麻雀虽小五脏俱全的全栈练手机会。适合谁看正在做课设/毕设的学生、想从SSH/Servlet往SpringBoot迁移的Java初学者、以及想快速搭一套管理系统模板接私活的开发者。先给你吃颗定心丸这套技术栈没有任何高深的地方跟着这篇博客走你不仅能跑起来还能搞清楚每一行配置、每一个接口在干什么。1. 项目整体设计与技术栈选型1.1 为什么是SpringBoot Vue这套组合先说结论这个组合不是性能最优解也不是功能最全解但它是当下Java后端入门和中小型管理系统开发中性价比最高的组合。SpringBoot能成为主流核心原因是它把Spring那一堆繁琐的XML配置几乎全部干掉内置Tomcat意味着你不需要单独装容器一个java -jar就能启动这对课设场景来说是致命的便利——答辩的时候不用现场配置各种环境变量少一个不安定因素。同时SpringBoot的自动配置机制AutoConfiguration会基于你引入的依赖帮你把常见组件数据源、ORM、Web MVC配置好你只需要在application.yml里写少量自定义项。这在开发效率上的提升只有经历过SSM时代的人才有切身体会。Vue这边我推荐用Vue 2 Element UI原因很简单Vue 3 Element Plus的组合在组件生态的成熟度和示例代码量上对新手没有Vue 2友好。尤其图书管理这种系统需要的表格、表单、弹窗、分页组件Element UI基本都是开箱即用百度一搜对应组件的用法也能立刻找到答案。如果为了追求新版而选Vue 3遇到一个组件API变化排查半天那就本末倒置了。当然你要是已经对Vue 3很熟直接用也没问题后端的接口设计不区分前端版本。MyBatis在这里的作用是持久层映射。有人会问SpringBoot官方推荐Spring Data JPA为什么不用我的看法是管理系统涉及大量多表联查和条件统计MyBatis的SQL可控性和动态SQL能力更适合这种场景。JPA的自动建表和HQL在简单CRUD时很爽但一旦遇到根据多个非必填条件组合筛选图书这种典型需求你得拼Criteria或者写Query远不如MyBatis的whereif标签来得直观。而且国内公司用MyBatis的比例还是很高的从学习到就业的角度选MyBatis更实用。1.2 功能模块划分先画清楚边界再动手很多新手拿到这种项目上来就写代码结果写着写着发现读者管理里的删除操作连带把历史借阅记录也搞乱了。这属于典型的没有做模块边界划分。图书管理系统按角色划分核心模块分为三大块图书管理模块图书信息增删改查、分类管理、库存管理。操作者管理员。读者管理模块读者信息维护、借阅证状态管理挂失、注销。操作者管理员。借阅管理模块借书、还书、续借、逾期处理、借阅历史查询。操作者管理员 读者读者仅查询。我需要特别强调一个容易被忽略的设计点借阅流程不能和图书表直接耦合。图书表只负责记录当前库存数量而借阅记录表负责保存哪本书被谁借走了。当你执行借书操作时系统做两件事——往借阅记录表插入一条数据、把图书表的库存量减一。这两件事必须放在同一个数据库事务里否则就会出现记录插入了但库存没减的数据不一致问题。这一点在第五章我会详细讲代码实现。从技术实现角度后端构建思路是标准的分层架构controller接收请求、参数校验、返回JSON ↓ service业务逻辑处理、事务控制 ↓ mapperMyBatis数据访问层 ↓ MySQL数据存储前端构建思路是组件化页面Vue页面组件view层 ↓ 调用api工具模块axios封装 ↓ 对接后端RESTful接口2. 数据库设计建表是后面少踩坑的根基2.1 核心表结构与字段设计做过几个管理系统之后你会发现建表设计其实有套路。以这个图书系统为例最少需要四张核心表再加两张辅助表。我把每张表的字段、类型、注释都整理出来你可以直接抄作业。用户表t_user——同时承载管理员和读者两种角色通过role字段区分字段名类型说明idint(11) PRIMARY KEY AUTO_INCREMENT用户IDusernamevarchar(50) UNIQUE登录用户名passwordvarchar(100)密码建议BCrypt加密real_namevarchar(50)真实姓名roleint(2)角色0管理员1读者phonevarchar(20)联系电话statusint(1)状态0正常1锁定/挂失图书表t_book字段名类型说明idint(11) PRIMARY KEY AUTO_INCREMENT图书IDbook_namevarchar(200)书名isbnvarchar(20)ISBN号authorvarchar(100)作者publishervarchar(100)出版社category_idint(11)分类ID关联分类表total_stockint(11)总库存available_stockint(11)可借库存关键字段locationvarchar(50)馆藏位置如A区3排create_timedatetime入库时间借阅记录表t_borrow_record字段名类型说明idint(11) PRIMARY KEY AUTO_INCREMENT记录IDbook_idint(11)图书IDuser_idint(11)借阅人IDborrow_timedatetime借出时间due_timedatetime应还时间return_timedatetime实际归还时间NULL表示未还statusint(2)状态0借出中1已归还2已逾期图书分类表t_categoryid、category_name、sort_order就是标准的字典表用来做下拉筛选和归类展示。这套设计的核心思想是冗余可用字段 精确状态控制。比如total_stock和available_stock分开设计虽然逻辑上可以只存一个总数再加加减减但在列表展示和统计场景下冗余字段能减少计算量而且不容易出并发问题——一个字段同时被读和写如果中间查询一次就拿到脏数据了。2.2 建表SQL与索引设计要点贴一段核心的建表SQL注意我把索引和默认值都带上了CREATE DATABASE IF NOT EXISTS book_system DEFAULT CHARACTER SET utf8mb4; USE book_system; CREATE TABLE t_book ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 图书ID, book_name VARCHAR(200) NOT NULL COMMENT 书名, isbn VARCHAR(20) DEFAULT NULL COMMENT ISBN号, author VARCHAR(100) DEFAULT NULL COMMENT 作者, publisher VARCHAR(100) DEFAULT NULL COMMENT 出版社, category_id INT DEFAULT NULL COMMENT 分类ID, total_stock INT DEFAULT 0 COMMENT 总库存, available_stock INT DEFAULT 0 COMMENT 可借库存, location VARCHAR(50) DEFAULT NULL COMMENT 馆藏位置, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 入库时间, KEY idx_category (category_id), KEY idx_book_name (book_name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT图书表;之所以用utf8mb4而不是utf8是因为如果书名里有生僻字或者特殊字符utf8会报错或者乱码。这个坑我见过不止一次有些人图书信息导进去后一堆问号就是因为数据库字符集不对。索引方面我的建议是少而精准。像book_name这种高频查询字段建立普通索引就够了不必搞复合索引——你很难预判用户的筛选维度索引建多了不仅浪费空间还会拖慢INSERT和UPDATE的速度。业务量到了索引真正需要调优的量级这套代码的重构重点也不是索引了。2.3 状态字段用整型而不是字符串实体类里的status字段我非常推荐用int类型配合注释来做而不是用正常、借出中这样的字符串。原因有三点第一整型在数据库存储和检索上比字符串快第二前端可以用数字直接做条件判断和映射到标签第三后续扩展状态值比如加一个已预约状态不需要改代码里的字符串常量只需要加一个数字枚举值。实际开发中我会在后端定义一个常量类或枚举类public class BorrowStatus { public static final int BORROWED 0; // 借出中 public static final int RETURNED 1; // 已归还 public static final int OVERDUE 2; // 已逾期 }还有一个小细节数据库表字段命名统一用下划线风格实体类用驼峰风格。虽然MyBatis可以配置下划线自动转驼峰但如果你在写resultMap的时候搞混了大小写排查起来很浪费生命。从一开始就约定好通过设置map-underscore-to-camel-case: true实体类和表字段之间的映射就交给框架自己不用在XML里写几百行resultMap。3. 后端核心模块从接口到业务的完整链路3.1 项目结构初始化与基础配置创建SpringBoot项目的姿势我就不多说了IDEA里直接Spring Initializr或者去start.spring.io生成都行。必要的核心依赖如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.0/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency这里我要单独聊一下PageHelper分页插件。管理系统的列表接口几乎全部是分页查询手写LIMIT当然能实现但每个接口都要向Service层传pageNum和pageSize再手动计算总页数重复劳动太多。PageHelper的用法极其简单在查询执行前调用PageHelper.startPage(pageNum, pageSize)紧接着执行的Mapper查询就会被自动拦截并改写为带LIMIT的SQL同时返回的PageInfo对象里会包含总记录数、总页数等现成数据。这个库在课设中使用频率很高答辩的时候被问到分页怎么实现的你要能回答出基于MyBatis拦截器对Executor进行拦截改写SQL这就算答到点子上了。前后端分离模式下的配置还有三个关键点跨域配置。后端端口默认8080前端Vue开发服务器端口通常是8081或5173两者端口不同会产生跨域问题需要在后端配置CORS跨域策略或通过代理转发解决。统一响应体。定义一个Result类包含code、message、data三个字段所有接口都返回这个结构前端统一处理错误。这个习惯很多新手没有到处乱返回Map前端根本不知道后端是不是报错了。接口路径规范。模块资源采用RESTful风格比如/api/book/list、/api/book/add、/api/borrow/doBorrow。虽然课程设计不强制要求但良好的路径设计能让你答辩时加不少印象分。3.2 登录鉴权与拦截器实现图书管理系统需要登录后才能操作但对课设这个体量来说引入Spring Security JWT可能有点大题小作而且配置复杂度会劝退不少人。我的建议是用简单的拦截器HandlerInterceptor Session或Token的方式来实现。给一个基于Token的实现思路。前端登录成功后后端生成一个Token可以用UUID也可以用JWT把它存到内存Map或Redis里然后返回给前端。前端在后续请求的Header里带上这个Token后端写一个拦截器统一校验public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口 if (request.getRequestURI().contains(/user/login)) { return true; } String token request.getHeader(token); // 校验Token是否存在且有效这里省略具体校验逻辑 if (token null || !TokenStore.isValid(token)) { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或登录已过期\}); return false; } return true; } }把拦截器注册到WebMvcConfigurer里指定拦截路径为/**排除掉登录接口和静态资源路径。在登录密码加密方面无论是不是课设我都建议不要明文存密码。用BCryptPasswordEncoder它不需要你手动加盐每次哈希的结果都不相同安全性足以应对这种业务场景。3.3 图书管理接口设计从CRUD到条件检索图书管理是核心模块接口设计上除了基础的增删改查必须包含分页 多条件组合查询。这地方不夸张地说是一道大分水岭——实现得漂不漂亮直接决定你是及格还是优秀。先定义接口清单功能请求方式路径参数分页查询图书GET/api/book/listpageNum, pageSize, bookName, categoryId新增图书POST/api/book/add图书信息JSON修改图书PUT/api/book/update图书信息JSON删除图书DELETE/api/book/delete/{id}图书ID查询全部图书分类GET/api/category/list无查询接口用MyBatis动态SQL实现Mapper XML里面长这样select idselectBookList resultTypecom.example.entity.Book SELECT * FROM t_book where if testbookName ! null and bookName ! AND book_name LIKE CONCAT(%, #{bookName}, %) /if if testcategoryId ! null AND category_id #{categoryId} /if /where ORDER BY id DESC /select注意我在这里用的是resultType而不是resultMap。因为实体类字段和表字段是下划线驼峰一一对应的配置好map-underscore-to-camel-case之后resultType就够用了。只有遇到多表联查需要额外展示字段时比如借阅列表要同时显示书名和读者姓名才需要写resultMap做映射。这也是MyBatis开发中一个懒人但正确的习惯。3.4 借阅归还事务和数据一致性是核心考点借书和还书是整个系统的业务核心也是最容易被答辩老师追问的地方。借书的Service方法代码如下Transactional(rollbackFor Exception.class) public void borrowBook(Integer bookId, Integer userId) { // 1. 查询图书校验库存 Book book bookMapper.selectById(bookId); if (book null) { throw new BusinessException(图书不存在); } if (book.getAvailableStock() 0) { throw new BusinessException(库存不足); } // 2. 插入借阅记录状态为借出中0 BorrowRecord record new BorrowRecord(); record.setBookId(bookId); record.setUserId(userId); record.setBorrowTime(new Date()); record.setDueTime(DateUtil.addDays(new Date(), 30)); // 默认借期30天 record.setStatus(BorrowStatus.BORROWED); borrowRecordMapper.insert(record); // 3. 扣减库存 book.setAvailableStock(book.getAvailableStock() - 1); bookMapper.updateById(book); }注意Transactional(rollbackFor Exception.class)这段注解。Spring默认只回滚RuntimeException如果业务方法里抛的是自定义异常且不继承运行时异常事务是不会自动回滚的所以一定要显式指定rollbackFor。这在答辩时十有八九会被问背下来不吃亏。还书流程反过来把return_time设为当前时间、状态改为已归还1、对应图书的可借库存加一。同时判断实际归还时间和due_time做对比超过就算逾期。还有一个并发问题值得提出来。如果两个用户同时借同一本只剩一本库存的书上面的代码可能两个请求都通过了库存校验然后都去扣减库存导致库存变成-1。这在真实的图书管理场景中概率极低但如果老师在答辩时问高并发下怎么保证不超卖你可以回答在扣减库存的SQL中加上条件判断例如UPDATE t_book SET available_stock available_stock - 1 WHERE id #{bookId} AND available_stock 0如果影响行数为0说明库存被并发扣减完抛异常回滚。这种基于乐观锁思想的处理方案既简单又能自圆其说比单纯讨论悲观锁更有说服力。4. 前端Vue实现从页面到路由再到接口对接4.1 前端工程化和目录结构Vue项目建议用Vue CLI创建如果你对Vite熟悉用Vite也行但课设我用Vue CLI更多因为它的webpack配置封装的比较完整遇到问题百度起来案例更多npm install -g vue/cli vue create book-frontend创建一个干净的目录结构src/ api/ # 后端接口封装 book.js user.js borrow.js router/ # 路由配置 index.js views/ # 页面组件 Login.vue Layout.vue BookList.vue BookAdd.vue BorrowList.vue ... store/ # Vuex状态管理登录信息、用户信息 utils/ # 工具模块axios封装 App.vue main.js4.2 axios统一封装把错误处理做在前面前端对接后端我把axios封装到utils/request.js里统一做三件事设置baseURL、附加Token请求头、统一拦截错误码。import axios from axios import { Message } from element-ui import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器附加Token request.interceptors.request.use(config { const token sessionStorage.getItem(token) if (token) { config.headers[token] token } return config }) // 响应拦截器统一处理错误 request.interceptors.response.use( response { const res response.data if (res.code ! 200) { Message.error(res.message || 请求失败) if (res.code 401) { router.push(/login) } return Promise.reject(new Error(res.message)) } return res }, error { Message.error(网络异常请稍后重试) return Promise.reject(error) } ) export default requestbaseURL这里直接写/api需要在前端项目的vue.config.js里配置代理将请求转发到后端8080端口module.exports { devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }这就同时解决了开发环境的跨域问题也算一个标准做法。真正到了生产环境把前端打包后的静态资源放进后端项目里就不存在代理了因为前后端同源了。4.3 核心页面拆解图书列表与借阅弹窗图书列表页是用户最常看到的页面核心需求就三个条件搜索框、数据表格、分页。Element UI的el-table和el-pagination基本是固定搭配。绑定数据时用el-table-column展示书名、作者、出版社、库存等字段库存少于5本的可以在列里用标签高亮显示el-table-column label可借库存 width100 template slot-scopescope el-tag :typescope.row.availableStock 5 ? danger : success {{ scope.row.availableStock }} /el-tag /template /el-table-column借阅操作弹窗我用el-dialog内嵌表单实现。点击借阅按钮时把当前行数据对象传进弹窗表单里让管理员选择借阅人从用户列表查询然后提交到后端/api/borrow/doBorrow。这里有一个交互上的小细节值得注意提交成功后要刷新列表和库存数据。否则前端页面上那本书的可借库存还是旧值用户就会误以为借阅没成功。最简单的做法是请求结束后再调一次getList()方法不要手动去改表格里的数据因为借阅操作可能影响多条数据手动维护容易出错。4.4 Vue Router路由与动态菜单因为系统有管理员和读者两个角色比较好的实践是根据角色动态生成路由表。管理员登录后能看到图书管理、借阅管理、读者管理三个菜单读者登录后只能看到图书查询和个人借阅记录。完成这个效果可以在路由守卫里根据当前用户的role字段做判断router.beforeEach((to, from, next) { const userInfo JSON.parse(sessionStorage.getItem(userInfo)) if (to.path /login) { next() } else { if (!userInfo) { next(/login) } else if (to.meta.roles !to.meta.roles.includes(userInfo.role)) { next(/403) // 无权限提示页 } else { next() } } })这种操作也叫路由元信息控制在meta里声明哪些角色可以访问。简单、直观、够用不会像动态菜单一样把代码复杂度拉那么高。5. 联调与踩坑实录那些文档里不会告诉你的问题5.1 跨域配置踩过的坑跨域是前后端联调遇到最多的问题。前端报错常常是No Access-Control-Allow-Origin header is present on the requested resource。解决办法有两个方向方向一后端加配置类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意allowCredentials(true)时allowedOriginPatterns(*)在SpringBoot 2.4以上版本才支持。版本不同API不一样这也是一个会浪费十分钟的细节。方向二用Nginx反向代理把前端请求转发到后端。不过课设阶段我不建议引入Nginx技术栈又多了一门还增加部署复杂度配置里一个location写错了排查半天得不偿失。5.2 MyBatis的“列名不存在”问题新手最常见的报错是### The error may exist in Mapper Mapper.xml和BadSqlGrammarException。原因两极分化要么是SQL写错了要么是表字段名不匹配。排查思路先看控制台打出的SQL对比SELECT的字段是不是都在表里存在——不要凭记忆写字段直接去数据库执行一下表结构命令。还有一个情况数据库字段是user_name但实体类属性是usernameMyBatis转驼峰失败。这时候需要检查全局配置是否生效早年间SpringBoot集成MyBatis还没那么自动化时这块没少折腾。现在只要在application.yml里设置mybatis: configuration: map-underscore-to-camel-case: true这个配置项就是所谓的“下划线转驼峰”它匹配的核心规则是数据库列名user_name到实体类字段userName的自动映射。5.3 日期前后端传值格式问题借阅记录的borrowTime、dueTime字段前后端传递时经常出问题——默认序列化出来是时间戳前端要显示成2024-01-25 14:30:00的格式还得处理一下。最简单的方案是在实体类的日期字段上注解JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private Date dueTime;这里timezone一定要写GMT8才能保证不会出现时间偏移8小时的情况。如果前端还需要接收日期做控件回显传输格式统一为yyyy-MM-dd更友好但查询返回建议带时分秒显示更完整。5.4 PageHelper分页无效或数据错乱PageHelper使用中有一个很重要的约定PageHelper.startPage()必须紧跟要分页的Mapper查询方法中间不能穿插其他查询。如果中间先执行了别的SQL分页参数会被那个查询消费掉导致你的目标查询没有分页效果。这个问题我在帮别人排查代码时遇到过不少次。还有一个坑是当你用了PageHelper.startPage后如果紧接着的Mapper方法返回类型是List它会被PageHelper包装成Page对象里面带有分页信息。但如果你在这个方法后又执行了一个查询第二个查询也会被分页生效。所以为了保险把分页查询和普通的查询方法拆开写在不同Mapper方法里不要共用一个方法依赖分页参数动态判断代码意图会清晰得多。5.5 Maven依赖冲突与打包失败依赖冲突的典型报错是ClassNotFoundException和NoSuchMethodError。以SpringBoot 2.x为例引入mybatis-spring-boot-starter时不需要再单独引入mybatis和mybatis-spring否则版本可能冲突。同样mysql-connector-java的runtimescope记得加上否则打包时会把数据库驱动也打进去纯属浪费。打包用mvn clean package会生成一个jar包。如果打包时提示spring-boot-maven-plugin找不到主类检查pom里有没有配置mainClass或者编译后有没有target目录下出现class文件。大部分打包失败都是本地环境问题——JDK版本和项目编译版本不一致是最常见元凶解决方式是保持IDEA的Project Structure里SDK版本和pom里的java.version一致。6. 部署上线与项目答辩的实用经验6.1 本地部署全流程实际操作时我建议按以下顺序跑通整个项目安装MySQL 5.7或8.0执行建库建表脚本导入初始测试数据至少20本图书、5个用户。用IDEA打开后端项目等待Maven下载依赖修改application.yml中的数据库账号密码。直接运行main方法启动后端访问http://localhost:8080/api/...验证接口通不通。用VSCode或WebStorm打开前端项目执行npm install安装依赖网络不好的情况下这个步骤可能很漫长建议提前执行。执行npm run serve启动开发服务器浏览器访问http://localhost:8081登录系统走通核心流程。有一说一部署环节最容易出问题的地方是JDK或Node版本不兼容。npm install后报node-sass错误基本是Node版本太高或太低npm install慢、卡住可以换国内镜像源npm config set registry https://registry.npmmirror.com6.2 项目演示顺序和拿分技巧答辩演示不要上来就对着屏幕把功能全部点一遍那样老师会困。我推荐的演示顺序是先登录讲清楚三种角色权限的区别。展示图书列表的分页检索功能重点提图书名模糊查询 分类精确筛选背后的动态SQL实现。现场借一本书演示库存扣减、借阅记录生成。还书演示库存恢复顺带展示逾期判断逻辑。打开数据库展示数据在库里真实变化的过程这一招老师会比较认可——证明你不是只做了个Mock数据的前端界面。扩展方面这个系统至少可以往三个方向延伸增加预约功能图书被借光后提交预约请求、引入Redis缓存热点书籍信息、把统计报表做成可视化图表。这些可以当“不足与展望”放在论文结尾没有要求一定要实现但能体现出你对系统演进方向有思考加分项。写在最后的话经过这一整套流程图书管理系统已经从登录页到数据库完整地跑通了。我能负责任地说这套SpringBoot Vue MySQL MyBatis的组合在学业、工作、接私活三个场景下都有很强的迁移价值。前端的组件化思路后端的分层边界、事务控制思路换一个业务领域几乎可以原样复用——你把Book换成Product就是进销存换成Course就是在线选课换成Equipment就是设备管理系统。最后再分享一个小技巧拿到别人的源码跑不起来时别急着道德审判代码先看日志——80%的问题都出在数据库连接、端口占用、依赖缺失这三件事上。学会看堆栈第一行比学会Google搜索更省时间。祝你的项目顺利跑通答辩沉着应对。
返回列表