一个SpringBoot单体项目--》外卖平台项目之后台管理端基础功能开发

外卖平台

  • 一 、项目基础实现
    • 1.1、搭建项目之项目引入静态资源
    • 1.2、项目功能实现 --> 后台用户
      • 1.2.1 -->后台用户登录逻辑的实现
        • 1.2.1.1 前后端交互流程
        • 1.2.1.2 流程说明
      • 1.2.2-->后台用户登出逻辑的实现
      • 1.2.2.1 请求流程分析
      • 1.2.3-->后台新增员工逻辑的实现
        • 1.2.3.1 程序执行流程
        • 1.2.3.2 代码实现流程
        • 1.2.3.2 该功能实现注意事项
      • 1.2.4-->后台员工信息的分页查询逻辑的实现
        • 1.2.4.1 程序执行流程
        • 1.2.4.2 代码实现
      • 1.2.5-->后台启用/禁用员工逻辑的实现
        • 1.2.5.1 展示效果
        • 1.2.5.2 程序执行流程
        • 1.2.5.3 代码实现后出现的问题
      • 1.2.6-->后台编辑员工逻辑的实现
        • 1.2.6.1 程序执行流程
          • 1.2.6.2 根据ID查询员工
          • 1.2.6.3 修改员工
    • 1.3 关于实现MP提供的公共字段填充
      • 1.3.1 基于ThreadLocal封装的工具类
      • 1.3.2 自动填充类
    • 1.4 项目后台功能实现 --> 分类管理
      • 1.4.1 新增分类
        • 1.4.1.1 程序执行过程
        • 1.4.1.1 代码实现
      • 1.4.2 分类信息的分页查询
        • 1.4.2.1 程序执行过程
        • 1.4.2.2 代码实现
      • 1.4.3 删除分类
        • 1.4.3.1 执行流程
        • 1.4.3.2 代码实现
      • 1.4.4 修改分类
        • 1.4.4.1 分析
        • 1.4.4.1 实现
    • 1.5 项目后台功能实现 --> 菜品管理
      • 1.5.1 关于菜品管理 需要上传菜品图片 ==》涉及文件上传与下载
        • 上传逻辑:
        • 下载逻辑:
        • 具体实现:
      • 1.5.2 菜品新增
        • 1.5.2.1 交互流程
          • 各种类型的实体模型
        • 1.5.2.2 代码实现
      • 1.5.3 菜品分页查询
        • 1.5.3.1 需求分析
        • 1.5.3.2 前端和服务端交互过程
        • 1.5.3.3 代码实现
      • 1.5.3 菜品修改
        • 1.5.3.1 需求分析
        • 1.5.3.2 交互流程
        • 1.5.3.3 功能实现
          • 根据ID查询菜品信息
          • 修改菜品信息
    • 1.6 项目后台功能实现 --> 套餐管理
      • 1.6.1 --> 新增套餐
        • 1.6.1.1 需求分析
        • 1.6.1.2 数据模型
        • 1.6.1.3 前端页面与服务端的交互过程
        • 1.6.1.4 代码开发
          • 根据分类查询菜品
          • 保存套餐
      • 1.6.2 --> 套餐的分页查询
        • 1.6.2.1 前端页面和服务端的交互过程
        • 1.6.2.2 代码开发
          • 基本信息查询
      • 1.6.3 --> 删除套餐
        • 1.6.3.1 --> 需求分析
        • 1.6.3.2 --> 交互过程
        • 1.6.3.3 --> 代码实现

一 、项目基础实现

1.1、搭建项目之项目引入静态资源

关于SpringBoot一体化开发,可以将静态资源(html/css/js等)放在resource文件下,然后编写一个SpringMvc的WebMvcConfig(需要继承一个WebMvcConfigurationSupport,重写一个addResoucehandlers(增加来源处理器)方法)进行响应配置代码的编写;
demo:
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;

@Slf4j
@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {
    /**
     * 设置静态资源映射
     * @param registry
     */
    @Override
    protected void addResourceHandlers(ResourceHandlerRegistry registry) {
        log.info("开始进行静态资源映射...");  	
              			  registry.addResourceHandler("/backend/**(访问谁)").addResourceLocations("classpath:/backend/(映射到哪里去)");
        registry.addResourceHandler("/front/** 访问谁)").addResourceLocations("classpath:/front/"(映射到哪里去));
    }
}

1.2、项目功能实现 --> 后台用户

1.2.1 -->后台用户登录逻辑的实现

1.2.1.1 前后端交互流程

一个SpringBoot单体项目--》外卖平台项目之后台管理端基础功能开发_第1张图片

1.2.1.2 流程说明

前端发送一个post请求,请求体中携带本次请求应该带有的参数,在后台,经过javaweb提供的过滤器(使用它,需要在启动类中加入@ServletConponentScan),进行访问路径的过滤,然后放行到员工的cotroller层,在cotroller进行账号是否存在,密码是否正确,以及该账号的状态是否已被禁用,然后同一个返回一个结果给前端控制器,前端控制器将数据给前端页面;
注意:在登录成功后,要记录该登录用户到session域对象中,以备之后对用户信息的回显,判断用户登录状态,登出用户的操作;

1.2.2–>后台用户登出逻辑的实现

1.2.2.1 请求流程分析

前端发一个post请求到后端,后端清理session中保存的用户id,返回结果

1.2.3–>后台新增员工逻辑的实现

1.2.3.1 程序执行流程

A. 点击"保存"按钮, 页面发送ajax请求,将新增员工页面中输入的数据以json的形式提交到服务端, 请求方式POST, 请求路径 /employee
B. 服务端Controller接收页面提交的数据并调用Service将数据进行保存
C. Service调用Mapper操作数据库,保存数据

