目录

1. 公共字段自动填充

1.1 问题分析

1.2 实现思路

1.3 代码开发

1.3.1 步骤一

1.3.2 步骤二

1.3.3 步骤三

1.4 功能测试

1.5 代码提交

2. 新增菜品

2.1 需求分析与设计

2.1.1 产品原型

2.1.2 接口设计

2.1.3 表设计

2.2 代码开发

2.2.1 文件上传实现

2.2.2 新增菜品实现

2.3 功能测试

2.4代码提交

3. 菜品分页查询

3.1 需求分析和设计

3.1.1 产品原型

3.1.2 接口设计

3.2 代码开发

3.2.1 设计DTO类

3.2.2 设计VO类

3.2.3 Controller层

3.2.4 Service层接口

3.2.5 Service层实现类

3.2.6 Mapper层

3.3 功能测试

3.3.1 接口文档测试

3.3.2 前后端联调测试

4. 删除菜品

4.1 需求分析和设计

4.1.1 产品原型

4.1.2 接口设计

4.1.3 表设计

4.2 代码开发

4.1.2 Controller层

4.2.2 Service层接口

4.2.3 Service层实现类

4.2.4 Mapper层

4.3 功能测试

4.4 代码提交

5. 修改菜品

5.1 需求分析和设计

5.1.1 产品原型

5.1.2 接口设计

5.2 代码开发

5.2.1 菜品回显

5.2.1 修改菜品实现

5.3 功能测试


  • 公共字段自动填充

  • 新增菜品

  • 菜品分页查询

  • 删除菜品

  • 修改菜品

功能实现:菜品管理

菜品管理效果图:

1. 公共字段自动填充

1.1 问题分析

在上一章节我们已经完成了后台系统的员工管理功能菜品分类功能的开发,在新增员工或者新增菜品分类时需要设置创建时间、创建人、修改时间、修改人等字段,在编辑员工或者编辑菜品分类时需要设置修改时间、修改人等字段。这些字段属于公共字段,也就是也就是在我们的系统中很多表中都会有这些字段,如下:

序号 字段名 含义 数据类型
1 create_time 创建时间 datetime
2 create_user 创建人id bigint
3 update_time 修改时间 datetime
4 update_user 修改人id bigint

而针对于这些字段,我们的赋值方式为:

1). 在新增数据时, 将createTime、updateTime 设置为当前时间, createUser、updateUser设置为当前登录用户ID。

2). 在更新数据时, 将updateTime 设置为当前时间, updateUser设置为当前登录用户ID。

目前,在我们的项目中处理这些字段都是在每一个业务方法中进行重复的赋值操作,如果都按照上述的操作方式来处理这些公共字段,需要在每一个业务方法中进行操作,编码相对冗余、繁琐,那能不能对于这些公共字段在某个地方统一处理,来简化开发呢?

答案是可以的,我们使用AOP切面编程,实现功能增强,来完成公共字段自动填充功能。

重点!!!!!

1.2 实现思路

在实现公共字段自动填充,也就是在插入或者更新的时候为指定字段赋予指定的值,使用它的好处就是可以统一对这些字段进行处理,避免了重复代码。在上述的问题分析中,我们提到有四个公共字段,需要在新增/更新中进行赋值操作, 具体情况如下:

序号 字段名 含义 数据类型 操作类型
1 create_time 创建时间 datetime insert
2 create_user 创建人id bigint insert
3 update_time 修改时间 datetime insert、update
4 update_user 修改人id bigint insert、update

要在哪一层实现AOP来增强功能?为什么不能增强service层,一定要去增强mapper?

因为只有mapper层才能拿到employee、category对象,才能去补充缺失的公共字段。

那么我们这里应该用AOP的哪种通知类型呢?

这里我们要想清楚,补充这些公共字段究竟是在mapper层的update和insert方法之前,还是之后,又或是环绕?

肯定是在方法执行之前的,拿到mapper层update和category的参数对象,然后调用参数的方法进行公共字段赋值。

实现步骤:

1). 自定义注解 AutoFill,用于标识需要进行公共字段自动填充的方法

2). 自定义切面类 AutoFillAspect,统一拦截加入了 AutoFill 注解的方法,通过反射为公共字段赋值

3). 在 Mapper 的方法上加入 AutoFill 注解

技术点:枚举、注解、AOP、反射

1.3 代码开发

按照上一小节分析的实现步骤依次实现,共三步。

1.3.1 步骤一

自定义注解 AutoFill

进入到sky-server模块,创建com.sky.annotation包。

/**
 * 自定义公共字段自动填充注解
 */
@Target(ElementType.METHOD) //注解的适用范围,限制在方法上
@Retention(RetentionPolicy.RUNTIME) //注解的生命周期
public @interface AutoFill {
    //用来标识新增还是修改
    OperationType value();
}

