【Uni-App+SSM 宠物项目实战】Day15:购物车添加

81 阅读12分钟

大家好!今天是学习路线的第 15 天,我们正式进入订单与购物车核心模块。昨天完成了商家服务列表的分页加载,今天聚焦 “购物车添加” 功能 —— 这是连接 “商品浏览” 与 “订单提交” 的关键环节,用户可将宠物用品(如粮食、玩具)加入购物车,后续统一结算。

为什么学这个? 购物车功能看似简单,实则包含 “数据唯一性校验”“用户身份关联”“前后端交互反馈” 等核心逻辑。通过本次实战,你将掌握:MyBatis-Plus 条件查询判断重复、JWT Token 验证用户身份、Uni-App 弹窗组件的灵活运用,这些都是电商类项目的必备技能。即使是零基础,也能通过 “分步代码 + 详细注释” 轻松上手!

本日目标:实现 “商品详情页→添加购物车” 全流程,包括后端重复商品检测(已存在则更新数量)、用户身份验证,以及前端弹窗确认、操作反馈等交互,最终达到 “添加成功有提示、重复添加能增量、未登录不让加” 的实战效果。

一、前置准备

在开始编码前,请确认以下环境与数据已就绪,避免开发中卡壳:

项目检查内容注意事项
数据库表结构确保cart(购物车表)已创建,字段需包含:id(主键)、user_id(关联用户 ID,Long 类型)、goods_id(关联商品 ID,Long 类型)、quantity(商品数量,INT,默认 1)、create_time(创建时间,DATETIME,默认当前时间)、update_time(更新时间,DATETIME,自动更新)若表缺失,执行以下建表 SQL:sqlCREATE TABLE cart (id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT '用户ID', goods_id BIGINT NOT NULL COMMENT '商品ID', quantity INT DEFAULT 1 COMMENT '商品数量', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_goods (user_id, goods_id) COMMENT '用户-商品唯一索引,防止重复添加');
后端依赖与配置1. pom.xml已引入 JWT(Day10 登录用)、MyBatis-Plus(3.5.x 版本)、Hutool(4.0.12+);2. MP 分页插件已配置;3. JWT 密钥与 Day10 保持一致(如mypet-secret-2024)若 JWT 依赖缺失,需补充:xmlio.jsonwebtokenjjwt0.9.1
前端组件与存储1. Uni-App 项目已安装uni-popup(弹窗)、uni-icons(图标,可选)组件;2. 登录后uni.getStorageSync('mypet_token')能获取有效 Token,mypet_userId已存储;3. 商品详情页能获取当前商品 ID(goodsId)uni-popup安装:HBuilder X→插件市场→搜索 “uni-ui”→导入 “uni-popup” 组件;商品 ID 可通过页面跳转参数传递(如pages/goods/detail?id=1)
测试数据1. goods(商品表)已存在测试数据(如id=1,商品名称 “宠物狗粮 10kg”);2. 存在已登录用户(user_id=1,Token 有效)若商品表无数据,执行 SQL:sqlINSERT INTO goods (id, goods_name, price, stock, status, image_url) VALUES (1, '宠物狗粮10kg', 199.00, 100, 1, '/static/images/dog-food.jpg');

二、核心逻辑流程图

先通过流程图理清 “添加购物车” 的完整逻辑,再动手编码更高效:

flowchart TD
    A[用户进入服务列表页] --> B[页面初始化<br>调用Mescroll的init方法]
    B --> C[默认触发下拉刷新<br>首次加载等同于刷新]
    C --> D[重置分页参数<br>page=1,list=空,hasMore=true]
    D --> E[调用后端/page接口<br>传递page=1,size=10,shangjia_id=当前商家ID]
    E --> F{后端返回数据}
    F -- 否 --> G[显示暂无服务数据]
    F -- 是 --> H[前端list接收第一页数据<br>res.data.records]
    H --> I[Mescroll结束刷新<br>显示第一页服务列表]
    I --> J[用户上拉页面至底部]
    J --> K{Mescroll检测到上拉<br>且hasMore=true}
    K -- 否 --> L[显示没有更多数据了]
    K -- 是 --> M[page+1 page=2<br>调用后端/page接口]
    M --> N{第二页有数据}
    N -- 是 --> O[list拼接新数据<br>list = list.concat新数据]
    N -- 否 --> P[设置hasMore=false<br>不再触发上拉加载]
    O/P --> Q[Mescroll结束加载<br>更新列表显示]
    Q --> R[用户下拉页面<br>触发下拉刷新]
    R --> D[重置分页参数<br>重新加载第一页]