1.2.3.2 代码实现流程

A. 在新增员工时, 按钮页面原型中的需求描述, 需要给员工设置初始默认密码 123456, 并对密码进行MD5加密。
B. 在组装员工信息时, 还需要封装创建时间、修改时间,创建人、修改人信息(从session中获取当前登录用户)。

1.2.3.2 该功能实现注意事项

因为在数据库中对用户名进行了唯一的约束,假设,前台提交的增加用户的用户名,在数据库中已经存在,便会抛出一个Duplicate entry异常;显然,在spring中如果让它抛出了,就不得劲了,此时我们便使用Spring提供的全局异常处理机制,也就是说,所有的异常处理都交给spring,spring对异常进行处理;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import java.sql.SQLIntegrityConstraintViolationException;

/**
 * 全局异常处理
 */
@ControllerAdvice(annotations = {RestController.class, Controller.class})
@ResponseBody
@Slf4j
public class  GlobalExceptionHandler {

    /**
     * 异常处理方法
     * @return
     */
    @ExceptionHandler(SQLIntegrityConstraintViolationException.class)
    public R<String> exceptionHandler(SQLIntegrityConstraintViolationException ex){
        log.error(ex.getMessage());
        if(ex.getMessage().contains("Duplicate entry")){
            String[] split = ex.getMessage().split(" ");
            String msg = split[2] + "已存在";
            return R.error(msg);
        }
        return R.error("未知错误");
    }
}

注解说明:
@ControllerAdvice : 指定拦截那些类型的控制器;
​ @ResponseBody: 将方法的返回值 R 对象转换为json格式的数据, 响应给页面;

1.2.4–>后台员工信息的分页查询逻辑的实现

1.2.4.1 程序执行流程

A. 点击菜单,打开员工管理页面时,执行查询:
1). 页面发送ajax请求,将分页查询参数(page、pageSize、name)提交到服务端
2). 服务端Controller接收页面提交的数据, 并组装条件调用Service查询数据
3). Service调用Mapper操作数据库,查询分页数据
4). Controller将查询到的分页数据, 响应给前端页面
5). 页面接收到分页数据, 并通过ElementUI的Table组件展示到页面上
B. 搜索栏输入员工姓名,回车,执行查询:
1). 页面发送ajax请求,将分页查询参数(page、pageSize、name)提交到服务端
2). 服务端Controller接收页面提交的数据, 并组装条件调用Service查询数据
3). Service调用Mapper操作数据库,查询分页数据
4). Controller将查询到的分页数据, 响应给前端页面
5). 页面接收到分页数据, 并通过ElementUI的Table组件展示到页面上
最终发送给服务端的请求为 : GET请求 , 请求链接 /employee/page?page=1&pageSize=10&name=xxx

1.2.4.2 代码实现

1. 分页查询==》本项目dao层框架使用的是mybatis+mybatispuls==〉在dao层框架中提供了分页插件,如果要使用,只需要进行相应配置与导入即可;
2. 配置分页插件步骤:创建一个mybatisPlus的配置文件,要加上@Configration注解,然后编写一个返回返回值为mybatisPlusInterceptor(mybatisplus的拦截器)的方法;在方法中new一个mybatisPlusInterceptor,然后调用该对象中addInnerInterceptor(增加内部拦截器方法),该方法的概述为分页内部拦截器:
demo:
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * 配置MP的分页插件
 */
@Configuration
public class MybatisPlusConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor(){
        MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor();
        mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor());
        return mybatisPlusInterceptor;
    }
}
3. cotroller中代码实现逻辑
	1. 构造分页构造器;new 一个page<>对象
	2. 构造条件构造器
	3. 添加过滤条件
	4. 添加排序条件
	5. 执行查询

1.2.5–>后台启用/禁用员工逻辑的实现

1.2.5.1 展示效果

员工的启用\禁用操作中,在前台两者的操作只能是管理员进行,也就是说只有管理员(admin用户)可以对其他普通用户进行启用、禁用操作,所以普通用户登录系统后启用、禁用按钮不显示,这是在前台代码中对登录的用户进行了一个判断;

1.2.5.2 程序执行流程

1). 页面发送ajax请求,将参数(id、status)提交到服务端
2). 服务端Controller接收页面提交的数据并调用Service更新数据	
3). Service调用Mapper操作数据库
注意:启用、禁用员工账号,本质上就是一个更新操作,也就是对status状态字段进行操作。在Controller中创建update方法,此方法是一个通用的修改员工信息的方法。

1.2.5.3 代码实现后出现的问题

关于后台查询出来的员工对象的id值为一个19位的Long类型,但是前端的Long类型只能处理16位,此时需要我们将后端发给前台的long类型的数据,提前转为String类型,而当前台提交过来也会将String再转为Long
具体实现:
该功能是由于在SpringMVC中, 将Controller方法返回值转换为json对象, 是通过jackson来实现的, 涉及到SpringMVC中的一个消息转换器MappingJackson2HttpMessageConverter, 解决方法是对这个消息转换器进行扩展
实现步骤:
1). 提供对象转换器JacksonObjectMapper,基于Jackson进行Java对象到json数据的转换
package com.itheima.reggie.common;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
import java.math.BigInteger;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES;

/**
 * 对象映射器:基于jackson将Java对象转为json,或者将json转为Java对象
 * 将JSON解析为Java对象的过程称为 [从JSON反序列化Java对象]
 * 从Java对象生成JSON的过程称为 [序列化Java对象到JSON]
 */
public class JacksonObjectMapper extends ObjectMapper {

    public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
    public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
    public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss";

    public JacksonObjectMapper() {
        super();
        //收到未知属性时不报异常
        this.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);

