大家好!今天是学习路线的第 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>
📌 前端关键交互讲解:
- 登录检查:点击 “加入购物车” 前先检查 Token,未登录则提示并跳转登录页,同时携带当前页面路由(登录后可返回);
- 数量控制:通过 “-/+” 按钮调整数量,限制最小 1、最大不超过库存,避免无效数量提交;
- 弹窗交互:弹窗内预览商品信息和选择的数量,确认后再调用接口,减少误操作;
- 重复提交防止:用isLoading状态禁用按钮,避免用户多次点击导致重复请求;
- 反馈提示:添加成功 / 失败均有 Toast 提示,成功后还会通知购物车页面更新数据(通过uni.$emit全局事件)。
四、效果验证
按以下步骤验证 “添加购物车” 功能是否正常:
✅ 1. 后端接口测试(Postman)
- 请求方式:POST
- 请求头:token: 有效用户Token
- 请求体(JSON) :
{
"userId": 1,
"goodsId": 1,
"quantity": 2
}
- 首次添加成功返回:
{
"code": 0,
"msg": "商品已成功添加到购物车!"
}
- 重复添加返回(再次发送相同请求):
{
"code": 0,
"msg": "购物车商品数量已更新,当前数量:4"
}
- 数据库验证:查询cart表,user_id=1、goods_id=1的记录数量为 4,与返回一致。
✅ 2. 前端功能测试(Uni-App 模拟器 / 真机)
- 加载商品详情:跳转至pages/goods/detail?id=1,页面显示 “宠物狗粮 10kg” 的图片、价格、库存;
- 未登录测试:清除 Token 后点击 “加入购物车”→弹出 “请先登录” 提示→点击 “去登录” 跳转登录页;
- 已登录测试:
-
- 调整数量:点击 “+” 将数量改为 2;
-
- 打开弹窗:点击 “加入购物车”→弹窗显示商品信息和数量 2;
-
- 确认添加:点击 “确认添加”→弹出 “添加成功” 提示→弹窗关闭;
- 重复添加测试:再次打开弹窗确认添加→提示 “数量已更新”,购物车数量变为 4;
- 库存不足测试:将数量调整为 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 功能扩展:购物车数量实时更新
当前前端成功添加后需手动刷新购物车页面,可通过全局事件优化:
- 在购物车列表页(如pages/cart/index.vue)监听cartUpdate事件:
onLoad() {
// 监听购物车更新事件
this.cartUpdateListener = uni.$on('cartUpdate', () => {
this.getCartList(); // 重新加载购物车列表
});
},
onUnload() {
// 页面卸载时移除监听,避免内存泄漏
uni.$off('cartUpdate', this.cartUpdateListener);
}
6.2 性能优化:购物车数据缓存
前端可将购物车数据缓存到本地,减少接口请求:
- 添加成功后,同时更新本地缓存的购物车数据;
- 购物车列表页优先读取本地缓存,再异步请求最新数据,提升加载速度。
6.3 安全优化:接口防刷
后端可添加接口限流,防止恶意多次调用:
- 使用 Spring Cloud Gateway 或自定义拦截器,限制/cart/add接口每分钟最多请求 5 次;
- 结合用户 ID 限流,避免单用户恶意刷量。
七、课堂互动
🙋♂️ 思考题:
- 如果用户添加购物车后,商品价格发生变化,购物车中的价格是否需要同步更新?如何实现?
- 如何实现 “购物车商品过期清理”(如商品下架后自动从购物车移除)?
💡 互动引导:你的 “添加购物车” 功能是否正常运行?如果遇到 “弹窗不居中”“数量不更新” 等问题,欢迎分享你的代码片段(如弹窗配置、接口请求部分),我们一起排查!
八、下节预告
👉 明天 Day16:购物车列表与删除!我们将学习:
- 实现购物车列表分页加载(复用 MP 分页逻辑);
- 开发购物车商品删除功能(单条删除、批量删除);
- 前端购物车数量编辑(实时同步后端);
- 购物车空状态处理与 “去购物” 引导。
记得提前复习 MP 的remove方法和 Uni-App 的列表渲染哦!