瑞吉外卖项目

SpringBoot + SSM + Maven + Git

软件开发整体介绍:

1. 软件开发流程:

 2. 角色分工

        项目经理:对整个项目负责、任务分配、把控进度

        产品经理:进行需求调研、输出需求调研文档、产品原型等(介入较早)

        UI设计师:根据产品原型输出界面效果图

        架构师:项目整体架构设计、技术选型(开发过程中会用到哪些技术栈)等

        开发工程师:代码实现

        测试工程师:编写测试用例、输出测试报告

        运维工程师:软件环境搭建、项目上线

3. 软件环境

        开发环境development:开发人员在开发阶段使用的环境,一般外部用户无法访问

        测试环境testing:专门给测试人员使用的环境,用于测试项目,一般外部用户无法访问

        生产环境production:线上环境,正是提供对外服务的环境

项目介绍:

包括系统管理后台和移动端应用两个部分。

系统管理后台:提供给餐饮企业内部员工使用,可以对餐厅的菜品、套餐、订单等进行管理维护。

移动端应用:提供给消费者使用,可以在线浏览菜品、添加购物车、下单等。

产品原型:一款产品成型之前的一个简单的框架,就是将页面的排版布局展现出来,使产品的初步构思有一个可视化的展示。通过原型展示,可以更加直观地了解项目的需求和提供的功能。主要用于展示项目的功能,并不是最终的页面效果。

技术选型:

功能架构:

移动端前台(H5、微信小程序):

手机号登陆、微信登录、地址管理、历史订单、菜品规格、购物车、下单、菜品浏览

系统管理后台:

分类管理、菜品管理、套餐管理、菜品口味管理、员工登录、员工退出、员工管理、订单管理

一、开发环境搭建:

1. 数据库环境搭建

show databases;
create database reggie character set utf8mb4;
use reggie;

建表语句详见:瑞吉外卖 数据库表-优快云博客

导入表结构,既可以使用上面的图形界面,也可以在以下命令面板中使用mysql命令;

通过命令导入表结构时,注意SQL文件不要放在中文目录中:

use reggie;
source D:\db_reggie.sq;     --可以直接将文件拖进来,但是目录中不要有中文
show database;

数据表:

序号表名说明
1employee员工表
2category菜品和套餐分类表
3dish菜品表
setmeal套餐表
setmeal_dish套餐菜品关系表
dish_flavor菜品口味关系表
user用户表(C端)
address_book地址簿表
9shopping_cart购物车表
10orders订单表
11order_detail订单明细表

2. Maven项目搭建

创建一个maven项目

项目创建完成后,检查maven,jdk,runner--jre等配置是否正确

配置pom文件

添加父工程

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.7.9</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>

配置java的版本 

<properties>
    <java.version>1.8</java.version>
</properties>

 依赖以及build:

<dependencies>

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

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <scope>compile</scope>
        </dependency>

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.2</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.20</version>
        </dependency>

        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.76</version>
        </dependency>

        <dependency>
            <groupId>commons-lang</groupId>
            <artifactId>commons-lang</artifactId>
            <version>2.6</version>
        </dependency>

        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.1.23</version>
        </dependency>
        <dependency>
            <groupId>com.aliyun</groupId>
            <artifactId>aliyun-java-sdk-core</artifactId>
            <version>4.5.16</version>
        </dependency>
        <dependency>
            <groupId>com.aliyun</groupId>
            <artifactId>aliyun-java-sdk-dysmsapi</artifactId>
            <version>2.1.0</version>
        </dependency>
        <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>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>2.4.5</version>
            </plugin>
        </plugins>
    </build>

配置application.yml文件

server:
  port: 8080
spring:
  application:
    #应用的名称,可选。不指定则默认使用工程名
    name: reggie_take_out
  datasource:
    druid:
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://localhost:3306/reggie?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
      username: root
      password: mysq123
mybatis-plus:
  configuration:
    #在映射实体或者属性时,将数据库中表名和字段名中的下划线去掉,按照驼峰命名法映射
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      #主键的生成策略
      id-type: ASSIGN_ID

编写启动类

@Slf4j
@SpringBootApplication
public class ReggieApplication {
    public static void main(String[] args) {
        SpringApplication.run(ReggieApplication.class, args);
        log.info("项目启动成功……");
    }
}

引入静态资源

放在resources目录下

将页面代码放到resources目录下后,访问localhost:8080/backend/index.html 访问不成功,找不到该页面,(因为默认状态下只能访问static或者template目录下的静态资源)

需要编写一个配置类,来配置MVC框架静态资源的映射。

在项目下创建一个config的包,所有的配置类放在该包中:

在config包下建静态资源映射类:WebMvcConfig

@Slf4j
@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {
    @Override
    protected void addResourceHandlers(ResourceHandlerRegistry registry) {
        log.info("开始进行静态资源映射……");
        registry.addResourceHandler("//backend/**").addResourceLocations("classpath:/backend/");
        registry.addResourceHandler("//front/**").addResourceLocations("classpath:/front/");
    }
}

classpath 即 resource 目录。

二、后台系统登陆/退出功能

1. 登陆功能开发

登陆页面:localhost:8080/backend/page/login/login.html

输入账密后点击登陆按钮,在network中找到“login”命令,可以看到状态码是【404】,这是因为后台系统还没有响应此请求的处理器,所以需要创建相关类来处理登陆请求。点击登陆后发送的请求地址是:localhost:8080/employee/login,请求方式是post,[payload]可以看到请求提交的参数(输入的账密)。

创建相关类的过程:Controller ----> Service ----> Mapper ----> DB(此时对应的是employee表)

前端页面/backend/page/login/login.html的代码中可以看到点击【登陆】按钮后,触发handleLogin动作,在页面代码中找到该动作的代码(98行)

代码开发:

先创建几个包:common、config、controller、dto、entity、filter、mapper、service(该包下创建impl包)、utils

(1)创建实体类Employee(在entity包下),和employee表进行映射

@Data
public class Employee implements Serializable {
    private static final long serialVersionUID = 1L;
    private Long id;
    private String username;
    private String name;
    private String password;
    private String phone;
    private String sex;
    private String idNumber;    //身份证号, 在yml文件中开启了驼峰命名
    private Integer status;
    @TableField(fill = FieldFill.INSERT)   //FieldFill:枚举型的填充策略——INSERT:插入时填充字段
    private LocalDateTime createTime;
    @TableField(fill = FieldFill.INSERT_UPDATE)     //插入和更新时填充字段
    private LocalDateTime updateTime;
    @TableField(fill = FieldFill.INSERT)
    private Long createUser;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateUser;
}

(2)创建controller、service、mapper

@Mapper
public interface EmployeeMapper extends BaseMapper<Employee> {
}
public interface EmployeeService extends IService<Employee> {
}
@Service
public class EmployeeServiceImpl extends ServiceImpl<EmployeeMapper, Employee> implements EmployeeService {
}
@Slf4j
@RestController
@RequestMapping("/employee")   //根据前端请求路径
public class EmployeeController  {
    @Autowired
    private EmployeeService employeeService;
}

(3)导入返回结果类R(放在common包下)

此类是一个通用结果类,服务端相应的所有结果最终都会包装成此种类型返回给前端页面。

@Data
public class R<T> implements Serializable {
    private Integer code; //编码:1成功,0和其它数字为失败
    private String msg; //错误信息
    private T data; //数据
    private Map map = new HashMap(); //动态数据
    public static <T> R<T> success(T object) {
        R<T> r = new R<T>();
        r.data = object;
        r.code = 1;
        return r;
    }
    public static <T> R<T> error(String msg) {
        R r = new R();
        r.msg = msg;
        r.code = 0;
        return r;
    }
    public R<T> add(String key, Object value) {
        this.map.put(key, value);
        return this;
    }
}

EmployeeController中的登陆方法login: 

@Slf4j
@RestController
@RequestMapping("/employee")   //根据前端请求路径
public class EmployeeController  {
    @Autowired
    private EmployeeService employeeService;

    @PostMapping("/login")    //根据前端源码(先点击登陆查看 network -- headers -- general 看请求路径和请求方式
    public R<Employee> login(HttpServletRequest request, @RequestBody Employee employee){
//        1. 将页面提交的密码password进行md5加密处理
        String password = employee.getPassword();
        password = DigestUtils.md5DigestAsHex(password.getBytes());
//        2. 根据页面提交的用户名username查询用户
        LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(Employee::getUsername, employee.getUsername());   //lambda表达式 判断括号中的两个参数是否相等
        //    .eq(R column, Object val); // 等价于 =,例: eq("name", "张三") ---> name = '张三'
        Employee emp = employeeService.getOne(queryWrapper);
//        3. 如果没有查询到则返回登陆失败结果
        if (emp == null){
            return R.error("登陆失败");
        }
//        4. 密码对比,如果不一致则返回登陆失败结果
        if (!emp.getPassword().equals(password)){  //前一个pwd是从数据库查到的,后一个pwd是登陆页面输入的
            return R.error("登陆失败");
        }
//        5. 查看员工状态,如果为已禁用,则返回员工已禁用结果
        if (emp.getStatus() == 0){
            return R.error("账号已禁用");
        }
//        6. 登陆成功,将员工id存入session并返回登陆成功结果
        request.getSession().setAttribute("employee", emp.getId());
        return R.success(emp);

    }

}

调试时可能会遇到超时的问题,可以在backend/js/request.js中修改超时时间:

timeout: 1000000

当登陆成功后,会将数据存储在“userInfo”里:

userInfo的数据在前端调试页面的【application】,可以看到存储的数据的明细 

当登陆失败时,response返回R对象转成的字符串:

2. 后台退出功能开发

登陆用户的显示:

/backend/index.html中相关代码,登录用户是通过userInfo.name动态获取的

<div class="avatar-wrapper">{{ userInfo.name }}</div>

钩子函数created() 把页面存的userInfo取出来,得到name: 

localstorage存在于前端调试页面F12的application中: 

点击了退出按钮,会发起一个logout的请求,可以看到请求地址和请求方式:

点击退出按钮时调用的方法logout:

其中logoutApi方法具体为:

在EmployeeController中写退出的方法:

@PostMapping("/logout")
public R<String> logout(HttpServletRequest request){
    request.getSession().removeAttribute("employee");    //首先清理登陆信息的缓存
    return R.success("退出成功");
}

页面通过url请求到logout方法,执行成功的话返回R.success(); 回到前端logout方法,res.code = 1,清除缓存,href回到登录页面。

3. 后台系统首页

页面左边的按钮是用{{item.name}}控制的, 

每点击一次按钮,就会触发menuHandle命令,每次点击菜单就是在切换item.url,在浏览器展示一个新的页面

iframeUrl的参数item.url是每个菜单按钮的url,该方法用在:

iframeUrl 的默认参数是【员工管理】页面的url

三、员工管理相关页面开发

1. 完善登陆功能

登陆功能存在的问题:用户如果不登录,直接访问系统首页面,照样可以正常访问。这种设计不合理,希望看到的效果应该是:只有登录成功后才可以访问系统中的页面以及动态数据,如果没有登录则跳转到登录页面。实现方法:使用过滤器或者拦截器,在过滤器或者拦截器中判断用户是否已经完成登录,如果没有登陆则跳转到登陆页面。

实现步骤:

(1)创建自定义过滤器LoginCheckFilter(创建filter的包)

(2)在启动类上加入注解@ServletComponentScan

(3)完善过滤器的处理逻辑

代码开发:

