【苍穹外卖 | 篇③】功能实现(二)

在牛某网看见了牛肉哥的帖子之后,打算向牛肉大佬学习,故开始书写优快云博客,通过博客的方式来巩固自身知识学习。

因为之前有粗略的学习了Java Web的基础课程,所以博客内容主要是巩固之前学习当中的模糊点,以及一些自己认为重要的内容,用于自己进一步的掌握开发技能。

课程内容:

  • 公共字段自动填充

  • 新增菜品

  • 菜品分页查询

  • 删除菜品

  • 修改菜品

公共字段自动填充:

在每一次的创建与更新操作中,总会产生以下的一些步骤:

setCreateTime(LocalDateTime.now());
setUpdateTime(LocalDateTime.now());

setCreateUser(BaseContext.getCurrentId());
setUpdateUser(BaseContext.getCurrentId());

那么有没有什么方法可以来简化这些共有的操作呢?-------------------AOP切面编程,实现功能增强,来完成公共字段自动填充功能

实现步骤:

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

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

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

新建一个注解类:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {
    //数据库操作类型:UPDATE INSERT
    OperationType value();
}

注意点:

  • Target......:这是一个元注解:用于指定自定义注解可以用在什么地方,整体表示:只能用在方法上
  • Retention...:这也是一个元注解,用于指定注解的保留策略,整体表示:在程序运行时仍然存在,故可以通过反射来获取方法上的这个注解信息。
  • OperationType value():是自定义注解的属性,名称是value,类型是OperationType枚举:里面是更新与插入。

新建一个切面类:

@Slf4j
@Component
@Aspect
public class AutoFillAspect {
    /**
     * 切入点:标识需要进行自动填充处理的方法
     */
    @Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
    public void autoFillPointcut() {}

    /**
     * 前置通知:在目标方法执行前进行自动填充处理
     */
    @Before("autoFillPointcut()")
    public void autoFillBefore(JoinPoint joinPoint) {
        log.info("自动填充开始");
    }
}

注意点:

  • 切入点:告诉Spring AOP 框架:哪些方法是我们需要 “拦截” 并进行额外处理(自动填充)的目标。
  • com.sky.mapper.*: 表示匹配 com.sky.mapper 包下的任意类

  • .*:该类下的任何方法

  • (. .):匹配方法的任意参数列表

  • @annotation(com.sky.annotation.AutoFill):这个部分会匹配到所有带有 @AutoFill 注解的方法。

  • 前置通知:Spring AOP 会在目标方法执行之前(在切入点匹配到时,执行之前)调用这个通知方法。

  • autoFillPointcut():这是一个引用,它告诉 Spring,这个通知要应用autoFillPointcut() 切入点所匹配的那些方法上。

完善代码:

@Before("autoFillPointcut()")
    public void autoFillBefore(JoinPoint joinPoint) {
        log.info("自动填充开始");
        //获取到当前被拦截的方法上的数据库操作类型
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();//获取到当前被拦截的方法的签名对象
        AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class);//获取到当前被拦截的方法上的AutoFill注解对象
        OperationType operationType = autoFill.value();//获得注解里的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().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){
                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();
                }
        }
    }

注意点:

  • 要实现公共字段自动填充功能:主要是三步
  •  获取到当前被拦截的方法上的数据库操作类型--从注解上获取
  • 获取到当前被拦截的方法的参数--实体对象--从传入参数里获取
  • 准备赋值的数据
  •  根据当前不同的操作类型,为对应的属性通过反射来赋值
  • MethodSignature:方法签名的子类,专门用于获取方法的注解、返回值类型等;
  • Mapper 方法的参数是实体对象(如 (Employee employee)),因此直接取第一个参数即可
  • getDeclaredMethod(methodName, paramTypes):获取类中声明的方法(包括私有方法),需要指定方法名和参数类型;
  • method.invoke(entity, now):调用方法,第一个参数是 “要操作的对象”,后面是方法的参数。
  • 新增菜品:

文件上传(菜品图片):

文件上传:是指将本地图片、视频、音频等文件上传到服务器上,可以供其他用户浏览或下载的过程。

定义OSS相关配置

在application.yml中利用${}型,而在application-dev.yml中填写数据。

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}
@Component
@ConfigurationProperties(prefix = "sky.alioss")
@Data
public class AliOssProperties {

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

}

@ConfigurationProperties(prefix = "sky.alioss"):将配置文件application.yml中以 sky.alioss 为前缀的配置项,批量绑定到一个 Java Bean 的属性上

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();
    }
}

新增:阿里云OSS文件上传代码,同时拼接文件访问URL,用于返回

@Slf4j
@Configuration
public class OssConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public AliOssUtil aliOssUtil(AliOssProperties aliOssProperties) {
        log.info("新建AliOssUtil对象: {}", aliOssProperties);
        return new AliOssUtil(aliOssProperties.getEndpoint(),
                              aliOssProperties.getAccessKeyId(),
                              aliOssProperties.getAccessKeySecret(),
                              aliOssProperties.getBucketName());
    }
}
  •     @ConditionalOnMissingBean:Spring 容器,如果你现在还没有一个 AliOssUtil 类型的 Bean,那么就调用 aliOssUtil() 方法来创建一个,并把它注册到容器中。如果你已经有了一个 AliOssUtil Bean,那就什么都不用做,直接使用已有的那个。
