搞过前后端分离项目的人应该都有这种感觉:单看SpringBoot、Vue、MyBatis、MySQL这几个词,每一个都不算陌生,但真要把它们揉进一个能跑、能交付、能部署的完整系统里,中间隔着的东西比想象中多得多。这套科研管理系统就是在这种背景下攒出来的,项目本身不算大,但五脏俱全:登录鉴权、动态菜单、分页检索、文件上传、多角色权限、打包部署,一条链路走下来,几乎把前后端分离项目里最常见的那批坑都踩了一遍。
所以我写这篇东西的思路很直接:不贴几十页流水账代码,而是把这个项目的设计取舍、核心实现、部署过程、坑点排查完整讲清楚。如果你正准备做毕业设计、课程设计,或者想找一套能直接上手改造的前后端分离脚手架,这篇内容应该能帮你省下大量翻文档和试错的时间。
1. 项目整体设计与思路拆解
先说清楚这套系统到底做了什么。科研管理系统面向的是高校或科研单位的日常科研业务管理场景,核心用户有四类:学生、教师、科研秘书、系统管理员。业务上主要围绕科研项目从立项、中期检查到结题的全生命周期展开,同时包含论文成果登记、经费使用记录、数据统计导出等功能。
1.1 前后端分离到底分离了什么
很多初学者对“前后端分离”的理解停留在“用了Vue当框架、用了SpringBoot写接口”这个层面,其实分离的核心在于交互方式变了。传统JSP或Thymeleaf那套是服务端渲染,页面跳转、数据填充、模板解析全在服务器上完成;前后端分离之后,前端工程独立运行,通过HTTP接口与后端通信,数据以JSON格式传输,页面路由由前端控制。
这套系统里,前端工程用Vue CLI构建,开发环境下通过代理解决跨域,生产环境把打包后的静态文件扔给Nginx,再由Nginx反向代理到后端服务。所以前端和后端在物理上就是两个独立部署的单元,Vue项目跑在80或8080端口,SpringBoot服务跑在8080或9090端口,两者之间只有JSON请求和响应。
这样设计的好处很明显:前后端可以并行开发互不阻塞;后端接口可以同时被Web端、小程序端甚至App端复用;部署时可以单独扩容,哪边压力大就扩哪边。代价则是联调成本变高、跨域问题必须处理、鉴权不能再依赖传统的Session+Cookie,要换成Token机制。
1.2 核心业务模块划分
科研管理系统的业务不算复杂,但模块之间有关联,设计时我按“以项目为主线”的思路来拆分:
- 系统管理模块:用户管理、角色管理、菜单管理、数据字典。这是后台系统的底座,也是动态路由的数据来源。
- 项目申报模块:学生或教师提交科研项目立项申请,填写项目名称、类型、预算、成员等信息,上传项目任务书附件。
- 项目审核模块:科研秘书对申报材料进行初审,按角色权限决定是否通过,流程上支持退回修改。
- 项目过程管理:立项后的中期检查、变更申请、结题申请,每个节点都有状态字段控制。
- 成果登记模块:论文、专利、获奖等科研成果的登记和审核。
- 经费管理模块:记录每笔经费支出,按项目汇总统计。
- 数据统计模块:按部门、按年份、按项目类型统计科研产出,生成表格数据供前端展示。
模块划分的原则遵循“高内聚、低耦合”,每个模块对应一组Controller、Service、Mapper,接口路径统一以/api/module/action方式命名,例如/api/project/apply、/api/review/approve。这样无论前端还是后端,看一眼路径就知道在做什么。
1.3 角色权限与数据隔离
权限模型采用经典的RBAC设计:用户-角色-菜单(权限)三张核心表,外加两张关联表。管理员给角色分配菜单权限,用户登录后根据角色ID查询菜单列表,动态生成前端路由。
数据隔离方面,普通用户只能看到自己提交的项目和成果;科研秘书能看到全院的申报数据;系统管理员则拥有全部权限。这个隔离不做成复杂的行级权限框架,而是在Service层通过判断当前用户的角色ID,在SQL查询条件中动态拼上user_id = ?或dept_id = ?,简单直接,对于这种规模的项目完全够用。
2. 技术选型深挖:SpringBoot、Vue、MyBatis、MySQL
这套系统的技术栈是市面上最常见的一档,网上随便一搜就是一大把同名项目。但常见不等于简单,真正动手时版本选择、框架搭配、工程结构都是需要想清楚的事。
2.1 SpringBoot版本选择与工程结构
项目采用了SpringBoot 2.7.x版本。之所以卡在这个版本线,而不是直接上SpringBoot 3.x,主要是因为生态兼容问题。SpringBoot 3要求JDK 17起步,且对javax包名改为jakarta,很多老牌第三方库的兼容性还没完全跟上。
如果你是跟着教程做毕业设计,选SpringBoot 2.7.x + JDK 1.8的组合是最稳的,因为大部分教学资源、封装好的工具类、网上搜到的解决方案都基于这套环境。如果确实想用SpringBoot 3,建议同时升级到对应的MyBatis Starter版本,并测试好所有依赖的兼容性,否则光是一个包名改起来就够折腾的。
后端工程结构采用标准的分层分包方式:
com.example.research ├── controller # 控制层,接收请求、返回结果 ├── service # 业务层,处理核心逻辑 │ └── impl ├── mapper # MyBatis数据访问层接口 ├── entity # 数据库实体类 ├── dto # 前端请求/响应数据传输对象 ├── config # 配置类:跨域、拦截器、WebMvc ├── common # 通用工具、统一返回结构、异常处理 ├── utils # JWT工具、文件处理工具等控制层只做参数接收和结果封装,业务逻辑写在Service中,数据库操作走Mapper接口。这样做的好处是隔离清晰、后续好扩展,比如需要加入缓存或消息队列时可以直接在Service层做切面,不用动Controller。
2.2 用MyBatis而不是MyBatis-Plus的取舍
现在很多新项目直接上MyBatis-Plus,确实省事,单表CRUD基本零SQL。这套系统选型时我特意回归了原生MyBatis,原因有两个:一是科研管理系统里的核心查询大多是多表关联,MP虽然后来也支持,但XML里写SQL的灵活度和可控性更强;二是原生MyBatis能让你对SQL的执行过程有更清晰的认识,对理解#{}和${}的区别、一级缓存、二级缓存这些基础概念更有帮助。
当然这不意味着我要否定MyBatis-Plus,实际开发中如果在做纯单表操作占多数的系统,用MP能提升不少效率。这里只是说明选型需要结合业务场景。
MyBatis的坐标我用了mybatis-spring-boot-starter,版本在2.3.x,与SpringBoot 2.7.x配合良好。Mapper接口与XML文件通过@Mapper注解扫描注册,在application.yml中配置了mapper-locations指向classpath:mapper/*.xml。
2.3 前端工程:Vue 2还是Vue 3
这是一个绕不开的选择题。这套系统用的是Vue 2 + Element UI的组合。从技术上看,Vue 3 + Element Plus已经是新项目的默认选项,但如果你的目标是把项目跑起来、快速完成毕业设计或后端方向的学习,Vue 2的教程数量、问题解决方案、组件库成熟度依然是最好的。
如果你选择Vue 3,需要注意的点包括:main.js的创建方式从new Vue()变成了createApp();路由和Vuex的引入方式从Vue.use()改为app.use();Element Plus的组件注册方式也略有不同。这些差异在写页面时都会遇到。
我这套项目里单独封装了request.js模块,基于Axios统一处理请求地址、超时时间、请求头、响应拦截。前端请求流程统一为:页面调用API模块中的函数,API模块通过Axios发出请求,后端返回统一JSON结构,前端通过响应拦截器判断业务码。
3. 数据库设计与MyBatis落地细节
数据库设计是这套系统里最不能马虎的部分。前后端分离项目,接口返回的数据结构基本由表结构决定,表设计不合理,后面所有接口都要跟着遭殃。
3.1 核心表结构设计要点
科研管理系统的主要数据表包括用户表、角色表、菜单表、科研项目表、项目参与者表、中期检查表、结题申请表、成果登记表、经费记录表、数据字典表。这里挑几张核心表说几个设计要点:
第一,用户表不直接挂角色字段。用户与角色之间用中间表关联,因为用户可能身兼多职,比如一个硕士生同时是某项目的参与者、某论文的第一作者。中间表方案能保持数据的规范化。
第二,科研项目表带一个status字段来记录审批状态,取值范围在数据字典中定义,比如0草稿、1待初审、2初审通过、3待中期、4待结题、5已结题、6已驳回。状态字段配数据字典,前端就能根据状态码渲染对应的标签颜色和操作按钮。
第三,所有的业务表统一带上create_time、update_time、deleted这三个字段。前两个用于排序和审计,deleted用于逻辑删除。这不是科研系统特有的要求,但很多初学者会忽略。
第四,经费记录表和项目表是多对一关系,经费表里必须包含project_id外键,同时记录支出类型、金额、经手人、备注。统计时通过GROUP BY project_id汇总,接口层再按项目回填数据。
3.2 XML映射与多表联查
MyBatis的核心在于SQL映射,这套系统里我把所有的多表查询都写在XML里。例如查询项目列表需要同时附上申报人姓名、所在部门、项目类型名称、当前状态名称,就要从项目表关联用户表、部门表、字典表。
SQL大概长这样:
<select id="selectProjectList" resultType="com.example.research.dto.ProjectDTO"> SELECT p.id, p.project_name, p.project_type, p.budget, p.status, u.real_name AS applicant_name, d.dept_name, dict.label AS status_label FROM research_project p LEFT JOIN sys_user u ON p.create_by = u.id LEFT JOIN sys_dept d ON u.dept_id = d.id LEFT JOIN sys_dict dict ON dict.type = 'project_status' AND dict.value = p.status <where> <if test="projectName != null and projectName != ''"> AND p.project_name LIKE CONCAT('%', #{projectName}, '%') </if> <if test="status != null"> AND p.status = #{status} </if> <if test="userId != null"> AND p.create_by = #{userId} </if> </where> ORDER BY p.create_time DESC </select>值得注意的细节是SQL里的动态条件:如果当前登录用户是普通教师,Service层会往查询参数里塞一个userId,XML通过<if>标签动态拼接过滤条件,从而实现数据隔离;如果用户是科研秘书,则不传这个参数,直接查全院数据。
#{}在这里是预处理参数,MyBatis会转成?占位符,可以防止SQL注入。而${}是字符串拼接,虽然有时写排序字段比较方便,但绝不能直接拼用户输入。
3.3 MyBatis缓存机制与坑点
MyBatis默认开启一级缓存,也就是SqlSession级别的缓存,同一个SqlSession中两次相同的查询会直接命中缓存。但在实际项目中,每个请求都会创建新的SqlSession,所以一级缓存的作用范围非常有限,对系统性能提升基本没有帮助。
真正有意义的是二级缓存,它是Mapper级别的缓存,多个SqlSession可以共享。在XML中加一行配置就能开启:
<cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/>不过我要提醒一句:科研管理系统这种低并发、多业务表关联的系统,我并不建议贸然开二级缓存。原因在于,当关联表中的数据发生变更时,缓存不会自动感知,特别容易查出脏数据。比如用户修改了姓名,之前缓存过的项目列表可能还存着旧姓名,要等缓存过期才能刷新。
如果你确实想用二级缓存,建议只用在高频读取且极少更新的字典表、部门表上,同时接受最多一分钟的延迟刷新。
4. 后端核心模块实现:从登录鉴权到文件上传
后端是整个系统的发动机。这里挑四个我认为最具代表性的模块详细展开:JWT登录鉴权、分页查询、项目审批流程、文件上传。
4.1 JWT登录鉴权与拦截器
前后端分离项目的登录鉴权不能用Session,原因很简单:Session依赖Cookie,Cookie依赖同源策略,跨域场景下处理起来非常别扭。所以这套系统采用JWT(JSON Web Token)机制。
登录流程是:
- 前端调用
/api/auth/login,传入用户名和密码。 - 后端校验用户名密码,成功则生成一个Token,返回给前端。
- 前端把Token存到
localStorage或sessionStorage里。 - 后续每个请求在Axios请求拦截器里带上
Authorization: Bearer token。 - 后端通过拦截器解析Token,取出用户ID和角色信息,放入
ThreadLocal,供后续业务代码使用。
Token生成的核心代码依赖io.jsonwebtoken:jjwt这个库:
public String generateToken(Integer userId, String username) { Map<String, Object> claims = new HashMap<>(); claims.put("userId", userId); claims.put("username", username); return Jwts.builder() .setClaims(claims) .setSubject(username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 1000L * 60 * 60 * 24)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }拦截器继承HandlerInterceptorAdapter,在preHandle方法中检查请求头里的Token。如果Token不存在或已过期,直接返回401状态码,前端响应拦截器捕获后跳转到登录页。
这里有一个实际开发中容易踩的坑:拦截器只拦截/api/**路径,但登录接口/api/auth/login必须放行,否则用户连登录都进不去。另外如果是前后端分离部署,别忘了配置跨域,否则浏览器会在CORS策略这关把请求拦下来。
4.2 分页查询与条件检索
列表接口是后台管理系统最普遍的接口类型,科研项目的列表页需要支持项目名称模糊搜索、状态条件筛选、申报人精确匹配、按时间排序、分页展示。我用PageHelper这个分页插件来简化分页操作。
PageHelper.startPage(pageNum, pageSize); List<ProjectDTO> list = projectMapper.selectProjectList(queryDTO); PageInfo<ProjectDTO> pageInfo = new PageInfo<>(list);PageHelper.startPage后面必须紧跟第一条查询语句,这个顺序不能乱,否则分页会失效。分页插件底层是通过MyBatis拦截器自动在SQL后面拼接LIMIT实现的,所以如果你在调用后额外执行了别的查询,分页参数就跑到那条SQL上去了。
封装返回给前端的分页数据结构一般长这样:
{ "code": 200, "data": { "list": [], "total": 100, "pageNum": 1, "pageSize": 10, "pages": 10 } }前端拿到total值用来计算分页组件的总页数,list用于渲染表格。这种结构几乎适用于所有的列表接口,建议在后端封装一个公共的PageResult类,避免每个接口返回结构不一致。
4.3 科研项目审批流程的状态机设计
审批流程的核心是状态流转控制。科研项目的状态不能乱跳,比如草稿状态不能直接跳到已结题,待初审状态不能直接退回草稿以外的其他状态。如果把这些判断散落在各个Service方法里,代码会越来越难维护。
我的做法是在Service层抽出一个状态校验逻辑,用Map定义合法流转关系:
private static final Map<Integer, Set<Integer>> TRANSITION_MAP = new HashMap<>(); static { TRANSITION_MAP.put(0, Set.of(1, 6)); // 草稿 -> 待初审, 已驳回 TRANSITION_MAP.put(1, Set.of(2, 6)); // 待初审 -> 初审通过, 已驳回 TRANSITION_MAP.put(2, Set.of(3, 4)); // 初审通过 -> 待中期, 待结题 TRANSITION_MAP.put(3, Set.of(4)); // 待中期 -> 待结题 TRANSITION_MAP.put(4, Set.of(5, 6)); // 待结题 -> 已结题, 已驳回 }每次执行流转操作前,先判断当前状态和目标状态是否在合法映射表中,不合法直接抛出业务异常。这种方式把“散装”的状态判断集中到了一个地方,后面要加新的状态节点,只需要改这一张映射表即可。
4.4 文件上传:本地存储与MinIO整合
科研项目申报时必须上传任务书、预算表这类附件,结题时还要传结题报告。这套系统最初用的是最简单的本地存储方案:上传的文件保存到服务器某个目录,数据库里存文件的访问路径。但部署到多台服务器或容器环境时,本地存储的弊端就暴露了——文件只存在于某一台机器上,其他请求访问不到。
后来我引入了MinIO来做对象存储。MinIO是开源的,兼容S3协议,部署非常简单,一条Docker命令就能拉起来。SpringBoot整合MinIO的步骤也不复杂:引入io.minio:minio依赖,配置一个MinioClient的Bean,然后封装上传、下载、删除方法。
MinIO接入的核心代码大概是这样:
public String uploadFile(MultipartFile file) { String originalFilename = file.getOriginalFilename(); String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); String objectName = UUID.randomUUID().toString().replace("-", "") + suffix; minioClient.putObject(PutObjectArgs.builder() .bucket(bucketName) .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); return objectName; }对象名用UUID重新生成,避免文件名冲突和中文乱码问题。数据库里保存的是objectName,前端展示时再通过后端接口生成临时访问URL(presignedGetObject),这样还可以控制访问权限。
如果只是做本地部署演示,继续用本地存储也完全可行,但要约定好统一的存储目录,并且用绝对路径保存,防止相对路径导致文件找不到。
5. 前端核心实现:动态路由、状态管理与交互
前端这部分的核心难度不在页面样式,而在工程化的组织方式。Vue项目写得久了就会明白,无非是路由配置、状态管理、接口封装、组件复用这么几件事。
5.1 动态路由与权限菜单
动态路由是前后端分离后台系统的标志性功能。普通的静态路由适合内容固定的页面,但带权限的后台系统必须做到:不同角色登录后看到的菜单不同,且不能通过在地址栏输入URL绕过菜单直接访问无权页面。
实现思路是这样的:
- 路由表分为两部分,基础路由(登录页、404页、首页)在代码里静态定义,业务路由在本地建立完整的“路由映射表”。
- 用户登录成功后,后端返回该用户有权限访问的菜单列表,数据结构是树形(父菜单+子菜单)。
- 前端拿到菜单树后,递归遍历菜单树,根据每个菜单项上的
component字段,从本地映射表中找到对应的路由组件,动态注册到Vue Router中。 - 菜单栏渲染的数据源也是这棵菜单树,不是手写死的。
这里引入了一个Vue Router的动态添加方法router.addRoute,这是实现的核心。权限菜单的完整流程可以总结为:登录接口返回菜单权限,前端根据权限动态注册路由,同时将菜单结构交给侧边栏组件渲染。
要注意的一个坑:直接刷新页面时,Vue实例会重新创建,vuex里的菜单数据会丢失,动态添加的路由也不复存在。所以刷新时必须重新获取用户信息和菜单权限,再走一遍动态路由注册流程。否则刷新后当前URL指向的页面会因为路由不存在而跳转到404。
5.2 Axios封装与Token处理
Axios封装是所有前后端分离项目前端的标准操作。我通常会单独建一个utils/request.js,统一配置:
const service = axios.create({ baseURL: process.env.VUE_APP_BASE_URL, timeout: 15000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code === 401) { localStorage.removeItem('token') router.push('/login') return Promise.reject(new Error('未授权')) } if (res.code !== 200) { Message.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, error => { Message.error(error.message) return Promise.reject(error) } )这样做的核心收益是:所有请求的鉴权头、错误处理都在一个地方管理,页面代码不需要重复处理错误弹窗和登录失效跳转。尤其是401这个状态码,一旦后端统一返回,前端只需在这一个位置做跳转处理,不需要每个页面都写一遍。
5.3 列表页、表单页、分页组件的套路化写法
后台管理系统的页面高度重复,大部分页面就是表格+搜索栏+弹窗表单的组合。我整理了一套固定的写法套路,新模块页面基本就是复制改改。
列表页的Vue组件模板大致是:
<template> <div> <el-form :inline="true" :model="queryParams"> <el-form-item label="项目名称"> <el-input v-model="queryParams.projectName" placeholder="请输入项目名称" /> </el-form-item> <el-form-item label="状态"> <el-select v-model="queryParams.status" placeholder="请选择状态"> <el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" /> </el-select> </el-form-item> <el-form-item> <el-button type="primary" @click="handleQuery">查询</el-button> <el-button @click="resetQuery">重置</el-button> </el-form-item> </el-form> <el-table :data="pageList" v-loading="loading"> <el-table-column prop="projectName" label="项目名称" /> <el-table-column prop="applicantName" label="申报人" /> <el-table-column prop="budget" label="预算金额" /> <el-table-column prop="statusLabel" label="状态" /> <el-table-column label="操作"> <template slot-scope="scope"> <el-button type="text" @click="handleEdit(scope.row)">编辑</el-button> </template> </el-table-column> </el-table> <el-pagination :current-page="queryParams.pageNum" :page-size="queryParams.pageSize" :total="total" @current-change="handlePageChange" /> </div> </template>把搜索、列表、分页绑定的逻辑写一次之后,你会发现后续的每个功能模块页面都只是字段不同,结构完全可以复用。这也是为什么框架类的项目代码看起来总是“千篇一律”,但这种千篇一律恰恰意味着需要调试的地方更少。
6. 部署上线:从本地到生产环境
代码写得再好,跑不起来没有任何价值。这一部分我完整走一遍部署流程,从服务器环境搭建到前端打包、Nginx配置、后端Jar包部署。
6.1 服务器环境准备
部署用的是Linux服务器(CentOS 7或Ubuntu都可以),需要安装以下环境:
- JDK 1.8:用
yum install java-1.8.0-openjdk或者上传tar包手动解压配置环境变量。 - MySQL 5.7或8.0:按官方教程配置好,注意设置字符集为utf8mb4。
- Nginx:通过
yum install nginx或编译安装。 - MinIO(可选):通过Docker一行命令启动。
MySQL安装后有一个特别常见的坑:本地连接正常,但SpringBoot启动时报SSL连接错误。这是因为MySQL 8.0默认开启SSL,而JDBC驱动在连接时默认尝试使用SSL。解决方法是连接串中显式关闭SSL:
jdbc:mysql://localhost:3306/research?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai另外一个容易遗漏的是数据库授权。建议单独创建应用账号,只授权当前数据库,不要用root直连,权限最小化原则在数据库层面同样适用。
6.2 前端打包与Nginx静态代理
前端构建需要本地Node环境,执行以下命令:
npm install npm run build打包完成后dist目录就是发布物。把这个目录上传到服务器,修改Nginx配置,将/路径指向该目录对应的静态文件位置。顺带处理一个常见场景:如果前端使用了BrowserRouter模式(即访问/project/list这种路径),刷新页面会出现404,因为Nginx找不到这个路径对应的物理文件。解决方案是在Nginx配置中加入:
location / { root /usr/share/nginx/html/research; index index.html; try_files $uri $uri/ /index.html; }try_files会先找物理文件,找不到就回退到index.html,这样前端路由就能接管了。
6.3 后端Jar包部署与脚本
SpringBoot项目使用Maven打包:
mvn clean package -DskipTests打出来的research-system.jar就是最终产物。上传到服务器后,用nohup命令后台启动:
nohup java -jar research-system.jar --spring.profiles.active=prod > logs/app.log 2>&1 &生产环境的数据库连接、Redis地址等配置放在application-prod.yml中,通过--spring.profiles.active=prod激活。日志输出到一个固定文件,方便排查问题。
还有一个小问题是JVM内存。如果服务器本身内存只有2G,建议限制JVM堆内存,否则容易导致被系统杀掉进程:
nohup java -Xms512m -Xmx512m -jar research-system.jar > logs/app.log 2>&1 &Nginx配置了反向代理后,前端请求路径/api会转发到后端端口。前后端的联通性通过curl http://localhost:8080/api/auth/login来验证,如果返回JSON而不是404,说明后端服务没问题。
7. 常见问题与排查技巧实录
这部分内容全是从实际部署和使用中攒出来的经验。整理成表格方便对照排查。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 前端页面能打开,列表无数据 | 后端跨域未配置或接口报错 | F12看网络请求,排查CORS错误和后端日志 |
登录接口报USER_NOT_FOUND | 数据库中无该用户或用户名参数传递错误 | 用navicat或命令行查询库中用户表确认 |
| 页面刷新后404 | 前端路由未配置try_files | 修改Nginx配置,加上try_files $uri $uri/ /index.html |
| SpringBoot启动报端口被占用 | 默认8080端口被其他进程占用 | 用`netstat -tunlp |
MyBatis报Invalid bound statement | Mapper接口与XML文件未匹配 | 检查XML的namespace、接口方法名和XML中id是否一致 |
| 时间字段显示相差8小时 | 数据库时区与JVM时区不一致 | 在Jdbc连接串上加serverTimezone=Asia/Shanghai |
| 文件上传成功但访问404 | MinIO未配置公网访问策略 | 用presignedGetObject生成临时访问URL |
上面这张表只是最常见的问题,真实项目里遇到的问题远比这个复杂。比如查一个项目列表,接口返回401,原因可能是Token过期但Axios没有走统一跳转处理;又比如上传文件后预览显示乱码,原因是Content-Type没设置对。
再单独说一个排查思路层面的建议。前后端分离项目的问题排查,一定要学会看三个地方:浏览器开发者工具里的Network面板、后端日志、前端Console报错。按照“请求发出去没有-后端有没有收到-后端处理有没有报错-响应数据对不对-前端渲染有没有报错”的顺序排查,90%的问题都能定位。
我在实际开发中最深的一个体会是:这类项目最难的不是某个框架的API用不熟,而是多个组件串联起来之后的状态一致性。比如用户登录后拿到了Token,但刷新页面后Token还在、菜单信息却丢了,这个时候需要同时检查前端的store持久化策略、路由动态注册逻辑、后端的Token有效期设计。三个环节只要有一个没对齐,表现出来就是一个莫名奇妙的Bug。
所以如果你正在做类似的项目,我的建议是:先把一条完整链路跑通,哪怕只有一个登录接口、一个列表接口,让前端通过代理访问到后端、后端查到数据库、数据返回页面渲染出来,再逐步添加模块。这也是我拿到任何脚手架时首先做的事情。整套系统打包源码项目里都有,按照文档里的部署顺序走一遍,本地起一个完整的科研管理系统不是问题。
最后分享一个小技巧:SpringBoot项目启动时,如果怀疑是某个Bean初始化失败,但又不知道是哪个类出问题,可以在启动参数里加上--debug,SpringBoot会把自动配置的匹配和不匹配日志全打出来。这个参数看起来不起眼,但在排查环境差异导致的启动失败时命中率极高。