@WebFilter(filterName = "loginCheckFilter", urlPatterns = "/*")
@Slf4j
public class LoginCheckFilter implements Filter {
//    路径匹配器,支持通配符。
    public static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;
//        1. 获取本次请求的URI
        String requestURI = request.getRequestURI();    //backend/index.html
        log.info("拦截到请求:{}", requestURI);
//        定义不需要处理的请求路径;
        String[] urls = new String[]{
                "/employee/login",
                "/employee/logout",
                "/backend/**",      //输入localhost:8080/backend/index.html之后,不能进入到首页,而是跳回到登陆页面,只有点击了登陆才能进入首页
                "/front/**"
        };
//        2. 判断本次请求是否需要处理
        boolean check = check(urls, requestURI);
//        3. 如果不需要处理则直接放行
        if (check){
            log.info("本次请求不需要处理:{}", requestURI);
            filterChain.doFilter(request, response);
            return;
        }
//        4. 判断登录状态,如果已登陆,则直接放行
        if (request.getSession().getAttribute("employee") != null){
            log.info("用户已登陆,用户id为{}", request.getSession().getAttribute("employee"));
            filterChain.doFilter(request, response);
            return;
        }
//        5. 如果未登录,则返回未登录结果,通过输出流方式向客户端页面响应数据
        response.getWriter().write(JSON.toJSONString(R.error("NOTLOGIN")));    //backend/request.js 中“返回登陆页面”的 res.data.msg
        return;
    }
    // 路径匹配,检查本次请求是否需要放行
    public boolean check(String[] urls, String requestURI){
        for (String url : urls) {
            boolean match = PATH_MATCHER.match(url, requestURI);
            if (match){
                return true;
            }
        }
        return false;
    }
}

首先请求 localhost:8080/backend/index.html 地址后,不能进入到首页,直接跳回到登陆页面,点击登陆后进入到首页,点击退出再返回到登陆页面,这个过程下来 idea 控制台显示:

        拦截到请求:/employee/page

        用户未登录

        拦截到请求:/employee/login

        本次请求/employee/login

       (查询登陆用户)

       拦截到请求:/employee/page

       用户已登录,用户id为:1

       拦截到请求:/employee/logout

       本次请求/employee/logout不需要处理

       拦截到请求:/employee/page

       用户未登录

2. 新增员工

页面发送Ajax请求,将新增员工页面中输入的数据以JSON的形式提交到服务端,服务端Controller接收页面提交的数据并调用service将数据进行保存,service调用mapper操作数据库保存数据

点击【保存】按钮后发送请求:

 【添加员工】按钮所在的前端页面:/backend/page/member/list.html

【添加员工】的点击事件:

该事件会跳到add.html页面:

在add.html页面 要添加的员工的信息添加完成后,点击保存,触发submitForm事件

submitForm事件先进行校验validate,校验成功则调用添加员工的方法addEmployee,这是一个js,点进去可以看到请求跳转的地址和方式:

请求参数是JSON字符串,所以传入的参数需要@RequestBody封装 

@PostMapping
public R<String> save(HttpServletRequest request, @RequestBody Employee employee){
    log.info("新增员工,员工信息:{}", employee.toString());
//        设置初始密码123456,需要进行md5加密
    employee.setPassword(DigestUtils.md5DigestAsHex("123456".getBytes()));
    employee.setCreateTime(LocalDateTime.now());
    employee.setUpdateTime(LocalDateTime.now());
//        获得当前登录用户的id
    Long empId = (Long)request.getSession().getAttribute("employee");
    employee.setCreateUser(empId);
    employee.setUpdateUser(empId);
    employeeService.save(employee);   //employeeService实现的接口中自带的save方法
    return R.success("新增员工成功");
}

当新增员工输入的账号已经存在时,由于employee表中对该字段加入了唯一约束,此时程序会抛出“用户名已存在”异常,此时需要程序进行异常捕获,通常有两种处理方式:

(1)在Controller方法中加入try,catch进行异常捕获(由于要新增的员工很多,需要些很多遍,不方便)

        try {
            employeeService.save(employee);   //employeeService实现的接口中自带的save方法
        } catch (Exception e) {
            return R.error("新增员工失败");
        }

(2)使用异常处理器进行全局异常捕获

       在common文件夹下新建类, 全局异常处理器:(作用之一 —— 处理用户名重复的情况)

@ControllerAdvice(annotations = {RestController.class, Controller.class})  //拦截使用了RestController的类
@Slf4j
@ResponseBody
public class GlobalExceptionHandler {
//    异常处理方法
    @ExceptionHandler(SQLIntegrityConstraintViolationException.class)
    public R<String> exceptionHandler(SQLIntegrityConstraintViolationException ex){
        log.error(ex.getMessage());
        if (ex.getMessage().contains("Duplicate entry")){   //SQL异常的信息包括很多,并不一定是人员重复(唯一约束报错:duplicate entry),所以需要判断是否是人员重复
            String[] split = ex.getMessage().split(" ");
            String msg = split[2] + "已存在";
            return R.error(msg);
        }
        return R.error("未知错误");
    }
}

3. 员工信息分页查询

程序的执行过程:

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

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

(3)Service调用mapper操作数据库,查询分页数据

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

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

前端页面发送请求(F12调试工具,刷新页面,找到下面的Network):

Vue的内置声明周期函数(钩子函数)(backend/page/member/list.html),该函数不需要自己调用,vue对象创建完之后自动调用这个方法,如果需要对象或页面创建完之后自动就执行,就可以把代码写入到created函数中:

对于get请求,将url拼接拼接上请求参数,将原来JSON格式变成“http://localhost:8080/employee/page?page=1&pageSize=2”格式

代码开发:

设置分页插件:

@Configuration   //配置分页插件
public class MyBatisPlusConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor(){
        MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor();
        mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor());
        return mybatisPlusInterceptor;
    }
}

页面中需要records和total两个数据,这两个数据封装在Page类:

当搜找某个员工时,发出的请求需要传入三个参数,所以controller中写入三个参数:

list.html中设置的page和pageSize:

EmployeeController.java中写方法:

    @GetMapping("/page")
    public R<Page> page(int page, int pageSize, String name){
        log.info("page = {}, pageSize = {}, name = {}", page, pageSize, name);
//        构造分页构造器
        Page pageInfo = new Page(page, pageSize);
//        构造条件构造器
        LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper<>();
//        添加过滤条件
        queryWrapper.like(StringUtils.isNotEmpty(name), Employee::getName, name);
//        添加排序条件
        queryWrapper.orderByDesc(Employee::getUpdateTime);
//        执行查询
        employeeService.page(pageInfo, queryWrapper);
        return R.success(pageInfo);
    }

list.html中:<el-pagination>中控制 分页条:“?条/页” 

重新发送请求的情况:

(1)页面初始化的时候,会调用created()方法,进而调用init()发送请求;

(2)点击查询按钮时;

(3)上下翻页的时候;

数据库中对于账号状态存的是“0”和“1”,但是页面显示的是汉字

这是因为在前端代码进行了处理:

4. 启用、禁用员工账号

在员工管理列表页面,可以对某个员工账号进行启用或者禁止操作。账号禁用的员工不能登陆系统,启用后的员工可以正常登录。只有管理员(admin用户)可以对其他普通用户进行启用、禁用操作,所以普通用户登录系统后启用、禁用按钮不显示,只显示编辑按钮。如果某个员工账号状态为正常,则按钮显示为“禁用”,如果员工账号状态为禁用,则按钮显示为“启用”。

如何判断是不是admin:(list.html页面)

账号登陆后,application中看到localstorage存储的信息:

 localstorage存储的信息赋值给user,传递给<el-button>中的user,判断user是否是admin,是admin时可以看到“启用 / 禁用”按钮,否则看不到:

程序执行过程:

(1)页面发送Ajax请求,将参数(id,status)提交到服务端

(2)服务端Controller接收页面提交的数据并调用Service更新数据

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

发送请求:

点击“禁用 / 启用”按钮时,触发事件:

scope.row为点击的那一行数据封装成的对象。 

 

代码开发:

启用、禁用员工账号,本质上就是一个更新操作,也就是对status状态字段进行操作,在controller中创建update方法,此方法是一个通用的(因为后边的“编辑”功能还会用到)修改员工信息的方法。

    @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("员工信息修改成功");
    }

controller写好后运行发现更新条数为0,调试发现response相应的id正确,但是request中payload传入的参数不对,这是因为id为19位,而Long型数字最多获取16位,所以想要获取很多位的id需要使用JSON转换为字符串类型,首先引入JacksonObjectMapper类配置文件,放在common文件夹下。

实现步骤:

(1)提供对象转换器JacksonObjectMapper,基于Jackson进行java对象到JSON数据的转换;

(2)在WebMvcConfig配置类中扩展SprinGMVC的消息转换器,在此消息转换器中使用提供的对象转换器进行Java对象到JSON数据的转换。

代码开发:

对象映射器JacksonObjectMapper:

