[前后端分离][MVC模式]JavaWeb实现购物网站主体功能,这个标题我太熟悉了。很多刚学完 JavaWeb 的朋友,简历上或课设里都会放一个类似的项目。最开始我也觉得这类项目就是“几个页面配几张表,能跑起来展示就行”,但真正动手以后才明白:登录注册、商品展示、购物车、下单结算、订单列表,每一环都能挖出不少坑。网上能找到的完整项目要么太老,还在用 JSP 拼页面;要么太杂,上来就上 Spring Cloud 那一套,对刚起步的人并不友好。这篇博文我按自己实际开发的思路,把这条技术栈和核心流程完整拆开:后端用 Spring Boot 按 MVC 分层提供 JSON 接口,前端用 Vue 配合 axios 调接口,数据库用 MySQL 设计用户、商品、购物车、订单这几张核心表,把主体功能前后端串起来。适合学完 JavaWeb、准备做课程设计或找工作练手项目的同学,也适合想快速把前后端分离思路落到代码里的读者。
1. 项目整体规划与技术选型
1.1 功能边界先定清楚:哪些算“主体功能”
拿到一个项目标题,第一个动作不是建工程,而是把功能范围圈出来。这个标题里有“简单也没有什么事”,“购物网站”这四个字看着不大,真展开做可以无限延展:秒杀、优惠券、支付接入、后台管理、消息推送、推荐系统……如果全做,一个练手项目的体量会被撑爆。所以我当时给自己定的边界是这样的:
- 用户端:注册、登录、退出登录、商品分类浏览、商品搜索、商品详情、加入购物车、修改购物车数量、删除购物车商品、勾选商品并结算下单、查看我的订单列表、查看订单详情。
- 商品端:商品由后台预置,维护商品上架、下架状态、库存字段。我没做完整的管理后台,而是用初始化 SQL 导入数据,再留了商品管理接口的扩展位。
- 不需要:真实支付渠道对接、物流轨迹、评论体系、优惠券。订单里留一个“模拟支付”按钮,直接把订单状态改成已支付,够演示全流程就行。
这样圈完,整个项目只剩下一条清晰链路:用户浏览 → 加购 → 结算 → 下单 → 查看订单。后面做的所有设计,都围绕这条链路展开。
1.2 为什么选前后端分离 + MVC 这套组合
“前后端分离”和“MVC 模式”这俩标签放在一起,其实有个容易混淆的地方需要提前说明。
传统的 JavaWeb MVC 里,View 是 JSP,页面在服务器端渲染,Controller 处理完业务后把 Model 塞到 JSP 里。前后端分离之后,后端不再返回页面,只返回 JSON 数据,View 的职责被浏览器里的框架接管了。所以这时的 MVC 实际上变成:Model 对应实体类和 Service 层的数据结构,View 变成前端页面加 JSON 数据契约,Controller 负责接收请求、调用 Service、输出统一响应体。很多人不理解“分离之后 MVC 还存在吗”,答案是存在,只是 View 的“实现方”变了。
选择前后端分离,是看重三点。
第一,开发和调试解耦。后端接口写好后,我直接用 Postman 测通,再把接口文档丢给前端联调,不用等页面渲染。前端也可以本地起服务,用代理转发接口,两边并行推进,整体节奏快很多。
第二,数据结构契约清晰。前后端之间只认 JSON,接口返回格式统一,字段名统一,谁也别在里面搞小动作。前端出了问题先看返回的 JSON,后端出了问题也先看 JSON,排错路径非常短。
第三,贴近现在大多数团队的真实工作方式。很多产品线的 Web 端已经不是后端渲染了,而是前端 SPA 打天下,后端只维护 API。作为练手项目,用这套模式能提前适应企业开发习惯。
1.3 数据库设计:四张核心表,外加订单明细快照
数据库设计决定业务逻辑的复杂度。我这个项目最终统一为六张表:用户表、分类表、商品表、购物车表、订单表、订单明细表。
用户表:
CREATE TABLE `sys_user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录名', `password` varchar(100) NOT NULL COMMENT 'BCrypt 加密后的密码', `nickname` varchar(50) DEFAULT NULL, `phone` varchar(20) DEFAULT NULL, `address` varchar(255) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;商品表:
CREATE TABLE `product` ( `id` bigint NOT NULL AUTO_INCREMENT, `category_id` bigint DEFAULT NULL, `name` varchar(100) NOT NULL, `subtitle` varchar(255) DEFAULT '', `main_image` varchar(255) DEFAULT '', `price` decimal(10,2) NOT NULL, `stock` int NOT NULL DEFAULT 0, `status` tinyint NOT NULL DEFAULT 1, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;购物车表:
CREATE TABLE `cart_item` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `product_id` bigint NOT NULL, `quantity` int NOT NULL DEFAULT 1, `checked` tinyint NOT NULL DEFAULT 1 COMMENT '是否勾选,1勾选 0不勾选', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_product` (`user_id`,`product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;订单表:
CREATE TABLE `orders` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(40) NOT NULL COMMENT '业务订单号', `user_id` bigint NOT NULL, `payment` decimal(10,2) NOT NULL COMMENT '实付金额', `status` tinyint NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已发货 3已完成 4已关闭', `shipping_address` varchar(255) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;订单明细表:
CREATE TABLE `order_item` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_id` bigint NOT NULL, `product_id` bigint NOT NULL, `product_name` varchar(100) NOT NULL, `product_image` varchar(255) DEFAULT '', `current_price` decimal(10,2) NOT NULL, `quantity` int NOT NULL, `total_price` decimal(10,2) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里要重点说一个设计细节:为什么订单明细里要把商品名称、图片、价格原样复制一份,而不是下单时通过 product_id 去关联查询?
因为订单是“快照”型数据。下单那一刻的商品价格和商品名称,应该被永久记录在订单里。如果只存 product_id,等商品下架了、价格改了,甚至商品被删了,用户的订单详情就会变成一堆查不到的数据。这一点做电商系统时几乎是标配,哪怕是一个练手项目,也建议养成这种“对变化的数据做冗余快照”的习惯。
2. 后端 MVC 分层的核心接口与代码实现
2.1 工程结构:一个一眼能看懂的 MVC 分包
后端工程我按 Spring Boot 常规方式建,包名结构如下:
com.example.mall ├── controller // 接口层,接收参数,返回 ApiResult ├── service // 业务层,事务、核心逻辑 ├── mapper // 数据持久层,MyBatis-Plus 的 Mapper ├── entity // 数据库实体映射 ├── dto // 前端传入参数封装 ├── vo // 返回给前端的数据结构 ├── config // CORS、拦截器、MyBatis-Plus 配置 ├── common // 统一响应体、异常类、工具类 └── interceptor // 登录拦截器Controller 层只做三件事:接收参数、调用 Service、把 Service 结果包装成 ApiResult。Service 层处理业务规则和事务。Mapper 层负责 SQL。这是 MVC 里 Model 和 Controller 的最直观体现。
统一响应体的设计值得说两句:
public class ApiResult<T> { private Integer code; private String message; private T data; public static <T> ApiResult<T> ok(T data) { ApiResult<T> result = new ApiResult<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> ApiResult<T> error(Integer code, String message) { ApiResult<T> result = new ApiResult<>(); result.setCode(code); result.setMessage(message); return result; } }不管成功失败,返回结构永远都是{code, message, data}。前端判断 code 是不是 200,再决定是否取 data。这样统一的好处是前端拦截器可以写得很薄,错误处理只用写一遍。
2.2 注册登录接口和 JWT 鉴权
登录这块踩过的最大的坑是用 Session。前后端分离之后,如果还在后端用 HttpSession 存用户,前端要么依赖 Cookie 跨域,要么每次请求手动带 JSESSIONID,联调起来非常闹心。更稳的方案是 JWT:登录成功后后端签发 Token,前端存在 localStorage 里,之后每次请求在 Header 里带上Authorization: Bearer {token}。
注册接口里,密码一定不能明文存。我见过不少课设项目直接把密码存数据库,这个习惯很危险。我用 Spring Security 里抽出来的 BCryptPasswordEncoder:
@PostMapping("/register") public ApiResult<Void> register(@RequestBody @Valid RegisterDTO dto) { if (!dto.getPassword().equals(dto.getConfirmPassword())) { throw new BizException("两次输入的密码不一致"); } userService.register(dto); return ApiResult.ok(null); }Service 里这样处理:
public void register(RegisterDTO dto) { Long count = userMapper.selectCount( new LambdaQueryWrapper<User>().eq(User::getUsername, dto.getUsername())); if (count > 0) { throw new BizException("用户名已存在"); } User user = new User(); user.setUsername(dto.getUsername()); user.setPassword(passwordEncoder.encode(dto.getPassword())); user.setNickname(dto.getNickname()); user.setPhone(dto.getPhone()); user.setAddress(dto.getAddress()); userMapper.insert(user); }登录成功后生成 JWT,同时在 Controller 返回 token:
@PostMapping("/login") public ApiResult<LoginVO> login(@RequestBody @Valid LoginDTO dto) { User user = userService.login(dto.getUsername(), dto.getPassword()); String token = JwtUtil.createToken(user.getId(), user.getUsername()); LoginVO vo = new LoginVO(); vo.setToken(token); vo.setUsername(user.getUsername()); vo.setNickname(user.getNickname()); return ApiResult.ok(vo); }JWT 生成和解析我用的是 jjwt,工具类做得很薄。业务里真正麻烦的是“如何让接口知道当前用户是谁”。这里我用一个拦截器统一解析 Token,再把 userId 放到 ThreadLocal 里:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } String token = request.getHeader("Authorization"); if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { Claims claims = JwtUtil.parse(token.substring(7)); UserContext.setUserId(Long.valueOf(claims.get("userId").toString())); return true; } response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}"); return false; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }UserContext 本质上就是 ThreadLocal 包了一层静态方法。这里有一个细节必须提醒:请求处理完一定要 clear,否则线程池复用会把这个线程的 userId 带到下一个请求里,造成用户数据串号,这个 bug 在没压测的时候很难发现。
2.3 商品分页查询与搜索
商品列表不用登录,接口直接放给游客。分页是商品列表的基础功能,我用的 MyBatis-Plus 自带的分页插件,Controller 接收 page、pageSize、keyword、categoryId 四个参数,Service 里组装条件查询:
public PageResult<ProductVO> list(Integer page, Integer pageSize, String keyword, Long categoryId) { Page<Product> p = new Page<>(page, pageSize); LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Product::getStatus, 1); if (StringUtils.hasText(keyword)) { wrapper.like(Product::getName, keyword); } if (categoryId != null) { wrapper.eq(Product::getCategoryId, categoryId); } wrapper.orderByDesc(Product::getCreateTime); Page<Product> result = productMapper.selectPage(p, wrapper); // 把 entity 转成 vo,隐藏不需要返回给前端的字段 }这里有必要提一下“为什么 Controller 不直接返回 Entity”。商品表里如果还有其他内部字段,直接返回 Entity 等于把数据库结构透传给了前端;更麻烦的是,如果前端需要冗余字段、或者后端要隐藏某个字段,直接返回实体类就很难灵活调整。做前后端分离时,接口的返回结构应该由 VO 控制,而不是由数据库表结构控制。
2.4 购物车接口与数量更新
购物车接口是主体功能里最容易做乱的模块。我先列一下我定义好的几个操作:
POST /api/cart/add:添加商品到购物车,参数 productId、quantityPUT /api/cart/update:修改购物车某个商品的 quantityDELETE /api/cart/delete:删除购物车中某个商品PUT /api/cart/checked:更新勾选状态(单选或全选)GET /api/cart/list:获取当前用户购物车列表
购物车表用了(user_id, product_id)唯一索引,这样用户重复添加同一个商品时,不会出现两行,而是数量累加。这个 upsert 逻辑放在 Service 层:
public void add(Long userId, Long productId, Integer quantity) { Product product = productMapper.selectById(productId); if (product == null || product.getStatus() != 1) { throw new BizException("商品不存在或已下架"); } CartItem existing = cartItemMapper.selectOne( new LambdaQueryWrapper<CartItem>() .eq(CartItem::getUserId, userId) .eq(CartItem::getProductId, productId)); if (existing == null) { CartItem item = new CartItem(); item.setUserId(userId); item.setProductId(productId); item.setQuantity(quantity); item.setChecked(1); cartItemMapper.insert(item); } else { int newQuantity = existing.getQuantity() + quantity; if (newQuantity > product.getStock()) { throw new BizException("加购数量超过库存"); } existing.setQuantity(newQuantity); cartItemMapper.updateById(existing); } }购物车接口全部要求登录,所以 Controller 里从 UserContext 拿 userId,而不是前端传 userId。前后端分离项目里,凡是涉及用户维度的数据,后端必须能从 Token 里自己判断身份,不能信任前端传来的 userId。这个原则要刻在脑子里。
2.5 下单接口与事务控制:核心难点
下单是整个项目业务难度最大的地方,因为它涉及多张表的联动:查询购物车、校验商品、扣减库存、创建订单、创建订单明细、清除购物车。任何一个环节失败,都不能留下脏数据,所以必须放在同一个事务里。
我在 Service 方法上加@Transactional(rollbackFor = Exception.class):
@Transactional(rollbackFor = Exception.class) public Order createOrder(Long userId, List<Long> cartItemIds) { List<CartItem> cartItems = cartItemMapper.selectBatchIds(cartItemIds); if (CollectionUtils.isEmpty(cartItems)) { throw new BizException("没有选中的商品"); } BigDecimal totalPayment = BigDecimal.ZERO; List<OrderItem> orderItems = new ArrayList<>(); for (CartItem cartItem : cartItems) { // 防止用户提交不属于自己购物车的商品 id if (!cartItem.getUserId().equals(userId)) { throw new BizException("非法购物车数据"); } Product product = productMapper.selectById(cartItem.getProductId()); if (product == null || product.getStatus() != 1) { throw new BizException("商品已下架,请重新选择"); } // 扣减库存,乐观锁防超卖,后面会专门讲 int rows = productMapper.reduceStock(product.getId(), cartItem.getQuantity()); if (rows == 0) { throw new BizException("商品[" + product.getName() + "]库存不足"); } totalPayment = totalPayment.add(product.getPrice() .multiply(BigDecimal.valueOf(cartItem.getQuantity()))); OrderItem orderItem = new OrderItem(); orderItem.setProductId(product.getId()); orderItem.setProductName(product.getName()); orderItem.setProductImage(product.getMainImage()); orderItem.setCurrentPrice(product.getPrice()); orderItem.setQuantity(cartItem.getQuantity()); orderItem.setTotalPrice(product.getPrice() .multiply(BigDecimal.valueOf(cartItem.getQuantity()))); orderItems.add(orderItem); } Orders order = new Orders(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setPayment(totalPayment); order.setStatus(0); order.setShippingAddress(userService.getById(userId).getAddress()); orderMapper.insert(order); for (OrderItem item : orderItems) { item.setOrderId(order.getId()); orderItemMapper.insert(item); } // 下单完成后,清掉已购买的购物车条目 cartItemMapper.deleteBatchIds(cartItemIds); return order; }等等,扣减库存这个方法里面才是关键。如果直接用普通 update 把库存减掉,会出现一个经典问题:两个人同时下单,检查库存都是够的,结果减出来变负数。解决超卖用的是乐观锁思路,SQL 这样写:
UPDATE product SET stock = stock - #{quantity} WHERE id = #{productId} AND stock >= #{quantity}这里的关键是WHERE stock >= #{quantity}。数据库在 update 时会锁住这条记录,如果此时库存不够,更新影响行数就是 0,Java 代码里通过rows == 0就能判断出库存不足,然后抛异常回滚事务。这个写法比“先 SELECT 再 UPDATE”安全得多,也比用 synchronized 锁 JVM 更适合真实场景。
订单号我用的是时间戳加随机数的方式生成:
private String generateOrderNo() { return System.currentTimeMillis() + "" + (100000 + new Random().nextInt(900000)); }日常开发里有人用 UUID,但 UUID 太长而且无规律;有人用雪花算法,但对练手项目来说,时间戳加随机数已经能满足唯一性需求,再加上数据库层面对 order_no 建了唯一索引,即使真重复了也会在插入时抛异常。
3. 前端页面设计、接口联调与打包部署
3.1 页面路由和组件拆分
前端我用 Vue 3,配合 Vue Router 和 Pinia 或本地存储来做状态管理。主体页面没有想象中那么多,最后实际完成了这些路由:
/login登录页,/register注册页/首页商品列表/product/:id商品详情页/cart购物车页/checkout结算页/orders订单列表页/orders/:id订单详情页
页面拆的时候,我把“商品卡片”和“购物车单项”都拆成了独立组件,这样商品列表和购物车列表的 UI 复用度会高很多。不过对这类练手项目来说,组件拆得太细反而是负担,拆到“页面级组件 + 列表项组件”这一层就够了。
3.2 axios 封装:统一带 Token,统一处理 401
前端调接口离不开 axios,但直接在每个页面里axios.get会很痛苦。我封装了一个统一请求模块,主要是两个拦截器。
请求拦截器负责自动带 Token:
import axios from 'axios' import router from '../router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config })响应拦截器负责统一处理业务码和 401:
request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { alert(res.message || '请求失败') return Promise.reject(new Error(res.message || '请求失败')) } return res }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } else if (error.response && error.response.status === 500) { alert('服务器异常,请稍后再试') } return Promise.reject(error) } )这样页面里写交互时只需要关心业务成功的情况,不用每个接口都写一遍错误处理。
3.3 购物车页面的交互逻辑
购物车页是所有前端页面里最需要“小心设计”的,因为有两个状态要维护:勾选状态和数量。而且前端修改数量后,需要实时刷新下方的合计金额。
我一开始直接用后端数据驱动,每次修改数量和勾选就调接口,然后重新拉购物车列表,逻辑最简单但接口会发得很频繁。做了几轮以后我发现,对这个项目来说“每次操作都刷新”也完全没问题,性能瓶颈根本不在购物车这种轻接口上。重要的是保持前后端状态一致,不要只在本地改数量,刷新页面又变回去了。改完本地展示,紧接着调用后端保存,然后把后端返回的最新购物车列表重新渲染,这是最不容易出错的方案。
下单时,前端从购物车页带着选中的 cartItemIds 跳到结算页。购物车页通过勾选框控制选中集合:
function checkout() { const checkedItems = cartItems.value.filter(item => item.checked === 1) if (checkedItems.length === 0) { alert('请先勾选要结算的商品') return } const ids = checkedItems.map(item => item.id) router.push({ path: '/checkout', query: { ids: ids.join(',') } }) }结算页再把这些 ids 拼成数组传给POST /api/order/create。
3.4 本地联调与生产部署
前端本地开发时,会遇到跨域问题。最简单的方式不是去后端配 CORS,而是用 Vite 的代理把/api转发到后端端口:
// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样浏览器里看到的所有请求都是发到同一个前端域名下,跨域问题直接消失。但在后台开发时,CORS 配置还是要有,否则其他前端接入接口就会报跨域。
生产部署时,我用 Nginx 托管前端静态文件,同时把/api反向代理到后端服务:
npm run build构建完前端项目,把 dist 目录放到 Nginx 的 html 目录下,然后配置:
server { listen 80; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里有个常见问题:SPA 应用如果刷新一个子路由页面,比如/orders/3,Nginx 默认会去磁盘找这个路径,找不到就返回 404。所以必须配try_files把所有路径都回退到 index.html,让前端路由接管。这个坑我见过太多人踩。
4. 并发安全、数据校验与防坑处理
4.1 库存防超卖:从扣库存 SQL 说起
前面在下单接口里写了productMapper.reduceStock,但很多人第一次做购物网站时,会写成这样的逻辑:
Product product = productMapper.selectById(productId); if (product.getStock() >= quantity) { product.setStock(product.getStock() - quantity); productMapper.updateById(product); }这种写法在单用户调试时完全没问题,一旦有两个线程同时读到库存为 1,然后同时执行减库存,最终库存会变成 0 甚至 -1,这就是超卖。用乐观锁 SQL 改写之后,数据库就在这一行 update 上通过stock >= quantity条件做了最终裁决,不需要在 Java 代码里加锁。
我给的 Mapper 方法是这样的:
@Update("UPDATE product SET stock = stock - #{quantity} WHERE id = #{productId} AND stock >= #{quantity}") int reduceStock(@Param("productId") Long productId, @Param("quantity") Integer quantity);如果返回的行数为 0,说明库存不足或商品已经被改掉,直接抛异常回滚。这里要提醒一句:不要把事务放在 Controller 层,也不要把try catch写在 Service 内部后吞掉异常。@Transactional 只有感知到 RuntimeException 才能触发回滚,如果你自己 catch 住异常并返回 null,事务是不会回滚的。
4.2 防止重复下单:一份 token 换一份订单
购物车结算页有一个非常常见的坑:用户手一抖,快速点了两次“提交订单”,结果生成了两笔相同订单。这类“重复提交”问题在业务层要考虑,但也不用引入特别复杂的方案。
最朴素的做法是在前端提交后立刻把按钮置灰,禁用点击。但这只是体验层面的优化,不能完全防止网络异常导致的重试。
更可靠的做法是后端做幂等。我采用的是“前端生成请求唯一标识”的方式,在结算页点击提交时生成一个 requestId,后端在订单表里增加一个request_id字段并建唯一索引。下单接口接收 requestId,插入订单时如果唯一索引冲突,就说明重复提交,直接抛出“订单正在处理中,请勿重复提交”的异常,回滚本次操作。
这个方案比在 Controller 里判断“这个用户是不是短时间内下过单”靠谱得多,因为唯一索引是数据库层面保证的,无论几个请求并发进来,最终只有一条数据能插入成功。
ALTER TABLE `orders` ADD COLUMN `request_id` varchar(64) DEFAULT NULL; ALTER TABLE `orders` ADD UNIQUE KEY `uk_request_id` (`request_id`);4.3 数据校验:前后端都要做
很多人觉得参数校验是前端的事,后端只要正常处理就行,这是错误认知。前后端分离模式下,接口可能被任何客户端调用,不排除有人直接拿 Postman 对你的接口做非法输入。后端接口一定要做完整性校验。
我用的做法是 Java Bean Validation,在 DTO 字段上加注解:
public class RegisterDTO { @NotBlank(message = "用户名不能为空") @Size(min = 3, max = 20, message = "用户名长度必须在3到20之间") private String username; @NotBlank(message = "密码不能为空") @Size(min = 6, max = 20, message = "密码长度必须在6到20之间") private String password; }Controller 里加@Valid,校验失败时统一被全局异常处理器捕获,返回 400 和具体错误信息。这样即使前端漏校验,后端也不会因为 null 或超长字符串而报出莫名其妙的 500。
4.4 登录鉴权接口的权限控制
项目里不是每个接口都要求登录。商品列表、商品详情、分类查询是公开接口;购物车、订单、结算必须登录。这意味着后端不能只靠一个全局拦截器把所有接口都拦住。
我的方案是给登录拦截器加一个“白名单”配置。在应用的配置中心或 WebMvc 配置里,把公开接口排除在拦截范围之外:
registry.addInterceptor(loginInterceptor) .addPathPatterns("/api/cart/**", "/api/order/**", "/api/user/logout") .excludePathPatterns("/api/product/**", "/api/category/**", "/api/user/login", "/api/user/register");这里要明确一个细节:我不知道别人怎么做,但我建议把公开接口和受保护接口的路径设计得尽量能区分开,比如商品模块统一以/api/product开头,购物车统一以/api/cart开头。这样在配置拦截器白名单时,一眼就能看出哪些接口需要登录,后期加接口也不容易漏配置。
4.5 下单时的用户身份核对:防止越权
再说一个容易忽略的安全细节。下单接口接收前端传来的 cartItemIds,但如果用户手动构造请求,传别人的购物车 id,后端必须拒绝。
我在下单代码里对每个 CartItem 都做了cartItem.getUserId().equals(userId)校验。这个校验看似多余,实际上很重要。因为购物车 id 是一个自增数字,别人很容易猜到另一个 id 并尝试提交。这类“水平越权”漏洞在真实项目里也经常出现,练手时养成这类习惯,后面做更复杂的系统能少踩很多坑。
5. 常见问题排查实操与避坑清单
5.1 前端请求后端永远报跨域
不管是后端配 CORS 还是前端配代理,都要先理解跨域的本质:浏览器的同源策略。如果页面运行在http://localhost:5173,请求发到http://localhost:8080,源不同,浏览器就会拦截。
如果坚持走 CORS,后端这样写比较稳妥:
@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); } }这里有个坑:如果allowCredentials(true)同时又写了allowedOrigins("*"),部分浏览器版本会直接报错,因为带 Cookie 或凭证的跨域请求不允许使用通配符来源。要使用allowedOriginPatterns("*"),它会在内部处理匹配逻辑。如果是开发阶段,我仍然推荐优先用 Vite 代理,省心得多。
5.2 后端返回的下划线字段,前端拿不到
前后端最容易出现的乌龙是字段命名。数据库字段是create_time,如果直接把 Entity 序列化成 JSON,返回的就是create_time,而前端代码里写的是createTime,结果一undefined就开始排查半天。
MyBatis-Plus 本身有驼峰转换配置。如果你还是遇到字段对不上,检查 application.yml:
mybatis-plus: configuration: map-underscore-to-camel-case: true如果确认开启后仍然不行,那大概率是你自己写了 XML 里的 resultMap 或者自定义了别名,此时优先检查手写的映射。接口层可以统一在 VO 上用@JsonProperty控制字段名,保证前端永远拿到的都是驼峰格式。
5.3 事务一直不生效,库存扣了但订单没建
这种场景通常有几种原因。第一种是方法被同类内部调用,createOrder被同一个类里的另一个方法调用,导致 @Transactional 失效,因为代理对象没有走进去。解决办法是注入自己的代理对象,或者把事务方法放在另一个 Service 里调用。第二种是方法不是 public,Spring 的声明式事务是基于代理的,非 public 方法无法被代理。第三种是异常被 catch 住,Service 里买了try catch却没抛出 RuntimeException,事务自然判断“一切正常”。
排查思路按这个顺序走:先确认方法修饰符,再确认是不是自调用,最后在 catch 块里打日志看异常是否真的抛出了。
5.4 购物车数量修改后,页面一刷新就还原
这是典型的前端本地状态没有同步到后端。购物车数量变化时,我建议走完整链路:前端调用PUT /api/cart/update,后端更新成功后再重新拉取购物车列表,用返回的数据重新渲染页面。不要只在本地数组里改quantity,刷新后没有人帮你记住刚才的状态。
5.5 订单创建成功但库存没变
这种问题大概率是扣库存的 UPDATE 没有生效。检查一下是不是用了乐观锁 SQL,同时确认reduceStock的返回值没被忽略。如果 update 影响行数为 0,可能是当前库存值本身小于要扣减的数量,也可能是商品 status 不是 1。在日志里把查询到的商品对象和扣减参数打出来,定位非常快。
5.7 常见问题速查表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 前端所有请求报跨域 | 未配置 CORS 或代理 | 开发环境用 Vite 代理,生产环境用 Nginx 反向代理 |
| JSON 字段全是下划线 | MyBatis 驼峰配置没开 | 检查 map-underscore-to-camel-case |
| 登录后接口返回 401 | Token 过期或 Header 没带 | 检查请求拦截器是否自动注入 Authorization |
| 事务不生效 | 自调用、非 public、异常被吞 | 拆 Service,抛 RuntimeException |
| 库存变负数 | 扣库存逻辑是 select 再 update | 改成带条件的原子 UPDATE |
| 刷新子路由 404 | Nginx 没配 try_files | 配置try_files $uri $uri/ /index.html |
| 下单重复 | 前端重复点击或网络重试 | 加 request_id 唯一索引 |
这张表基本覆盖了做这个项目时最容易翻车的问题。真到排查的时候,每一条都能省下小半天时间。
6. 从 Demo 到工程化:一点经验补充
项目做到这里,“主体功能”已经完整跑通了。每次有人问我这类项目怎么做才能显得有价值,我都会说:不要只停留在“能跑就行”,往下走几步,项目深度会完全不一样。
第一个可以扩展的方向是商品缓存。商品列表是读多写少的接口,可以用 Redis 缓存热门商品列表,减少数据库查询压力。练手时不用做得太深,在商品详情接口上加一个缓存查询,再解决一下缓存与数据库的一致性,就已经比大多数课设项目高出一个档次。
第二个扩展方向是订单超时关闭。真实的购物网站里,下单不支付会占用库存,所以需要定时把超过 30 分钟未支付的订单关掉,并把库存加回来。最简单的实现方式是写一个定时任务,每 5 分钟扫描一次待支付订单,看看是否超时。想再进一步,可以用 RabbitMQ 的延迟队列来实现,但那是后话。
第三个方向是完善后台管理。商品上架、分类维护、订单状态流转,这些都是很实际的痛点。加一个简单的后台管理页,给管理员分配权限,项目就从“用户端 Demo”升级成了“前后端完整业务系统”。
最后再分享一个我自己的固定习惯:每做完一个模块,就提交一次 Git,commit message 里写清楚“完成了什么”。因为这个项目看着小,做完也要三四天,过程中一旦改乱,没有版本控制会觉得非常绝望。后面前端改接口、后端改字段,两边频繁联调,随时回滚的能力比什么都重要。
做项目最有意思的点不是你写出了多少行代码,而是你把一个标题变成了一套能自己跑的完整系统,中间踩过的每个坑,后面都能变成面试时脱口而出的细节。