由于我们在mapper层执行的方法是不同的,insert和update方法补充的公共字段是不一样的,所以我们应该在添加注解时再添加一个标识,区分到底是添加几个字段,方便在切面类 AutoFillAspect中获取注解值判断,然后进行相对应的添加公共字段操作。

为什么要用枚举类OperationType value();,而不是String value;?

  • 类型安全:枚举提供了编译时类型检查,避免了因拼写错误导致的运行时错误。如果使用String,很容易因为拼写错误(如"INSERT"写成"INSET")而引发问题,而这些错误只有在运行时才能发现。
  • 代码可读性和维护性:枚举使代码更加清晰和易于理解。开发者可以直接看到所有可能的值,而不需要记住字符串常量。
  • IDE支持:使用枚举时,IDE可以提供自动补全和重构支持,提高开发效率并减少错误。
  • 避免魔法值,代码更加规范,可读性更好

其中枚举类OperationType已在sky-common模块中定义

package com.sky.enumeration;

/**
 * 数据库操作类型
 */
public enum OperationType {

    /**
     * 更新操作
     */
    UPDATE,

    /**
     * 插入操作
     */
    INSERT

}

1.3.2 步骤二

自定义切面 AutoFillAspect

在sky-server模块,创建com.sky.aspect包。

/**
 * 公共字段自动填充切面类
 */
@Aspect
@Component
@Slf4j
public class AutoFillAspect {

    @Before("@annotation(com.sky.anno.AutoFill)")
    public void autoFill(JoinPoint joinPoint){
        log.info("开始进行公共字段自动填充...");
        //1.获取目标方法上的注解,并拿到注解里的属性值
        MethodSignature methodsignature = (MethodSignature)joinPoint.getSignature();//拿到方法签名(强转)
        Method method = methodsignature.getMethod(); //拿到方法对象
        AutoFill autoFill = method.getAnnotation(AutoFill.class);//拿到注解对象
        OperationType operationType = autoFill.value();//拿到注解属性值

        //2.获取到目标方法的参数对象
        Object[] args = joinPoint.getArgs();
        if (args == null || args.length == 0) {
            return;
        }
        Object entity = args[0];//不可强转,因为参数对象可能是任意类型(员工,分类,菜品等)

        //3.判断注解中属性值,如果是INSERT,就补充四个字段(创建时间、更新时间、创建人、更新人)
        try {
            if (operationType == OperationType.INSERT) {
                //通过反射去补充属性值
                Method setCreateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class);
                Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
                Method setCreateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER, Long.class);
                Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
                setCreateTime.invoke(entity, LocalDateTime.now());
                setUpdateTime.invoke(entity, LocalDateTime.now());
                setCreateUser.invoke(entity, BaseContext.getCurrentId());
                setUpdateUser.invoke(entity, BaseContext.getCurrentId());
            }else if (operationType == OperationType.UPDATE){
                //4.判断注解中属性值,如果是UPDATE,就补充四个字段(更新时间、更新人)
                Method setCreateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER, Long.class);
                Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
                //通过反射为对象属性赋值
                setCreateUser.invoke(entity, BaseContext.getCurrentId());
                setUpdateUser.invoke(entity, BaseContext.getCurrentId());
            }
        } catch (Exception e) {
            throw new RuntimeException(e);
        }

    }
}
1.3.3 步骤三

在Mapper接口的方法上加入 AutoFill 注解

CategoryMapper为例,分别在新增和修改方法添加@AutoFill()注解,也需要EmployeeMapper做相同操作

@Mapper
public interface CategoryMapper {

    /**
     * 插入数据
     * @param category
     */
    @AutoFill(OperationType.INSERT)
    @Insert("insert into category(type, name, sort, status, create_time, update_time, create_user, update_user)" +
            " VALUES" +
            " (#{type}, #{name}, #{sort}, #{status}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser})")
    void insert(Category category);

    /**
     * 分页查询
     * @param categoryPageQueryDTO
     * @return
     */
    Page<Category> pageQuery(CategoryPageQueryDTO categoryPageQueryDTO);

    /**
     * 根据id删除分类
     * @param id
     */
    @Delete("delete from category where id = #{id}")
    void deleteById(Long id);

    /**
     * 根据id修改分类
     * @param category
     */
    @AutoFill(OperationType.UPDATE)
    void update(Category category);

    /**
     * 根据类型查询分类
     * @param type
     * @return
     */
    List<Category> list(Integer type);
}

同时,将业务层为公共字段赋值的代码注释掉。

1). 将员工管理的新增和编辑方法中的公共字段赋值的代码注释。

2). 将菜品分类管理的新增和修改方法中的公共字段赋值的代码注释。

1.4 功能测试

新增菜品分类为例,进行测试

启动项目和Nginx

查看控制台

通过观察控制台输出的SQL来确定公共字段填充是否完成

