🐢

苍穹外卖进阶

26065 字
130 分钟
苍穹外卖进阶

公共字段管理#

问题#

image.webp
image.webp

  • DTO模型中无法传输创建时间、更新时间、创建人、更新人这些字段,
  • 补充属性的相关操作,很多表中都有这四个公共属性,补充属性的代码又相对固定
  • 公共字段的填充抽象出来,用到AOP技术面向切面编程,选择性针对那些需要增强功能的方法。

切点#

切点就是需要实现该增强功能的方法,增强功能本质上是补充属性,需要调用相关的set方法,

取决于我们在哪里可以取到这个set方法。取set方法的方式是通过反射加上切面类的joinPoint,

反射可以通过getDeclaredMethod方法得到这个对象的方法,而joinPoint则负责拿到这个对象,

目标就是获取到具备setUpdateTime这些方法的类的对象,而这些类基本上都是entity实体类,字段往往就跟表里面一一对应,而我们只能在Mapper层设置切点,然后通过joinPoint拿到entity参数对象,再用反射机制获取到方法进行动态代理。

切点表达式#

在确定切点时我们已经明确了是在各个模块的Mapper层的新增和修改方法上切入

一种使用execution进行匹配,但是不同模块的新增和修改方法往往所在接口、方法名和参数各不相同,用execution需要写多个匹配语句用 || 逻辑连接。

常用方法就是使用特定注解,这一种方法更加灵活,可以在需要增强的切点上自习添加注解。

特定注解#

/**
* 数据库操作类型
*/
//进行枚举
public enum OperationType {
//更新操作
UPDATE,
//插入操作
INSERT
}
//自定义注解,用于标识需要自动填充的字段
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {
//数据库操作类型
OperationType value();
}
  • 在自定义特定注解上需要加上两个元注解@Target和@Retention,

  • @Target是用来表明注解的适用位置、@Retention用来表明注解的生命周期,

  • 因为注解我们要放在Mapper层的新增修改方法上,所以就是ElementType.METHOD,

  • 生命周期就设置为常见的运行时RetentionPolicy.RUNTIME

  • 在修改和新增两种方法公共字段填充的逻辑是不一样的,前者不需要补充创建人和创建时间两个字段,后者四个四段都要填充,

  • 增强前进行区别,那就可以用注解的属性进行区分。这里的value属性作用就是用来区分修改还是新增操作,OperationType就有UPDATE和INSERT两种取值。这样我们就可以通过切面类的参数joinPoint取到注解的value值,进而判断采取哪种增强逻辑。

###切面类