        //反序列化时,属性不存在的兼容处理
        this.getDeserializationConfig().withoutFeatures(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);


        SimpleModule simpleModule = new SimpleModule()
                .addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
                .addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
                .addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)))

                .addSerializer(BigInteger.class, ToStringSerializer.instance)
                .addSerializer(Long.class, ToStringSerializer.instance)
                //long ==》String
                .addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
                .addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
                .addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)));

        //注册功能模块 例如,可以添加自定义序列化器和反序列化器
        this.registerModule(simpleModule);
    }
}

2). 在WebMvcConfig配置类中扩展Spring mvc的消息转换器,在此消息转换器中使用提供的对象转换器进行Java对象到json数据的转换
/**
 * 扩展mvc框架的消息转换器
 * @param converters
 */
@Override
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
    log.info("扩展消息转换器...");
    //创建消息转换器对象
    MappingJackson2HttpMessageConverter messageConverter = new MappingJackson2HttpMessageConverter();
    //设置对象转换器,底层使用Jackson将Java对象转为json
    messageConverter.setObjectMapper(new JacksonObjectMapper());
    //将上面的消息转换器对象追加到mvc框架的转换器集合中
    converters.add(0,messageConverter);
}

1.2.6–>后台编辑员工逻辑的实现

1.2.6.1 程序执行流程

1). 点击编辑按钮时,页面跳转到add.html,并在url中携带参数[员工id]
2). 在add.html页面获取url中的参数[员工id]
3). 发送ajax请求,请求服务端,同时提交员工id参数
4). 服务端接收请求,根据员工id查询员工信息,将员工信息以json形式响应给页面	
5). 页面接收服务端响应的json数据,通过VUE的数据绑定进行员工信息回显
6). 点击保存按钮,发送ajax请求,将页面中的员工信息以json方式提交给服务端
7). 服务端接收员工信息,并进行处理,完成后给页面响应
8). 页面接收到服务端响应信息后进行相应处理
1.2.6.2 根据ID查询员工

代码实现:

/**
 * 根据id查询员工信息
 * @param id
 * @return
 */
@GetMapping("/{id}")
public R<Employee> getById(@PathVariable Long id){
    log.info("根据id查询员工信息...");
    Employee employee = employeeService.getById(id);
    if(employee != null){
        return R.success(employee);
    }
    return R.error("没有查询到对应员工信息");
}
1.2.6.3 修改员工

代码实现:

/**
 * 根据id修改员工信息
 * @param employee
 * @return
 */
@PutMapping
public R<String> update(HttpServletRequest request,@RequestBody Employee employee){
    log.info(employee.toString());

    Long empId = (Long)request.getSession().getAttribute("employee");

    employee.setUpdateTime(LocalDateTime.now());
    employee.setUpdateUser(empId);
    employeeService.updateById(employee);

    return R.success("员工信息修改成功");
}

1.3 关于实现MP提供的公共字段填充

分析:我们可以从数据库中各个表中观察得知,每一张数据表都含有createUser、createTi me、updateUser、updateTime这些公共字段,而在本项目的技术选型中,选用mybatis以及它的最好搭档mybatisplus进行dao层的框架的搭建,而在mybatisplus中提供了公共字段自动填充的功能,通过在实体类中需要填充的字段加上@TableField注解以及编写一个自定义元数据类去实现一个MetaObjectHandler接口,实现其中的方法,该类需要交给spring容器进行管理;在通过元数据对象.setValue去设置公共字段填充内容;
关于自动填充创建/更新用户ID,子啊自定义元数据类中在通过session与获取userID的自动填充,显然是不现实的,接下来想另外一个方法去进行登录用户的信息的保存与提取—》通过实现得知,当一个请求来到后端—〉首先经过了过滤器,然后经过cotroller ,然后经过元数据处理类,最后在去service,dao层,而这一条数据处理链,都是由一个线程来处理的,也就是说,客户端发送的每次http请求,对应的在服务端都会分配一个新的线程来处理,在处理过程中涉及到下面类中的方法都属于相同的一个线程:所以我们可以用这个线程先保存信息,通过thread中threadlocal局部表量,去设置/获取参数;
ThreadLocal常用方法:
A. public void set(T value) : 设置当前线程的线程局部变量的值
B. public T get() : 返回当前线程所对应的线程局部变量的值
C. public void remove() : 删除当前线程所对应的线程局部变量的值

1.3.1 基于ThreadLocal封装的工具类

作用:设置线程中数据与获取线程中数据
实现:

package cn.zkwf.takeout.common;

import cn.zkwf.takeout.entity.Employee;

/**
 * @description:线程数据共享工具类
 * @author: Sw_Ljb
 * @PACKAGE_NAME:cn.zkwf.takeout.common
 * @time: 2022/7/21 下午12:53
 * @version: 1.0
 */

public class ThreadContext {

    //1、直接new出来这个对象 范性T是参数类型
    private static ThreadLocal<Long> threadLocal = new ThreadLocal<Long>();

    /**
     * 设置参数值
     * @param empId
     */
    public static void setParameter(Long empId ){
        threadLocal.set(empId);
    }

    /**
     * 得到参数
     * @return
     */
    public static Long getParameter(){
        return threadLocal.get();
    }

}

1.3.2 自动填充类

package cn.zkwf.takeout.common;

import cn.zkwf.takeout.entity.Employee;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

/**
 * @description: 元数据处理类
 * @author: Sw_Ljb
 * @PACKAGE_NAME:cn.zkwf.takeout.common
 * @time: 2022/7/21 下午12:46
 * @version: 1.0
 */
@Component
@Slf4j
public class MyMetaObjectHandler implements MetaObjectHandler {