@RestController
@Slf4j
@RequestMapping("/admin/common")
@Api(tags = "公共接口")
public class CommonController {

    @Autowired
    private AliOssUtil aliOssUtil;

    /**
     * 文件上传
     *
     * @param file
     * @return
     */
    @PostMapping("/upload")
    @ApiOperation("文件上传")
    public Result<String> upload(MultipartFile file) {
        log.info("文件上传:{}", file);
        try {
            String originalFilename = file.getOriginalFilename();//获得文件名
            String extention = originalFilename.substring(originalFilename.lastIndexOf("."));//获得文件后缀
            String objectName = UUID.randomUUID().toString() + extention;//构造新文件名称
            String filePath = aliOssUtil.upload(file.getBytes(),objectName);
            return Result.success(filePath);
        } catch (Exception e) {
            log.info("文件上传失败:{}", e);
        }
        return Result.error(MessageConstant.UPLOAD_FAILED);
    }
}

文件上传的整个流程:

  1. Spring Boot 应用启动时,会扫描带有 @Configuration 注解的类。它发现了 OssConfiguration
  2. Spring 首先执行配置类中的方法,特别是带有@Bean的
  3. 然后,Spring 执行 OssConfiguration 中的 aliOssUtil() 方法。
  4. 由于该方法的参数是 AliOssProperties,Spring 会自动从容器中找到这个 Bean 并传入。注意:在properties类里已经使用了@Component了,所以能找到
  5. properties在application.yml中填充了具体值,
  6. 回到配置类的方法内部,通过 new AliOssUtil(...) 手动创建了 AliOssUtil 的实例,并将 AliOssProperties 中的属性作为构造函数参数传递进去。
  7. 这个新创建的 AliOssUtil 实例被注册为 Spring Bean。
  8. 随后在CommonController中利用@AutoWired来声明依赖,所以此时的AliOssUtil实例aliOssUtil已经有了具体的bucketname等值,随后在进行aliOssUtil.upload(file.getBytes(),objectName)时也并不会报错

新增菜品实现:

逻辑外键:在业务逻辑层面,一个表的某个字段的值,逻辑上 引用了另一个表的主键或唯一索引字段的值。

    @Override
    @Transactional
    public void saveWithFlavor(DishDTO dishDTO) {
        // 1. 转换为 Dish 实体
        Dish dish = new Dish();
        BeanUtils.copyProperties(dishDTO, dish);
        dishMapper.insert(dish);

        Long dishId = dish.getId();//获取主键
        List<DishFlavor> flavors = dishDTO.getFlavors();
        if(flavors != null && flavors.size() > 0) {
            flavors.forEach(flavor -> {
                flavor.setDishId(dishId);//设置外键
            });
            dishFlavorMapper.insertBatch(flavors);
        }
    }

在原先学习的基础上,此处在save方法上进行了新的操作,由于DishDTO中有DishFlavor的列表,那么在新增菜品即操作菜品表的同时就有可能会操作到味道表。故需要用到逻辑外键。

 flavors.forEach(flavor -> {
                flavor.setDishId(dishId);//设置外键
            });
  • forEach集合遍历方法,作用是:依次取出集合中的每个元素,执行指定的操作
  • 设置外键:将设置的味道都归到该新增Dish的ID下
  • Transactional:事务处理,连续处理两个表的时候要确保一致性

我们以这两图为例,当同时设置了甜味与温度,那么List<DishFlavor> flavors里就会有两个DishFlavor对象,而forEach就会去遍历这两个对象,将这两个对象的DishID进行设置。

@AutoFill(OperationType.INSERT)
void insert(Dish dish);

注意使用自定义注解来自动填充公共字段

菜品分页查询:

因为返回数据里有categoryName,其他数据都包含在了Dish表里,但categoryName,因此我们需要设计一个VO类:按需封装数据,用于特定场景的数据展示或传递

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<>();
}