/**
 * 对象映射器:基于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)
                .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);
    }
}

通过序列化器进行转换,把Long型数据转化成了带引号的字符串类型。

在WebMvcConfig配置类中扩展SprinGMVC的消息转换器:

//    扩展MVC框架的消息转换器
    @Override
    protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
        log.info("扩展消息转换器……");
//        创建消息转换器对象
        MappingJackson2HttpMessageConverter messageConverter = new MappingJackson2HttpMessageConverter();
//        设置对象转换器,底层使用Jackson将对象转为JSON
        messageConverter.setObjectMapper(new JacksonObjectMapper());
//        将上面的消息转换器对象追加到MVC框架的转换器集合中
        converters.add(0, messageConverter);   //将自己的转换器放到最前边,即序号0
    }

再前端调试看到请求参数中序号id和时间都发生了格式的变化。

再进行调试可以看到Long型id变成了带有引号的字符串类型,日期格式均变成设置的格式,例如“2023-11-11 08:12:23”

5. 编辑工具信息

程序执行流程:

(1)点击编辑按钮时,页面跳转到add.html (add.html页面为公共页面,新增员工和编辑员工都是在此页面操作),并在url中携带参数【员工id】

(2)在add.html页面获取url中的参数【员工id】,并在url中携带参数【员工id】

(3)发送Ajax请求,请求服务端,同时提交员工id参数

(4)服务端接收请求,根据员工id查询员工信息,将员工信息以JSON形式响应给页面

(5)页面接收服务端响应的JSON数据,通过vue的数据绑定进行员工信息回显

(6)点击保存按钮,发送Ajax请求,将页面中的员工信息以JSON方式提交给服务端

(7)服务端接收员工信息,并进行处理,完成后给页面响应

(8)页面接收到服务端响应信息后进行相应处理

请求的过程有两次和服务端进行交互,一次是发送请求查询相应的要编辑的数据进行回显,二是点击【保存】按钮时把修改后的数据提交到服务端

点击编辑按钮后:

在add.html页面获取url中的参数【员工id】:

vue创建完成后,框架自动调用created方法,当【id】不为空时,actionType即为“edit”,然后调用init();

 从url地址获取【员工id】,其中argname参数即id

init()方法:

 queryEmployeeById用来发送请求:

根据前端调试出来的url请求的格式,controller方法传参时需要使用{}的方式,并且给参数加上@PathVariable注解(表示id在请求路径里):

@GetMapping("/{id}")    //id是从URL获取的
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("没有查询到对应员工的信息……");
}

【保存】按钮提交到的还是update方法,在【账号启用、禁用】时写的update方法是通用的

【修改】后【保存】:

【启用、禁用】:

因为以上两个请求路径和请求方式完全一样,所以用的是同一个update方法?

四、分类管理相关页面开发

1. 公共字段自动填充

在新增员工时需要设置创建时间,创建人,修改时间,修改人等字段,在编辑员工时需要设置修改时间和修改人等字段,这些字段属于公共字段,很多表中都会有,可以借助[mybatis plus]提供的公共字段自动填充功能对这些公共字段进行统一管理。

mybatisplus公共字段自动填充,也就是在插入或者更新的时候为指定字段赋予指定的值,使用它的好处就是可以统一对这些字段进行处理,避免了重复代码。

实现步骤:

(1)在实体类的属性上加上@TableField注解,指定自动填充的策略

(2)按照框架要求编写元数据对象处理器,在此类中统一为公共字段赋值,此类需要实现MetaObjectHandler接口

Employee.java :

    @TableField(fill = FieldFill.INSERT)   //FieldFill:枚举型的填充策略——INSERT:插入时填充字段
    private LocalDateTime createTime;

    @TableField(fill = FieldFill.INSERT_UPDATE)     //插入和更新时填充字段
    private LocalDateTime updateTime;

    @TableField(fill = FieldFill.INSERT)
    private Long createUser;

    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateUser;

在common文件夹新增类设置公共填充字段的值:

@Component
@Slf4j
public class MyMetaObjectHandler implements MetaObjectHandler {
//    插入操作自动填充
    @Override
    public void insertFill(MetaObject metaObject) {
        log.info("公共字段自动填充[insert]……");
        log.info(metaObject.toString());
        metaObject.setValue("createTime", LocalDateTime.now());
        metaObject.setValue("updateTime", LocalDateTime.now());
        metaObject.setValue("createUser", BaseContext.getCurrentId());
        metaObject.setValue("updateUser", BaseContext.getCurrentId());
    }
//    修改操作自动填充
    @Override
    public void updateFill(MetaObject metaObject) {
        log.info("公共字段自动填充[update]……");
        log.info(metaObject.toString());
        metaObject.setValue("updateTime", LocalDateTime.now());
        metaObject.setValue("updateUser", BaseContext.getCurrentId());
    }
}

其中对于createdUser和updateUser需要动态获取,但在MyMetaObjectHandler类中不能获得HttpSession对象,所以需要借助JDK中的ThreadLocal来解决。

客户端发送的每次http请求,对应的在服务端都会分配一个新的线程来处理,在处理过程中涉及到下面类中的方法都属于相同的线程,包括(MyMetaObjecthandler的updateFill方法、EmployeeController的update方法、LoginCheckFilter类的doFilter方法),可以在该三个方法中分别加入获得当前线程id的代码来查验:

获得当前线程的代码:

long id = Thread.currentThread().getId();
log.info(id);

(1)编写BaseContext工具类,基于ThreadLocal封装的工具类

(2)在LoginCheckFilter的doFilter方法中调用BaseContext来设置当前登录用户的id

(3)在MyMetaObjectHandler的方法中调用BaseContext来获取登录用户的id

在common文件夹中新增工具类:

//基于ThreadLocal封装工具类,用于保存和获取当前登录用户id
public class BaseContext {   //作用范围:某一个线程之内
    private static ThreadLocal<Long> threadLocal = new ThreadLocal<>();

    public static void setCurrentId(Long id){   //作为工具类的方法,需要设置为静态
        threadLocal.set(id);
    }
    public static Long getCurrentId(){
        return threadLocal.get();
    }
}

LoginCheckFilter中:

然后在MyMetaObjectHandler类中对createdUser和updateUser两个字段的属性值进行设置。

        metaObject.setValue("createUser", BaseContext.getCurrentId());
        metaObject.setValue("updateUser", BaseContext.getCurrentId());

2. 新增分类

需求分析

数据模型

实体类Category

@Data
public class Category implements Serializable {
    private static final long serialVersionUID = 1L;
    private Long id;
    //类型 1 菜品分类 2 套餐分类
    private Integer type;
    //分类名称
    private String name;
    //顺序
    private Integer sort;
    //创建时间
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    //更新时间
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
    //创建人
    @TableField(fill = FieldFill.INSERT)
    private Long createUser;
    //修改人
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateUser;
    //是否删除
//    private Integer isDeleted;
}

Mapper接口CategoryMapper

@Mapper
public interface CategoryMapper extends BaseMapper<Category> {
}

业务层接口CategoryService

public interface CategoryService extends IService<Category> {
}

业务层实现类CategoryServiceImpl

@Service
public class CategoryServiceImpl extends ServiceImpl<CategoryMapper, Category> implements CategoryService {
}

控制层CategoryController

@RestController
@RequestMapping("/category")
@Slf4j
public class CategoryController {
    @Autowired
    private CategoryService categoryService;
}

程序执行过程

(1)页面(backend/page/category/list.html)发送Ajax请求,将新增分类窗口输入的数据以JSON形式提交到服务器

(2)服务端Controller接收页面提交的数据并调用Service将数据进行保存

(3)Service调用Mapper操作数据库,保存数据

不管新增菜品分类还是新增套餐分类,请求的服务端地址和提交的JSON数据结构相同,所以服务端只需要提供一个方法统一处理即可。

当填完要新增的分类后点击确定,会触发submitForm()事件

代码开发

@PostMapping
public R<String> save(@RequestBody Category category){
    log.info("category:{}", category);
    categoryService.save(category);
    return R.success("新增分类成功");
}

因为在数据库表中对分类的名字做了不可以重复的限制,所以新增分类时如果出现了重复的名字则会走全局的异常处理器,在页面上提示:“XXX”已存在。

3. 分类信息分页查询

需求分析

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

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

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

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

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

created()方法:刷新页面时自动调用:

代码开发

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

4. 删除分类

需求分析

在分类管理列表页面,可以对某个分类进行删除操作,要注意的是当分类关联了菜品或者套餐时,此分类不允许删除,并且给出用户提示。

(1)页面发送Ajax请求,将参数{id}提交到服务器

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

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

点击了删除后发送的请求:

点击删除时,触发deleteHandle事件:

该事件触发请求:deleteCategory

发送请求:

代码开发

    // 根据id动态删除分类
    @DeleteMapping
    public R<String> delete(@RequestParam Long ids){
        log.info("删除分类:{}", ids);
        categoryService.removeById(ids);   // mybatis自带的删除方法
        return R.success("分类信息删除成功");
    }

功能完善

之前的没有检查删除的分类是否关联了菜品或者套餐,所以需要进行完善。需要以下类和接口:

实体类Dish和Setmeal

@Data
public class Dish implements Serializable {
    private static final long serialVersionUID = 1L;
    private Long id;
    //菜品名称
    private String name;
    //菜品分类id
    private Long categoryId;
    //菜品价格
    private BigDecimal price;
    //商品码
    private String code;
    //图片
    private String image;
    //描述信息
    private String description;
    //0 停售 1 起售
    private Integer status;
    //顺序
    private Integer sort;
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
    @TableField(fill = FieldFill.INSERT)
    private Long createUser;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateUser;
    //是否删除
    private Integer isDeleted;
}
@Data
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 code;
    //描述信息
    private String description;
    //图片
    private String image;
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
    @TableField(fill = FieldFill.INSERT)
    private Long createUser;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateUser;
    //是否删除
    private Integer isDeleted;
}

Mapper接口DishMapper和SetmealMapper

@Mapper
public interface DishMapper extends BaseMapper<Dish> {
}
@Mapper
public interface SetMealMapper extends BaseMapper<Setmeal> {
}

Service接口DishService和SetmealService

public interface DishService extends IService<Dish> {

}
public interface SetMealService extends IService<Setmeal> {

}

Service实现类DishServiceImpl和SetmealServiceImpl

@Slf4j
@Service
public class DishServiceImpl extends ServiceImpl<DishMapper, Dish> implements DishService {

}
@Service
@Slf4j
public class SetMealServiceImpl extends ServiceImpl<SetMealMapper, Setmeal> implements SetMealService {
}

在CategoryService中自己定义remove方法,而不再使用mybatis自带的remove方法:

public interface CategoryService extends IService<Category> {
    public void remove(Long id);
}
@Service
public class CategoryServiceImpl extends ServiceImpl<CategoryMapper, Category> implements CategoryService {
    @Autowired
    private DishService dishService;
    @Autowired
    private SetMealService setMealService;
//    根据id删除分类, 删除之前需要进行判断
    @Override
    public void remove(Long id) {
        LambdaQueryWrapper<Dish> dishLambdaQueryWrapper = new LambdaQueryWrapper<>();
//        增加查询条件,根据分类id进行查询
        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);       //IService提供的removeById(id)方法
    }
}

其中实现类中的自定义异常CustomException(common目录下)为:

//自定义业务异常类
public class CustomException extends RuntimeException {
    public CustomException(String message){
        super(message);
    }
}

在全局异常处理类中加入以下异常处理方法: 

    @ExceptionHandler(CustomException.class)
    public R<String> exceptionHandler(CustomException ex){
        log.error(ex.getMessage());
//        返回给客户端页面,页面上就会显示该提示信息
        return R.error(ex.getMessage());   //"当前分类项已经关联了菜品,不能删除"
    }

在CategoryController中修改调用的remove方法为自己写的remove方法:

    // 根据id动态删除分类
    @DeleteMapping
    public R<String> delete(@RequestParam Long ids){
        log.info("删除分类:{}", ids);
        categoryService.remove(ids);  // 在CategoryService自己写的remove方法
        return R.success("分类信息删除成功");
    }

5. 修改分类

需求分析

在分类管理列表页面点击修改按钮,弹出修改窗口,在修改窗口回显分类信息并进行修改,最后点击确定按钮完成修改操作。

点击修改按钮会触发editHandle(scope.row)事件,该事件为classData的各个属性动态赋值达到回显的效果

修改完成后点击确定时发送请求:

传入的参数是JSON字符串,所以controller中需要加@RequestBody注解:

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

对于category表的更新人和更新时间的自动填充借助于之前写的MyMetaObjectHandler.java

五、菜品管理业务

1. 文件上传下载

文件上传,也称为upload,是指将本地图片,视频、音频等文件上传到服务器上,可以供其他用户浏览或下载的过程,文件上传在项目中应用非常广泛,发微博、朋友圈等都会用到。

文件上传时,对页面的form表单有如下要求:

  • method="post"                                采用post方式提交数据
  • enctype="multipart/form-data"        采用multipart格式上传数据
  • type="file"                                        使用input的file控件上传

举例:

<form method="post" action="/common/upload" enctype="multipart/form-data">
    <input name="myFile" type="file"/>
    <input type="submit" value="提交"/>
</form>

 服务端要接收客户端页面上传的文件,通常都会使用Apache的两个组件:

  • commons-fileupload
  • commons-io

Spring框架在spring-web包中对文件上传进行了封装,大大简化了服务端代码,只需要在controller 的方法中声明一个MultipartFile类型的参数即可接收上传的文件,例如:

    @PostMapping("/upload")
    public R<String> upload(MultipartFile file){
        log.info(file.toString());
        return null;
    }

文件下载,也称为download,是指将文件从服务器传输到本地计算机的过程。

通过浏览器进行文件下载,通常有两种表现形式:

  • 以附件形式下载,弹出保存对话框,将文件保存到指定磁盘目录
  • 直接在浏览器中打开

通过浏览器进行文件下载,本质上就是服务端将文件以流的形式写回浏览器的过程。

backend/page/demo/upload.html为下载的前端页面:

点击上传文件后发起upload请求,其中controller中upload方法中的MultipartFile类型的参数名需要和请求中name的属性保持一致,即下图中的“file”:

在过滤器中加入上传页面的路径:

//        定义不需要处理的请求路径;
        String[] urls = new String[]{
                "/employee/login",
                "/employee/logout",
                "/backend/**",
                "/front/**",
                "/common/**",
                "/user/sendMsg",//移动端发送短信
                "/user/login"//移动端登陆
        };

application.yml中设置保存图片的根目录:

reggie:
  path: D:\ProImg\

文件下载

文件下载时页面端可以使用<img>标签展示下载的图片:

<img v-if="imageUrl" :src="imageUrl" class="avatar"></img>

文件上传完之后回调handleAvatarSuccess方法:

通过response.data拿到文件名称 

//文件上传和下载
@RequestMapping("/common")
@RestController
@Slf4j
public class CommonController {
    @Value("${reggie.path}")    //类似于el表达式
    private String basePath;

    @PostMapping("/upload")
    public R<String> upload(MultipartFile file){
//        file是一个临时文件,需要转存到指定位置,否则本次请求完成后临时文件会删除。
        log.info(file.toString());
        //获得原始文件名;
        String originalFilename = file.getOriginalFilename();
        String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
//        使用UUID重新生成文件名,防止文件名称重复造成文件覆盖
        String fileName = UUID.randomUUID().toString();
        fileName += suffix;  //把原始文件名的后缀拼接到新文件名的最后
//        创建一个目录对象
        File dir = new File(basePath);
//        判断当前目录是否存在
        if (!dir.exists()){
            dir.mkdirs();
        }
        try {//将临时文件转存到指定位置
            file.transferTo(new File(basePath + fileName));
        } catch (IOException e) {
            e.printStackTrace();
        }
        return R.success(fileName);    //把文件名称存到数据库
    }

    @GetMapping("/download")
    public void download(String name, HttpServletResponse response){
        try {
            // 通过输入流读取文件内容
            FileInputStream fileInputStream = new FileInputStream(new File(basePath + name));
            // 通过输出流将文件写回浏览器,在浏览器展示图片
            ServletOutputStream outputStream = response.getOutputStream();
            response.setContentType("image/jpeg");
            int len = 0;
            byte[] bytes = new byte[1024];
            while ((len = fileInputStream.read(bytes)) != -1){
                outputStream.write(bytes, 0, len);
                outputStream.flush();
            }
            outputStream.close();
            fileInputStream.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

2. 新增菜品

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

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

需要用到的类和接口:

实体类:DishFlavor

Mapper接口:DishFlavorMapper

业务层接口:DishFlavorService

业务层实现类:DishFlavorServiceImpl

控制层:DishController

/**
菜品口味
 */