    @Override
    public void insertFill(MetaObject metaObject) {
        log.info("MP进入插入时自动注入元素信息");
        //1、设置自动注入时间
        metaObject.setValue("createTime", LocalDateTime.now());
        metaObject.setValue("updateTime",LocalDateTime.now());
        //2、设置自动注入操作人员 因为session不能在这使用 又由于这一条执行链是同一个线程 因此使用localthread进行参数共享
        //2.1 得到线程中的值
        Long empID = ThreadContext.getParameter();
        metaObject.setValue("createUser",empID);
        metaObject.setValue("updateUser",empID);
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        log.info("MP进入更新数据时自动注入元素信息");
        //1、设置更新自动注入时间
        metaObject.setValue("updateTime",LocalDateTime.now());
        //2、设置自动注入操作人员 因为session不能在这使用 又由于这一条执行链是同一个线程 因此使用localthread进行参数共享
        //2.1 得到线程中的值
        Long empID = ThreadContext.getParameter();
        metaObject.setValue("updateUser",empID);
    }
}

1.4 项目后台功能实现 --> 分类管理

1.4.1 新增分类

1.4.1.1 程序执行过程

1). 在页面(backend/page/category/list.html)的新增分类表单中填写数据,点击 “确定” 发送ajax请求,将新增分类窗口输入的数据以json形式提交到服务端
2). 服务端Controller接收页面提交的数据并调用Service将数据进行保存
3). Service调用Mapper操作数据库,保存数据

1.4.1.1 代码实现

1. 基础环境的搭建(实体类+mapper(继承basemapper<实体类>)+service(interface IService<实体类>)(serviceimpl(extends ServiceImpl<实体类mapper,实体类> interface service))+controller);
2. 代码实现流程:前端将数据转为json传递给后端,后端在controller中使用@RequestBody注解 使用实体类去接受,然后直接调用service直接操作;

1.4.2 分类信息的分页查询

1.4.2.1 程序执行过程

1). 页面发送ajax请求,将分页查询参数(page、pageSize)提交到服务端

2). 服务端Controller接收页面提交的数据并调用Service查询数据

3). Service调用Mapper操作数据库,查询分页数据

4). Controller将查询到的分页数据响应给页面

5). 页面接收到分页数据并通过ElementUI的Table组件展示到页面上

1.4.2.2 代码实现

/**
 * 分页查询
 * @param page
 * @param pageSize
 * @return
 */
@GetMapping("/page")
public R<Page> page(int page,int pageSize){
    //分页构造器
    Page<Category> pageInfo = new Page<>(page,pageSize);
    //条件构造器
    LambdaQueryWrapper<Category> queryWrapper = new LambdaQueryWrapper<>();
    //添加排序条件,根据sort进行排序
    queryWrapper.orderByAsc(Category::getSort);

    //分页查询
    categoryService.page(pageInfo,queryWrapper);
    return R.success(pageInfo);
}

1.4.3 删除分类

1.4.3.1 执行流程

1). 点击删除,页面发送ajax请求,将参数(id)提交到服务端

2). 服务端Controller接收页面提交的数据并调用Service删除数据

3). Service调用Mapper操作数据库

1.4.3.2 代码实现

分析:在执行删除分类操作时,删除逻辑不能时直接将分类,而是应该先检查当前分类下是否关联了菜品或者套餐,然后将这个分类的id拿着,去菜品或者套餐中查询是否含有该分类的数据,如果有抛一个自定义异常,如果没有,则可以删除该分类;
实现步骤:

  1. 创建自定义异常
package cn.zkwf.takeout.exception;

/**
 * @description:
 * @author: Sw_Ljb
 * @PACKAGE_NAME:cn.zkwf.takeout.exception
 * @time: 2022/7/21 下午2:05
 * @version: 1.0
 */

public class CostomExcetion extends RuntimeException{

    public CostomExcetion(String message) {
        super(message);
    }
}
  1. 在全局异常处理器中将自定义异常加入
    全局异常处理器:
package cn.zkwf.takeout.common;

import cn.zkwf.takeout.exception.CostomExcetion;
import cn.zkwf.takeout.resulttype.R;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

import java.sql.SQLIntegrityConstraintViolationException;

/**
 * @description:
 * @author: Sw_Ljb
 * @PACKAGE_NAME:cn.zkwf.takeout.common
 * @time: 2022/7/20 下午1:53
 * @version: 1.0
 */
@Slf4j
@ControllerAdvice(annotations = {RestController.class, Controller.class})
@ResponseBody
public class GlobalExceptionHandler {


    @ExceptionHandler(SQLIntegrityConstraintViolationException.class)
    public R<String>  exceptionHandler(SQLIntegrityConstraintViolationException ex){
        //1、打印日志
        log.info("自定义全局异常处理正在运行!!");
        //Duplicate entry 'zhangsan' for key 'employee.idx_username'
        if (ex.getMessage().contains("Duplicate entry ")){
            //2、判断当前抛出的异常是否为Duplicate entry
            String[] strArray = ex.getMessage().split(" ");
            //3、截取异常信息中重点错误信息
            String msg= strArray[2] +"已经存在了!!";
            //4、设置返回信息
            return R.error(msg);

        }
        //4、设置返回信息
        return R.error("未知错误!");
    }


    @ExceptionHandler(CostomExcetion.class)
    public R<String>  costomerExceptionHandler(CostomExcetion ex){
        //1、打印日志
        log.info("自定义全局异常处理正在运行!!==>自定义异常");
        return R.error(ex.getMessage());
    }

}

  1. 扩展分类service,实现该方法
    CategoryServiceImpl.remove():
@Autowired
private DishService dishService;
@Autowired
private SetmealService setmealService;

/**
 * 根据id删除分类,删除之前需要进行判断
 * @param id
 */
