ARTICLE DETAIL

资讯详情

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

Spring Boot前后端分离项目实战:跨域、JWT与文件上传踩坑记

Spring Boot前后端分离项目实战:跨域、JWT与文件上传踩坑记 后端学习笔记这个系列写到第四天说实话前两天我还在老老实实照着教程敲代码今天才隐约摸到一点“做项目”的门道。先交代一下背景我目前的计划是用 Java Spring Boot 走一条后端开发学习路线前端配一个 Vue 3 项目做前后端分离联调目标是把一个能演示的小项目从 0 搭起来。day1 配环境day2 写 Controller 和 Serviceday3 接数据库到了 day4我开始踩真正的坑了——跨域、Token、文件上传这三个词在教程里看起来都只是配置几行代码的事真到自己上手做前后端分离项目实战才知道里面全是细节。这篇笔记适合刚开始学后端、或者想从接口层面理解前后端协作的开发者看完你应该能少折腾好几个晚上。1. 第四天到底学什么从“跑通接口”到“能交付项目”1.1 前三天做了什么先把进度捋一下day1 到 day3 基本是“单点技术验证”day1 用 Spring Initializr 搭了一个 Spring Boot 3.x 项目配好 Maven 依赖跑通一个 Hello World 接口。day2 学三层架构搞明白 Controller 负责收请求、Service 负责写业务逻辑、Mapper 负责操作数据库然后对着例子写了几个简单的增删改查接口。day3 接上 MySQL选了 MyBatis-Plus 当 ORM 框架把一张单表的各种条件查询玩了一遍。前三天的问题是每个知识点都是独立验证的今天写 CRUD明天写分页后天连数据库但一旦要做一个真实的功能这些知识要怎么串起来心里其实没底。day4 我给自己定的目标很明确不再“为了学而学”而是用一个小项目把已经学到的零碎东西全部串一遍在这个过程里把那些教程里一笔带过的“坑”填掉。1.2 day4 要完成的小项目我选了一个很小的业务场景用户登录后可以发布文章文章带标题、正文、封面图列表页分页展示详情页能打开看正文。这个功能看着简单实际上覆盖了后端开发里非常核心的几条线第一要设计两张表用户表和文章表第二要写登录接口和 Token 认证不然谁都能发布文章第三要做文章的增删改查和分页第四封面图是文件涉及上传、存储和访问第五前端 Vue 页面要和后端接口联调必然要处理跨域。一套流程走下来前后端分离开发里那几个高频问题全碰上了。我刻意没有选“用户管理”这种纯演示模块因为文章天然带文件上传、带分页、带权限控制这几个点才是面试和工作中真正躲不开的东西。而且这个需求足够小一天能做完不会让人中途放弃。1.3 先自己写再参考开源项目刚开始学后端的人很容易犯一个毛病看到一个 RuoYi若依、pig 这类开源 Java 后端管理框架觉得功能齐全、代码规范直接 Clone 下来就开始“学习”。结果框架里各种权限、多租户、代码生成、字典管理根本不知道从哪看起最后变成复制粘贴工具人。我的做法是 day4 先不碰这些框架就用自己的笨办法把功能写出来。哪怕代码不优雅、没有那么多分层只要主流程能跑通心里就对“一个请求从前端到数据库再返回前端”有了完整的概念。等主流程通了再回头去翻 RuoYi 的源代码这时候你才能看懂它为什么要做那么多封装哪些是为了应对真实项目的复杂场景哪些是单纯的架构洁癖。先写丑代码再看好代码进步速度比直接抄快得多。2. 数据库设计和后端接口别把“增删改查”做成玩具2.1 表结构怎么设计day4 我只设计了两张表但每张表都不是随便建的。用户表 user 至少要有 id、username、password、nickname、create_time、update_time、deleted。密码这个字段我单独说一下很多学习项目直接存明文密码这在实际项目里是绝对不行的。我 day4 用了 BCrypt 哈希存密码登录的时候用 BCrypt 去比对而不是把密码取出来拿字符串等于。虽然只是小项目但养成这个习惯很重要。文章表 article 字段有 id、author_id、title、content、cover_url、status、create_time、update_time、deleted。author_id 关联用户表cover_url 存封面图的访问路径status 表示文章状态比如 1 已发布、0 草稿。这里有个经验之谈凡是可能会被“删除”的数据都预留一个 deleted 字段做逻辑删除而不是真的执行 DELETE 语句这样后续恢复数据、做历史追踪都方便。MyBatis-Plus 里可以直接加 TableLogic 注解查询和删除都会自动带上条件。连接数据库的时候还有个容易忽略的设置。MySQL 的 JDBC URL 里我建议加上characterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai不加的话中文大概率乱码时间字段也可能差 8 个小时。这种问题排查起来相当费劲不如建库的时候就写对。2.2 用 MyBatis-Plus 写 DAO 层MyBatis-Plus 最大的价值是让我不用写大量重复的 XML 文件。day3 学习的时候我还纠结过是不是应该老老实实学 MyBatis 的 XML 写法会不会用 ORM 是“偷懒”后来想通了MyBatis-Plus 在中小型项目里是绝对的生产力工具它没有抛弃 SQL只是把单表 CRUD 的模板代码封装掉了。遇到复杂查询你还是可以自己写 XML 和注解 SQL并不冲突。day4 的 DAO 层我基本就只写了一个接口Mapper public interface ArticleMapper extends BaseMapperArticle { }然后凡是单表操作直接调用 BaseMapper 自带的方法比如insert、selectById、updateById。分页查询要配一个分页插件Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }Service 层查询文章列表的时候用 LambdaQueryWrapper 构造条件PageArticle page articleMapper.selectPage( new Page(current, size), new LambdaQueryWrapperArticle() .eq(Article::getStatus, 1) .orderByDesc(Article::getCreateTime) );这里有个细节current和size是前端传过来的页码和每页条数不能直接拼进 SQL因为分页插件的逻辑是帮我们做LIMIT的。别小看分页很多新手在这个地方会犯一个低级错误——自己写LIMIT offset, pageSize然后把前端传的值直接拼进 SQL这种做法一方面可能存在注入风险另一方面也没有处理边界情况。用 MyBatis-Plus 的分页插件既安全又省事。2.3 统一返回体和 RESTful 设计day2 我第一次写接口的时候每个方法都是返回MapString, Object自己想塞什么塞什么前端对接的时候毫无章法。这次我学聪明了先定义了一个统一的返回体Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }这个Result类看起来不起眼但它是整个项目接口风格的地基。前端只要拿到一个固定格式的 JSON就能统一处理成功和失败的情况不用每接一个接口就去猜返回结构。接口路径怎么设计我 day4 用了最简单的 RESTful 风格参考约定如下操作请求方式路径登录POST/api/auth/login文章列表GET/api/articles?page1size10文章详情GET/api/articles/{id}发布文章POST/api/articles修改文章PUT/api/articles/{id}删除文章DELETE/api/articles/{id}上传封面图POST/api/upload/imageRESTful 最大的好处是语义清晰看到路径和请求方法基本知道这个接口是干嘛的。面试的时候如果能说出“我为什么用 POST 表示新增、用 PUT 表示全量更新、用 PATCH 表示部分更新”这是很加分的。day4 我还没引入 PATCH因为小项目用不到但至少 GET、POST、PUT、DELETE 这套要分清楚。这里我要提醒一个反面典型很多人写接口时不管什么操作全都POST /api/getArticleList、POST /api/deleteArticle也没有统一返回格式前端拿到数据还要各种判断。小项目可能无所谓但到了团队协作和联调阶段这种接口风格会让整个项目变得特别难维护。3. 前后端分离联调的两个拦路虎跨域和 Token3.1 跨域到底是怎么回事day4 我遇到了这个系列笔记开篇提到的第一个大坑前端页面访问后端接口浏览器直接报 CORS error。跨域的根源是浏览器的同源策略。所谓“同源”指的是协议、域名、端口三者完全一致。我用 Vue 起了个开发服务器地址是http://localhost:5173后端 Spring Boot 跑在http://localhost:8080端口不一样浏览器就认为这是跨域请求。打个比方同源策略就像小区门卫只允许本小区的业主进出外来的访客必须先登记、出示通行证。localhost:5173 的页面想调用 localhost:8080 的接口相当于访客要进另一栋楼门卫不认识直接拦下来。这里有个非常重要的认知跨域是浏览器的行为不是服务器的行为。你用 Postman 或 Apifox 直接请求后端接口根本不会出现跨域问题因为 Postman 不在浏览器里跑 JavaScript不做同源检查。所以当你发现“Postman 通、浏览器不通”的时候十有八九就是跨域问题。这个排查经验在联调中极其有用。3.2 Spring Boot 侧配置 CORS跨域的解决方案有好几种day4 我选择在后端直接配置 CORS因为最直接改动也小。Spring Boot 里写一个配置类即可Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }这段配置里有个大坑我必须单独讲如果你把allowedOrigins设置成*同时又开了allowCredentials(true)浏览器会直接拒绝响应。因为这是 CORS 规范的硬性限制——不能一边允许任意来源一边允许携带凭据。我一开始就写了allowedOrigins(*)结果怎么调试都报错最后换成allowedOriginPatterns(*)才通过。另外浏览器发复杂请求比如带 JSON 的 POST 请求之前会先发一个OPTIONS预检请求去试探服务器允不允许跨域。这个OPTIONS请求是不带业务数据的所以如果需要配拦截器一定要把OPTIONS请求放行否则前端会看到一个莫名其妙的跨域报错但实际问题是拦截器把预检请求给拦截掉了。3.3 Token 从生成到校验的完整流程day4 之前我一直在用 Session 做登录状态也就是用户登录成功后后端把用户信息存到 Session返回一个 JSESSIONID 给浏览器存 Cookie之后每次请求浏览器自动带着 Cookie后端就能认出是谁。但前后端分离项目里这套方案不太好用。首先前端和后端不在同一个域名下跨域请求里处理 Cookie 很麻烦其次做集群部署的时候用户第一次请求被分到服务器 A第二次被分到服务器 B如果 B 没有这个 Session用户就被当成未登录了。所以现在主流的做法是 Token我用的是 JWTJSON Web Token。JWT 本质上是一串包含了用户信息的字符串分三段Header、Payload、Signature。Header 说明加密算法Payload 放用户 id、过期时间这些信息Signature 用服务端密钥对前两段签名防止内容被篡改。登录接口的处理流程是这样的前端把 username 和 password 发给后端 → 后端去数据库查用户 → 用 BCrypt 校验密码 → 通过后生成 JWT 字符串 → 返回给前端。前端拿到 Token 之后存起来每次发请求都在请求头里带上。后端有一个拦截器专门检查每个请求的 Authorization 头Token 合法就放行并解析出用户身份不合法就返回 401。我 day4 写了一个最简单的 JwtUtil没引入 Spring Security核心代码如下public class JwtUtil { private static final SecretKey KEY Keys.hmacShaKeyFor(your-secret-key-please-change-me.getBytes()); public static String generate(Long userId, String username) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .setExpiration(new Date(System.currentTimeMillis() 24 * 60 * 60 * 1000)) .signWith(KEY) .compact(); } public static Claims parse(String token) { return Jwts.parserBuilder().setSigningKey(KEY).build() .parseClaimsJws(token).getBody(); } }拦截器长这样Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); try { Claims claims JwtUtil.parse(token); request.setAttribute(userId, Long.valueOf(claims.getSubject())); return true; } catch (Exception e) { response.setStatus(401); return false; } } }注册拦截器时要特别注意白名单Configuration public class WebConfig implements WebMvcConfigurer { Resource private JwtInterceptor jwtInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login); } }如果你把/api/auth/login漏了登录接口自己都会被拦前端一登录就 401排查半天发现是“自己拦自己”这个坑我踩过印象非常深刻。3.4 前端 axios 怎么带 Token后端接口写好了前端也得配合。我用 axios 做请求库最方便的做法是设置请求拦截器让每个请求自动带上 Tokenaxios.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization token } return config }) axios.interceptors.response.use( response response, error { if (error.response error.response.status 401) { localStorage.removeItem(token) window.location.href /login } return Promise.reject(error) } )这里有一个前后端必须对齐的约定后端拦截器读的是Authorization请求头前端就要在这个请求头里放 Token有的项目习惯放Bearer前缀比如Bearer xxxxxx那后端解析的时候也得把前缀去掉。我 day4 因为前后端都是自己写的就直接裸 Token省了这一步但如果是接手别人的项目一定要先确认这个约定。还有一个细节是关于 Token 存哪的。很多人习惯存localStorage简单方便但 XSS 脚本可以读到它安全性一般。更安全的方案是存内存变量配合刷新 Token但小项目用 localStorage 足够。我建议在后端把 Token 过期时间设成一天前端每次登录的时候重新获取这样即使 Token 泄露影响窗口也小很多。4. 文件上传与下载一个真实的需求实例4.1 需求场景与拆解day4 的文章发布功能里有一个封面图上传的需求完整的流程是前端用户选择一张图片 → 请求后端上传接口 → 后端接收文件、校验格式和大小、重新命名、保存到服务器本地目录 → 后端返回一个图片访问 URL → 前端拿到 URL把它作为cover_url和其他表单字段一起提交到文章发布接口。这其实就是很多人会碰到的“前端上传一个文件后端接收后处理处理完返回给前端”的场景。很多人觉得这个功能简单但实际做起来要考虑的事情比想象多得多文件存哪、文件名怎么处理、能不能被外部直接访问、大小限制设多少、图片扩展名要不要校验这些问题在教程里很少讲全。4.2 后端接收、处理、返回后端接收文件用 Spring MVC 的MultipartFile接口代码大致如下PostMapping(/api/upload/image) public ResultString uploadImage(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return Result.error(400, 文件不能为空); } long maxSize 5 * 1024 * 1024L; if (file.getSize() maxSize) { return Result.error(400, 文件不能超过5MB); } String originalFilename file.getOriginalFilename(); String ext ; if (originalFilename ! null originalFilename.contains(.)) { ext originalFilename.substring(originalFilename.lastIndexOf(.)); } String filename UUID.randomUUID() ext; File dest new File(uploadDir, filename); try { file.transferTo(dest); } catch (IOException e) { return Result.error(500, 文件保存失败); } return Result.success(/files/ filename); }这里有几个细节我很想展开说。第一文件名用 UUID 重新生成有三个原因一是两个用户可能上传同名文件不用 UUID 会相互覆盖二是中文文件名在 URL 传输时经常出现编码问题三是用户可控的文件名直接拼到服务器路径上容易出安全问题。UUID 重命名是成本最低的解决方案。第二扩展名必须校验。我写的逻辑是取最后一个点后面的字符串然后拼接。但更稳妥的做法是做一个白名单只允许.jpg、.jpeg、.png、.webp这几种遇到不认识的扩展名直接拒绝。只靠后缀判断其实还能被绕过更严谨的方案是读文件头判断真实格式但 day4 能做到后缀加大小校验已经比很多项目强了。第三光把文件存到磁盘还不够要让浏览器能访问到必须配置静态资源映射Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/files/**) .addResourceLocations(file: uploadDir); }uploadDir是本地保存文件的实际目录路径比如D:/dev/upload/。这里加一个绝对路径的最后斜杠很容易漏漏了会导致拼接出来的路径不对资源照样访问不到。还有 Spring Boot 的文件大小默认限制是 1MB我提交的时候发现图片稍微大一点就报错后来在配置里调大了spring.servlet.multipart.max-file-size10MB spring.servlet.multipart.max-request-size10MB这两行配置分别在“单个文件”和“整个请求体”两个维度做限制。如果不设置 max-request-size就算单个文件没超限如果前端一个请求里上传了多个文件或者带了大量表单数据后端还是会拒绝。4.3 联调中的小坑文件上传和前后端联调放在一起还有两个坑。第一个是关于开发环境的代理方式。我 day4 一开始是后端配了 CORS 放行所有来源前端直接请求http://localhost:8080的接口倒也能跑通。但后来发现这种模式下前端代码里到处都是后端的完整地址不太好维护而且和生产环境的部署方式不太一样。所以我后来切回了 Vite 的 proxy 方案// vite.config.js server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端代码里只写/api/xxx开发服务器自动把/api开头的请求转发到后端。这个思路和生产环境用 Nginx 做反向代理很接近只是开发阶段用 node 起了个轻量代理。用了代理之后前端到开发服务器是同源的后端可以不再配跨域代码还更干净。第二个坑是上传和跨域混在一起的时候特别容易误判。上传请求通常是一个带文件的 POST 请求会触发浏览器的预检请求。如果 CORS 配置有问题前端看到的报错是跨域错误很容易让人一直去调 CORS 配置但实际上文件本身可能根本没传到后端。我的排查习惯是先看后端日志确认请求到底有没有进来如果进来了再往后查业务逻辑如果根本没进来才是跨域或者网络层面的问题。5. 踩坑记录与常见问题排查5.1 遇到的问题速查表day4 结束之后我把自己实际踩过的坑整理了一遍以后接手项目或者面试前温习都很有用现象原因解决方案后端配了 CORS浏览器还是报跨域allowCredentials(true)和allowedOrigins(*)冲突改用allowedOriginPatterns(*)登录接口返回 401拦截器白名单没配对登录接口被拦截器拦了excludePathPatterns加上/api/auth/login上传文件后访问图片 404没配置静态资源映射或目录路径最后的斜杠漏了实现addResourceHandlers并核对绝对路径文章列表返回的时间字段是个数组Jackson 默认序列化LocalDateTime成数组配置 Jackson 日期格式或加JsonFormat这四个问题都不是那种看一眼就能解决的每一个都可能让人折腾半小时以上。最让我印象深刻的还是第一个因为 CORS 配置表面上看完全没毛病allowedOrigins写*、allowCredentials写true都是按教程写的但浏览器就是不认。后来才知道这两者互斥是浏览器层面的规范限制不是你代码写得不对是规范不允许。5.2 排查问题的统一思路学到这里我总结出一个非常重要的方法论前后端联调出了问题第一步永远是“先定位问题在前端还是后端”。具体做法是先把后端接口拿到 Apifox 或 Postman 里跑一遍。如果工具里请求正常、返回数据也正常那问题大概率出在前端或浏览器环境如果工具里已经报错了那直接去后端查日志。不要一上来就一边改前端一边看后端两头瞎忙效率极低。如果后端在工具里是通的浏览器里却不行那就看浏览器的 Network 面板重点看请求的状态码401Token 没带、Token 过期、或者被拦截器拦了。403权限不足虽然登录了但这个操作不允许。404路径写错了或者静态资源映射没配好。405请求方法不对比如前端发的是 POST后端只写了 GET。500后端抛异常需要看后端控制台或者日志文件。CORS error优先确认响应头里有没有Access-Control-Allow-Origin。这个速查思路基本覆盖了新手阶段 90% 的联调问题。日志一定要养成看的习惯后端报错时异常栈信息是最好用的线索比网上瞎搜要快得多。5.3 day4 之后怎么继续day4 结束这个节点我已经把一个小项目的主链路跑通了。后面要补的东西还很多但方向已经很清晰把认证换成 Spring Security 加 JWT这是 RuoYi 这类成熟框架的标配引入 Redis 做 Token 的刷新和黑名单把文件存储从本地换成 OSS 对象存储因为服务器重启或者多机部署的时候存本地的文件没法共享再往后面就是部署Linux 环境装 Nginx 反向代理Java 项目打成 jar 包启动。day4 涉及的知识点其实已经覆盖了 Java 后端初级面试里非常高频的一批问题跨域是什么、怎么解决、JWT 原理、为什么不用 Session、文件上传怎么处理、分页怎么写。如果能把今天踩过的坑原原本本地讲清楚比死记硬背八股文要扎实得多。我个人还有个体会想多说一句前后端分离项目最有意思的一刻就是前端页面上出现“登录成功”然后带着 Token 请求到文章列表数据的那一瞬间。那一刻你突然发现Vue 页面里的一切动态数据原来是这么一环扣一环地从浏览器走到服务器、走到数据库、再原路返回的。day4 最大的收获不是哪段代码而是建立了对“一条完整请求链路”的整体认知。这次踩过的跨域和 404 的坑换来的都是后面做项目时再也不会犯的错。
返回列表