其中create_time,update_time,create_user,update_user字段都已完成自动填充。

由于使用admin(id=1)用户登录进行菜品添加操作,故create_user,update_user都为1.

1.5 代码提交

2. 新增菜品

2.1 需求分析与设计

2.1.1 产品原型

后台系统中可以管理菜品信息,通过 新增功能来添加一个新的菜品,在添加菜品时需要选择当前菜品所属的菜品分类,并且需要上传菜品图片。

这个新增菜品跟之前的案例不一样,一个新增菜品功能里面会包含多个请求,之前我们写的分类查询就会在点击新增菜品时开始发送请求,所以在点击菜品分类的下拉列表中才会有之前我们所写的分类数据。

新增菜品原型:

 

当填写完表单信息, 点击"保存"按钮后, 会提交该表单的数据到服务端, 在服务端中需要接受数据, 然后将数据保存至数据库中。

业务规则:

  • 菜品名称必须是唯一的

  • 菜品必须属于某个分类下,不能单独存在

  • 新增菜品时可以根据情况选择菜品的口味

  • 每个菜品必须对应一张图片

2.1.2 接口设计

根据上述原型图先粗粒度设计接口,共包含3个接口。

接口设计:

  • 根据类型查询分类(已完成)

  • 文件上传

  • 新增菜品

接下来细粒度分析每个接口,明确每个接口的请求方式、请求路径、传入参数和返回值。

1. 根据类型查询分类(已完成)

2. 文件上传

3. 新增菜品

2.1.3 表设计

通过原型图进行分析:

新增菜品,其实就是将新增页面录入的菜品信息插入到dish表,如果添加了口味做法,还需要向dish_flavor表插入数据。所以在新增菜品时,涉及到两个表:

表名 说明
dish 菜品表
dish_flavor 菜品口味表

1). 菜品表:dish

字段名 数据类型 说明 备注
id bigint 主键 自增
name varchar(32) 菜品名称 唯一
category_id bigint 分类id 逻辑外键
price decimal(10,2) 菜品价格
image varchar(255) 图片路径
description varchar(255) 菜品描述
status int 售卖状态 1起售 0停售
create_time datetime 创建时间
update_time datetime 最后修改时间
create_user bigint 创建人id
update_user bigint 最后修改人id

2). 菜品口味表:dish_flavor

字段名 数据类型 说明 备注
id bigint 主键 自增
dish_id bigint 菜品id 逻辑外键
name varchar(32) 口味名称
value varchar(255) 口味值

2.2 代码开发

2.2.1 文件上传实现

因为在新增菜品时,需要上传菜品对应的图片(文件),包括后绪其它功能也会使用到文件上传,故要实现通用的文件上传接口。

文件上传,是指将本地图片、视频、音频等文件上传到服务器上,可以供其他用户浏览或下载的过程。文件上传在项目中应用非常广泛,我们经常发抖音、发朋友圈都用到了文件上传功能。

实现文件上传服务,需要有存储的支持,那么我们的解决方案将以下几种:

  1. 直接将图片保存到服务的硬盘(springmvc中的文件上传)

    1. 优点:开发便捷,成本低

    2. 缺点:扩容困难

  2. 使用分布式文件系统进行存储

    1. 优点:容易实现扩容

    2. 缺点:开发复杂度稍大(有成熟的产品可以使用,比如:FastDFS,MinIO)

  3. 使用第三方的存储服务(例如OSS)

    1. 优点:开发简单,拥有强大功能,免维护

    2. 缺点:付费

在本项目选用阿里云的OSS服务进行文件存储。(前面已学习过阿里云OSS,不再赘述)

实现步骤:

1). 定义OSS相关配置

在sky-server模块

application-dev.yml

sky:
  alioss:
    endpoint: oss-cn-hangzhou.aliyuncs.com
    access-key-id: LTAI5tPeFLzsPPT8gG3LPW64
    access-key-secret: U6k1brOZ8gaOIXv3nXbulGTUzy6Pd7
    bucket-name: sky-take-out

application.yml

spring:
  profiles:
    active: dev    #设置环境
sky:
  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}

2). 读取OSS配置

在sky-common模块中,已定义

@Component
@ConfigurationProperties(prefix = "sky.alioss")
@Data
public class AliOssProperties {

    private String endpoint;
    private String accessKeyId;
    private String accessKeySecret;
    private String bucketName;

}

1. @Component 注解
@Component 是 Spring 框架中的一个基础注解,属于 stereotype 注解之一。
作用:
组件注册:将 AliOssProperties 类标记为 Spring 容器中的一个组件(Bean)
自动扫描:使得 Spring 在组件扫描过程中能够发现并加载这个类
依赖注入基础:为其他类通过 @Autowired 注入 AliOssProperties 实例提供基础