@Data
public class DishFlavor implements Serializable {
    private static final long serialVersionUID = 1L;
    private Long id;
    //菜品id
    private Long dishId;
    //口味名称
    private String name;
    //口味数据list
    private String value;
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
    @TableField(fill = FieldFill.INSERT)
    private Long createUser;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateUser;
    //是否删除
    private Integer isDeleted;

}
@Mapper
public interface DishFlavorMapper extends BaseMapper<DishFlavor> {
}
public interface DishFlavorService extends IService<DishFlavor> {
}
@Service
public class DishFlavorServiceImpl extends ServiceImpl<DishFlavorMapper, DishFlavor> implements DishFlavorService {
}
@RestController
@RequestMapping("/dish")
@Slf4j
public class DishController {
    @Autowired
    private DishService dishService;
    @Autowired
    private DishFlavorService dishFlavorService;
}

代码执行过程:

(1)页面 (backend/page/food/add.html) 发送Ajax请求,请求服务端获取菜品分类数据并展示到下拉框中

(2)页面发送请求进行图片上传,请求服务端将图片保存到服务器(之前写好了,复用即可)

(3)页面发送请求进行图片下载,将上传的图片进行回显

(4)点击保存按钮,发送Ajax请求,将菜品相关数据以JSON形式提交到服务端。

在category表中,type=1代表的是菜品分类,dishList是页面上的下拉框。

下拉框触发的事件的请求地址是:/category/list,

所以在CategoryController中写获得菜品列表的参数: 

//    根据条件查询分类数据
    @GetMapping("/list")
    public R<List<Category>> list(Category category){
        LambdaQueryWrapper<Category> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(category.getType() != null, Category::getType, category.getType());
//        添加排序条件
        queryWrapper.orderByAsc(Category::getSort).orderByDesc(Category::getUpdateTime);
        List<Category> list = categoryService.list(queryWrapper);
        return R.success(list);
    }

点击保存时,触发的事件:

点击保存后,提交的数据如下所示:

关于口味这个字段,不能直接存入数据库,所以新建DishDto表(dto目录下),用于封装页面提交的数据:

@Data
public class DishDto extends Dish {
    private List<DishFlavor> flavors = new ArrayList<>();
    private String categoryName;
    private Integer copies;
}

DTO (Data Transfer Object):数据传输对象,一般用于展示层和服务层之间的数据传输。

新增菜品时,需要向菜品表和口味表添加数据,所以需要自己在DishService中写新增保存的方法:

public interface DishService extends IService<Dish> {
//    新增菜品,同时插入菜品对应的口味数据,需要操作两张表,dish、dish_flavor
    public void saveWithFlavor(DishDto dishDto);
}
@Slf4j
@Service
public class DishServiceImpl extends ServiceImpl<DishMapper, Dish> implements DishService {
    @Autowired
    private DishFlavorService dishFlavorService;
//    新增菜品,同时保存对应的口味数据
    @Transactional    //涉及到多张表的操作(先操作dish表,再操作口味表),需要在启动类加入事务的支持@Enable TransactionManagement
    @Override
    public void saveWithFlavor(DishDto dishDto) {
//        保存菜品的基本信息到菜品表dish
        this.save(dishDto);

        Long dishId = dishDto.getId();   //菜品id,dishDto继承了Dish类,所以可以获得Id
//        菜品口味
        List<DishFlavor> flavors = dishDto.getFlavors();
//        给集合中的每个元素赋值一个id
        flavors = flavors.stream().map((item) -> {
            item.setDishId(dishId);
            return item;
        }).collect(Collectors.toList());
//        保存菜品口味数据到菜品口味表dish_flavor
        dishFlavorService.saveBatch(flavors);   //因为是集合,所以批量保存
    }
}

DishController中: 

//    新增菜品
    @PostMapping
    public R<String> save(@RequestBody DishDto dishDto){   //提交的是JSON数据,@RequestBody注解一定要加
        log.info(dishDto.toString());
        dishService.saveWithFlavor(dishDto);
        return R.success("新增菜品成功");
    }

3. 菜品信息分页查询

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

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

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

dish数据库表中存的是分类id,页面需要展示的是分类名称categoryName,所以直接查询dish表无法正常显示在页面,需要借助DishDto实体中的CategoryName字段。

在DishController中注入categoryService对象,因为要获得分类name:

    @Autowired
    private CategoryService categoryService;
    @GetMapping("/page")
    public R<Page> page(int page, int pageSize, String name){
        Page<Dish> pageInfo = new Page<>(page, pageSize);
        Page<DishDto> dishDtoPage = new Page<>(); //因为Dish没有categoryName,所以需要DishDto类
        LambdaQueryWrapper<Dish> queryWrapper = new LambdaQueryWrapper<>();
//        添加过滤条件
        queryWrapper.like(name != null, Dish::getName, name);   //name为要查询的名字
//        添加排序条件
        queryWrapper.orderByDesc(Dish::getUpdateTime);
//        执行分页查询
        dishService.page(pageInfo, queryWrapper);
//        对象拷贝    忽略Page类中的records属性(后续手动处理),将pageInfo中的其他属性都复制到dishDtoPage中来
        BeanUtils.copyProperties(pageInfo, dishDtoPage, "records");
        List<Dish> records = pageInfo.getRecords();
        List<DishDto> list = records.stream().map((item) -> {
            DishDto dishDto = new DishDto();
            //把item的属性值拷贝到dishDto中,dishDto又继承了Dish类,所以是把属性值拷贝到了Dish
            BeanUtils.copyProperties(item, dishDto);
            Long categoryId = item.getCategoryId(); //从item中获得 “分类id”
//            需要注入新的service: private CategoryService categoryService;
//            从categoryService中根据“分类id”查询分类对象
            Category category = categoryService.getById(categoryId);
            if (category != null){
                String categoryName = category.getName();    //得到分类id对应的分类name
                dishDto.setCategoryName(categoryName);   //设置dishDto的分类name
            }
            return dishDto;
        }).collect(Collectors.toList());
        dishDtoPage.setRecords(list);
        return R.success(dishDtoPage);   //菜品分类信息显示在浏览器
    }

4. 修改菜品

前端页面(add.html)和服务端的交互过程:

(1)页面发送Ajax请求,请求服务端获取分类数据,用于菜品分类下拉框中数据展示(新增时已经完成)

(2)页面发送Ajax请求,请求服务端,根据id查询当前菜品信息,用于菜品信息回显

(3)页面发送请求,请求服务端进行图片下载,用于页面图片回显

(4)点击保存按钮,页面发送Ajax请求,将修改后的菜品相关数据以JSON形式提交到服务端

在DishService 接口扩展方法 : public DishDto getByIdWithFlavor(Long id);
public interface DishService extends IService<Dish> {
//    新增菜品,同时插入菜品对应的口味数据,需要操作两张表,dish、dish_flavor
    public void saveWithFlavor(DishDto dishDto);
//    根据id查询菜品信息和对应的口味信息
    public DishDto getByIdWithFlavor(Long id);
}

DishServiceImpl实现以上方法: 

//  根据id查询菜品信息和对应的口味信息
    @Override
    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;
    }

DishController中页面回显:

//    根据id查询菜品信息和对应的口味信息
    @GetMapping("/{id}")
    public R<DishDto> get(@PathVariable Long id){
//        在DishService 接口扩展方法 : public DishDto getByIdWithFlavor(Long id);
        DishDto dishDto = dishService.getByIdWithFlavor(id);
        return R.success(dishDto);
    }

修改完成后点击保存时发送请求:

因为更新菜品信息时,要同时更新口味信息,所以要重写更新的方法:

public interface DishService extends IService<Dish> {
//    新增菜品,同时插入菜品对应的口味数据,需要操作两张表,dish、dish_flavor
    public void saveWithFlavor(DishDto dishDto);
//    根据id查询菜品信息和对应的口味信息
    public DishDto getByIdWithFlavor(Long id);
//    更新菜品信息及对应的口味信息
    public void updateWithFlavor(DishDto dishDto);
}

实现类中重写该方法:

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

 DishController中:

//    修改菜品
    @PutMapping
    public R<String> update(@RequestBody DishDto dishDto){   //提交的是JSON数据,@RequestBody注解一定要加
        log.info(dishDto.toString());
        dishService.updateWithFlavor(dishDto);
        return R.success("修改菜品成功");
    }

六、套餐管理业务开发

批量删除、起售、停售功能课下完成

1. 新增套餐

需求分析

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

数据模型

新增套餐,其实就是将新增页面录入的套餐信息插入到setmeal表,还需要向setmeal_dish表插入套餐和菜品关联数据,所以在新增套餐时,涉及到两个表:

  • setmeal            套餐表
  • setmeal_dish   套餐菜品关系表

需要用到的类和接口:

实体类 SetmealDish

/**
 * 套餐菜品关系
 */
@Data
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;
    //排序
    private Integer sort;
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
    @TableField(fill = FieldFill.INSERT)
    private Long createUser;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateUser;
    //是否删除
    private Integer isDeleted;
}

DTO SetmealDto

@Data
public class SetmealDto extends Setmeal {
    private List<SetmealDish> setmealDishes;
    private String categoryName;
}

Mapper接口 SetmealDishMapper

@Mapper
public interface SetmealDishMapper extends BaseMapper<SetmealDish> {
}

业务层接口 SetmealDishService

public interface SetmealDishService extends IService<SetmealDish> {
}

业务层实现类 SetmealDishServiceImpl

@Service
@Slf4j
public class SetmealDishServiceImpl extends ServiceImpl<SetmealDishMapper, SetmealDish> implements SetmealDishService {
}

控制层 SetmealController

//套餐管理
@RestController
@RequestMapping("/setmeal")
@Slf4j
public class SetmealController {
    @Autowired
    private SetMealService setMealService;
    @Autowired
    private SetmealDishService setmealDishService;
    @Autowired
    private CategoryService categoryService;
}

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

(1)页面 (backend/page/combo/add.html) 发送Ajax请求,请求服务端获取套餐分类数据并展示到下拉框中

(2)页面发送Ajax请求,请求服务端获取菜品分类数据并展示到添加菜品窗口中