三、代码实现

3.1 后端:购物车添加接口(含重复校验、权限控制)

3.1.1 1. 实体类:CartEntity(购物车实体)

路径:src/main/java/com/entity/CartEntity.java

作用:映射cart表字段,与数据库交互。

import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.util.Date;
@Data  // Lombok注解,自动生成getter/setter/toString
@TableName("cart")  // 关联数据库表名
public class CartEntity {
    @TableId(type = IdType.AUTO)  // 主键自增
    private Long id;
    @TableField("user_id")  // 关联表字段user_id
    private Long userId;  // 用户ID(关联yonghu表)
    @TableField("goods_id")  // 关联表字段goods_id
    private Long goodsId;  // 商品ID(关联goods表)
    @TableField("quantity")  // 关联表字段quantity
    private Integer quantity;  // 商品数量,默认1
    @TableField(value = "create_time", fill = FieldFill.INSERT)  // 插入时自动填充
    private Date createTime;
    @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)  // 插入/更新时自动填充
    private Date updateTime;
    // 逻辑删除字段(可选,若项目支持商品删除后购物车隐藏)
    @TableLogic  // MP逻辑删除注解(0-未删除,1-已删除)
    @TableField("is_deleted")
    private Integer isDeleted = 0;
}

3.1.2 2. Mapper 层:CartMapper(MP 数据访问接口)

路径:src/main/java/com/mapper/CartMapper.java

作用:继承 MP 的BaseMapper,获取 CRUD 方法。

import com.entity.CartEntity;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper  // 标识为MyBatis Mapper接口
public interface CartMapper extends BaseMapper<CartEntity> {
    // 无需自定义方法,MP BaseMapper已包含selectOne、insert、updateById等
}

3.1.3 3. Service 层:CartService 与 CartServiceImpl

路径 1(Service 接口):src/main/java/com/service/CartService.java

路径 2(ServiceImpl 实现):src/main/java/com/service/impl/CartServiceImpl.java

Service 接口(定义业务方法):

import com.entity.CartEntity;
import com.baomidou.mybatisplus.extension.service.IService;
public interface CartService extends IService<CartEntity> {
    // 1. 检查商品是否已在购物车(user_id + goods_id)
    CartEntity getCartByUserAndGoods(Long userId, Long goodsId);
    
    // 2. 添加商品到购物车(已存在则更新数量,不存在则新增)
    void addOrUpdateCart(CartEntity cart, Integer stock);
}

ServiceImpl 实现(实现业务逻辑):

import com.entity.CartEntity;
import com.mapper.CartMapper;
import com.service.CartService;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import cn.hutool.core.util.ObjectUtil;
@Service
public class CartServiceImpl extends ServiceImpl<CartMapper, CartEntity> implements CartService {
    // 1. 检查商品是否已在购物车
    @Override
    public CartEntity getCartByUserAndGoods(Long userId, Long goodsId) {
        // MP条件查询:user_id = ? AND goods_id = ? AND is_deleted = 0
        QueryWrapper<CartEntity> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("user_id", userId)
                    .eq("goods_id", goodsId)
                    .eq("is_deleted", 0);  // 排除逻辑删除的记录
        return baseMapper.selectOne(queryWrapper);
    }
    // 2. 添加或更新购物车
    @Override
    public void addOrUpdateCart(CartEntity cart, Integer stock) {
        Long userId = cart.getUserId();
        Long goodsId = cart.getGoodsId();
        Integer quantity = cart.getQuantity();
        // 校验数量:不能小于1,且不能超过商品库存
        if (quantity < 1) {
            throw new RuntimeException("商品数量不能小于1!");
        }
        if (quantity > stock) {
            throw new RuntimeException("商品库存不足,当前库存:" + stock);
        }
        // 查询是否已存在该商品
        CartEntity existingCart = getCartByUserAndGoods(userId, goodsId);
        if (ObjectUtil.isNotNull(existingCart)) {
            // 已存在:更新数量(原数量 + 新增数量,且不超过库存)
            int newQuantity = existingCart.getQuantity() + quantity;
            if (newQuantity > stock) {
                throw new RuntimeException("购物车数量已达上限(库存:" + stock + "),无法继续添加!");
            }
            existingCart.setQuantity(newQuantity);
            baseMapper.updateById(existingCart);  // MP更新方法
        } else {
            // 不存在:新增购物车记录
            baseMapper.insert(cart);  // MP新增方法
        }
    }
}

