Day | 07 【苍穹外卖 :用户端添加购物车】

🔥个人主页:北极的代码(欢迎来访)

🎬作者简介:java后端学习者

❄️个人专栏:苍穹外卖日记SSM框架深入JavaWeb

命运的结局尽可永在,不屈的挑战却不可须臾或缺!

前言:

思维导图:

产品原型:

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 层中,该接口的核心处理逻辑如下:

  1. 身份解析: 从 JWT Token 中解析出当前登录用户的 ID (userId)。

  2. 参数封装: 将接收到的 dishId/setmealIddishFlavor 以及解析出的 userId 封装成一个购物车实体对象 (ShoppingCart)。

  3. 查询判断:

    • 查询数据库表 shopping_cart,条件为:user_id = ? AND (dish_id = ? OR setmeal_id = ?) AND dish_flavor = ?
  4. 业务分支:

    • 如果存在: 更新该条记录的 number 字段(数量)加1。

    • 如果不存在:

      • 查询菜品或套餐表,补全该商品的其他信息(如名称、图片、单价等)。

      • 设置 number = 1

      • 设置 create_time 为当前时间。

      • 插入一条新的购物车记录。

  5. 返回值: 返回通用成功结果。

三、 数据库表设计参考(购物车表 - 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; -- 还是 28

2. 更新开销

  • 如果业务要求"购物车价格跟随实时价格",就需要额外维护逻辑

  • 可能需要在价格变动时,同步更新所有购物车中的该商品价格(性能开销大)

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
}

五、关键点总结

  1. 用户ID获取 :通过BaseContext.getCurrentId()从ThreadLocal获取

  2. 存在性判断:用DTO的三个字段+userId查询

  3. 数量处理:存在则+1,不存在则新增

  4. 数据补全:新记录需要从菜品/套餐表查询名称、价格等

  5. 事务管理 :使用@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);
        
        // ... 其他业务逻辑
    }
}

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

相关推荐
uzong1 小时前
为什么是你来做?面试中犀利问题的底层逻辑是什么和标准回答模版
后端·面试
chikaaa1 小时前
RabbitMQ 核心机制总结笔记
java·笔记·rabbitmq·java-rabbitmq
Francek Chen1 小时前
【大数据存储与管理】分布式数据库HBase:05 HBase运行机制
大数据·数据库·hadoop·分布式·hdfs·hbase
Sailing1 小时前
🚀AI 写代码越来越快,但我开始不敢上线了
前端·后端·面试
小小怪7501 小时前
将Python Web应用部署到服务器(Docker + Nginx)
jvm·数据库·python
咕叽吧咔1 小时前
LeetBook乐扣题库 142. 环形链表 II
java·数据结构·leetcode·链表
Sylvia33.1 小时前
体育数据API实战:用火星数据实现NBA赛事实时比分与状态同步
java·linux·开发语言·前端·python
麦聪聊数据1 小时前
SQL 到 API 转化过程中的版本控制与灰度发布机制
数据库·sql·低代码·微服务
Coder-coco1 小时前
家政服务管理系统|基于springboot + vue家政服务管理系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·论文·毕设·家政服务管理系统
程序员鱼皮1 小时前
万字干货 | OpenClaw 进阶玩法大全:技能 / 多 Agent / 省钱 / 安全,50+ 实战技巧一次学会
前端·后端·ai编程