(3)页面发送Ajax请求,请求服务端,根据菜品分类查询对应的菜品数据并展示到添加菜品窗口中

(4)页面发送请求进行图片上传,请求服务端将图片保存到服务器

(5)页面发送请求进行图片下载,将上传的图片进行回显

(6)点击保存按钮,发送Ajax请求,将套餐相关数据以JSON形式提交到服务端

代码开发

//    根据条件查询对应的菜品数据
    @GetMapping("/list")
    public R<List<Dish>> list(Dish dish){
        LambdaQueryWrapper<Dish> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(dish.getCategoryId() != null, Dish::getCategoryId, dish.getCategoryId());
//        查询起售状态的菜品
        queryWrapper.eq(Dish::getStatus, 1);
        queryWrapper.orderByAsc(Dish::getSort).orderByDesc(Dish::getUpdateTime);
        List<Dish> list = dishService.list(queryWrapper);
        // 从截图看到要的是菜品的列表,所以返回list
        return R.success(list);
    }

public interface SetMealService extends IService<Setmeal> {
//    新增套餐,同时需要保存套餐和菜品的关联关系
    public void saveWithDish(SetmealDto setmealDto);
}
@Service
@Slf4j
public class SetMealServiceImpl extends ServiceImpl<SetMealMapper, Setmeal> implements SetMealService {
    @Autowired
    private SetmealDishService setmealDishService;
    //    新增套餐,同时需要保存套餐和菜品的关联关系
    @Override
    @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);
    }
}
//    新增套餐
    @PostMapping
    public R<String> save(@RequestBody SetmealDto setmealDto){
        log.info("套餐信息:{}", setmealDto);
        setMealService.saveWithDish(setmealDto);
        return R.success("新增套餐成功");
    }

2. 套餐信息分页查询

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

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

//    套餐分页查询
    @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查询分类对象,需要注入categoryService对象
            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);
    }

3. 删除套餐

注意:对于状态为售卖中的套餐不能删除,需要先停售才能删除。

删除单个套餐时的请求:

删除多个套餐时的请求:

两种请求的地址和请求方式都是相同的,不同的则是传递的id个数,所以在服务端可以提供一个方法来统一处理。

删除的时候要同时操作两张表,所以要重写删除的方法:

public interface SetMealService extends IService<Setmeal> {
//    新增套餐,同时需要保存套餐和菜品的关联关系
    public void saveWithDish(SetmealDto setmealDto);
//    删除套餐和套餐与菜品的关联数据
    public void removeWithDish(List<Long> ids);
}
    @Transactional
    @Override
    public void removeWithDish(List<Long> ids) {
//        查询套餐的状态(停售状态),确定是否可以删除
        LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper<>();
//        select count(*) from setmeal where id in (?,?,?) and status = 1;        
        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);
//        删除关系表中的数据——setmeal_dish
//        delete from setmeal_dish where setmeal_id in (ids) 
        LambdaQueryWrapper<SetmealDish> lambdaQueryWrapper = new LambdaQueryWrapper<>();
        lambdaQueryWrapper.in(SetmealDish::getSetmealId, ids);
//        删除关系表中的数据--setmealDish
        setmealDishService.remove(lambdaQueryWrapper);
    }
//    删除套餐
    @DeleteMapping
//    @CacheEvict(value = "setmealCache", allEntries = true)
    public R<String> delete(@RequestParam List<Long> ids){
        log.info("ids:{}", ids);
        setMealService.removeWithDish(ids);
        return R.success("套餐数据删除成功");
    }

4. 短信发送

目前市面上有很多第三方提供的短信服务,这些第三方短信服务会和各个运营商(移动、联通、电信)对接,我们只需要注册成为会员并且按照提供的开发文档进行调用就可以发送短信,一般来说,这些短信服务都是收费服务。常用的短信服务有:阿里云、华为云、腾讯云、京东、梦网、乐信。

阿里云短信服务介绍:

阿里云短信服务(Short Message Service)是广大企业客户快速触达手机用户所优先使用的通信能力。调用API或用群发助手,即可发送验证码、通知类和营销类短信;国内验证短信秒级触达,到达率可高达99%;国际/港澳台短信覆盖200多个国家和地区,安全稳定,广受出海企业选用。应用场景:验证码、短信通知、推广短信。

阿里云短信服务——注册账号:

阿里云登录 - 欢迎登录阿里云,安全稳定的云计算服务平台

右上角【控制台】

 左上角【阿里云】

左边导航栏选【国内消息】

【短信签名】是短信发送者的署名,表示发送方的身份。例如上边短信中的“阿里云”、“菜鸟裹裹”、“天猫”

【短信模板】包含短信发送内容、场景、变量信息。

选择“开始使用子用户AccessKey”

需要创建一个新的用户

 点击上图中用户登录名后新增授权:

 确定后可以看到新增了两条授权:

 

短信发送代码开发

(1)导入Maven坐标

<dependency>
    <groupId>com.aliyun</groupId>
    <artifactId>aliyun-java-sdk-core</artifactId>
    <version>4.5.16</version>
</dependency>
<dependency>
    <groupId>com.aliyun</groupId>
    <artifactId>aliyun-java-sdk-dysmsapi</artifactId>
    <version>2.1.0</version>
</dependency>

(2)调用API

/**
 * 短信发送工具类
 */
public class SMSUtils {

	/**
	 * 发送短信
	 * @param signName 签名
	 * @param templateCode 模板
	 * @param phoneNumbers 手机号
	 * @param param 参数
	 */
	public static void sendMessage(String signName, String templateCode,String phoneNumbers,String param){
//      regionId, accessKeyId, secret
		DefaultProfile profile = DefaultProfile.getProfile("cn-hangzhou", "", "");
		IAcsClient client = new DefaultAcsClient(profile);

		SendSmsRequest request = new SendSmsRequest();
		request.setSysRegionId("cn-hangzhou");
		request.setPhoneNumbers(phoneNumbers);
		request.setSignName(signName);
		request.setTemplateCode(templateCode);
		request.setTemplateParam("{\"code\":\""+param+"\"}");
		try {
			SendSmsResponse response = client.getAcsResponse(request);
			System.out.println("短信发送成功");
		}catch (ClientException e) {
			e.printStackTrace();
		}
	}

}

5. 手机验证码登陆(前端页面)

为了方便用户登录,移动端通常都会提供手机验证码登录的功能。

手机验证码登录的优点:

  • 方便快捷,无需注册,直接登录
  • 短信验证码作为登录凭证,无需记忆密码
  • 安全

登陆流程:输入手机号 > 获取验证码 > 输入验证码 > 点击登录 > 登陆成功

 通过手机验证码登录时,涉及的表为user表,即用户表(没有用户名和密码两个字段,因为用短信验证码登陆,有手机号即可)。

交互过程

(1)在登录页面 (front/page/login.html) 输入手机号,点击【获取验证码】按钮,页面发送Ajax请求,在服务端调用短信服务API给指定手机号发送短信验证码。

(2)在登录页面输入验证码,点击【登陆】按钮,发送Ajax请求,在服务端处理登录请求。

准备工作

实体类 User

/**
 * 用户信息
 */
@Data
public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    private Long id;
    //姓名
    private String name;
    //手机号
    private String phone;
    //性别 0 女 1 男
    private String sex;
    //身份证号
    private String idNumber;
    //头像
    private String avatar;
    //状态 0:禁用,1:正常
    private Integer status;
}

Mapper接口 UserMapper

@Mapper
public interface UserMapper extends BaseMapper<User> {
}

业务层接口 UserService

public interface UserService extends IService<User> {
}

业务层实现类 UserServiceImpl

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
}

控制层 UserController

@RestController
@Slf4j
@RequestMapping("/user")
public class UserController {
    @Autowired
    private UserService userService;

    @Autowired
    private RedisTemplate redisTemplate;
}

工具类 SMSUtils、ValidateCodeUtils

SMSUtils 上面有

/**
 * 随机生成验证码工具类
 */
public class ValidateCodeUtils {
    /**
     * 随机生成验证码
     * @param length 长度为4位或者6位
     * @return
     */
    public static Integer generateValidateCode(int length){
        Integer code =null;
        if(length == 4){
            code = new Random().nextInt(9999);//生成随机数,最大为9999
            if(code < 1000){
                code = code + 1000;//保证随机数为4位数字
            }
        }else if(length == 6){
            code = new Random().nextInt(999999);//生成随机数,最大为999999
            if(code < 100000){
                code = code + 100000;//保证随机数为6位数字
            }
        }else{
            throw new RuntimeException("只能生成4位或6位数字验证码");
        }
        return code;
    }

    /**
     * 随机生成指定长度字符串验证码
     * @param length 长度
     * @return
     */
    public static String generateValidateCode4String(int length){
        Random rdm = new Random();
        String hash1 = Integer.toHexString(rdm.nextInt());
        String capstr = hash1.substring(0, length);
        return capstr;
    }
}

代码开发

修改LoginCheckFilter:

在进行手机验证码登陆时,发送的请求需要再次过滤器处理时直接放行。

//        定义不需要处理的请求路径;
        String[] urls = new String[]{
                "/employee/login",
                "/employee/logout",
                "/backend/**",
                "/front/**",
                "/common/**",
                "/user/sendMsg",//移动端发送短信
                "/user/login"//移动端登陆
        };

对手机端的登录用户进行判断:

//        4-1. 判断后台系统登录状态,如果已登陆,则直接放行
        if (request.getSession().getAttribute("employee") != null){
            log.info("用户已登陆,用户id为{}", request.getSession().getAttribute("employee"));
            Long empId = (Long) request.getSession().getAttribute("employee");
            BaseContext.setCurrentId(empId);
            filterChain.doFilter(request, response);
            return;
        }
 //        4-2. 判断移动端用户登录状态,如果已登陆,则直接放行
        if (request.getSession().getAttribute("user") != null){
            log.info("用户已登陆,用户id为{}", request.getSession().getAttribute("user"));
            Long userId = (Long) request.getSession().getAttribute("user");
            BaseContext.setCurrentId(userId);
            filterChain.doFilter(request, response);
            return;
        }

获取验证码时,执行的是front/page/login.html中的getCode()方法:

UserController中:

@RestController
@Slf4j
@RequestMapping("/user")
public class UserController {
    @Autowired
    private UserService userService;

//    发送手机短信验证码
    @PostMapping("/sendMsg")
    public R<String> sendMsg(@RequestBody User user, HttpSession session){
//        获取手机号
        String phone = user.getPhone();
        if (StringUtils.isNotEmpty(phone)){
//        生成随机的6位验证码
            String code = ValidateCodeUtils.generateValidateCode(6).toString();
            log.info("code = {}", code);
//        调用阿里云提供的短信服务API完成发送短信
//            SMSUtils.sendMessage("瑞吉外卖", "", phone, code);
//        需要将生成的验证码保存到session
            session.setAttribute(phone, code);
            return R.success("手机验证码短信发送成功");
        }
        return R.error("短信发送失败");
    }
//    移动端用户登陆
    @PostMapping("/login")
    public R<User> login(@RequestBody Map map, HttpSession session){
        log.info(map.toString());    //map中应该有两个键值对,分别为phone电话号码和code验证码
//        获取手机号
        String phone = map.get("phone").toString();
//        获取验证码
        String code = map.get("code").toString();
//        从session中获取保存的验证码
        Object codeInSession = session.getAttribute(phone);
//        进行验证码的比对(页面提交的验证码和session中保存的验证码比较)
        if (codeInSession != null && codeInSession.equals(code)){
            // 如果能比对成功,说明登陆成功
            LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
            queryWrapper.eq(User::getPhone, phone);
            User user = userService.getOne(queryWrapper);
            if (user == null){
                // 判断当前手机号对应的用户是否为新用户,如果是新用户就自动完成注册
                user = new User();
                user.setPhone(phone);
                user.setStatus(1);
                userService.save(user);
            }
            session.setAttribute("user",user.getId());
            return R.success(user);
        }

        return R.error("登陆失败");

    }
}