3.1.4 4. Controller 层:CartController(接口入口)

路径:src/main/java/com/controller/CartController.java

作用:接收前端请求,调用 Service 处理,返回响应结果。

import com.entity.CartEntity;
import com.entity.GoodsEntity;
import com.service.CartService;
import com.service.GoodsService;
import com.utils.R;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import cn.hutool.core.util.ObjectUtil;
@RestController
@RequestMapping("/cart")
public class CartController {
    @Autowired
    private CartService cartService;
    @Autowired
    private GoodsService goodsService;  // 注入商品Service,用于校验商品合法性
    // JWT密钥(与Day10登录一致,建议配置在application.properties)
    private static final String JWT_SECRET = "mypet-secret-2024";
    /**
     * 购物车添加接口(需登录,Token验证)
     * @param cart 前端传递的购物车数据(userId、goodsId、quantity)
     * @param request 用于获取Token,验证用户身份
     */
    @PostMapping("/add")
    public R addCart(@RequestBody CartEntity cart, HttpServletRequest request) {
        try {
            //  从Token解析用户ID,验证身份(防止前端篡改userId)
            String token = request.getHeader("token");
            Claims claims = Jwts.parser().setSigningKey(JWT_SECRET).parseClaimsJws(token).getBody();
            Long loginUserId = Long.parseLong(claims.getSubject());  // Token中的真实用户ID
            // 校验前端传递的userId与Token解析的是否一致(防止越权)
            if (!loginUserId.equals(cart.getUserId())) {
                return R.error("身份验证失败,无法添加购物车!");
            }
            //  校验商品合法性(商品是否存在、是否上架、库存是否充足)
            Long goodsId = cart.getGoodsId();
            GoodsEntity goods = goodsService.getById(goodsId);
            if (ObjectUtil.isNull(goods)) {
                return R.error("该商品不存在或已下架!");
            }
            if (goods.getStatus() != 1) {  // 假设status=1表示上架,0表示下架
                return R.error("该商品已下架,无法添加购物车!");
            }
            Integer stock = goods.getStock();  // 商品当前库存
            // 调用Service添加或更新购物车
            cartService.addOrUpdateCart(cart, stock);
            //  判断是新增还是更新,返回对应提示
            CartEntity existingCart = cartService.getCartByUserAndGoods(loginUserId, goodsId);
            if (existingCart.getQuantity() == cart.getQuantity()) {
                return R.ok("商品已成功添加到购物车!");
            } else {
                return R.ok("购物车商品数量已更新,当前数量:" + existingCart.getQuantity());
            }
        } catch (RuntimeException e) {
            // 业务异常(如库存不足、数量非法),返回具体错误信息
            return R.error(e.getMessage());
        } catch (Exception e) {
            // 其他异常(如Token解析失败)
            e.printStackTrace();
            return R.error("添加购物车失败,请重试!");
        }
    }
}

📌 后端关键逻辑讲解

  • 身份验证:通过 Token 解析真实用户 ID,与前端传递的userId对比,防止恶意篡改 ID 添加他人购物车;
  • 商品校验:先查询商品是否存在、是否上架,再校验库存,避免添加无效或库存不足的商品;
  • 重复处理:用getCartByUserAndGoods查询是否已存在,存在则更新数量(且不超过库存),不存在则新增,符合电商购物车常规逻辑;
  • 异常处理:区分业务异常(如库存不足)和系统异常,返回清晰的错误提示,方便前端用户理解。

3.2 前端:商品详情页 + 购物车弹窗交互