查询语句的写法:

  1. 要查到完整的DishVO,那么就需要查询dish和category两表
  2. 我的写法顺序
    1. 先查询dish表全部:select * from dish
    2. 还要查询category表的name字段,所以设置别名:select d.* , c.name from dish as d left outer join category as c on d.category_id =c.id
      1. LEFT OUTER JOIN 是 SQL 中一种常用的 表连接方式
      2. 以左侧表为基准,返回左表的所有记录,同时将右表中与左表匹配的记录合并进来;如果右表中没有匹配的记录,则用 NULL 填充右表的字段
      3. on 左表.关联字段 = 右表.关联字段;
    3. 因为d表里有字段name,c表查询返回也有name,为了防止映射错误,所以再做一步处理:c.name as categoryName
  3. 故总语句:
    select  d.*,c.name from dish as d left outer join category as c on d.category_id =c.id
  4. 再加where处理:
    1. <where>
          <if test="categoryId !=null">
              and categoryId=#{categoryId}
              </if>
          <if test="name !=null and name != ' '" >
              and name like concat('%',#{name},'%')
              </if>
          <if test="status !=null">
              and status=#{status}
          </if>
      </where>

删除菜品:

删除菜品并没有想象中的简单:操作会关联到三个数据表:dish,dish_flavor,setmeal_dish(套餐表)。因为:

  • 起售中的菜品不能删除

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

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

    @DeleteMapping
    @ApiOperation("菜品批量删除")
    public Result delete(@RequestParam List<Long> ids) {
        log.info("菜品批量删除:{}", ids);
        dishService.deleteBatch(ids);//后绪步骤实现
        return Result.success();
    }

注意点:

  • 请求参数ids,是一个字符串,只不过使用了逗号隔开。可以直接在Controller的参数中使用String ids,但需要我们做处理得到每一个id,因此这里使用@RequestParam注解来做处理,得到一个List<Long>型的ids。

当业务逻辑复杂的时候,可以先写好每一步的注解再去书写Java代码

//先判断该菜品是否正在售卖

    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);
        }
    @Delete("delete from dish_flavor where dish_id=#{dishId}")
    void deleteByDishId(Long dishId);

为什么Service方法里传入的参数是id,但这里要修改为dishId呢?------因为这样更加明确,表明是根据菜名来删除而不是主键来删除

 //select setmeal_id from setmeal_dish where dish_id in (1,2,3,4)
    List<Long> getSetmealIdsByDishIds(List<Long> dishIds);


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

在第二部查找关联套餐步骤中:因为我们要批量查找,例如要查找id=1,=2,=3,=4,那我们的sql语句应该是select setmeal_id from setmeal_dish where dish_id in (1,2,3,4),但我们无法确定id列表里到底是哪些Id,所以需要动态的去构建sql语句

采用foreach来实现:循环遍历集合中的元素,并将它们拼接成 SQL 语句的一部分。在你的场景中,它通常用于 “批量处理多个 ID”(比如批量查询、批量删除)。

  • collection:指定要遍历的集合参数名称

  • item:定义遍历集合时当前元素的变量名。每次循环中,集合中的一个元素会被赋值给 dishId 

  • separator:指定元素之间的分隔符。这里用逗号 , 分隔,最终会在元素之间添加 ,(如

  • open:遍历结果的前缀。这里指定以 ( 开头。

  • close:遍历结果的后缀。这里指定以 ) 结尾

优化:

        for (Long id : ids) {
            dishMapper.deleteById(id);
            dishFlavorMapper.deleteByDishId(id);
        }

此代码,因ids的长度变化,而执行次数发生变化,修改代码,使能够得到固定执行

        dishMapper.deleteByIds(ids);
        dishFlavorMapper.deleteByDishIds(ids);
    <delete id="deleteByDishIds">
        delete from dish_flavor where dish_id in
        <foreach collection="dishIds" open="(" close=")" separator="," item="dishId">
            #{dishId}
        </foreach>
    </delete>


    <delete id="deleteByIds">
        delete from dish where id in
            <foreach collection="ids" open="(" close=")" separator="," item="id">
                #{id}
            </foreach>
    </delete>

修改菜品:

查询回显:

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

路径中有“/{id}”:所以记得添加@PathVariable注解

为什么要利用VO型,而不是DTO型呢:

  • VO 用于后端向客户端返回数据,而 DTO 主要用于客户端向后端传递数据服务间内部数据传输

  • 返回数据需要组合菜品基本信息和关联的口味信息VO 可灵活封装这些数据;
    @Override
    public DishVO getByIdWithFlavor(Long id) {
        //根据id查询菜品数据
        //根据菜品id查询口味数据
        Dish dish =dishMapper.getById(id);
        List<DishFlavor> dishFlavors=dishFlavorMapper.getByDishId(id);
        DishVO dishVO = new DishVO();
        BeanUtils.copyProperties(dish,dishVO);
        dishVO.setFlavors(dishFlavors);
        return dishVO;
    }

查询双表再进行组合

修改数据:

@Override
    public void updateWithFlavor(DishDTO dishDTO) {
        Dish dish = new Dish();
        BeanUtils.copyProperties(dishDTO, dish);
        dishMapper.update(dish);
        //删除掉原来的口味,插入新的口味即可
        dishFlavorMapper.deleteByDishId(dish.getId());
        List<DishFlavor> flavors = dishDTO.getFlavors();
        if(flavors != null && flavors.size() > 0) {
            flavors.forEach(flavor -> {
                flavor.setDishId(dish.getId());
            });
            dishFlavorMapper.insertBatch(flavors);
        }
    }
  • 新建dish用于数据修改处理
  • 这里使用先删除再添加的方式来处理口味表
  • 添加操作与saveWithFlavor相同

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值