资讯动态

Spring Boot+Vue前后端分离实现购物网站核心功能实战解析

发布时间:2026/10/17 22:15:59 来源:尧图企业网站定制
[前后端分离][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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;商品表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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;购物车表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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单表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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单明细表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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里要重点说一个设计细节为什么订单明细里要把商品名称、图片、价格原样复制一份而不是下单时通过 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 ApiResultT { private Integer code; private String message; private T data; public static T ApiResultT ok(T data) { ApiResultT result new ApiResult(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ApiResultT error(Integer code, String message) { ApiResultT 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 里抽出来的 BCryptPasswordEncoderPostMapping(/register) public ApiResultVoid 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 LambdaQueryWrapperUser().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 返回 tokenPostMapping(/login) public ApiResultLoginVO 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;charsetUTF-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 PageResultProductVO list(Integer page, Integer pageSize, String keyword, Long categoryId) { PageProduct p new Page(page, pageSize); LambdaQueryWrapperProduct 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); PageProduct 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 LambdaQueryWrapperCartItem() .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, ListLong cartItemIds) { ListCartItem cartItems cartItemMapper.selectBatchIds(cartItemIds); if (CollectionUtils.isEmpty(cartItems)) { throw new BizException(没有选中的商品); } BigDecimal totalPayment BigDecimal.ZERO; ListOrderItem 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 时会锁住这条记录如果此时库存不够更新影响行数就是 0Java 代码里通过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会很痛苦。我封装了一个统一请求模块主要是两个拦截器。请求拦截器负责自动带 Tokenimport 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 })响应拦截器负责统一处理业务码和 401request.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/3Nginx 默认会去磁盘找这个路径找不到就返回 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.ymlmybatis-plus: configuration: map-underscore-to-camel-case: true如果确认开启后仍然不行那大概率是你自己写了 XML 里的 resultMap 或者自定义了别名此时优先检查手写的映射。接口层可以统一在 VO 上用JsonProperty控制字段名保证前端永远拿到的都是驼峰格式。5.3 事务一直不生效库存扣了但订单没建这种场景通常有几种原因。第一种是方法被同类内部调用createOrder被同一个类里的另一个方法调用导致 Transactional 失效因为代理对象没有走进去。解决办法是注入自己的代理对象或者把事务方法放在另一个 Service 里调用。第二种是方法不是 publicSpring 的声明式事务是基于代理的非 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登录后接口返回 401Token 过期或 Header 没带检查请求拦截器是否自动注入 Authorization事务不生效自调用、非 public、异常被吞拆 Service抛 RuntimeException库存变负数扣库存逻辑是 select 再 update改成带条件的原子 UPDATE刷新子路由 404Nginx 没配 try_files配置try_files $uri $uri/ /index.html下单重复前端重复点击或网络重试加 request_id 唯一索引这张表基本覆盖了做这个项目时最容易翻车的问题。真到排查的时候每一条都能省下小半天时间。6. 从 Demo 到工程化一点经验补充项目做到这里“主体功能”已经完整跑通了。每次有人问我这类项目怎么做才能显得有价值我都会说不要只停留在“能跑就行”往下走几步项目深度会完全不一样。第一个可以扩展的方向是商品缓存。商品列表是读多写少的接口可以用 Redis 缓存热门商品列表减少数据库查询压力。练手时不用做得太深在商品详情接口上加一个缓存查询再解决一下缓存与数据库的一致性就已经比大多数课设项目高出一个档次。第二个扩展方向是订单超时关闭。真实的购物网站里下单不支付会占用库存所以需要定时把超过 30 分钟未支付的订单关掉并把库存加回来。最简单的实现方式是写一个定时任务每 5 分钟扫描一次待支付订单看看是否超时。想再进一步可以用 RabbitMQ 的延迟队列来实现但那是后话。第三个方向是完善后台管理。商品上架、分类维护、订单状态流转这些都是很实际的痛点。加一个简单的后台管理页给管理员分配权限项目就从“用户端 Demo”升级成了“前后端完整业务系统”。最后再分享一个我自己的固定习惯每做完一个模块就提交一次 Gitcommit message 里写清楚“完成了什么”。因为这个项目看着小做完也要三四天过程中一旦改乱没有版本控制会觉得非常绝望。后面前端改接口、后端改字段两边频繁联调随时回滚的能力比什么都重要。做项目最有意思的点不是你写出了多少行代码而是你把一个标题变成了一套能自己跑的完整系统中间踩过的每个坑后面都能变成面试时脱口而出的细节。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