路径:pages/goods/detail.vue

作用:展示商品详情,点击 “加入购物车” 弹出确认弹窗,调用后端接口完成添加。

<template>
  <view class="goods-detail-page">
    <!-- 商品基本信息(图片、名称、价格、库存) -->
    <view class="goods-info">
      <image :src="goodsInfo.imageUrl" mode="widthFix" class="goods-img"></image>
      <view class="goods-name">{{ goodsInfo.goodsName }}</view>
      <view class="goods-price">¥{{ goodsInfo.price.toFixed(2) }}</view>
      <view class="goods-stock" :class="goodsInfo.stock <= 10 ? 'stock-low' : ''">
        库存:{{ goodsInfo.stock }}件
        <text v-if="goodsInfo.stock <= 10">(库存紧张,欲购从速)</text>
      </view>
    </view>
    <!-- 数量选择器(控制添加数量) -->
    <view class="quantity-container">
      <view class="quantity-label">购买数量:</view>
      <view class="quantity-btn-group">
        <button 
          class="quantity-btn" 
          @click="decreaseQuantity" 
          :disabled="quantity <= 1 || isLoading"
        >-</button>
        <view class="quantity-value">{{ quantity }}</view>
        <button 
          class="quantity-btn" 
          @click="increaseQuantity" 
          :disabled="quantity >= goodsInfo.stock || isLoading"
        >+</button>
      </view>
    </view>
    <!-- 加入购物车按钮 -->
    <button 
      class="add-cart-btn" 
      @click="openCartPopup" 
      :disabled="goodsInfo.stock <= 0 || isLoading"
    >
      <uni-icons type="shopcart" size="24" color="#fff"></uni-icons>
      加入购物车
    </button>
    <!-- 确认添加购物车弹窗(uni-popup) -->
    <uni-popup ref="cartPopup" type="center" :mask="true" @close="closeCartPopup">
      <view class="popup-content">
        <view class="popup-title">确认添加到购物车</view>
        <!-- 弹窗内商品信息预览 -->
        <view class="popup-goods-info">
          <image :src="goodsInfo.imageUrl" mode="widthFix" class="popup-goods-img"></image>
          <view class="popup-goods-desc">
            <view class="popup-goods-name">{{ goodsInfo.goodsName }}</view>
            <view class="popup-goods-price">¥{{ goodsInfo.price.toFixed(2) }}</view>
            <view class="popup-quantity">数量:{{ quantity }}件</view>
          </view>
        </view>
        <!-- 弹窗按钮组 -->
        <view class="popup-btn-group">
          <button 
            class="popup-cancel-btn" 
            @click="closeCartPopup" 
            :disabled="isLoading"
          >取消</button>
          <button 
            class="popup-confirm-btn" 
            type="primary" 
            @click="confirmAddCart" 
            :disabled="isLoading"
          >
            {{ isLoading ? '提交中...' : '确认添加' }}
          </button>
        </view>
      </view>
    </uni-popup>
  </view>