2. @ConfigurationProperties 注解
@ConfigurationProperties 是 Spring Boot 提供的专门用于处理配置属性的注解。
作用:
配置绑定:将配置文件(如 application.yml 或 application.properties)中的属性值自动绑定到类的字段上
前缀匹配:通过 prefix 参数指定配置属性的前缀,只绑定匹配该前缀的属性
 

执行过程:
1.Spring 启动时扫描到 @Component 注解,创建 AliOssProperties 实例
2.@ConfigurationProperties 发现 prefix="sky.alioss",开始从配置文件中查找匹配的属性
3.自动将配置文件中的值绑定到对应字段:

  • sky.alioss.endpoint → endpoint 字段
  • sky.alioss.access-key-id → accessKeyId 字段
  • sky.alioss.access-key-secret → accessKeySecret 字段
  • sky.alioss.bucket-name → bucketName 字段

 4.通过 @Data(Lombok)自动生成 getter 和 setter 方法

3). 生成OSS工具类对象

在sky-server模块

/**
 * 阿里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;
    }
}

@Bean 注解的基本含义
@Bean 是 Spring 框架中的一个核心注解,用于在配置类中显式地定义和注册一个 Bean 到 Spring 容器中。

1. Bean 创建和注册

  • @Bean 告诉 Spring 容器:执行这个方法并将其返回值注册为一个名为 "aliOssUtil" 的 Bean
  • 方法名 aliOssUtil 就是 Bean 的名称(默认情况下)

2. 依赖注入

  • 方法参数 AliOssProperties aliOssProperties 会由 Spring 自动注入
  • 这个 AliOssProperties 实例已经在 Spring 容器中存在(因为它有 @Component 和 @ConfigurationProperties 注解)

3. 自定义初始化逻辑

  • 允许你执行自定义的 Bean 创建逻辑
  • 在这里,使用从配置文件读取的 OSS 参数创建 AliOssUtil 实例

4. 整合配置和工具类
整个流程如下:
AliOssProperties 通过 @Component 和 @ConfigurationProperties 从配置文件读取 OSS 配置
OssConfiguration 中的 @Bean 方法获取这些配置并创建 AliOssUtil 实例
将 AliOssUtil 实例注册到 Spring 容器中供其他组件使用


其中,AliOssUtil.java已在sky-common模块中定义

@Data
@AllArgsConstructor
@Slf4j
public class AliOssUtil {

    private String endpoint;
    private String accessKeyId;
    private String accessKeySecret;
    private String bucketName;

    /**
     * 文件上传
     *
     * @param bytes
     * @param objectName
     * @return
     */
    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();
    }
}

4). 定义文件上传接口

在sky-server模块中定义接口

@Slf4j
@RequestMapping("/admin/common")
@RestController
public class CommonController {

    @Autowired
    private AliOssUtil aliOssUtil;


    //使用阿里云oss做文件上传
    @PostMapping("/upload")
    //如果参数必须要写image可以在参数前面加上RequestParam("file")
    public Result upload(MultipartFile file) {
        //获取原始文件名
        String originalFilename = file.getOriginalFilename();
        log.info("文件上传原始文件名:{}", originalFilename);
        //获取文件后缀
        String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); // .png
        String url = null;

        try {
            //1.调用AliOssUtil工具类的upload文件上传方法
            String objectName = UUID.randomUUID().toString() + suffix;
            url = aliOssUtil.upload(file.getBytes(), objectName);
        } catch (IOException e) {
            log.info("文件上传失败!!!{}",e.getMessage());
            return Result.error("文件上传失败");
        }

        //2.返回图片结果
        return Result.success(url);
    }
}

2.2.2 新增菜品实现

1). 设计DTO类

在sky-pojo模块中

@Data
public class DishDTO implements Serializable {

    private Long id;
    //菜品名称
    private String name;
    //菜品分类id
    private Long categoryId;
    //菜品价格
    private BigDecimal price;
    //图片
    private String image;
    //描述信息
    private String description;
    //0 停售 1 起售
    private Integer status;
    //口味
    private List<DishFlavor> flavors = new ArrayList<>();

}

2). Controller层

进入到sky-server模块

@Slf4j
@Api(tags = "菜品相关接口")
@RestController
@RequestMapping("/admin/dish")
public class DishController {

    @Autowired
    private DishService dishService;


    /**
     * 新增菜品
     * @param dto
     * @return
     */
    @PostMapping
    public Result<String> addDish(@RequestBody DishDTO dto) {
        log.info("新增菜品:{}", dto);
        dishService.addDish(dto);
        return Result.success();
    }
}

3). Service层接口

public interface DishService {

    /**
     * 新增菜品和对应的口味
     * @param dto
     */
    void addDish(DishDTO dto);
}

4). Service层实现类

@Slf4j
@Service
public class DishServiceImpl implements DishService {

    @Autowired
    private DishMapper dishMapper;

