1. 为什么一个"过气"的Java Web选题,反而是最稳妥的毕设路径
每年到了毕业季,总有一批人被"前端要玩React、后端要上微服务、中间件不配个消息队列都不好意思写进简历"之类的卷王氛围带偏,毕业论文和系统设计一股脑奔着复杂度去,结果三个月后卡在环境依赖上欲哭无泪。我见过好几个同学的惨痛案例:选择了分布式高并发电商系统,写开题报告时意气风发,等真正动手时才发现自己连Nginx反向代理和Redis集群都还没跑通,最终只能连夜找人改需求降复杂度。
科研工作量管理系统这类题目之所以经久不衰,恰恰因为它踩中了毕业设计的核心逻辑:评审老师看重的不是技术栈有多新,而是你能不能把一条完整的技术链路跑通、能不能把业务需求转化为可维护的代码结构、能不能讲清楚每一个设计决策的来龙去脉。 SpringBoot加Vue这套组合虽然听起来"老",但它背后对应的是企业级Java开发中最主流的工程实践——前后端分离、RESTful接口设计、ORM持久层操作、JWT鉴权、组件化前端开发。这些能力,恰恰是绝大多数Java岗位面试时真正会考察的东西。
这篇博文面向的读者很明确:正在准备Java Web方向毕业设计、需要一个能落地能答辩的完整项目作为参考的同学。我接下来要做的,不是把代码贴一遍让你照着抄,而是把整个项目的骨架逻辑、核心模块的设计思路、从SQL脚本到接口文档这一整条链路的组织方式,以及那些网上教程从来不会告诉你但实际开发中一定会踩的坑,全部摊开来讲清楚。尤其是答辩环节老师最爱追问的几个点——为什么这样设计数据库、为什么选择这个鉴权方案、前端路由为什么这样划分——我都会给出可以直接应对的思考路径。
先把准备工作和环境搭配说清楚,这是整个项目的地基。我假设你已经安装了JDK 8以上版本(推荐8或11,千万别一上来就冲JDK 17,有些老依赖会让你怀疑人生)、Navicat或者DataGrip这类数据库管理工具、Node.js 14以上版本,以及IDEA开发工具。数据库这边我用的MySQL 5.7,理论上8.x也没问题,但后面SQL脚本设计里我专门做了兼容处理,这点到数据库章节再细说。前端构建工具用的Vite而非Webpack,原因很简单:Vite在开发环境下的冷启动速度和热更新体验,比Webpack那套配置省心太多,能直接帮你节省大量调试时间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 项目整体架构与核心功能拆解:别上来就写代码
2.1 系统角色与业务流程设计
所有管理系统类的毕设,第一步永远是定义清楚"谁在用这个系统"。科研工作量管理系统不是简单的CRUD堆砌,它的核心矛盾在于"科研人员填报数据"和"管理人员审核数据"之间的流程流转。我设计了三类角色:
- 普通科研人员:负责填报个人科研成果,包括论文发表、项目立项与结题、专利申请、获奖情况等,可以查看自己的历史填报记录和审核状态。
- 学院管理员:对本学院人员的填报数据进行初审,退回不合规的数据或通过审核,并可以导出统计报表。
- 系统管理员:负责全院系的数据管理、用户分配、指标参数配置以及最终的汇总分析。
这个角色划分不是拍脑袋想出来的,它对应的是真实科研管理场景中最典型的审批流。很多同学做毕设容易犯的毛病是角色设计得太简单——只有管理员和用户两个维度,结果答辩时老师一问"如果你的用户既有老师又有行政人员,他们的审核权力如何区分",就直接卡壳。我这里的方案虽然只做了三层,但每一层之间的数据边界是清晰的:普通人员只能操作自己的数据,学院管理员只能看到本院数据,系统管理员拥有全局视角。这样的权限模型在扩展时也相对容易——如果需要新增"校级管理员"层级,只需要在用户表中加一个role字段的枚举值,然后在后端拦截器中加一条判断分支即可。
2.2 前后端分离架构下的工程目录规划
工程结构直接决定你后面写代码时心里是否清爽。我把整个项目拆成两个独立目录:前端frontend和后端backend。很多同学会把前后端代码塞在同一个工程里,我强烈不建议这么干——哪怕是毕设规模的项目,保持前后端物理隔离能让你在打包部署、排错定位时省很多精力。
后端采用经典的SpringBoot分层架构:
controller层:只做参数接收和结果封装,不写任何业务逻辑。service层:业务逻辑的核心落点,事务控制全部在这一层处理。mapper层:基于MyBatis-Plus的数据访问,复杂查询使用XML文件,简单增删改查直接用注解或Wrapper。config层:放WebMvc拦截器、跨域配置、MyBatis-Plus分页插件配置等。common层:统一返回结果封装、异常处理器、常量定义。
前端Vue工程内部则按"页面-组件-路由-状态"四个维度组织:
views目录存放页面级组件,每个模块一个文件夹。components目录存放可复用的子组件,比如弹窗、表单、表格封装。router目录维护前端路由表,同时配置路由守卫实现登录拦截。api目录按后端接口模块划分文件,统一封装axios请求。
这套结构不是我凭空发明的,它是Vue官方脚手架和SpringBoot官方推荐实践的标准组合,最大好处是你写好一个模块的完整链路(Controller到页面),其他模块照葫芦画瓢即可,而且答辩时画架构图特别有底气。
2.3 核心功能模块的边界划分
我最终敲定的功能清单如下,每一块都直接对应着明确的页面和接口,不存在"做了但没地方展示"的情况:
| 功能模块 | 前端页面 | 后端接口 | 核心业务简述 |
|---|---|---|---|
| 登录认证 | 登录页 | /api/auth/login | JWT签发,登录后返回Token |
| 用户管理 | 用户列表页 | /api/user/ | 用户增删改查、角色分配、重置密码 |
| 成果填报 | 填报表单页 | /api/record/ | 按类型(论文/项目/专利)填报,附件上传用MinIO |
| 成果审核 | 审核列表页 | /api/record/audit | 学院管理员的通过/驳回操作,填写驳回理由 |
| 统计报表 | 统计看板页 | /api/statistics/* | 按院系、年份、成果类型维度聚合统计 |
| 系统管理 | 参数配置页 | /api/config/ | 指标权重、成果分值系数配置 |
这里我特别想强调一个容易被忽略的细节:毕设系统一定要有一个"看起来有技术含量且能被稳定跑通"的亮点功能。我选的是统计报表模块,用ECharts在前端做柱状图和饼图展示各院系科研成果分布,后端接口则用MyBatis-Plus的LambdaQueryWrapper配合Java流操作做聚合计算。这样做的好处是不需要引入复杂的OLAP组件,又能完整展示数据库查询到前端渲染的全链路技能。答辩时老师只要问一句"统计维度是怎么实现的",你就能把SQL层面的分组聚合和Java层面的Stream计算都讲一遍,展示面一下就打开了。
3. 数据库设计与SQL脚本编写:表结构才是这个项目的灵魂
3.1 核心数据表的设计思路
很多人写毕设的时候,数据库设计是随手建的,表字段想到一个加一个,等到写业务代码时发现查询极其别扭,再回来改表结构,一改就是一夜。我这次在设计阶段就花了比写代码更长的时间,把所有表结构一次性定死,后面几乎没有返工。
核心表一共6张:
-
sys_user用户表:字段包含id(自增主键)、username、password(BCrypt加密存储)、real_name、college_id(所属院系外键)、role(区分三类角色)、status(是否启用)、created_time。 -
college院系表:id、college_name、code,这张表是为了支撑按院系维度的统计需求。很多同学会忽略这个维度,但科研工作量统计天然是院系导向的,没有这张表,统计模块根本做不起来。 -
achievement_type成果类型表:id、type_name、base_score(基础分值)、audit_level(审核层级)。这是整个系统的"规则配置"所在——比如一篇SCI论文的基础分值是10分,一个国家级项目立项是20分,这些系数不用写死在代码里,而是做成可配置的数据表,这就是答辩时能讲"系统具备灵活的规则配置能力"的依据。 -
achievement_record成果记录表:这个表是整个系统的核心中的核心,字段非常多:id、user_id(填报人)、type_id(所属类型)、title(成果名称,比如论文标题/项目名称)、source(发表期刊/立项单位)、date(发表或立项日期)、proof_url(证明材料附件路径)、status(审核状态,0待审核,1已通过,2已驳回)、audit_user_id(审核人)、audit_comment(驳回理由)、audit_time、score(最终得分,审核通过时由后台自动计算)、create_time、update_time。 -
sys_config系统参数表:id、config_key、config_value、description。用于存放一些全局可调整的参数,比如打分规则开关、学期时间范围等。这张表能体现你对系统可维护性的理解。 -
operation_log操作日志表:id、user_id、action、target_id、detail、ip、create_time。不用做得太重,能记录关键操作就行。加这张表的目的很纯粹——毕设答辩时如果老师问"系统安全性做了哪些考虑",除了登录鉴权,你还能提操作留痕,这就是加分项。
关于附件存储,我最初考虑过把文件路径直接存到MySQL的varchar字段里,后来考虑到材料的非结构化特性以及后续可能对接对象存储,我引入了MinIO作为附件存储中间件,数据库里只保存文件的bucketName/objectName路径字符串。这一设计和网上很多热门关键词的趋势一致——"MinIO加入到SpringBoot"现在已经是简历上一个不小的加分项,工作量不大,但体现的架构意识完全不同。
3.2 SQL脚本编写中的关键细节
SQL脚本文件我命名为init_database.sql,直接放在项目根目录的sql文件夹下,导航软件里执行一次即可生成全套表结构。这里有几个实操层面的细节分享给各位:
第一,统一字符集与排序规则。 我在脚本开头显式指定了CREATE DATABASE IF NOT EXISTS research_workload DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;。为什么强调这个看似不起眼的细节?因为如果默认字符集没指定,不同机器上MySQL的默认配置不一样,中文插入时轻则乱码,重则直接报错Incorrect string value,极大影响开发时的心情。
第二,初始化数据的种子脚本。 除了建表语句,我还写好了INSERT语句的初始化数据:一个系统管理员账号(admin/admin123,密码存储的是BCrypt加密后的密文)、两个学院管理员账号、若干普通用户账号,外加几条演示用的成果记录数据。我的建议是:创建测试账号时一定要同时提供BCrypt加密后的密文,而不是明文,这样系统一跑起来就能用管理员账号登录,不需要额外写一个用户注册功能。
第三,物化冗余字段的考量。 在achievement_record表里,我冗余了user_name和college_name两个字段。理论上这两者可以通过user_id和college_id关联查询得到,属于典型的"违反第三范式"设计。但我的理由很实际:列表页和统计页高频查询这两个字段,如果每次都去JOIN用户表和院系表,SQL复杂度上升还不是主要问题,真正麻烦的是分页查询和条件筛选会变得极其难写。用空间换查询效率,这在真实项目中也是常见的取舍,答辩时如果被问到就说"基于查询性能和数据一致性维护成本的权衡"。
3.3 一条合理的主链路SQL应该长什么样
从学生视角,最关注的查询是我的成果列表;从管理员视角,最核心的查询是待审核列表。我分别设计了两条主SQL,这里以审核列表为例:
sql复制SELECT
r.id,
r.title,
r.source,
r.date,
t.type_name,
t.base_score,
u.real_name,
cl.college_name,
r.status,
r.create_time
FROM
achievement_record r
LEFT JOIN achievement_type t ON r.type_id = t.id
LEFT JOIN sys_user u ON r.user_id = u.id
LEFT JOIN college cl ON u.college_id = cl.id
WHERE
r.status = 0 -- 待审核状态
AND u.college_id = #{collegeId} -- 当前登录管理员所属院系
ORDER BY
r.create_time DESC
LIMIT #{offset}, #{pageSize}
这条SQL的核心逻辑是:学院管理员只能看到本院系、且处于待审核状态的成果记录。注意我用的是LEFT JOIN而不是INNER JOIN,从业务逻辑上讲,每条记录必然关联到一个用户和一个类型,INNER JOIN才是更严谨的写法。但在实际开发中,LEFT JOIN能在某些脏数据场景下保证记录不丢失,配合后端空值判断就能兜底。如果你的系统数据足够干净,换成INNER JOIN完全没问题,这里只是给你一个区别于课本说法的真实开发视角。
对应这个查询,MyBatis-Plus的分页插件配置也一并附上。这一套配置几乎可以无脑复制到任何SpringBoot项目中:
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInterceptor.setMaxLimit(500L);
interceptor.addInnerInterceptor(paginationInterceptor);
return interceptor;
}
}
分页插件配好后,Service层只需要写Page<AchievementRecord> page = new Page<>(current, size);然后传入Mapper方法,MyBatis-Plus会自动生成带LIMIT的SQL并返回总条数,省去大量手写分页的样板代码。
4. 后端接口开发的关键设计:从鉴权方案到统一返回格式
4.1 JWT鉴权方案:为什么不用Session,以及具体落地姿势
登录鉴权我用了JWT,具体是jjwt库生成的Token。SpringBootSession方案现在已经被大部分互联网企业技术栈淘汰了,因为前后端分离场景下Session天然存在跨域、分布式扩展难的问题。JWT的思路是"无状态认证":用户登录成功后,服务端签发一个包含用户身份信息的加密Token发给前端,前端保存到localStorage,之后每次请求在Authorization请求头带上这个Token,后端拦截器解码校验即可。全程不占用服务端内存。
实现路径分成三块:
第一块是登录接口。用户提交用户名和密码,我们通过BCryptPasswordEncoder校验密码明文和密文是否匹配。这里千万不要用MD5——MD5加盐可以暴力破解,而BCrypt内置了随机盐值和可变迭代次数,安全性根本不在一个量级上。密码校验通过后,用Jwts.builder()生成Token:
java复制String token = Jwts.builder()
.setSubject(user.getUsername())
.claim("userId", user.getId())
.claim("role", user.getRole())
.claim("collegeId", user.getCollegeId())
.setExpiration(new Date(System.currentTimeMillis() + 6L * 60 * 60 * 1000))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
有效期设了6小时,对毕设演示来说是合理值——太短了演示时频繁重新登录很尴尬,太长了有安全风险。关注一下这里我用了一个secretKey,实际项目中应该放到application.yml配置文件里,不要硬编码在Java代码中。
第二块是拦截器。定义一个JwtInterceptor实现HandlerInterceptor接口,在preHandle方法中解析Token:
java复制@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true; // 跨域预检请求直接放行
}
String token = request.getHeader("Authorization");
if (StringUtils.hasText(token) && token.startsWith("Bearer ")) {
try {
Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token.substring(7)).getBody();
Long userId = claims.get("userId", Long.class);
// 将userId放入request attribute,后续Controller可直接使用
request.setAttribute("userId", userId);
return true;
} catch (JwtException e) {
// Token过期或非法,继续走到下方统一返回
}
}
// 手动写入错误响应,而不是抛异常,避免被全局异常处理器二次包装
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"认证失败,请重新登录\"}");
return false;
}
为什么要先处理OPTIONS请求?因为前端Vue开发服务器默认端口是5173,后端是8080,两者跨域,浏览器会自动发出OPTIONS预检请求。预检请求本身不带Token,如果我们的拦截器直接拦截并返回401,前端就会报跨域错误,且这个错误极其隐蔽,排查起来可费劲了。
第三块是注册拦截器并配置放行路径。登录接口/api/auth/login、前端静态资源的访问路径、以及可能存在的验证码接口,都需要从拦截范围中排除:
java复制@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private JwtInterceptor jwtInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/auth/login");
}
}
4.2 统一返回格式与全局异常处理
前后端分离开发最痛苦的事之一,就是每个接口的返回值格式都不一样——有的直接返回数据,有的包装成{code: 0, message: 'success'},前端每个请求都要单独处理一遍异常。我的方案是定义一个泛型返回类Result<T>:
java复制@Data
public class Result<T> {
private Integer code; // 200成功,500异常,401未认证
private String message;
private T data;
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage("操作成功");
result.setData(data);
return result;
}
public static <T> Result<T> error(String message) {
Result<T> result = new Result<>();
result.setCode(500);
result.setMessage(message);
return result;
}
}
然后写一个全局异常处理器GlobalExceptionHandler,加上@RestControllerAdvice注解:
java复制@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result<?> handleBusinessException(BusinessException e) {
log.error("业务异常:{}", e.getMessage());
return Result.error(e.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<?> handleValidException(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(";"));
return Result.error(message);
}
@ExceptionHandler(Exception.class)
public Result<?> handleException(Exception e) {
log.error("系统异常", e);
return Result.error("系统繁忙,请稍后重试");
}
}
这里我引入了一个自定义的BusinessException,这是开发中最实用的模式之一:业务逻辑出错(比如"审核状态已变更,请刷新后再试")时,直接throw new BusinessException("xxx"),抛出的异常会被全局处理器捕获,统一转换为Result.error返回给前端。防御式地捕获所有未知异常返回通用提示,也能避免堆栈信息直接暴露给用户。
这一条链如果设计好了,后续写Controller时会非常轻松:每个接口基本就是调用Service层,返回Result.success(data),不需要在前端每个请求回调里做杂乱的错误分派。
4.3 附件上传与MinIO:给毕设项目增加一个"企业级"卖点
achievement_record里有一个proof_url字段,对应的就是成果证明材料的附件上传功能。这里我单独提出来讲,因为我把这块的存储方案定为了MinIO。MinIO是一个开源的高性能对象存储服务,兼容Amazon S3接口规范,单机部署成本极低,一条docker run命令就能跑起来,但足以在毕设答辩时展示你具备缓存、异步入存储、对象存储这些企业级分布式系统的常识。
SpringBoot集成MinIO的基本步骤包括:
- 在
pom.xml引入MinIO Java SDK的依赖。 - 在
application.yml中配置minio.endpoint、access-key、secret-key、bucket-name。 - 在代码中初始化
MinioClient,封装上传方法:
java复制public String upload(MultipartFile file, String userId) {
String originalFilename = file.getOriginalFilename();
String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
String objectName = userId + "/" + System.currentTimeMillis() + suffix;
try {
minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build()
);
return bucketName + "/" + objectName;
} catch (Exception e) {
throw new BusinessException("文件上传失败");
}
}
关于MinIO的部署,我采用的不是裸机安装,而是Docker容器化:
bash复制docker run -d --name minio \
-p 9000:9000 -p 9001:9001 \
-e "MINIO_ROOT_USER=minioadmin" \
-e "MINIO_ROOT_PASSWORD=minioadmin" \
minio/minio server /data --console-address ":9001"
9000端口是S3 API端口,9001是管理控制台端口。如果是最终演示部署在一个环境里,把宿主机IP和端口对调通就行。这里不需要实际动手,开发的时候本地起一个容器,一个星期后再也不需要动它,稳定得很。
5. 前端Vue实现:从工程创建到核心页面串联
5.1 基于Vite的工程初始化与基础配置
创建Vue工程我使用的是Vite脚手架,命令如下:
bash复制npm create vite@latest frontend -- --template vue
cd frontend
npm install
npm install vue-router@4 axios element-plus echarts
这里我用的UI组件库是Element Plus——Vue 3时代的组件库首选,表单、表格、弹窗、分页、消息提示全都有现成组件,能帮你在最短时间内拼出一个看起来专业度足够的后台界面。如果你觉得Element Plus太重,也可以用Naive UI或Ant Design Vue,但Element Plus文档中文友好、案例丰富,对毕设时间紧张的你是最优解。
如果还有同学在纠结Vue 2和Vue 3之间选择的话,直接说结论:直接选Vue 3 + Composition API。 Vue 2已经进入维护末期,而Vue 3的Options API语法和Vue 2基本兼容,Composition API(setup函数里用ref、reactive)写起来逻辑很集中,熟悉几天后效率极高。别把时间浪费在即将被淘汰的东西上。
5.2 Vue Router路由规划与权限控制
前端路由我分了三层:
javascript复制const routes = [
{
path: '/login',
component: () => import('@/views/Login.vue')
},
{
path: '/',
component: () => import('@/layout/MainLayout.vue'),
redirect: '/dashboard',
children: [
{ path: 'dashboard', component: () => import('@/views/Dashboard.vue') },
{ path: 'record/list', component: () => import('@/views/record/MyRecord.vue') },
{ path: 'record/audit', component: () => import('@/views/record/AuditRecord.vue') },
{ path: 'user/list', component: () => import('@/views/user/UserList.vue') },
{ path: 'statistics', component: () => import('@/views/Statistics.vue') },
{ path: 'config', component: () => import('@/views/Config.vue') }
]
}
]
路由守卫的逻辑是:如果未登录(localStorage里没有Token)且目标路由不是/login,就直接重定向到登录页。这种"路由级权限控制"只需要一道全局前置守卫:
javascript复制router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.path !== '/login' && !token) {
next('/login')
} else {
next()
}
})
对于按钮级或页面内元素级的权限来控制,比如"只有学院管理员才显示审核按钮",我在用户登录后把角色信息存到localStorage或Pinia里,模板中通过v-if判断即可。这里建议使用Pinia而不是Vuex来管理用户状态,因为Pinia是Vue 3官方推荐的状态管理方案,API设计更简洁,没有那么多样板代码。照着Vuex的很多老教程学习的话,学到的东西已经有点落后了——Pinia无论从学习成本还是内容现代化程度来讲都更占优势。
5.3 axios封装:拦截器与错误处理的正确姿势
如果不是做前后端分离的项目,axios基本只是个请求函数;但做了分离之后,请求拦截器和响应拦截器是整体体验的命门。我在src/api/request.js里做了一个集中封装:
javascript复制import axios from 'axios'
import { ElMessage } from 'element-plus'
const request = axios.create({
baseURL: 'http://localhost:8080/api',
timeout: 15000
})
// 请求拦截器:自动携带Token
request.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截器:统一处理业务码和HTTP错误
request.interceptors.response.use(
response => {
const res = response.data
if (res.code === 200) {
return res
} else if (res.code === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
ElMessage.error('登录已过期,请重新登录')
} else {
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message))
}
},
error => {
ElMessage.error(error.response?.data?.message || '网络异常,请稍后重试')
return Promise.reject(error)
}
)
export default request
这样的封装带来的直接效果是:业务组件里只管调接口、拿数据、渲染视图,所有异常处理和提示都是全局的。你可以完全不出现任何一个写死的try-catch在页面上。这个点对于答辩时讲"前端工程质量"很有说服力。
5.4 填报页面与审核页面的完整实现逻辑
以成果填报页为例。表单包含成果类型(下拉选择)、成果名称(输入框)、来源(输入框)、日期(日期选择器)、证明材料(上传组件)。提交时首先做前端表单校验(Element Plus的rules配置),通过后调用/api/record/create接口,将一份完整的JSON数据递交给后端。
我尤其推荐在这个场景里使用动态表单的展示方法:当用户切换成果类型时,表单字段会随之变化。比如选"论文"类型时出现"期刊名称"和"是否SCI收录"字段;选"项目"类型时出现"项目级别"和"立项单位"字段。这对应了后端设计里achievement_type表的可扩展性。如果你带着这个设计去答辩,老师会认为你真的在业务维度有思考,而不是单纯做了一个增删改查。
审核页的核心是列表和详情弹窗的组合:列表展示待审核记录,点击"审核"按钮弹出详情抽屉,展示填报的所有信息以及证明材料附件的预览或下载链接。审核操作区有两个按钮——"通过"和"驳回",驳回必须填写理由。前端要在提交驳回时强制校验理由字段非空:
javascript复制if (auditStatus === 2 && !auditComment.value) {
ElMessage.warning('请填写驳回理由')
return
}
这个细节虽然简单,但能体现"业务流程完整的闭环"。前端展示后台按状态(待审核/已通过/已驳回)做了Tab页签,方便管理员快速筛选处理。
6. 接口文档编写与后端联调:别让工具成本吃掉你的时间
6.1 接口文档的组织形式
关于接口文档,很多同学有个误区:觉得写文档是在浪费时间,不如多写两行代码。然而一旦进入联调阶段——特别是前端是别人写的,或者你需要照着文档去给别人交接——一份好的接口文档能把沟通成本降到接近零。我用的是Swagger/SpringDoc在SpringBoot 3中自动生成API文档的方式,同时保存了一份手动整理的Markdown版接口说明放在项目docs目录下。
接口文档里必须包含的内容有:接口路径、请求方式、请求参数(包括参数名、类型、是否必填、说明)、返回体结构示例、以及一个简单的调用示例。这里最容易被忽略的是"返回体结构示例",很多同学写文档只写了"返回成功或失败",前端根本不知道data里面到底是嵌套对象还是数组,联调时就开始靠猜了。
从工作量角度考虑,如果你用的是SpringDoc的注解,可以在Controller上标注@Tag和@Operation,文档会自动同步代码,导出HTML或者OpenAPI JSON后,前端可以直接用swagger-ui在线调试。但手动维护的Markdown版仍然是需要的,因为它能让你从"使用者"角度审视接口设计的合理性,同时也方便答辩时直接打印展示。
6.2 联调阶段最常见的三类问题
想先提前踩一下雷,把联调过程中最可能遇到的几个问题拿出来说,避免你到时候排查半天。
第一类:跨域配置。 SpringBoot后端和Vite前端端口不一致,必然有跨域问题。我在后端配置类里已经允许了所有来源和所有请求头。但如果你遇到的是"Access-Control-Allow-Origin"错误,大概率是拦截器或者过滤器没有正确放行OPTIONS请求,就对照上文4.1小节里的OPTIONS判断逻辑逐步排查。
第二类:日期格式。 Java后端默认返回的LocalDateTime格式是yyyy-MM-dd'T'HH:mm:ss,前端直接展示会出现一个刺眼的T。最省事的方案是在application.yml里配置全局日期格式:
yaml复制spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
当然,也可以在@JsonFormat注解上单独指定每个字段的格式,但全局配置能让你一次搞定所有接口。
第三类:空值序列化。 如果后端返回的对象中某字段为null,前端用undefined判断时会莫名报错。我建议在返回实体类上直接加@JsonInclude(JsonInclude.Include.NON_NULL),将空值字段直接忽略,这样前端拿到的一定是"有值的字段"或"字段不存在",配合可选链操作符(?)读取,几乎不会出现运行时异常。
6.3 接口自测的最佳路径
自测建议直接用Postman或者Apifox,顺序按照"登录获取Token -> 带上Token访问业务接口"来进行。有一件事能做到事半功倍:在Postman的Collection变量里存好token字段,每次登录成功后通过测试脚本自动更新:
javascript复制const response = pm.response.json()
pm.collectionVariables.set('token', response.data.token)
然后所有业务接口的请求头里,直接使用{{token}}引用。这样每次联调只需登录一次,剩下的接口测试流程基本是一键跑完,精力能真正聚焦在业务逻辑验证上,而不是浪费在无休止的登录请求中。
我还养成了一个习惯:随手保存一份debug_requests.http文件在项目根目录,用IDEA的HTTP Client功能直接执行调试请求。这个文件的好处是纯文本易维护,而且放进Git仓库后,任何人都能快速复现代码环境的核心请求链路:
http复制POST http://localhost:8080/api/auth/login
Content-Type: application/json
{
"username": "admin",
"password": "admin123"
}
###
GET http://localhost:8080/api/record/list?page=1&size=10
Authorization: Bearer {{token}}
这个文件的每一块用###分隔,IDEA可以直接点击运行单个请求,比Postman更轻量,更符合技术控的审美。
7. 打包部署与答辩演示准备:最后一公里往往最要命
7.1 后端打包与前端产物集成的三种方式
后端打成可执行jar包,命令不复杂:mvn clean package -DskipTests。注意,SpringBoot的打包插件必须配了才能生成包含依赖的fat jar,否则可能会打出无法运行的普通jar包。检查pom.xml中是否有spring-boot-maven-plugin插件,之后执行java -jar target/research-system.jar就能启动服务。
前端的部署有三种方式,我一次说清楚:
-
方式一:将前端构建产物放到后端jar包解压后的
static目录或用WebMvcConfigurer映射静态资源路径。这是最简单的单机演示方案——前端构建一次后生成dist目录,将整个dist文件夹下的文件拷贝到后端项目的src/main/resources/static下重新打包,这样访问8080端口即可直接打开页面,不存在跨域。 -
方式二:前端独立部署在Nginx。生产环境标准做法,配置一个反向代理,将
/api前缀的请求转发到后端。
nginx复制server {
listen 80;
server_name your_server_ip;
root /usr/share/nginx/html/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;
}
}
- 方式三:开发模式下双开服务。后端8080,前端Vite的5173,后端配置跨域直接允许。这种方式适合开发过程中的调试,也是第四次联调时用的方案。
答辩演示时的建议:用方式一最稳妥。一个jar包搞定整个后端和前端,老师在笔记本上运行也只需要装一个JDK和一个MySQL就行,不需要额外配置Nginx。这里尤其注意:不要为了演示"前后端分离"而故意开两个服务,因为现场的稳定性是最重要的,你不需要把一个开发模式的概念在不合适的场景里强行展示。
7.2 答辩之前必须准备的东西
除了代码能跑通,我建议你在答辩前一天完成之前完成以下清单:
-
演示脚本:设计一个完整的演示流程。先以管理员身份登录,展示用户管理、数据初始化;切换到学院管理员账号,做一条审核操作(通过和驳回各一次);切换回管理员查看统计页面,指出数据变化。演示过程要尽量控制在8-10分钟内,讲清楚"做了什么"和"为什么这样做"即可。
-
环境还原脚本:写一个
reset_demo.sh脚本,一键清空业务数据并重新导入初始Alice数据。演示前可能因为反复操作导致数据很乱,一键重置能让你从容应对现场。 -
源码压缩包:把前后端工程、SQL脚本、接口文档、README、演示说明文档全部打包一个zip压缩包,命名格式
项目名_学号_姓名_v1.0.zip。README里要写清楚JDK版本、MySQL版本、Node版本、初始化步骤、启动命令,还有默认账号密码。 -
常见问题速查表:把答辩时老师最喜欢问的几十个问题提前写好答案,包括:为什么选SpringBoot、数据库为什么这样设计、如果并发量大怎么办、系统安全如何保证、能做哪些扩展。这些问题也在我的博文每章里都做了提前应对。
8. 从毕设到简历:这套项目如何讲出真正的价值
项目做完并不意味着结束恰恰是开始。把科研工作量管理系统从毕设变成面试项目,需要你重新梳理"项目亮点"的表达方式。
最忌讳的表达是:"我实现了一个SpringBoot和Vue的管理系统,有用户管理、权限管理、信息CRUD功能。" 这种描述等于没说,面试官每天读十几份雷同简历早都麻了。
我建议按如下结构包装:
-
项目背景:面向高校科研管理场景,实现科研成果录入、多级审核、权重计算、统计报表的一体化流程。一句话交代清楚目标域和解决的问题。
-
自己负责的核心环节:独立完成从数据库设计到前后端开发的全流程,重点讲解JWT鉴权体系、MinIO附件存储、动态多维度统计接口、Excel导出等模块。
-
难点与解法:例如"在导出Excel报表时,遇到大数据量时内存溢出,改为分批查询和流式写入Excel"——只要真实解决过一个小问题,比任何空洞的自我评价都有说服力。哪怕你的解决方案最终没那么优雅,但具备"发现问题、分析原因、给出方案、验证效果"四步描述,就是面试官想听到的东西。
-
数字量化:管理上有3种角色的权限模型、支撑6张核心业务表、覆盖院系、9类成果、年度统计等多维度分析等。数字能帮你锚定记忆和谈资,让整个描述更具体。
如果时间允许,可以在这套系统上做两个低成本扩展:一是引入Spring Data Redis缓存热门统计结果,二是增加一个简单的RabbitMQ异步通知(审核结果通过时给填报人发站内信或邮件)。这两个扩展都能用一两百行代码完成,但简历上呈现出来的技术面就完全不一样了。
9. 我在开发过程中踩过的坑与最终建议
最后一章不聊架构了,分享一些纯粹实操层面的东西。
第一个坑是数据库时区问题。连接MySQL时,如果JDBC URL不指定serverTimezone=Asia/Shanghai,在凌晨跑程序时日期会差8小时,排查大概花了我一个晚上。这类问题最隐蔽,因为白天测试永远正常。直接给出结论:jdbc:mysql://localhost:3306/research_workload?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai,照着写准没错。
第二个坑是文件路径符号。在Windows环境下使用File操作时,路径分隔符\和/混用容易出错。尽量用Paths.get()配合File.separator,或者干脆用Path对象操作。如果你在代码里硬编码了C:/xxx这种绝对路径,换台电脑就崩了,好多人答辩演示翻车就是栽在这里。
第三个坑是前端路由白屏。Vite打包后直接双击index.html打开,往往白屏,因为相对路径问题。解决办法是在vite.config.js里设置base: './'。这个细节我在每次做Vue项目时都会踩一次,建议直接记为默认配置习惯。
第四个坑是Maven仓库依赖下载极慢。如果网络状况不佳,建议配置阿里云镜像。这个配置只影响你的下载速度,不影响项目结构。
再聊最后一个认知层面的建议:不要追求代码量越多越好。 很多同学总觉得系统做得越复杂、代码量越大,老师就会给越高分。但其实毕业设计的评审标准从来不是代码行数,而是你能否清晰表达出"每个模块存在的意义"和"每个设计决策背后的思考"。一个结构干净、每个模块都能讲清楚为什么存在的小系统,远好过一个为了凑代码量而堆砌大量冗余功能但自己都说不清逻辑的庞大系统。我自己当时做的时候,一开始也总想加点这个加个那个,后来冷静下来砍掉了将近三分之一的功能,只留下那些能在答辩时讲清楚、演示时能走通的,最终效果反而更好。
希望这篇拆解能帮你把从数据库设计到接口调通的整条链路彻底打通。如果你在搭建过程中遇到具体问题,欢迎在评论区把报错信息贴出来,我会挑典型的案例结合代码细节再展开聊。
