☰
Spring Boot在线答疑系统毕设:文件上传与权限控制实战
2026/10/8 10:36:30 网站建设 项目流程

简介:面向计算机专业毕业设计及课程设计的一套在线答疑系统完整源码,后端采用 Spring Boot 框架,配合 Java 1.8 环境、MySQL 5.7 数据库和 Tomcat 7 服务器,项目按 Maven 标准组织,可导入常用的集成开发环境直接运行。压缩包共 818 个文件,整体约 23.57 兆,包含 97 个 Java 后端源文件、100 个 Vue 前端页面、96 个编译后的 class 文件、40 个 JavaScript 脚本、24 个 XML 配置文件、3 个数据库初始化脚本及多种启动辅助文件,能支撑从环境准备、数据建表、后端接口编码到前端页面联调的过程。目前已有 143 人学习下载,适合作为毕业设计或课程设计的参考实现,也适合用来梳理在线答疑场景下的数据交互流程。包内附有一键安装与启动脚本、前端组件备份和备份文件,便于导入项目时对照排错,并为二次扩展功能提供基础;整体目录清晰、模块划分明确,对 Java Web 实践入门者具有较好的参考价值。

1. 在线答疑系统是什么:这个毕设题目的真实分量与「文件」二字的双重含义

拿到「Java毕业设计基于springboot的在线答疑系统文件的实现.zip」这个名字时,很多人的第一反应是「又一个学生提问平台」。但对毕设来说,这个题目真正的价值在于它踩中了三个高频考点:Spring Boot 的 CRUD 基本功、角色权限的处理、以及文件(附件)上传下载这个最容易被轻视的模块。所谓「文件的实现」其实有两层意思——一是指整个系统工程的源码文件,二是指答疑过程中提问者上传的图片、压缩包、文档等附件功能。不少同学把前者当成了全部,答辩时被「你上传的文件存到哪、怎么防超限、别人能不能乱下」一问就卡壳。这篇文章把整套链路完整讲透:表怎么建、接口怎么写、文件怎么存、上线前哪些坑必须躲开,照着做能省下至少一周的返工时间。

2. 先立骨架:六张表与角色权限,把答疑系统的数据关系定死

2.1 六张核心表:每个字段为什么存在

答疑系统的业务模型不复杂,最多六张表就能覆盖。我的习惯是先把表结构定死,再写代码,因为实体类、Service、接口全是围着表转的。下面这张表是经过几次迭代后最稳的版本:

表名用途关键字段
user用户表id, username, password, role(0学生/1教师/2管理员), avatar, create_time
question问题表id, user_id, title, content, status(0待答/1已答/2已采纳), view_count, create_time
answer回答表id, question_id, user_id, content, is_accepted, create_time
attachment附件表id, business_type(question/answer), business_id, file_name, file_path, file_size, upload_time
category分类表id, name(Java、数据库、算法等)
message站内信id, from_user, to_user, content, is_read

category 和 message 属于加分项,时间紧可以砍掉,但前四张表必须完整。一个常被问到的设计:为什么单独拆 attachment 表,而不是直接给 question 加一个 file_url 字段?因为回答也可以带附件,而且一个问题可能挂多个附件。用 business_type + business_id 这种多态关联,一张附件表同时服务问题和回答,查询时只需要一个字段区分归属。

2.2 用 MyBatis Plus 生成建表 SQL:脚本写法与更稳的手写路线

很多人搜「mybatisplus根据java实体类生成创建表的sql语句」,确实有这条路。MyBatis Plus 的 TableInfoHelper 能反射实体类拿到表名和字段映射,拼出 CREATE TABLE。我写过一个最小脚本,核心长这样:

// 用 TableInfoHelper 反射实体类,拼出建表 DDL 骨架 TableInfo tableInfo = TableInfoHelper.getTableInfo(Question.class); StringBuilder ddl = new StringBuilder(); ddl.append("CREATE TABLE IF NOT EXISTS `").append(tableInfo.getTableName()).append("` (\n"); for (TableFieldInfo field : tableInfo.getFieldList()) { ddl.append(" `").append(field.getColumn()).append("` "); if (field.getPropertyType().equals(String.class)) { ddl.append("VARCHAR(255)"); } else if (field.getPropertyType().equals(Integer.class)) { ddl.append("INT"); } else { ddl.append("BIGINT"); } ddl.append(",\n"); } // 主键、索引、注释需要手动补,脚本只负责字段部分