</template>
<script>
import request from '@/api/request.js';
import uniPopup from '@dcloudio/uni-ui/lib/uni-popup/uni-popup';
import uniIcons from '@dcloudio/uni-ui/lib/uni-icons/uni-icons';
export default {
  components: {
    uniPopup,
    uniIcons
  },
  data() {
    return {
      goodsId: '',          // 当前商品ID(从路由参数获取)
      goodsInfo: {          // 商品详情数据
        goodsName: '',      // 商品名称
        price: 0,           // 商品价格
        stock: 0,           // 商品库存
        imageUrl: '',       // 商品图片URL
        status: 0           // 商品状态(1-上架,0-下架)
      },
      quantity: 1,          // 默认添加数量
      isLoading: false,     // 是否正在加载/提交(防止重复操作)
      cartPopup: null       // 弹窗组件引用
    };
  },
  onLoad(options) {
    // 页面加载:从路由参数获取商品ID,加载商品详情
    this.goodsId = options.id;
    this.getGoodsDetail();
    // 获取弹窗组件引用
    this.$nextTick(() => {
      this.cartPopup = this.$refs.cartPopup;
    });
  },
  methods: {
    // 1. 加载商品详情(调用后端接口)
    getGoodsDetail() {
      this.isLoading = true;
      request.get(`/goods/detail?id=${this.goodsId}`)
        .then(res => {
          if (res.data.code === 0) {
            this.goodsInfo = res.data.data;
          } else {
            uni.showToast({ title: res.data.msg, icon: 'none' });
            // 商品不存在,返回上一页
            setTimeout(() => {
              uni.navigateBack({ delta: 1 });
            }, 1500);
          }
        })
        .catch(err => {
          uni.showToast({ title: '加载商品详情失败', icon: 'none' });
          console.error('商品详情加载失败:', err);
        })
        .finally(() => {
          this.isLoading = false;
        });
    },
    // 2. 减少数量(数量不小于1)
    decreaseQuantity() {
      if (this.quantity > 1) {
        this.quantity--;
      }
    },
    // 3. 增加数量(数量不超过库存)
    increaseQuantity() {
      if (this.quantity < this.goodsInfo.stock) {
        this.quantity++;
      }
    },
    // 4. 打开购物车弹窗(先检查登录状态)
    openCartPopup() {
      // 检查是否已登录(Token是否存在)
      const token = uni.getStorageSync('mypet_token');
      const userId = uni.getStorageSync('mypet_userId');
      if (!token || !userId) {
        uni.showModal({
          title: '提示',
          content: '请先登录,再添加购物车',
          confirmText: '去登录',
          success: (modalRes) => {
            if (modalRes.confirm) {
              // 跳转登录页,登录后返回当前页
              uni.navigateTo({ url: '/pages/login/login?redirect=' + encodeURIComponent('/pages/goods/detail?id=' + this.goodsId) });
            }
          }
        });
        return;
      }
      // 已登录,打开弹窗
      this.cartPopup.open();
    },
    // 5. 关闭购物车弹窗
    closeCartPopup() {
      this.cartPopup.close();
    },
    // 6. 确认添加购物车(调用后端接口)
    confirmAddCart() {
      // 防止重复提交
      if (this.isLoading) return;
      this.isLoading = true;
      // 构造请求参数
      const cartData = {
        userId: uni.getStorageSync('mypet_userId'),  // 用户ID
        goodsId: this.goodsId,                       // 商品ID
        quantity: this.quantity                      // 添加数量
      };
      // 调用后端添加购物车接口
      request.post('/cart/add', cartData, {
        headers: { 'token': uni.getStorageSync('mypet_token') }  // 携带Token
      })
        .then(res => {
          if (res.data.code === 0) {
            // 添加成功:提示+关闭弹窗+更新购物车图标(可选)
            uni.showToast({ title: res.data.msg, icon: 'success' });
            this.closeCartPopup();
            // 通知购物车页面更新数据(可选,用全局事件)
            uni.$emit('cartUpdate');
          } else {
            // 添加失败:提示错误信息
            uni.showToast({ title: res.data.msg, icon: 'none' });
          }
        })
        .catch(err => {
          uni.showToast({ title: '添加失败,请重试', icon: 'none' });
          console.error('添加购物车失败:', err);
        })
        .finally(() => {
          // 无论成功失败,都恢复加载状态
          this.isLoading = false;
        });
    }
  }
};
</script>
<style scoped>
/* 页面整体样式 */
.goods-detail-page {
  padding: 20rpx;
  background-color: #f5f5f5;
  min-height: 100vh;
}
/* 商品信息样式 */
.goods-info {
  margin-bottom: 30rpx;
}
.goods-img {
  width: 100%;
  border-radius: 16rpx;
  margin-bottom: 20rpx;
}
.goods-name {
  font-size: 32rpx;
  font-weight: bold;
  color: #333;
  margin-bottom: 16rpx;
  line-height: 1.4;
}
.goods-price {
  font-size: 36rpx;
  color: #f53f3f;
  margin-bottom: 16rpx;
}
.goods-stock {
  font-size: 24rpx;
  color: #666;
}
.stock-low {
  color: #ff7d00;
}
/* 数量选择器样式 */
.quantity-container {
  display: flex;
  align-items: center;
  margin-bottom: 40rpx;
  padding: 20rpx;
  background-color: #fff;
  border-radius: 16rpx;
}
.quantity-label {
  font-size: 28rpx;
  color: #333;
  margin-right: 20rpx;
}
.quantity-btn-group {
  display: flex;
  align-items: center;
}
.quantity-btn {
  width: 60rpx;
  height: 60rpx;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: #f5f5f5;
  border: 1px solid #eee;
  border-radius: 8rpx;
  font-size: 32rpx;
}
.quantity-btn:disabled {
  background-color: #eee;
  color: #ccc;
}
.quantity-value {
  width: 80rpx;
  height: 60rpx;
  line-height: 60rpx;
  text-align: center;
  font-size: 28rpx;
  border-top: 1px solid #eee;
  border-bottom: 1px solid #eee;
}
/* 加入购物车按钮样式 */
.add-cart-btn {
  width: 100%;
  padding: 24rpx 0;
  background-color: #007aff;
  color: #fff;
  font-size: 30rpx;
  border-radius: 16rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12rpx;
}
.add-cart-btn:disabled {
  background-color: #8cc5ff;
}
/* 弹窗样式 */
.popup-content {
  width: 80%;
  padding: 40rpx 30rpx;
  background-color: #fff;
  border-radius: 20rpx;
}
.popup-title {
  font-size: 32rpx;
  font-weight: bold;
  color: #333;
  text-align: center;
  margin-bottom: 30rpx;
}
/* 弹窗内商品信息 */
.popup-goods-info {
  display: flex;
  gap: 20rpx;
  margin-bottom: 40rpx;
}
.popup-goods-img {
  width: 140rpx;
  height: 140rpx;
  border-radius: 8rpx;
  object-fit: cover;
}
.popup-goods-desc {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
}
.popup-goods-name {
  font-size: 28rpx;
  color: #333;
  margin-bottom: 8rpx;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.popup-goods-price {
  font-size: 26rpx;
  color: #f53f3f;
  margin-bottom: 8rpx;
}
.popup-quantity {
  font-size: 24rpx;
  color: #666;
}
/* 弹窗按钮组 */
.popup-btn-group {
  display: flex;
  gap: 20rpx;
}
.popup-cancel-btn, .popup-confirm-btn {
  flex: 1;
  padding: 20rpx 0;
  font-size: 28rpx;
  border-radius: 10rpx;
}
.popup-cancel-btn {
  background-color: #f5f5f5;
  color: #333;
}
.popup-confirm-btn {
  background-color: #007aff;
  color: #fff;
}
</style>

📌 前端关键交互讲解

  1. 登录检查:点击 “加入购物车” 前先检查 Token,未登录则提示并跳转登录页,同时携带当前页面路由(登录后可返回);
  1. 数量控制:通过 “-/+” 按钮调整数量,限制最小 1、最大不超过库存,避免无效数量提交;
  1. 弹窗交互:弹窗内预览商品信息和选择的数量,确认后再调用接口,减少误操作;
  1. 重复提交防止:用isLoading状态禁用按钮,避免用户多次点击导致重复请求;
  1. 反馈提示:添加成功 / 失败均有 Toast 提示,成功后还会通知购物车页面更新数据(通过uni.$emit全局事件)。

四、效果验证

按以下步骤验证 “添加购物车” 功能是否正常:

✅ 1. 后端接口测试(Postman)

  1. 请求地址http://localhost:8080/cart/add
  1. 请求方式:POST
  1. 请求头:token: 有效用户Token
  1. 请求体(JSON)
{
  "userId": 1,
  "goodsId": 1,
  "quantity": 2
}
  1. 首次添加成功返回
{
  "code": 0,
  "msg": "商品已成功添加到购物车!"
}
  1. 重复添加返回(再次发送相同请求):
{
  "code": 0,
  "msg": "购物车商品数量已更新,当前数量:4"
}
  1. 数据库验证:查询cart表,user_id=1、goods_id=1的记录数量为 4,与返回一致。

✅ 2. 前端功能测试(Uni-App 模拟器 / 真机)

  1. 加载商品详情:跳转至pages/goods/detail?id=1,页面显示 “宠物狗粮 10kg” 的图片、价格、库存;
  1. 未登录测试:清除 Token 后点击 “加入购物车”→弹出 “请先登录” 提示→点击 “去登录” 跳转登录页;
  1. 已登录测试
    • 调整数量:点击 “+” 将数量改为 2;
    • 打开弹窗:点击 “加入购物车”→弹窗显示商品信息和数量 2;
    • 确认添加:点击 “确认添加”→弹出 “添加成功” 提示→弹窗关闭;
  1. 重复添加测试:再次打开弹窗确认添加→提示 “数量已更新”,购物车数量变为 4;
  1. 库存不足测试:将数量调整为 101(超过库存 100)→“+” 按钮禁用,无法提交。

五、常见问题与排查

问题现象可能原因解决方式
1. 未登录也能添加购物车后端未验证 Token;或前端未检查登录状态直接调用接口1. 确保后端CartController中 Token 解析逻辑正常;2. 前端openCartPopup方法先检查 Token 再打开弹窗;3. 给/cart/add接口添加拦截器,未登录则拦截
2. 重复添加未更新数量后端getCartByUserAndGoods查询条件错误;或未排除逻辑删除1. 检查queryWrapper是否包含eq("is_deleted", 0);2. 确认userId和goodsId传递正确;3. 打印existingCart,确认查询到重复记录
3. 弹窗不显示1. 未注册uni-popup组件;2. 组件ref引用错误;3. 弹窗类型配置错误1. 在components中注册uniPopup;2. 确保ref="cartPopup"与组件引用一致;3. 检查uni-popup的type="center"配置正确
4. 提示 “商品库存不足” 但实际有库存前端goodsInfo.stock数据过时;或后端goods表库存字段错误1. 刷新商品详情页,重新加载最新库存;2. 检查后端GoodsEntity的stock字段是否与数据库对应;3. 打印stock值,确认前后端库存一致
5. 接口返回 “身份验证失败”前端userId与 Token 解析的loginUserId不一致1. 确保前端userId从uni.getStorageSync('mypet_userId')获取;2. 检查 Token 解析的subject是否为用户 ID;3. 打印两个 ID,确认一致

六、扩展与提升

6.1 功能扩展:购物车数量实时更新

当前前端成功添加后需手动刷新购物车页面,可通过全局事件优化:

  1. 在购物车列表页(如pages/cart/index.vue)监听cartUpdate事件:
onLoad() {
  // 监听购物车更新事件
  this.cartUpdateListener = uni.$on('cartUpdate', () => {
    this.getCartList(); // 重新加载购物车列表
  });
},
onUnload() {
  // 页面卸载时移除监听,避免内存泄漏
  uni.$off('cartUpdate', this.cartUpdateListener);
}

6.2 性能优化:购物车数据缓存

前端可将购物车数据缓存到本地,减少接口请求:

  1. 添加成功后,同时更新本地缓存的购物车数据;
  1. 购物车列表页优先读取本地缓存,再异步请求最新数据,提升加载速度。

6.3 安全优化:接口防刷

后端可添加接口限流,防止恶意多次调用:

  1. 使用 Spring Cloud Gateway 或自定义拦截器,限制/cart/add接口每分钟最多请求 5 次;
  1. 结合用户 ID 限流,避免单用户恶意刷量。

七、课堂互动

🙋‍♂️ 思考题:

  1. 如果用户添加购物车后,商品价格发生变化,购物车中的价格是否需要同步更新?如何实现?
  1. 如何实现 “购物车商品过期清理”(如商品下架后自动从购物车移除)?

💡 互动引导:你的 “添加购物车” 功能是否正常运行?如果遇到 “弹窗不居中”“数量不更新” 等问题,欢迎分享你的代码片段(如弹窗配置、接口请求部分),我们一起排查!

八、下节预告

👉 明天 Day16:购物车列表与删除!我们将学习:

  1. 实现购物车列表分页加载(复用 MP 分页逻辑);
  1. 开发购物车商品删除功能(单条删除、批量删除);
  1. 前端购物车数量编辑(实时同步后端);
  1. 购物车空状态处理与 “去购物” 引导。

记得提前复习 MP 的remove方法和 Uni-App 的列表渲染哦!