@Override
public void remove(Long id) {
    //添加查询条件,根据分类id进行查询菜品数据
    LambdaQueryWrapper<Dish> dishLambdaQueryWrapper = new LambdaQueryWrapper<>();
    dishLambdaQueryWrapper.eq(Dish::getCategoryId,id);
    int count1 = dishService.count(dishLambdaQueryWrapper);
    //如果已经关联,抛出一个业务异常
    if(count1 > 0){
        throw new CustomException("当前分类下关联了菜品,不能删除");//已经关联菜品,抛出一个业务异常
    }

    //查询当前分类是否关联了套餐,如果已经关联,抛出一个业务异常
    LambdaQueryWrapper<Setmeal> setmealLambdaQueryWrapper = new LambdaQueryWrapper<>();
    setmealLambdaQueryWrapper.eq(Setmeal::getCategoryId,id);
    int count2 = setmealService.count(setmealLambdaQueryWrapper);
    if(count2 > 0){
        throw new CustomException("当前分类下关联了套餐,不能删除");//已经关联套餐,抛出一个业务异常
    }

    //正常删除分类
    super.removeById(id);
}

1.4.4 修改分类

1.4.4.1 分析

当前端点"修改"按钮的时候,当信息被修改后,点击保存按钮,页面在发送一个请求,去修改分类信息

1.4.4.1 实现

回显:回显这一步的操作前端已经实现

一个SpringBoot单体项目--》外卖平台项目之后台管理端基础功能开发_第2张图片
修改:

/**
 * 根据id修改分类信息
 * @param category
 * @return
 */
@PutMapping
public R<String> update(@RequestBody Category category){
    log.info("修改分类信息:{}",category);
    categoryService.updateById(category);
    return R.success("修改分类信息成功");
}

1.5 项目后台功能实现 --> 菜品管理

1.5.1 关于菜品管理 需要上传菜品图片 ==》涉及文件上传与下载

文件上传三要素:

表单属性 取值 说明
method post 必须选择post方式提交
enctype multipart/form-data 采用multipart格式上传文件
type file 使用input的file控件上传

文件下载两种表现形式

  1. 以附件形式下载,弹出保存对话框,将文件保存到指定磁盘目录
  2. 直接在浏览器中打开,通过浏览器进行文件下载,本质上就是服务端将文件以流的形式写回浏览器的过程。

上传逻辑:

1). 获取文件的原始文件名, 通过原始文件名获取文件后缀

2). 通过UUID重新声明文件名, 文件名称重复造成文件覆盖

3). 创建文件存放目录

4). 将上传的临时文件转存到指定位置

下载逻辑:

1). 定义输入流,通过输入流读取文件内容

2). 通过response对象,获取到输出流

3). 通过response对象设置响应数据格式(image/jpeg)

4). 通过输入流读取文件数据,然后通过上述的输出流写回浏览器

5). 关闭资源

具体实现:

package cn.zkwf.takeout.controller;

import cn.zkwf.takeout.resulttype.R;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.UUID;

/**
 * @description:
 * http://localhost:7070/common/upload
 * Request Method: POST
 * @author: Sw_Ljb
 * @PACKAGE_NAME:cn.zkwf.takeout.controller
 * @time: 2022/7/22 下午2:54
 * @version: 1.0
 */
@Slf4j
@RestController
@RequestMapping("/common")
public class FileController {

    @Value(value = "${serverpath.serverImpPath}")
    private String serverImpPath;

    //Content-Disposition: form-data; name="file"; filename="0a3b3288-3446-4420-bbff-f263d0c02d8e.jpg"
    @RequestMapping("/upload")
    public R<String> upload(MultipartFile file){
        log.info("上传路径{}:",serverImpPath);

        //更保险的做法 将上传过来的照片再次创建一个uuid
        String suffdex = file.getOriginalFilename().substring(file.getOriginalFilename().indexOf('.'));
        String imgName = UUID.randomUUID().toString()+suffdex;

        try {
            //上传路径/Users/mac/Desktop/fliedemo/takeoutServerImg保险期间对目录进行一次判断
            File imgfile = new File(serverImpPath);
            if (!imgfile.exists()){
                imgfile.mkdirs();
            }
            file.transferTo(new File(serverImpPath+"/"+imgName));
        } catch (IOException e) {
            e.printStackTrace();
        }
        return R.success(imgName);
    }
    /**
     * 下载数据
     * @param response
     * @param name
     */
    @GetMapping("/download")
    public void download(HttpServletResponse response, String name){
        log.info("下载文件{}",name);
        try {
            //输入流
            FileInputStream fis = new FileInputStream(new File(serverImpPath+"/"+name));
            log.info(serverImpPath+"/"+name);
            //输出流
            ServletOutputStream outputStream = response.getOutputStream();
            //先读出来 再响应给前台

            //设置响应类型
            response.setContentType("image/jpeg");

            //1、定义一个读出来存的字节数组
            byte[] bytes = new byte[1024];
            //2、定一个读多少
            int len= 0;
            while ((len = fis.read(bytes))!=-1){
                //3、写会前台
                outputStream.write(bytes,0,len);
                outputStream.flush();
            }
            //4、记得关流,释放资源
            outputStream.close();
            fis.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

1.5.2 菜品新增

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

1.5.2.1 交互流程

1). 点击新建菜品按钮, 访问页面(backend/page/food/add.html), 页面加载时发送ajax请求,请求服务端获取菜品分类数据并展示到下拉框中
2). 页面发送请求进行图片上传,请求服务端将图片保存到服务器(上传功能已实现)
3). 页面发送请求进行图片下载,将上传的图片进行回显(下载功能已实现)
4). 点击保存按钮,发送ajax请求,将菜品相关数据以json形式提交到服务端
对于前端传递到服务端的数据分析:
如果使用菜品类Dish来封装,只能封装菜品的基本属性,flavors属性是无法封装的。这个时候,我们需要自定义一个实体类,然后继承自 Dish,并对Dish的属性进行拓展,增加 flavors 集合属性(内部封装DishFlavor)。