这段代码用来验证「实体和表能对上」是够用的,但我不推荐把建表这件事交给运行时反射——主键策略、索引设计、字段注释它都处理不了。更稳的路线是先手写 SQL,再让实体类去对齐表结构。一个标准的 question 建表语句长这样:

CREATE TABLE `question` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL COMMENT '提问人ID', `title` varchar(100) NOT NULL COMMENT '问题标题', `content` text COMMENT '问题描述,支持富文本', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0待答复 1已答复 2已被采纳', `view_count` int(11) NOT NULL DEFAULT '0' COMMENT '浏览量', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='答疑问题表';

两个关键点:字符集必须用 utf8mb4 而不是 utf8——用户在问题里贴个 emoji,utf8 表直接报错或变问号,这种低级问题在答辩演示时特别丢分;外键不要加,MyBatis Plus 体系里靠逻辑层保证一致性,数据库外键反而让测试数据清理变得麻烦。

2.3 三种角色怎么落到接口上:一个注解搞定权限控制

user 表的 role 字段定死了三种角色:0 学生、1 教师、2 管理员。权限控制不需要上 Spring Security,毕设场景用它反而显得笨重——配置类、过滤链、密码加密器一套下来,答辩时很难讲清楚。常见做法是自定义一个注解加拦截器,三十行代码解决问题:

@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { int[] value(); }

拦截器里从请求头拿到 token,解析出当前用户的 role,再和注解上的要求比对:

// 拦截器核心逻辑:判断当前用户角色是否在注解允许的范围内 HandlerMethod handlerMethod = (HandlerMethod) handler; RequireRole requireRole = handlerMethod.getMethodAnnotation(RequireRole.class); if (requireRole != null) { Integer userRole = SessionUtil.getCurrentUserRole(request); boolean allowed = Arrays.stream(requireRole.value()) .anyMatch(role -> role == userRole); if (!allowed) { throw new ForbiddenException("当前角色无权操作"); } }

这样做的好处是接口上写一行@RequireRole({0, 1})就能控制「学生和教师可访问,管理员不行」,比在方法里写 if 判断清爽得多。答辩时如果老师问「为什么不直接用 Spring Security」,你就说:毕设系统角色只有三种,自定义注解更轻量,且核心逻辑一眼能看懂。

3. Spring Boot 核心接口:用状态字段管住提问、回答与被采纳

3.1 提问接口:校验、入库与「别在同步链路里发通知」

提问是系统的入口,接口设计上有一个容易犯的错:在提问的同时给所有教师发站内信。这个操作如果同步执行,会让请求变慢,而且一旦有教师账号被禁用,消息发送失败还会导致整个提问接口报错。我的做法是提问接口只做三件事:校验参数、插入记录、返回 ID。

