feat: 实现用户地址管理和购物车功能

- 新增收货地址模块,包括地址的增删改查、设置默认地址等功能
- 实现购物车模块,支持添加商品、修改数量、选中状态管理、批量操作等
- 添加相应的DTO、Entity、VO和Mapper层实现
- 集成SaToken登录验证和数据权限控制
- 实现地址数量限制和购物车商品数量限制防刷单机制
```
This commit is contained in:
Yuhang Wu 2026-06-02 17:33:40 +08:00
parent 1595dc8c35
commit e5ac034349
16 changed files with 1227 additions and 0 deletions

View File

@ -0,0 +1,79 @@
package com.snack.server.module.address.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import com.snack.server.common.Result;
import com.snack.server.module.address.dto.req.AddressSaveReq;
import com.snack.server.module.address.service.AddressService;
import com.snack.server.module.address.vo.AddressVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 收货地址用户端
*/
@Tag(name = "收货地址")
@RestController
@RequestMapping("/api/address")
@RequiredArgsConstructor
public class AddressController {
private final AddressService addressService;
@Operation(summary = "获取当前用户的所有地址")
@SaCheckLogin
@GetMapping
public Result<List<AddressVO>> list() {
return Result.ok(addressService.listByCurrentUser());
}
@Operation(summary = "获取默认地址")
@SaCheckLogin
@GetMapping("/default")
public Result<AddressVO> getDefault() {
return Result.ok(addressService.getDefault());
}
@Operation(summary = "获取地址详情")
@SaCheckLogin
@GetMapping("/{id}")
public Result<AddressVO> detail(@Parameter(description = "地址 ID") @PathVariable Long id) {
return Result.ok(addressService.getById(id));
}
@Operation(summary = "新增地址")
@SaCheckLogin
@PostMapping
public Result<Long> create(@Valid @RequestBody AddressSaveReq req) {
return Result.ok(addressService.create(req));
}
@Operation(summary = "更新地址")
@SaCheckLogin
@PutMapping
public Result<Void> update(@Valid @RequestBody AddressSaveReq req) {
addressService.update(req);
return Result.ok();
}
@Operation(summary = "删除地址")
@SaCheckLogin
@DeleteMapping("/{id}")
public Result<Void> delete(@Parameter(description = "地址 ID") @PathVariable Long id) {
addressService.delete(id);
return Result.ok();
}
@Operation(summary = "设为默认地址")
@SaCheckLogin
@PutMapping("/{id}/default")
public Result<Void> setDefault(@Parameter(description = "地址 ID") @PathVariable Long id) {
addressService.setDefault(id);
return Result.ok();
}
}

View File

@ -0,0 +1,51 @@
package com.snack.server.module.address.dto.req;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.io.Serializable;
/**
* 新增/更新地址请求
*/
@Data
@Schema(description = "收货地址请求")
public class AddressSaveReq implements Serializable {
@Schema(description = "地址 ID更新时必填")
private Long id;
@NotBlank(message = "收货人姓名不能为空")
@Size(max = 50, message = "姓名最长 50 字")
@Schema(description = "收货人姓名", example = "张三")
private String receiver;
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
@Schema(description = "手机号", example = "13800138000")
private String phone;
@Schema(description = "", example = "广东省")
private String province;
@Schema(description = "", example = "深圳市")
private String city;
@Schema(description = "区/县", example = "南山区")
private String district;
@NotBlank(message = "详细地址不能为空")
@Size(max = 255, message = "详细地址最长 255 字")
@Schema(description = "详细地址", example = "科技园南路 88 号")
private String detail;
@Size(max = 20, message = "标签最长 20 字")
@Schema(description = "标签:家/公司/学校")
private String tag;
@Schema(description = "是否设为默认0-否 1-是", example = "0")
private Integer isDefault = 0;
}

View File

@ -0,0 +1,57 @@
package com.snack.server.module.address.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 收货地址
*
* 对应数据库表address
*/
@Data
@TableName("address")
public class Address implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 用户 ID */
private Long userId;
/** 收货人姓名 */
private String receiver;
/** 收货人手机号 */
private String phone;
/** 省 */
private String province;
/** 市 */
private String city;
/** 区/县 */
private String district;
/** 详细地址 */
private String detail;
/** 标签:家/公司/学校 */
private String tag;
/** 是否默认地址0-否 1-是 */
private Integer isDefault;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}

View File

@ -0,0 +1,20 @@
package com.snack.server.module.address.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.snack.server.module.address.entity.Address;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
/**
* 收货地址 Mapper
*/
@Mapper
public interface AddressMapper extends BaseMapper<Address> {
/**
* 取消该用户所有地址的默认状态
*/
@Update("UPDATE address SET is_default = 0 WHERE user_id = #{userId}")
int clearDefaultByUserId(@Param("userId") Long userId);
}

View File

@ -0,0 +1,53 @@
package com.snack.server.module.address.service;
import com.snack.server.module.address.dto.req.AddressSaveReq;
import com.snack.server.module.address.entity.Address;
import com.snack.server.module.address.vo.AddressVO;
import java.util.List;
/**
* 收货地址业务接口
*/
public interface AddressService {
/**
* 获取当前登录用户的所有地址
*/
List<AddressVO> listByCurrentUser();
/**
* 获取默认地址
*/
AddressVO getDefault();
/**
* 获取地址详情
*/
AddressVO getById(Long id);
/**
* 创建地址
*/
Long create(AddressSaveReq req);
/**
* 更新地址
*/
void update(AddressSaveReq req);
/**
* 删除地址
*/
void delete(Long id);
/**
* 设为默认地址
*/
void setDefault(Long id);
/**
* 内部使用根据 ID 获取实体订单快照
*/
Address getEntityById(Long id);
}

View File

@ -0,0 +1,218 @@
package com.snack.server.module.address.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.snack.server.common.ResultCode;
import com.snack.server.exception.BusinessException;
import com.snack.server.module.address.dto.req.AddressSaveReq;
import com.snack.server.module.address.entity.Address;
import com.snack.server.module.address.mapper.AddressMapper;
import com.snack.server.module.address.service.AddressService;
import com.snack.server.module.address.vo.AddressVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 收货地址业务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AddressServiceImpl implements AddressService {
/** 单用户最大地址数 */
private static final int MAX_ADDRESS_PER_USER = 20;
private final AddressMapper addressMapper;
// ==================== 查询 ====================
@Override
public List<AddressVO> listByCurrentUser() {
Long userId = StpUtil.getLoginIdAsLong();
List<Address> list = addressMapper.selectList(
new LambdaQueryWrapper<Address>()
.eq(Address::getUserId, userId)
.orderByDesc(Address::getIsDefault)
.orderByDesc(Address::getId)
);
return list.stream().map(this::toVO).toList();
}
@Override
public AddressVO getDefault() {
Long userId = StpUtil.getLoginIdAsLong();
Address addr = addressMapper.selectOne(
new LambdaQueryWrapper<Address>()
.eq(Address::getUserId, userId)
.eq(Address::getIsDefault, 1)
.last("LIMIT 1")
);
return addr == null ? null : toVO(addr);
}
@Override
public AddressVO getById(Long id) {
Long userId = StpUtil.getLoginIdAsLong();
Address addr = addressMapper.selectOne(
new LambdaQueryWrapper<Address>()
.eq(Address::getId, id)
.eq(Address::getUserId, userId) // 只能看自己的
);
if (addr == null) {
throw new BusinessException(1000, "地址不存在或无权访问");
}
return toVO(addr);
}
@Override
public Address getEntityById(Long id) {
return addressMapper.selectById(id);
}
// ==================== 创建 ====================
@Override
@Transactional(rollbackFor = Exception.class)
public Long create(AddressSaveReq req) {
Long userId = StpUtil.getLoginIdAsLong();
// 1. 单用户地址数量限制
Long count = addressMapper.selectCount(
new LambdaQueryWrapper<Address>().eq(Address::getUserId, userId)
);
if (count >= MAX_ADDRESS_PER_USER) {
throw new BusinessException(1000, "地址数量已达上限(" + MAX_ADDRESS_PER_USER + "");
}
// 2. 保存
Address address = new Address();
BeanUtil.copyProperties(req, address, "id");
address.setUserId(userId);
address.setIsDefault(req.getIsDefault() == null ? 0 : req.getIsDefault());
// 3. 默认地址处理
if (address.getIsDefault() == 1) {
addressMapper.clearDefaultByUserId(userId);
} else {
// 若用户没有地址自动设为默认
if (count == 0) {
address.setIsDefault(1);
}
}
addressMapper.insert(address);
log.info("用户 {} 新增地址 id={}", userId, address.getId());
return address.getId();
}
// ==================== 更新 ====================
@Override
@Transactional(rollbackFor = Exception.class)
public void update(AddressSaveReq req) {
if (req.getId() == null) {
throw new BusinessException(1000, "ID 不能为空");
}
Long userId = StpUtil.getLoginIdAsLong();
Address existing = addressMapper.selectOne(
new LambdaQueryWrapper<Address>()
.eq(Address::getId, req.getId())
.eq(Address::getUserId, userId)
);
if (existing == null) {
throw new BusinessException(1000, "地址不存在或无权访问");
}
// 默认地址处理
Integer isDefault = req.getIsDefault() == null ? 0 : req.getIsDefault();
if (isDefault == 1 && existing.getIsDefault() == 0) {
addressMapper.clearDefaultByUserId(userId);
}
Address address = new Address();
BeanUtil.copyProperties(req, address);
address.setUserId(userId); // 防止被改
address.setIsDefault(isDefault);
addressMapper.updateById(address);
log.info("更新地址 id={}", address.getId());
}
// ==================== 删除 ====================
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
Long userId = StpUtil.getLoginIdAsLong();
Address existing = addressMapper.selectOne(
new LambdaQueryWrapper<Address>()
.eq(Address::getId, id)
.eq(Address::getUserId, userId)
);
if (existing == null) {
throw new BusinessException(1000, "地址不存在或无权访问");
}
addressMapper.deleteById(id);
// 若删除的是默认地址自动将剩余地址中最近的一个设为默认
if (existing.getIsDefault() != null && existing.getIsDefault() == 1) {
Address next = addressMapper.selectOne(
new LambdaQueryWrapper<Address>()
.eq(Address::getUserId, userId)
.orderByDesc(Address::getId)
.last("LIMIT 1")
);
if (next != null) {
next.setIsDefault(1);
addressMapper.updateById(next);
}
}
log.info("用户 {} 删除地址 id={}", userId, id);
}
// ==================== 设为默认 ====================
@Override
@Transactional(rollbackFor = Exception.class)
public void setDefault(Long id) {
Long userId = StpUtil.getLoginIdAsLong();
Address existing = addressMapper.selectOne(
new LambdaQueryWrapper<Address>()
.eq(Address::getId, id)
.eq(Address::getUserId, userId)
);
if (existing == null) {
throw new BusinessException(1000, "地址不存在或无权访问");
}
if (existing.getIsDefault() != null && existing.getIsDefault() == 1) {
return; // 已经是默认
}
// 先清掉其他默认再设自己
addressMapper.clearDefaultByUserId(userId);
existing.setIsDefault(1);
addressMapper.updateById(existing);
log.info("用户 {} 将地址 id={} 设为默认", userId, id);
}
// ==================== 私有方法 ====================
private AddressVO toVO(Address a) {
AddressVO vo = new AddressVO();
BeanUtil.copyProperties(a, vo);
// 拼接完整地址
StringBuilder sb = new StringBuilder();
if (StrUtil.isNotBlank(a.getProvince())) sb.append(a.getProvince()).append(" ");
if (StrUtil.isNotBlank(a.getCity())) sb.append(a.getCity()).append(" ");
if (StrUtil.isNotBlank(a.getDistrict())) sb.append(a.getDistrict()).append(" ");
if (StrUtil.isNotBlank(a.getDetail())) sb.append(a.getDetail());
vo.setFullAddress(sb.toString().trim());
return vo;
}
}

View File

@ -0,0 +1,51 @@
package com.snack.server.module.address.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 收货地址 VO
*/
@Data
@Schema(description = "收货地址")
public class AddressVO implements Serializable {
@Schema(description = "地址 ID")
private Long id;
@Schema(description = "收货人姓名")
private String receiver;
@Schema(description = "手机号")
private String phone;
@Schema(description = "")
private String province;
@Schema(description = "")
private String city;
@Schema(description = "区/县")
private String district;
@Schema(description = "详细地址")
private String detail;
@Schema(description = "标签")
private String tag;
@Schema(description = "是否默认0-否 1-是")
private Integer isDefault;
/** 拼接的完整地址(省市区+详细),供订单快照使用 */
@Schema(description = "完整地址")
private String fullAddress;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Schema(description = "创建时间")
private LocalDateTime createTime;
}

View File

@ -0,0 +1,102 @@
package com.snack.server.module.cart.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import com.snack.server.common.Result;
import com.snack.server.module.cart.dto.req.CartAddReq;
import com.snack.server.module.cart.dto.req.CartUpdateReq;
import com.snack.server.module.cart.service.CartService;
import com.snack.server.module.cart.vo.CartVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 购物车用户端
*/
@Tag(name = "购物车")
@RestController
@RequestMapping("/api/cart")
@RequiredArgsConstructor
public class CartController {
private final CartService cartService;
@Operation(summary = "获取购物车")
@SaCheckLogin
@GetMapping
public Result<CartVO> list() {
return Result.ok(cartService.getCurrentCart());
}
@Operation(summary = "添加商品到购物车")
@SaCheckLogin
@PostMapping
public Result<Void> add(@Valid @RequestBody CartAddReq req) {
cartService.addItem(req);
return Result.ok();
}
@Operation(summary = "修改购物车商品数量")
@SaCheckLogin
@PutMapping("/quantity")
public Result<Void> updateQuantity(@Valid @RequestBody CartUpdateReq req) {
cartService.updateQuantity(req);
return Result.ok();
}
@Operation(summary = "切换选中状态")
@SaCheckLogin
@PutMapping("/{skuId}/selected")
public Result<Void> updateSelected(
@Parameter(description = "SKU ID") @PathVariable Long skuId,
@Parameter(description = "0-未选 1-已选") @RequestParam Integer selected) {
cartService.updateSelected(skuId, selected);
return Result.ok();
}
@Operation(summary = "全选 / 全不选")
@SaCheckLogin
@PutMapping("/all-selected")
public Result<Void> updateAllSelected(
@Parameter(description = "0-全不选 1-全选") @RequestParam Integer selected) {
cartService.updateAllSelected(selected);
return Result.ok();
}
@Operation(summary = "删除购物车项")
@SaCheckLogin
@DeleteMapping("/{skuId}")
public Result<Void> delete(@Parameter(description = "SKU ID") @PathVariable Long skuId) {
cartService.deleteItem(skuId);
return Result.ok();
}
@Operation(summary = "批量删除(结算页)")
@SaCheckLogin
@DeleteMapping
public Result<Void> batchDelete(@RequestBody List<Long> skuIds) {
cartService.deleteItems(skuIds);
return Result.ok();
}
@Operation(summary = "清空购物车")
@SaCheckLogin
@DeleteMapping("/all")
public Result<Void> clear() {
cartService.clear();
return Result.ok();
}
@Operation(summary = "购物车商品数量(用于角标)")
@SaCheckLogin
@GetMapping("/count")
public Result<Integer> count() {
Long userId = cn.dev33.satoken.stp.StpUtil.getLoginIdAsLong();
return Result.ok(cartService.countByUserId(userId));
}
}

View File

@ -0,0 +1,29 @@
package com.snack.server.module.cart.dto.req;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.io.Serializable;
/**
* 加入购物车请求
*/
@Data
@Schema(description = "加入购物车请求")
public class CartAddReq implements Serializable {
@NotNull(message = "商品 ID 不能为空")
@Schema(description = "商品 SPU ID")
private Long productId;
@NotNull(message = "SKU ID 不能为空")
@Schema(description = "商品 SKU ID")
private Long skuId;
@NotNull(message = "数量不能为空")
@Min(value = 1, message = "数量至少为 1")
@Schema(description = "数量", example = "1")
private Integer quantity = 1;
}

View File

@ -0,0 +1,25 @@
package com.snack.server.module.cart.dto.req;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.io.Serializable;
/**
* 修改购物车数量
*/
@Data
@Schema(description = "修改数量请求")
public class CartUpdateReq implements Serializable {
@NotNull(message = "SKU ID 不能为空")
@Schema(description = "SKU ID")
private Long skuId;
@NotNull(message = "数量不能为空")
@Min(value = 1, message = "数量至少为 1")
@Schema(description = "新的数量", example = "2")
private Integer quantity;
}

View File

@ -0,0 +1,46 @@
package com.snack.server.module.cart.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 购物车
*
* 对应数据库表cart
* 唯一约束(user_id, sku_id)
*/
@Data
@TableName("cart")
public class Cart implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 用户 ID */
private Long userId;
/** 商品 SPU ID */
private Long productId;
/** 商品 SKU ID */
private Long skuId;
/** 数量 */
private Integer quantity;
/** 选中状态0-未选 1-已选 */
private Integer selected;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}

View File

@ -0,0 +1,29 @@
package com.snack.server.module.cart.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.snack.server.module.cart.entity.Cart;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface CartMapper extends BaseMapper<Cart> {
/**
* 数量累加已存在记录时调用
*/
@Update("UPDATE cart SET quantity = quantity + #{delta}, selected = 1 " +
"WHERE user_id = #{userId} AND sku_id = #{skuId}")
int incrQuantity(@Param("userId") Long userId,
@Param("skuId") Long skuId,
@Param("delta") Integer delta);
/**
* 设置数量用户手动修改购物车数量
*/
@Update("UPDATE cart SET quantity = #{quantity} " +
"WHERE user_id = #{userId} AND sku_id = #{skuId}")
int updateQuantity(@Param("userId") Long userId,
@Param("skuId") Long skuId,
@Param("quantity") Integer quantity);
}

View File

@ -0,0 +1,70 @@
package com.snack.server.module.cart.service;
import com.snack.server.module.cart.dto.req.CartAddReq;
import com.snack.server.module.cart.dto.req.CartUpdateReq;
import com.snack.server.module.cart.entity.Cart;
import com.snack.server.module.cart.vo.CartVO;
import java.util.Collection;
import java.util.List;
/**
* 购物车业务接口
*/
public interface CartService {
/**
* 获取当前用户的购物车含统计
*/
CartVO getCurrentCart();
/**
* 添加商品到购物车已存在则累加数量
*/
void addItem(CartAddReq req);
/**
* 修改购物车某项数量
*/
void updateQuantity(CartUpdateReq req);
/**
* 切换选中状态
*/
void updateSelected(Long skuId, Integer selected);
/**
* 全选 / 全不选
*/
void updateAllSelected(Integer selected);
/**
* 删除购物车项
*/
void deleteItem(Long skuId);
/**
* 批量删除 SKU ID 列表
*/
void deleteItems(Collection<Long> skuIds);
/**
* 清空购物车
*/
void clear();
/**
* 内部获取当前用户已选中的购物车项订单提交时使用
*/
List<Cart> listSelectedItems();
/**
* 内部删除已选中的购物车项订单创建成功后调用
*/
void deleteSelectedItems();
/**
* 内部获取购物车项数量角标
*/
int countByUserId(Long userId);
}

View File

@ -0,0 +1,309 @@
package com.snack.server.module.cart.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.snack.server.common.ResultCode;
import com.snack.server.exception.BusinessException;
import com.snack.server.module.cart.dto.req.CartAddReq;
import com.snack.server.module.cart.dto.req.CartUpdateReq;
import com.snack.server.module.cart.entity.Cart;
import com.snack.server.module.cart.mapper.CartMapper;
import com.snack.server.module.cart.service.CartService;
import com.snack.server.module.cart.vo.CartItemVO;
import com.snack.server.module.cart.vo.CartVO;
import com.snack.server.module.product.entity.Product;
import com.snack.server.module.product.entity.ProductSku;
import com.snack.server.module.product.mapper.ProductMapper;
import com.snack.server.module.product.mapper.ProductSkuMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.*;
import java.util.stream.Collectors;
/**
* 购物车业务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CartServiceImpl implements CartService {
/** 购物车单 SKU 数量上限(防止恶意刷单) */
private static final int MAX_QTY_PER_ITEM = 99;
private final CartMapper cartMapper;
private final ProductMapper productMapper;
private final ProductSkuMapper productSkuMapper;
// ==================== 查询 ====================
@Override
public CartVO getCurrentCart() {
Long userId = StpUtil.getLoginIdAsLong();
List<Cart> carts = cartMapper.selectList(
new LambdaQueryWrapper<Cart>()
.eq(Cart::getUserId, userId)
.orderByDesc(Cart::getId)
);
if (CollUtil.isEmpty(carts)) {
return emptyCart();
}
return enrichCartVO(carts);
}
private CartVO emptyCart() {
CartVO vo = new CartVO();
vo.setItems(new ArrayList<>());
vo.setTotalCount(0);
vo.setSelectedCount(0);
vo.setTotalAmount(BigDecimal.ZERO);
vo.setSelectedAmount(BigDecimal.ZERO);
vo.setAllSelected(0);
return vo;
}
/**
* 装配购物车 VO含商品 / SKU 信息
*/
private CartVO enrichCartVO(List<Cart> carts) {
// 1. 批量查 SPU / SKU
Set<Long> productIds = carts.stream().map(Cart::getProductId).collect(Collectors.toSet());
Set<Long> skuIds = carts.stream().map(Cart::getSkuId).collect(Collectors.toSet());
Map<Long, Product> productMap = productMapper.selectBatchIds(productIds).stream()
.collect(Collectors.toMap(Product::getId, p -> p));
Map<Long, ProductSku> skuMap = productSkuMapper.selectBatchIds(skuIds).stream()
.collect(Collectors.toMap(ProductSku::getId, s -> s));
// 2. VO + 统计
int totalCount = 0;
int selectedCount = 0;
BigDecimal totalAmount = BigDecimal.ZERO;
BigDecimal selectedAmount = BigDecimal.ZERO;
int allSelected = 1;
List<CartItemVO> items = new ArrayList<>();
for (Cart c : carts) {
Product p = productMap.get(c.getProductId());
ProductSku s = skuMap.get(c.getSkuId());
if (p == null || s == null) {
// 商品 / SKU 已被删除跳过或保留用于用户清理
continue;
}
CartItemVO vo = new CartItemVO();
vo.setId(c.getId());
vo.setProductId(p.getId());
vo.setProductName(p.getName());
vo.setProductImage(StrUtil.blankToDefault(p.getMainImage(), s.getImage()));
vo.setSkuId(s.getId());
vo.setSkuName(s.getSkuName());
vo.setSkuImage(s.getImage());
vo.setPrice(s.getPrice());
vo.setQuantity(c.getQuantity());
vo.setSubtotal(s.getPrice().multiply(BigDecimal.valueOf(c.getQuantity())));
vo.setStock(s.getStock());
vo.setSelected(c.getSelected());
vo.setProductStatus(p.getStatus());
items.add(vo);
totalCount += c.getQuantity();
totalAmount = totalAmount.add(vo.getSubtotal());
if (c.getSelected() != null && c.getSelected() == 1) {
selectedCount += c.getQuantity();
selectedAmount = selectedAmount.add(vo.getSubtotal());
} else {
allSelected = 0;
}
}
// 如果购物车里没有未选中的项才算全选
if (items.stream().anyMatch(i -> i.getSelected() == 0)) {
allSelected = 0;
}
CartVO vo = new CartVO();
vo.setItems(items);
vo.setTotalCount(totalCount);
vo.setSelectedCount(selectedCount);
vo.setTotalAmount(totalAmount);
vo.setSelectedAmount(selectedAmount);
vo.setAllSelected(allSelected);
return vo;
}
// ==================== 添加 ====================
@Override
@Transactional(rollbackFor = Exception.class)
public void addItem(CartAddReq req) {
Long userId = StpUtil.getLoginIdAsLong();
// 1. 校验商品
Product product = productMapper.selectById(req.getProductId());
if (product == null) {
throw new BusinessException(ResultCode.PRODUCT_NOT_EXIST);
}
if (product.getStatus() == null || product.getStatus() == 0) {
throw new BusinessException(ResultCode.PRODUCT_OFF_SHELF);
}
ProductSku sku = productSkuMapper.selectById(req.getSkuId());
if (sku == null || !sku.getProductId().equals(req.getProductId())) {
throw new BusinessException(1000, "SKU 不存在或与商品不匹配");
}
if (sku.getStock() == null || sku.getStock() < req.getQuantity()) {
throw new BusinessException(ResultCode.STOCK_INSUFFICIENT);
}
// 2. 已存在则累加
Cart existing = cartMapper.selectOne(
new LambdaQueryWrapper<Cart>()
.eq(Cart::getUserId, userId)
.eq(Cart::getSkuId, req.getSkuId())
);
if (existing != null) {
int newQty = existing.getQuantity() + req.getQuantity();
if (newQty > MAX_QTY_PER_ITEM) {
throw new BusinessException(1000, "单 SKU 数量不能超过 " + MAX_QTY_PER_ITEM);
}
if (newQty > sku.getStock()) {
throw new BusinessException(ResultCode.STOCK_INSUFFICIENT);
}
cartMapper.updateQuantity(userId, req.getSkuId(), newQty);
log.info("用户 {} 累加购物车 skuId={} -> {}", userId, req.getSkuId(), newQty);
} else {
if (req.getQuantity() > MAX_QTY_PER_ITEM) {
throw new BusinessException(1000, "单 SKU 数量不能超过 " + MAX_QTY_PER_ITEM);
}
Cart cart = new Cart();
cart.setUserId(userId);
cart.setProductId(req.getProductId());
cart.setSkuId(req.getSkuId());
cart.setQuantity(req.getQuantity());
cart.setSelected(1); // 新加入默认选中
cartMapper.insert(cart);
log.info("用户 {} 加入购物车 skuId={} qty={}", userId, req.getSkuId(), req.getQuantity());
}
}
// ==================== 修改数量 ====================
@Override
public void updateQuantity(CartUpdateReq req) {
Long userId = StpUtil.getLoginIdAsLong();
Cart existing = cartMapper.selectOne(
new LambdaQueryWrapper<Cart>()
.eq(Cart::getUserId, userId)
.eq(Cart::getSkuId, req.getSkuId())
);
if (existing == null) {
throw new BusinessException(1000, "购物车中不存在该商品");
}
if (req.getQuantity() > MAX_QTY_PER_ITEM) {
throw new BusinessException(1000, "单 SKU 数量不能超过 " + MAX_QTY_PER_ITEM);
}
// 校验库存
ProductSku sku = productSkuMapper.selectById(req.getSkuId());
if (sku != null && sku.getStock() != null && req.getQuantity() > sku.getStock()) {
throw new BusinessException(ResultCode.STOCK_INSUFFICIENT);
}
cartMapper.updateQuantity(userId, req.getSkuId(), req.getQuantity());
}
// ==================== 选中状态 ====================
@Override
public void updateSelected(Long skuId, Integer selected) {
Long userId = StpUtil.getLoginIdAsLong();
Cart existing = cartMapper.selectOne(
new LambdaQueryWrapper<Cart>()
.eq(Cart::getUserId, userId)
.eq(Cart::getSkuId, skuId)
);
if (existing == null) {
throw new BusinessException(1000, "购物车中不存在该商品");
}
if (existing.getSelected() != null && existing.getSelected().equals(selected)) {
return;
}
existing.setSelected(selected);
cartMapper.updateById(existing);
}
@Override
public void updateAllSelected(Integer selected) {
Long userId = StpUtil.getLoginIdAsLong();
List<Cart> carts = cartMapper.selectList(
new LambdaQueryWrapper<Cart>().eq(Cart::getUserId, userId)
);
for (Cart c : carts) {
c.setSelected(selected);
cartMapper.updateById(c);
}
}
// ==================== 删除 ====================
@Override
public void deleteItem(Long skuId) {
Long userId = StpUtil.getLoginIdAsLong();
cartMapper.delete(
new LambdaQueryWrapper<Cart>()
.eq(Cart::getUserId, userId)
.eq(Cart::getSkuId, skuId)
);
}
@Override
public void deleteItems(Collection<Long> skuIds) {
if (CollUtil.isEmpty(skuIds)) return;
Long userId = StpUtil.getLoginIdAsLong();
cartMapper.delete(
new LambdaQueryWrapper<Cart>()
.eq(Cart::getUserId, userId)
.in(Cart::getSkuId, skuIds)
);
}
@Override
public void clear() {
Long userId = StpUtil.getLoginIdAsLong();
cartMapper.delete(new LambdaQueryWrapper<Cart>().eq(Cart::getUserId, userId));
}
// ==================== 内部 ====================
@Override
public List<Cart> listSelectedItems() {
Long userId = StpUtil.getLoginIdAsLong();
return cartMapper.selectList(
new LambdaQueryWrapper<Cart>()
.eq(Cart::getUserId, userId)
.eq(Cart::getSelected, 1)
);
}
@Override
public void deleteSelectedItems() {
Long userId = StpUtil.getLoginIdAsLong();
cartMapper.delete(
new LambdaQueryWrapper<Cart>()
.eq(Cart::getUserId, userId)
.eq(Cart::getSelected, 1)
);
}
@Override
public int countByUserId(Long userId) {
return cartMapper.selectCount(
new LambdaQueryWrapper<Cart>().eq(Cart::getUserId, userId)
).intValue();
}
}

View File

@ -0,0 +1,54 @@
package com.snack.server.module.cart.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 购物车项 VO含商品信息
*/
@Data
@Schema(description = "购物车项")
public class CartItemVO implements Serializable {
@Schema(description = "购物车记录 ID")
private Long id;
@Schema(description = "商品 SPU ID")
private Long productId;
@Schema(description = "商品名称")
private String productName;
@Schema(description = "商品主图")
private String productImage;
@Schema(description = "SKU ID")
private Long skuId;
@Schema(description = "SKU 规格名称")
private String skuName;
@Schema(description = "SKU 图片")
private String skuImage;
@Schema(description = "单价")
private BigDecimal price;
@Schema(description = "购买数量")
private Integer quantity;
@Schema(description = "小计金额")
private BigDecimal subtotal;
@Schema(description = "当前库存")
private Integer stock;
@Schema(description = "是否已选中")
private Integer selected;
@Schema(description = "商品状态0-下架 1-上架")
private Integer productStatus;
}

View File

@ -0,0 +1,34 @@
package com.snack.server.module.cart.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.List;
/**
* 购物车完整数据含统计
*/
@Data
@Schema(description = "购物车")
public class CartVO implements Serializable {
@Schema(description = "购物车项列表")
private List<CartItemVO> items;
@Schema(description = "商品总件数(含未选中)")
private Integer totalCount;
@Schema(description = "已选中商品件数")
private Integer selectedCount;
@Schema(description = "购物车总金额(含未选中)")
private BigDecimal totalAmount;
@Schema(description = "已选中商品总金额")
private BigDecimal selectedAmount;
@Schema(description = "全选状态0-未全选 1-全选")
private Integer allSelected;
}