From e5ac0343497f789556d4c1865b29e8517237d2bb Mon Sep 17 00:00:00 2001 From: Yuhang Wu Date: Tue, 2 Jun 2026 17:33:40 +0800 Subject: [PATCH] =?UTF-8?q?```=20feat:=20=E5=AE=9E=E7=8E=B0=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=9C=B0=E5=9D=80=E7=AE=A1=E7=90=86=E5=92=8C=E8=B4=AD?= =?UTF-8?q?=E7=89=A9=E8=BD=A6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增收货地址模块,包括地址的增删改查、设置默认地址等功能 - 实现购物车模块,支持添加商品、修改数量、选中状态管理、批量操作等 - 添加相应的DTO、Entity、VO和Mapper层实现 - 集成SaToken登录验证和数据权限控制 - 实现地址数量限制和购物车商品数量限制防刷单机制 ``` --- .../address/controller/AddressController.java | 79 +++++ .../address/dto/req/AddressSaveReq.java | 51 +++ .../server/module/address/entity/Address.java | 57 ++++ .../module/address/mapper/AddressMapper.java | 20 ++ .../address/service/AddressService.java | 53 +++ .../service/impl/AddressServiceImpl.java | 218 ++++++++++++ .../server/module/address/vo/AddressVO.java | 51 +++ .../cart/controller/CartController.java | 102 ++++++ .../module/cart/dto/req/CartAddReq.java | 29 ++ .../module/cart/dto/req/CartUpdateReq.java | 25 ++ .../snack/server/module/cart/entity/Cart.java | 46 +++ .../server/module/cart/mapper/CartMapper.java | 29 ++ .../module/cart/service/CartService.java | 70 ++++ .../cart/service/impl/CartServiceImpl.java | 309 ++++++++++++++++++ .../server/module/cart/vo/CartItemVO.java | 54 +++ .../snack/server/module/cart/vo/CartVO.java | 34 ++ 16 files changed, 1227 insertions(+) create mode 100644 server-snack/src/main/java/com/snack/server/module/address/controller/AddressController.java create mode 100644 server-snack/src/main/java/com/snack/server/module/address/dto/req/AddressSaveReq.java create mode 100644 server-snack/src/main/java/com/snack/server/module/address/entity/Address.java create mode 100644 server-snack/src/main/java/com/snack/server/module/address/mapper/AddressMapper.java create mode 100644 server-snack/src/main/java/com/snack/server/module/address/service/AddressService.java create mode 100644 server-snack/src/main/java/com/snack/server/module/address/service/impl/AddressServiceImpl.java create mode 100644 server-snack/src/main/java/com/snack/server/module/address/vo/AddressVO.java create mode 100644 server-snack/src/main/java/com/snack/server/module/cart/controller/CartController.java create mode 100644 server-snack/src/main/java/com/snack/server/module/cart/dto/req/CartAddReq.java create mode 100644 server-snack/src/main/java/com/snack/server/module/cart/dto/req/CartUpdateReq.java create mode 100644 server-snack/src/main/java/com/snack/server/module/cart/entity/Cart.java create mode 100644 server-snack/src/main/java/com/snack/server/module/cart/mapper/CartMapper.java create mode 100644 server-snack/src/main/java/com/snack/server/module/cart/service/CartService.java create mode 100644 server-snack/src/main/java/com/snack/server/module/cart/service/impl/CartServiceImpl.java create mode 100644 server-snack/src/main/java/com/snack/server/module/cart/vo/CartItemVO.java create mode 100644 server-snack/src/main/java/com/snack/server/module/cart/vo/CartVO.java diff --git a/server-snack/src/main/java/com/snack/server/module/address/controller/AddressController.java b/server-snack/src/main/java/com/snack/server/module/address/controller/AddressController.java new file mode 100644 index 0000000..cf4684c --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/address/controller/AddressController.java @@ -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() { + return Result.ok(addressService.listByCurrentUser()); + } + + @Operation(summary = "获取默认地址") + @SaCheckLogin + @GetMapping("/default") + public Result getDefault() { + return Result.ok(addressService.getDefault()); + } + + @Operation(summary = "获取地址详情") + @SaCheckLogin + @GetMapping("/{id}") + public Result detail(@Parameter(description = "地址 ID") @PathVariable Long id) { + return Result.ok(addressService.getById(id)); + } + + @Operation(summary = "新增地址") + @SaCheckLogin + @PostMapping + public Result create(@Valid @RequestBody AddressSaveReq req) { + return Result.ok(addressService.create(req)); + } + + @Operation(summary = "更新地址") + @SaCheckLogin + @PutMapping + public Result update(@Valid @RequestBody AddressSaveReq req) { + addressService.update(req); + return Result.ok(); + } + + @Operation(summary = "删除地址") + @SaCheckLogin + @DeleteMapping("/{id}") + public Result delete(@Parameter(description = "地址 ID") @PathVariable Long id) { + addressService.delete(id); + return Result.ok(); + } + + @Operation(summary = "设为默认地址") + @SaCheckLogin + @PutMapping("/{id}/default") + public Result setDefault(@Parameter(description = "地址 ID") @PathVariable Long id) { + addressService.setDefault(id); + return Result.ok(); + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/address/dto/req/AddressSaveReq.java b/server-snack/src/main/java/com/snack/server/module/address/dto/req/AddressSaveReq.java new file mode 100644 index 0000000..98ae0a8 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/address/dto/req/AddressSaveReq.java @@ -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; +} diff --git a/server-snack/src/main/java/com/snack/server/module/address/entity/Address.java b/server-snack/src/main/java/com/snack/server/module/address/entity/Address.java new file mode 100644 index 0000000..1bf0e4b --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/address/entity/Address.java @@ -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; +} diff --git a/server-snack/src/main/java/com/snack/server/module/address/mapper/AddressMapper.java b/server-snack/src/main/java/com/snack/server/module/address/mapper/AddressMapper.java new file mode 100644 index 0000000..86f2868 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/address/mapper/AddressMapper.java @@ -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
{ + + /** + * 取消该用户所有地址的默认状态 + */ + @Update("UPDATE address SET is_default = 0 WHERE user_id = #{userId}") + int clearDefaultByUserId(@Param("userId") Long userId); +} diff --git a/server-snack/src/main/java/com/snack/server/module/address/service/AddressService.java b/server-snack/src/main/java/com/snack/server/module/address/service/AddressService.java new file mode 100644 index 0000000..378b7f9 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/address/service/AddressService.java @@ -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 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); +} diff --git a/server-snack/src/main/java/com/snack/server/module/address/service/impl/AddressServiceImpl.java b/server-snack/src/main/java/com/snack/server/module/address/service/impl/AddressServiceImpl.java new file mode 100644 index 0000000..9687b81 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/address/service/impl/AddressServiceImpl.java @@ -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 listByCurrentUser() { + Long userId = StpUtil.getLoginIdAsLong(); + List
list = addressMapper.selectList( + new LambdaQueryWrapper
() + .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
() + .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
() + .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
().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
() + .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
() + .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
() + .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
() + .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; + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/address/vo/AddressVO.java b/server-snack/src/main/java/com/snack/server/module/address/vo/AddressVO.java new file mode 100644 index 0000000..626ea5c --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/address/vo/AddressVO.java @@ -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; +} diff --git a/server-snack/src/main/java/com/snack/server/module/cart/controller/CartController.java b/server-snack/src/main/java/com/snack/server/module/cart/controller/CartController.java new file mode 100644 index 0000000..cb6830e --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/cart/controller/CartController.java @@ -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 list() { + return Result.ok(cartService.getCurrentCart()); + } + + @Operation(summary = "添加商品到购物车") + @SaCheckLogin + @PostMapping + public Result add(@Valid @RequestBody CartAddReq req) { + cartService.addItem(req); + return Result.ok(); + } + + @Operation(summary = "修改购物车商品数量") + @SaCheckLogin + @PutMapping("/quantity") + public Result updateQuantity(@Valid @RequestBody CartUpdateReq req) { + cartService.updateQuantity(req); + return Result.ok(); + } + + @Operation(summary = "切换选中状态") + @SaCheckLogin + @PutMapping("/{skuId}/selected") + public Result 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 updateAllSelected( + @Parameter(description = "0-全不选 1-全选") @RequestParam Integer selected) { + cartService.updateAllSelected(selected); + return Result.ok(); + } + + @Operation(summary = "删除购物车项") + @SaCheckLogin + @DeleteMapping("/{skuId}") + public Result delete(@Parameter(description = "SKU ID") @PathVariable Long skuId) { + cartService.deleteItem(skuId); + return Result.ok(); + } + + @Operation(summary = "批量删除(结算页)") + @SaCheckLogin + @DeleteMapping + public Result batchDelete(@RequestBody List skuIds) { + cartService.deleteItems(skuIds); + return Result.ok(); + } + + @Operation(summary = "清空购物车") + @SaCheckLogin + @DeleteMapping("/all") + public Result clear() { + cartService.clear(); + return Result.ok(); + } + + @Operation(summary = "购物车商品数量(用于角标)") + @SaCheckLogin + @GetMapping("/count") + public Result count() { + Long userId = cn.dev33.satoken.stp.StpUtil.getLoginIdAsLong(); + return Result.ok(cartService.countByUserId(userId)); + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/cart/dto/req/CartAddReq.java b/server-snack/src/main/java/com/snack/server/module/cart/dto/req/CartAddReq.java new file mode 100644 index 0000000..81caa49 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/cart/dto/req/CartAddReq.java @@ -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; +} diff --git a/server-snack/src/main/java/com/snack/server/module/cart/dto/req/CartUpdateReq.java b/server-snack/src/main/java/com/snack/server/module/cart/dto/req/CartUpdateReq.java new file mode 100644 index 0000000..7703a0b --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/cart/dto/req/CartUpdateReq.java @@ -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; +} diff --git a/server-snack/src/main/java/com/snack/server/module/cart/entity/Cart.java b/server-snack/src/main/java/com/snack/server/module/cart/entity/Cart.java new file mode 100644 index 0000000..dd6f214 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/cart/entity/Cart.java @@ -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; +} diff --git a/server-snack/src/main/java/com/snack/server/module/cart/mapper/CartMapper.java b/server-snack/src/main/java/com/snack/server/module/cart/mapper/CartMapper.java new file mode 100644 index 0000000..a3e5ee9 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/cart/mapper/CartMapper.java @@ -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 { + + /** + * 数量累加(已存在记录时调用) + */ + @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); +} diff --git a/server-snack/src/main/java/com/snack/server/module/cart/service/CartService.java b/server-snack/src/main/java/com/snack/server/module/cart/service/CartService.java new file mode 100644 index 0000000..004c193 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/cart/service/CartService.java @@ -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 skuIds); + + /** + * 清空购物车 + */ + void clear(); + + /** + * 内部:获取当前用户已选中的购物车项(订单提交时使用) + */ + List listSelectedItems(); + + /** + * 内部:删除已选中的购物车项(订单创建成功后调用) + */ + void deleteSelectedItems(); + + /** + * 内部:获取购物车项数量(角标) + */ + int countByUserId(Long userId); +} diff --git a/server-snack/src/main/java/com/snack/server/module/cart/service/impl/CartServiceImpl.java b/server-snack/src/main/java/com/snack/server/module/cart/service/impl/CartServiceImpl.java new file mode 100644 index 0000000..4a2370f --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/cart/service/impl/CartServiceImpl.java @@ -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 carts = cartMapper.selectList( + new LambdaQueryWrapper() + .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 carts) { + // 1. 批量查 SPU / SKU + Set productIds = carts.stream().map(Cart::getProductId).collect(Collectors.toSet()); + Set skuIds = carts.stream().map(Cart::getSkuId).collect(Collectors.toSet()); + + Map productMap = productMapper.selectBatchIds(productIds).stream() + .collect(Collectors.toMap(Product::getId, p -> p)); + Map 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 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() + .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() + .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() + .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 carts = cartMapper.selectList( + new LambdaQueryWrapper().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() + .eq(Cart::getUserId, userId) + .eq(Cart::getSkuId, skuId) + ); + } + + @Override + public void deleteItems(Collection skuIds) { + if (CollUtil.isEmpty(skuIds)) return; + Long userId = StpUtil.getLoginIdAsLong(); + cartMapper.delete( + new LambdaQueryWrapper() + .eq(Cart::getUserId, userId) + .in(Cart::getSkuId, skuIds) + ); + } + + @Override + public void clear() { + Long userId = StpUtil.getLoginIdAsLong(); + cartMapper.delete(new LambdaQueryWrapper().eq(Cart::getUserId, userId)); + } + + // ==================== 内部 ==================== + + @Override + public List listSelectedItems() { + Long userId = StpUtil.getLoginIdAsLong(); + return cartMapper.selectList( + new LambdaQueryWrapper() + .eq(Cart::getUserId, userId) + .eq(Cart::getSelected, 1) + ); + } + + @Override + public void deleteSelectedItems() { + Long userId = StpUtil.getLoginIdAsLong(); + cartMapper.delete( + new LambdaQueryWrapper() + .eq(Cart::getUserId, userId) + .eq(Cart::getSelected, 1) + ); + } + + @Override + public int countByUserId(Long userId) { + return cartMapper.selectCount( + new LambdaQueryWrapper().eq(Cart::getUserId, userId) + ).intValue(); + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/cart/vo/CartItemVO.java b/server-snack/src/main/java/com/snack/server/module/cart/vo/CartItemVO.java new file mode 100644 index 0000000..5c41248 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/cart/vo/CartItemVO.java @@ -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; +} diff --git a/server-snack/src/main/java/com/snack/server/module/cart/vo/CartVO.java b/server-snack/src/main/java/com/snack/server/module/cart/vo/CartVO.java new file mode 100644 index 0000000..a1fad67 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/cart/vo/CartVO.java @@ -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 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; +}