@PostMapping("/question") public Result<Long> createQuestion(@RequestBody QuestionDTO dto, HttpServletRequest request) { // 1. 从拦截器写入的 attribute 里拿当前用户 Long userId = (Long) request.getAttribute("userId"); if (StringUtils.isBlank(dto.getTitle())) { return Result.error("问题标题不能为空"); } // 2. 标题去空格,防止只输入空格的情况 Question question = new Question(); question.setUserId(userId); question.setTitle(dto.getTitle().trim()); question.setContent(dto.getContent()); question.setStatus(0); question.setViewCount(0); questionService.save(question); // 3. 这里不要写发通知的逻辑,放到事务提交后异步去发 return Result.ok(question.getId()); }

参数说明:DTO 用@RequestBody接收 JSON,前端富文本编辑器提交的 content 是一段 HTML,入库前要做 HTML 转义或白名单过滤,否则一个<script>标签就能让你的页面崩掉。校验放在 Controller 层是为了让非法请求快速失败,不进入事务,减少无谓的数据库连接占用。至于发通知,正确姿势是事务提交后调用一个@Async的方法,失败只记日志,不影响主流程。

3.2 回答与采纳:乐观锁状态流转,避免并发下的一题多态

回答接口的核心不是 insert,而是对 question.status 的状态流转。一个常见 bug:两个学生同时回答同一道题,都读到 status=0,都插入成功,然后各自把 status 更新成 1,虽然数据没丢,但状态更新里出现了不可控的中间态。答疑系统允许一题多答,这里真正要保护的是「采纳」这个动作——一道题只能被采纳一次。

@PostMapping("/answer") @RequireRole({0, 1}) public Result<Long> createAnswer(@RequestBody AnswerDTO dto, HttpServletRequest request) { Long userId = (Long) request.getAttribute("userId"); Question question = questionService.getById(dto.getQuestionId()); if (question == null) { return Result.error("问题不存在"); } if (question.getStatus() == 2) { return Result.error("该问题已被采纳,不能再回答"); } // 插入回答记录 Answer answer = new Answer(); answer.setQuestionId(dto.getQuestionId()); answer.setUserId(userId); answer.setContent(dto.getContent()); answer.setIsAccepted(0); answerService.save(answer); // 如果问题还是待答状态,更新为已答复 if (question.getStatus() == 0) { question.setStatus(1); questionService.updateById(question); } return Result.ok(answer.getId()); }

采纳动作单独写一个接口,关键是那条条件更新。用 MyBatis Plus 的 lambdaUpdate 直接写成乐观锁风格:

// 采纳回答:只有问题处于“已答复”状态才能被采纳,且只能成功一次 boolean updated = questionService.lambdaUpdate() .eq(Question::getId, dto.getQuestionId()) .eq(Question::getStatus, 1) .set(Question::getStatus, 2) .update(); if (!updated) { return Result.error("问题状态已变化,请刷新后重试"); } answerService.lambdaUpdate() .eq(Answer::getId, dto.getAnswerId()) .set(Answer::getIsAccepted, 1) .update();

这里eq(Question::getStatus, 1)就是那把锁:并发请求同时到达时,数据库行锁保证只有一个 update 影响行数为 1,另一个 updated 为 false,直接返回错误。比先查再改的写法安全一个量级。参数上注意,这个接口应该只允许提问人本人调用,所以要再加一个userId.equals(question.getUserId())的判断。

3.3 分页与搜索:LambdaQueryWrapper 的标准写法与 N+1 问题

列表页是答疑系统访问量最大的地方,分页加搜索是必考。MyBatis Plus 的 Page + LambdaQueryWrapper 是最常见的组合:

// 问题分页查询:支持关键字模糊搜索和按状态过滤 Page<Question> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Question> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.isNotBlank(keyword), Question::getTitle, keyword) .eq(status != null, Question::getStatus, status) .orderByDesc(Question::getCreateTime); IPage<Question> result = questionService.page(page, wrapper);

这里的技巧在第 4 行和第 5 行:StringUtils.isNotBlank(keyword)作为条件参数,keyword 为空时整条 like 条件自动跳过,不用写 if 判断;status 同理。返回给前端时还需要补两个字段:提问人的昵称和头像。最直观的写法是 join 查询,但 MyBatis Plus 的 LambdaQueryWrapper 不直接支持 join,常见做法是先查出分页结果,再拿到所有 user_id 一次性查用户信息,内存中组装:

// 避免 N+1 查询:先查问题,再批量查用户,内存组装 List<Long> userIds = result.getRecords().stream() .map(Question::getUserId).distinct().collect(Collectors.toList()); Map<Long, User> userMap = userIds.isEmpty() ? Collections.emptyMap() : userService.listByIds(userIds).stream() .collect(Collectors.toMap(User::getId, u -> u)); result.getRecords().forEach(q -> { User user = userMap.get(q.getUserId()); q.setUserName(user != null ? user.getUsername() : "已注销用户"); });

参数说明:pageNum 从 1 开始,pageSize 建议限制在 20 以内,防止有人一次拉全表。这种「先分页再批量补关联数据」的模式,在数据量到十万级之前都不会有性能问题,也比拼接 SQL 优雅。很多毕设在这块直接用@Select注解写一个连表分页,查询本身没问题,但一旦加条件就变得极难维护。

4. 文件上传与访问权限:把「文件的实现」做成答辩加分项

4.1 本地存储还是 OSS:毕设场景的选型判断

「基于 springboot 的在线答疑系统文件的实现」这个题目里,「文件」最容易出彩的部分就是附件模块。选型上我的建议很直接:毕设答辩用本地磁盘存储,不要碰 OSS。理由有三点:演示现场不确定有没有外网,OSS 需要 bucket 配置和密钥,现场翻车率高;本地存储可以让老师看到文件实实在在落在服务器目录里,讲存储逻辑更有说服力;OSS 的凭证、计费、跨域等问题会转移答辩焦点。

但代码上要为日后切换留后路。定义一个存储接口:

// 文件存储抽象:本地实现和 OSS 实现可以无缝切换 public interface FileStorage { String store(MultipartFile file, String bizType) throws IOException; void delete(String filePath); InputStream getInputStream(String filePath) throws IOException; }

答辩时老师问「以后文件太多、单机磁盘不够怎么办」,你只需要说「再写一个 OSS 实现类替换 Bean 就行,业务代码不动」。这一句话就能展示你考虑了扩展性,是很实用的加分话术。

4.2 上传接口:MultipartFile 双重校验与本地落盘实现

上传接口是文件模块的核心,要同时做类型校验和大小校验。类型校验靠后缀白名单,大小校验靠 Spring 配置加业务层兜底。先看配置:

spring: servlet: multipart: max-file-size: 10MB max-request-size: 50MB

这个配置里,max-file-size 是单文件上限,max-request-size 是一次请求的总大小(多文件上传时算总和)。配置不生效是后期最容易翻车的地方,原因大多是 yaml 缩进错误,或者干脆放错位置进了spring.http.multipart——那是 Spring Boot 1.x 的写法,2.x 开始已经改了。

上传接口的完整实现:

@PostMapping("/file/upload") @RequireRole({0, 1, 2}) public Result<FileVO> upload(@RequestParam("file") MultipartFile file, @RequestParam String bizType, @RequestParam Long bizId, HttpServletRequest request) { Long userId = (Long) request.getAttribute("userId"); // 1. 空文件校验 if (file.isEmpty()) { return Result.error("文件不能为空"); } // 2. 后缀白名单校验:防止上传可执行文件 String originalName = file.getOriginalFilename(); String ext = StringUtils.getFilenameExtension(originalName); Set<String> allowed = new HashSet<>(Arrays.asList( "jpg", "png", "gif", "pdf", "zip", "rar", "doc", "docx", "txt")); if (!allowed.contains(ext.toLowerCase())) { return Result.error("不支持的文件类型: " + ext); } // 3. 落盘并拿到访问路径 FileStorage storage = SpringContextUtil.getBean(FileStorage.class); String path = storage.store(file, bizType); // 4. 写附件记录 Attachment attachment = new Attachment(); attachment.setBusinessType(bizType); attachment.setBusinessId(bizId); attachment.setFileName(originalName); attachment.setFilePath(path); attachment.setFileSize(file.getSize()); attachment.setUploaderId(userId); attachmentService.save(attachment); return Result.ok(FileVO.of(attachment)); }

参数说明:bizType 传question或answer,标识这个附件挂在哪个业务下;bizId 是问题或回答的 ID。这里有一个容易漏的校验:bizId 对应的业务记录必须真实存在且当前用户有权操作,否则任何人都可以往别人的问题上挂附件。代码里的allowed集合要按需放行,答辩现场有人传个.jsp文件会非常尴尬。

本地存储实现类,注意落盘路径的设计:

@Component public class LocalFileStorage implements FileStorage { @Value("${file.storage-path:./upload}") private String storagePath; @Override public String store(MultipartFile file, String bizType) throws IOException { // 按日期分目录,避免单个目录文件过多 String dateDir = LocalDate.now().toString().replace("-", ""); String ext = StringUtils.getFilenameExtension(file.getOriginalFilename()); // UUID 重命名:解决中文文件名乱码和覆盖问题 String fileName = UUID.randomUUID().toString().replace("-", "") + "." + ext; Path dir = Paths.get(storagePath, dateDir); if (!Files.exists(dir)) { Files.createDirectories(dir); } Path target = dir.resolve(fileName); file.transferTo(target.getAbsolutePath()); // 返回相对路径,不暴露服务器绝对路径 return "/file/" + dateDir + "/" + fileName; } }

两个关键参数:file.storage-path在 application.yml 里配置,我习惯用绝对路径,比如/data/qa-upload,不要用相对路径./upload——因为相对路径取决于你从哪里启动 jar,同一个 jar 在 IDE 里启动和命令行启动,落盘位置可能完全不一样。文件名用 UUID 重命名,一是避免两个用户上传同名文件互相覆盖,二是避免中文名在下载时产生 URL 编码问题。

4.3 文件下载接口:用权限控制替代裸奔的静态映射

很多教程让你把上传目录配成静态资源映射,然后前端拼一个 URL 直接访问。这是在埋雷:任何拿到 URL 的人都能下载文件,你没法判断「这个用户是不是这个问题的参与者」。毕设系统里至少要做到——附件只允许问题提出者、回答者和教师管理员访问。

我的做法是下载走接口,先鉴权再返回文件流:

@GetMapping("/file/{dateDir}/{fileName}") public ResponseEntity<Resource> download(@PathVariable String dateDir, @PathVariable String fileName, HttpServletRequest request) { Long userId = (Long) request.getAttribute("userId"); // 1. 先查附件记录 Attachment attach = attachmentService.lambdaQuery() .eq(Attachment::getFilePath, "/file/" + dateDir + "/" + fileName) .one(); if (attach == null) { return ResponseEntity.notFound().build(); } // 2. 权限判断:提问者、回答者、教师、管理员可访问 if (!canDownload(userId, attach)) { return ResponseEntity.status(403).build(); } // 3. 读文件并返回 Path path = Paths.get(storagePath, dateDir, fileName); Resource resource = new FileSystemResource(path); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_OCTET_STREAM) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + URLEncoder.encode(attach.getFileName(), "UTF-8") + "\"") .body(resource); }

canDownload 的逻辑可以写简单点:如果附件的上传者是自己,放行;如果附件挂在 question 下,查 question 的 user_id 是否是自己;role 为 1 或 2 的教师和管理员直接放行。注意下载文件名用URLEncoder.encode处理一下,不然中文文件名在 Chrome 里会变成一坨乱码,这个细节很能体现工程经验。

4.4 附件表设计:为什么不用 question 表的冗余字段

关于「文件」最后一层设计决策:附件到底存哪。有人习惯在 question 表加一个file_url字段,问题不大,但一旦遇到两种情况就尴尬了——回答也想带附件怎么办?一个问题贴了三个参考文档怎么办?冗余字段要么再加file_url2、file_url3,要么用逗号分隔存一个字符串,这两种都是坏味道。

独立 attachment 表的优势在查询时体现得最明显。查一个问题的所有附件,只需要:

List<Attachment> qAttachments = attachmentService.lambdaQuery() .eq(Attachment::getBusinessType, "question") .eq(Attachment::getBusinessId, questionId) .list();

如果还要把回答里的附件也一起查出来,语句也清晰:

// 查问题和其所有回答下的附件,避免循环里单查 List<Long> answerIds = answerService.lambdaQuery() .eq(Answer::getQuestionId, questionId) .list().stream().map(Answer::getId).collect(Collectors.toList()); List<Attachment> attachments = attachmentService.lambdaQuery() .and(wrapper -> wrapper .eq(Attachment::getBusinessType, "question") .eq(Attachment::getBusinessId, questionId)) .or(wrapper -> wrapper .eq(Attachment::getBusinessType, "answer") .in(Attachment::getBusinessId, answerIds.isEmpty() ? Collections.singletonList(0L) : answerIds)) .list();

这段的细节在处理 answerIds 为空的情况:in条件不能传空集合,否则 MyBatis Plus 生成的 SQL 会变成IN ()直接报语法错误,所以塞一个不存在的 0L 进去。这是很多人会在答辩前夜翻车的地方。独立附件表还有一个隐性好处:管理后台做文件统计时,一条 SQL 就能算出「本月上传文件总数、总体积」,可以作为答辩时的数据展示点。

5. 避坑手册:Spring Boot 答疑系统最容易翻车的 5 个真实问题

5.1 版本号玄学:Spring Boot 3.x 的 javax/jakarta 包名迁移

现象:按照 Spring Boot 2.x 的教程写代码,import javax.servlet.*,在 3.x 项目里编译直接报「程序包不存在」。 原因:Spring Boot 3 基于 Jakarta EE 9,所有 javax.servlet 开头的包名换成了 jakarta.servlet,连 Tomcat 的依赖都换了一整套。搜「springboot 版本太高」的人基本都卡在这一步。 解决:毕设直接用 Spring Boot 2.7 这条线最稳,教程最多、MyBatis Plus 兼容性最好、网上遇到的坑基本都有答案。如果已经建了 3.x 项目,全局替换javax.servlet为jakarta.servlet,MyBatis Plus 也要换用mybatis-plus-spring-boot3-starter这个坐标。另外 Spring Boot 3 默认的 Java 版本是 17,如果你的机器装的是 JDK 8,启动就会报UnsupportedClassVersionError——这也是「版本太高」的连锁反应。

5.2 文件上传 413:Multipart 配置没生效的排查顺序

现象:小文件上传正常,传一个大文件直接返回 413 Request Entity Too Large,或者抛 MaxUploadSizeExceededException。 原因:Spring Boot 2.x 的默认单文件上限是 1MB,你没在 application.yml 里配置 multipart 参数。还有一种情况:配置写了但放在 Nginx 层,Nginx 默认 client_max_body_size 也是 1MB,请求还没到 Spring Boot 就被挡了。 解决:先看后端日志里有没有 MaxUploadSizeExceededException,有说明 Spring 配置没生效或没写;没有异常但请求被拒,优先查 Nginx 的client_max_body_size。配置项注意是spring.servlet.multipart,不是spring.http.multipart。还有一个容易忽略的点:全局异常处理器里要捕一下 MaxUploadSizeExceededException,返回一个友好的 JSON,不然前端拿到的是 Tomcat 默认的 HTML 错误页,很难看。

5.3 中文乱码:连接串、表字符集与驱动版本三件事

现象:插入的中文正常,但换一台电脑部署后,数据库中看到的是问号,或者前端传进来的 emoji 直接报错。 原因:字符集问题有三个层次,缺一不可——数据库表字符集、JDBC 连接串字符集、驱动版本对字符集的处理。 解决:建表一律用 utf8mb4;连接串写成jdbc:mysql://localhost:3306/qa_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false。注意这里不要写characterEncoding=utf8mb4,MySQL Connector/J 8.x 里 UTF-8 会映射到 utf8mb4,写 utf8mb4 反而可能不被识别。如果已经建好的表是 utf8,执行一句ALTER TABLE question CONVERT TO CHARACTER SET utf8mb4;可以补救,但千万记得同时检查连接串和驱动版本,三个环节有一个不对,问题就还在。这类问题在答辩演示时尤其致命——你之前在本机好好的,换到教室电脑就乱码,多半就是 MySQL 版本或字符集变量不同。

5.4 附件列表有记录、磁盘却没文件:文件生命周期的一致性

现象:列表页能看到附件名称,点击下载却 404,去服务器目录看,文件不见了。 原因:最常见的是上传目录落在了target/classes或项目构建目录里,每次mvn clean都会把文件一起清掉。另一个方向是删除操作没做干净——只删了 attachment 表记录,磁盘文件残留;或者只删了磁盘文件,记录还在。 解决:存储路径用绝对路径配置,不要放在项目目录内,启动时在日志里打印当前生效的存储路径,方便排查。删除附件的方法要写成「先删磁盘文件,再删数据库记录;磁盘删除失败则记录日志并保留记录,定时任务补偿」。上传同理:文件先落盘,再写数据库记录,如果写库失败要立即删除刚落的文件。这套流程不复杂,但能保证两个源始终一致,答辩时老师问你「文件删了磁盘怎么办」,你有完整回答。

5.5 启动失败三板斧:端口占用、数据源与 Banner 误导

现象:启动类一跑,控制台满屏红色 stack trace,或者干脆启动到一半停住不动。 原因:九成是三类问题——端口被占用,控制台报BindException: Address already in use;数据源连接失败,报Access denied for user或Communications link failure;资源目录缺 application.yml 导致默认配置找不到数据源。 解决:端口占用用netstat -ano | findstr 8080(Windows)或lsof -i:8080(Mac/Linux)查进程 PID,杀掉或者改server.port。数据源失败,先确认 MySQL 服务启动了、账号密码正确、数据库已创建,再检查连接串里的serverTimezone——这个参数缺失在新版驱动里会直接启动报错。至于 Banner:很多人用「springboot banner生成器」做了个花哨的启动图案贴进 resources,结果里面有隐藏特殊字符,打印出来满屏乱码,误导你以为启动出错。我的习惯是直接关掉 Banner,在 application.yml 里加spring.main.banner-mode=off,日志干净,排查问题省心。

6. 最后冲刺:把 Vue 打包进 Spring Boot,一条命令交付演示

6.1 前端 dist 合并进 resources/static:项目结构变化与路由回退

毕设做前后端分离很常见——基于 springboot vue 项目的结构,前端独立跑 8081 端口开发,上线时合并部署。很多人的「vue打包放进springboot中」的流程是这样的:前端执行npm run build,生成 dist 目录,把 dist 下的所有文件复制到后端src/main/resources/static下,重新mvn package,得到单个可执行 jar。这样演示时只需要java -jar xxx.jar,不用再开一个 Node 服务,对答辩现场非常友好。

这里有一个关键坑:前端路由如果用的 history 模式,直接访问http://localhost:8080/question/3会 404,因为 Spring Boot 找不到这个路径对应的 Controller,只有http://localhost:8080/index.html是通的。解决办法是加一个回退 Controller:

// 非 API 路径统一转发到 index.html,交给前端路由处理 @Controller public class PageForwardController { @RequestMapping(value = "/{path:[^\\.]*}") public String forward() { return "forward:/index.html"; } }

这段代码写在项目根 Controller 包下即可。注意正则[^\\.]*排除了带点的路径,这样静态资源(js、css、图片)不会误伤。同时后端接口如果统一带有/api前缀,就不会被这个转发规则拦截。合并部署后,前端代码里的接口 baseURL 要改成完整的http://localhost:8080/api,不要再依赖开发时的 proxy 代理,这是合并后接口通不通的关键。

6.2 交付前自测:用一条 curl 链跑通核心链路

答辩前夜,我会用一组 curl 命令把核心链路完整跑一遍,确认无死角。这比在页面上手工点靠谱得多,因为 curl 能看到状态码和原始响应。一组典型命令如下:

# 1. 注册两个测试账号并登录,取出 token curl -X POST http://localhost:8080/api/user/register \ -H "Content-Type: application/json" \ -d '{"username":"stu01","password":"123456","role":0}' curl -X POST http://localhost:8080/api/user/login \ -H "Content-Type: application/json" \ -d '{"username":"stu01","password":"123456"}' | tee login.json # 手工从 login.json 里取出 token 变量,继续后面的操作 # 2. 学生提问:带一个附件文件 curl -X POST http://localhost:8080/api/question \ -H "Authorization: Bearer $TOKEN" \ -F "title=Spring Boot 多数据源怎么配置" \ -F "content=如题,求详细步骤" \ -F "file=@./readme.txt" # 3. 教师回答 curl -X POST http://localhost:8080/api/answer \ -H "Authorization: Bearer $TEACHER_TOKEN" \ -H "Content-Type: application/json" \ -d '{"questionId":1,"content":"在 pom 里加两个 DataSource 配置即可"}' # 4. 学生采纳回答 curl -X POST http://localhost:8080/api/answer/accept \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"questionId":1,"answerId":1}'

跑完这四条,你的系统核心业务就全部验证过了。中间任何一步返回非预期状态码,就按错误信息去查日志,这是最直接的验证方式。最后检查一下 upload 目录里文件是否真的落盘了,再用浏览器访问一次附件下载链接确认权限控制生效,整套交付就稳了。

我自己早年做这类系统时,把上传目录放在了target/classes下,每次一mvn clean附件全没了,答辩前夜差点翻车。后来所有文件相关的代码一律强制用绝对路径,并在启动日志里打印出当前生效的存储路径。这个小习惯救了我很多次。希望这篇笔记能帮到你,别在文件这个不起眼的模块上栽跟头——把它做扎实,反而是整个毕设最亮眼的加分项。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询