//定义为切面类
@Aspect
//交由IOC容器管理
@Component
//方便打印日志
@Slf4j
public class AutoFillAspect {
//定义切点对指定方法进行拦截
@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
public void autoFillPointcut(){}
//定义前置通知在方法前置执行
@Before("autoFillPointcut()")
public void autoFill(JoinPoint joinPoint){
log.info("开始进行公共字段自动填充...");
//获取到当前被拦截的方法上的数据库操作类型
MethodSignature signature = (MethodSignature) joinPoint.getSignature();//方法签名对象
AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class);//获得方法上的注解对象
OperationType operationType = autoFill.value();//获得数据库操作类型
//获取到当前被拦截的方法的参数--实体对象
Object[] args = joinPoint.getArgs();//获取到了所有的参数
if(args == null || args.length == 0){
return;
}
Object entity = args[0];
//准备赋值的数据
LocalDateTime now = LocalDateTime.now();
Long currentId = BaseContext.getCurrentId();
//根据当前不同的操作类型,为对应的属性通过反射来赋值
if(operationType == OperationType.INSERT){
//为4个公共字段赋值
try {
//Method setCreateTime = entity.getClass()进行反射
Method setCreateTime =entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class);
Method setCreateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER, Long.class);
Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
//通过反射为对象属性赋值
setCreateTime.invoke(entity,now);
setCreateUser.invoke(entity,currentId);
setUpdateTime.invoke(entity,now);
setUpdateUser.invoke(entity,currentId);
} catch (Exception e) {
e.printStackTrace();
}
}else if(operationType == OperationType.UPDATE){
//为2个公共字段赋值
try {
Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
//通过反射为对象属性赋值
setUpdateTime.invoke(entity,now);
setUpdateUser.invoke(entity,currentId);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}

注解@Component交给IOC容器管理,@Aspect表明切面类

必须是@Before,因为Mapper层的修改、新增方法执行时已经需要用到公共属性字段了,所以必须在方法执行前就进行增强

**获取目标方法上的注解,拿到属性值,**判断是执行修改操作的增强逻辑还是新增操作的。具体要使用增强方法的参数JoinPoint对象,调用getSignature然后必须强转成MethodSignature(不强转就会得到Signature,它是调不了getMethod方法获得Method对象的),调用getMethod方法得到Method对象后再调用getAnnotation拿到注解。

拿到目标方法的参数对象,还是需要用到JoinPoint对象,调用getArgs方法,前提可以判断Args不为空增强代码健壮性。一般参数都只有一个所以调用args[0]即可。

判断注解中的属性值执行不同的增强逻辑。接下来就是反射逻辑,首先需要使用刚刚拿到的参数对象调用getClass拿到该对象的类,然后调用getDeclaredMethod方法,里面需要传方法名(自定义常量)和返回值.class,最后就是把拿到的方法调用invoke动态代理,目标对象就填之前拿到的参数对象,值照常写就行。

在需要增强的方法上加上@AutoFill注解,标注上对应value属性即可

//在需要增强的方法上加上@AutoFill注解,标注上对应value属性即可
@AutoFill(value = OperationType.UPDATE)
//启用和禁用账号
void update(Employee employee);

文件上传#

image.webp
image.webp

yml配置#

//切换环境配置
spring:
profiles:
active: dev
alioss:
endpoint: ${sky.alioss.endpoint}
access-key-id: ${sky.alioss.access-key-id}
access-key-secret: ${sky.alioss.access-key-secret}
bucket-name: ${sky.alioss.bucket-name}
//dev.yml文件
alioss :
endpoint: oss-cn-beijing.aliyuncs.com
access-key-id: <你的AccessKeyId>
access-key: <你的AccessKeySecret>
bucket-name: lyf-token

登录阿里云创建Bucket,记得取消阻止公共访问,然后把读写权限改为公共读写。同时通过AccessKey获取到AccessKeyId和AccessKeySecret。在IDEA导入阿里云OSS依赖

在application-dev.yml文件里面配置好自己私密的endpoint,access-key-id,access-key-secret,bucket-name四个值。然后在application.yml文件里面利用${}传输私密配置的值。

//属性配置类
@Component
@ConfigurationProperties(prefix = "sky.alioss")
@Data
public class AliOssProperties {
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String bucketName;
}

工具类#

声明阿里Oss配置类,用来生成AliOssUtil对象,配合@Bean将它注册到IOC容器里面

@Data
@AllArgsConstructor
@Slf4j
public class AliOssUtil {
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String bucketName;
//文件上传
public String upload(byte[] bytes, String objectName) {
// 创建OSSClient实例。
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
try {
// 创建PutObject请求。
ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(bytes));
} catch (OSSException oe) {
System.out.println("Caught an OSSException, which means your request made it to OSS, "
+ "but was rejected with an error response for some reason.");
System.out.println("Error Message:" + oe.getErrorMessage());
System.out.println("Error Code:" + oe.getErrorCode());
System.out.println("Request ID:" + oe.getRequestId());
System.out.println("Host ID:" + oe.getHostId());
} catch (ClientException ce) {
System.out.println("Caught an ClientException, which means the client encountered "
+ "a serious internal problem while trying to communicate with OSS, "
+ "such as not being able to access the network.");
System.out.println("Error Message:" + ce.getMessage());
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
//文件访问路径规则 https://BucketName.Endpoint/ObjectName
StringBuilder stringBuilder = new StringBuilder("https://");
stringBuilder
.append(bucketName)
.append(".")
.append(endpoint)
.append("/")
.append(objectName);
log.info("文件上传到:{}", stringBuilder.toString());
return stringBuilder.toString();
}
}
/**
* 阿里OSS配置类,用来生成AliOssUtil对象,并交由IOC容器管理
*/
@Slf4j
@Configuration
public class OssConfiguration {
@Bean
public AliOssUtil aliOssUtil(AliOssProperties aliOssProperties) {
log.info("初始化aliOssUtil对象");
AliOssUtil aliOssUtil = new AliOssUtil(
aliOssProperties.getEndpoint(),
aliOssProperties.getAccessKeyId(),
aliOssProperties.getAccessKeySecret(),
aliOssProperties.getBucketName()
);
return aliOssUtil;
}
}

Controller#

@RestController
@RequestMapping("/common")
@Slf4j
public class CommonController {
@Autowired
private AliOssUtil aliOssUtil;
/**
* 使用阿里OSS来做文件存储
*
* @param file
* @return
*/
@PostMapping("/upload")
public Result upload(MultipartFile file) {
// 获取原始文件名
String originalFilename = file.getOriginalFilename();
log.info("上传文件:{}", originalFilename);
String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
String objectName = UUID.randomUUID().toString() + suffix;
String url = null;
try {
// 1. 调用AliOssUtil工具类的upload文件上传方法
url = aliOssUtil.upload(file.getBytes(), objectName);
} catch (IOException e) {
log.info("上传文件失败!!! ");
return Result.error(MessageConstant.UPLOAD_FAILED);
}
// 2. 返回图片路径结果
return Result.success(url);
}
}
  • .根据接口文档编写专门的CommonController实现文件上传功能(不存在Service层和Mapper层)。刚刚配合@Bean已经注册到的aliOssUtil对象在这里通过自动注入获取到。请求参数是MultiPartFile,调用它的getOriginalFilename可以获取原始文件名,我们需要通过substring方法取到它的后缀类似于.jpg,以此确定文件类型,但不能直接用原始文件名直接作为上传文件名,原因是如果原始文件名重复了,那么后上传的就会把先上传的文件覆盖。因此前缀文件名objectName必须唯一。此时就可以使用UUID.randomUUID方法来拼接处唯一的上传文件名。随后调用aliOssUtil的upload方法即可,
  • 第一个参数是源文件的字节数据file.getBytes()即可,
  • 第二个参数就是上传文件名设置成什么。
  • 返回值url直接作为data返回即可。

新增菜品#

实体类嵌套#

表名说明
dish菜品表
dish_flavor菜品口味表
  1. 菜品表:dish
字段名数据类型说明备注
idbigint主键自增
namevarchar(32)菜品名称唯一
category_idbigint分类id逻辑外键
pricedecimal(10,2)菜品价格
imagevarchar(255)图片路径
descriptionvarchar(255)菜品描述
statusint售卖状态1起售 0停售
create_timedatetime创建时间
update_timedatetime最后修改时间
create_userbigint创建人id
update_userbigint最后修改人id

菜品口味表:dish_flavor

字段名数据类型说明备注
idbigint主键自增
dish_idbigint菜品id逻辑外键
namevarchar(32)口味名称
valuevarchar(255)口味值

image.webp
image.webp

一次插入一个菜品,一个菜品包含多种口味

Controller#

/**
* 菜品管理
*/
@RestController
@RequestMapping("/admin/dish")
@Api(tags = "菜品相关接口")
@Slf4j
public class DishController {
@Autowired
private DishService dishService;
//新增菜品
@PostMapping
@ApiOperation("新增菜品")
public Result save(@RequestBody DishDTO dishDTO) {
log.info("新增菜品:{}", dishDTO);
dishService.saveWithFlavor(dishDTO);//后绪步骤开发
return Result.success();
}
}

Service#

@Service
@Slf4j
public class DishServiceImpl implements DishService {
@Autowired
private DishMapper dishMapper;
@Autowired
private DishFlavorMapper dishFlavorMapper;
//新增菜品和对应的口味
@Transactional
public void saveWithFlavor(DishDTO dishDTO) {
// 菜品数据对象 dish无需传入口味
//DishDTO多了口味集合
Dish dish = new Dish();
BeanUtils.copyProperties(dishDTO, dish);
//向菜品表插入1条数据
dishMapper.insert(dish);//后绪步骤实
//获取insert语句生成的主键值
Long dishId = dish.getId();
//前端没有传递id,
List<DishFlavor> flavors = dishDTO.getFlavors();
if (flavors != null && flavors.size() > 0) {
flavors.forEach(dishFlavor -> {
dishFlavor.setDishId(dishId);
});
//向口味表插入n条数据
dishFlavorMapper.insertBatch(flavors);//后绪步骤实
}
}
}
  • 由于DishDTO传输的数据不全所以需要新建一个DIsh然后利用BeanUtils浅拷贝到DIsh上,补充属性的操作我们在此之前已经放到AOP里面了。这里就可以先调用dishMapper把dish的数据插入到dish表了,
  • 前端传来的DTO里面包含了Flavors集合,需要把这个集合里面的所有flavor批量插入到dish_flavor表里面,但需要注意的是因为是新增操作,我们压根不知道菜品的id是什么,前端传来的DishFlavor中也没有dish_id这个字段,因此直接把数据插入到dish_flavor会因为找不到这个口味对应的菜品而无法插入报错。
  • 所以我们需要事先取到这个id封装到新建的这个dish里面然后利用lambda表达式让每个口味都setDishId补充dish_id字段后再插入。
  • 涉及到了多表的增删改操作要开启事务,由于该Service层的方法涉及到两个表的插入操作所以要加@Transactional注解开启事务,

Mapper#

/**
* 插入菜品数据
*
* @param dish
*/
@AutoFill(value = OperationType.INSERT)
void insert (Dish dish);
//方式一
@0ptions(useGeneratedKeys = true, keyProperty = "id")
@insert()
//方式二
<mapper namespace="com.sky.mapper.DishMapper">
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into dish (name, category_id, price, image, description, status,
create_time, update_time, create_user, update_user)
values (#{name}, #{categoryId}, #{price}, #{image}, #{description}, #{status},
#{createTime}, #{updateTime}, #{createUser}, #{updateUser})
</insert>
</mapper>

口味

@Mapper
public interface DishFlavorMapper {
/**
* 批量插入口味数据
* @param flavors
*/
void insertBatch(List<DishFlavor> flavors);
}
<mapper namespace="com.sky.mapper.DishFlavorMapper">
<insert id="insertBatch">
insert into dish_flavor (dish_id, name, value) VALUES
<foreach collection="flavors" item="df" separator=",">
(#{df.dishId},#{df.name},#{df.value})
</foreach>
</insert>
</mapper>
  • useGeneratedKeys 属性通过 JDBC 的 Statement.getGeneratedKeys() 方法获取数据库生成的主键值

  • 在 MyBatis 的 标签中,设置 useGeneratedKeys=“true”,告诉 MyBatis 使用数据库自动生成的主键。

  • keyProperty 指定将生成的主键值设置到实体类的哪个属性中。,keyProperty=“id” 表示将生成的主键值设置到实体类的 id 属性中。

分页查询#

Controller#

image.webp
image.webp

//菜品分页查询
@GetMapping("/page")
@ApiOperation("菜品分页查询")
public Result<PageResult> page(DishPageQueryDTO dishPageQueryDTO){
log.info("菜品分页查询:{}", dishPageQueryDTO);
PageResult pageResult = dishService.pageQuery(dishPageQueryDTO);
return Result.success(pageResult);
}

Service#

//分页查询
@Override
public PageResult pageQuery(DishPageQueryDTO dishPageQueryDTO) {
PageHelper.startPage(dishPageQueryDTO.getPage(), dishPageQueryDTO.getPageSize());
Page<DishVO> page = dishMapper.pageQuery(dishPageQueryDTO);
return new PageResult(page.getTotal(), page.getResult());
}
  • DishPageQueryDTO 查询条件

  • DIshVO相当于分页的每一横行类别

  • Page继承Arraylist集合,泛型是每行显示类型

  • 用Dish实体类封装的话你会发现categoryName没法封装进去,所以我们要专门再自定义一个DishVO类封装查询出来的数据

Mapper#

<!-- //分页查询-->
<select id="pageQuery" resultType="com.sky.vo.DishVO">
select d.*, c.name as categoryName from dish d left outer join category c on d.category_id = c.id
<where>
<if test="name != null and name != ''">
and d.name like concat('%',#{name},'%')
</if>
<if test="categoryId != null">
and d.category_id = #{categoryId}
</if>
<if test="status != null">
and d.status = #{status}
</if>
</where>
order by d.create_time desc
</select>
  • 左外链接到分类名称,两个表的name字段重复需要起别名给分类名字(实体名)

  • Mybatis自动封装是驼峰命名数据库creat_time->实体类creatTime

批量删除#

Controller#

  • 请求参数<为普通参数ids>,特别注意的是传入的是1,2,3…这样的多个id。
  • 在售状态下的菜品不准删除,被套餐关联的菜品不准删除,
  • 每个菜品都对应了多个口味.菜品被删除了那么对应的口味数据也需要删除。
  • image.webp
    image.webp
@DeleteMapping
@ApiOperation("菜品批量删除")
public Result delete(@RequestParam List<Long> ids) {
log.info("菜品批量删除:{}", ids);
dishService.deleteBatch(ids);//后绪步骤实现
return Result.success();
  • 请求参数传来的多个id可以使用集合或数组封装

  • 这里使用集合是因为它的可调用API多一点,到时候处理更加方便。

  • 需要注意的是请求参数为普通参数且为一个集合的时候需要加注解@RequestParam。

Service#

@Autowired
private SetmealDishMapper setmealDishMapper;
/**
* 菜品批量删除
*
* @param ids
*/
@Transactional//事务
public void deleteBatch(List<Long> ids) {
//判断当前菜品是否能够删除---是否存在起售中的菜品??
for (Long id : ids) {
Dish dish = dishMapper.getById(id);//后绪步骤实现
if (dish.getStatus() == StatusConstant.ENABLE) {
//当前菜品处于起售中,不能删除
throw new DeletionNotAllowedException(MessageConstant.DISH_ON_SALE);
}
}
//判断当前菜品是否能够删除---是否被套餐关联了??
List<Long> setmealIds = setmealDishMapper.getSetmealIdsByDishIds(ids);
if (setmealIds != null && setmealIds.size() > 0) {
//当前菜品被套餐关联了,不能删除
throw new DeletionNotAllowedException(MessageConstant.DISH_BE_RELATED_BY_SETMEAL);
}
//删除菜品表中的菜品数据
for (Long id : ids) {
dishMapper.deleteById(id);//后绪步骤实现
//删除菜品关联的口味数据
dishFlavorMapper.deleteByDishId(id);//后绪步骤实现
}
}
  • 1.起售状态菜品不可删除,把传来的全部菜品id遍历,状态在dish表中可以查到,selectById方法查*查到的数据封装到dish中,
  • 判断dish的状态是否等于自定义常量“ENABLE”,是的话就抛出自定义异常,自定义信息即可。
  • 2.被套餐关联的菜品不可删除
  • 菜品和套餐实际上是多对多的关系,一般这种关系在设计数据库的时候会多一个第三方表进行连接它们之间的关系,在这里也就是setmeal_dish表,
  • SetMealDishMapper的countByDIshId方法这张表中根据dish_id查有多少条数据countByDishId,对应了一个菜品关联了多少个套餐count。count大于0就抛出自定义异常和自定义信息。 3.删除菜品基本信息,经过上面两层逻辑过滤后确定了菜品可以删除,调用dishMapper的批量删除方法即可,传入ids。
  • 4.删除菜品对应口味信息dish,菜品一旦删除,它的口味就没必要留着了,需要在dish_flavor表中根据dish_id删除菜品对应的口味,也是批量删除

Mapper#

//批量删除
/ /根据主键查询菜品
@Select("select * from dish where id = #{id}")
Dish getById(Long id);
//根据主键删除菜品数据
@Delete("delete from dish where id = #{id}")
void deleteById(Long id);
//根据菜品id删除对应的口味数据
@Delete("delete from dish_flavor where dish_id = #{dishId}")
void deleteByDishId(Long dishId);
@Mapper
public interface SetmealDishMapper {
/**
* 根据菜品id查询对应的套餐id
*
* @param dishIds
* @return
*/
//select setmeal_id from setmeal_dish where dish_id in (1,2,3,4)
List<Long> getSetmealIdsByDishIds(List<Long> dishIds);
}

SetmealDishMapper.xml

这个方法返回的是指定菜品id在这个第三方表setmeal_dish中出现的总次数,是每个菜品出现次数之和,而不是单单的一个菜品的出现次数。因为我们的业务逻辑是如果这个count大于0(即指定菜品集合中至少存在一个菜品有关联的套餐),就直接抛异常停止删除操作,让用户重新选择删除的菜品。

<mapper namespace="com.sky.mapper.SetmealDishMapper">
<select id="getSetmealIdsByDishIds" resultType="java.lang.Long">
select count(*) from setmeal_dish where dish_id in
<foreach collection="dishIds" item="dishId" separator="," open="(" close=")">
#{dishId}
</foreach>
</select>
</mapper>

修改菜品#

image.webp
image.webp

image.webp
image.webp

Controller#

/**
* 根据id查询菜品
*
@GetMapping("/{id}")
@ApiOperation("根据id查询菜品")
public Result<DishVO> getById(@PathVariable Long id) {
log.info("根据id查询菜品:{}", id);
DishVO dishVO = dishService.getByIdWithFlavor(id);//后绪步骤实现
return Result.success(dishVO);
}
  • DishVo是页面每一行数据类型定制的实体类比Dish多了类型

  • 路径参数@PathVariable注解要加上

  • json的@RequestBody注解。

  • 分析根据id查询菜品的返回值可以发现需要DishVO进行封装返回值。

Service#

//编辑菜品
//1.根据id查询菜品和对应的口味数据
public DishVO getByIdWithFlavor(Long id) {
//根据id查询菜品数据
Dish dish = dishMapper.getById(id);
//根据菜品id查询口味数据
List<DishFlavor> dishFlavors = dishFlavorMapper.getByDishId(id);//后绪步骤实现
//将查询到两组数据的数据封装到VO
DishVO dishVO = new DishVO();
BeanUtils.copyProperties(dish, dishVO);
dishVO.setFlavors(dishFlavors);
return dishVO;
}
  • 回显操作业务
  • 在dish表把菜品基本信息根据id查了,
  • 在dish_flavor表把菜品对应的全部口味根据dish_id查
  • 由于最后封装到DishVO里面,前者可以浅拷贝,后者之间setFlavors即可。

Mapper#

@Select("select * from dish_flavor where dish_id = #{id}")
List<DishFlavor> getByDishId(Long id);

Controller#

//修改菜品
@PutMapping
@ApiOperation("修改菜品")
public Result update(@RequestBody DishDTO dishDTO) {
log.info("修改菜品:{}", dishDTO);
dishService.updateWithFlavor(dishDTO);
return Result.success();
}

Service#

//根据id修改菜品基本信息和对应的口味信息
public void updateWithFlavor(DishDTO dishDTO) {
Dish dish = new Dish();
BeanUtils.copyProperties(dishDTO, dish);
//修改菜品表基本信息
dishMapper.update(dish);
//删除原有的口味数据
dishFlavorMapper.deleteByDishId(dishDTO.getId());
//重新插入口味数据
List<DishFlavor> flavors = dishDTO.getFlavors();
if (flavors != null && flavors.size() > 0) {
flavors.forEach(dishFlavor -> {
dishFlavor.setDishId(dishDTO.getId());
});
//向口味表插入n条数据
dishFlavorMapper.insertBatch(flavors);
}
}
  • 第一步创建一个dish先把前端传来的DishDTO数据浅拷贝进去,根据dish做更新操作。
  • 把对应菜品(dto里面的id)全部口味信息都删除,然后再把dto传来的flavors重新插入进去{每个数据可能选中}
  • 判断口味信息是否为空,因为如果为空的话后续进行批量插入操作的时候因为数据为空会报SQL异常。
  • 需要注意插入口味前需要把每个口味都关联菜品ID,这一步跟新增菜品的时候有点像,但存在一个关键的区别,那就是新增操作我们是不知道菜品ID的,因此我们当时在Mapper层insert菜品操作的时候加了Options注解,获取到数据库里面新增后的这条数据的菜品ID,然后封装回Service层的new出来的dish里面,然后才能让口味关联菜品ID。
  • 这里是修改操作,前端已经把菜品ID通过DishDTO传过来了,所有在浅拷贝那一步的时候我们的dish里面就已经有id值了,可以直接遍历所有口味进行setDIshId的关联操作!最后再把口味批量插入到dish_flavor表中即可。最后的最后,
  • 涉及到两张表的增删改操作,需要加事务注解@Transactional。

Mapper#

<!-- 修改菜品信息-->
<update id="update">
update dish
<set>
<if test="name != null">name = #{name},</if>
<if test="categoryId != null">category_id = #{categoryId},</if>
<if test="price != null">price = #{price},</if>
<if test="image != null">image = #{image},</if>
<if test="description != null">description = #{description},</if>
<if test="status != null">status = #{status},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
<if test="updateUser != null">update_user = #{updateUser},</if>
</set>
where id = #{id}
</update>
</mapper>

起售停售#

新增套餐#

实体类#

@Data
public class SetmealDTO implements Serializable {
private Long id;
//分类id
private Long categoryId;
//套餐名称
private String name;
//套餐价格
private BigDecimal price;
//状态 0:停用 1:启用
private Integer status;
//描述信息
private String description;
//图片
private String image;
//套餐菜品关系
private List<SetmealDish> setmealDishes = new ArrayList<>();
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Setmeal implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
//分类id
private Long categoryId;
//套餐名称
private String name;
//套餐价格
private BigDecimal price;
//状态 0:停用 1:启用
private Integer status;
//描述信息
private String description;
//图片
private String image;
private LocalDateTime createTime;
private LocalDateTime updateTime;
private Long createUser;
private Long updateUser;
}
/**
* 套餐菜品关系
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SetmealDish implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
//套餐id
private Long setmealId;
//菜品id
private Long dishId;
//菜品名称 (冗余字段)
private String name;
//菜品原价
private BigDecimal price;
//份数
private Integer copies;
}

Controller#

@RestController
@RequestMapping("/admin/setmeal")
@Api(tags = "套餐相关接口")
@Slf4j
public class SetmealController {
@Autowired
private SetmealService setmealService;
//SetmealDTO 套餐实体类嵌套菜品集合SetmealDish
//新增套餐
@ApiOperation("新增套餐")
@PostMapping
public Result insertSetmeal(@RequestBody SetmealDTO setmealDTO) {
log.info("新增套餐");
setmealService.insertSetmeal(setmealDTO);
return Result.success();
}

Service#

@Service
public class SetmealServiceImpl implements SetmealService {
@Autowired
private SetmealMapper setmealMapper;
@Autowired
private SetmealDishMapper setmealDishMapper;
//新增菜品
@Transactional
@Override
public void insertSetmeal(SetmealDTO dto) {
Setmeal setmeal=new Setmeal();
BeanUtils.copyProperties(dto,setmeal);
//利用aop实现其他属性的填充
//像套餐表插入数据
setmealMapper.insert(setmeal);
/
/SetmealDish 套餐里的菜品
//菜品都设置套餐的id属性
List<SetmealDish> setmealDishes = dto.getSetmealDishes();
setmealDishes.forEach(setmealDish -> {
setmealDish.setSetmealId(setmeal.getId());
});
//批量插入套餐里的菜品
setmealDishMapper.insertBatch(setmealDishes);
}
}

把套餐基本信息插入到setmeal表中,

把与套餐关联的菜品插入setmeal_dish表中,当然在插入第二张表之前套餐的每个菜品都要关联套餐ID,这个套餐ID我们并不知道,必须要在setmealMapper的insert方法上加@Options注解,取到数据库中自增的套餐ID赋值回参数setmeal中,然后就可以通过setmeal.getId()拿到套餐ID了。

Mapper#

// 新增套餐
@Options(useGeneratedKeys = true, keyProperty = "id")
@AutoFill(value = OperationType.INSERT)
@Insert("insert into setmeal (category_id, name, price, status, description, image, " +
"create_time, update_time, create_user, update_user) " +
"values (#{categoryId}, #{name}, #{price}, #{status}, #{description}, #{image}, " +
"#{createTime}, #{updateTime}, #{createUser}, #{updateUser})")
void insert(Setmeal setmeal);
<!-- //套餐里添加菜品-->
<insert id="insertBatch" parameterType="list">
insert into setmeal_dish
(setmeal_id,dish_id,name,price,copies)
values
<foreach collection="setmealDishes" item="sd" separator=",">
(#{sd.setmealId},#{sd.dishId},#{sd.name},#{sd.price},#{sd.copies})
</foreach>
</insert>

查询套餐#

实体类#

@Data
public class SetmealPageQueryDTO implements Serializable {
private int page;
private int pageSize;
private String name;
//分类id
private Integer categoryId;
//状态 0表示禁用 1表示启用
private Integer status;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SetmealVO implements Serializable {
private Long id;
//分类id
private Long categoryId;
//套餐名称
private String name;
//套餐价格
private BigDecimal price;
//状态 0:停用 1:启用
private Integer status;
//描述信息
private String description;
//图片
private String image;
//更新时间
private LocalDateTime updateTime;
//分类名称
private String categoryName;
//套餐和菜品的关联关系
private List<SetmealDish> setmealDishes = new ArrayList<>();
}

Controller#

//查询套餐
@ApiOperation("查询套餐")
@GetMapping("/page")
public Result<PageResult> page(SetmealPageQueryDTO setmealPageQueryDTO) {
log.info("分页查询");
PageResult pageResult = setmealService.pageQuery(setmealPageQueryDTO);
return Result.success(pageResult);
}

Service#

//分页查询
@Override
public PageResult pageQuery(SetmealPageQueryDTO setmealPageQueryDTO) {
//开启分页查询
PageHelper.startPage(setmealPageQueryDTO.getPage(),setmealPageQueryDTO.getPageSize());
//查询分页每行数据类型
Page<SetmealVO> page = setmealMapper.listPageQuery(setmealPageQueryDTO);
return new PageResult(page.getTotal(),page.getResult());
}

调用Mapper接口需要想想Page泛型里面是什么类,相当于就是查出来显示的每行数据用啥封装合适,自定义一个SetmealVO返回前端。

Mapper#

<mapper namespace="com.sky.mapper.SetmealMapper">
<!-- 查询套餐-->
<select id="listPageQuery" resultType="com.sky.vo.SetmealVO">
select t1 *,t2.name as categoryName from setmeal t1
left join Category t2 on t2.category_id = t1.id
<where>
<if test="name != null">
and s.name like concat('%',#{name},'%')
</if>
<if test="status != null">
and s.status = #{status}
</if>
<if test="categoryId != null">
and s.category_id = #{categoryId}
</if>
</where>
</select>
</mapper>

删除套餐#

Controller#

/删除套餐
@ApiOperation("删除套餐")
@DeleteMapping
public Result delete(@RequestParam List<Long> ids) {
log.info("删除套餐");
setmealService.deleteBatch(ids);
return Result.success();
}
  • 请求参数ids后端用集合接收,
  • 必须要加@ResutParam注解。分析产品原型,
  • 批量删除套餐的时候如果套餐在售是不准删除的
  • setmeal这张表对应套餐删了,那么setmeal_dish这张表被删套餐对应的菜品也要删掉。

Service#

//删除套餐
@Transactional
@Override
public void deleteBatch(List<Long> ids) {
ids.forEach(id -> {
Setmeal setmeal = setmealMapper.getById(id);
if(StatusConstant.ENABLE == setmeal.getStatus()){
//起售中的套餐不能删除
throw new DeletionNotAllowedException(MessageConstant.SETMEAL_ON_SALE);
}
});
ids.forEach(setmealId -> {
//删除套餐表中的数据
setmealMapper.deleteById(setmealId);
//删除套餐菜品关系表中的数据
setmealDishMapper.deleteBySetmealId(setmealId);
});
}

Mapper#

//删除套餐
// 根据id查询套餐判断餐品售卖状态
@Select("select * from setmeal where id = #{id}")
Setmeal getById(Long id);
//根据id删除套餐
@Delete("delete from setmeal where id = #{id}")
void deleteById(Long setmealId);
@Delete("delete from setmeal_dish where setmeal_id = #{setmealId}")
void deleteBySetmealId(Long setmealId);

因为service已经遍历集合 如果没有则xml文件需要遍历ids集合

<delete id="deleteBatch">
delete from setmeal where id in
<foreach collection="ids" item="id" open="(" close=")" separator=",">
#{id}
</foreach>
</delete>
setmealDishMapper的批量删除方法同上
<delete id="deleteBatch">
delete from setmeal_dish where setmeal_id in
<foreach collection="ids" item="id" open="(" close=")" separator=",">
#{id}
</foreach>
</delete>

修改套餐#

image.webp
image.webp

Controller#

//编辑套餐
@PutMapping
@ApiOperation("编辑套餐")
public Result update(@RequestBody SetmealDTO setmealDTO){
log.info("更新套餐信息");
setmealService.update(setmealDTO);
return Result.success();
}

根据id查询套餐注意是路径参数,修改套餐请求参数为json。

Service#

//2.编辑套餐
@Override
public void update(SetmealDTO dto) {
Setmeal setmeal = new Setmeal();
BeanUtils.copyProperties(dto,setmeal);
//更新套餐的基本信息
setmealMapper.update(setmeal);
//删除套餐菜品关系表中的菜品关系数据
setmealDishMapper.deleteById(setmeal.getId());
//批量插入套餐菜品关系数据
List<SetmealDish> dishes = dto.getSetmealDishes();
//判断菜品关系数据是否为空
//菜品关联id赋值
if (dishes !=null && dishes.size()>0){
dishes.forEach(dish ->{
dish.setSetmealId(dto.getId());
});
setmealDishMapper.insert(dishes);
}
}
  • 回显操作中,setmealMapper负责查找套餐基本信息回显,setmealDishMapper则负责查找该套餐关联菜品的基本信息回显,最后全部封装到SetmealVO返回到前端。

  • 更新套餐操作中,主要分为三大步。

  • 第一步更新套餐基本信息,也就是setmeal表中的数据,所需要的东西已经在SetmealDTO里面了,这里之所以要new一个Setmeal类进行浅拷贝,主要是考虑到DTO里面的数据不全像更新时间更新人都没有,因此使用Setmeal作为参数,配合AOP技术实行公共字段填充。

  • 第二步删除所有套餐关联的所有菜品信息,这一步就是在处理setmeal_dish表里面的数据,需要根据套餐ID清空全部数据。

  • 第三步就是新增最终修改确定好的套餐关联的所有菜品信息,这个东西已经封装到DTO传给我们了,我们取到这个SetmealDish数据集合后必须要先把所有数据都和该套餐id都关联起来,不然到时候往setmeal_dish表插入数据会因为每条数据都没有SetmealId而报错,当然这个套餐ID在DTO里面也已经封装了

Mapper#

//编辑套餐
//1.回显套餐数据
@Select("select t1.*,t2.name categoryName from setmeal t1,category t2 where t1.category_id = t2.id and t1.id = #{id}")
SetmealVO selectById(Long id);
//2.回显套餐菜品信息
//2.修改套餐基本信息
@AutoFill(value = OperationType.UPDATE)
void update(Setmeal setmeal);
//编辑信息
//1.回显套餐数据
@Select("select * from setmeal_dish where setmeal_id = #{id}")
List<SetmealDish> selectBySetmealId(Long id);
//根据所有套餐关联的菜品信息setmeal_dish表
@Delete("delete from setmeal_dish where setmeal_id = #{id}")
void deleteById(Long id);
//重新插入套餐对应的菜品信息
void insert(List<SetmealDish> dishes);
<!-- 编辑套餐-->
<!-- 3.重新添加套餐中的菜品信息-->
<insert id="insert">
insert into setmeal_dish values
<foreach collection="dishes" item="setmealDish" separator=",">
(null,#{setmealDish.setmealId},#{setmealDish.dishId},#{setmealDish.name},#{setmealDish.price},#{setmealDish.copies})
</foreach>
</insert>

套餐起售停售#

Controller#

image.webp
image.webp

//套餐起售停售
@ApiOperation("套餐起售停售")
@PostMapping("/status/{status}")
public Result updateStatus(@PathVariable Integer status, Long id) {
setmealService.updateStatus(status, id);
return Result.success();
}
  • 套餐内如果有停售菜品,则套餐无法上架
  • 【启售/停售】,分类被禁用后,前台不展示
  • 【启售/停售】弹出确认弹窗,操作成功提示操作成功,失败提示操作失败。

Service#

//套餐起售停售
@Override
public void updateStatus(Integer status, Long id) {
// 设置为起售,判断套餐里面的菜品是否存在停售状态,如果有抛出自定义异常
if (status == StatusConstant.ENABLE) {
List<Dish> dishList = setmealDishMapper.selectDishById(id);
dishList.forEach(dish -> {
if (dish.getStatus() == StatusConstant.DISABLE) {
throw new SetmealEnableFailedException(MessageConstant.SETMEAL_ENABLE_FAILED);
}
});
}
// 更新起售停售信息
Setmeal setmeal = new Setmeal();
setmeal.setStatus(status);
setmeal.setId(id);
setmealMapper.update(setmeal);
}
  • 第一步判断操作为起售操作,那就进入if分支进一步判断这个套餐的所有菜品是否存在停售菜品(这里需要联表查询,因为菜品的状态只有在dish表中才有),如果存在那就抛出自定义异常。
  • 第二步就是更新套餐起售停售状态,这里用一个Setmeal封装是因为光有status和id属性做更新操作是不够的,还需要更新updateTime和updateUser,所以配合AOP增强填充公共字段,需要new一个setmeal。

Mapper#

重点取到每个Dish的status,判断是否存在停售的菜品。

//查询套餐对应的菜品信息
@Select("select t2.* from setmeal_dish t1, dish t2 where t1.setmeal_id = #{id} and t1.dish_id = t2.id")
List<Dish> selectDishById(Long id);

根据ID更新套餐状态,复用之前的update方法。

<update id="update">
update setmeal set
<if test="categoryId != null">category_id = #{categoryId},</if>
<if test="name != null and name != ''">name = #{name},</if>
<if test="price != null">price = #{price},</if>
<if test="status != null">status = #{status},</if>
<if test="description != null and description != ''">description = #{description},</if>
<if test="image != null and image != ''">image = #{image},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
<if test="updateUser != null">update_user = #{updateUser}</if>
where id = #{id}
</update>

Redis#

核心特点#

image.webp
image.webp

Redis实质上是一个基于内存的key-value结构数据库,他存储的value类型比较丰富,也被称为结构化的非关系型数据库(NoSQL),同时支持将数据持久化到磁盘。它不仅是缓存工具,更是一个功能丰富的数据结构服务器。

  • 纯内存存储:数据默认在内存中读写,速度极快,单实例 QPS 可达 10 万级。

  • 丰富的数据结构:支持字符串(String)、哈希(Hash)、列表(List)、集合(Set)、有序集合(ZSet)等多种原生数据结构,满足复杂业务场景。

  • 持久化机制:提供 RDB(快照)和 AOF(日志追加)两种持久化方式,可在重启后恢复数据,兼顾性能与可靠性。

  • 高并发与原子性:所有命令都是单线程原子执行,天然避免了多线程竞争问题。

  • 支持集群与高可用:通过主从复制、哨兵(Sentinel)和集合(Cluster)模式,轻松实现数据冗余和故障自动切换。

  • 丰富的扩展能力:支持发布订阅(Pub/Sub)、Lua 脚本、事务、过期策略等高级特性

主要作用#

  • 缓存热点数据:将数据库中的高频访问数据(如商品详情、用户信息)缓存到 Redis,减轻数据库压力,提升接口响应速度。

  • 实现分布式锁:利用SETNX等命令实现分布式环境下的互斥锁,保证多服务操作的一致性。

  • 消息队列:基于List的LPUSH/RPOP或Pub/Sub 模式,实现轻量级消息队列。

  • 计数器与排行榜:用String做访问量计数,用ZSet实现实时排行榜(如热门商品、用户积分排名)。

  • 会话共享:在分布式系统中,将用户Session存储到Redis,实现多服务间的 Session 共享。

启动#

redis-server.exe redis.windows.conf // 启动服务器并使自定义配置文件生效

数据类型#

Redis存储的是key-value结构的数据,其中key是字符串类型,value有五种常用数据类型:字符串(String)、哈希(Hash)、列表(List)、集合(Set)、有序集合(Sorted Set / ZSet)。

数据类型基本特点核心能力典型场景
String最基础的键值类型,Value 可以是字符串、数字甚至二进制数据,单个 Value 最大能存 512MB。支持直接对数字进行加减操作,如 INCR / DECR;也能追加字符串,如 APPEND缓存用户信息、接口计数器、验证码存储。
Hash是一个键值对的集合,类似 Java 里的 HashMap,适合存储结构化对象可以单独对某个字段进行增删改查,如 HSET / HGET,不用修改整个对象。存储用户详情,如 user:1000nameageemail 等字段;商品信息。
List是一个有序的字符串链表,元素可重复,两端插入 / 删除的时间复杂度为 O(1)支持从头部或尾部添加 / 弹出元素,如 LPUSH / RPOP;也能按范围读取,如 LRANGE实现消息队列、时间线动态、评论列表。
Set无序且唯一的字符串集合,不允许重复元素,支持集合间的交集、并集、差集运算快速判断元素是否存在,如 SISMEMBER;以及集合间运算,如 SINTER / SUNION存储用户的点赞 / 收藏、共同关注好友、抽奖活动的中奖用户去重。
Sorted Set / ZSet和 Set 一样元素唯一,但每个元素会关联一个分数 Score,Redis 会根据分数自动排序。可以按分数范围或排名范围获取元素,如 ZRANGE / ZREVRANGE;也能快速获取元素排名,如 ZRANK实时排行榜,如热门商品、用户积分;带权重的任务队列。

导入Maven#

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

配置Redis#

在application-dev.yml文件中配置Redis的端口号、地址、密码

sky:
redis:
port: 6379
host: localhost
password: 123456
database: 0

在application.yml文件中配置Redis的端口号、地址、密码的引用

spring:
redis:
port: ${sky.redis.port}
host: ${sky.redis.host}
password: ${sky.redis.password}
database: ${sky.redis.database}

Template#

@Configuration
@Slf4j
public class RedisConfiguration {
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
log.info("开始创建redis模板对象...");
RedisTemplate redisTemplate = new RedisTemplate();
// 设置Key的序列化器为StringRedisSerializer 默认为JdkSerializationRedisSerializer
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
// 不推荐修改Value的序列化器为单一的StringRedisSerializer之类的
// redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
// 通过redis工厂创建对象,帮助RedisTemplate获取数据库连接,即连接Redis服务器的能力
redisTemplate.setConnectionFactory(redisConnectionFactory);
return redisTemplate;
}
}
  • @Configuration定义配置类和 @Bean引入Redis生产工厂并将RedisTemplate放到IOC容器
  • 设置jKey的序列化器为StringRedisSerializer,
  • 键key的序列化反序列化方式设定为StringRedisSerializer。
  • 在Redis中的关系为 -> 写操作之前需要把内存中的数据转换成字节流(序列化)
  • 读操作之前需要把传输的字节流还原成内存中可识别的数据(反序列化)。
  • 这里重新设置Key的序列化器主要是避免在Redis客户端看到的键名存在一些多余的符号,但
  • 不推荐修改Value的序列化器为StringRedisSerializer这种单一的序列化器,
  • 原因是修改之后可能破坏跨语言兼容性,同时在进行接收数据强转的时候会抛出异常,推荐把Value的序列化器修改为GenericJackson2JsonRedisSerializer。
  • value和key的序列化器种类其实就对应了value值类型各种各样,而key值一般就是字符串。

Value#

@Test
public void test() {
// 1.获取valueOperation对象
ValueOperations valueOperations = redisTemplate.opsForValue();
// 2.通过valueOperations对象,操作string类型数据
valueOperations.set("name", "Kurisu", 20, TimeUnit.SECONDS);
Object name = valueOperations.get("name");
System.out.println(name);
valueOperations.set("code", "123456", 20, TimeUnit.SECONDS);
Object code = valueOperations.get("code");
System.out.println(code);
}
  • 无论使用哪种Operations,第一步都是先使用redisTemplate对象获取对应Operations。
  • ValueOperations是专门用于String数据操作,通过opsForValue获取到。
  • 常用set、get方法,set四个参数分别是key名字、key的值、超时时间、超时时间单位。

Hash#

@Test
public void test1() {
// 1.获取hashOperations对象
HashOperations hashOperations = redisTemplate.opsForHash();
// 2.通过hashOperations对象,操作hash类型对象
hashOperations.put("user", "name", "lisi");
hashOperations.put("user", "age", 20);
hashOperations.put("user", "gender", "男");
Set user = hashOperations.keys("user");
System.out.println(user);
List user1 = hashOperations.values("user");
System.out.println(user1);
Map user2 = hashOperations.entries("user");
System.out.println(user2);
}
  • opsForHash获取对应Operations,专门用于Hash类型数据操作。
  • 新增操作put,三个参数,key名字,哈希桶的键名、哈希桶的值。
  • keys方法获取所有哈希桶的键名、values方法获取所有哈希桶的值、entries方法获取所有哈希桶键值对。

ZSet#

@Test
public void test2() {
ZSetOperations zSetOperations = redisTemplate.opsForZSet();
zSetOperations.add("zset01", "lisi", 99);
zSetOperations.add("zset01", "zhangsan", 88);
zSetOperations.add("zset01", "yuanshen", 77);
Set zset1 = zSetOperations.range("zset01", 0, -1);
System.out.println(zset1);
Set<DefaultTypedTuple> zset2 = zSetOperations.rangeWithScores("zset01", 0, -1);
System.out.println(zset2);
for (DefaultTypedTuple object : zset2) {
System.out.println(object.getValue() + "--" + object.getScore());
}
}

opsForZSet获取对应Operations,专门用于ZSet类型数据操作。

新增操作add,三个参数,key名字,value值、value值对应的分数score。range方法三个参数,查询的ZSet的key名、起始索引、末尾索引。rangeWithScores方法同上,唯一区别就是返回的值里面第二维是带有value值和score分数的二元组。而且这个二元组DefaultTypedTuple有getValue和getScore两种方法,分别获取value和score。两种查询方法查出来的数据都是按照score升序排序的。

Set#

@Test
public void test3() {
SetOperations setOperations = redisTemplate.opsForSet();
setOperations.add("set01", "java", "go", "python", "c");
setOperations.add("set02", "java", "go", "dance", "sing");
Set set01 = setOperations.members("set01");
System.out.println(set01);
Set set02 = setOperations.members("set02");
System.out.println(set02);
Set union = setOperations.union("set01", "set02");
System.out.println(union);
Set intersect = setOperations.intersect("set01", "set02");
System.out.println(intersect);
Set difference = setOperations.difference("set01", "set02");
System.out.println(difference);
}

opsForSet获取对应Operations,专门用于Set类型数据操作。

常用新增操作add,两个参数,key名字,value值。这个value可以一次性添加多个value。查询操作members,获取给定键名的集合Set。求并集union,求交集intersect,求差集difference(前面差后面)。

List#

@Test
public void test3() {
SetOperations setOperations = redisTemplate.opsForSet();
setOperations.add("set01", "java", "go", "python", "c");
setOperations.add("set02", "java", "go", "dance", "sing");
Set set01 = setOperations.members("set01");
System.out.println(set01);
Set set02 = setOperations.members("set02");
System.out.println(set02);
Set union = setOperations.union("set01", "set02");
System.out.println(union);
Set intersect = setOperations.intersect("set01", "set02");
System.out.println(intersect);
Set difference = setOperations.difference("set01", "set02");
System.out.println(difference);
}

opsForList获取对应Operations,专门用于List类型的数据操作。

常用新增操作leftPushAll或者rightPushAll,两个参数,key名字,value值。这个value可以一次性添加多个value。查询操作range,三个参数key名、起始索引、末尾索引。移除操作leftPop剔除头部第一个元素。

营业状态#

image.webp
image.webp

客户端#

@RestController("userShopController")
@RequestMapping("/user/shop")
@Slf4j
@Api("店铺相关接口")
public class ShopController {
public static final String KEY="SHOP_STATUS";
@Autowired
private RedisTemplate redisTemplate;
@GetMapping("/status")
@ApiOperation("查询营业状态")
public Result<Integer> getStatus(){
Integer shopStatus = (Integer) redisTemplate.opsForValue().get("SHOP_STATUS");
log.info("查询营业状态:{}",shopStatus == 1 ? "营业中" : "打烊中");
return Result.success(shopStatus);
}
}

商家端#

@RestController("adminShopController")
@RequestMapping("/admin/shop")
@Slf4j
@Api("店铺相关接口")
public class ShopController {
public static final String KEY="SHOP_STATUS";
@Autowired
private RedisTemplate redisTemplate;
@PutMapping("/{status}")
@ApiOperation("设置营业状态")
public Result setStatus(@PathVariable Integer status){
log.info("设置营业状态:{}",status == 1 ? "营业中" : "打烊中");
redisTemplate.opsForValue().set(KEY, status);
return Result.success();
}
@GetMapping("/status")
@ApiOperation("查询营业状态")
public Result<Integer> getStatus(){
Integer SHOP_STATUS = (Integer) redisTemplate.opsForValue().get(KEY);
log.info("查询营业状态:{}",SHOP_STATUS == 1 ? "营业中" : "打烊中");
return Result.success(SHOP_STATUS);
}
}
  • 直接在Controller层自动注入RedisTemplate对象
  • 常量SHOP_STATUS是Redis中存储营业状态的key名称。
  • 由于数据类型是String所以调用redisTemplate对象封装下的ValueOperations接口(opsForValue()),
  • get(SHOP_STATUS)即可获取到状态值。
  • 让我们返回的status是Integer类型,所以要把Object类型强转为Integer。
  • 配置类中已经把Value的序列化器设置为GenericJackson2JsonRedisSerializer,否则就会无法强转。

HttpClient#

初识#

HttpClient作用:*(核心)*

  • 让 Java 程序能够像浏览器 / Postman/Apifox 一样,主动发送 HTTP/HTTPS 请求,并处理服务端返回的响应
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.13</version>
</dependency>
核心 API作用
HttpClientHttp 客户端对象类型,使用该类型对象可发起 Http 请求
HttpClients可认为是构建器,可创建 HttpClient 对象
CloseableHttpClient实现类,实现了 HttpClient 接口
HttpGetGet 方式请求类型
HttpPostPost 方式请求类型

Get#

@SpringBootTest
public class HttpClientTest {
/**
* 测试通过httpclient发送GET方式的请求
*/
@Test
public void testGET() throws Exception{
//创建httpclient对象
CloseableHttpClient httpClient = HttpClients.createDefault();
//创建请求对象
HttpGet httpGet = new HttpGet("http://localhost:8080/user/shop/status");
//发送请求,接受响应结果
CloseableHttpResponse response = httpClient.execute(httpGet);
//获取服务端返回的状态码
int statusCode = response.getStatusLine().getStatusCode();
System.out.println("服务端返回的状态码为:" + statusCode);
HttpEntity entity = response.getEntity();
String body = EntityUtils.toString(entity);
System.out.println("服务端返回的数据为:" + body);
//关闭资源
response.close();
httpClient.close();
}
  • 1.利用HttpClients的createDefault方法创建一个HttpClient对象

  • 2.由于是发送Get请求,所以new一个HttpGet对象,请求路径就是/user/shop/status

  • 3.调用HttpClient对象的execute方法发送请求获取响应CloseableHttpResponse

  • 4.解析响应数据调用getStatusLine(获取状态行),getStatusCode(获取状态码)。也可以调用getEntity方法(获取响应体),

  • 因为响应体HttpEntity内部是二进制字节流无法直接转成String或JSON来获取业务数据所以调用自定义工具类EntityUtils对HttpEntity进行解析。

  • toString就是把响应体解析后转成可见的字符串形式并打印出来。

  • PS:HttpResponse、HttpEntity、StatusLine三者之间关系

  • HttpResponse 代表了整个 HTTP 响应的所有内容

  • 状态行(Status Line):比如状态码 200 OK、401 Unauthorized

  • 响应头(Headers):比如 Content-Type、Content-Length、Set-Cookie 等元信息

  • 响应体(Body):通过 getEntity() 方法获取,也就是 HttpEntity 对象

  • 作用:它让你能获取整个响应的所有信息,包括响应的状态、元数据和实际内

Post#

/**
* 测试通过httpclient发送POST方式的请求
*/
@Test
public void testPOST() throws Exception{
// 创建httpclient对象
CloseableHttpClient httpClient = HttpClients.createDefault();
//创建请求对象
HttpPost httpPost = new HttpPost("http://localhost:8080/admin/employee/login");
JSONObject jsonObject = new JSONObject();
jsonObject.put("username","admin");
jsonObject.put("password","123456");
StringEntity entity = new StringEntity(jsonObject.toString());
//指定请求编码方式
entity.setContentEncoding("utf-8");
//数据格式
entity.setContentType("application/json");
httpPost.setEntity(entity);
//发送请求
CloseableHttpResponse response = httpClient.execute(httpPost);
//解析返回结果
int statusCode = response.getStatusLine().getStatusCode();
System.out.println("响应码为:" + statusCode);
HttpEntity entity1 = response.getEntity();
String body = EntityUtils.toString(entity1);
System.out.println("响应数据为:" + body);
//关闭资源
response.close();
httpClient.close();
}

1.利用HttpClients的createDefault方法创建一个HttpClient对象

2.由于是发送POST请求,所以new一个HttpPOST对象,请求路径就是/admin/employee/login

3.构造请求体参数,登录请求请求体参数就是username和password,k-v结构使用Map进行存储。由于要把Map里面的数据传输过去,但因为Map是Java内存中的对象无法传输,所以使用Fastjson库提供的JSON.toJSONString方法,将Map对象序列化成JSON格式的字符串作为Http请求发送。StringEntity的作用就是把JSON格式的字符串封装成请求体。

4.请求体StringEntity还需要手动设置请求体编码以及类型,编码通过方法setContentEncoding设置为UTF-8,请求体参数类型通过方法setContentType设置为json。这一步如果不做的话就会报415异常。

5.调用HttpClient对象的execute方法发送请求获取响应CloseableHttpResponse

6.解析响应数据调用getStatusLine(获取状态行),getStatusCode(获取状态码)。调用getEntity方法(获取响应体),利用工具类EntityUtils解析成可见字符串并打印出来。

PS:常见4开头报错类型

404:资源找不到

405:路径找到了但是请求方式不对(eg.Get请求写成Post请求)

401:没有登录没有权限导致,检查是否校验成功了(eg.无下发JWT令牌)

400:参数相关(eg.参数写错了或者少写了)

415:服务器不支持请求体数据格式或本身不匹配或者未设置请求头之类的

微信登录#

yml#

wechat:
appid: ${sky.wechat.appid}
secret: ${sky.wechat.secret}
sky:
jwt:
# 设置jwt签名加密时使用的秘钥
admin-secret-key: itcast
# 设置jwt过期时间
admin-ttl: 7200000
# 设置前端传递过来的令牌名称
admin-token-name: token
# 用户
user-secret-key: itheima
user-ttl: 720000000
user-token-name: authentication

实体类#

/**
* C端用户登录
*/
@Data
public class UserLoginDTO implements Serializable {
private String code;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserLoginVO implements Serializable {
private Long id;
private String openid;
private String token;
}

Controller#

image.webp
image.webp

  • 小程序端,调用wx.login()获取code,就是授权码。 电子邮件与即时消息

  • 小程序端,调用wx.request()发送请求并携带code,请求开发者服务器。

  • 开发者服务端,通过HttpClient向微信接口服务发送请求,并携带对应参数。

  • 开发者服务端,接收微信接口服务返回的数据。opendId是微信用户的唯一标识。

  • 开发者服务端,自定义登录态,生成令牌(token)和openid等数据返回给小程序端。

  • 小程序端,收到自定义登录态,存储storage。

  • 小程序端,后绪通过wx.request()发起业务请求时,携带token。

  • 开发者服务端,收到请求后,通过携带的token,解析当前登录用户的id。

  • 开发者服务端,身份校验通过后,继续相关的业务逻辑处理,最终返回业务数据。

    image.webp
    image.webp

  • 在小程序端获取授权码code后,向后端服务发送请求,并携带授权码,

  • 后端服务在收到授权码后,就可以去请求微信接口服务。最终,后端向小程序返回openid和token

@RestController
@Api(tags = "C端用户接口")
@Slf4j
@RequestMapping("/user/user")
public class UserController {
@Autowired
private UserService userService;
@Autowired
private JwtProperties jwtProperties;
@ApiOperation("用户登录")
@PostMapping("/login")
public Result<UserLoginVO> Login(@RequestBody UserLoginDTO userLoginDTO ) {
log.info("用户登录:{}", userLoginDTO);
//根据code获取到登录的用户信息
User user= userService.wxlogin(userLoginDTO);
//如果登录成功,获取jwt令牌
Map<String, Object> claims=new HashMap<>();
claims.put(JwtClaimsConstant.USER_ID,user.getId());
String token=JwtUtil.createJWT(
jwtProperties.getUserSecretKey(),//密钥
jwtProperties.getUserTtl(),//过期时间
claims
);
//@builder便于构建对象
UserLoginVO userLoginVO=UserLoginVO.builder()
.id(user.getId())
.openid(user.getOpenid())
.token(token)
.build();
return Result.success(userLoginVO);
}
  • 返回值为自定义类UserLoginVO,封装了用户id,openid,JWT令牌

  • 根据传入UserLoginDTO里面封装的临时登录凭证code,获取到登录的用户。

  • 登录成功生成JWT令牌,先创建一个Map存放载荷信息claims(key-value),此处需要存放的载荷信息是USER_ID

  • 调用工具类JwtiUtil的createJWT方法生成JWT令牌,

  • 主要传入签名密钥、过期时间、载荷信息,前两个参数主要在我们的yml文件里面配置即可第

  • 构造UserLoginVO对象,把用户id,openid,JWT令牌都封装进去返回即可。

Service#

@Service
@Slf4j
public class UserServiceImpl implements UserService {
//微信服务接口地址
public static final String WX_LOGIN = "https://api.weixin.qq.com/sns/jscode2session";
@Autowired
private WeChatProperties weChatProperties;
@Autowired
private UserMapper userMapper;
/**
* 微信登录
* @param userLoginDTO
* @return
*/
public User wxLogin(UserLoginDTO userLoginDTO) {
String openid = getOpenid(userLoginDTO.getCode());
//判断openid是否为空,如果为空表示登录失败,抛出业务异常
if(openid == null){
throw new LoginFailedException(MessageConstant.LOGIN_FAILED);
}
//判断当前用户是否为新用户
User user = userMapper.getByOpenid(openid);
//如果是新用户,自动完成注册
if(user == null){
user = User.builder()
.openid(openid)
.createTime(LocalDateTime.now())
.build();
userMapper.insert(user);//后绪步骤实现
}
//返回这个用户对象
return user;
}
/**
* 调用微信接口服务,获取微信用户的openid
* @param code
* @return
*/
private String getOpenid(String code){
//调用微信接口服务,获得当前微信用户的openid
Map<String, String> map = new HashMap<>();
map.put("appid",weChatProperties.getAppid());
map.put("secret",weChatProperties.getSecret());
map.put("js_code",code);
map.put("grant_type","authorization_code");
String json = HttpClientUtil.doGet(WX_LOGIN, map);
JSONObject jsonObject = JSON.parseObject(json);
String openid = jsonObject.getString("openid");
return openid;
}
  • 开发者服务器向微信接口服务发送请求的过程,这里可以查看微信官方文档服务端,可以看到类似于接口文档的信息,这个就是向第三方服务发送请求的解释文档。

  • 第一步构造请求参数k-v结构可以使用Map进行存储,主要是appid(依旧在小程序开发管理模块可以看到),secret(同上,这两个参数都提前配置在了WeChatProperties中了),

  • 临时登录凭证code,这个封装在请求参数UserLoginDTO中,第四个参数就是固定值直接填即可。然后调用工具类HttpClientUtil的doGet方法,传入目标URL和实现封装好的请求参数。

  • 解析响应结果,doGet方法返回的是JSON字符串,利用JSON的parseObject方法可以把JSON字符串转换成可操作的JSONObject对象,轻松提取出里面各个key对应的value。这里先可以提取出openid,通过判断openid是否存在,如果不存在那就说明登陆失败了,即第三方微信接口服务不允许登录,那就抛出自定义异常。

  • 判断是否为新用户,调用Mapper层的selectByOpenid方法,如果能查到说明不是新用户,如果查不到说明是新用户,那就到了第四步。

  • 如果user为空,那就new一个新的User,调用一些set方法(最重要的还是setOpenid,openid字段是唯一判断微信用户是否存在的标识),然后调用Mapper层插入这个user即可。最后都是返回User对象

Mapper#

@Mapper
public interface UserMapper {
//微信登录根据code查询User表中openai是否存在
@Select("select * from user where openid=#{openid}")
User getByOpenid(String openid);
void insert(User user);
}
<mapper namespace="com.sky.mapper.UserMapper">
<!-- 新增微信用户信息-->
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into user (openid, name, phone, sex, id_number, avatar, create_time)
values (#{openid}, #{name}, #{phone}, #{sex}, #{idNumber}, #{avatar}, #{createTime})
</insert>
</mapper>

JWT拦截器#

@Component
@Slf4j
public class UserTokenAdminInterceptor implements HandlerInterceptor {
@Autowired
private JwtProperties jwtProperties;
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//判断当前拦截到的是Controller的方法还是其他资源
if (!(handler instanceof HandlerMethod)) {
//当前拦截到的不是动态方法,直接放行
return true;
}
//1、从请求头中获取令牌
String token = request.getHeader(jwtProperties.getUserTokenName());
//2、校验令牌
try {
log.info("jwt校验:{}", token);
Claims claims = JwtUtil.parseJWT(jwtProperties.getUserSecretKey(), token);
Long userId = Long.valueOf(claims.get(JwtClaimsConstant.USER_ID).toString());
log.info("当前员工id:{}", userId);
/////将微信用户id存储到ThreadLocal////////
BaseContext.setCurrentId(userId);
//3、通过,放行
return true;
} catch (Exception ex) {
//4、不通过,响应401状态码
response.setStatus(401);
return false;
}
}
}
// 注册自定义拦截器
protected void addInterceptors(InterceptorRegistry registry) {
//管理端拦截请求
log.info("开始注册自定义拦截器...");
registry.addInterceptor(jwtTokenAdminInterceptor)
.addPathPatterns("/admin/**")
.excludePathPatterns("/admin/employee/login");
//微信用户端拦截请求
registry.addInterceptor(jwtTokenUserInterceptor)
.addPathPatterns("/user/**")
.excludePathPatterns("/user/user/login")
.excludePathPatterns("/user/shop/status");
}

商品浏览#

分类类型查询#

Controller#

image.webp
image.webp

  • 查询分类是个Get请求
  • 请求参数只有个type。
  • 返回值可以使用Category类封装,data显示object[],说明查询出来是很多条数据所以就是List
// 根据类型查询分类
@GetMapping("/list")
@ApiOperation("根据类型查询分类")
public Result<List<Category>> list(Integer type){
List<Category> list = categoryService.list(type);
return Result.success(list);
}

Service#

//根据类型查询商品
@Override
public List<Category> getByType(Integer type) {
return categoryMapper.list(type);
}

Mapper#

<!-- 管理端和用户端查询分类-->
<!-- 根据传入类型动态选择分类-->
<select id="getByType" resultType="Category">
select * from category
where status = 1
<if test="type != null">
and type = #{type}
</if>
order by sort asc,create_time desc
</select>

分类id查询#

Controller#

image.webp
image.webp

  • 请求参数就是分类id
  • 返回数据不仅有该分类下所有菜品的基本信息
  • 每个菜品的口味信息,有无口味就体现在是“+号”还是“选择规格”。
@RestController("userDishController")
@RequestMapping("/user/dish")
@Slf4j
public class DishController {
@Autowired
private DishService dishService;
//商品浏览
//根据分类id查询菜品
public Result<List<DishVO>> listDIsh(Long categoryId){
List<DishVO> list = dishService.selectByCategoryId(categoryId);
return Result.success(list);
}

Service#

/根据分类id查询菜品和口味
@Override
public List<DishVO> selectByCategoryId(Long categoryId) {
DishDTO dto = new DishDTO();
//为菜品附分类id
dto.setCategoryId(categoryId);
//根据分类id查询菜品
List<Dish> dishList = dishMapper.select(dto);
//创建返回对象
List<DishVO> dishVOList = new ArrayList<>();
//遍历菜品集合,将菜品数据封装成dishVO对象,添加到dishVOList中每一条数据
for (Dish dish : dishList) {
DishVO dishVO = new DishVO();
BeanUtils.copyProperties(dish, dishVO);
//根据菜品id查询口味
List<DishFlavor> dishFlavorList =dishFlavorMapper.selectByDishId(dish.getId());
//将菜品口味数据封装成dishVO对象
dishVO.setFlavors(dishFlavorList);
//添加dish菜品属性
dishVOList.add(dishVO);
}
return dishVOList;

Mapper#

//根据菜品id查询口味
@Select("select * from dish_flavor where dish_id = #{dishId}")
List<DishFlavor> selectByDishId(Long id);
<select id="select" resultType="com.sky.entity.Dish">
select * from dish
<where>
<if test="id != null">id = #{id}</if>
<if test="name != null and name != ''">name like concat('%',#{name},'%')</if>
<if test="categoryId != null">category_id = #{categoryId}</if>
<if test="price != null">price = #{price}</if>
<if test="image != null and image != ''">image = #{image}</if>
<if test="description != null and description != ''">description = #{description}</if>
<if test="status != null">status = #{status}</if>
</where>
</select>

套餐里菜品#

Controller#

image.webp
image.webp

@ApiOperation("根据套餐id查询包含的菜品")
@GetMapping("/dish/{id}")
public Result<List<DishVO>> selectDishById(@PathVariable Long id) {
List<DishVO> list = setmealService.selectDishById(id);
return Result.success(list);
}

Service#

@Override
public List<DishVO> selectDishById(Long id) {
return setmealMapper.selectDishById(id);
}

Mapper#

@Select("select t2.*,t1.copies from setmeal_dish t1, dish t2 where t1.setmeal_id = #{id} and t1.dish_id = t2.id")
List<DishVO> selectDishById(Long id);

缓存菜品#

Controller#

问题引入用户端访问量比较大,数据库访问压力随之增大

查询流程用户使用小程序查询菜品时调用后端服务对应接口,在调用对应方法查询数据库之前,先判断缓存是否存在,如果存在就直接读取缓存,如果不存在再调用具体方法查询数据库,并把查询结果载入缓存当中。

key为每个分类下的菜品/套餐保存一份缓存数据(dish/setmeal区分是菜品还是套餐的缓存,1,2,3则是区分不同分类

value里面是相同分类下所有的菜品/套餐数据。

数据库中有菜品数据发生变更时清理缓存数据,避免缓存中查询的数据跟数据库中真正的数据不一样,保持数据的一致性。保底操作可以设置到指定时间就清理一下缓存,不至于手动清理缓存数据出现问题时,缓存数据一直不清理。

@RestController("userDishController")
@RequestMapping("/user/dish")
@Slf4j
public class DishController {
@Autowired
private DishService dishService;
@Autowired
private RedisTemplate redisTemplate;
//商品浏览
//根据分类id查询菜品和口味
@GetMapping("/list")
public Result<List<DishVO>> listDIsh(Long categoryId){
//构造redis的缓存key 规则:dish_分类id
String key = "dish_" + categoryId;
//根据key查询菜品缓存
List<DishVO> list =(List<DishVO>)redisTemplate.opsForValue().get(key);
if(list != null && list.size() > 0){
return Result.success(list);
}
//缓存中没有数据,查询数据库
list = dishService.selectByCategoryId(categoryId);
//将数据放入缓存
redisTemplate.opsForValue().set(key,list);
return Result.success(list);
}

自动注入RedisTemplate,这里我们以缓存菜品举例,构造Redis缓存的key,key的前面应该是dish_跟缓存套餐区分,后面的数据1,2,3则是区分不同分类,所以取categoryId拼接。在调用数据库查询方法前先查询Redis中是否有缓存数据,如果存在数据那就可以直接把缓存数据返回给前端,否则就执行MYSQL查询语句,并将查询到的数据载入缓存。

清理缓存#

在数据库进行增删改等操作时,数据库中数据会发生变化,但是Redis中缓存的数据未发生变化从而导致数据库和Redis中的缓存的数据不一致,故而在对数据库进行操作时,需要加入清理缓存逻辑来****清理Redis中对应的未同步缓存数据

//新增菜品
@PostMapping
@ApiOperation("新增菜品")
public Result save(@RequestBody DishDTO dishDTO) {
log.info("新增菜品:{}", dishDTO);
dishService.saveWithFlavor(dishDTO);//后绪步骤开发
//缓存优化
redisTemplate.delete("dish_" + dishDTO.getCategoryId());
//缓存优化
Set keys = redisTemplate.keys("dish_*");
redisTemplate.delete(keys);

在管理端的DishController层进行清理缓存操作,标准就是如果某个分类下的数据被更新了那么就要清楚这个分类下的缓存数据。像新增菜品操作,只有一个分类下(categoryId)的数据被更新了,所以只需要把这个分类的缓存清除,调用redisTemplate的delete方法。批量删除和更新菜品可能都会涉及到多个分类下数据的变更,因此使用通配符“dish_”获取菜品的所有分类的key名称(keys),然后调用delete方法批量删除所有key,清理缓存。菜品起售、停售虽然只涉及到一个分类下数据的更新,但由于请求参数中无法拿到categoryId,所以也使用缓存全部清除的方法。

SpringCache#

maven#

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
  • SpringCache是Spring框架提供的缓存抽象层,通过注解简化缓存操作。它整合了多种缓存实现(如EhCache、Redis、Caffeine等),开发者无需关心底层细节,只需通过注解声明缓存规则。核心思想是通过AOP在方法调用前后自动处理缓存读写,提升性能并减少重复计算。
注解说明
@EnableCaching开启缓存注解功能,通常加在启动类上
@Cacheable在方法执行前先查询缓存中是否有数据,如果有数据,则直接返回缓存数据;如果没有缓存数据,调用方法并将方法返回值放到缓存中。通常加在方法上
@CachePut将方法的返回值放到缓存中
@CacheEvict将一条或多条数据从缓存中删除

@EnableCaching#

@SpringBootApplication
@EnableTransactionManagement //开启注解方式的事务管理
@EnableCaching//开启缓存注解功能
@Slf4j
public class SkyApplication {
public static void main(String[] args) {
SpringApplication.run(SkyApplication.class, args);
log.info("server started");
}
}

@CachePut#

CachePut:将方法返回值放入缓存
* value:缓存的名称,每个缓存名称下面可以有多个key
* key:缓存的key
//@CachePut(value = "userCache", key = "#user.id")
key的生成:userCache::1
key解释
#user.id使用 userid 属性作为 key
#result.id以返回对象的 id 属性作为 key
#p0.id使用第一个参数 p0id 属性作为 key
#a0.id使用第一个参数的 id 属性作为 key
#root.args[0].id使用第一个参数的 id 属性作为 key

@Cacheable#

@Cacheable(cacheNames = "setmealCache",key = "#categoryId")
@ApiOperation("根据分类id查询套餐")
@GetMapping("/list")
public Result<List<Setmeal>> selectByCategoryId(Long categoryId) {
List<Setmeal> list = setmealService.selectByCategoryId(categoryId);
return Result.success(list);
}

Cacheable orq.springframework.cache.annotation是这个包

@CacheEvict#

//新增套餐
@PostMapping
@ApiOperation("新增套餐")
@CacheEvict(cacheNames = "setmeal",key ="#dto.categoryId")
//key: setmealCache::100
public Result save(@RequestBody SetmealDTO dto) {
setmealService.saveWithDish(setmealDTO);
return Result.success();
}
// 批量删除套餐
@DeleteMapping
@ApiOperation("批量删除套餐")
@CacheEvict(cacheNames = "setmealCache",allEntries = true)
public Result delete(@RequestParam List<Long> ids) {
setmealService.deleteBatch(ids);
return Result.success();
}
//修改套餐
@PutMapping
@ApiOperation("修改套餐")
@CacheEvict(cacheNames = "setmealCache",allEntries = true)
public Result update(@RequestBody SetmealDTO setmealDTO) {
setmealService.update(setmealDTO);
return Result.success();
}
//套餐起售停售
@PostMapping("/status/{status}")
@ApiOperation("套餐起售停售")
@CacheEvict(cacheNames = "setmealCache",allEntries = true)
public Result startOrStop(@PathVariable Integer status, Long id) {
setmealService.startOrStop(status, id);
return Result.success();
}

添加购物车#

Controller#

image.webp
image.webp

image.webp
image.webp

  • 添加购物车,请求参数只有setmealId,添加套餐
  • 有dishId和dishFlavor,添加菜品,最终目的是把用户添加的商品存到shopping_cart表。
@RestController("userShoppingCartController")
@RequestMapping("/user/shoppingCart")
@Api(tags = "C端购物车接口")
@Slf4j
public class ShoppingCartController {
@Autowired
private ShoppingCartService shoppingCartService;
//添加购物车
@PostMapping("/add")
@ApiOperation("添加购物车")
public Result add(@RequestBody ShoppingCartDTO dto){
log.info("添加购物车:{}",dto);
shoppingCartService.add(dto);
return Result.success();
}

Service#

@Override
public void add(ShoppingCartDTO dto) {
// 创建ShoppingCart对象
ShoppingCart shoppingCart = new ShoppingCart();
// 拷贝属性值
BeanUtils.copyProperties(dto, shoppingCart);
// 1. 判断该商品是否已经存在于购物车 -- 条件:dishId+dishFlavor+userId
// 只查当前用户自己的购物车
shoppingCart.setUserId(BaseContext.getCurrentId());
ShoppingCart cart = shoppingCartMapper.selectBy(shoppingCart);
if (cart == null) { // 代表购物车没有该商品数据
// 2.补充缺失的属性值
// 判断是新增套餐还是新增菜品
if (dto.getDishId() != null) { // 代表新增菜品
// 根据菜品的id查询菜品表,获取菜品相关信息
Dish dish = dishMapper.selectById(dto.getDishId());
shoppingCart.setName(dish.getName());
shoppingCart.setImage(dish.getImage());
shoppingCart.setAmount(dish.getPrice());
} else { // 代表新增套餐
SetmealVO setmealVO = setmealMapper.selectById(dto.getSetmealId());
shoppingCart.setName(setmealVO.getName());
shoppingCart.setImage(setmealVO.getImage());
shoppingCart.setAmount(setmealVO.getPrice());
}
shoppingCart.setNumber(1); // 数量设置为1,因为判断购物车没有该商品数据
shoppingCart.setCreateTime(LocalDateTime.now());
// 3.将商品数据存入到shopping_cart表中
shoppingCartMapper.insert(shoppingCart);
} else { // 代表购物车有该商品数据
// 4.将原来的购物车商品数量+1,调用Mapper的更新方法
cart.setNumber(cart.getNumber() + 1);
shoppingCartMapper.update(cart);
}
// 最终目的:将用户添加的商品,存入到shopping_cart表
}

将用户添加的商品存入到shopping_cart表中,那针对商品是否已经存在于购物车中,就要分insert和update两种操作,insert操作需要把数据都封装到ShoppingCart实体类中,update操作只需要把shopping_cart表中查出来的已经存在的商品数据更新一下商品数量和创建时间即可。

那对于insert操作首先就需要新建一个ShoppingCart对象封装数据,先把DTO里面有的数据都尽量先复制到ShoppingCart对象中,接下来就要先根据这个对象查询shopping_cart表中是不是已经存在该商品数据了。那确定某一种商品数据需要(dishId+dishFlavor)/(setmealId) + userId(只查当前用户自己的购物车),前面的参数基本上都已经被我们复制到ShoppingCart对象里面了,userId可以通过用户端拦截器处的ThreadLocal中取到,这样就能通过对象参数唯一查找到一种商品数据,调用shoppingCartMapper的selectBy方法查询cart即可。

如果cart为空,就说明购物车中还没有该商品数据(这一步逻辑判断主要是为了后续setNumber时究竟时设置为1还是取原来的Number进行+1操作)。下一步就是补充缺失的属性值了,但在此之前还需要一层逻辑判断,即用户的添加操作究竟是添加菜品还是套餐,添加的东西不同,那需要补充的属性值也不同,一个要从dishMapper里面查数据,一个要从setmealMapper中查数据,最后封装到shoppingcart对象里面,二者公有的属性补充(Number、CreateTime)就抽取在外面,最后执行insert操作。

如果cart不为空,即购物车已经存在该商品数据了那就很简单直接利用最外层我们调用selectBy方法查出来的cart对象,在cart基础上重新setNumber(自增),然后调用update操作

Mapper#

<!-- 1.根据用户id查询购物车数据-->
<select id="selectBy" resultType="com.sky.entity.ShoppingCart">
select * from shopping_cart
<where>
<if test="userId != null">
and user_id = #{userId}
</if>
<if test="dishId != null">
and dish_id = #{dishId}
</if>
<if test="setmealId != null">
and setmeal_id = #{setmealId}
</if>
<if test="dishFlavor != null">
and dish_flavor = #{dishFlavor}
</if>
</where>
order by create_time desc
</select>
//用户端添加套餐
//根据id查询菜品
@Select("select * from dish where id = #{id}")
Dish selectById(Long id);
//根据id查询套餐
@Select("select t1.*,t2.name categoryName from setmeal t1,category t2 where t1.category_id = t2.id and t1.id = #{id}")
SetmealVO selectByup(Long id);
/2.如果存在数据,则数量加1
@Update("update shopping_cart set number=#{number} where id=#{id}")
void update(ShoppingCart cart);
//3.如果不存在数据,则插入数据
@Insert("insert into shopping_cart (name, image, user_id, dish_id, setmeal_id, dish_flavor, amount, create_time, number) " +
"values (#{name},#{image},#{userId},#{dishId},#{setmealId},#{dishFlavor},#{amount},#{createTime},#{number})")
void insert(ShoppingCart shoppingCart);

查看购物车#

Controller#

image.webp
image.webp

@GetMapping("/list")
public Result list() {
List<ShoppingCart> list = shoppingCartService.select();
return Result.success(list);
}

Service#

@Override
public List<ShoppingCart> select() {
return shoppingCartMapper.select(BaseContext.getCurrentId());
}

Mapper#

@Select("select * from shopping_cart where user_id = #{userId}")
List<ShoppingCart> select(Long userId);

删除一个#

Controller#

image.webp
image.webp

//删除购物车中的一个商品
@PostMapping("/sub")
public Result sub(@RequestBody ShoppingCartDTO dto) {
log.info("删除购物车商品:{}", dto);
shoppingCartService.sub(dto);
return Result.success();
}
  • 点击“-号”删除购物车中一个商品,分两种情况,如果商品数量是一个,那么就直接删除这个商品数据
  • 如果商品数量大于一个,就是调用update方法商品数量Number自减即可

Service#

@Override
public void sub(ShoppingCartDTO dto) {
// 创建ShoppingCart对象
ShoppingCart shoppingCart = new ShoppingCart();
BeanUtils.copyProperties(dto, shoppingCart);
// 只减自己的购物车
shoppingCart.setUserId(BaseContext.getCurrentId());
// 查询表中已经存在的商品信息
ShoppingCart cart = shoppingCartMapper.selectBy(shoppingCart);
// 商品数量等于1就做删除操作
if (cart.getNumber() == 1) {
shoppingCartMapper.deleteBy(shoppingCart);
} else { // 商品数量大于1就做自减操作
cart.setNumber(cart.getNumber() - 1);
shoppingCartMapper.update(cart);
}
}

Mapper#

<select id="selectBy" resultType="com.sky.entity.ShoppingCart">
select * from shopping_cart
<where>
<if test="userId != null">
and user_id = #{userId}
</if>
<if test="dishId != null">
and dish_id = #{dishId}
</if>
<if test="setmealId != null">
and setmeal_id = #{setmealId}
</if>
<if test="dishFlavor != null">
and dish_flavor = #{dishFlavor}
</if>
</where>
order by create_time desc
</select>
<delete id="deleteBy">
delete from shopping_cart
<where>
<if test="userId != null">
and user_id = #{userId}
</if>
<if test="dishId != null">
and dish_id = #{dishId}
</if>
<if test="setmealId != null">
and setmeal_id = #{setmealId}
</if>
<if test="dishFlavor != null">
and dish_flavor = #{dishFlavor}
</if>
</where>
</delete>
@Update("update shopping_cart set number=#{number} where id=#{id}")

用户下单#

Controller#

image.webp
image.webp

表名含义说明
orders订单表主要存储订单的基本信息(如: 订单号、状态、金额、支付方式、下单用户、收件地址等)
order_detail订单明细表主要存储订单详情信息(如: 该订单关联的套餐及菜品的信息)
字段名数据类型说明备注
idbigint主键自增
numbervarchar(50)订单号
statusint订单状态1待付款 2待接单 3已接单 4派送中 5已完成 6已取消
user_idbigint用户id逻辑外键
address_book_idbigint地址id逻辑外键
order_timedatetime下单时间
checkout_timedatetime付款时间
pay_methodint支付方式1微信支付 2支付宝支付
pay_statustinyint支付状态0未支付 1已支付 2退款
amountdecimal(10,2)订单金额
remarkvarchar(100)备注信息
phonevarchar(11)手机号冗余字段
addressvarchar(255)详细地址信息冗余字段
consigneevarchar(32)收货人冗余字段
cancel_reasonvarchar(255)订单取消原因
rejection_reasonvarchar(255)拒单原因
cancel_timedatetime订单取消时间
estimated_delivery_timedatetime预计送达时间
delivery_statustinyint配送状态1立即送出 0选择具体时间
delivery_timedatetime送达时间
pack_amountint打包费
tableware_numberint餐具数量
tableware_statustinyint餐具数量状态1按餐量提供 0选择具体数量
字段名数据类型说明备注
idbigint主键自增
namevarchar(32)商品名称冗余字段
imagevarchar(255)商品图片路径冗余字段
order_idbigint订单id逻辑外键
dish_idbigint菜品id逻辑外键
setmeal_idbigint套餐id逻辑外键
dish_flavorvarchar(50)菜品口味
numberint商品数量
amountdecimal(10,2)商品单价
@PostMapping("/submit")
@ApiOperation("用户下单")
public Result<OrderSubmitVO> submit(@RequestBody OrdersSubmitDTO ordersSubmitDTO) {
log.info("用户下单:{}", ordersSubmitDTO);
OrderSubmitVO orderSubmitVO = orderService.submitOrder(ordersSubmitDTO);
return Result.success(orderSubmitVO);
}

Service#

@Service
@Slf4j
public class OrderServiceImpl implements OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private OrderDetailMapper orderDetailMapper;
@Autowired
private ShoppingCartMapper shoppingCartMapper;
@Autowired
private AddressBookMapper addressBookMapper;
//用户下单
@Transactional
public OrderSubmitVO submitOrder(OrdersSubmitDTO ordersSubmitDTO) {
//异常情况的处理(收货地址为空、超出配送范围、购物车为空)
AddressBook addressBook = addressBookMapper.getById(ordersSubmitDTO.getAddressBookId());
if (addressBook == null) {
throw new AddressBookBusinessException(MessageConstant.ADDRESS_BOOK_IS_NULL);
}
Long userId = BaseContext.getCurrentId();
ShoppingCart shoppingCart = new ShoppingCart();
shoppingCart.setUserId(userId);
//查询当前用户的购物车数据
List<ShoppingCart> shoppingCartList = shoppingCartMapper.list(shoppingCart);
if (shoppingCartList == null || shoppingCartList.size() == 0) {
throw new ShoppingCartBusinessException(MessageConstant.SHOPPING_CART_IS_NULL);
}
//构造订单数据
Orders order = new Orders();
BeanUtils.copyProperties(ordersSubmitDTO,order);
order.setPhone(addressBook.getPhone());
order.setAddress(addressBook.getDetail());
order.setConsignee(addressBook.getConsignee());
order.setNumber(String.valueOf(System.currentTimeMillis()));
order.setUserId(userId);
order.setStatus(Orders.PENDING_PAYMENT);
order.setPayStatus(Orders.UN_PAID);
order.setOrderTime(LocalDateTime.now());
//向订单表插入1条数据
orderMapper.insert(order);
//订单明细数据
List<OrderDetail> orderDetailList = new ArrayList<>();
for (ShoppingCart cart : shoppingCartList) {
OrderDetail orderDetail = new OrderDetail();
BeanUtils.copyProperties(cart, orderDetail);
orderDetail.setOrderId(order.getId());
orderDetailList.add(orderDetail);
}
//向明细表插入n条数据
orderDetailMapper.insertBatch(orderDetailList);
//清理购物车中的数据
shoppingCartMapper.deleteByUserId(userId);
//封装返回结果
OrderSubmitVO orderSubmitVO = OrderSubmitVO.builder()
.id(order.getId())
.orderNumber(order.getNumber())
.orderAmount(order.getAmount())
.orderTime(order.getOrderTime())
.build();
return orderSubmitVO;
}

Mapper#

<mapper namespace="com.sky.mapper.OrderMapper">
<insert id="insert" parameterType="Orders" useGeneratedKeys="true" keyProperty="id">
insert into orders
(number, status, user_id, address_book_id, order_time, checkout_time, pay_method, pay_status, amount, remark,
phone, address, consignee, estimated_delivery_time, delivery_status, pack_amount, tableware_number,
tableware_status)
values (#{number}, #{status}, #{userId}, #{addressBookId}, #{orderTime}, #{checkoutTime}, #{payMethod},
#{payStatus}, #{amount}, #{remark}, #{phone}, #{address}, #{consignee},
#{estimatedDeliveryTime}, #{deliveryStatus}, #{packAmount}, #{tablewareNumber}, #{tablewareStatus})
</insert>
</mapper>
<insert id="insertBatch" parameterType="list">
insert into order_detail
(name, order_id, dish_id, setmeal_id, dish_flavor, number, amount, image)
values
<foreach collection="orderDetails" item="od" separator=",">
(#{od.name},#{od.orderId},#{od.dishId},#{od.setmealId},#{od.dishFlavor},
#{od.number},#{od.amount},#{od.image})
</foreach>
</insert>

用户端#

用户下单功能进行优化,加入加入校验逻辑,如果用户的收货地址距离商家门店超出配送范围(配送范围为5公里内),则下单失败。另外需要完成用户端的历史订单模块以及商家端订单管理模块

查询历史订单#

Controller#

image.webp
image.webp

请求参数page和pageSize可以看出这个查询是做一个分页查询操作,status可以发现有全部订单、待付款、已取消三种选项页面,status通过一个动态SQL查询,条件status,用自定义DTO类OrdersPageQueryDTO封装

userId作为条件, 返回值可以使用Orders进行封装SQL语句查询结果,需要注意orderDetailList字段是个List,OrderDetail类的所有字段属性都来自另一个表order_detail,因此需要联表查询,可以通过一次Select语句完成全部属性的封装,在.xml文件里面利用resultMap、,由于两张表一次性查存在重复字段,需要给其中一张表的查询字段添加统一前缀(别名)进行区分,防止封装时属性相互覆盖。

//历史订单查询
@ApiOperation("历史订单查询")
@GetMapping("/historyOrders")
public Result<PageResult> historyOrders(OrdersPageQueryDTO dto) {
log.info("历史订单查询:{}",dto);
PageResult pageResult = orderService.historyOrders(dto);
return Result.success(pageResult);
}

Service#

@Override
public PageResult historyOrders(OrdersPageQueryDTO dto) {
// 设置分页参数
PageHelper.startPage(dto.getPage(), dto.getPageSize());
// 调用Mapper
dto.setUserId(BaseContext.getCurrentId());
Page<Orders> page = ordersMapper.list(dto);
// 返回PageResult
return new PageResult(page.getTotal(), page.getResult());
}
  • 设置分页参数(请求参数DTO里面封装了传过来),调用Mapper(只看用户自己历史订单设置userid,每条数据用Orders类进行封装,Page泛型就写Orders,
  • 把Orders的主表字段和联表字段orderDetailList的全部属性一次性
  • 商家端订单搜索接口采用分两次SQL查询封装的操作,后一种方法利于Mapper层方法的复用,不过Service层逻辑会稍微复杂一些),返回新建的PageResult(这里page.getResult()其实就是得到了List,所以Page的泛型就是查出来的每行数据究竟用什么封装,记住这一点分页查询就不会忘了)

Mapper#

<resultMap id="historyOrders" type="com.sky.entity.Orders">
<result property="id" column="id"/>
<result property="number" column="number"/>
<result property="status" column="status"/>
<result property="userId" column="user_id"/>
<result property="addressBookId" column="address_book_id"/>
<result property="orderTime" column="order_time"/>
<result property="checkoutTime" column="checkout_time"/>
<result property="payMethod" column="pay_method"/>
<result property="payStatus" column="pay_status"/>
<result property="amount" column="amount"/>
<result property="remark" column="remark"/>
<result property="phone" column="phone"/>
<result property="address" column="address"/>
<result property="userName" column="user_name"/>
<result property="consignee" column="consignee"/>
<result property="cancelReason" column="cancel_reason"/>
<result property="rejectionReason" column="rejection_reason"/>
<result property="cancelTime" column="cancel_time"/>
<result property="estimatedDeliveryTime" column="estimated_delivery_time"/>
<result property="deliveryStatus" column="delivery_status"/>
<result property="deliveryTime" column="delivery_time"/>
<result property="packAmount" column="pack_amount"/>
<result property="tablewareNumber" column="tableware_number"/>
<result property="tablewareStatus" column="tableware_status"/>
<collection property="orderDetailList" ofType="com.sky.entity.OrderDetail" columnPrefix="od_">
<result property="id" column="id"/>
<result property="name" column="name"/>
<result property="image" column="image"/>
<result property="orderId" column="order_id"/>
<result property="dishId" column="dish_id"/>
<result property="setmealId" column="setmeal_id"/>
<result property="dishFlavor" column="dish_flavor"/>
<result property="number" column="number"/>
<result property="amount" column="amount"/>
</collection>
</resultMap>
<select id="list" resultMap="historyOrders">
select t1.*,t2.id as 'od_id', t2.name as 'od_name', t2.image as 'od_image', t2.order_id as 'od_order_id',
t2.dish_id as 'od_dish_id', t2.setmeal_id as 'od_setmeal_id', t2.dish_flavor as 'od_dish_flavor', t2.number as
'od_number', t2.amount as 'od_amount' from order_detail t2 join orders t1 on t1.id = t2.order_id
<where>
<if test="status != null">t1.status = #{status}</if>
<if test="userId != null">and t1.user_id = #{userId}</if>
</where>
order by t1.order_time desc
</select>
  • 嵌套查询Orders类存在字段orderDetailList,它属于List
  • resultType已经无法满足这种业务需求(一次SQL语句,属性里面套属性使用xml文件里面的resultMap,id属性是方便SQL语句对其进行引用,type属性则是返回结果封装到哪个类。
  • 标签先表示Orders主表字段,property属性是结果封装类里的属性名,column属性是SQL查询出的字段名(有别名优先别名,没有别名就是表中的字段名)。

专门使用resultMap的情形所在(属性里面包属性),属性OrderDetail内又包含了多个属性,至于的使用是因为每个订单Orders类对应了多个OrderDetail类,即List,property属性依然是封装类Orders的属性名,ofType属性是这个集合的泛型类(of有种里面的意思就可以记成List里面的东西是什么?),columnPrefix主要是前缀(逻辑是内部的的column属性加上这个前缀columnPrefix就是SQL查询OrderDetail属性内部属性时的字段名,同上别名或者order_detail表中字段名)。观察下面的select语句可以发现resultMap引用了上面的id,然后查order_detail表的时候我把这个表查出来的每个字段都取了别名“od_xx”,大费周章的原因就是主表orders和联表order_detail表字段名有重复(如id、number…),如果放任重复直接查不取别名,那么就会出现两个表查询重复字段的结果,相互覆盖封装类的某个属性。可以发现内的的column属性加上columnPrefix就刚好是select语句中给order_detail表每个字段取的别名。

联表查询一个细节是order_detail join orders(join默认左外连接,因为接口文档要求order_detail属性必须返回,orders主表属性反而不是必须的,这么做就正好可以以order_detail为主)。

后面的话就是按照status和userId动态查询,并且按照下单时间倒序排序,符合产品原型要求。

查询订单详情#

Controller#

//查看订单详情
@ApiOperation("查看订单详情")
@GetMapping("/orderDetail/{id}")
public Result<Orders> ordersDetail(@PathVariable Long id) {
log.info("查看订单详情:{}",id);
Orders orders = ordersService.ordersDetail(id);
return Result.success(orders);
}

Mapper#

<select id="ordersDetail" resultMap="historyOrders">
select t1.*,t2.id as 'od_id', t2.name as 'od_name', t2.image as 'od_image', t2.order_id as 'od_order_id',
t2.dish_id as 'od_dish_id', t2.setmeal_id as 'od_setmeal_id', t2.dish_flavor as 'od_dish_flavor', t2.number as
'od_number', t2.amount as 'od_amount' from order_detail t2 join orders t1 on t1.id = t2.order_id
<where>
<if test="id != null">t1.id = #{id}</if>
</where>
order by t1.order_time desc
</select>
  • 查询订单详情条件参数是订单Id,这样就唯一确定了某个特定的订单
  • 查询历史订单是根据status和userId两个条件参数,这两个参数无法确定某个特定的订单而是某群特定的订单群,符合Page

取消订单#

Controller#

请求参数一个订单Id,请求方式PUT是修改操作,联系数据库表Orders有个字段status,那推测大概就是根据订单Id修改status为已取消,设置一下订单取消时间即可,就是一个Mapper层的update操作。

// 取消订单
@ApiOperation("取消订单")
@PutMapping("/cancel/{id}")
public Result cancel(@PathVariable Long id) {
log.info("取消订单:{}", id);
ordersService.cancel(id);
return Result.success();
}

Service#

// 取消订单
@Override
public void cancel(Long id) {
Orders orders = new Orders();
orders.setId(id);
orders.setStatus(Orders.CANCELLED);
orders.setCancelTime(LocalDateTime.now());
ordersMapper.update(orders);
}

Mapper#

<update id="update" parameterType="com.sky.entity.Orders">
update orders
<set>
<if test="cancelReason != null and cancelReason!='' ">
cancel_reason=#{cancelReason},
</if>
<if test="rejectionReason != null and rejectionReason!='' ">
rejection_reason=#{rejectionReason},
</if>
<if test="cancelTime != null">
cancel_time=#{cancelTime},
</if>
<if test="payStatus != null">
pay_status=#{payStatus},
</if>
<if test="payMethod != null">
pay_method=#{payMethod},
</if>
<if test="checkoutTime != null">
checkout_time=#{checkoutTime},
</if>
<if test="status != null">
status = #{status},
</if>
<if test="deliveryTime != null">
delivery_time = #{deliveryTime}
</if>
</set>
where id = #{id}
</update>

再来一单#

Controller#

  • POST请求,推测是新增但是又不知道往哪里新增数据。

  • 把历史订单的东西加入到购物车里面

  • 购物车表字段和订单明细表的字段几乎一样

  • 根据请求参数订单Id,查询它关联的多个订单明细数据,然后逐一插入shopping_cart表中

    // 再来一单
    @ApiOperation("再来一单")
    @PostMapping("/repetition/{id}")
    public Result repetition(@PathVariable Long id) {
    ordersService.repetition(id);
    return Result.success();
    }

Service#

/**
* 再来一单
* @param id
*/
@Override
public void repetition(Long id) {
// 根据订单id把对应的所有订单明细表数据加入shopping_cart表中
List<OrderDetail> orderDetailList = orderDetailMapper.selectByOrderId(id);
orderDetailList.forEach(orderDetail -> {
// 补充缺失的属性值
ShoppingCart shoppingCart = new ShoppingCart();
BeanUtils.copyProperties(orderDetail, shoppingCart, "id");
shoppingCart.setUserId(BaseContext.getCurrentId());
shoppingCart.setCreateTime(LocalDateTime.now());
shoppingCartMapper.insert(shoppingCart);
});
}

Mapper#

@Select("select * from order_detail where order_id = #{orderId}")
List<OrderDetail> selectByOrderId(Long orderId);
@Insert("insert into shopping_cart (name, image, user_id, dish_id, setmeal_id, dish_flavor, amount, create_time, number) " +
"values (#{name},#{image},#{userId},#{dishId},#{setmealId},#{dishFlavor},#{amount},#{createTime},#{number})")
void insert(ShoppingCart shoppingCart);

商家端#

订单搜索#

Controller#

  • 订单管理六种页面,发现上面都有订单号、手机号、下单时间范围三个选择框,配合上右边的查询可以推测出是一个条件分页查询。
  • 请求参数包含page、pageSize确定就是一个分页查询。
  • 请求参数number(订单号)、phone(手机号)、beginTime和endTime(下单时间范围),当然还有个status,这个其实就对应了订单管理六种页面,全部订单、待接单、待派送、派送中、已完成、已取消,切换不同页面请求参数status的值也就不同。
  • records里面的数据用Orders类封装是没问题,多了个字段orderDishes订单包含的菜品,以字符串形式展示,相当于是要把每个订单包含的菜品以及它包含的套餐下包含的菜品的名字全部拼接起来作为orderDishes字段
/**
* 订单搜索
* @param dto
* @return
*/
@ApiOperation("订单搜索")
@GetMapping("/conditionSearch")
public Result<PageResult> conditionSearch(OrdersPageQueryDTO dto) {
log.info("订单搜索:{}",dto);
PageResult pageResult = ordersService.conditionSearch(dto);
return Result.success(pageResult);
}

Service#

/**
* 订单搜索
* @param dto
* @return
*/
@Override
public PageResult conditionSearch(OrdersPageQueryDTO dto) {
// 设置分页参数
PageHelper.startPage(dto.getPage(), dto.getPageSize());
// 调用Mapper
Page<Orders> page = ordersMapper.conditionSearch(dto);
// 补充orderDishes字段,把每个订单需要的菜品的名称拼接成一个字符串
List<Orders> result = page.getResult();
result.forEach(orders -> {
List<OrderDetail> orderDetailList = orderDetailMapper.selectByOrderId(orders.getId());
List<String> orderDishNames = new ArrayList<>();
orderDetailList.forEach(orderDetail -> {
// 从order_detail表中获取订单的dish_id和setmeal_id
Long dishId = orderDetail.getDishId();
Long setmealId = orderDetail.getSetmealId();
// 根据dish_id查表dish获取dish_name
if (dishId != null) {
Dish dish = dishMapper.selectById(dishId);
orderDishNames.add(dish.getName());
}
// 根据setmeal_id查setmeal_dish表中的name(也是菜品名称)
if (setmealId != null) {
List<SetmealDish> setmealDishes = setmealDishMapper.selectBySetmealId(setmealId);
setmealDishes.forEach(setmealDish -> {
String name = setmealDish.getName();
orderDishNames.add(name);
});
}
});
// 拼接所有涉及到的菜品名称
String orderDishes = String.join(",", orderDishNames);
// set进orderDishes字段
orders.setOrderDishes(orderDishes);
});
return new PageResult(page.getTotal(), page.getResult());
}
  • 先设置分页参数,然后调用Mapper返回Page泛型里面就是每一行数据封装到Orders类中,通过conditionSearch方法无法封装orderDishes字段,需要遍历每一个订单,获取该订单包含的所有菜品名称拼接在一起(包括套餐下的菜品)。
  • 首先就是通过getResult方法获取到订单集合List
  • 遍历所有订单进行处理。一个订单对应多个订单明细,
  • 需要获通过selectByOrderId方法取到订单对应所有的订单明细数据
  • 遍历该订单的所有订单明细数据前创建一个orderDishNames集合
  • 存储该订单包含的所有菜品名称
  • 通过orderDetail获取到dish_id和setmeal_id,
  • 分别查询该订单包含的所有菜品名称并加入到集合orderDishNames中。
  • 遍历结束后通过String.join方法拼接所有涉及到的菜品名称,
  • setOrderDishes补充这个缺失属性,每遍历一个订单Orders都重复上述操作。
  • 整个result.forEach都是对Page中的数据进行修正补充,保证最后返回的PageResult是正确的

Mapper#

conditionSearch:根据订单号、订单状态、手机号、时间范围动态查询

<select id="conditionSearch" resultType="com.sky.entity.Orders">
select * from orders
<where>
<if test="number != null and number != ''">number = #{number}</if>
<if test="status != null">and status = #{status}</if>
<if test="phone != null and phone != ''">and phone = #{phone}</if>
<if test="beginTime != null and endTime != null">and order_time between #{beginTime} and #{endTime}</if>
</where>
order by order_time desc
</select>

selectByOrderId:根据订单Id查该订单对应的所有订单明细数据

@Select("select * from order_detail where order_id = #{orderId}")
List<OrderDetail> selectByOrderId(Long orderId);

selectById:根据菜品Id查询对应菜品信息

@Select("select * from dish where id = #{id}")
Dish selectById(Long id);

selectBySetmealId:根据套餐Id查它包含的所有菜品名称,setmealDish封装有菜品名称

@Select("select * from setmeal_dish where setmeal_id = #{id}")
List<SetmealDish> selectBySetmealId(Long id);

数量统计#

实体类#

@Builder
@Data
public class OrderStatisticsVO implements Serializable {
//待接单数量
private Integer toBeConfirmed;
//待派送数量
private Integer confirmed;
//派送中数量
private Integer deliveryInProgress;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Orders implements Serializable {
/**
* 订单状态 1待付款 2待接单 3已接单 4派送中 5已完成 6已取消
*/
public static final Integer PENDING_PAYMENT = 1;
public static final Integer TO_BE_CONFIRMED = 2;
public static final Integer CONFIRMED = 3;
public static final Integer DELIVERY_IN_PROGRESS = 4;
public static final Integer COMPLETED = 5;
public static final Integer CANCELLED = 6;
/**
* 支付状态 0未支付 1已支付 2退款
*/
public static final Integer UN_PAID = 0;
public static final Integer PAID = 1;
public static final Integer REFUND = 2;
private static final long serialVersionUID = 1L;
private Long id;
//订单号
private String number;
//订单状态 1待付款 2待接单 3已接单 4派送中 5已完成 6已取消 7退款
private Integer status;
//下单用户id
private Long userId;
//地址id
private Long addressBookId;
//下单时间
private LocalDateTime orderTime;
//结账时间
private LocalDateTime checkoutTime;
//支付方式 1微信,2支付宝
private Integer payMethod;
//支付状态 0未支付 1已支付 2退款
private Integer payStatus;
//实收金额
private BigDecimal amount;
//备注
private String remark;
//用户名
private String userName;
//手机号
private String phone;
//地址
private String address;
//收货人
private String consignee;
//订单取消原因
private String cancelReason;
//订单拒绝原因
private String rejectionReason;
//订单取消时间
private LocalDateTime cancelTime;
//预计送达时间
private LocalDateTime estimatedDeliveryTime;
//配送状态 1立即送出 0选择具体时间
private Integer deliveryStatus;
//送达时间
private LocalDateTime deliveryTime;
//打包费
private int packAmount;
//餐具数量
private int tablewareNumber;
//餐具数量状态 1按餐量提供 0选择具体数量
private Integer tablewareStatus;
//订单详情列表
private List<OrderDetail> orderDetailList;
//名字
private String orderDishes;

Controller#

每次点击到订单管理相关页面(包括各个订单状态)都会调用statistics这个请求,可见每个数字都会实时更新的,接口没有请求参数,只要求返回待派送、派送中、待接单三个订单状态数据各自的数量,相当于调用三次Mapper层方法做三次查询封装到实体类OrderStatisticsVO返回即可

/**
*
* 各个状态的订单数量统计
* @return
*/
@ApiOperation("各个状态的订单数量统计")
@GetMapping("/statistics")
public Result<OrderStatisticsVO> statistics() {
log.info("各个状态的订单数量统计");
OrderStatisticsVO vo = ordersService.statistics();
return Result.success(vo);
}

Service#

@Override
public OrderStatisticsVO statistics() {
// 统计不同状态订单各自的数量
Orders orders = new Orders();
// 1.待派送数量
orders.setStatus(Orders.CONFIRMED);
Integer confirmed = ordersMapper.statistics(orders);
// 2.派送中数量
orders.setStatus(Orders.DELIVERY_IN_PROGRESS);
Integer deliveryInProgress = ordersMapper.statistics(orders);
// 3.待接单数量
orders.setStatus(Orders.TO_BE_CONFIRMED);
Integer toBeConfirmed = ordersMapper.statistics(orders);
// 4.封装进OrderStatisticsVO中
return OrderStatisticsVO.builder()
.confirmed(confirmed)
.deliveryInProgress(deliveryInProgress)
.toBeConfirmed(toBeConfirmed)
.build();
}
  • 在查询三种不同状态订单各自的数量时三次同一个Mapper层方法statistics,
  • 每次调用前都会对请求参数Orders类进行set操作,即设置当前查询哪种状态的订单数量,最后通过链式编程return OrderStatisticsVO。

Mapper#

//各个状态的订单数量统计
@Select("select count(*) from orders where status = #{status}")
Integer statistics(Orders orders);

接单#

Controller#

Controller层直接调用,Service层补充属性,Mapper层做更新update操作。像接单和派送订单只需要传ID和把订单修改成什么状态status即可,拒单和取消订单还有完成订单都需要传或者补充属性比如说取消时间、取消原因、拒单原因、送达时间等等,而且注意派送订单和完成订单只传了Id而不是DTO,所有补充属性更多一些,总之一句话就是给订单数据修改状态、补充属性。

/**
* 接单
* @param dto
* @return
*/
@ApiOperation("接单")
@PutMapping("/confirm")
public Result confirm(@RequestBody OrdersDTO dto) {
log.info("接单:{}",dto);
ordersService.confirm(dto);
return Result.success();
}
/**
* 拒单
* @param dto
* @return
*/
@ApiOperation("拒单")
@PutMapping("/rejection")
public Result rejection(@RequestBody OrdersRejectionDTO dto) {
log.info("拒单:{}",dto);
ordersService.rejection(dto);
return Result.success();
}
/**
* 取消订单
* @param dto
* @return
*/
@ApiOperation("取消订单")
@PutMapping("/cancel")
public Result cancel(@RequestBody OrdersCancelDTO dto) {
log.info("取消订单");
ordersService.adminCancel(dto);
return Result.success();
}
/**
* 派送订单
* @param id
* @return
*/
@ApiOperation("派送订单")
@PutMapping("/delivery/{id}")
public Result delivery(@PathVariable Long id) {
ordersService.delivery(id);
return Result.success();
}
/**
* 完成订单
* @param id
* @return
*/
@ApiOperation("完成订单")
@PutMapping("/complete/{id}")
public Result complete(@PathVariable Long id) {
ordersService.complete(id);
return Result.success();
}

Service#

@Override
public void confirm(OrdersDTO dto) {
Orders orders = new Orders();
BeanUtils.copyProperties(dto, orders);
orders.setStatus(Orders.CONFIRMED);
ordersMapper.update(orders);
}
@Override
public void rejection(OrdersRejectionDTO dto) {
Orders orders = new Orders();
BeanUtils.copyProperties(dto, orders);
orders.setCancelTime(LocalDateTime.now());
orders.setStatus(Orders.CANCELLED);
orders.setCancelReason(dto.getRejectionReason());
ordersMapper.update(orders);
}
@Override
public void adminCancel(OrdersCancelDTO dto) {
Orders orders = new Orders();
BeanUtils.copyProperties(dto, orders);
orders.setCancelTime(LocalDateTime.now());
orders.setStatus(Orders.CANCELLED);
ordersMapper.update(orders);
}
@Override
public void delivery(Long id) {
Orders orders = new Orders();
orders.setId(id);
orders.setStatus(Orders.DELIVERY_IN_PROGRESS);
ordersMapper.update(orders);
}
@Override
public void complete(Long id) {
Orders orders = new Orders();
orders.setId(id);
orders.setStatus(Orders.COMPLETED);
orders.setDeliveryTime(LocalDateTime.now());
ordersMapper.update(orders);
}
  • 基本上Service层都是在拷贝补充属性然后直接调用Mapper层update方法,
  • 取消订单我使用adminCancel与用户端取消订单方法进行区分。
  • 在rejection拒单方法中还补充了CancelTime和CancelReason,一开始可能以为rejection就对应rejectReason,但是发现商家端订单管理页面展示的是CancelTime和CancelReason,
  • 如果不补充的话对于拒单的订单那两行数据就是空的并不美观,拒单原因rejectReason只会在你点击查看后的订单信息页面才会显示

Mapper#

<update id="update" parameterType="com.sky.entity.Orders">
update orders
<set>
<if test="cancelReason != null and cancelReason!='' ">
cancel_reason=#{cancelReason},
</if>
<if test="rejectionReason != null and rejectionReason!='' ">
rejection_reason=#{rejectionReason},
</if>
<if test="cancelTime != null">
cancel_time=#{cancelTime},
</if>
<if test="payStatus != null">
pay_status=#{payStatus},
</if>
<if test="payMethod != null">
pay_method=#{payMethod},
</if>
<if test="checkoutTime != null">
checkout_time=#{checkoutTime},
</if>
<if test="status != null">
status = #{status},
</if>
<if test="deliveryTime != null">
delivery_time = #{deliveryTime}
</if>
</set>
where id = #{id}
</update>

SpringTask#

初识#

SpringTask 是 Spring 框架内置的轻量级定时任务框架

SpringTask 注解式更简洁(推荐日常使用),配置式更灵活(适用于复杂场景),均支持 Spring 自动装配。

  1. 注解式(@Scheduled,推荐)
  • 导入maven坐标spring-context

  • 启动类添加 @EnableScheduling 注解,开启定时任务功能;

  • 自定义定时任务类,并打上@Component注解

  • 类中定义方法,方法上加上@Scheduled(cron = “0/5 * * * * ?”)(每隔5秒执行一次)

@SpringBootApplication
@EnableScheduling // 开启定时任务
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
@Component // 交由Spring容器管理
public class TimedTask {
// cron表达式:每日凌晨0点执行
@Scheduled(cron = "0 0 0 * * ?")
public void cleanExpireOrder() {
// 执行清理过期订单逻辑
}
}

cron表达式其实就是一个字符串,通过cron表达式可以定义任务触发的时间。构成规则:分为6或7个域,由空格分隔开,每个域代表一个含义。每个域的含义分别为:秒、分钟、小时、日、月、周、年(可选)。

注意:SpringTask定时任务框架不支持年份字段,只有6个域,而且一般周都是固定写”?”。 常用特殊符号(必记)

符号含义示例
*匹配该维度所有值秒位 * = 每秒执行
?仅用于日 / 周,表示无指定值,避免日和周冲突,二选一用 ?0 0 8 ? * MON-FRI = 工作日早 8 点执行
/步长分位 0/10 = 每 10 分钟,从 0 分开始
-范围时位 9-18 = 9 点到 18 点之间
,枚举周位 1,3,5 = 周一、三、五执行

高频实用示例

含义Cron
每日凌晨 0 点执行0 0 0 * * ?
每小时执行一次0 0 * * * ?
每分钟执行一次0 * * * * ?
工作日(周一到周五)早 8 点执行0 0 8 ? * MON-FRI
每天 9 点、18 点各执行一次0 0 9,18 * * ?
每 10 分钟执行一次0 0/10 * * * ?
每月

超时未支付#

  • 下单后未支付,订单一直处于“待支付”状态
  • 用户收货后管理端未点击完成按钮,订单一直处于“派送中”状态。
  • 1.通过定时任务每分钟检查一次是否存在支付超时订单(下单后超过15分钟仍未支付则判定为支付超时订单),如果存在则修改订单状态为“已取消”。
  • 2.通过定时任务每天凌晨1点检查一次是否存在“派送中”的订单,如果存在则修改订单状态为“已完成”。

通过maven-helper插件发现已经导入了spring-context(在多个依赖中都有包含)

启动类加入@EnableScheduling开启定时任务调度功能。

创建定时任务类加上@Component注解使之交给IOC容器管理。注入OrdersMapper方便后面使用。

@SpringBootApplication
@EnableTransactionManagement //开启注解方式的事务管理
@EnableCaching//开启缓存注解功能
@EnableScheduling//开启定时任务功能
@Slf4j
public class SkyApplication {
public static void main(String[] args) {
SpringApplication.run(SkyApplication.class, args);
log.info("server started");
}
}
//定时任务类
@Slf4j
@Component
public class OderTask {
@Autowired
private OrderMapper orderMapper;
//每分钟检查一次是否存在超时未支付订单(下单超过15分钟未支付就代表超时,需要修改)
@Scheduled(cron = "0 * * * * ?")
public void overTimeOrder() {
log.info("每分钟检查一次是否存在超时未支付订单");
// 1.查询Orders表,条件:状态 = 待付款,下单时间 < 当前时间 - 15分钟
// SQL语句 select * from orders where status = 1 and order_time < 当前时间 - 15分钟
LocalDateTime time = LocalDateTime.now().minusMinutes(15);
List<Orders> ordersList = orderMapper.selectByStatusAndOrderTime(Orders.PENDING_PAYMENT, time);
// 2.遍历所有符合条件的Orders,update操作把所有订单状态改为已取消,同时设置取消原因和取消时间
// update ...
if (ordersList != null && ordersList.size() > 0) {
ordersList.forEach(orders -> {
orders.setCancelTime(LocalDateTime.now());
orders.setCancelReason(MessageConstant.ORDER_TIME_OVER);
orders.setStatus(Orders.CANCELLED);
orderMapper.update(orders);
});
}
}
@Select("select * from orders where status = #{status} and order_time < #{time}")
List<Orders> selectByStatusAndOrderTime(Integer status, LocalDateTime time);
  • 每分钟检查一次,所有在定时方法上的@Scheduled注解中cron表达式写为“0 * * * * ?”
  • 筛选符合条件的订单(下单后超过15分钟仍未支付则判定为支付超时订单;状态是待付款),
  • 调用Mapper层加上这两个条件的Select语句查询符合条件的ordersList。
  • 遍历所有符合条件的Orders,使用update操作把所有订单状态改为已取消,同时可以设置取消时间、取消原因。在此之前可以进行判断ordersList是否为空。

一直派送#

}
//每天凌晨1点检查一次订单表,查看是否存在“派送中”订单,如果存在修改状态为“已完成”
// @Scheduled(cron = "0 0 1 * * *")
@Scheduled(cron = "0 54 18 * * *") // TODO:测试用:18:54修改
public void deliveringOrder() {
log.info("每天凌晨1点检查一次订单表");
// 1.查看数据库orders表,条件:状态-派送中,下单时间 < 当前时间 - 1h
// select * from orders where status = 4 and order_time < 当前时间 - 1h
LocalDateTime time = LocalDateTime.now().minusHours(1);
List<Orders> ordersList = orderMapper.selectByStatusAndOrderTime(Orders.DELIVERY_IN_PROGRESS, time);
// 2.遍历ordersList,更新每个orders状态和送达时间
if (ordersList != null && ordersList.size() > 0) {
ordersList.forEach(orders -> {
orders.setDeliveryTime(time);
orders.setStatus(Orders.COMPLETED);
orderMapper.update(orders);
});
}
}

WebSocket#

初识#

WebSocket 是基于 TCP 协议的全双工通信协议,在客户端(如浏览器)和服务器之间建立持久的双向连接,打破了 HTTP 协议 “请求 - 响应” 的单向通信限制,实现数据的实时双向传输。核心特点:连接建立后,客户端和服务器可主动向对方发送数据,无需频繁发起 HTTP 请求,且连接全程保持有效(除非主动关闭)

对比维度HTTPWebSocket
通信协议基于 TCP 的应用层单向通信协议(以 HTTP/1.1 为主)基于 TCP 的应用层全双工通信协议(HTML5 标准)
通信模式请求-响应模式:客户端主动发请求,服务器被动响应,无请求则无反馈双向实时通信:连接建立后,客户端和服务器均可主动向对方发送数据,无需请求触发
连接特性短连接(HTTP/1.1 支持长连接 Keep-Alive,但仍为请求响应式);每次请求独立,请求结束后连接释放或闲置持久长连接:一次握手建立连接,直到主动关闭,连接全程有效
数据传输每次传输携带大量 HTTP 头信息(如请求头、响应头),有效数据占比较低仅握手阶段使用 HTTP 协议,后续通过精简的 WebSocket 帧传输业务数据,开销较低
服务器推送不原生支持主动推送,需通过轮询、长轮询等方式模拟(客户端定时发请求拉取数据)原生支持服务器主动推送,数据更新后可实时推送给客户端
连接建立完成 TCP 握手后即可发起 HTTP 请求;使用 HTTPS 时还需进行 TLS 握手通常先通过 HTTP Upgrade 请求完成 101 协议切换,协商成功后切换为 WebSocket 协议
状态维护HTTP 本身是无状态协议,通常通过 Cookie、Session 或 Token 维护身份和会话WebSocket 连接是有状态的,服务器需维护连接及其关联的客户端信息
端口使用默认使用 80(HTTP)或 443(HTTPS)端口通常使用 80(WS)或 443(WSS)端口,与 HTTP 基础设施兼容,便于部署
开发复杂度开发相对简单,前后端采用通用的请求-响应逻辑,无需长期维护连接需处理连接状态、心跳检测、断线重连等问题,前后端需编写相应的事件监听逻辑
适用场景非实时、一次性数据交互场景:网页浏览、表单提交、数据查询、静态资源加载实时、双向数据交互场景:即时通信、直播弹幕、物流轨迹、实时监控、协同办公
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

.导入WebSocket服务端组件WebSocketServer,用于和客户端通信

/**
* WebSocket服务
*/
@Component
@ServerEndpoint("/ws/{sid}")
public class WebSocketServer {
//存放会话对象
private static Map<String, Session> sessionMap = new HashMap();
//连接建立成功调用的方法
@OnOpen
public void onOpen(Session session, @PathParam("sid") String sid) {
System.out.println("客户端:" + sid + "建立连接");
sessionMap.put(sid, session);
}
//收到客户端消息后调用的方法
//message 客户端发送过来的消息
@OnMessage
public void onMessage(String message, @PathParam("sid") String sid) {
System.out.println("收到来自客户端:" + sid + "的信息:" + message);
}
//连接关闭调用的方法
@OnClose
public void onClose(@PathParam("sid") String sid) {
System.out.println("连接断开:" + sid);
sessionMap.remove(sid);
}
//群发
public void sendToAllClient(String message) {
Collection<Session> sessions = sessionMap.values();
for (Session session : sessions) {
try {
//服务器向客户端发送消息
session.getBasicRemote().sendText(message);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}

导入配置类WebSocketConfiguration,注册WebSocket的服务端组件

package com.sky.websocket;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
/**
* WebSocket配置类,用于注册WebSocket的Bean
*/
@Configuration
public class WebSocketConfiguration {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}

导入定时任务类WebSocketTask,定时向客户端推送数据

package com.sky.websocket;
import com.sky.websocket.WebSocketServer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@Component
public class WebSocketTask {
@Autowired
private WebSocketServer webSocketServer;
/**
* 通过WebSocket每隔5秒向客户端发送消息
*/
@Scheduled(cron = "0/5 * * * * ?")
public void sendMessageToClient() {
webSocketServer.sendToAllClient("这是来自服务端的消息:" + DateTimeFormatter.ofPattern("HH:mm:ss").format(LocalDateTime.now()));
}
}

来单提醒#

image.webp
image.webp

  • 提示形式交由前端完成,导入WebSocket配置类以及WebSocket服务WebSocketServer(类似于HTTP请求中的Controller入口层)。
  • 后端需要做构造请求参数paramMap,补充type、orderId、content三个字段内容,然后将其转化为JSON字符串,作为调用WebSocketServer相关API(sendToAllClient方法)的请求参数,向客户端推送消息
//支付成功,修改订单状态
public void paySuccess(String outTradeNo) {
// 根据订单号查询订单
Orders ordersDB = orderMapper.getByNumber(outTradeNo);
// 根据订单id更新订单的状态、支付方式、支付状态、结账时间
Orders orders = Orders.builder()
.id(ordersDB.getId())
.status(Orders.TO_BE_CONFIRMED)
.payStatus(Orders.PAID)
.checkoutTime(LocalDateTime.now())
.build();
orderMapper.updateOder(orders);
// 通过websocket向客户端浏览器推送消息 type orderId content
Map paramMap = new HashMap();
paramMap.put("type", 1); // 1表示来单提醒 2表示客户催单
paramMap.put("orderId", ordersDB.getId());
paramMap.put("content", "订单号:" + outTradeNo);
String json = JSON.toJSONString(paramMap);
webSocketServer.sendToAllClient(json);
}

催单#

Controller#

//催单提醒
@ApiOperation("催单提醒")
@GetMapping("/reminder/{id}")
public Result reminder(@PathVariable Long id) {
ordersService.reminder(id);
return Result.success();
}

Service#

@Override
public void reminder(Long id) {
Orders orders = ordersMapper.getById(id);
if (orders == null) {
throw new OrderBusinessException(MessageConstant.ORDER_NOT_FOUND);
}
Map paramMap = new HashMap();
paramMap.put("type", 2); // 1表示来单提醒 2表示用户催单
paramMap.put("orderId", orders.getId());
paramMap.put("content", "订单号:" + orders.getNumber());
String json = JSON.toJSONString(paramMap);
// 通过webSocket向客户端推送消息
webSocketServer.sendToAllClient(json);
}

营业额统计#

Controller#

image.webp
image.webp
#

  • 请求参数开始日期begin和结束日期end,返回日期列表和营业额列表
  • 日期列表就是从begin到end的每一天作为一个独立元素,逗号分隔,每一天呢又有对应的营业额,
  • 每天的营业额也是逗号分隔。当天营业额就是这一天状态status为已完成的订单总金额(amount之和)。
@RestController
@RequestMapping("/admin/report")
@Slf4j
public class ReportController {
@Autowired
private ReportService reportService;
@GetMapping("/turnoverStatistics")
public Result<TurnoverReportVO> turnoverStatistics(@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin,
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end) {
log.info("营业额统计:{},{}", begin, end);
TurnoverReportVO vo = reportService.turnoverStatistics(begin, end);
return Result.success(vo);
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TurnoverReportVO implements Serializable {
//日期,以逗号分隔,例如:2022-10-01,2022-10-02,2022-10-03
private String dateList;
//营业额,以逗号分隔,例如:406.0,1520.0,75.0
private String turnoverList;
}
  • 两个请求参数都是LocalDate这种时间,需要加注解@DateTimeFormat(pattern = “xxxx”)定义格式
  • 如果请求参数是使用DTO这种类封装,DTO类的时间相关属性上也要加上述注解定义格式。返回结果使用自定义VO类封装。

Service#

@Override
public TurnoverReportVO turnoverStatistics(LocalDate begin, LocalDate end) {
// 1.获取日期列表 2.6,2.7.....2.8
List<LocalDate> dateList = getLocalDates(begin, end);
// 2.获取营业额列表 每天即2.6 00:00:00 - 2.6 23:59:59.99999999的数据,且已经完成
List<Double> turnoverList = new ArrayList<>();
dateList.forEach(localDate -> {
Map paramMap = new HashMap();
LocalDateTime beginTime = LocalDateTime.of(localDate, LocalTime.MIN);
LocalDateTime endTime = LocalDateTime.of(localDate, LocalTime.MAX);
Integer status = Orders.COMPLETED;
paramMap.put("beginTime", beginTime);
paramMap.put("endTime", endTime);
paramMap.put("status", status);
Double turnover = ordersMapper.turnoverStatistics(paramMap);
turnover = turnover == null ? 0 : turnover; // 保证统计表每一天营业额都有值,图像能够绘出。
turnoverList.add(turnover);
});
// 3.构造TurnoverReportVO对象
return TurnoverReportVO.builder()
.dateList(StringUtils.join(dateList, ","))
.turnoverList(StringUtils.join(turnoverList, ","))
.build();
}
private static List<LocalDate> getLocalDates(LocalDate begin, LocalDate end) {
List<LocalDate> dateList = new ArrayList<>();
while (!begin.isAfter(end)) {
dateList.add(begin);
begin = begin.plusDays(1);
}
log.info("日期列表为:{}", dateList);
return dateList;
}

第一步获取日期列表,封装方法getLocalDates复用,主要就是把从开始日期begin到结束日期end之间的每一天都加入到dateList里面。

第二步获取营业额列表,这一步就要调用Mapper层查表了,遍历dateList每一天,两个条件,第一个条件是状态status必须为已完成,第二个条件是下单时间order_time必须在开始日期和结束日期之间,因为每一天的开始时间就是当天0点,结束时间就是当天23点59分无限接近0点,因此需要把这一天的LocalDate分别转换成开始与结束时间的LocalDateTime类型,使用LocalDateTime.of()方法,第一个参数传LocaDate即哪一天,第二个参数填LocalTime,LocalTime.min和LocalTime.max是里面的两个固定常量。这里构造请求参数主要是使用Map,把beginTime、endTime、status都封装到Map里面传到Mapper层。调用Mapper返回后的数据还需要进行处理,前端根据Apache Echarts制作可视化图表时,就营业额统计而言需要每一天都有相应的值才会点连成线,换言之就是即便你这一天没有营业额,查不出来,那也要把null值处理成0,方便连线。第三步就是构造TurnoverReportVO。注意接口文档要求返回的dateList和turnoverList都需要是字符串,并且每个元素之间需要逗号分隔。因此可以调用StringUtils工具类的join方法,指定需要处理的集合和分隔符号。

Mapper#

@Select("select sum(amount) from orders where status = #{status} and order_time between #{beginTime} and #{endTime}")
Double turnoverStatistics(Map paramMap);

用户统计#

Controller#

image.webp
image.webp

返回dateList日期列表,newUserList新增用户数列表,totalUserList总用户量列表,String类型,且里面的每个元素都是相对于每一天而言的

//用户统计
@GetMapping("/userStatistics")
public Result<UserReportVO> userStatistics(@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin,
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end) {
log.info("用户统计:{},{}", begin, end);
UserReportVO vo = reportService.userStatistics(begin, end);
return Result.success(vo);
}

Service#

@Override
public UserReportVO userStatistics(LocalDate begin, LocalDate end) {
// 1.获取日期列表
List<LocalDate> dateList = getLocalDates(begin, end);
List<Integer> newUserList = new ArrayList<>();
List<Integer> totalUserList = new ArrayList<>();
dateList.forEach(localDate -> {
// 2.获取新增用户数列表
Map paramMap = new HashMap();
paramMap.put("beginTime", LocalDateTime.of(localDate, LocalTime.MIN));
paramMap.put("endTime", LocalDateTime.of(localDate, LocalTime.MAX));
Integer newUserCount = userMapper.countUser(paramMap);
newUserList.add(newUserCount);
// 3.获取总用户量列表
paramMap.put("beginTime", null);
Integer totalUserCount = userMapper.countUser(paramMap);
totalUserList.add(totalUserCount);
});
// 4.构造UserReportVO
return UserReportVO.builder()
.dateList(StringUtils.join(dateList, ","))
.newUserList(StringUtils.join(newUserList, ","))
.totalUserList(StringUtils.join(totalUserList, ","))
.build();
}
  • 获取日期列表使用抽取的方法,日期的遍历中处理的因为newUserList和totalUserList都是针对每一天
  • 获取新增用户数列表,请求参数需要beginTime和endTime当天0点和23点59分,调用Mapper查出了这一天有多少个用户创建,条件就是create_time在请求参数之间。
  • 获取总用户量列表可以复用上述的Mapper,只需要把beginTime置为空即可,只会要求用户创建时间create_time小于当天23点59分,查出来的数据totalUserCount含义就成了截至今天为止一共有多少个用户。Mapper层编写动态SQL。
  • 构造UserReportVO链式编程,StringUtils.join()方法。

Mapper#

<select id="countUser" resultType="java.lang.Integer">
select count(*) from user
<where>
<if test="beginTime != null">create_Time >= #{beginTime}</if>
<if test="endTime != null">and create_Time &lt; #{endTime}</if>
</where>
</select>

xml文件中”<“号不能直接写,因为要和标签的左括号做区分。需要使用转义字符(<),大于号的转义字符为(>)

订单统计#

Controller#

image.webp
image.webp

@GetMapping("/ordersStatistics")
public Result<OrderReportVO> ordersStatistics(@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin,
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end) {
log.info("订单统计:{},{}", begin, end);
OrderReportVO vo = reportService.ordersStatistics(begin, end);
return Result.success(vo);
}

Service#

@Override
public OrderReportVO ordersStatistics(LocalDate begin, LocalDate end) {
// 1.获取日期列表
List<LocalDate> dateList = getLocalDates(begin, end);
List<Integer> orderCountList = new ArrayList<>();
List<Integer> validOrderCountList = new ArrayList<>();
Integer totalOrderCount = 0;
Integer totalValidOrderCount = 0;
for (LocalDate localDate : dateList) {
// 2.获取订单数列表
Map paramMap = new HashMap();
paramMap.put("beginTime", LocalDateTime.of(localDate, LocalTime.MIN));
paramMap.put("endTime", LocalDateTime.of(localDate, LocalTime.MAX));
Integer orderCount = orderMapper.countByMap(paramMap);
orderCountList.add(orderCount);
// 3.获取有效订单数列表
paramMap.put("status", Orders.COMPLETED);
Integer validOrderCount = orderMapper.countByMap(paramMap);
validOrderCountList.add(validOrderCount);
// 4.获取订单总数
totalOrderCount += orderCount;
// 5.获取有效订单总数
totalValidOrderCount += validOrderCount;
}
// 4.获取订单总数 写法二
// totalOrderCount = orderCountList.stream().reduce(new BinaryOperator<Integer>() {
// @Override
// public Integer apply(Integer num1, Integer num2) {
// return num1 + num2;
// }
// }).get(); 匿名内部类
// totalOrderCount = orderCountList.stream().reduce((num1, num2) -> num1 + num2).get(); lambda表达式
// totalOrderCount = orderCountList.stream().reduce(Integer::sum).get(); 方法引用
// 6.获取订单完成率
Double orderCompletionRate = 0.0;
if (totalOrderCount != 0) {
orderCompletionRate = (totalValidOrderCount + 0.0) / totalOrderCount;
}
// 7.构造OrderReportVO
return OrderReportVO.builder()
.dateList(StringUtils.join(dateList, ","))
.orderCountList(StringUtils.join(orderCountList, ","))
.validOrderCountList(StringUtils.join(validOrderCountList, ","))
.totalOrderCount(totalOrderCount)
.validOrderCount(totalValidOrderCount)
.orderCompletionRate(orderCompletionRate)
.build();
}
  • 对每一天遍历实现的,获取订单数列表就是获取指定时间范围内(依旧每天0点到23点59分)的订单数即可,不论状态,获取有效订单数则需要加上状态已完成
  • 获取订单总数和获取有效订单总数则从一开始遍历就进行累加就可以了,这样得出来的数据就是截至那天23点59分的总数。
  • 第四部第五步放在循环外得到orderCountList和validOrderCountList之后利用流的形式可以实现相同功能,注解中的就是第二种写法,然后这种流的写法又有三种版本,第一个是匿名内部类new一个累加器,第二个是使用lambda表达式,第三个是方法引用。第六步获取订单完成率需要注意最后要求的值是Double类型,除了对除数判0外,被除数+0.0就可以变成Double类型,在除法中只要有一个值是Double那么最终结果就会是Double类型。最后移步就是构造OrderReportVO。

Mapper#

<!-- 订单统计-->
<select id="countByMap" resultType="java.lang.Integer">
select count(*) from orders
<where>
<if test="status != null">status = #{status}</if>
<if test="beginTime != null">and order_time >= #{beginTime}</if>
<if test="endTime != null">and order_time &lt; #{endTime}</if>
</where>
</select>

前十统计#

Controller#

img
img

@GetMapping("/top10")
public Result<SalesTop10ReportVO> top10(@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin,
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end) {
log.info("查询销量排名top10:{},{}", begin, end);
SalesTop10ReportVO vo = reportService.top10(begin, end);
return Result.success(vo);
}

Service#

@Override
public SalesTop10ReportVO top10(LocalDate begin, LocalDate end) {
// 1.构造商品名称nameList
List<String> nameList = new ArrayList<>();
// 2.构造销量列表numberList
List<Integer> numberList = new ArrayList<>();
Map paramMap = new HashMap();
paramMap.put("beginTime", LocalDateTime.of(begin, LocalTime.MIN));
paramMap.put("endTime", LocalDateTime.of(end, LocalTime.MAX));
paramMap.put("status", Orders.COMPLETED);
List<GoodsSalesDTO> list = orderMapper.sumTop10(paramMap);
for (GoodsSalesDTO dto : list) {
nameList.add(dto.getName());
numberList.add(dto.getNumber());
}
// 3.构造SalesTop10ReportVO
return SalesTop10ReportVO.builder()
.nameList(StringUtils.join(nameList, ","))
.numberList(StringUtils.join(numberList, ","))
.build();
}

Mapper#

@Select("select od.name name, sum(od.number) number from orders o join order_detail od on o.id = od.order_id " +
"where o.status = #{status} and
o.order_time between #{beginTime} and #{endTime} group by od.name order by number desc limit 0, 10")
List<GoodsSalesDTO> sumTop10(Map paramMap);

orders(限定状态和时间)和order_detail(查询需要的值name和number)两张表,

主要查出来的SQL字段取别名要保证和要封装到的目标类属性名相同,因为最终结果是一种商品名称对应一种销量,所以需要按照商品名称分组,最后根据销量降序排序,取前十个(limit语句)。

导出数据#

Controller#

@GetMapping("/export")
public void export(HttpServletResponse response) {
reportService.export(response);
}

导出Excel文件,里面的数据需要用到输出流才能把表中的数据查出来写到Excel文件里面,所以方法参数需要一个HttpServletResponse。

Service#

public void export(HttpServletResponse response) {
//1.查询数据库,获取营业数据--查询最近三十天的运营数据
LocalDate dateBegin = LocalDate.now().minusDays(30);
LocalDate dateEnd = LocalDate.now().minusDays(1);
//查询概览数据
BusinessDataVO businessData = workspaceService.getBusinessData(LocalDateTime.of(dateBegin, LocalTime.MIN), LocalDateTime.of(dateEnd, LocalTime.MAX));
//2.通过POI将数据写入Excel文件中
InputStream in = this.getClass().getResourceAsStream("/template/model.xlsx");
try {
//基于模板文件创建一个新的Excel文件,工作簿
XSSFWorkbook excel = new XSSFWorkbook(in);
//获取表格文件的Sheet工作表
XSSFSheet sheet = excel.getSheet("Sheet1");
//填充数据-时间
sheet.getRow(1).getCell(1).setCellValue("时间" + dateBegin + "至" + dateEnd);
//获取第4行
XSSFRow row = sheet.getRow(3);
row.getCell(2).setCellValue(businessData.getTurnover());
row.getCell(4).setCellValue(businessData.getOrderCompletionRate());
row.getCell(6).setCellValue(businessData.getNewUsers());
//获取第5行
row = sheet.getRow(4);
row.getCell(2).setCellValue(businessData.getValidOrderCount());
row.getCell(4).setCellValue(businessData.getUnitPrice());
//填充明细数据
for (int i = 0; i < 30; i++) {
LocalDate date = dateBegin.plusDays(i);
//查询某一天的营业数据
workspaceService.getBusinessData(LocalDateTime.of(date, LocalTime.MIN), LocalDateTime.of(date, LocalTime.MAX));
//获取某一行
row = sheet.getRow(7 + i);
row.getCell(1).setCellValue(date.toString());
row.getCell(2).setCellValue(businessData.getTurnover());
row.getCell(3).setCellValue(businessData.getValidOrderCount());
row.getCell(4).setCellValue(businessData.getOrderCompletionRate());
row.getCell(5).setCellValue(businessData.getUnitPrice());
row.getCell(6).setCellValue(businessData.getNewUsers());
}
//3.通过输出流将Excel文件下载到客户端浏览器
ServletOutputStream outputStream = response.getOutputStream();
excel.write(outputStream);
//关闭资源
outputStream.close();
excel.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
  • 根据当前时间计算出开始日期dateBegin和结束日期dateEnd,通过观察所给Excel模板的概览数据可以发现五项数据与工作台模块查询今日运营数据接口返回的五项数据一模一样,所以可以调用WorkSpaceService的getBusinessData方法帮我们返回BusinessDataVO对象,之后往单元格写数据需要什么直接get即可。

  • 通过POI框架将数据写入Excel文件中,把所给模板放入到resources目录下,新建一个template目录把模板Excel文件放入(尽量改成英文名,使用POI都是基于已经有的模板进行写数据,而不是从头new一个工作簿文件,利用相关API自己搞样式)。

  • 读取模板数据需要输入流,通过this.getClass获取当前类的 Class 对象,它是访问类路径资源的入口。getResourceAsStream这是 Class 类的方法,作用是从类路径(classpath)中读取指定路径的资源文件,并返回一个输入流(InputStream)。

  • “/template/model.xlsx”:这是资源文件的路径。路径以 / 开头,表示从类路径的根目录开始查找。对应项目中 resources/template/model.xlsx 这个文件。如果不加 /,路径是相对于当前类的包路径来查找的。

  • 先new一个XSSFWorBook,传入输入流in作为参数表示是基于所给模板的工作簿Excel文件。

  • getSheet方法,传入Sheet表名称获取到工作表Sheet。

  • 运营数据报表第一行下的空白行用来填充报表时间,先getRow定位具体行,再getCell定位具体单元格,

  • 最后setCellValue写入具体值。后面填充概览数据第四行第五行也都是这个流程。

  • 最后填充明细数据需要for循环遍历因为每一行对应三十天中每一天的数据。for循环内第一步还是先获取某一天,然后根据这天的0点和23点59分查这一天的营业数据。后面填充数据还是参照上面的流程。

  • 数据就已经都写入Excel模板了,最后需要利用参数输出流将处理好的Excel文件下载到客户端浏览器。然后关闭资源就完事了。

response 是 Servlet 规范中代表「服务器对客户端请求的响应对象」,每个 HTTP 请求都会对应一个 HttpServletResponse 对象;

getOutputStream() 方法会获取一个字节型输出流(ServletOutputStream),这个流是 Tomcat/Jetty 等 Web 容器创建的,直接关联到客户端的 HTTP 响应通道;

这个流的核心作用:承接服务器要发送给客户端的二进制数据(Excel、图片、视频等非文本内容都用字节流)。

这里的 excel 是 POI 构建的 XSSFWorkbook/SXSSFWorkbook 对象,内部封装了 Excel 文件的所有二进制数据(单元格、样式、公式等);

write(OutputStream) 是 POI 提供的核心方法,

  • 把 Excel 对象的内存数据转换成符合 .xlsx/.xls 格式的二进制字节序列;

  • 调用 outputStream.write(byte[]) 方法,将字节序列逐批写入 Servlet 输出流;

  • 输出流会立即将这些字节通过 TCP 连接发送到客户端(浏览器)。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
苍穹外卖进阶
https://blog.f3f3.top/posts/java项目/苍穹外卖进阶/
作者
Firefly
发布于
2026-07-29
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Firefly
Hello, I'm Firefly.
公告
此网站仅为个人学习笔记
分类
标签
最新动态
站点统计
文章
26
分类
10
标签
21
总字数
159,373
运行时长
0
最后活动
0 天前
站点信息
构建平台
Cloudflare Pages
博客版本
Firefly v6.15.6
文章许可
CC BY-NC-SA 4.0