各种类型的实体模型

一个SpringBoot单体项目--》外卖平台项目之后台管理端基础功能开发_第3张图片

1.5.2.2 代码实现

页面传递的菜品口味信息,仅仅包含name 和 value属性,缺少一个非常重要的属性dishId, 所以在保存完菜品的基本信息后,我们需要获取到菜品ID,然后为菜品口味对象属性dishId赋值。
具体逻辑如下:
①. 保存菜品基本信息 ;
②. 获取保存的菜品ID ;
③. 获取菜品口味列表,遍历列表,为菜品口味对象属性dishId赋值;
④. 批量保存菜品口味列表;
注意:由于需要进行了两次数据库的保存操作,操作了两张表,那么为了保证数据的一致性,我们需要在方法上加上注解 @Transactional来控制事务。Service层方法上加的注解@Transactional要想生效,需要在引导类上加上注解 @EnableTransactionManagement, 开启对事务的支持。
代码实现:

@Autowired
private DishFlavorService dishFlavorService;
/**
* 新增菜品,同时保存对应的口味数据
* @param dishDto
*/
@Transactional
public void saveWithFlavor(DishDto dishDto) {
    //保存菜品的基本信息到菜品表dish
    this.save(dishDto);
	
    Long dishId = dishDto.getId();//菜品id
    //菜品口味
    List<DishFlavor> flavors = dishDto.getFlavors();
    flavors = flavors.stream().map((item) -> {
        item.setDishId(dishId);
        return item;
    }).collect(Collectors.toList());

    //保存菜品口味数据到菜品口味表dish_flavor
    dishFlavorService.saveBatch(flavors);
}

1.5.3 菜品分页查询

1.5.3.1 需求分析

系统中的菜品数据很多的时候,如果在一个页面中全部展示出来会显得比较乱,不便于查看,所以一般的系统中都会以分页的方式来展示列表数据。
一个SpringBoot单体项目--》外卖平台项目之后台管理端基础功能开发_第4张图片
在菜品列表展示时,除了菜品的基本信息(名称、售价、售卖状态、更新时间)外,还有两个字段略微特殊,第一个是图片字段 ,我们从数据库查询出来的仅仅是图片的名字,图片要想在表格中回显展示出来,就需要下载这个图片。第二个是菜品分类,这里展示的是分类名称,而不是分类ID,此时我们就需要根据菜品的分类ID,去分类表中查询分类信息,然后在页面展示。

1.5.3.2 前端和服务端交互过程

1). 访问页面(backend/page/food/list.html)时,发送ajax请求,将分页查询参数(page、pageSize、name)提交到服务端,获取分页数据

2). 页面发送请求,请求服务端进行图片下载,用于页面图片展示

1.5.3.3 代码实现

分析:
实体类 Dish 中,仅仅包含 categoryId, 不包含 categoryName,这里我们可以返回DishDto对象,在该对象中我们可以拓展一个属性 categoryName,来封装菜品分类名称。
具体逻辑为:
1). 构造分页条件对象
2). 构建查询及排序条件
3). 执行分页条件查询
4). 遍历分页查询列表数据,根据分类ID查询分类信息,从而获取该菜品的分类名称
5). 封装数据并返回
实现:

/**
 * 菜品信息分页查询
 * @param page
 * @param pageSize
 * @param name
 * @return
 */
@GetMapping("/page")
public R<Page> page(int page,int pageSize,String name){
    //构造分页构造器对象
    Page<Dish> pageInfo = new Page<>(page,pageSize);
    Page<DishDto> dishDtoPage = new Page<>();

    //条件构造器
    LambdaQueryWrapper<Dish> queryWrapper = new LambdaQueryWrapper<>();
    //添加过滤条件
    queryWrapper.like(name != null,Dish::getName,name);
    //添加排序条件
    queryWrapper.orderByDesc(Dish::getUpdateTime);

    //执行分页查询
    dishService.page(pageInfo,queryWrapper);

    //对象拷贝
    BeanUtils.copyProperties(pageInfo,dishDtoPage,"records");
    List<Dish> records = pageInfo.getRecords();
    List<DishDto> list = records.stream().map((item) -> {
        
        DishDto dishDto = new DishDto();
        BeanUtils.copyProperties(item,dishDto);
        Long categoryId = item.getCategoryId();//分类id
        //根据id查询分类对象
        Category category = categoryService.getById(categoryId);
        
        if(category != null){
            String categoryName = category.getName();
            dishDto.setCategoryName(categoryName);
        }
        return dishDto;
    }).collect(Collectors.toList());
    dishDtoPage.setRecords(list);
    
    return R.success(dishDtoPage);
}

数据库查询菜品信息时,获取到的分页查询结果 Page 的泛型为 Dish,而我们最终需要给前端页面返回的类型为 DishDto,所以这个时候就要进行转换,基本属性我们可以直接通过属性拷贝的形式对Page中的属性进行复制,而对于结果列表 records属性,是需要进行特殊处理的(需要封装菜品分类名称);

1.5.3 菜品修改

1.5.3.1 需求分析

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

1.5.3.2 交互流程