    @Autowired
    private DishFlavorMapper dishFlavorMapper;

    @Transactional //开启事务(涉及到多表的增删改操作需要开启事务)
    public void addDish(DishDTO dto) {
        //1.构造菜品基本信息数据,将其存入dish表中
        Dish dish = new Dish();
        //拷贝属性值
        BeanUtils.copyProperties(dto,dish);
        //调用mapper保存方法
        dishMapper.insert(dish);
        //TODO
        log.info("dishID={}",dish.getId());


        //2.构造菜品口味列表数据,将其存入dish_flavor表中
        List<DishFlavor> dishFlavorList = dto.getFlavors();
        //2.1 关联菜品id
        dishFlavorList.forEach(flavor -> {
            flavor.setDishId(dish.getId());
        });
        //2.2 调用mapper保存方法,批量插入口味列表数据
        dishFlavorMapper.insertBatch(dishFlavorList);
    }
}

注意这里mapper层的插入菜品方法要想拿到口味列表对应的菜品id,必须添加@Options注解,将主键值赋给id:

@Options(useGeneratedKeys = true,keyProperty = "id") //获取主键值,并且赋值给id

5). Mapper层

DishMapper.java中添加

public interface DishMapper {

    /**
     * 根据分类id查询菜品数量
     * @param categoryId
     * @return
     */
    @Select("select count(id) from dish where category_id = #{categoryId}")
    Integer countByCategoryId(Long categoryId);

    /**
     * 插入菜品数据
     * @param dish
     */
    @AutoFill(OperationType.INSERT)
//    @Options(useGeneratedKeys = true,keyProperty = "id") //获取主键值,并且赋值给id
//    @Insert("insert into dish values (null,#{name},#{categoryId},#{price},#{image},#{description}" +
//            ",#{status},#{createTime},#{updateTime},#{createUser},#{updateUser})")
    void insert(Dish dish);
}

在/resources/mapper中创建DishMapper.xml

