

🔥个人主页:北极的代码(欢迎来访)
🎬作者简介:java后端学习者
✨命运的结局尽可永在,不屈的挑战却不可须臾或缺!
前言:
思维导图:


产品原型:
1. 触发入口:
菜品列表页: 每个菜品卡片上通常有一个 "加入购物车" 的按钮(可能是"+"号图标)。
菜品详情页: 进入菜品详情,选择规格(如口味)后,底部有一个 "加入购物车" 按钮。
套餐页面: 类似于菜品,直接点击加入购物车。
2. 业务规则(原型背后的逻辑):
用户状态: 用户必须处于登录状态。如果未登录,点击加入购物车应跳转到登录页。
相同菜品处理:
如果当前购物车中没有该菜品(且口味相同),则新增一条记录,数量为1。
如果当前购物车中已有 该菜品(且口味相同),则不会新增记录,而是将数量加1。
套餐处理: 套餐通常视为一个整体加入购物车(不需要选择口味,或者口味固定)。
店铺状态: 通常要校验店铺是否处于"营业中",如果已打烊,应提示无法加入购物车。
需求分析和接口设计:
基本信息
接口地址:
/user/shoppingCart/add请求方式:
POST接口描述: 用户添加菜品或套餐到购物车
请求参数(菜品id,套餐id,口味)
由于既可以添加菜品(Dish),也可以添加套餐(Setmeal),因此前端需要传递一个区分类型的标识。
请求头(Headers):
Content-Type: application/json
token:(用户JWT令牌,用于识别当前操作用户)
请求体(Body JSON 示例):
场景A:添加菜品(假设选择微辣)
json
{
"dishId": 101,
"setmealId": null,
"dishFlavor": "微辣"
}
场景B:添加套餐
json
{
"dishId": null,
"setmealId": 202,
"dishFlavor": ""
}
参数说明:
| 参数名 | 类型 | 是否必须 | 说明 |
|---|---|---|---|
dishId |
Long | 否 (二选一) | 菜品ID |
setmealId |
Long | 否 (二选一) | 套餐ID |
dishFlavor |
String | 否 | 菜品口味(如果是菜品且有口味选项时必须传) |
3. 返回数据
响应示例(成功):
json
{ "code": 1, // 1表示成功,0表示失败 "msg": "操作成功", "data": null // 通常添加操作不需要返回数据 }响应示例(失败 - 店铺已打烊):
json
{ "code": 0, "msg": "店铺已打烊,无法添加商品", "data": null }4. 后端逻辑处理(接口实现要点)
在 Controller 和 Service 层中,该接口的核心处理逻辑如下:
身份解析: 从 JWT Token 中解析出当前登录用户的 ID (
userId)。参数封装: 将接收到的
dishId/setmealId、dishFlavor以及解析出的userId封装成一个购物车实体对象 (ShoppingCart)。查询判断:
- 查询数据库表
shopping_cart,条件为:user_id = ? AND (dish_id = ? OR setmeal_id = ?) AND dish_flavor = ?。业务分支:
如果存在: 更新该条记录的
number字段(数量)加1。如果不存在:
查询菜品或套餐表,补全该商品的其他信息(如名称、图片、单价等)。
设置
number = 1。设置
create_time为当前时间。插入一条新的购物车记录。
返回值: 返回通用成功结果。
三、 数据库表设计参考(购物车表 - shopping_cart)
为了支撑上述接口,数据库表通常包含以下字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
id |
bigint | 主键ID |
name |
varchar | 商品名称(冗余字段,方便查询) |
user_id |
bigint | 关联的用户ID(核心,区分不同用户的购物车) |
dish_id |
bigint | 菜品ID(可为空) |
setmeal_id |
bigint | 套餐ID(可为空) |
dish_flavor |
varchar | 菜品口味(如果是菜品) |
number |
int | 商品数量 |
amount |
decimal | 商品单价(冗余字段,下单时参考) |
image |
varchar | 商品图片 |
create_time |
datetime | 加入购物车的时间(通常用于按时间倒序展示) |
总结: 该接口的设计重点在于幂等性处理 (同样的商品加两次变成数量2,而不是两条记录)以及用户隔离(通过user_id区分不同用户的购物车)。
冗余字段的定义
冗余字段 指的是那些可以通过其他表关联查询得到,但却故意在当前表中额外存储的字段。
在购物车表
shopping_cart中,name(商品名称)、amount(单价)、image(图片)就是典型的冗余字段,因为这些信息本来存储在dish(菜品表)和setmeal(套餐表)中。为什么要使用冗余字段?
1. 性能优化(最主要原因)
减少关联查询:如果不冗余,每次查询购物车都需要 JOIN 菜品表或套餐表
示例对比:
sql
-- 有冗余字段:只需查一张表 SELECT name, amount, number FROM shopping_cart WHERE user_id = ?; -- 无冗余字段:需要联合查询 SELECT d.name, d.price, sc.number FROM shopping_cart sc LEFT JOIN dish d ON sc.dish_id = d.id WHERE sc.user_id = ?;购物车是高频操作(用户经常打开查看),减少 JOIN 能显著提升响应速度
2. 数据一致性场景
价格快照:商品价格可能会变动,但用户加购时的价格应该被"冻结"
业务举例:
用户 A 在周一将菜品加入购物车,价格 ¥28
周二商家涨价到 ¥32
用户 A 周三打开购物车,看到的应该是他加购时的 ¥28,而不是最新价格
这就是为什么需要冗余
amount字段3. 避免数据丢失
如果菜品或套餐被商家删除 或下架
有冗余字段,购物车依然能显示商品名称和价格(可提示"已下架")
无冗余字段,关联查询就查不到数据,购物车会显示空白
4. 简化开发
前端展示购物车时,不需要多次调用接口获取商品详情
一次查询,所有展示数据都有了
冗余字段的代价
任何设计都有两面性,冗余字段也带来了一些问题:
1. 数据冗余导致的一致性问题
sql
-- 如果菜品表的价格从 28 改为 32 UPDATE dish SET price = 32 WHERE id = 101; -- 但购物车表里还是 28 SELECT amount FROM shopping_cart WHERE dish_id = 101; -- 还是 282. 更新开销
如果业务要求"购物车价格跟随实时价格",就需要额外维护逻辑
可能需要在价格变动时,同步更新所有购物车中的该商品价格(性能开销大)
3. 存储空间浪费
- 虽然现代数据库存储成本很低,但大量冗余确实会增加空间占用
冗余字段是一种"以空间换时间"的优化手段。在购物车这个场景中:
读多写少:用户频繁查看购物车,很少修改
需要快照:价格必须在加购时锁定
容忍短暂不一致:价格变动不立即同步到购物车是可接受的
因此,适当使用冗余字段是非常合理的设计选择。
代码开发:
我们首先要考虑怎么接收前端提交过来的参数,我们可以用我们准备好的DTO实体类来封装提交过来的参数。

一、代码结构分层
text
Controller层 (接收请求) → Service层 (业务逻辑) → Mapper层 (数据库操作)
二、完整代码实现
1. DTO类 - 接收前端数据
java
java
package com.sky.dto;
import lombok.Data;
import java.io.Serializable;
@Data
public class ShoppingCartDTO implements Serializable {
// 菜品ID
private Long dishId;
// 套餐ID
private Long setmealId;
// 口味
private String dishFlavor;
}
2. 实体类 - 对应数据库表
java
java
package com.sky.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ShoppingCart implements Serializable {
private static final long serialVersionUID = 1L;
// 主键
private Long id;
// 商品名称
private String name;
// 用户id
private Long userId;
// 菜品id
private Long dishId;
// 套餐id
private Long setmealId;
// 口味
private String dishFlavor;
// 数量
private Integer number;
// 金额
private BigDecimal amount;
// 图片
private String image;
// 创建时间
private LocalDateTime createTime;
}
3. Controller层 - 接收请求
java
java
package com.sky.controller.user;
import com.sky.dto.ShoppingCartDTO;
import com.sky.result.Result;
import com.sky.service.ShoppingCartService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/user/shoppingCart")
@Api(tags = "用户端-购物车接口")
@Slf4j
public class ShoppingCartController {
@Autowired
private ShoppingCartService shoppingCartService;
/**
* 添加购物车
* @param shoppingCartDTO
* @return
*/
@PostMapping("/add")
@ApiOperation("添加购物车")
public Result<String> add(@RequestBody ShoppingCartDTO shoppingCartDTO) {
log.info("添加购物车:{}", shoppingCartDTO);
// 调用Service层处理业务
shoppingCartService.addShoppingCart(shoppingCartDTO);
return Result.success();
}
}
4. Service接口
java
java
package com.sky.service;
import com.sky.dto.ShoppingCartDTO;
public interface ShoppingCartService {
/**
* 添加购物车
* @param shoppingCartDTO
*/
void addShoppingCart(ShoppingCartDTO shoppingCartDTO);
}
5. Service实现类 - 核心业务逻辑
java
java
package com.sky.service.impl;
import com.sky.context.BaseContext;
import com.sky.dto.ShoppingCartDTO;
import com.sky.entity.Dish;
import com.sky.entity.Setmeal;
import com.sky.entity.ShoppingCart;
import com.sky.mapper.DishMapper;
import com.sky.mapper.SetmealMapper;
import com.sky.mapper.ShoppingCartMapper;
import com.sky.service.ShoppingCartService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
@Service
@Slf4j
public class ShoppingCartServiceImpl implements ShoppingCartService {
@Autowired
private ShoppingCartMapper shoppingCartMapper;
@Autowired
private DishMapper dishMapper;
@Autowired
private SetmealMapper setmealMapper;
/**
* 添加购物车
* @param shoppingCartDTO
*/
@Transactional(rollbackFor = Exception.class)
public void addShoppingCart(ShoppingCartDTO shoppingCartDTO) {
// 1. 构建查询条件
ShoppingCart shoppingCart = new ShoppingCart();
BeanUtils.copyProperties(shoppingCartDTO, shoppingCart);
// 2. 获取当前登录用户ID
Long userId = BaseContext.getCurrentId();
shoppingCart.setUserId(userId);
// 3. 查询当前商品是否已经在购物车中
List<ShoppingCart> list = shoppingCartMapper.list(shoppingCart);
// 4. 如果已经存在,数量+1
if (list != null && list.size() > 0) {
ShoppingCart cart = list.get(0);
cart.setNumber(cart.getNumber() + 1); // 数量加1
shoppingCartMapper.updateNumberById(cart);
} else {
// 5. 如果不存在,需要插入一条购物车记录
// 5.1 设置基本属性
shoppingCart.setNumber(1);
shoppingCart.setCreateTime(LocalDateTime.now());
// 5.2 判断是菜品还是套餐,并补全其他信息
if (shoppingCartDTO.getDishId() != null) {
// 添加的是菜品
Dish dish = dishMapper.getById(shoppingCartDTO.getDishId());
shoppingCart.setName(dish.getName());
shoppingCart.setImage(dish.getImage());
shoppingCart.setAmount(dish.getPrice());
} else {
// 添加的是套餐
Setmeal setmeal = setmealMapper.getById(shoppingCartDTO.getSetmealId());
shoppingCart.setName(setmeal.getName());
shoppingCart.setImage(setmeal.getImage());
shoppingCart.setAmount(setmeal.getPrice());
}
// 5.3 插入数据库
shoppingCartMapper.insert(shoppingCart);
}
}
}
6. Mapper接口
java
java
package com.sky.mapper;
import com.sky.entity.ShoppingCart;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface ShoppingCartMapper {
/**
* 动态条件查询购物车
* @param shoppingCart
* @return
*/
List<ShoppingCart> list(ShoppingCart shoppingCart);
/**
* 根据id更新数量
* @param shoppingCart
*/
@Update("UPDATE shopping_cart SET number = #{number} WHERE id = #{id}")
void updateNumberById(ShoppingCart shoppingCart);
/**
* 插入购物车数据
* @param shoppingCart
*/
void insert(ShoppingCart shoppingCart);
}
7. Mapper.xml文件
XML
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.ShoppingCartMapper">
<!-- 插入购物车数据 -->
<insert id="insert" parameterType="com.sky.entity.ShoppingCart">
INSERT INTO shopping_cart
(name, user_id, dish_id, setmeal_id, dish_flavor, number, amount, image, create_time)
VALUES
(#{name}, #{userId}, #{dishId}, #{setmealId}, #{dishFlavor},
#{number}, #{amount}, #{image}, #{createTime})
</insert>
<!-- 动态查询购物车列表 -->
<select id="list" parameterType="com.sky.entity.ShoppingCart"
resultType="com.sky.entity.ShoppingCart">
SELECT * FROM shopping_cart
<where>
<if test="userId != null">
AND user_id = #{userId}
</if>
<if test="dishId != null">
AND dish_id = #{dishId}
</if>
<if test="setmealId != null">
AND setmeal_id = #{setmealId}
</if>
<if test="dishFlavor != null and dishFlavor != ''">
AND dish_flavor = #{dishFlavor}
</if>
</where>
ORDER BY create_time DESC
</select>
</mapper>
三、代码执行流程
场景1:添加已存在的商品
java
java
// 用户A已有一个"宫保鸡丁 微辣"在购物车,数量=2
// 再次添加同一个商品
// 1. 查询条件:userId=1001, dishId=101, dishFlavor="微辣"
// 2. 查到记录:{id=5, number=2, ...}
// 3. setNumber(3) → 更新数据库
// 4. 结果:数量变为3
场景2:添加不存在的商品
java
java
// 用户A购物车没有"酸菜鱼"
// 1. 查询条件:userId=1001, dishId=102
// 2. 查不到记录,list为空
// 3. 从菜品表查询酸菜鱼信息
// 4. 组装完整对象,插入数据库
// 5. 结果:新增一条记录,数量=1
四、测试数据
请求示例
json
POST /user/shoppingCart/add
Content-Type: application/json
token: eyJhbGciOiJIUzI1NiJ9...
{
"dishId": 101,
"dishFlavor": "微辣"
}
响应示例
json
{
"code": 1,
"msg": "操作成功",
"data": null
}
五、关键点总结
-
用户ID获取 :通过
BaseContext.getCurrentId()从ThreadLocal获取 -
存在性判断:用DTO的三个字段+userId查询
-
数量处理:存在则+1,不存在则新增
-
数据补全:新记录需要从菜品/套餐表查询名称、价格等
-
事务管理 :使用
@Transactional保证数据一致性
(补充)关于用户id从哪里取:
用户登录
↓
后端生成JWT(包含userId=1001)
↓
前端保存token
↓
用户添加购物车 → 请求头携带token
↓
拦截器拦截请求 → 解析token → 获取userId=1001
↓
存入ThreadLocal ← BaseContext.setCurrentId(1001)
↓
Controller接收请求
↓
Service调用 BaseContext.getCurrentId() → 得到1001
↓
Service使用userId处理业务
↓
请求结束 → 拦截器清理ThreadLocal
步骤1:用户登录成功,生成JWT令牌
java
java
@Service
public class UserServiceImpl implements UserService {
public LoginVO login(UserLoginDTO loginDTO) {
// 1. 验证用户信息
User user = userMapper.getByOpenid(loginDTO.getCode());
// 2. 登录成功,生成JWT令牌
Map<String, Object> claims = new HashMap<>();
claims.put("userId", user.getId()); // 把用户ID放进token
String token = JwtUtil.createJWT(
jwtProperties.getUserSecretKey(),
jwtProperties.getUserTtl(),
claims
);
// 3. 返回token给前端
return LoginVO.builder()
.id(user.getId())
.token(token)
.build();
}
}
步骤2:前端存储token,后续请求都带上
javascript
javascript
// 前端代码:登录成功后保存token
localStorage.setItem('token', response.data.token);
// 后续每次请求都在header中携带token
axios.defaults.headers.common['token'] = localStorage.getItem('token');
步骤3:拦截器解析token,存入BaseContext
java
java
@Component
public class JwtTokenUserInterceptor implements HandlerInterceptor {
@Autowired
private JwtProperties jwtProperties;
/**
* 在请求进入Controller之前执行
*/
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
// 1. 从请求头获取token
String token = request.getHeader(jwtProperties.getUserTokenName());
// 2. 解析token
Claims claims = JwtUtil.parseJWT(
jwtProperties.getUserSecretKey(),
token
);
// 3. 从token中提取userId
Long userId = claims.get("userId", Long.class);
// 4. 【关键】存入ThreadLocal
BaseContext.setCurrentId(userId);
// 5. 放行请求
return true;
}
/**
* 请求结束后清理
*/
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
// 防止内存泄漏
BaseContext.removeCurrentId();
}
}
步骤4:在Service中直接使用
java
java
@Service
public class ShoppingCartServiceImpl implements ShoppingCartService {
public void addShoppingCart(ShoppingCartDTO shoppingCartDTO) {
// 直接从BaseContext获取当前登录用户的ID
Long userId = BaseContext.getCurrentId();
// 这个userId是安全的,来自token,不是用户传的
shoppingCart.setUserId(userId);
// ... 其他业务逻辑
}
}

结语:如果对你有帮助,请点赞,关注,收藏!