1). 点击菜品列表的中的修改按钮,携带菜品id跳转至add.html
2). 进入add.html,页面发送ajax请求,请求服务端获取分类数据,用于菜品分类下拉框中数据展示
3). add.html获取id, 发送ajax请求,请求服务端,根据id查询当前菜品信息,用于菜品信息回显
4). 页面发送请求,请求服务端进行图片下载,用于页图片回显
5). 点击保存按钮,页面发送ajax请求,将修改后的菜品相关数据以json形式提交到服务端
我们只需要在这里实现两个功能即可:
1). 根据ID查询菜品及菜品口味信息
2). 修改菜品及菜品口味信息

1.5.3.3 功能实现

根据ID查询菜品信息
具体逻辑为: 
A. 根据ID查询菜品的基本信息 
B. 根据菜品的ID查询菜品口味列表数据
C. 组装数据并返回
/**
* 根据id查询菜品信息和对应的口味信息
* @param id
* @return
*/
public DishDto getByIdWithFlavor(Long id) {
    //查询菜品基本信息,从dish表查询
    Dish dish = this.getById(id);
	
    DishDto dishDto = new DishDto();
    BeanUtils.copyProperties(dish,dishDto);
	
    //查询当前菜品对应的口味信息,从dish_flavor表查询
    LambdaQueryWrapper<DishFlavor> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(DishFlavor::getDishId,dish.getId());
    List<DishFlavor> flavors = dishFlavorService.list(queryWrapper);
    dishDto.setFlavors(flavors);
	
    return dishDto;
}
修改菜品信息
实现步骤:
点击保存按钮,页面发送ajax请求,将修改后的菜品相关数据以json形式提交到服务端。在修改菜品信息时需要注意,除了要更新dish菜品表,还需要更新dish_flavor菜品口味表;在该方法中,我们既需要更新dish菜品基本信息表,还需要更新dish_flavor菜品口味表。而页面再操作时,关于菜品的口味,有修改,有新增,也有可能删除,我们应该如何更新菜品口味信息呢,其实,无论菜品口味信息如何变化,我们只需要保持一个原则: 先删除,后添加。
@Override
@Transactional
public void updateWithFlavor(DishDto dishDto) {
    //更新dish表基本信息
    this.updateById(dishDto);

    //清理当前菜品对应口味数据---dish_flavor表的delete操作
    LambdaQueryWrapper<DishFlavor> queryWrapper = new LambdaQueryWrapper();
    queryWrapper.eq(DishFlavor::getDishId,dishDto.getId());

    dishFlavorService.remove(queryWrapper);

    //添加当前提交过来的口味数据---dish_flavor表的insert操作
    List<DishFlavor> flavors = dishDto.getFlavors();

    flavors = flavors.stream().map((item) -> {
        item.setDishId(dishDto.getId());
        return item;
    }).collect(Collectors.toList());

    dishFlavorService.saveBatch(flavors);
}

1.6 项目后台功能实现 --> 套餐管理

1.6.1 --> 新增套餐

1.6.1.1 需求分析

后台系统中可以管理套餐信息,通过新增套餐功能来添加一个新的套餐,在添加套餐时需要选择当前套餐所属的套餐分类和包含的菜品,并且需要上传套餐对应的图片,在移动端会按照套餐分类来展示对应的套餐。

1.6.1.2 数据模型

新增套餐,其实就是将新增页面录入的套餐信息插入到setmeal表,还需要向setmeal_dish表插入套餐和菜品关联数据。所以在新增套餐时,涉及到两个表:
一个SpringBoot单体项目--》外卖平台项目之后台管理端基础功能开发_第5张图片

1.6.1.3 前端页面与服务端的交互过程

1). 点击新建套餐按钮,访问页面(backend/page/combo/add.html),页面加载发送ajax请求,请求服务端获取套餐分类数据并展示到下拉框中
2). 访问页面(backend/page/combo/add.html),页面加载时发送ajax请求,请求服务端获取菜品分类数据并展示到添加菜品窗口中
3). 当点击添加菜品窗口左侧菜单的某一个分类, 页面发送ajax请求,请求服务端,根据菜品分类查询对应的菜品数据并展示到添加菜品窗口中
4). 页面发送请求进行图片上传,请求服务端将图片保存到服务器
5). 页面发送请求进行图片下载,将上传的图片进行回显
6). 点击保存按钮,发送ajax请求,将套餐相关数据以json形式提交到服务端
分析发送的请求有5个,分别是:
A. 根据传递的参数,查询套餐分类列表
B. 根据传递的参数,查询菜品分类列表
C. 图片上传
D. 图片下载展示
E. 根据菜品分类ID,查询菜品列表
F. 保存套餐信息

1.6.1.4 代码开发

根据分类查询菜品
/**
* 根据条件查询对应的菜品数据
* @param dish
* @return
*/
@GetMapping("/list")
public R<List<Dish>> list(Dish dish){
    //构造查询条件
    LambdaQueryWrapper<Dish> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(dish.getCategoryId() != null ,Dish::getCategoryId,dish.getCategoryId());
    //添加条件,查询状态为1(起售状态)的菜品
    queryWrapper.eq(Dish::getStatus,1);
    //添加排序条件
    queryWrapper.orderByAsc(Dish::getSort).orderByDesc(Dish::getUpdateTime);
	
    List<Dish> list = dishService.list(queryWrapper);
	
    return R.success(list);
}
保存套餐

分析: 在进行套餐信息保存时,前端提交的数据,不仅包含套餐的基本信息,还包含套餐关联的菜品列表数据 setmealDishes。所以这个时候我们使用Setmeal就不能完成参数的封装了,我们需要在Setmeal的基本属性的基础上,再扩充一个属性 setmealDishes 来接收页面传递的套餐关联的菜品列表,而我们在准备工作中,导入进来的SetmealDto能够满足这个需求。