七、菜品展示、购物车、下单(移动端)

1. 导入用户地址簿相关功能代码

地址簿,指的是移动端消费者用户的地址信息,用户登录成功后可以维护自己的地址信息。同一个用户可以有多个地址信息,但是只能有一个默认地址。

用户的地址信息会存储在address_book表,即地址簿表中。is_default字段表示是否是默认地址。

实体类 AddressBook

/**
 * 地址簿
 */
@Data
public class AddressBook implements Serializable {
    private static final long serialVersionUID = 1L;
    private Long id;
    //用户id
    private Long userId;
    //收货人
    private String consignee;
    //手机号
    private String phone;
    //性别 0 女 1 男
    private String sex;
    //省级区划编号
    private String provinceCode;
    //省级名称
    private String provinceName;
    //市级区划编号
    private String cityCode;
    //市级名称
    private String cityName;
    //区级区划编号
    private String districtCode;
    //区级名称
    private String districtName;
    //详细地址
    private String detail;
    //标签
    private String label;
    //是否默认 0 否 1是
    private Integer isDefault;
    //创建时间
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    //更新时间
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
    //创建人
    @TableField(fill = FieldFill.INSERT)
    private Long createUser;
    //修改人
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateUser;
    //是否删除
    private Integer isDeleted;
}

Mapper接口 AddressBookMapper

@Mapper
public interface AddressBookMapper extends BaseMapper<AddressBook> {
}

业务层接口 AddressBookService

public interface AddressBookService extends IService<AddressBook> {
}

业务层实现类 AddressBookServiceImpl

@Slf4j
@Service
public class AddressBookServiceImpl extends ServiceImpl<AddressBookMapper, AddressBook> implements AddressBookService {
}

控制层 AddressBookController

/**
 * 地址簿管理
 */
@Slf4j
@RestController
@RequestMapping("/addressBook")
public class AddressBookController {

    @Autowired
    private AddressBookService addressBookService;

    /**
     * 新增
     */
    @PostMapping
    public R<AddressBook> save(@RequestBody AddressBook addressBook) {
        addressBook.setUserId(BaseContext.getCurrentId());
        log.info("addressBook:{}", addressBook);
        addressBookService.save(addressBook);
        return R.success(addressBook);
    }

    /**
     * 设置默认地址
     */
    @PutMapping("default")
    public R<AddressBook> setDefault(@RequestBody AddressBook addressBook) {
        log.info("addressBook:{}", addressBook);
        LambdaUpdateWrapper<AddressBook> wrapper = new LambdaUpdateWrapper<>();
        wrapper.eq(AddressBook::getUserId, BaseContext.getCurrentId());
        wrapper.set(AddressBook::getIsDefault, 0);
        //SQL:update address_book set is_default = 0 where user_id = ?
        addressBookService.update(wrapper);

        addressBook.setIsDefault(1);
        //SQL:update address_book set is_default = 1 where id = ?
        addressBookService.updateById(addressBook);
        return R.success(addressBook);
    }

    /**
     * 根据id查询地址
     */
    @GetMapping("/{id}")
    public R get(@PathVariable Long id) {
        AddressBook addressBook = addressBookService.getById(id);
        if (addressBook != null) {
            return R.success(addressBook);
        } else {
            return R.error("没有找到该对象");
        }
    }

    /**
     * 查询默认地址
     */
    @GetMapping("default")
    public R<AddressBook> getDefault() {
        LambdaQueryWrapper<AddressBook> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(AddressBook::getUserId, BaseContext.getCurrentId());
        queryWrapper.eq(AddressBook::getIsDefault, 1);

        //SQL:select * from address_book where user_id = ? and is_default = 1
        AddressBook addressBook = addressBookService.getOne(queryWrapper);

        if (null == addressBook) {
            return R.error("没有找到该对象");
        } else {
            return R.success(addressBook);
        }
    }

    /**
     * 查询指定用户的全部地址
     */
    @GetMapping("/list")
    public R<List<AddressBook>> list(AddressBook addressBook) {
        addressBook.setUserId(BaseContext.getCurrentId());
        log.info("addressBook:{}", addressBook);

        //条件构造器
        LambdaQueryWrapper<AddressBook> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(null != addressBook.getUserId(), AddressBook::getUserId, addressBook.getUserId());
        queryWrapper.orderByDesc(AddressBook::getUpdateTime);

        //SQL:select * from address_book where user_id = ? order by update_time desc
        return R.success(addressBookService.list(queryWrapper));
    }
}

2. 菜品展示

用户登陆成功后跳转到系统首页,在 首页需要根据分类来展示菜品和套餐。如果菜品设置了口味信息,需要展示【选择规格】按钮,否则显示【+】按钮

(1)页面(front/index.html)发送Ajax请求,获取分类数据(菜品分类和套餐分类)

(2)页面发送Ajax请求,获取第一个分类下的菜品或者套餐。

初始化方法里发送了两次请求,promise.all(两个请求Api):两次请求必须都成功才能渲染前端页面。

首页加载完成后,还发送了一次Ajax请求用于加载购物车数据,此处可以将这次请求的地址暂时修改一下,从静态JSON文件获取数据,等后续开发购物车功能时再修改回来,如下:

//获取购物车内商品的集合
function cartListApi(data) {
    return $axios({
        // 'url': '/shoppingCart/list',
        'url': '/front/cartData.json',
        'method': 'get',
        params:{...data}
    })
}

修改请求地址后,进入有页面后左侧自动出现分类信息,是因为category/list的controller之前已经写好。

菜品信息也已经写好,是因为dish/list的controller写好。

页面显示还有细节未处理好:有口味信息的要可以【选择规格】而不是【+】

之前的dish/list的controller查询的是dish类,如果需要口味信息需要查dishDto类。

//    根据条件查询对应的菜品数据
    @GetMapping("/list")
    public R<List<DishDto>> 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);

        List<DishDto> dishDtoList = list.stream().map((item) -> {
            DishDto dishDto = new DishDto();
            BeanUtils.copyProperties(item, dishDto);
            Long categoryId = item.getCategoryId(); //分类id
//            需要注入新的service: private CategoryService categoryService;
//            根据id查询分类对象
            Category category = categoryService.getById(categoryId);
            if (category != null){
                String categoryName = category.getName();
                dishDto.setCategoryName(categoryName);
            }
            Long dishId = item.getId();
            LambdaQueryWrapper<DishFlavor> lambdaQueryWrapper = new LambdaQueryWrapper<>();
            lambdaQueryWrapper.eq(DishFlavor::getDishId, dishId);
//            SQL: select * from dish_flavor where dish_id = ?
            List<DishFlavor> dishFlavorList = dishFlavorService.list(lambdaQueryWrapper);
            dishDto.setFlavors(dishFlavorList);
            return dishDto;
        }).collect(Collectors.toList());
        return R.success(dishDtoList);
    }

此时,除套餐外的其他都可以用,套餐无法正常访问,需要写setmeal/list

//    根据条件查询套餐数据
    @GetMapping("/list")
    public R<List<Setmeal>> list(Setmeal setmeal){   //因为url参数是拼接字符串,所以不能加@RequestBody
        LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(setmeal.getCategoryId() != null, Setmeal::getCategoryId, setmeal.getCategoryId());
        queryWrapper.eq(setmeal.getStatus() != null, Setmeal::getStatus, setmeal.getStatus());
        queryWrapper.orderByDesc(Setmeal::getUpdateTime);
        List<Setmeal> list = setMealService.list(queryWrapper);
        return R.success(list);
    }

3. 购物车

移动端用户可以将菜品或者套餐添加到购物车。对于菜品来说,如果设置了口味信息,则需要选择规格后才能加入购物车;对于套餐来说,可以直接点击【+】将当前套餐加入购物车。在购物车中可以修改菜品和套餐的数量,也可以清空购物车。

购物车对应的数据表为shopping_cart表。

(1)点击【加入购物车】或者【+】按钮,页面发送Ajax请求,请求服务端,将菜品或者套餐添加到购物车。

(2)点击购物车图标,页面发送Ajax请求,请求服务端查询购物车中的菜品和套餐

(3)点击清空购物车按钮,页面发送Ajax请求,请求服务端来执行清空购物车操作

实体类 ShoppingCart

/**
 * 购物车
 */
@Data
public class ShoppingCart implements Serializable {
    private static final long serialVersionUID = 1L;
    private Long id;
    //名称
    private String name;
    //用户id
    private Long userId;
    //菜品id
    private Long dishId;
    //套餐id
    private Long setmealId;
    //口味
    private String dishFlavor;
    //数量
    private Integer number;
    //金额
    private BigDecimal amount;
    //图片
    private String image;
    private LocalDateTime createTime;
}

Mapper接口 ShoppingCartMapper 

@Mapper
public interface ShoppingCartMapper extends BaseMapper<ShoppingCart> {
}

业务层接口 ShoppingCartService

public interface ShoppingCartService extends IService<ShoppingCart> {
}

业务层实现类 ShoppingCartServiceImpl

@Service
@Slf4j
public class ShoppingCartServiceImpl extends ServiceImpl<ShoppingCartMapper, ShoppingCart> implements ShoppingCartService {
}

控制层 ShoppingCartController

@Slf4j
@RestController
@RequestMapping("/shoppingCart")
public class ShoppingCartController {
    @Autowired
    private ShoppingCartService shoppingCartService;
}

加购物车的请求:

    @PostMapping("/add")
    public R<ShoppingCart> add(@RequestBody ShoppingCart shoppingCart){
        log.info("购物车数据:{}", shoppingCart);
//        设置用户id,指定当前是哪个用户的购物车数据
        Long currentId = BaseContext.getCurrentId();
        shoppingCart.setUserId(currentId);
        Long dishId = shoppingCart.getDishId();
        LambdaQueryWrapper<ShoppingCart> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(ShoppingCart::getUserId, currentId);
        if (dishId != null){
//            添加到购物车的是菜品
            queryWrapper.eq(ShoppingCart::getDishId, dishId);
        }else {
//            添加到购物车的是套餐
            queryWrapper.eq(ShoppingCart::getSetmealId, shoppingCart.getSetmealId());
        }
//        查询当前菜品或者套餐是否在购物车中
//        SQL: select * from shopping_cart where user_id = ? and dish_id /setmeal_id = ?
        ShoppingCart cartServiceOne = shoppingCartService.getOne(queryWrapper);
        if (cartServiceOne != null){
//            如果已经存在,就在原来数量基础上加1
            Integer number = cartServiceOne.getNumber();
            cartServiceOne.setNumber(number + 1);
            shoppingCartService.updateById(cartServiceOne);
        }else {
//            如果不存在,则添加到购物车,数量默认就是1
            shoppingCart.setNumber(1);
            shoppingCart.setCreateTime(LocalDateTime.now());
            shoppingCartService.save(shoppingCart);
            cartServiceOne = shoppingCart;
        }
        return R.success(cartServiceOne);
    }

查购物车的数据,将之前购物车的请求url改回来(页面如果不起作用可能是因为缓存,按Ctrl+F5清理缓存即可)并写controller:

    //    根据userID查询购物车
    @GetMapping("/list")
    public R<List<ShoppingCart>> list(){
        log.info("查看购物车...");
        LambdaQueryWrapper<ShoppingCart> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(ShoppingCart::getUserId, BaseContext.getCurrentId());
        queryWrapper.orderByAsc(ShoppingCart::getCreateTime);
        List<ShoppingCart> list = shoppingCartService.list(queryWrapper);
        return R.success(list);
    }