新增菜品,注意:获取主键属性值的时候,如果sql写在xml中,那么useGeneratedKeys="true" keyProperty="id"必须写在xml中

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.DishMapper">
    <!--新增菜品,注意:获取主键属性值的时候,如果sql写在xml中,那么useGeneratedKeys="true" keyProperty="id"必须写在xml中-->
    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
        insert into dish values (null,#{name},#{categoryId},#{price},#{image},#{description},
            #{status},#{createTime},#{updateTime},#{createUser},#{updateUser})
    </insert>
</mapper>

DishFlavorMapper.java

//注意:启动类上加了@MapperScan注解,该接口的@Mapper注解可省略
public interface DishFlavorMapper {

    /**
     * 批量插入口味列表数据
     * @param dishFlavorList
     */
    void insertBatch(List<DishFlavor> dishFlavorList);
}

在启动类加了@MapperScan("com.sky.mapper") //指定扫描mapper  后,所有mapper层的@Mapper可以省略。

在/resources/mapper中创建DishFlavorMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.DishFlavorMapper">
    <!--批量插入-->
    <insert id="insertBatch">
        insert into dish_flavor (dish_id, name, value) values
        <foreach collection="list" item="dishFlavor" separator=",">
            (#{dishFlavor.dishId},#{dishFlavor.name},#{dishFlavor.value})
        </foreach>
    </insert>
</mapper>

注意!!!这里用到了foreach循环插入。

2.3 功能测试

进入到菜品管理--->新建菜品

由于没有实现菜品查询功能,所以保存后,暂且在表中查看添加的数据。

dish表:

dish_flavor表:

测试成功。

2.4代码提交

3. 菜品分页查询

3.1 需求分析和设计

3.1.1 产品原型

系统中的菜品数据很多的时候,如果在一个页面中全部展示出来会显得比较乱,不便于查看,所以一般的系统中都会以分页的方式来展示列表数据。

菜品分页原型:

在菜品列表展示时,除了菜品的基本信息(名称、售价、售卖状态、最后操作时间)外,还有两个字段略微特殊,第一个是图片字段 ,我们从数据库查询出来的仅仅是图片的名字,图片要想在表格中回显展示出来,就需要下载这个图片。第二个是菜品分类,这里展示的是分类名称,而不是分类ID,此时我们就需要根据菜品的分类ID,去分类表中查询分类信息,然后在页面展示。

业务规则:

  • 根据页码展示菜品信息

  • 每页展示10条数据

  • 分页查询时可以根据需要输入菜品名称、菜品分类、菜品状态进行查询

3.1.2 接口设计

根据上述原型图,设计出相应的接口。

3.2 代码开发

3.2.1 设计DTO类

根据菜品分页查询接口定义设计对应的DTO:

在sky-pojo模块中,已定义


@Data
public class DishPageQueryDTO implements Serializable {

    private int page;
    private int pageSize;
    private String name;
    private Integer categoryId; //分类id
    private Integer status; //状态 0表示禁用 1表示启用

}

3.2.2 设计VO类

根据菜品分页查询接口定义设计对应的VO:

在sky-pojo模块中,已定义

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DishVO implements Serializable {

    private Long id;
    //菜品名称
    private String name;
    //菜品分类id
    private Long categoryId;
    //菜品价格
    private BigDecimal price;
    //图片
    private String image;
    //描述信息
    private String description;
    //0 停售 1 起售
    private Integer status;
    //更新时间
    private LocalDateTime updateTime;
    //分类名称
    private String categoryName;
    //菜品关联的口味
    private List<DishFlavor> flavors = new ArrayList<>();
}

分页查询要注意返回的数据类型是DishVO,因为Dish实体类中并不存在接口文档要求返回的categoryName。

3.2.3 Controller层

根据接口定义创建DishController的page分页查询方法:

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

3.2.4 Service层接口

在 DishService 中扩展分页查询方法:

/**
     * 菜品查询
     * @param dto
     * @return
     */
    PageResult page(DishPageQueryDTO dto);

3.2.5 Service层实现类

在 DishServiceImpl 中实现分页查询方法:

/**
     * 菜品查询
     * @param dto
     * @return
     */
    @Override
    public PageResult page(DishPageQueryDTO dto) {
        //1.设置分页参数
        PageHelper.startPage(dto.getPage(),dto.getPageSize());

        //2.调用mapper的列表查询方法,强转成Page
        Page<DishVO> page = dishMapper.list(dto);

        //3.封装PageResult对象并返回
        return new PageResult(page.getTotal(),page.getResult());
    }

3.2.6 Mapper层

在 DishMapper 接口中声明 pageQuery 方法:

/**
     * 菜品分页查询
     * @param dto
     * @return
     */
    Page<DishVO> list(DishPageQueryDTO dto);

在 DishMapper.xml 中编写SQL:

<!--菜品条件列表查询-->
    <select id="list" resultType="com.sky.vo.DishVO">
        select t1.*,t2.name categoryName from dish t1,category t2
                            where t1.category_id = t2.id
        <if test="name != null and name != ''">
            and t1.name like concat('%',#{name},'%')
        </if>
        <if test="status != null and status != ''">
            and t1.status = #{status}
        </if>
        <if test="categoryId != null">
            and t1.category_id = #{categoryId}
        </if>
        order by t1.update_time desc
    </select>

注意xml文件中的where此时可以不换成标签格式,因为where后面的标签是必须存在的,要联表查询,才能查到菜品对应的分类名称。

注意:

分类名称并没有显示到前端页面,是因为数据库的表名和DishVO的分类名称不匹配,虽然可以驼峰命名,但是这里的名称完全不一样,我们需要给数据库t2.name起别名

3.3 功能测试

3.3.1 接口文档测试

启动服务:访问http://localhost:8080/doc.html,进入菜品分页查询接口

注意:使用admin用户登录重新获取token,防止token失效。

点击发送:

3.3.2 前后端联调测试

启动nginx,访问 http://localhost

点击菜品管理

数据成功查出。

4. 删除菜品

4.1 需求分析和设计

4.1.1 产品原型

在菜品列表页面,每个菜品后面对应的操作分别为修改删除停售,可通过删除功能完成对菜品及相关的数据进行删除。

删除菜品原型:

业务规则:

  • 可以一次删除一个菜品,也可以批量删除菜品

  • 起售中的菜品不能删除

  • 被套餐关联的菜品不能删除

  • 删除菜品后,关联的口味数据也需要删除掉

4.1.2 接口设计

根据上述原型图,设计出相应的接口。

注意:删除一个菜品和批量删除菜品共用一个接口,故ids可包含多个菜品id,之间用逗号分隔。

4.1.3 表设计

在进行删除菜品操作时,会涉及到以下三张表。这里用到一个中间表来进行删除菜品对应的口味。

注意事项:

  • 在dish表中删除菜品基本数据时,同时,也要把关联在dish_flavor表中的数据一块删除。

  • setmeal_dish表为菜品和套餐关联的中间表。

  • 若删除的菜品数据关联着某个套餐,此时,删除失败。

  • 若要删除套餐关联的菜品数据,先解除两者关联,再对菜品进行删除。

4.2 代码开发

4.1.2 Controller层

根据删除菜品的接口定义在DishController中创建方法:

/**
     * 删除菜品
     * @param ids
     * @return
     */
    @ApiOperation("删除菜品")
    @DeleteMapping
    public Result delete(@RequestParam List<Long> ids) {
        log.info("删除菜品:{}", ids);
        dishService.delete(ids);
        return Result.success();
    }

注意删除菜品时要加注解@RequestParam,原因是什么?有什么作用?

1.参数绑定需求:

  • Spring MVC 默认将 HTTP 请求中的查询参数(query parameters)或表单数据绑定到控制器方法的参数上
  • @RequestParam 明确指定该参数应从请求参数中获取,而不是请求体(request body)或其他来源

2.处理数组/列表参数:

  • 删除菜品接口需要接收一个 List<Long> 类型的 ids 参数
  • @RequestParam 可以正确处理 URL 中传递的多个同名参数(如 ?ids=1&ids=2&ids=3)并将其转换为列表

3.明确参数来源:

  • 使用 @RequestParam 可以让代码更清晰地表达参数来源,提高代码可读性和维护性

4.避免因参数解析不明确导致的潜在问题

  • RESTful API 规范:对于 DELETE 请求,通常通过查询参数传递要删除的资源标识符列表
  • 这符合 RESTful API 设计的最佳实践

4.2.2 Service层接口

在DishService接口中声明delete方法:

/**
     * 批量删除
     * @param ids
     */
    void delete(List<Long> ids);

4.2.3 Service层实现类

在DishServiceImpl中实现delete方法:

/**
     * 批量删除菜品
     *
     * @param ids
     */
    @Transactional //0.多表操作开启事务
    @Override
    public void delete(List<Long> ids) {
        //1.删除菜品之前要判断菜品状态,是否起售
        for (Long id : ids) {
            Dish dish = dishMapper.getById(id);
            if (dish.getStatus() == StatusConstant.ENABLE) {
                //起售中的菜品不能删除
                throw new DeletionNotAllowedException(MessageConstant.DISH_ON_SALE);
            }
        }
        //2.菜品是否被套餐关联,套餐关联则无法删除
        int count = setmealDishMapper.countByDishId(ids);
        if (count > 0) {
            throw new DeletionNotAllowedException(MessageConstant.DISH_BE_RELATED_BY_SETMEAL);
        }
        //3.删除菜品基本信息dish表
        dishMapper.delete(ids);

        //4.删除菜品口味dish_flavor表
        dishFlavorMapper.deleteByDishId(ids);
    }

注意!!!这里删除菜品涉及到多张表格的查询,一定要开启事务管理。

4.2.4 Mapper层

在DishMapper中声明getById方法,并配置SQL:

/**
     * 根据id删除菜品数据
     * @param ids
     */
    void delete(List<Long> ids);
<!--    批量删除菜品-->
    <delete id="delete">
        delete from dish where id in (
        <foreach item="id" collection="ids" separator="," open="(" close=")">
            #{id}
        </foreach>
        )
    </delete>

重点!!!注意在菜品套餐关联表中,根据传入的id集合在xml中写删除语句时,id要用到循环。

创建SetmealDishMapper,声明getSetmealIdsByDishIds方法,并在xml文件中编写SQL:

/**
     * 根据菜品id查询套餐id
     * @param dishIds
     * @return
     */
    int countByDishId(List<Long> dishIds);
<select id="countByDishId" resultType="java.lang.Integer">
        select count(*) from setmeal_dish where dish_id in (
            <foreach item="dishId" collection="dishIds" separator="," open="(" close=")">
                #{dishId}
            </foreach>
        )
    </select>

在DishFlavorMapper中声明deleteByDishId方法并配置SQL:

/**
     * 根据菜品id删除对应的口味数据
     * @param dishIds
     * @return
     */
    void deleteByDishId(List<Long> dishIds);
<!--    批量删除口味-->
    <delete id="deleteByDishId">
        delete from dish_flavor where dish_id in
        <foreach item="dishId" collection="dishIds" separator="," open="(" close=")">
            #{dishId}
        </foreach>
    </delete>

同样这里一定要注意批量删除菜品的逻辑,要用到foreach循环。

4.3 功能测试

既可以通过Swagger接口文档进行测试,也可以通过前后端联调测试,接下来,我们直接使用前后端联调测试

进入到菜品列表查询页面

同时,进到dish表和dish_flavor两个表查看测试菜品的相关数据都已被成功删除。

再次,删除状态为启售的菜品

删除失败,因为起售中的菜品不能删除。

4.4 代码提交

5. 修改菜品

5.1 需求分析和设计

5.1.1 产品原型

在菜品管理列表页面点击修改按钮,跳转到修改菜品页面,在修改页面回显菜品相关信息并进行修改,最后点击保存按钮完成修改操作。

修改菜品原型:

5.1.2 接口设计

通过对上述原型图进行分析,该页面共涉及4个接口。

接口:

  • 根据id查询菜品

  • 根据类型查询分类(已实现)

  • 文件上传(已实现)

  • 修改菜品

我们只需要实现根据id查询菜品修改菜品两个接口,接下来,我们来重点分析这两个接口。

1). 根据id查询菜品(菜品回显)

2). 修改菜品

注:因为是修改功能,请求方式可设置为PUT。

5.2 代码开发

5.2.1 菜品回显

1). Controller层