SetmealServiceImpl.saveWithDish():
具体逻辑:
A. 保存套餐基本信息
B. 获取套餐关联的菜品集合,并为集合中的每一个元素赋值套餐ID(setmealId)
C. 批量保存套餐关联的菜品集合
代码实现:

/**
 * 新增套餐,同时需要保存套餐和菜品的关联关系
 * @param setmealDto
 */
@Transactional
public void saveWithDish(SetmealDto setmealDto) {
    //保存套餐的基本信息,操作setmeal,执行insert操作
    this.save(setmealDto);

    List<SetmealDish> setmealDishes = setmealDto.getSetmealDishes();
    setmealDishes.stream().map((item) -> {
        item.setSetmealId(setmealDto.getId());
        return item;
    }).collect(Collectors.toList());

    //保存套餐和菜品的关联信息,操作setmeal_dish,执行insert操作
    setmealDishService.saveBatch(setmealDishes);
}

1.6.2 --> 套餐的分页查询

1.6.2.1 前端页面和服务端的交互过程

1). 访问页面(backend/page/combo/list.html),页面加载时,会自动发送ajax请求,将分页查询参数(page、pageSize、name)提交到服务端,获取分页数据
2). 在列表渲染展示时,页面发送请求,请求服务端进行图片下载,用于页面图片展示

1.6.2.2 代码开发

基本信息查询

基本逻辑:
1). 构建分页条件对象
2). 构建查询条件对象,如果传递了套餐名称,根据套餐名称模糊查询, 并对结果按修改时间降序排序
3). 执行分页查询
4). 由于查询套餐信息时, 只包含套餐的基本信息, 并不包含套餐的分类名称, 所以在这里查询到套餐的基本信息后, 还需要根据分类ID(categoryId), 查询套餐分类名称(categoryName),并最终将套餐的基本信息及分类名称信息封装到SetmealDto。组装数据并返回
代码实现:

/**
* 套餐分页查询
* @param page
* @param pageSize
* @param name
* @return
*/
@GetMapping("/page")
public R<Page> page(int page,int pageSize,String name){
    //分页构造器对象
    Page<Setmeal> pageInfo = new Page<>(page,pageSize);
    Page<SetmealDto> dtoPage = new Page<>();

    LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper<>();
    //添加查询条件,根据name进行like模糊查询
    queryWrapper.like(name != null,Setmeal::getName,name);
    //添加排序条件,根据更新时间降序排列
    queryWrapper.orderByDesc(Setmeal::getUpdateTime);

    setmealService.page(pageInfo,queryWrapper);

    //对象拷贝
    BeanUtils.copyProperties(pageInfo,dtoPage,"records");
    List<Setmeal> records = pageInfo.getRecords();

    List<SetmealDto> list = records.stream().map((item) -> {
        SetmealDto setmealDto = new SetmealDto();
        //对象拷贝
        BeanUtils.copyProperties(item,setmealDto);
        //分类id
        Long categoryId = item.getCategoryId();
        //根据分类id查询分类对象
        Category category = categoryService.getById(categoryId);
        if(category != null){
            //分类名称
            String categoryName = category.getName();
            setmealDto.setCategoryName(categoryName);
        }
        return setmealDto;
    }).collect(Collectors.toList());

    dtoPage.setRecords(list);
    return R.success(dtoPage);
}

1.6.3 --> 删除套餐

1.6.3.1 --> 需求分析

在套餐管理列表页面,点击删除按钮,可以删除对应的套餐信息。也可以通过复选框选择多个套餐,点击批量删除按钮一次删除多个套餐。注意,对于状态为售卖中的套餐不能删除,需要先停售,然后才能删除

1.6.3.2 --> 交互过程

1). 点击删除, 删除单个套餐时,页面发送ajax请求,根据套餐id删除对应套餐
2). 删除多个套餐时,页面发送ajax请求,根据提交的多个套餐id删除对应套餐
分析:开发删除套餐功能,其实就是在服务端编写代码去处理前端页面发送的这2次请求即可,一次请求为根据ID删除,一次请求为根据ID批量删除。观察删除单个套餐和批量删除套餐的请求信息可以发现,两种请求的地址请求方式都是相同的,不同的则是传递的id个数,所以在服务端可以提供一个方法来统一处理。

1.6.3.3 --> 代码实现

分析:删除套餐时, 我们不仅要删除套餐, 还要删除套餐与菜品的关联关系。
实现:SetmealServiceImpl.removeWithDish():
具体的逻辑:
A. 查询该批次套餐中是否存在售卖中的套餐, 如果存在, 不允许删除
B. 删除套餐数据
C. 删除套餐关联的菜品数据

/**
* 删除套餐,同时需要删除套餐和菜品的关联数据
* @param ids
*/
@Transactional
public void removeWithDish(List<Long> ids) {
    //select count(*) from setmeal where id in (1,2,3) and status = 1
    //查询套餐状态,确定是否可用删除
    LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper();
    queryWrapper.in(Setmeal::getId,ids);
    queryWrapper.eq(Setmeal::getStatus,1);

    int count = this.count(queryWrapper);
    if(count > 0){
   	 	//如果不能删除,抛出一个业务异常
    	throw new CustomException("套餐正在售卖中,不能删除");
    }

    //如果可以删除,先删除套餐表中的数据---setmeal
    this.removeByIds(ids);

    //delete from setmeal_dish where setmeal_id in (1,2,3)
    LambdaQueryWrapper<SetmealDish> lambdaQueryWrapper = new LambdaQueryWrapper<>();
    lambdaQueryWrapper.in(SetmealDish::getSetmealId,ids);
    //删除关系表中的数据----setmeal_dish
    setmealDishService.remove(lambdaQueryWrapper);
}

你可能感兴趣的:(springboot,spring,boot,java,前端)