花了大概两周多的时间,我把这个基于Spring Boot + Vue的美食网站从零到一完整撸了一遍。做这个项目之前,我正好在纠结怎么把前端Vue和后端Spring Boot这两块技术真正串起来——网上demo很多,但大多数要么只讲后端接口,要么只聊前端页面,能一路从数据库设计做到部署上线的完整案例反而不多。所以我自己动手做了一整套,从表结构、REST接口、鉴权登录,到Vue路由、状态管理、跨域联调,再到最后的打包部署全走了一遍。这篇就当作一份完整的项目笔记,把那些“踩过才知道”的细节原原本本盘出来,适合正在做毕设、准备面试项目、或者想系统练一遍前后端分离开发的读者参考。
1. 为什么选Spring Boot + Vue这套组合做美食网站
1.1 先拆一下美食网站的核心需求
很多同学拿到“美食网站”这种题目第一反应就是“随便做个列表页加个详情页就行了”,等真正动手才发现需求没那么简单。我给自己定了一个能跑通全流程的功能边界,而不是一味往大做:
- 前台用户端:注册登录、菜品分类浏览、关键词搜索、菜品详情、加购物车、下单、订单查看、收藏菜品。
- 后台管理端:菜品CRUD、分类管理、订单状态处理、用户管理、轮播图配置。
这个范围是可扩展的,后面想做评论、评分、推荐算法都有现成的位置可以加。关键在于它覆盖了Web开发最常见的痛点场景:登录鉴权、文件上传(菜品图片)、一对多关联查询(分类-菜品)、多表联查(订单-订单项)、状态流转(订单待支付/已支付/已完成),这些都是面试和实际工作中最常被问的东西。
1.2 前后端分离到底解决了什么问题
如果只是做一个课程作业,用Spring Boot自带的Thymeleaf模板引擎直接在服务端渲染页面会更快,但“基于Spring Boot + Vue”的题目天然要求前后端分离。我一开始也觉得这在毕业设计里有点“为了分离而分离”,真正做完之后反而觉得这个选择是对的。
前后端分离最核心的价值在于:前端只需要关心页面和交互,通过Ajax请求后端接口拿数据;后端只需要关心业务逻辑和接口返回值,不用再纠结页面渲染这种事。这带来两个直接的好处:一个是前端页面可以用Vue组件化组织,菜品卡片、购物车、轮播图这些都能复用;另一个是后端接口可以独立测试,用Postman把接口全部打通了再连前端,排查问题的时候能快速定位到底是前端没调对还是后端返回有问题。
Vue和Spring Boot各自还有一层生态优势。Vue这边有Vue Router管页面跳转、Vuex或Pinia管全局状态(比如用户登录态、购物车数量);Spring Boot这边有Spring Security或JWT做鉴权、MyBatis管数据库操作、Spring Validation做参数校验。两边都是主流方案,遇到问题网上随便一搜都有成熟的解决方案,不像冷门技术栈卡住了半天找不到资料。
1.3 技术栈版本选择与理由
这是我在实际开发前花了不少时间确定的具体版本组合:
| 技术 | 选型 | 理由 |
|---|---|---|
| JDK | JDK 8 或 11 | 稳定,Spring Boot 2.x完全支持,面试用得最多 |
| Spring Boot | 2.7.x | 稳定版,资料最多,避开3.x的兼容坑 |
| MyBatis | 2.x starter | 自己写SQL灵活,关联查询直观,比JPA更可控 |
| 数据库 | MySQL 8.x | 免费、生态好、遗留系统连接兼容性好 |
| 前端框架 | Vue 2 + Element UI 或 Vue 3 + Element Plus | 看个人熟悉程度,我用的Vue 2,生态成熟稳 |
| 构建工具 | Maven | 国内网络环境下比Gradle省心 |
| 鉴权 | JWT + 拦截器 | 无状态、简单、不引入Spring Security的复杂度 |
补充一句,Spring Boot 3.x现在已经很成熟了,但如果你是为了毕业设计或者面试项目去做,我建议选2.7.x。原因很简单:网上90%的资料和博客都是基于2.x写的,遇到问题按图索骥更快;3.x的jakarta命名空间改动和Spring Security 6的变化,会白白消耗你本可以用在业务逻辑上的时间。
2. 数据库设计:美食网站最核心的家底
2.1 表结构设计核心思路
美食网站的数据模型不像电商那么复杂,但该有的关系都得覆盖。我最终设计了七张核心表,一张表负责一个业务域:
- 用户表:id、username、password、nickname、avatar、phone、create_time
- 分类表:id、name、sort_order、status
- 菜品表:id、category_id、name、description、price、image、stock、status、create_time
- 购物车表:id、user_id、dish_id、quantity
- 订单表:id、order_no、user_id、total_amount、status、receiver_name、receiver_phone、address、create_time
- 订单项表:id、order_id、dish_id、dish_name、price、quantity
- 收藏表:id、user_id、dish_id、create_time
这个设计里有一个很容易被忽略的点:订单项表为什么要冗余一份dish_name和price字段?因为菜品价格和名称是会被后台修改的,如果订单项去关联菜品表实时查价格,用户下单之后管理员改个价格,订单历史就跟着变了,这在业务上是不可接受的。订单一旦生成,快照就必须固定下来。这也是一个很小但面试官很爱问的细节。
分类和菜品是一对多关系,在菜品表里放category_id外键;用户和订单是一对多关系,订单表里放user_id外键;订单和订单项是一对多关系,订单项表里放order_id外键。购物车和收藏都是用户和菜品多对多的“中间表”简化版。
2.2 数据库连接配置与MyBatis的坑
Spring Boot连MySQL的配置很简单,但有几个参数值得多写几笔:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/food_website?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 servlet: multipart: max-file-size: 10MB max-request-size: 10MBserverTimezone=Asia/Shanghai这个参数是我一开始没加,结果时间字段出来全是“北京时间早上八点比数据库差八小时”的诡异情况。useUnicode=true&characterEncoding=utf8则是保证中文不乱码的标配,不加的话菜品描述里的中文极易变成问号。
MyBatis这边我直接用的注解加XML混合方式:简单的单表操作用注解(@Select、@Insert),涉及关联查询和动态SQL就写在XML Mapper里。比如按分类查询菜品还需要连分类表拿分类名,这种两表联查直接写XML更干净:
<select id="selectDishWithCategory" resultType="com.example.food.vo.DishVO"> SELECT d.id, d.name, d.price, d.image, d.description, c.name AS categoryName FROM dish d LEFT JOIN category c ON d.category_id = c.id <where> <if test="categoryId != null"> AND d.category_id = #{categoryId} </if> <if test="keyword != null"> AND d.name LIKE CONCAT('%', #{keyword}, '%') </if> </where> ORDER BY d.create_time DESC </select>动态SQL这块是MyBatis最值钱的地方,搜索关键词和分类过滤可以用同一个查询方法,不用拆成两个接口。注意这里有第一个容易踩的坑:MySQL的LIKE拼接不要直接写成'%#{keyword}%',MyBatis不会这样替换参数,必须用CONCAT('%', #{keyword}, '%')。这个坑我翻了半天源码才反应过来。
2.3 统一响应体:让前端少写一万个判断
后端接口返回给前端的数据格式如果不统一,前端每个请求都要猜“这次到底是对象还是数组还是错误信息”,联调起来非常痛苦。我一开始就定义了一个统一的返回体:
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); 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; } }前端axios封装里只需要判断res.data.code === 200即可,所有异常统一在catch里处理。这个设计看起来简单,但能让前后端联调效率提升好几倍,尤其是你的前端是独立写的时候,统一格式能避免“这个接口怎么一会儿返回数组一会儿返回对象”的困惑。
3. 后端接口落地:鉴权、菜品模块与文件上传
3.1 注册登录模块:选JWT而没有用Session的原因
登录是每个项目的门面,也是最容易做“假”的部分。我最终选择了JWT(JSON Web Token)方案。核心原因是前后端分离后,后端接口是无状态的,如果还用Session,每次请求都要带着JSESSIONID去服务端查内存里的会话状态,后端一重启所有登录态就没了,扩展的时候还得做Session共享,麻烦。JWT把用户信息加密签在Token里,服务端不用存任何状态,只要校验签名就能确认用户身份,天然适配前后端分离。
实现逻辑大概是这样的:登录成功之后用jjwt库生成Token,把用户ID和用户名放进去,设置两小时的过期时间,返回给前端保存到localStorage里。前端每次请求在axios拦截器里加上Authorization: Bearer ${token}。后端写一个拦截器,拦截除登录注册外的所有接口,校验Token的有效性,从Token里解析出用户ID放到ThreadLocal或者直接通过HandlerArgumentResolver注入Controller参数。
@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"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); try { Claims claims = JwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); return true; } catch (Exception e) { response.setStatus(401); return false; } } response.setStatus(401); return false; } }这里有个特别容易踩的坑:跨域请求会先发一个OPTIONS预检请求,这个请求不带业务Token,如果你在拦截器里直接拦截,前端的每个请求都会被401打回去。所以必须像上面代码一样,先放行OPTIONS方法。这个问题的排查一度让我怀疑人生,最后发现是拦截器把预检请求一起拦了,网上能搜到的帖子很多但说得都不是很清楚。
3.2 菜品模块的分页、搜索与图片上传
菜品列表是前台访问最频繁的接口,必须做分页和控制返回字段。我用PageHelper做分页插件,Controller层接收pageNum和pageSize参数,Service层返回PageInfo统一封装:
@GetMapping("/dish/list") public Result<PageInfo<DishVO>> list(@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "8") Integer pageSize, @RequestParam(required = false) Long categoryId, @RequestParam(required = false) String keyword) { PageHelper.startPage(pageNum, pageSize); List<DishVO> list = dishService.queryDishList(categoryId, keyword); PageInfo<DishVO> pageInfo = new PageInfo<>(list); return Result.success(pageInfo); }图片上传是菜品管理里的重头戏,也是很多初学者做项目时最容易“看起来能跑但实际各种毛病”的地方。我是这样处理的:前端用Element UI的el-upload组件选择图片,把图片文件通过FormData发送到后端/upload接口,后端用MultipartFile接收,保存到服务器本地磁盘,返回图片的访问URL。重点来了,这个URL必须能被前端直接访问,所以要么你在Spring Boot里配置静态资源映射把本地磁盘目录映射成虚拟路径,要么把图片存到Nginx代理的静态目录。我采用的是在配置类里加资源映射的办法:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 磁盘路径 /home/ubuntu/food/images/ 对应访问路径 /images/** registry.addResourceHandler("/images/**") .addResourceLocations("file:" + "/home/ubuntu/food/images/"); } }这样前端拿到/images/xxx.jpg就能直接渲染。如果你不配置映射,把图片存在项目内部的static目录,可能当时能显示,但打包成Jar之后写不进文件系统,等到部署到服务器上就会出各种“图片上传成功但访问404”的诡异问题。
3.3 购物车与订单:事务和状态流转怎么处理
购物车本身逻辑不复杂,就是用户ID加菜品ID的增删改查加数量累加。但订单的生成涉及多张表的写操作:创建订单、批量插入订单项、清空购物车、扣减库存,这四步必须在一个事务里完成。我用@Transactional注解保证了原子性——任何一步失败,前面写入的数据全部回滚,不然用户下单成功但购物车没清或者库存负数,在业务上是灾难级的bug。
库存扣减这块我推荐一个做法:更新菜品表库存时加上stock > 0条件,用数据库层面的行锁防止超卖。虽然美食网站并发量不会很大,但这个写法是生产级的标准思路,写进项目文档里面试能加分:
@Update("UPDATE dish SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity}") int deductStock(@Param("id") Long id, @Param("quantity") Integer quantity); // 返回0说明库存不足,抛出异常回滚事务 if (deductStock(dishId, quantity) == 0) { throw new RuntimeException("库存不足"); }订单状态我直接用一个Integer字段存储,0待付款、1已付款/待发货、2已完成、3已取消。后台管理修改状态时,加上状态机的校验逻辑,比如只能从待付款流转到已付款或已取消,防止前端调接口直接把状态改成完成。
4. Vue前端从骨架到交互:路由、请求封装与组件拆分
4.1 项目初始化和目录结构的约定
我用Vue CLI初始化前端项目,命令是vue create food-web。这里有一个很容易忽略的环境细节:安装依赖时建议用npm而且用国内镜像源,否则npm install卡在node-sass这类二进制依赖上会让你怀疑人生。虽然没有必要展开讲镜像源的具体配置(和网络环境相关),但至少要知道,如果安装依赖失败,优先尝试换源后删除node_modules重新安装。
目录结构我按模块划分,而不是按页面划分:
src/ ├── api/ # 按模块封装的接口请求 │ ├── dish.js │ ├── order.js │ └── user.js ├── assets/ # 静态资源 ├── components/ # 公共组件 │ ├── DishCard.vue │ ├── CartBadge.vue │ └── SearchBar.vue ├── router/ # 路由配置 ├── store/ # Vuex状态管理 ├── utils/ # axios封装、工具函数 ├── views/ # 页面组件 │ ├── Home.vue │ ├── DishList.vue │ ├── DishDetail.vue │ ├── Cart.vue │ ├── Login.vue │ └── Admin │ ├── DishManage.vue │ └── OrderManage.vue └── App.vue这么分组的好处是:新增一个接口时,不需要去几十个文件里找应该写在哪儿;按业务模块拆分的api文件,每个页面调用起来也很直观。
4.2 请求封装:拦截器里统一处理Token和错误
axios不封装直接用,项目里会到处散落着重复的Token设置和错误提示代码。我是这样封装的基础请求实例:
import axios from 'axios' import { Message } from 'element-ui' const service = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截器:自动携带Token service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) // 响应拦截器:统一处理非200状态 service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { Message.error(res.message || '请求出错') return Promise.reject(new Error(res.message)) } return res.data }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } Message.error(error.message || '网络异常') return Promise.reject(error) } )这里有个设计细节值得注意:我在请求拦截器去携Token、在响应拦截器去处理401,把登录态失效后自动跳转登录页的逻辑收敛到了一处。这样业务代码里完全不用关心登录态,只管拿数据。
4.3 路由守卫与页面权限控制
美食网站分了前台和后台两套页面,后台管理页必须有登录态且是管理员才能访问。这在前端通过路由守卫实现,而不是依赖后端拦截——后端拦截管的是接口安全,前端守卫管的是页面访问体验,两者缺一不可。
const router = new VueRouter({ routes: [ { path: '/', component: Home, meta: { title: '首页' } }, { path: '/dish/:id', component: DishDetail, meta: { title: '菜品详情' } }, { path: '/cart', component: Cart, meta: { auth: true } }, { path: '/admin', component: AdminLayout, meta: { auth: true, admin: true }, children: [...] }, { path: '/login', component: Login } ] }) router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.auth && !token) { next('/login') return } if (to.meta.admin) { const user = JSON.parse(localStorage.getItem('user') || '{}') if (user.role !== 1) { next('/') return } } next() })这个守卫逻辑支撑了“游客能看菜品,登录用户能加购物车,管理员才能进后台上传菜品”的权限模型。注意角色判断我用的本地存储里的user对象,它是和后端返回的对齐的,安全上不能只靠前端控制,后端接口同样有管理员校验。
4.4 关键组件:菜品卡片、搜索防抖和购物车徽标
菜品卡片是首页和列表页都会用到的公共组件,我拆成了DishCard.vue,接收一个dish对象,展示图片、名称、价格,提供“加入购物车”的事件。图片加载失败的处理也放在了组件内部——用@error事件替换成一张占位图,这个能避开很多线上图片404的尴尬场景。
搜索框我做了防抖处理,用户停止输入0.5秒后才发起请求。不做防抖的话,用户输入一个关键词会连续触发五六次接口请求,浪费带宽不说,还会出现前后请求返回顺序不一致、旧结果覆盖新结果的问题:
import { debounce } from 'lodash' methods: { handleSearch: debounce(function() { this.$router.push({ path: '/dish/list', query: { keyword: this.keyword } }) }, 500) }购物车徽标是用Vuex管理购物车总数量的典型场景。加入购物车、修改数量、删除、清空这四种操作都同步提交到Vuex的mutation里,购物车数量在Header组件里通过mapGetters取出来显示。刷新页面后从后端重新拉取购物车列表,保证前端状态和后端数据保持一致。
5. 联调、打包与部署:文档里不会写的那些坑
5.1 开发环境的跨域配置:用Proxy而不是零散的CORS头
前后端分离开发时,前端跑在localhost:8080,后端跑在localhost:8081(或8080),端口不同浏览器就会报跨域错误。我在Vue CLI的vue.config.js里配置了devServer代理,把前端的/api前缀代理到后端地址,这样浏览器视角看所有请求都是同源的:
const { defineConfig } = require('@vue/cli-service') module.exports = defineConfig({ devServer: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '' } } } } })这里有个特别容易搞混的细节:我的后端接口路径是没有/api前缀的,前端请求写在baseURL: '/api',代理转发时通过pathRewrite把/api去掉再转发给后端。如果你后端Controller写的是/api/dish/list,那pathRewrite就不能去掉,否则路径对不上返回404。很多联调半天不通的问题,最后查下来都是这种前缀没对齐。
5.2 打包发布:两种部署方式的选择
打包发布我先后试过两种方式。第一种是把Vue打包后的dist目录扔到Spring Boot的src/main/resources/static下,重新打成Jar包,这样Spring Boot同时提供静态页面和接口。这种方式胜在部署简单,适合毕设演示和中小型个人项目。但后台修改菜品图片时有个隐患:如果前端页面和后端接口在同一个Origin下,没有跨域问题,但图片文件如果存在服务器磁盘而非Jar包内,路径映射在Jar里需要额外配置。
第二种方式是前后端彻底分离部署:后端Spring Boot打包成Jar运行在8080端口,前端npm run build后部署到Nginx,Nginx监听80端口并代理/api到后端。这种方式更适合真实项目,分离更彻底,但多一个Nginx配置步骤:
server { listen 80; server_name localhost; # 前端静态文件 location / { root /usr/share/nginx/html/food-web/dist; index index.html; try_files $uri $uri/ /index.html; # 解决Vue路由刷新404 } # 后端接口代理 location /api { proxy_pass http://localhost:8080/api; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里面最值得记住的是try_files $uri $uri/ /index.html这一行。如果你不做这个配置,Vue Router用history模式时,直接访问/cart刷新页面就会404——因为Nginx去找服务器上对应的cart.html,根本不存在。加了try_files之后,所有未知路径都回退到index.html,由前端路由接管。
5.3 实测过程中遇到的高频问题排查清单
我把整个项目联调过程中遇到频率最高的问题整理成了一张排查清单,给正在做类似项目的人直接对照:
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 前端请求一直401 | 拦截器拦了OPTIONS预检请求 | 放行preHandle中的OPTIONS请求 |
| 中文乱码 | 数据库连接串没配characterEncoding | 加到jdbc.url参数里 |
| 上传图片后刷新404 | 没有配置磁盘路径资源映射 | 加WebMvcConfigurer映射 |
| 前端改了数据后页面不刷新 | 没有用Vuex响应式状态 | 全局状态用store管理,不要散落组件里 |
| 打包后Vue路由刷新404 | Nginx没有try_files回退 | 加try_files $uri $uri/ /index.html |
| 时间字段比数据库差8小时 | 数据库连接串没配serverTimezone | 加serverTimezone=Asia/Shanghai |
| 下载依赖失败卡住 | npm依赖下载问题 | 换镜像后删除node_modules重装 |
这张表看着简单,但每一行都是我实际花了一两个小时甚至更久才定位出来的。尤其是401那个问题,前后端都检查遍了,最后才发现是拦截器把预检请求误伤了——这个经验写下来,能帮后来者省一整晚的排查时间。
5.4 管理员后台:表格、表单校验和图片上传组件
管理员后台我主要做了菜品管理和订单管理两个页面。菜品管理用Element UI的el-table展示菜品列表,支持搜索、分页,点“编辑”弹出el-dialog框,里面是el-form表单,带图片上传。这里分享一个表单校验的实用姿势:数字类型的价格字段,我在表单里用el-input-number,校验规则里设置精度为2,确保只能保留两位小数;价格在前端展示时再用toFixed(2)格式化,避免0.1+0.2那种浮点精度问题直接暴露给用户。
图片上传组件那块,我封装了一个ImageUpload.vue,内部用el-upload配合action属性指到后端接口。上传成功之后把返回的URL回填给表单的image字段,提交时随表单一起发到后端。预检测文件类型我做了jpg/png限制,大小限制10MB,避免用户传一个几十兆的高清原图把磁盘撑爆。要注意el-upload组件默认会在onSuccess回调里拿到响应数据,但如果你在axios拦截器里统一解包了一层,那这里拿到的就应该是被解包后的数据,别取错层级。
6. 项目复盘:这套代码还能怎么继续扩展
6.1 从架构角度审视自己写的代码
两周的项目做完,回头看代码,我觉得最有价值的成长点是理解了分层架构在真实项目里是怎么约束人的。我的后端结构分了Controller、Service、Mapper三层,Controller只负责收参数、调Service、返回结果;Service只负责业务逻辑,比如下单事务、库存扣减;Mapper只负责SQL操作数据库数据。强制按层写代码初期会觉得麻烦,但后期加功能的时候真香——比如加一个“销量排行”接口,只需要在Mapper加一条SQL、Service加一个方法、Controller加一个路由,不影响任何已有功能。
有一个分层没做到位的反面例子:最开始我图省事,在Controller里直接写SQL查询相关逻辑,后来要复用同一套查询逻辑的时候就尴尬了,只能重构。所以给正在做项目的读者一个建议:从第一个接口开始就遵守Controller-Service-Mapper三层写法,不要为了省几行代码把路走窄了。
6.2 推荐的功能扩展方向
这个美食网站的基础框架搭好之后,扩展什么功能都有空地。我整理了几个方向,按性价比排序:
- 菜品推荐:基于用户收藏或下单记录做简单推荐。用MyBatis查用户看过/买过的分类,推荐同分类高分菜品,不需要引入太高深的算法,就能明显提升项目亮点。
- 评论与评分:在菜品表旁边挂一张评论表,前台菜品详情页展示评论列表,后台可以删除违规评论。这个功能对电商、O2O类项目来说是刚需。
- 接入支付模拟:订单增加支付宝/微信扫码支付的按钮,哪怕只是跳转到支付成功页,也能把订单流程补全得更完整。
- 并发优化:把商品库存热点数据放进Redis,用Redis的
DECR命令做扣减,扛高并发的同时配合数据库最终一致。这个方向作为面试谈资非常加分。 - 用Pinia替换Vuex:如果起步就用Vue 3,主流状态管理已经切换到了Pinia,代码更简洁,TypeScript支持更好。
6.3 我个人做完这个项目最实在的三条体会
第一,先画完数据库表再做代码,真的能省一半时间。我第一版是边写代码边加表,结果改了三遍表结构,代码也跟着返工。第二,接口文档无论多简陋,都要同步写。我用Apifox管理接口,前端同学(哪怕就是自己)照着文档调接口,比翻Controller代码高不知道多少倍效率。第三,不要怕踩坑,但一定要记录坑。本文里那张排除清单,正是这半个月最值钱的产出。
要说下一个版本最想做什么,我可能会给推荐模块加一个基于用户行为的简单召回,用Spring Boot加Redis把热榜做起来。这个项目做完最直接的感受是:后端管好数据和业务规则,前端管好交互和状态,两者用统一接口协议连接起来,这就是现代Web开发最核心的骨架。剩下的,就是在这个骨架上持续堆肉了。