根据id查询菜品的接口定义在DishController中创建方法:

/**
     * 回显菜品
     * @param id
     * @return
     */
    @ApiOperation("根据id查询菜品")
    @GetMapping("/{id}")
    public Result<DishVO> getById(@PathVariable Long id) {
        log.info("回显菜品:{}",id);
        DishVO dishVO = dishService.getById(id);
        return Result.success(dishVO);
    }

注意这里的id是路径参数一定要用到注解@PathVariable

 菜品回显这一部分的重点就是要重新封装回显的数据DishVO,因为Dish里面并没有封装菜品口味列表。

2). Service层接口

在DishService接口中声明getById方法:

/**
     * 根据id查询菜品和对应的口味
     * @param id
     * @return
     */
    DishVO getById(Long id);

3). Service层实现类

在DishServiceImpl中实现getById方法:

/**
     * 回显菜品
     *
     * @param id
     * @return
     */
    @Override
    public DishVO getById(Long id) {
        DishVO dishVO = new DishVO();

        //1.根据菜品id查询菜品基本信息
        Dish dish = dishMapper.getById(id);
        BeanUtils.copyProperties(dish, dishVO);
        //2.根据菜品id查询菜品口味列表数据
        List<DishFlavor> flavors = dishFlavorMapper.selectByDishId(id);
        dishVO.setFlavors(flavors);

        //2.构造VO对象并返回
        return dishVO;
    }