//    清空购物车
    @DeleteMapping("/clean")
    public R<String> clean(){
//        SQL: delete from shopping_cart where user_id = ?
        LambdaQueryWrapper<ShoppingCart> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(ShoppingCart::getUserId, BaseContext.getCurrentId());
        shoppingCartService.remove(queryWrapper);
        return R.success("清空购物车成功");
    }

4.  下单

移动端用户将菜品或者套餐加入购物车后,可以点击购物车中的【去结算】按钮,页面跳转到订单确认页面,点击【去支付】按钮则完成下单操作。(订单保存到数据库,而不开发支付功能)

用户下单业务对应的数据表为order表和order_detail表:

  • orders:订单表
  • order_detail:订单明细表

交互过程:

(1)在购物车中点击【去结算】按钮,页面跳转到订单确认页面

(2)在订单确认页面,发送Ajax 请求,请求服务端获取当前登录用户的默认地址

(3)在订单确认页面,发送Ajax请求,请求服务端获取当前登录用户的购物车数据

(4)在订单确认页面点击【去支付】按钮,发送Ajax请求,请求服务端完成下单操作。

实体类 Orders、OrderDetail

/**
 * 订单
 */
@Data
public class Orders implements Serializable {
    private static final long serialVersionUID = 1L;
    private Long id;
    //订单号
    private String number;
    //订单状态 1待付款,2待派送,3已派送,4已完成,5已取消
    private Integer status;
    //下单用户id
    private Long userId;
    //地址id
    private Long addressBookId;
    //下单时间
    private LocalDateTime orderTime;
    //结账时间
    private LocalDateTime checkoutTime;
    //支付方式 1微信,2支付宝
    private Integer payMethod;
    //实收金额
    private BigDecimal amount;
    //备注
    private String remark;
    //用户名
    private String userName;
    //手机号
    private String phone;
    //地址
    private String address;
    //收货人
    private String consignee;
}
/**
 * 订单明细
 */
@Data
public class OrderDetail implements Serializable {
    private static final long serialVersionUID = 1L;
    private Long id;
    //名称
    private String name;
    //订单id
    private Long orderId;
    //菜品id
    private Long dishId;
    //套餐id
    private Long setmealId;
    //口味
    private String dishFlavor;
    //数量
    private Integer number;
    //金额
    private BigDecimal amount;
    //图片
    private String image;
}

Mapper接口 OrdersMapper、OrderDetailMapper

@Mapper
public interface OrderMapper extends BaseMapper<Orders> {
}
@Mapper
public interface OrderDetailMapper extends BaseMapper<OrderDetail> {
}

业务层接口 OrdersService、OrderDetailService

public interface OrderService extends IService<Orders> {
    public void submit(Orders orders);
}
public interface OrderDetailService extends IService<OrderDetail> {
}

业务层实现类 OrdersServiceImpl、OrderDetailServiceImpl

@Service
@Slf4j
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Orders> implements OrderService {
}
@Service
@Slf4j
public class OrderDetailServiceImpl extends ServiceImpl<OrderDetailMapper, OrderDetail> implements OrderDetailService {
}

控制层 OrdersController、OrderDetailController

@Slf4j
@RestController
@RequestMapping("/order")
public class OrdersController {
    @Autowired
    private OrderService orderService;

}
@Slf4j
@RequestMapping("/orderDetail")
@RestController
public class OrderDetailController {
    @Autowired
    private OrderDetailService orderDetailService;
}

点击【去支付】发起的请求:

提交的请求包含的参数,可以使用Orders实体类接收:

在OrderServiceImpl中重写submit方法:

    @Autowired
    private ShoppingCartService shoppingCartService;
    @Autowired
    private UserService userService;
    @Autowired
    private AddressBookService addressBookService;
    @Autowired
    private OrderDetailService orderDetailService;
    @Override
    @Transactional
    public void submit(Orders orders) {
//        获得当前用户的id
        Long userId = BaseContext.getCurrentId();
//        查询当前用户的购物车数据
        LambdaQueryWrapper<ShoppingCart> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(ShoppingCart::getUserId, userId);
        List<ShoppingCart> shoppingCarts = shoppingCartService.list(queryWrapper);
        if (shoppingCarts == null || shoppingCarts.size() == 0){
            throw new CustomException("购物车为空,不能下单");
        }
//        查询用户数据
        User user = userService.getById(userId);
//        查询地址数据
        Long addressBookId = orders.getAddressBookId();
        AddressBook addressBook = addressBookService.getById(addressBookId);
        if (addressBook == null){
            throw new CustomException("用户地址信息有误,不能下单");
        }
        long orderId = IdWorker.getId();   //订单号

        AtomicInteger amount = new AtomicInteger(0);   //原子整数类,保证多线程安全。
        List<OrderDetail> orderDetails = shoppingCarts.stream().map((item) -> {
            OrderDetail orderDetail = new OrderDetail();
            orderDetail.setOrderId(orderId);
            orderDetail.setNumber(item.getNumber());
            orderDetail.setDishFlavor(item.getDishFlavor());
            orderDetail.setDishId(item.getDishId());
            orderDetail.setSetmealId(item.getSetmealId());
            orderDetail.setName(item.getName());
            orderDetail.setImage(item.getImage());
            orderDetail.setAmount(item.getAmount());
//            累加,+=
            amount.addAndGet(item.getAmount().multiply(new BigDecimal(item.getNumber())).intValue());
            return orderDetail;
        }).collect(Collectors.toList());

        orders.setId(orderId);
        orders.setOrderTime(LocalDateTime.now());
        orders.setCheckoutTime(LocalDateTime.now());
        orders.setStatus(2);
        orders.setAmount(new BigDecimal(amount.get()));//总金额
        orders.setUserId(userId);
        orders.setNumber(String.valueOf(orderId));
        orders.setUserName(user.getName());
        orders.setConsignee(addressBook.getConsignee());
        orders.setPhone(addressBook.getPhone());
        orders.setAddress((addressBook.getProvinceName() == null ? "" : addressBook.getProvinceName())
                + (addressBook.getCityName() == null ? "" : addressBook.getCityName())
                + (addressBook.getDistrictName() == null ? "" : addressBook.getDistrictName())
                + (addressBook.getDetail() == null ? "" : addressBook.getDetail()));
//        向订单表插入数据,一条数据
        this.save(orders);
//        向订单明细表插入数据,可能不止一条数据
        orderDetailService.saveBatch(orderDetails);
//        清空购物车数据
        shoppingCartService.remove(queryWrapper);
    }

OrderController中:

//    用户下单
    @PostMapping("/submit")
    public R<String> submit(@RequestBody Orders orders){
        log.info("订单数据:{}", orders);
        orderService.submit(orders);
        return R.success("下单成功");
    }

八、缓存优化

在项目的pom.xml文件中导入spring data redis的Maven坐标:

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

在项目的application.yml中加入redis相关配置:

server:
  port: 8080
spring:
  application:
    name: reggie_take_out
  datasource:
    druid:
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://localhost:3306/reggie?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
      username: XXXXXXX
      password: XXXXXXX
  redis:
    host: localhost
    port: 6379
#    password: 123456
    database: 0

 创建RedisConfig的类,为了获得可以序列化key的RedisTemplate对象:

@Configuration
public class RedisConfig extends CachingConfigurerSupport {
    @Bean
    public RedisTemplate<Object,Object> redisTemplate(RedisConnectionFactory connectionFactory){
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
//        对于字符串的处理
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(connectionFactory);
        return redisTemplate;
    }
}

运行项目之前要先开启redis服务 

 1. 缓存短信验证码

之前的短信验证码存在session中,但是session的默认有效期是30分钟,一般的话短信验证码时5分钟,现在将短信验证码从session改到Redis中保存,并设置短信验证码的有效期是5分钟。

(1)在服务端UserController中注入RedisTemplate对象,用于操作Redis

(2)在服务端UserController的sendMsg方法中,将随机生成的验证码缓存到Redis中,并设置有效期为5分钟

(3)在服务端UserController的login方法中,从Redis中获取缓存的验证码,如果登陆成功则删除Redis中的验证码

UserController注入RedisTemplate对象

@RestController
@Slf4j
@RequestMapping("/user")
public class UserController {
    @Autowired
    private UserService userService;

    @Autowired
    private RedisTemplate redisTemplate;

//    发送手机短信验证码
    @PostMapping("/sendMsg")
    public R<String> sendMsg(@RequestBody User user, HttpSession session){
//        获取手机号
        String phone = user.getPhone();
        if (StringUtils.isNotEmpty(phone)){
//        生成随机的6位验证码
            String code = ValidateCodeUtils.generateValidateCode(6).toString();
            log.info("code = {}", code);   //控制台看验证码
//        调用阿里云提供的短信服务API完成发送短信
//            SMSUtils.sendMessage("瑞吉外卖", "", phone, code);  //模板签名 模板code 电话 验证码
//        需要将生成的验证码保存到session
//            session.setAttribute(phone, code);
//            将生成的验证码缓存到redis中,并且设置有效期为5分钟
            redisTemplate.opsForValue().set(phone, code, 5, TimeUnit.MINUTES);
            return R.success("手机验证码短信发送成功");
        }
        return R.error("短信发送失败");
    }
//    移动端用户登陆
    @PostMapping("/login")
    public R<User> login(@RequestBody Map map, HttpSession session){
        log.info(map.toString());    //map中应该有一个键值对,分别为phone电话号码和code验证码
//        获取手机号
        String phone = map.get("phone").toString();
//        获取验证码
        String code = map.get("code").toString();
//        从session中获取保存的验证码
//        Object codeInSession = session.getAttribute(phone);
//        从redis中获取缓存的验证码
        Object codeInSession = redisTemplate.opsForValue().get(phone);
//        进行验证码的比对(页面提交的验证码和session中保存的验证码比较)
        if (codeInSession != null && codeInSession.equals(code)){
            // 如果能比对成功,说明登陆成功
            LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
            queryWrapper.eq(User::getPhone, phone);
            User user = userService.getOne(queryWrapper);
            if (user == null){
                // 判断当前手机号对应的用户是新用户,是新用户时自动完成注册
                user = new User();
                user.setPhone(phone);
                user.setStatus(1);
                userService.save(user);
            }
            session.setAttribute("user",user.getId());

//            如果用户登陆成功,删除redis中缓存的验证码
            redisTemplate.delete(phone);

            return R.success(user);
        }

        return R.error("登陆失败");

    }
}

2. 缓存菜品数据

前面实现了移动端菜品查看功能,对应的服务端方法为DishController的list方法,此方法会根据前端提交的查询条件进行数据库查询操作。在高并发的情况下,频繁查询数据库会导致系统性能下降,服务端响应时间增长,现在对此方法进行缓存优化,提高系统的性能。具体的实现思路如下:

(1)改造DishController的list方法,先从Redis中获取菜品数据,如果有则直接返回,无需查询数据库;如果没有则查询数据库,并将查询到的菜品数据放入Redis。

(2)改造DishController的save和update方法,加入清理缓存的逻辑。 

注意:在使用缓存的过程中,清理保证数据库中的数据和缓存中的数据一致,如果数据库中的数据发生变化,需要及时清理缓存数据

每点击一次菜品分类,都会查询一次数据库,所以需要按照分类缓存一份菜品的数据。