4). Mapper层

在DishMapper中声明getById方法来查询当前菜品对应的数据,并配置SQL:

/**
     * 根据id查询菜品和口味
     * @param id
     * @return
     */
    Dish getById(Long id);
<!--    根据id查询菜品-->
    <select id="getById" resultType="com.sky.entity.Dish">
        select *
        from dish
        where id = #{id}
    </select>

在DishFlavorMapper中声明selectByDishId方法来查询当前菜品对应的口味列表,并配置SQL:

/**
     * 根据菜品id查询对应的口味列表数据
     * @param dishId
     * @return
     */
    List<DishFlavor> selectByDishId(Long dishId);
<!--    根据菜品id查询口味列表数据-->
    <select id="selectByDishId" resultType="com.sky.entity.DishFlavor">
        select *
        from dish_flavor
        where dish_id = #{dishId}
    </select>

5.2.1 修改菜品实现

1). Controller层

根据修改菜品的接口定义在DishController中创建方法:

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

2). Service层接口

在DishService接口中声明update方法:

/**
     * 修改菜品和口味
     * @param dto
     */
    void update(DishDTO dto);

3). Service层实现类

在DishServiceImpl中实现update方法:

/**
     * 更新菜品数据(包括口味列表)
     *
     * @param dto
     * @return
     */
    @Transactional //0.多表操作开启事务
    public void update(DishDTO dto) {
        Dish dish = new Dish();
        BeanUtils.copyProperties(dto, dish);

        //1.修改菜品的基本信息,dish表
        dishMapper.update(dish);

        //2.修改口味列表信息,dish_flavor表
        //由于口味列表可能会新增,也可能会删除,还可能修改口味的值,
        // 所以需要先删除所有口味数据,再插入新的口味数据
        dishFlavorMapper.deleteBatch(dto.getId());

        List<DishFlavor> flavors = dto.getFlavors();
        if (flavors != null && flavors.size() > 0) {
            //关联菜品id
            flavors.forEach(flavor -> {
                flavor.setDishId(dto.getId());
            });
            dishFlavorMapper.insertBatch(dto.getFlavors());
        }


    }

修改口味列表时,我们会先执行删除操作,然后再重新添加Dishdto返回的数据,如果有新增的口味列表,列表是不会对应当前的菜品id的,所以我们要执行关联id操作,如下图所示:

注意:关联菜品id之前一定要进行判断:

if (flavors != null && flavors.size() > 0)

如果不判断,虽然不会进入列表循环,但是依然会调用mapper层的insert方法执行插入口味数据列表数据,此时SQL会出现语句异常。

4). Mapper层

在DishMapper中,声明update方法:

/**
     * 修改菜品数据
     * @param dish
     */
    @AutoFill(OperationType.UPDATE)
    void update(Dish dish);

并在DishMapper.xml文件中编写SQL:

<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>

在DishFlavorMapper类中写deleteBatch方法来删除口味列表:

/**
     * 根据菜品id删除口味列表
     * @param id
     */
    void deleteBatch(Long id);

编写sql语句:

<!--根据菜品id删除口味列表-->
    <delete id="deleteBatch">
        delete from dish_flavor where dish_id = #{dishId}

    </delete>

5.3 功能测试

本次测试直接通过前后端联调测试 ,可使用Debug方式启动项目,观察运行中步骤。

进入菜品列表查询页面,对第一个菜品的价格进行修改

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