@RestController
@RequestMapping("/dish")
@Slf4j
public class DishController {
    @Autowired
    private DishService dishService;
    @Autowired
    private DishFlavorService dishFlavorService;
    @Autowired
    private CategoryService categoryService;
    @Autowired
    private RedisTemplate redisTemplate;

//    新增菜品
    @PostMapping
    public R<String> save(@RequestBody DishDto dishDto){   //提交的是JSON数据,@RequestBody注解一定要加
        log.info(dishDto.toString());
        dishService.saveWithFlavor(dishDto);
//        清理所有菜品的缓存数据
//        Set keys = redisTemplate.keys("dish_*");
//        redisTemplate.delete(keys);
//        清理某个分类下面的菜品缓存数据,“1”是菜品的分类,“2”是套餐的分类
        String key = "dish_" + dishDto.getCategoryId() + "_1";
        redisTemplate.delete(key);
        return R.success("新增菜品成功");
    }
//    修改菜品
    @PutMapping
    public R<String> update(@RequestBody DishDto dishDto){   //提交的是JSON数据,@RequestBody注解一定要加
        log.info(dishDto.toString());
        dishService.updateWithFlavor(dishDto);
//        清理所有菜品的缓存数据
        Set keys = redisTemplate.keys("dish_*");
        redisTemplate.delete(keys);
//        清理某个分类下的菜品缓存数据
        String key = "dish_" + dishDto.getCategoryId() + "_1";
        redisTemplate.delete(key);
        return R.success("修改菜品成功");
    }

//    根据条件查询对应的菜品数据
    @GetMapping("/list")
    public R<List<DishDto>> list(Dish dish){
        List<DishDto> dishDtoList = null;
//        动态构造key
        String key = "dish_" + dish.getCategoryId() + "_" + dish.getStatus();   //每一个分类都有一个categoryId
//        先从redis中获取缓存数据
        dishDtoList = (List<DishDto>) redisTemplate.opsForValue().get(key);
        if (dishDtoList != null){
//            //  如果存在,直接返回,无需查询数据库
            return R.success(dishDtoList);
        }

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

        dishDtoList = list.stream().map((item) -> {
            DishDto dishDto = new DishDto();
            BeanUtils.copyProperties(item, dishDto);
            Long categoryId = item.getCategoryId(); //分类id
//            需要注入新的service: private CategoryService categoryService;
//            根据id查询分类对象
            Category category = categoryService.getById(categoryId);
            if (category != null){
                String categoryName = category.getName();
                dishDto.setCategoryName(categoryName);
            }
            Long dishId = item.getId();
            LambdaQueryWrapper<DishFlavor> lambdaQueryWrapper = new LambdaQueryWrapper<>();
            lambdaQueryWrapper.eq(DishFlavor::getDishId, dishId);
//            SQL: select * from dish_flavor where dish_id = ?
            List<DishFlavor> dishFlavorList = dishFlavorService.list(lambdaQueryWrapper);
            dishDto.setFlavors(dishFlavorList);
            return dishDto;
        }).collect(Collectors.toList());
//        如果不存在,则需要查询数据库,将查询到的菜品数据缓存到redis
        redisTemplate.opsForValue().set(key, dishDtoList, 60, TimeUnit.MINUTES);
        return R.success(dishDtoList);
    }
}

九、使用Spring Cache缓存数据

Spring Cache 基础介绍参见:Spring Cache-优快云博客

以上缓存短信验证码和缓存菜品数据都是手动缓存,但使用spring cache 就不用手动实现了,实现方式:在controller方法上加注解。

前面实现了移动端套餐查看功能,对应的服务端方法为SetmealController的list方法,此方法会根据前端提交的查询条件进行数据库查询操作。在高并发的情况下,频繁查询数据库会导致系统性能下降,服务端响应时间增长。现在需要对此方法进行缓存优化,提高系统的性能。

(1)导入Spring Cache和Redis相关Maven坐标

(2)在application.yml中配置缓存数据的过期时间

(3)在启动类上加入@EnableCaching注解,开启缓存注解功能

(4)在SetmealController的list方法上加入@Cacheable注解

(5)在SetmealController的save和delete方法上加入CacheEvict注解

运行项目之前要先开启redis服务 

导入相应的Maven坐标:

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

配置缓存数据的过期时间:

spring:
  application:
    #应用的名称,可选
    name: cache_demo
  datasource:
    druid:
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://localhost:3306/cache_demo?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
      username: root
      password: mysq123
  redis:
    host: 172.17.2.94
    port: 6379
#    password: root@123456
    database: 0
  cache:
    redis:
      time-to-live: 1800000 #设置缓存过期时间,可选,不设置的话永久有效

启动类上加@EnableCaching注解

@ServletComponentScan
@Slf4j
@SpringBootApplication
@EnableTransactionManagement  //启动类开启事务的支持
@EnableCaching
public class ReggieApplication {
    public static void main(String[] args) {
        SpringApplication.run(ReggieApplication.class, args);
        log.info("项目启动成功……");
    }
}

在SetmealController的list方法上加入@Cacheable注解

//    根据条件查询套餐数据
    @Cacheable(value = "setmealCache", key = "#setmeal.categoryId + '_' + #setmeal.status")
    @GetMapping("/list")
    public R<List<Setmeal>> list(Setmeal setmeal){   //因为url参数是拼接字符串,所以不能加@RequestBody
        LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(setmeal.getCategoryId() != null, Setmeal::getCategoryId, setmeal.getCategoryId());
        queryWrapper.eq(setmeal.getStatus() != null, Setmeal::getStatus, setmeal.getStatus());
        queryWrapper.orderByDesc(Setmeal::getUpdateTime);
        List<Setmeal> list = setMealService.list(queryWrapper);
        return R.success(list);
    }

查询套餐时报错:java.lang.IllegalArgumentException: DefaultSerializer requires a Serializable payload but received an object of type [com.xxx.reggie.common.R]

R要实现序列化接口:

@Data
public class R<T> implements Serializable {
    private Integer code; //编码:1成功,0和其它数字为失败
    private String msg; //错误信息
    private T data; //数据
    private Map map = new HashMap(); //动态数据
    public static <T> R<T> success(T object) {
        R<T> r = new R<T>();
        r.data = object;
        r.code = 1;
        return r;
    }
    public static <T> R<T> error(String msg) {
        R r = new R();
        r.msg = msg;
        r.code = 0;
        return r;
    }
    public R<T> add(String key, Object value) {
        this.map.put(key, value);
        return this;
    }
}

在SetmealController的save和delete方法上加入CacheEvict注解

//    新增套餐   allEntries = true表示该分类下的所有套餐的缓存都被删掉
    @PostMapping   
    @CacheEvict(value = "setmealCache", allEntries = true)
    public R<String> save(@RequestBody SetmealDto setmealDto){
        log.info("套餐信息:{}", setmealDto);
        setMealService.saveWithDish(setmealDto);
        return R.success("新增套餐成功");
    }
//    删除套餐
    @DeleteMapping
    @CacheEvict(value = "setmealCache", allEntries = true)
    public R<String> delete(@RequestParam List<Long> ids){
        log.info("ids:{}", ids);
        setMealService.removeWithDish(ids);
        return R.success("套餐数据删除成功");

    }

十、读写分离

主从复制的内容参见:主从复制 master_slave-优快云博客

背景:面对日益增加的系统访问量,数据库的吞吐量面临着巨大瓶颈。面对同一时刻有大量并发读操作和较少写操作类型的应用系统来说,将数据库拆分为主库和从库,主库负责处理事务性的增删改操作,从库负责处理查询操作,能够有效地避免由数据更新导致的行锁,使得整个系统的查询性能得到极大的改善。

Sharding-JDBC介绍:

Sharding-JDBC定位为轻量级Java框架,在Java的jdbc层提供的额外服务。它使用客户端连接数据库,以jar包形式提供服务,无需额外部署和依赖,可以理解为增强版的jdbc驱动,完全兼容jdbc和各种orm框架。

使用Sharding-JDBC可以在程序中轻松实现数据库读写分离。

  • 适用于任何基于JDBC的orm框架,如:JPA,Hibernate,Mybatis,Spring JDBC Template或直接使用JDBC。
  • 支持任何第三方的数据库连接池,如:JDBC,C3P0,BoneCP,Druid,HikariCP等。
  • 支持任意实现JDBC规范的数据库。目前支持MySQL,oracle,SQLserver,PostgreSQL以及任何遵循SQL92标准的数据库。

使用Sharding-JDBC实现读写分离步骤:

1. 导入Maven坐标

        <dependency>
            <groupId>org.apache.shardingsphere</groupId>
            <artifactId>sharding-jdbc-spring-boot-starter</artifactId>
            <version>4.0.0-RC1</version>
        </dependency>

2. 在配置文件中配置读写分离规则

查询时使用从库,增删改时使用主库。

spring:
  shardingsphere:
    datasource:
      names:
        master,slave
      # 主数据源
      master:
        type: com.alibaba.druid.pool.DruidDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://192.168.153.131:3306/rw?characterEncoding=utf-8
        username: root
        password: root
      # 从数据源
      slave:
        type: com.alibaba.druid.pool.DruidDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://192.168.153.129:3306/rw?characterEncoding=utf-8
        username: root
        password: root
    masterslave:
      # 读写分离配置
      load-balance-algorithm-type: round_robin #轮询
      # 最终的数据源名称(bean的名字)
      name: dataSource
      # 主库数据源名称
      master-data-source-name: master
      # 从库数据源名称列表,多个逗号分隔
      slave-data-source-names: slave
    props:
      sql:
        show: true #开启SQL显示(在控制台输出SQL),默认false
  main:
    allow-bean-definition-overriding: true

3. 在配置文件中配置允许bean定义覆盖配置项

启动项目时会报错:

Description:

The bean 'dataSource', defined in class path resource [org/apache/shardingsphere/shardingjdbc/spring/boot/SpringBootConfiguration.class], could not be registered. A bean with that name has already been defined in class path resource [com/alibaba/druid/spring/boot/autoconfigure/DruidDataSourceAutoConfigure.class] and overriding is disabled.

Action:

Consider renaming one of the beans or enabling overriding by setting spring.main.allow-bean-definition-overriding=true

解决办法:

在application.yml中加以下配置,即允许bean定义覆盖(和shardingsphere对齐)

  main:
    allow-bean-definition-overriding: true

在master主库创建rw数据库,在rw数据库中创建user表,并在user表中设置以下字段:

打开从库看到也会生成一样的库和表。

运行测试代码可以看到:

主库master主要处理新增、删除、修改操作;

从库slave主要处理查询操作。

项目实现读写分离:

1. 数据库环境准备(主从复制):

在主库中创建瑞吉外卖项目的业务数据库reggie并导入相关表结构和数据

在主库运行SQL语句创建表和需要的数据

2. 代码改造:

(1)导入Maven坐标

        <dependency>
            <groupId>org.apache.shardingsphere</groupId>
            <artifactId>sharding-jdbc-spring-boot-starter</artifactId>
            <version>4.0.0-RC1</version>
        </dependency>

(2)在配置文件中配置读写分离规则和允许bean定义覆盖配置项,并删除之前原本存在的数据源

server:
  port: 8080
spring:
  application:
    name: reggie_take_out
  shardingsphere:
    datasource:
      names:
        master,slave
      # 主数据源
      master:
        type: com.alibaba.druid.pool.DruidDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://192.168.153.131:3306/reggie?characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
        username: root
        password: root
      # 从数据源
      slave:
        type: com.alibaba.druid.pool.DruidDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://192.168.153.129:3306/reggie?characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
        username: root
        password: root
    masterslave:
      # 读写分离配置
      load-balance-algorithm-type: round_robin #轮询
      # 最终的数据源名称(bean的名字)
      name: dataSource
      # 主库数据源名称
      master-data-source-name: master
      # 从库数据源名称列表,多个逗号分隔
      slave-data-source-names: slave
    props:
      sql:
        show: true #开启SQL显示(在控制台输出SQL),默认false
  main:
    allow-bean-definition-overriding: true
  redis:
    host: localhost
    port: 6379
#    password: 123456
    database: 0
  cache:
    redis:
      time-to-live: 1800000 #设置缓存过期时间,可选
mybatis-plus:
  configuration:
    #在映射实体或者属性时,将数据库中表名和字段名中的下划线去掉,按照驼峰命名法映射
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      id-type: ASSIGN_ID
reggie:
  path: D:\ProImg\

十一、负载均衡

十二、项目部署

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值