黑马程序员 Java 项目《瑞吉外卖》

news2024/10/7 14:29:41

教程链接:https://www.bilibili.com/video/BV13a411q753
Gitee 仓库:https://gitee.com/weixinyang1980/reggie_take_out
运行视频:
瑞吉外卖后台运行视频
瑞吉外卖用户端运行视频

目录

        • 开发环境搭建
          • 数据库环境搭建
          • Maven 项目搭建
        • 后台登录功能开发
          • 需求分析
          • 代码开发
          • 功能测试
        • 后台退出功能开发
          • 需求分析
          • 代码开发
          • 功能测试
        • 分析后台系统首页构成和效果展示方式
        • 完善登录功能
          • 问题分析
          • 代码实现
            • 过滤器的具体处理逻辑如下:
        • 新增员工
          • 需求分析
          • 数据模型
          • 代码开发
          • 总结
        • 员工信息分页查询
          • 需求分析
          • 代码开发
          • 功能测试
        • 启用/禁用员工账号
          • 需求分析
          • 代码开发
          • 代码修复
          • 功能测试
        • 编辑员工信息
          • 需求分析
          • 代码开发
          • 功能测试
        • 公共字段自动填充
          • 问题分析
          • 代码实现
          • 功能完善
        • 新增分类
          • 需求分析
          • 数据模型
          • 代码开发
        • 分类信息分页查询
          • 需求分析
          • 代码开发
        • 删除分类
          • 需求分析
          • 代码开发
          • 功能完善
        • 修改分类
        • 文件上传下载
          • 文件上传介绍
          • 文件下载介绍
          • 文件上传代码实现
          • 文件下载代码实现
        • 新增菜品
          • 需求分析
          • 数据模型
          • 代码开发 - 准备工作
          • 代码开发 - 梳理交互过程
          • 代码开发 - 查询分类数据
          • 代码开发 - 接收页面提交的数据
          • 代码开发 - 导入 DTO
          • 代码开发 - 保存数据到菜品表和菜品口味表
        • 菜品信息分页查询
          • 需求分析
          • 代码开发
          • 功能测试
        • 修改菜品
          • 需求分析
          • 代码开发 - 梳理交互过程
          • 代码开发 - 根据 id 查询菜品信息和对应的口味信息
          • 功能测试
          • 代码开发 - 修改菜品信息与口味信息
          • 功能测试
        • 新增套餐
          • 需求分析
          • 数据模型
          • 代码开发 - 准备工作
          • 代码开发 - 梳理交互过程
          • 代码开发 - 根据分类查询菜品
          • 代码开发 - 服务端接收页面提交的数据
        • 套餐信息分页查询
          • 需求分析
          • 代码开发
        • 删除套餐
          • 需求分析
          • 代码开发
        • 短信发送
          • 阿里云短信服务
          • 代码开发
        • 手机验证码登录
          • 需求分析
          • 数据模型
          • 代码开发 - 梳理交互过程
          • 代码开发 - 准备工作
          • 代码开发 - 修改 LoginCheckFilter
          • 代码开发 - 发送验证码短信
          • 代码开发 - 登录校验
        • 导入用户地址簿相关功能代码
          • 需求分析
          • 数据模型
          • 导入功能代码
          • 功能测试
        • 菜品展示
          • 需求分析
          • 代码开发 - 梳理交互过程
          • 代码开发 - 修改 DishController 的 list 方法
          • 代码开发 - 创建 SetmealController 的 list 方法
        • 购物车
          • 需求分析
          • 数据模型
          • 代码开发 - 梳理交互过程
          • 代码开发 - 准备工作
          • 代码开发 - 添加购物车
          • 代码开发 - 查看购物车
          • 代码开发 - 清空购物车
        • 用户下单
          • 需求分析
          • 数据模型
          • 代码开发 - 梳理交互过程
          • 代码开发
          • 功能测试
        • 缓存优化环境搭建
          • maven 坐标
          • 配置文件
          • 配置类
        • 缓存短信验证码
          • 实现思路 & 代码改造
          • 功能测试
        • 缓存菜品数据
          • 实现思路
          • 缓存菜品数据代码改造 & 功能测试
          • 清理缓存代码改造 & 功能测试
        • Spring Cache
          • Spring Cache 介绍
          • Spring Cache 常用注解
            • CachePut 注解
            • CacheEvict 注解
            • Cacheable 注解
            • 使用 Redis 作为缓存技术
        • 缓存套餐数据
          • 实现思路
        • MySQL 主从复制
          • 介绍
          • 配置
          • 测试
        • 读写分离案例
          • 背景
          • Sharding-JDBC 介绍
          • 入门案例
        • 项目实现读写分离
          • 数据库环境准备(主从复制)
          • 代码改造
          • 测试
        • Nginx
          • Nginx 概述
          • Nginx 命令
          • Nginx 配置文件结构
          • Nginx 具体应用
            • 部署静态资源
            • 反向代理
            • 负载均衡
        • 前后端分离开发
          • 介绍
          • 开发流程
          • 前端技术栈
        • YApi
          • 介绍
          • 使用方式
        • Swagger
          • 介绍
          • 使用方式
          • 常用注解
        • 项目部署
          • 部署架构
          • 部署环境说明
          • 部署前端项目
          • 部署后端项目

开发环境搭建

数据库环境搭建

(1) 创建项目对应的数据库(图形界面或命令行都可以)
请添加图片描述
(2) 导入表结构 (资料/数据模型/db_reggie.sql)
请添加图片描述
通过命令导入表结构时,注意 sql 文件不要放在中文目录中!
请添加图片描述
(3) 数据表
请添加图片描述

Maven 项目搭建

(1) 创建 Maven 项目 reggie_take_out
创建完项目后,注意检查项目的编码、Maven 仓库配置、jdk 配置等
(2) 导入 pom 文件

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

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

    <groupId>com.weixinyang</groupId>
    <artifactId>reggie_take_out</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <java.version>1.8</java.version>
    </properties>
    <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>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.1.23</version>
        </dependency>

    </dependencies>

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

</project>

(3) springBoot 配置文件 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: 12345678
mybatis-plus:
  configuration:
  	# user_name ---> userName
    # 在映射实体或者属性时,将数据库中表名和字段名中的下划线去掉,按照驼峰命名法映射
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      id-type: ASSIGN_ID

(4) 编写启动类
请添加图片描述

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

(4) 导入前端界面
将 资料/前端资源 下的 backend 和 front 复制到 resources 文件夹下:
请添加图片描述
(5) 配置静态资源映射
请添加图片描述

@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) 登录页面展示
http://localhost:8080/backend/page/login/login.html
请添加图片描述
(3) 查看登录请求信息
通过浏览器调试工具(F12), 可以发现, 点击登录按钮时,页面会发送请求(请求地址为 http://localhost:8080/employee/login,并提交参数 username 和 password)。
此时报 404,是因为后台系统还没有响应此请求的处理器,所以我们需要创建相关类来处理登录请求。
请添加图片描述
(4) 数据模型(employee 表)
请添加图片描述
(5) handleLogin 方法
请添加图片描述

代码开发

(1) 创建实体类 Employee, 和 employee 表进行映射:

package com.weixinyang.reggie.entity;

import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * 员工实体
 */
@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;            // 身份证号码

    private Integer status;

    private LocalDateTime createTime;

    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
此类是一个通用结果类,服务端响应的所有结果最终都会包装成此种类型返回给前端页面。

package com.weixinyang.reggie.common;

import lombok.Data;
import java.util.HashMap;
import java.util.Map;

/**
 * 通用返回结果,服务端响应的数据最终都会封装成此对象
 * @param <T>
 */
@Data
public class R<T> {

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

}

(4) 在 Controller 中创建登录方法:
请添加图片描述

    /**
     * 员工登录
     * @param request
     * @param employee
     * @return
     */
    @PostMapping("/login")
    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());
        Employee emp = employeeService.getOne(queryWrapper);

        // 3. 如果没有查询到则返回登录失败结果
        if (emp == null) {
            return R.error("登录失败");
        }

        // 4. 密码比对,如果不一致则返回登录失败结果
        if (!emp.getPassword().equals(password)) {
            return R.error("登录失败");
        }

        // 5. 查看员工状态,如果为已禁用状态,则返回员工已禁用结果
        if (emp.getStatus() == 0) {
            return R.error("账号已禁用");
        }

        // 6. 登录成功,将员工 id 存入 Session 并返回登录成功结果
        request.getSession().setAttribute("employee", emp.getId());
        return R.success(emp);
    }
功能测试

加断点:
请添加图片描述
错误密码测试:
请添加图片描述
单步调试:
请添加图片描述
放行:请添加图片描述
超时:
请添加图片描述
request.js 中:
请添加图片描述
浏览器清缓存:
请添加图片描述
调试放行,登录成功:
请添加图片描述

请添加图片描述

请添加图片描述
用户名不存在或密码错误:
请添加图片描述
把 status 改为 0:
请添加图片描述
请添加图片描述
请添加图片描述
SpringMvc 将 R 对象转成的 json。

后台退出功能开发

需求分析

员工登录成功后,页面跳转到后台系统首页面 (backend/index.html), 此时会显示当前登录用户的姓名:请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述

请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述

代码开发
    /**
     * 员工退出
     * @param request
     * @return
     */
    @PostMapping("/logout")
    public R<String> logout(HttpServletRequest request) {
        // 清理 Session 中保存的当前登录员工的 id
        request.getSession().removeAttribute("employee");
        return R.success("退出成功");
    }
功能测试

请添加图片描述

回到主页面, 移除 userInfo。

分析后台系统首页构成和效果展示方式

请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述

v-if 不会成立,执行 v-else
请添加图片描述
请添加图片描述
iframeUrl 初始值:
请添加图片描述
请添加图片描述

完善登录功能

问题分析

前面已经完成了后台系统的员工登录功能开发,但是还存在一个问题:用户如果不登录,直接访问系统首页面,照样可以正常访问。
这种设计并不合理,我们希望看到的效果是,只有登录成功才能访问系统中的页面,如果没有登录则调整到登录页面。
使用过滤器或拦截器,在过滤器或拦截器中判断用户是否已经完成登录,如果没有登录则跳转到登录页面。

代码实现

(1) 创建自定义过滤器 LoginCheckFilter
(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();

        // 2. 定义不需要处理的请求路径
        String[] urls = new String[]{
                "/employee/login",
                "/employee/logout",
                "/backend/**",
                "/front/**"
        };

        // 2. 判断本次请求是否需要处理
        boolean check = check(urls, requestURI);

        // 3. 如果不需要处理,则直接放行
        if (check) {
            filterChain.doFilter(request, response);
            return;
        }

        // 4. 判断登录状态,如果已登录,则直接放行
        if (request.getSession().getAttribute("employee") != null) {
            filterChain.doFilter(request, response);
            return;
        }

        // 5. 如果未登录则返回未登录结果, 通过输出流方式向客户端页面响应数据
        response.getWriter().write(JSON.toJSONString(R.error("NOTLOGIN")));
        return;

//        log.info("拦截到请求: {}", request.getRequestURI());
//        filterChain.doFilter(request, response);
    }

    /**
     * 路径匹配,检查本次请求是否需要放行
     * @param urls
     * @param requestURI
     * @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;
    }
}

新增员工

需求分析

后台系统可以管理员工信息,通过新增员工来添加后台系统用户。点击 [添加员工] 按钮跳转到新增页面。如下:
请添加图片描述

数据模型

新增员工,其实就是将我们页面录入的员工数据插入到 employee 表。需要注意,employee 表中对 username 字段加入了唯一约束,因为 username 是员工的登录账号,必须是唯一的。
请添加图片描述
employee 表中 status 字段已经设置了默认值 1, 表示状态正常。
请添加图片描述

代码开发

在开发代码之前,需要梳理一下整个程序的执行过程:
(1) 页面发送 ajax 请求,将新增员工页面中输入的数据以 json 格式提交到服务端;
(2) 服务端 Controller 接受页面提交的数据并调用 Service 将数据进行保存;
(3) Service 调用 Mapper 操作数据库,保存数据;
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
确保能够请求到这个方法,并且参数能够封装:
请添加图片描述
请添加图片描述
完整代码:

    /**
     * 新增员工
     * @param employee
     * @return
     */
    @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);
        return R.success("新增员工成功");
    }

测试新增成功:
请添加图片描述
前面的程序还存在一个问题,就是当新增员工时输入的账号已经存在,由于 employee 表中对该字段加了唯一约束,此时程序就会抛出异常:
再次添加 zhangsan:
请添加图片描述
此时需要进行异常捕获,通常有以下两种方式:
(1) 在 Controller 方法中加入 try、catch 进行异常捕获;
(2) 使用异常处理器进行全局异常捕获;
请添加图片描述
确保能够请求到这个方法,并且参数能够封装:
请添加图片描述
请添加图片描述
请添加图片描述
完整代码:

/**
 * 全局异常处理
 */
@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("未知错误");
    }
}

请添加图片描述

总结

(1) 根据产品原型明确业务需求;
(2) 重点分析数据的流转过程和数据格式;
(3) 通过 debug 断点调试跟踪程序执行过程;
请添加图片描述

员工信息分页查询

需求分析

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

代码开发

在开发代码之前,需要梳理一下整个程序的执行过程:
(1) 页面发送 ajax 请求, 将分页查询参数 (page、pageSize、name) 提交到服务端;
(2) 服务端 Controller 接收页面提交的数据并调用 Service 查询数据;
(3) Service 调用 Mapper 操作数据库,查询分页数据;
(4) Controller 将查询到的分页数据响应给页面;
(5) 页面接收分页数据并通过 ElementUI 的 Table 组件展示到页面上;
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
(1) 配置 MP 的分页插件
请添加图片描述

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

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor();
        mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor());
        return mybatisPlusInterceptor;
    }
}

(2) 确定返回值与参数
请添加图片描述

请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述

请添加图片描述
请添加图片描述
(3) 完整代码

    /**
     * 员工信息分页查询
     * @param page
     * @param pageSize
     * @param name
     * @return
     */
    @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.isEmpty(name), Employee::getName, name);
        // 添加排序条件
        queryWrapper.orderByDesc(Employee::getUpdateTime);

        // 执行查询
        employeeService.page(pageInfo, queryWrapper);

        return R.success(pageInfo);
    }
功能测试

(1) 没有 name 的查询
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
放行:
请添加图片描述
格式化 json:
https://www.bejson.com/

{
	"code": 1,
	"msg": null,
	"data": {
		"records": [{
			"id": 1613093405972250626,
			"username": "zhangsan",
			"name": "张三",
			"password": "e10adc3949ba59abbe56e057f20f883e",
			"phone": "18923915286",
			"sex": "1",
			"idNumber": "111222333444555666",
			"status": 1,
			"createTime": [2023, 1, 11, 16, 40, 12],
			"updateTime": [2023, 1, 11, 16, 40, 12],
			"createUser": 1,
			"updateUser": 1
		}, {
			"id": 1,
			"username": "admin",
			"name": "管理员",
			"password": "e10adc3949ba59abbe56e057f20f883e",
			"phone": "13812312312",
			"sex": "1",
			"idNumber": "110101199001010047",
			"status": 1,
			"createTime": [2021, 5, 6, 17, 20, 7],
			"updateTime": [2021, 5, 10, 2, 24, 9],
			"createUser": 1,
			"updateUser": 1
		}],
		"total": 2,
		"size": 10,
		"current": 1,
		"orders": [],
		"optimizeCountSql": true,
		"hitCount": false,
		"countId": null,
		"maxLimit": null,
		"searchCount": true,
		"pages": 1
	},
	"map": {}
}

请添加图片描述
(2) 有 name 的查询
请添加图片描述

请添加图片描述
没有查到数据
请添加图片描述
(3) 更改每页显示的信息条数
请添加图片描述

请添加图片描述
发现显示有问题的时候清一下浏览器缓存:
请添加图片描述
(4) 点击这两个地方都会发送请求:
请添加图片描述
请添加图片描述

请添加图片描述
请添加图片描述
请添加图片描述
(5) 账号状态本来是 Integer 类型,在前端转成了 String 类型:
请添加图片描述

请添加图片描述
请添加图片描述
请添加图片描述
改成这样:
请添加图片描述
请添加图片描述

启用/禁用员工账号

需求分析

在员工管理列表页面,可以对某个员工账号进行启用或禁用操作。账号禁用的员工不能登录系统,启用后的员工可以正常登录。
需要注意,只有管理员 (admin 用户) 可以对其他普通用户进行启用、禁用操作,所以普通用户登录后启用、禁用按钮不显示。
管理员 admin 登录系统可以对所有员工账号进行 禁用、启用 操作:
如果某个员工状态为正常,则按钮显示为 禁用;
如果员工状态为已禁用,则按钮显示为 启用;
请添加图片描述
普通员工登录系统后,启用、禁用按钮不显示。
请添加图片描述

代码开发

页面中是怎么做到只有管理员 admin 能够看到启用、禁用按钮的?
请添加图片描述
请添加图片描述
请添加图片描述
如果把 if 拿掉:
请添加图片描述
ctrl + f5 刷新(清缓存)
普通用户也显示 启用、禁用 按钮:
请添加图片描述
在开发代码之前,需要梳理一下整个程序的执行过程:
(1) 页面发送 ajax 请求,将参数 (id, status) 提交到服务端;
(2) 服务端 Controller 接收页面提交的数据并调用 Service 更新数据;
(3) Service 调用 Mapper 操作数据库;
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述

启用、禁用员工账号,本质上是一个更新操作,也就是对 status 状态字段进行操作。
在 Controller 中创建 update 方法,此方法是一个通用的修改员工信息的方法。
请添加图片描述
请添加图片描述
请添加图片描述
测试过程中没有报错,但是功能却没有实现,查看数据库中数据也没有变化。
没有更新成功(观察控制台输出的 SQL) :
请添加图片描述
请添加图片描述
仔细观察 id 的值,和数据库中对应记录的 id 值并不相同:
请添加图片描述
分页查询时服务端响应给页面的数据中 id 的值为 19 位数字,类型为 long,说明没有问题。
请添加图片描述
页面中 js 处理 long 型数据只能精确到前 16 位,所以最终通过 ajax 请求提交给服务端的时候 id 变为了1613093405972250600。
演示:
请添加图片描述
请添加图片描述

如何解决这个问题?
我们可以在服务端给页面响应 json 数据时进行处理,将 long 型数据统一转换为 String 字符串,效果如下:
请添加图片描述

代码修复

具体实现步骤:
(1) 提供对象转换器 JacksonObjectMapper, 基于 Jackson 进行 Java 对象到 json 数据的转换;
(2) 在 WebMvcConfig 配置类中扩展 Spring MVC 的消息转换器,在此消息转换器中使用提供的对象转换器进行 Java 对象到 json 数据的转化;
请添加图片描述

/**
 * 对象映射器:基于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);
    }
}
    /**
     * 扩展 mvc 框架的消息转换器
     * @param converters
     */
    @Override
    protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
        // 创建消息转换器对象
        MappingJackson2HttpMessageConverter messageConverter = new MappingJackson2HttpMessageConverter();
        // 设置对象转换器,底层使用 Jackson 将 java 对象转换为 json
        messageConverter.setObjectMapper(new JacksonObjectMapper());
        // 将上面的消息转换器对象追加到 mvc 框架的转换器集合中
        converters.add(0, messageConverter);
    }
功能测试

请添加图片描述
默认的 7 个转换器:
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
测试禁用:
请添加图片描述
请添加图片描述
测试启用:
请添加图片描述
请添加图片描述

编辑员工信息

需求分析

在员工管理列表页面点击编辑按钮,跳转到编辑页面,在编辑页面回显员工信息并进行修改,最后点击保存按钮完成编辑操作。
请添加图片描述

代码开发

在开发代码之前需要梳理一下操作过程和对应的程序的执行流程:
(1) 点击编辑按钮,页面跳转到 add.html,并在 url 中携带参数[员工id];
(2) 在 add.html 页面获取 url 中的参数[员工id];
(3) 发送 ajax 请求,请求服务端,同时提交员工 id 参数;
(4) 服务端接收请求,根据员工 id 查询员工信息,将员工信息以 json 形式响应给页面;
(5) 页面接收服务端响应的 json 数据,通过 vue 的数据绑定进行员工信息回显;
(6) 点击保存按钮,发送 ajax 请求,将页面中的员工信息以 json 方式提交给服务端;
(7) 服务端接收员工信息,并进行处理,完成后给页面响应;
(8) 页面接收到服务端响应信息后进行相应处理;
注意 add.html 页面为公共页面,新增员工和编辑员工都是在此页面操作;
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述

    /**
     * 根据 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("没有查询到对应员工信息");
    }
功能测试

实现回显:
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
调用 update 方法:
请添加图片描述

公共字段自动填充

问题分析

前面我们已经完成了后台系统的员工管理功能开发,在新增员工时需设置创建时间、创建人、修改时间、修改人等字段,在编辑员工是需设置修改时间和修改人等字段。这些字段属于公共字段,也就是很多表中都有这些字段,如下:
请添加图片描述
请添加图片描述
能不能对这些公共字段在某个地方统一处理来简化开发呢?答案是使用 Mybatisplus 提供的公共字段自动填充功能。

代码实现

MybatisPlus 公共字段自动填充,也就是在插入或更新的时候为指定字段赋予指定的值,使用它的好处就是可以统一对这些字段进行处理,避免了重复代码:
实现步骤:
(1) 在实体类的属性上加上 @TableField 注解,指定自动填充的策略;
(2) 按照框架要求编写元数据对象处理器,在此类中统一为公共字段赋值,此类需要实现 MetaObjectHandler 接口;

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

请添加图片描述
请添加图片描述
请添加图片描述
注释掉这 4 行:
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
放行报错:
请添加图片描述

请添加图片描述
请添加图片描述
完整代码:

    /**
     * 插入操作,自动填充
     * @param metaObject
     */
    @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", new Long(1));
        metaObject.setValue("updateUser", new Long(1));
    }

    /**
     * 更新操作,自动填充
     * @param metaObject
     */
    @Override
    public void updateFill(MetaObject metaObject) {
        log.info("公共字段自动填充[update]...");
        log.info(metaObject.toString());
        metaObject.setValue("updateTime", LocalDateTime.now());
        metaObject.setValue("updateUser", new Long(1));
    }

测试:请添加图片描述
注释掉这 3 行:
请添加图片描述
测试:
请添加图片描述

功能完善

前面我们已经完成了公共字段自动填充的代码开发,但是还有一个问题没解决,就是我们在自动填充 createUser 和 updateUser 时设置的用户 id 是固定值,现在我们需要改造成动态获取当前登录用户的 id。
有的同学可能想到,用户登录成功后我们将 用户 id 存入了 HttpSession 中,现在我从 HttpSession 中获取不就行了?
注意我们在 MyMetaObjectHandler 类中是不能获取 HttpSession 对象的,所以我们需要通过其他方式来获取用户 id。可以使用 ThreadLocal 来解决此问题,它是 JDK 中提供的一个类。
在学习 ThreadLocal 之前,我们需要先确认一件事情,就是客户端发送的 每次 http 请求,对应的服务端都会分配一个新的线程来处理,在处理过程中涉及到下面类中的方法都属于相同的一个进程:
(1) LoginCheckFilter 的 doFilter 方法;
(2) EmployeeController 的 update 方法;
(3) MyMetaObjectHandler 的 updateFill 方法;
可以在上面的三个方法中分别加入下面代码(获取当前线程 id) :
请添加图片描述
执行编辑员工功能进行验证,通过观察控制台输出可以发现,一次请求对应的线程 id 是相同的:
请添加图片描述
什么是 ThreadLocal ?
ThreadLocal 并不是一个 Thread, 而是 Thread 的局部变量。当使用 ThreadLocal 维护变量时,ThreadLocal 为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立改变自己的副本,而不会影响其它线程对应的副本。
ThreadLocal 为每个线程提供单独一份存储空间,具有线程隔离的效果,只有在线程内才能获取对应的值,线程外则不能访问。
ThreadLocal 常用方法:

  • public void set (T value)     设置当前线程的线程局部变量的值
  • public T get ()              返回当前线程所对应的线程局部变量的值

我们可以在 LoginCheckFilter 的 doFilter 方法中获取当前登录用户 id,并调用 ThreadLocal 的 set 方法来设置当前线程的线程的局部变量的值(用户 id),然后在 MyMetaObjectHandler 的 updateFill 方法中调用 ThreadLocal 的 get 方法来获取当前线程所对应的线程局部变量的值(用户 id)。
实现步骤:
(1) 编写 BaseContext 工具类,基于 ThreadLocal 封装的工具类;

/**
 * 基于 ThreadLocal 封装工具类,用于保存和获取当前登录用户 id
 */
public class BaseContext {
    private static ThreadLocal<Long> threadLocal = new ThreadLocal<>();

    /**
     * 设置值
     * @param id
     */
    public static void setCurrentId(Long id) {
        threadLocal.set(id);
    }

    /**
     * 获取值
     * @return
     */
    public static Long getCurrentId() {
        return threadLocal.get();
    }
}

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

        // 4. 判断登录状态,如果已登录,则直接放行
        if (request.getSession().getAttribute("employee") != null) {
            log.info("用户已登录,用户 id 为: {}", request.getSession().getAttribute("employee"));

            Long empId = (Long)request.getSession().getAttribute("employee");
            BaseContext.setCurrentId(empId);
            long id = Thread.currentThread().getId();
            log.info("线程 id 为: {}", id);

            filterChain.doFilter(request, response);
            return;
        }

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

    /**
     * 更新操作,自动填充
     * @param metaObject
     */
    @Override
    public void updateFill(MetaObject metaObject) {
        log.info("公共字段自动填充[update]...");
        log.info(metaObject.toString());

        long id = Thread.currentThread().getId();
        log.info("线程 id 为: {}", id);

        metaObject.setValue("updateTime", LocalDateTime.now());
        metaObject.setValue("updateUser", BaseContext.getCurrentId());
    }

测试:
请添加图片描述
请添加图片描述请添加图片描述

请添加图片描述
请添加图片描述

新增分类

需求分析

后台系统可以管理分类信息,分类包括两种类型,分别是菜品分类和套餐分类。当我们在后台系统中添加菜品时需要选择一个菜品分类,当我们在后台系统添加一个套餐时需要选择一个套餐分类,在移动端也会按照菜品分类和套餐分类来展示对应的菜品和套餐。
请添加图片描述
可以在后台系统的分类管理页面分别添加菜品分类和套餐分类,如下:

请添加图片描述

数据模型

新增分类,其实就是将我们新增窗口录入的分类数据插入到 category 表, 表结构如下:
请添加图片描述
需要注意, category 表中对 name 字段加入唯一约束,保证分类的名称是唯一的:
请添加图片描述

代码开发

在开发业务功能之前需要先将用到的类和接口基本结构创建好:
实体类 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")
public class CategoryController {
    @Autowired
    private CategoryService categoryService;
}

在开发代码之前,需要梳理一个整个程序的执行过程:
(1) 页面 (backend/page/category/list.html) 发送 ajax 请求,将新增分类窗口输入的数据以 json 形式提交到服务端;
(2) 服务端 Controller 接收页面提交的数据并调用 Service 将数据进行保存;
(3) Service 调用 Mapper 操作数据库,保存数据;
可以看到新增菜品分类和新增套餐分类请求的服务端地址和提交的 json 数据结构相同,所以服务端只需要提供一个方法统一处理即可:
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
完整代码:

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

请添加图片描述
请添加图片描述

请添加图片描述

分类信息分页查询

需求分析

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

代码开发

在开发代码之前需要梳理一下整个程序的执行过程:
(1) 页面发送 ajax 请求,将分类查询的参数 (page、pageSize) 提交到服务端;
(2) 服务端 Controller 接收页面提交的数据并调用 Service 查询数据;
(3) Service 调用 Mapper 操作数据库,查询分页数据;
(4) Controller 将查询到的分页数据响应给页面;
(5) 页面接收到分页数据并通过 ElementUI 的 Table 组件展示到页面上;
请添加图片描述
请添加图片描述
请添加图片描述
完整代码:

    /**
     * 分页查询
     * @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) 页面发送 ajax 请求,将参数 (id) 提交到服务端;
(2) 服务端 Controller 接收页面提交的数据并调用 Service 删除数据;
(3) Service 调用 Mapper 操作数据库;
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述

    /**
     * 根据 id 删除分类
     * @param id
     * @return
     */
    @DeleteMapping
    public R<String> delete(Long id) {
        log.info("删除分类: id 为:{}", id);
        categoryService.removeById(id);
        return R.success("分类信息删除成功");
    }

测试:
请添加图片描述
请添加图片描述
请添加图片描述

功能完善

前面我们已经完成根据 id 删除分类的功能,但是并没有检查删除的分类是否关联了菜品或者套餐,所以我们需要进行功能完善。
要完善分类删除功能,需要先准备基础的类和接口:
(1) 实体类 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;
}

(2) Mapper 接口 DishMapper 和 SetmealMapper;

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

(3) Service 接口 DishService 和 SetmealService;

public interface DishService extends IService<Dish> {
}
public interface SetmealService extends IService<Setmeal> {
}

(4) Service 实现类 DishServiceImpl 和 SetmealServiceImpl;

@Service
@Slf4j
public class DishServiceImpl extends ServiceImpl<DishMapper, Dish> implements DishService {
}
@Service
@Slf4j
public class SetmealServiceImpl extends ServiceImpl<SetmealMapper, Setmeal> implements SetmealService {
}

请添加图片描述
大体框架:

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

    @Autowired
    private DishService dishService;

    @Autowired
    private SetmealService setmealService;

    /**
     * 根据 id 删除分类,删除之前需要进行判断
     * @param 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) {
            // 已经关联菜品,抛出一个业务异常
        }

        // 查询当前分类是否关联了套餐,如果已经关联,抛出一个业务异常
        LambdaQueryWrapper<Setmeal> setmealLambdaQueryWrapper = new LambdaQueryWrapper<>();
        // 添加查询条件,根据分类 id 进行查询
        setmealLambdaQueryWrapper.eq(Setmeal::getCategoryId, id);
        int count2 = setmealService.count();
        if (count2 > 0) {
            // 已经关联套餐,抛出一个业务异常
        }

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

自定义业务异常类:

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

全局异常处理器:

    /**
     * 异常处理方法
     * @return
     */
    @ExceptionHandler(CustomException.class)
    public R<String> exceptionHandler(CustomException ex) {
        log.error(ex.getMessage());

        return R.error(ex.getMessage());
    }

完整代码:

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

    @Autowired
    private DishService dishService;

    @Autowired
    private SetmealService setmealService;

    /**
     * 根据 id 删除分类,删除之前需要进行判断
     * @param 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<>();
        // 添加查询条件,根据分类 id 进行查询
        setmealLambdaQueryWrapper.eq(Setmeal::getCategoryId, id);
        int count2 = setmealService.count();
        if (count2 > 0) {
            // 已经关联套餐,抛出一个业务异常
            throw new CustomException("当前分类下关联了套餐,不能删除");
        }

        // 正常删除分类
        super.removeById(id);
    }
}
    /**
     * 根据 id 删除分类
     * @param id
     * @return
     */
    @DeleteMapping
    public R<String> delete(Long id) {
        log.info("删除分类: id 为:{}", id);
        categoryService.remove(id);
        return R.success("分类信息删除成功");
    }

测试:
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述

请添加图片描述

请添加图片描述

修改分类

在分类管理列表页面点击修改按钮,弹出修改窗口,在修改窗口回显分类信息并进行修改,最后点击确定按钮完成修改操作:
请添加图片描述
回显:
请添加图片描述
请添加图片描述
模型数据:
请添加图片描述
双向绑定:
请添加图片描述
点确定:
请添加图片描述
请添加图片描述

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

测试:
请添加图片描述
请添加图片描述
请添加图片描述

文件上传下载

文件上传介绍

文件上传,也称为 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>

目前一些前端组件库也提供了相应的上传组件,但是底层原理还是基于 form 表单的文件上传,例如 ElementUI 中提供的 upload 上传组件:
请添加图片描述
服务端要接收客户端页面上传的文件,通常会使用 Apache 的两个组件:
commons-fileupload
commons-io
Spring 框架在 spring-web 包中对文件上传进行了封装,大大简化了服务端代码,我们只需要在 Controller 的方法中声明一个 MultipartFile 类型的参数即可接收上传的文件:
请添加图片描述

文件下载介绍

文件下载,也称为 download, 是指将文件从服务端传输到本地计算机的过程。
通过浏览器进行文件下载,通常有两种表现形式:
以附件形式下载,弹出保存对话框,将文件保存到指定磁盘目录
直接在浏览器中打开
通过浏览器进行文件下载,本质上就是服务端将文件以流的形式写回浏览器的过程。

文件上传代码实现

文件上传,页面就可以使用 ElementUI 提供的上传组件:
请添加图片描述
请添加图片描述

请添加图片描述

请添加图片描述
动态生成标签:
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
放行,临时文件就不在了:
请添加图片描述
请添加图片描述

请添加图片描述
请添加图片描述
请添加图片描述

请添加图片描述
请添加图片描述

请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述

请添加图片描述
完整代码:

/**
 * 文件上传及下载
 */
@Slf4j
@RestController
@RequestMapping("/common")
public class CommonController {

    @Value("${reggie.path}")
    private String basePath;
    /**
     * 文件上传
     * @param file
     * @return
     */
    @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() + 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);
    }
}

reggie:
  path: D:\img\
文件下载代码实现

请添加图片描述
请添加图片描述

完整代码:

	/**
     * 文件下载
     * @param name
     * @param response
     */
    @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();
        }

    }

测试:
请添加图片描述
请添加图片描述
请添加图片描述

新增菜品

需求分析

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

数据模型

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

dish_flavor 菜品口味表
请添加图片描述

代码开发 - 准备工作

在开发业务功能之前,先将需要用到的类和接口基本结构创建好:
实体类 DishFlavor

/**
菜品口味
 */
@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 接口 DishFlavorMapper

@Mapper
public interface DishFlavorMapper extends BaseMapper<DishFlavor> {
}

业务层接口 DishFlavorService

public interface DishFlavorService extends IService<DishFlavor> {
}

业务层实现类 DishFlavorServiceImpl

@Service
public class DishFlavorServiceImpl extends ServiceImpl<DishFlavorMapper, DishFlavor> implements DishFlavorService {
}

控制层 DishController

/**
 * 菜品管理
 */
@RestController
@RequestMapping("/dish")
public class DishController {
    @Autowired
    private DishService dishService;
    @Autowired
    private DishFlavorService dishFlavorService;
}
代码开发 - 梳理交互过程

页面 (backend/page/food/add.html) 发送 ajax 请求,请求服务端获取菜品分类并展示到下拉框中;
请添加图片描述
页面发送请求进行图片上传,请求服务端将图片保存到服务器;(已实现)
页面发送请求进行图片下载,将上传的图片进行回显;(已实现)
点击保存按钮,发送 ajax 请求,将菜品相关数据以 json 形式提交到服务端;
开发新增菜品功能,其实就是在服务端编写代码去编写前端页面发送的这 4 次请求即可。

代码开发 - 查询分类数据

请添加图片描述
请添加图片描述
请添加图片描述
完整代码:

    /**
     * 根据条件查询分类数据
     * @param category
     * @return
     */
    @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);
    }

请添加图片描述

代码开发 - 接收页面提交的数据

请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述

代码开发 - 导入 DTO

DTO 全称 Data Transfer Object, 用于封装页面提交的数据。

@Data
public class DishDto extends Dish {

    private List<DishFlavor> flavors = new ArrayList<>();

    private String categoryName;

    private Integer copies;
}

请添加图片描述
没有封装成功的原因,没加 @RequestBody。
请添加图片描述
封装成功。

代码开发 - 保存数据到菜品表和菜品口味表

因为需要操作两张表,在 DishService 中扩展一个新的方法:
请添加图片描述
首先写操作菜品表的代码:
请添加图片描述
操作菜品口味表,需要注入 DishFlavorService:
请添加图片描述
保存菜品口味数据到菜品口味表 dish_flavor:
请添加图片描述
由于 dishId 没有封装上,需要对集合 flavors 进行处理:
请添加图片描述
因为操作多张表,记得加上事务相关注解:
请添加图片描述
请添加图片描述

完整代码:

public interface DishService extends IService<Dish> {

    // 新增菜品,同时插入菜品对应的口味数据,需要插入两张表:dish、dish_flavor
    public void saveWithFlavor(DishDto dishDto);
}
@Service
@Slf4j
public class DishServiceImpl extends ServiceImpl<DishMapper, Dish> implements DishService {

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

    /**
     * 新增菜品
     * @param dishDto
     * @return
     */
    @PostMapping
    public R<String> save(@RequestBody DishDto dishDto) {
        log.info(dishDto.toString());

        dishService.saveWithFlavor(dishDto);

        return R.success("新增菜品成功");
    }
}
@Slf4j
@SpringBootApplication
@ServletComponentScan
@EnableTransactionManagement
public class ReggieApplication {
    public static void main(String[] args) {
        SpringApplication.run(ReggieApplication.class, args);
        log.info("项目启动成功...");
    }
}

测试:
请添加图片描述
请添加图片描述

菜品信息分页查询

需求分析

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

代码开发

在开发代码之前,需要梳理一下菜品分页查询时前端页面和服务端的交互过程:
页面 (backend/page/food/list.html) 发送 ajax 请求,将分页查询参数 (page, pageSize, name) 提交到服务端,获取分页数据;
请添加图片描述
页面发送请求,请求服务端进行图片下载,用于页面图片展示;
开发菜品信息分页查询功能,其实就是在服务端编写代码去处理前端页面发送的这两次请求即可。
部分代码:
请添加图片描述
测试,菜品分类无法展示:
请添加图片描述
请添加图片描述
响应回来的只有分类的 id , 没有名称:
请添加图片描述
请添加图片描述
前面导入了一个 DishDto :
请添加图片描述
对象拷贝,不需要拷 records:
请添加图片描述
请添加图片描述
获取 records:
请添加图片描述
获取 分类 id:
请添加图片描述
根据 id 查询分类对象:
请添加图片描述
请添加图片描述
构造一个 dishDto 对象:
请添加图片描述
把普通属性拷贝上去:
请添加图片描述
返回 dishDto,并用一个 list 收集起来:
请添加图片描述
再把这个集合对象赋给 page 的 records 的这个属性:
请添加图片描述
完整代码:

/**
 * 菜品管理
 */
@RestController
@RequestMapping("/dish")
@Slf4j
public class DishController {
    @Autowired
    private DishService dishService;
    @Autowired
    private DishFlavorService dishFlavorService;
    @Autowired
    private CategoryService categoryService;

    /**
     * 新增菜品
     * @param dishDto
     * @return
     */
    @PostMapping
    public R<String> save(@RequestBody DishDto dishDto) {
        log.info(dishDto.toString());

        dishService.saveWithFlavor(dishDto);

        return R.success("新增菜品成功");
    }

    /**
     * 菜品信息分页查询
     * @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
            BeanUtils.copyProperties(item, dishDto);
            // 根据 id 查询分类对象
            Category category = categoryService.getById(categoryId);
            String categoryName = category.getName();
            dishDto.setCategoryName(categoryName);
            return dishDto;
        }).collect(Collectors.toList());

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

功能测试

请添加图片描述
请添加图片描述
请添加图片描述
空指针异常:
请添加图片描述
改进:

            if (category != null) {
                String categoryName = category.getName();
                dishDto.setCategoryName(categoryName);
            }

请添加图片描述
请添加图片描述
图片是怎么展示的:
请添加图片描述
请添加图片描述

修改菜品

需求分析

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

代码开发 - 梳理交互过程

在开发代码之前,需要梳理一下修改菜品时前端页面 (add.html) 和服务端的交互过程:
(1) 页面发送 ajax 请求,请求服务端获取分类数据,用于商品分类下拉框中数据展示;
(2) 页面发送 ajax 请求,请求服务端根据 id 查询当前菜品信息,用于菜品信息回显;
(3) 页面发送请求,请求服务端进行图片下载,用于页图片回显;
(4) 点击保存按钮,页面发送 ajax 请求,将修改后的菜品相关数据以 json 形式提交到服务端;
开发修改菜品功能,其实就是在服务端编写代码去处理前端页面发送的这四次请求即可。
请添加图片描述
请添加图片描述
回显菜品基本信息:
请添加图片描述

代码开发 - 根据 id 查询菜品信息和对应的口味信息

请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
拷贝:
请添加图片描述
请添加图片描述
在 Controller 中调用这个方法:
请添加图片描述
完整代码:

    /**
     * 根据 id 查询菜品信息和口味信息
     * @param id
     * @return
     */
    @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;
    }
    /**
     * 根据 id 查询菜品信息和对应的口味信息
     * @param id
     * @return
     */
    @GetMapping("/{id}")
    public R<DishDto> get(@PathVariable Long id) {
        DishDto dishDto = dishService.getByIdWithFlavor(id);

        return R.success(dishDto);
    }
功能测试

请添加图片描述
请添加图片描述
请添加图片描述
回显成功:
请添加图片描述

代码开发 - 修改菜品信息与口味信息

请添加图片描述
复制一份 save 的代码:
请添加图片描述
创建接口与实现类:
请添加图片描述
更新 dish 表的基本信息:
请添加图片描述
清理当前菜品对应的口味数据:
请添加图片描述
请添加图片描述
添加当前提交过来的口味数据:
请添加图片描述
加事务注解:
请添加图片描述
完整代码:

    @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);
    }
    /**
     * 修改菜品
     * @param dishDto
     * @return
     */
    @PutMapping
    public R<String> update(@RequestBody DishDto dishDto) {
        log.info(dishDto.toString());

        dishService.updateWithFlavor(dishDto);

        return R.success("新增菜品成功");
    }
功能测试

请添加图片描述
请添加图片描述

新增套餐

需求分析

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

数据模型

新增套餐,其实就是将新增页面录入的套餐信息插入到 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;
}
代码开发 - 梳理交互过程

页面 (backend/page/combo/add.html) 发送 ajax 请求,请求服务端获取套餐分类数据并展示到下拉框中;
请添加图片描述

请添加图片描述

页面发送 ajax 请求,请求服务端获取菜品分类数据并展示到添加菜品窗口中;
请添加图片描述
请添加图片描述
页面发送 ajax 请求,请求服务端,根据菜品分类查询对应的菜品数据并展示到添加菜品窗口中;
请添加图片描述
页面发送请求进行图片上传,请求服务端将图片保存到服务器;
页面发送请求进行图片下载, 将上传的图片进行回显;
点击保存按钮,发送 ajax 请求,将套餐相关数据以 json 形式提交到服务端;

代码开发 - 根据分类查询菜品

DishController 里:

    /**
     * 根据条件查询对应的菜品数据
     * @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());

        // 添加排序条件
        queryWrapper.orderByAsc(Dish::getSort).orderByDesc(Dish::getUpdateTime);

        List<Dish> list = dishService.list(queryWrapper);
        return R.success(list);
    }

测试:
请添加图片描述
sql 语句:

SELECT id,name,category_id,price,code,image,description,status,sort,create_time,update_time,create_user,update_user,is_deleted FROM dish WHERE (category_id = ?) ORDER BY sort ASC,update_time DESC

查出来 4 条数据:
请添加图片描述
请添加图片描述
请添加图片描述
添加条件,查询状态为 1(起售状态)的菜品:
请添加图片描述
再次测试:
请添加图片描述

代码开发 - 服务端接收页面提交的数据

点击保存按钮:
请添加图片描述
请添加图片描述
部分代码:
请添加图片描述
测试:
请添加图片描述
由于要操作两张表,在 SetmealService 中扩展 saveWithDish :
请添加图片描述
保存套餐的基本信息,操作 setmeal, 执行 insert 操作:
请添加图片描述
操作 setmeal_dish 表, 需要注入 setmealDishService:
请添加图片描述
保存套餐和菜品的关联信息,操作 setmeal_dish, 执行 insert 操作:
请添加图片描述
由于没有封装 setmealId, 需要对 setmealDishes 进行处理:
请添加图片描述
因为操作多张表,需要加上事务注解:
请添加图片描述
Controller 中:
请添加图片描述
测试:
请添加图片描述
数据库中:
请添加图片描述

套餐信息分页查询

需求分析

系统中的套餐数据很多的时候,如果在一个页面全部展示出来会显得比较乱,不便于查看,所以一般的系统中都会以分页的方式展示列表数据:
请添加图片描述

代码开发

在开发代码之前,需要梳理一下套餐分页查询时前端页面和服务端的交互过程:
(1) 页面 (backend/page/combo/list.html) 发送 ajax 请求, 将分页查询参数 (page、pageSize、name) 提交到服务端,获取分页数据;
(2) 页面发送请求,请求服务端进行图片下载,用于页面图片展示;
开发套餐信息分页查询功能,其实就是在服务端编写代码去处理前端页面发送的这 2 次请求即可。
请添加图片描述
基本分页查询代码:
请添加图片描述
由于 Setmeal 里面只有分类 id, 无法展示分类名称:
请添加图片描述
测试,普通字段都是可以展示出来的:
请添加图片描述
由于 SetmealDto 扩展了 categoryName
请添加图片描述
创建分页构造器对象:
请添加图片描述
对象拷贝,不需要拷 records(因为泛型不符合):
请添加图片描述
处理 records:
请添加图片描述
请添加图片描述
测试:
请添加图片描述
请添加图片描述
完整代码:

    @Autowired
    private CategoryService categoryService;

    /**
     * 套餐分页查询
     * @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) 删除单个套餐时,页面发送 ajax 请求,根据套餐 id 删除对应套餐即可;
请添加图片描述
(2) 删除多个套餐时,页面发送 ajax 请求,根据提交的多个套餐 id 删除对应套餐;
请添加图片描述
开发删除套餐功能,其实就是在服务端编写代码去处理前端页面发送的这两次请求即可。
观察删除单个套餐和批量删除套餐的请求信息可以发现,两种请求方式的地址和请求方式是相同的,不同的是传递的 id 的个数,所以在服务端可以提供一个方法统一处理。
先确定参数能够正确接收到:
请添加图片描述
请添加图片描述
删除套餐时,还需要删除套餐对应的关联数据,所以需要操作两张表(封装到 Service 中完成)
请添加图片描述
查询套餐状态,确定是否可以删除,大概是这么一个 sql:

select count(*) from setmealn where id in (1, 2, 3) and status = 1

如果不能删除,抛出一个业务异常:
请添加图片描述
如果可以删除,先删除套餐表中的数据 _ setmeal:
请添加图片描述
删除关系表中的数据 _ setmeal_dish 前,需要先注入 setmealDishService:
请添加图片描述
删除关系表中的数据 _ setmeal_dish,大概是这么一个 sql:

delete from setmeal_dish where setmeal_id in (1, 2, 3)

请添加图片描述
记得加上事务注解:
请添加图片描述

在 Controller 中调用这个方法:
请添加图片描述
测试:
请添加图片描述
请添加图片描述
改一下数据库,把状态改为 0:
请添加图片描述
请添加图片描述
页面:
请添加图片描述
数据库:
请添加图片描述
请添加图片描述

短信发送

阿里云短信服务

阿里云官网:https://www.aliyun.com/
注册账号;
设置短信签名;
设置短信模板;
设置 AccessKey:
(1) 新建用户 :
请添加图片描述
(2) 设置权限:
请添加图片描述
ID: LTAI5tH4yXd2VXU7yFCS5eem
Secret: hSODEzcLUcfz4VDGYqvFCmdxuXTnBb
请添加图片描述

代码开发

(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){
		DefaultProfile profile = DefaultProfile.getProfile("cn-hangzhou", "LTAI5tH4yXd2VXU7yFCS5eem", "hSODEzcLUcfz4VDGYqvFCmdxuXTnBb");
		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();
		}
	}

}

手机验证码登录

需求分析

为了方便用户登录,移动端通常会提供通过手机验证码登录的功能。
手机验证码登录的优点:
方便快捷,无需注册,直接登录;
使用短信验证码作为凭证,无需记忆密码;
安全;
登录流程:
输入手机号 > 获取验证码 > 输入验证码 > 点击登录 > 登录成功
注意: 通过手机验证码登录,手机号是区分不同用户的标识。

数据模型

通过手机验证码登录时,涉及的表为 user 表,即用户表,结构如下:
请添加图片描述

代码开发 - 梳理交互过程

在开发代码之前,需要梳理一下登录时前端页面与服务端的交互过程:
在登录页面 (front/page/login.html) 输入手机号,点击获取验证码按钮,页面发送 ajax 请求,在服务端调用短信服务 API 给指定手机号发送验证码短信;
在登录页面输入验证码,点击登录按钮,发送 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
@RequestMapping("/user")
@Slf4j
public class UserController {
    @Autowired
    private UserService userService;
}

工具类 SMSUtils、ValidateCodeUtils;

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

	/**
	 * 发送短信
	 * @param signName 签名
	 * @param templateCode 模板
	 * @param phoneNumbers 手机号
	 * @param param 参数
	 */
	public static void sendMessage(String signName, String templateCode,String phoneNumbers,String param){
		DefaultProfile profile = DefaultProfile.getProfile("cn-hangzhou", "LTAI5tH4yXd2VXU7yFCS5eem", "hSODEzcLUcfz4VDGYqvFCmdxuXTnBb");
		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();
		}
	}

}

/**
 * 随机生成验证码工具类
 */
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

前面我们已经完成了 LoginCheckFilter 过滤器的开发,此过滤器用于检查用户的登录状态。我们在进行手机验证码登录时,发送的请求需要在此过滤器处理时直接放行。
请添加图片描述
在 LoginCheckFilter 过滤器中扩展逻辑,判断移动端用户登录状态:
复制一份这个代码:
请添加图片描述

        // 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);
            long id = Thread.currentThread().getId();
            log.info("线程 id 为: {}", id);

            filterChain.doFilter(request, response);
            return;
        }
代码开发 - 发送验证码短信

请添加图片描述

请添加图片描述

    /**
     * 发送手机短信验证码
     * @param user
     * @return
     */
    @PostMapping("/sendMsg")
    public R<String> sendMsg(@RequestBody User user, HttpSession session) {
        // 获取手机号
        String phone = user.getPhone();

        if (!StringUtils.isEmpty(phone)) {
            // 生成随机的 4 位验证码
            String code = ValidateCodeUtils.generateValidateCode(4).toString();
            log.info("code = {}", code);
            // 调用阿里云提供的短信服务 API 完成发送短信
            SMSUtils.sendMessage("魏新炀的博客",
                    "SMS_268610874", phone, code);
            // 需要将生成的验证码保存到 Session
            session.setAttribute(phone, code);

            return R.success("手机验证码短信发送成功");
        }
        return R.error("短信发送失败");
    }

请添加图片描述

代码开发 - 登录校验

请添加图片描述
请添加图片描述
请添加图片描述
使用 map 来接收:
请添加图片描述
参数可以正确接收到:
请添加图片描述
代码如下:

    /**
     * 移动端用户登录
     * @param map
     * @param session
     * @return
     */
    @PostMapping("/login")
    public R<User> login(@RequestBody Map map, HttpSession session) {
//        log.info(map.toString());
        // 获取手机号
        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);
            }
            return R.success(user);
        }

        return R.error("登录失败");
    }

测试,发现跳了一下又跳出来,是因为没有把用户 id 存到 session 里去,加代码:
请添加图片描述
登录成功:
请添加图片描述

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

需求分析

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

数据模型

用户的地址信息会存储在 address_book 表,即地址簿表中。具体表结构如下:
请添加图片描述

导入功能代码

功能代码清单:
实体类 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

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

功能测试

请添加图片描述
请添加图片描述
请添加图片描述
数据库中:
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述

菜品展示

需求分析

用户登录成功后跳转到系统首页,在首页需要根据分类展示菜品和套餐。如果菜品设置了口味信息,需要展示 “选择规格” 按钮,否则显示 “+” 按钮。
请添加图片描述

代码开发 - 梳理交互过程

在开发代码之前,需要梳理一下前端页面与服务端的交互过程:
(1) 页面 (front/index.html) 发送 ajax 请求,获取分类数据(菜品分类和套餐分类);
(2) 页面发送 ajax 请求,获取第一个分类下的菜品或者套餐;
开发菜品展示功能,其实就是在服务端编写代码去处理前端页面发送的这两次请求即可。
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
注意:首页加载完成后,还发送了一次 ajax 请求用于加载购物车数据,此处可以将这次请求的地址暂时修改一下,从静态 json 文件获取数据,等后续开发购物车功能时再修改回来:
请添加图片描述
请添加图片描述
测试:
请添加图片描述
请添加图片描述

展示分类数据的代码已经写好了:
请添加图片描述
菜品数据也展示出来了:
请添加图片描述
请添加图片描述
请添加图片描述
但是这里应该显示选择规格按钮,却没有展示出来:
请添加图片描述
改造这个方法:
请添加图片描述
让它返回来的数据既有菜品的基本信息,又有菜品的口味信息。

代码开发 - 修改 DishController 的 list 方法

返回值改为 DishDto:
请添加图片描述
要得到这么一个集合:
请添加图片描述
把 page 方法的这段代码拷过来:
请添加图片描述
改成这样:
请添加图片描述
测试:
菜品基本信息:
请添加图片描述
dishDtoList:
请添加图片描述
请添加图片描述
请添加图片描述

代码开发 - 创建 SetmealController 的 list 方法

请添加图片描述
请添加图片描述

    /**
     * 根据条件查询套餐数据
     * @param setmeal
     * @return
     */
    @GetMapping("/list")
    public R<List<Setmeal>> list(@RequestBody Setmeal setmeal) {
        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);
    }

测试:
请添加图片描述
请添加图片描述
因为传过来的并不是 json, 所以不需要加 @RequestBody:
请添加图片描述

    /**
     * 根据条件查询套餐数据
     * @param setmeal
     * @return
     */
    @GetMapping("/list")
    public R<List<Setmeal>> list(Setmeal setmeal) {
        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);
    }

再次测试:
请添加图片描述

购物车

需求分析

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

数据模型

购物车对应的数据表为 shopping_cart 表,具体表结构如下:
请添加图片描述

代码开发 - 梳理交互过程

在开发代码之前,需要梳理一下购物车操作时前端页面与服务端的交互过程:
(1) 点击 “加入购物车” 或 “+” 按钮,页面发送 ajax 请求,请求服务端将菜品或者套餐添加到购物车;
(2) 点击购物车图标,页面发送 ajax 请求,请求服务端查询购物车中的菜品或者套餐;
(3) 点击清空购物车按钮,页面发送 ajax 请求, 请求服务端执行清空购物车操作;
开发购物车功能,其实就是在服务端编写代码去处理前端页面发送的这 3 次请求即可。
请添加图片描述
请添加图片描述
请添加图片描述

代码开发 - 准备工作

在开发业务功能之前,先将需要用到的类和接口的基本结构创建好:
实体类 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
public class ShoppingCartServiceImpl extends ServiceImpl<ShoppingCartMapper, ShoppingCart> implements ShoppingCartService {
}

控制层 ShoppingCartController

/**
 * 购物车管理
 */
@Slf4j
@RestController
@RequestMapping("/shoppingCart")
public class ShoppingCartController {
    @Autowired
    private ShoppingCartService shoppingCartService;
}
代码开发 - 添加购物车

先确保参数能够封装:
请添加图片描述
请添加图片描述
设置用户 id, 指定当前是哪个用户的购物车数据:
请添加图片描述
查询当前菜品或者套餐是否在购物车中:
请添加图片描述
添加到购物车的是菜品:
请添加图片描述
添加到购物车的是套餐:
请添加图片描述
大概发的是这么一条 sql:

select * from shopping_cart where user_id = ? and dish_id/setmeal_id = ?

请添加图片描述
如果已经存在,就在原来数量基础上 + 1:
请添加图片描述
如果不存在,则添加到购物车,数量默认就是 1:
请添加图片描述
完整代码:

    /**
     * 添加购物车
     * @param shoppingCart
     * @return
     */
    @PostMapping("/add")
    public R<ShoppingCart> add(@RequestBody ShoppingCart shoppingCart) {
        log.info("购物车数据:{}", shoppingCart);
        // 设置用户 id, 指定当前是哪个用户的购物车数据
        Long currentId = BaseContext.getCurrentId();
        shoppingCart.setUserId(currentId);
        // 查询当前菜品或者套餐是否在购物车中
        Long dishId = shoppingCart.getDishId();
        Long setmealId = shoppingCart.getSetmealId();
        LambdaQueryWrapper<ShoppingCart> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(ShoppingCart::getUserId, currentId);

        if (dishId != null) {
            // 添加到购物车的是菜品
            queryWrapper.eq(ShoppingCart::getDishId, dishId);
        } else {
            // 添加到购物车的是套餐
            queryWrapper.eq(ShoppingCart::getSetmealId, setmealId);
        }

        // 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);
            shoppingCartService.save(shoppingCart);
            cartServiceOne = shoppingCart;
        }

        return R.success(cartServiceOne);
    }

测试:
添加菜品到购物车中:
请添加图片描述
请添加图片描述
请添加图片描述
同样的菜品再添加一次购物车:
请添加图片描述
请添加图片描述
请添加图片描述
添加套餐到购物车中:
请添加图片描述
请添加图片描述
请添加图片描述

代码开发 - 查看购物车

先把这个地方改回来:
请添加图片描述

    /**
     * 查看购物车
     * @return
     */
    @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);
    }

请添加图片描述
测试:
请添加图片描述
请添加图片描述

代码开发 - 清空购物车

请添加图片描述
请添加图片描述

    /**
     * 清空购物车
     * @return
     */
    @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("清空购物车成功");
    }

测试:
请添加图片描述
请添加图片描述
请添加图片描述

用户下单

需求分析

移动端用户将菜品或者套餐加入购物车后,可以点击购物车中的 “去结算” 按钮,页面跳转到订单确认页面,点击 “去支付” 按钮则完成下单操作。
请添加图片描述

数据模型

用户下单业务对应的数据表为 orders表 和 order_detail表:
(1) orders: 订单表;
请添加图片描述
(2) order_detail: 订单明细表;
请添加图片描述

代码开发 - 梳理交互过程

在开发代码之前,需要梳理一下用户下单操作时前端页面与服务端的交互过程:
(1) 在购物车点击 “去结算” 按钮,页面跳转到订单确认页面;
(2) 在订单确认页面,发送 ajax 请求,请求服务端获取当前登录用户的默认地址;
请添加图片描述
请添加图片描述
(3) 在订单确认页面,发送 ajax 请求,请求服务端获取当前登录用户的购物车数据;
请添加图片描述
请添加图片描述
(4) 在订单确认页面点击 “去支付” 按钮,发送 ajax 请求,请求服务端完成下单操作;
开发用户下单功能,其实就是在服务端编写代码去处理前端页面发送的请求即可。

代码开发

在开发业务功能之前,先将需要用到的类和接口的基本结构创建好:
(1) 实体类 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;
}

(2) Mapper 接口 OrderMapper、OrderDetailMapper;

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

(3) 业务层接口 OrderService、OrderDetailService;

public interface OrderService extends IService<Orders> {
}
public interface OrderDetailService extends IService<OrderDetail> {
}

(4) 业务层实现类 OrderServiceImpl、OrderDetailServiceImpl;

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

(5) 控制层 OrderController、OrderDetailController;

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

请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
梳理逻辑:
请添加图片描述
操作多个数据库,需要加上事务注解:
请添加图片描述
获得当前用户 id:
请添加图片描述
操作购物车需要注入 shoppingCartService:
请添加图片描述
查询当前用户的购物车数据:
请添加图片描述
请添加图片描述
查询用户数据:
请添加图片描述
查询地址数据:
请添加图片描述
orders 除了提交过来的 3 个参数,其他属性都需要设置:
请添加图片描述
向订单表插入数据, 1 条数据:
请添加图片描述
遍历购物车,算出总金额,并且构造订单明细集合:
需要先注入 orderDetailService:
请添加图片描述
请添加图片描述
向订单明细表插入数据,多条数据:
请添加图片描述
清空购物车数据:
请添加图片描述
完整代码:

    @Autowired
    private ShoppingCartService shoppingCartService;
    @Autowired
    private UserService userService;
    @Autowired
    private AddressBookService addressBookService;
    @Autowired
    private OrderDetailService orderDetailService;
    /**
     * 用户下单
     * @param orders
     * @return
     */
    @Override
    @Transactional
    public void submit(Orders orders) {
        // 获得当前用户 id
        Long userId = BaseContext.getCurrentId();
        // 查询当前用户的购物车数据
        LambdaQueryWrapper<ShoppingCart> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(ShoppingCart::getUserId, userId);
        List<ShoppingCart> shoppingCarts = shoppingCartService.list(wrapper);

        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()));
        // 向订单表插入数据, 1 条数据
        this.save(orders);
        // 向订单明细表插入数据,多条数据
        orderDetailService.saveBatch(orderDetails);
        // 清空购物车数据
        shoppingCartService.remove(wrapper);
    }
    @Autowired
    private OrderService orderService;
    /**
     * 用户下单
     * @param orders
     * @return
     */
    @PostMapping("/submit")
    public R<String> submit(@RequestBody Orders orders) {
        log.info("订单数据:{}", orders);
        orderService.submit(orders);
        return R.success("下单成功");
    }
功能测试

请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述

请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
购物车表被清空:
请添加图片描述
订单表:
请添加图片描述
订单明细表:
请添加图片描述

缓存优化环境搭建

maven 坐标

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

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
配置文件

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

spring:
	redis:
	    host : localhost
	    port : 6379
	 #  password : 123456
	    database : 0        # 操作的是 0 号数据库
配置类

在项目中加入配置类 RedisConfig:

@Configuration
public class RedisConfig extends CachingConfigurerSupport {

    @Bean
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();

        // 默认的 Key 序列化器为:JdkSerializationRedisSerializer
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());

        redisTemplate.setConnectionFactory(connectionFactory);

        return redisTemplate;
    }
}

缓存短信验证码

实现思路 & 代码改造

前面我们已经实现了移动端手机验证码登录,随机生成的验证码我们是保存在 HttpSession 中的。
现在需要改造为将验证码缓存在 Redis 中,具体的实现思路如下:
在服务端 UserController 中注入 RedisTemplate 对象,用于操作 Redis
请添加图片描述
在服务端 UserController 的 sendMsg 方法中,将随机生成的验证码缓存到 Redis 中,并设置有效期为 5 分钟
请添加图片描述
在服务端 UserController 的 login 方法中,从 Redis 中获取缓存的验证码,如果登录成功则删除 Redis 中的验证码
请添加图片描述
请添加图片描述
完整代码:

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

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 发送手机短信验证码
     * @param user
     * @return
     */
    @PostMapping("/sendMsg")
    public R<String> sendMsg(@RequestBody User user, HttpSession session) {
        // 获取手机号
        String phone = user.getPhone();

        if (!StringUtils.isEmpty(phone)) {
            // 生成随机的 4 位验证码
            String code = ValidateCodeUtils.generateValidateCode(4).toString();
            log.info("code = {}", code);
            // 调用阿里云提供的短信服务 API 完成发送短信
//            SMSUtils.sendMessage("魏新炀的博客",
//                    "SMS_268610874", phone, code);
            // 需要将生成的验证码保存到 Session
            // session.setAttribute(phone, code);

            // 将生成的验证码缓存到 Redis 中,并且设置有效期为 5 分钟
            redisTemplate.opsForValue().set(phone, code, 5, TimeUnit.MINUTES);

            return R.success("手机验证码短信发送成功");
        }
        return R.error("短信发送失败");
    }

    /**
     * 移动端用户登录
     * @param map
     * @param session
     * @return
     */
    @PostMapping("/login")
    public R<User> login(@RequestBody Map map, HttpSession session) {
//        log.info(map.toString());
        // 获取手机号
        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("登录失败");
    }
}

功能测试

获取验证码:
请添加图片描述
请添加图片描述
点登录,删除 Redis 中验证码:
请添加图片描述

缓存菜品数据

实现思路

前面我们已经实现了移动端菜品查看功能,对应的服务端方法为 DishController 的 list 方法,此方法会根据前端提供的查询条件进行数据库查询操作。在高并发的情况下,频繁查询数据库会导致系统性能下降,服务端的响应时间很长。现在需要对此方法进行缓存优化,提高系统的性能。
具体的实现思路如下:
改造 DishController 的 list 方法,先从 Redis 中获取菜品数据,如果有则直接返回,无需查询数据库;如果没有则查询数据库,并将查询到的菜品放入 Redis。
改造 DishController 的 save 和 update 方法,加入清理缓存的逻辑。
在使用缓存的过程中,要注意保证数据库中的数据与缓存中的数据一致;如果数据库中的数据发生变化,需要及时清理缓存数据。(防止读脏数据)

缓存菜品数据代码改造 & 功能测试

请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
再点一次同样的分类:
请添加图片描述
控制台也没有发 sql。
完整代码:

    /**
     * 根据条件查询对应的菜品数据
     * @param dish
     * @return
     */
    @GetMapping("/list")
    public R<List<DishDto>> list(Dish dish) {
        List<DishDto> dishDtoList = null;

        // 动态构造 key
        String key = "dish_" + dish.getCategoryId() + "_" + dish.getStatus();                   // dish_1397844263642378242_1

        // 先从 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
            BeanUtils.copyProperties(item, dishDto);
            // 根据 id 查询分类对象
            Category category = categoryService.getById(categoryId);
            if (category != null) {
                String categoryName = category.getName();
                dishDto.setCategoryName(categoryName);
            }
            // 当前菜品 id
            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);
    }
清理缓存代码改造 & 功能测试

save 和 update 方法中:
请添加图片描述
点击用户端的饮品让 Redis 缓存数据:
请添加图片描述
请添加图片描述
在后台修改菜品, 执行 update 方法:
请添加图片描述
Redis 中相应分类的缓存被清理了。
再点一次同样的分类,控制台发了 sql:
请添加图片描述
完整代码:

    /**
     * 新增菜品
     * @param dishDto
     * @return
     */
    @PostMapping
    public R<String> save(@RequestBody DishDto dishDto) {
        log.info(dishDto.toString());

        dishService.saveWithFlavor(dishDto);

        // 清理某个分类下面的菜品缓存数据
        String key = "dish_" + dishDto.getCategoryId() + "_1";
        redisTemplate.delete(key);

        return R.success("新增菜品成功");
    }
    /**
     * 修改菜品
     * @param dishDto
     * @return
     */
    @PutMapping
    public R<String> update(@RequestBody DishDto dishDto) {
        log.info(dishDto.toString());

        dishService.updateWithFlavor(dishDto);

        // 清理某个分类下面的菜品缓存数据
        String key = "dish_" + dishDto.getCategoryId() + "_1";
        redisTemplate.delete(key);

        return R.success("修改菜品成功");
    }

Spring Cache

Spring Cache 介绍

Spring Cache 是一个框架,实现了基于注解的缓存功能,只需要简单地加一个注解,就能实现缓存功能。
Spring Cache 提供了一层抽象,底层可以切换不同的 cache 实现,具体就是通过 CacheManager 接口来统一不同的缓存技术。
CacheManager 是 Spring 提供的各种缓存技术的抽象接口。
针对不同的缓存技术需要实现不同的 CacheManager:
请添加图片描述

Spring Cache 常用注解

请添加图片描述
在 SpringBoot 项目中,使用缓存技术只需要在项目中导入相关缓存技术的依赖包,并在启动类上使用 @EnableCaching 开启缓存支持即可。
例如,使用 Redis 作为缓存技术,只需要导入 Spring data Redis 的 maven 坐标即可。
导入资料的 cache_demo 项目:
请添加图片描述
请添加图片描述
请添加图片描述
创建表:
请添加图片描述

CachePut 注解

请添加图片描述

请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
再发一次请求:
请添加图片描述
由于 map 是基于内存的,因此重启服务后,缓存的数据会被清理。
重启服务后再发一次请求:
请添加图片描述

    /**
     * CachePut : 将方法返回值放入缓存
     * value : 缓存的名称,每个缓存名称下面可以有多个 key
     * key : 缓存的 key
     */
    @CachePut(value = "userCache", key = "#result.id")
    @PostMapping
    public User save(User user){
        userService.save(user);
        return user;
    }
CacheEvict 注解

请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
再发一次请求:
请添加图片描述

    /**
     * CacheEvict : 清理指定缓存
     * value : 缓存的名称,每个缓存名称下面可以有多个 key
     * key : 缓存的 key
     */
//    @CacheEvict(value = "userCache", key = "#p0")
//    @CacheEvict(value = "userCache", key = "#root.args")
    @CacheEvict(value = "userCache", key = "#id")
    @DeleteMapping("/{id}")
    public void delete(@PathVariable Long id){
        userService.removeById(id);
    }

请添加图片描述

请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
再发送一次请求:
请添加图片描述

//    @CacheEvict(value = "userCache", key = "#p0.id")
//    @CacheEvict(value = "userCache", key = "#user.id")
//    @CacheEvict(value = "userCache", key = "#root.args[0].id")
    @CacheEvict(value = "userCache", key = "#result.id")
    @PutMapping
    public User update(User user){
        userService.updateById(user);
        return user;
    }
Cacheable 注解

请添加图片描述
请添加图片描述
再发一次请求,不会调用方法,而是直接使用缓存的数据。
问题:如果 id 对应的 user 不存在,也会缓存到 redis 中。因此我们希望返回结果不为空的时候才缓存。
condition : 满足条件时才查询 (不能使用 result)    unless : 不满足条件时才查询
请添加图片描述
请添加图片描述

list 方法 也需要加 Cacheable 注解:
请添加图片描述
请添加图片描述
请添加图片描述
再发一次请求,不会调用方法,而是直接使用缓存的数据。

    /**
     * Cacheable : 在方法执行前 spring 先查看缓存中是否有数据,如果有数据,则直接返回缓存数据
     * value : 缓存的名称,每个缓存下面可以有多个 key
     * key : 缓存的 key
     * condition : 条件,满足条件时才缓存数据
     * unless : 满足条件则不缓存
     */
    @Cacheable(value = "userCache", key = "#id", unless = "#result == null")
    @GetMapping("/{id}")
    public User getById(@PathVariable Long id){
        User user = userService.getById(id);
        return user;
    }

    /**
     * Cacheable : 在方法执行前 spring 先查看缓存中是否有数据,如果有数据,则直接返回缓存数据
     * value : 缓存的名称,每个缓存下面可以有多个 key
     * key : 缓存的 key
     * condition : 条件,满足条件时才缓存数据
     */
    @Cacheable(value = "userCache", key = "#user.id + '_' + #user.name")
    @GetMapping("/list")
    public List<User> list(User user){
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(user.getId() != null,User::getId,user.getId());
        queryWrapper.eq(user.getName() != null,User::getName,user.getName());
        List<User> list = userService.list(queryWrapper);
        return list;
    }
使用 Redis 作为缓存技术

在 SpringBoot 技术中使用 Spring Cache 的操作步骤 (基于 redis 缓存技术):
导入 maven 坐标
spring-boot-starter-data-redis 、spring-boot-starter-cache

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

请添加图片描述
配置 application.yml

spring:
  cache:
    redis:
      time-to-live : 1800000    # 设置缓存过期时间

请添加图片描述
请添加图片描述
请添加图片描述
缓存被清理了:
请添加图片描述

缓存套餐数据

实现思路

导入 Spring Cache 和 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>

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

spring:
  cache:
    redis:
      time-to-live: 1800000       # 设置缓存数据的过期时间

在启动类上加上 @EnableCaching
请添加图片描述
在 SetmealController 的 list 方法加上 @Cacheable 注解:
请添加图片描述
测试,点套餐时接口 500 异常:
请添加图片描述
是因为返回对象 R 没有实现序列化接口:
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
再点一次控制台不会发 sql。
清理缓存:
请添加图片描述
请添加图片描述
新增一个儿童套餐:
请添加图片描述
缓存被清理了。

@RestController
@RequestMapping("/setmeal")
@Slf4j
public class SetmealController {
    @Autowired
    private SetmealService setmealService;
    @Autowired
    private CategoryService categoryService;
    @Autowired
    private SetmealDishService setmealDishService;

    /**
     * 新增套餐
     * @param setmealDto
     * @return
     */
    @PostMapping
    @CacheEvict(value = "setmealCache", allEntries = true)
    public R<String> save(@RequestBody SetmealDto setmealDto) {
        log.info("套餐信息: {}", setmealDto);
        setmealService.saveWithDish(setmealDto);
        return R.success("新增套餐成功");
    }

    /**
     * 套餐分页查询
     * @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);
    }

    /**
     * 删除套餐
     * @param ids
     * @return
     */
    @DeleteMapping
    @CacheEvict(value = "setmealCache", allEntries = true)
    public R<String> delete(@RequestParam List<Long> ids) {
        log.info("ids: {}" , ids);
        setmealService.removeWithDish(ids);
        return null;
    }

    /**
     * 根据条件查询套餐数据
     * @param setmeal
     * @return
     */
    @GetMapping("/list")
    @Cacheable(value = "setmealCache", key = "#setmeal.categoryId + '_' + #setmeal.status")
    public R<List<Setmeal>> list(Setmeal setmeal) {
        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);
    }

    /**
     * 启用禁用套餐
     * @param sta
     * @param ids
     * @return
     */
    @PostMapping("/status/{sta}")
    public R<String> status(@PathVariable int sta ,String ids) {
        String[] array = ids.split(",");
        for (String id : array) {
            Setmeal setmeal = setmealService.getById(id);
            setmeal.setStatus(sta);
            setmealService.updateById(setmeal);
        }
        return R.success("修改状态成功");
    }
}

MySQL 主从复制

当前系统存在的问题:
请添加图片描述
读写分离:
请添加图片描述

介绍

MySQL 主从复制是一个异步的复制过程,底层是基于 MySQL 数据库自带的二进制日志功能。就是一台或多台 MySQL 数据库 (slave 即从库) 从另一台 MySQL 数据库 (master 即主库)进行日志的复制然后再解析日志并应用到自身,最终实现从库的数据和主库的数据保持一致。MySQL 主从复制是 MySQL 数据库自带功能,无需借助第三方工具。
请添加图片描述
MySQL 复制过程分为三步:
master 将改变记录到二进制日志 (binary log)
slave 将 master 的 binary log 拷贝到它的中继日志 relay log
slave 重做中继日志中的事件,将改变应用到自己的数据库中

配置

提前准备好两台服务器,分别安装 MySQL 并启动服务成功
主库 Master : 192.168.126.100
从库 Slave : 192.168.126.101
主库 Master 配置:
修改 MySQL 数据库的配置文件 /etc/my.cnf
[mysqld]
log-bin=mysql-bin         # [必须] 启用二进制日志
server-id=100               # [必须] 服务器唯一 id

请添加图片描述
重启 MySQL 服务
systemctl restart mysqld
登录 MySQL 数据库,执行下列 sql:
GRANT REPLICATION SLAVE ON . to ‘xiaoming’@‘%’ identified by ‘Root@123456’;
:上面 SQL 的作用是创建一个新用户 xiaoming,密码为 Root@123456,并且给 xiaoming 用户授予 REPLICATION SLAVE 权限。常用于建立复制时所需要用到的用户权限,也就是 slave 必须被 master 授权具有该权限的用户,才能通过该用户复制。
登录 MySQL 数据库,执行下列 sql, 记录下结果中 FilePosition 的值:
show master status;
请添加图片描述
:上面 SQL 的作用是查看 Master 的状态,执行完此 SQL 后不要再执行任何操作。
从库 Slave 配置:
修改 MySQL 数据库的配置文件 /etc/my.cnf
[mysqld]
server-id=101               # [必须] 服务器唯一 id

请添加图片描述
重启 MySQL 服务
systemctl restart mysqld
登录 MySQL 数据库,执行下列 sql:
change master to master_host = ‘192.168.126.100’, master_user = ‘xiaoming’, master_password = ‘Root@123456’, master_log_file = ‘mysql-bin.000001’, master_log_pos = 441;

start slave;
请添加图片描述
登录 MySQL 数据库,执行下列 sql, 查看从数据库的状态:
show slave status;
请添加图片描述

测试

在主库中新建数据库 test 和 weixinyang:
请添加图片描述
刷新一下从库:
请添加图片描述
主库的 weixinyang 中创建 user 表:
请添加图片描述
刷新一下从库的 weixinyang 数据库,也有 user 表。

读写分离案例

背景

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

Sharding-JDBC 介绍

Sharding-JDBC 定位为轻量级 Java 框架,在 Java 的 JDBC 层提供的额外服务。它使用客户端直连数据库,以 jar 包的形式提供服务,无需额外部署和依赖,可以理解为增强版的 JDBC 驱动,完全兼容 JDBC 和各种 ORM 框架。
使用 Sharding-JDBC 可以在程序中轻松地实现读写分离
适用于任何基于 JDBC 的 ORM 框架,如:JPA、Hibernate、MyBatis、Spring JDBC Template 或直接使用 JDBC
支持任何第三方的数据库连接池,如:DBCP、C3P0、BoneCP、Druid、HikariCP 等
支持任意实现 JDBC 规范的数据库。目前支持 MySQL、Oracle、SQLServer、PostgreSQL 以及任何遵循 SQL92 标准的数据库

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

导入资料中的 rw_demo:
请添加图片描述
主库中创建 rw 数据库和 user 表:
请添加图片描述

使用 Sharding-JDBC 实现读写分离步骤:
导入 maven 坐标

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

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

server:
  port: 8080
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
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.126.100: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.126.101:3306/rw?characterEncoding=utf-8
        username: root
        password: root
    masterslave:
      # 读写分离配置
      load-balance-algorithm-type: round_robin
      # 最终的数据源名称
      name: dataSource
      # 主库数据源名称
      master-data-source-name: master
      # 从库数据源名称列表,多个逗号分隔
      slave-data-source-names: slave
    props:
      sql:
        show: true #开启SQL显示,默认false

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

spring:
  main:
    allow-bean-definition-overriding: true

测试:
查询操作:
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
新增操作
请添加图片描述
请添加图片描述
请添加图片描述
主库和从库数据都插进来了:
请添加图片描述

项目实现读写分离

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

直接使用我们前面在虚拟机中搭建的主从复制的数据库环境即可。
在主库中创建瑞吉外卖项目的业务数据库 reggie 并导入相关表结构和数据。

代码改造

使用 Sharding-JDBC 实现读写分离步骤:
导入 maven 坐标

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

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

server:
  port: 8080
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
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.126.100: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.126.101:3306/rw?characterEncoding=utf-8
        username: root
        password: root
    masterslave:
      # 读写分离配置
      load-balance-algorithm-type: round_robin
      # 最终的数据源名称
      name: dataSource
      # 主库数据源名称
      master-data-source-name: master
      # 从库数据源名称列表,多个逗号分隔
      slave-data-source-names: slave
    props:
      sql:
        show: true #开启SQL显示,默认false

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

spring:
  main:
    allow-bean-definition-overriding: true
测试

请添加图片描述
请添加图片描述
请添加图片描述

Nginx

Nginx 概述

Nginx 介绍:
Nginx 是一款轻量级的 Web 服务器 / 反向代理服务器 及 电子邮件 (IMAP/POP3) 代理服务器。其特点是占有内存少,并发能力强,事实上 Nginx 的并发能力在同类型的网页服务器中表现较好。
Nginx 是由 伊戈尔·塞索耶夫 为俄罗斯访问第二的站点 Rambler.ru 站点开发的, 第一个公开版本 0.1.0 发布于 2004 年 10 月 4 日。
官网:http://nginx.org/
请添加图片描述
Nginx 下载与安装:
[http://nginx.org/en/download.html](http://nginx.org/en/download.ht
ml)
安装过程:
安装依赖包:yum -y install gcc pcre-devel zlib-devel openssl openssl-devel
下载 Nginx 安装包
yum -y install wget
wget https://nginx.org/download/nginx-1.16.1.tar.gz
解压:
tar -zxvf nginx-1.16.1.tar.gz
cd nginx-1.16.1
./configure --prefix=/usr/local/nginx
make && make install
Nginx 目录结构:
安装完 Nginx 后,先熟悉一下 Nginx 的目录结构 :
请添加图片描述
重点文件 / 目录:
conf/nginx.conf                      nginx 配置文件
html                             存放静态文件 (html、CSS、Js等)
logs                             存放日志文件
sbin/nginx               二进制文件,用于启动、停止 Nginx 服务

Nginx 命令

查看版本:
./nginx -v
请添加图片描述
检查配置文件正确性:
在启动 Nginx 服务之前,可以先检查一下 conf/nginx.conf 文件配置是否有错误:
./nginx -t
请添加图片描述
启动和停止:
启动 Nginx 服务使用如下命令:
./nginx
请添加图片描述
关闭防火墙:
请添加图片描述
请添加图片描述
请添加图片描述
停止 Nginx 服务使用如下命令:
请添加图片描述
注意 Nginx 启动完成后,目录结构发生了变化:
请添加图片描述
没启动:
请添加图片描述
启动:
请添加图片描述
请添加图片描述
重新加载配置文件:
当修改 Nginx 配置文件后,需要重新加载才能生效,可以使用下面命令重新加载配置文件:
./nginx -s reload
请添加图片描述
请添加图片描述
请添加图片描述
在环境变量中配置 Nginx:
请添加图片描述
请添加图片描述
请添加图片描述
简化命令:
请添加图片描述

Nginx 配置文件结构

Nginx 配置文件 (conf/nginx.conf) 整体分为三部分:

  • 全局块:和 Nginx 运行相关的全局配置
  • events 块:和网络连接相关的配置
  • http 块:代理、缓存、日志记录、虚拟主机配置
    • http 全局块
    • Server 块
      • Server 全局块
      • location 块

注意: http 块中可以配置多个 Server 块,每个 Server 块中可以配置多个 location 块。
请添加图片描述

Nginx 具体应用
部署静态资源

Nginx 可以作为静态 web 服务器来部署静态资源。静态资源指在服务端真实存在并且可以直接展示的一些文件,比如常见的 html 页面、css 文件、js 文件、图片、视频等资源。
相对于 Tomcat,Nginx 处理静态资源的能力更加高效,所以在生产环境下,一般都会将静态资源部署到 Nginx 中,将静态资源部署到 Nginx 中非常简单,只需要将文件复制到 Nginx 安装目录下的 html 目录中即可。
请添加图片描述
请添加图片描述
请添加图片描述
server {
      listen 80;      # 监听端口
      server_name localhost;      # 服务器的名称
      location / {     # 匹配客户端请求 url
            root html;     # 指定静态资源根目录
            index index.html;     # 指定默认首页
     }
}
请添加图片描述
请添加图片描述
请添加图片描述

反向代理
  • 正向代理:
    是一个位于客户端和原始服务器 (origin server) 之间的服务器,为了从原始服务器取得内容,客户端向代理发送一个请求并指定目标(原始服务器),然后代理向原始服务器转交请求并将获得的内容返回给客户端。
    正向代理的典型用途是为防火墙内的局域网用户提供访问 Internet 的途径。
    正向代理一般是 在客户端设置代理服务器,通过代理服务器转发请求,最终到达目标服务器。
    请添加图片描述
  • 反向代理:
    反向代理服务器位于用户和目标服务器之间,但是对于用户而言,反向代理服务器就相当于目标服务器,即用户直接访问反向代理服务器就可以获得目标服务器的资源,反向代理服务器负责将请求转发给目标服务器。
    用户不需要知道目标服务器的地址,也无需在用户端作任何设定

请添加图片描述

  • 配置反向代理:
    请添加图片描述
    请添加图片描述
    请添加图片描述

修改 nginx.conf :
server {
       listen 82;
       server_name localhost;
       location / {
              proxy_pass http://192.168.126.101:8080;              #反向代理配置,将请求转发到指定服务
       }
}
请添加图片描述
请添加图片描述
请添加图片描述

负载均衡

早期的网站流量和业务功能都比较简单,单台服务器就可以满足基本需求,但是随着互联网的发展,业务流量越来越大且业务逻辑也越来越复杂,单台服务器的性能及单点故障问题就凸显出来了,因此需要多台服务器组成应用集群,进行性能的水平扩展以及避免单点故障出现。
应用集群:将同一应用部署到多台机器上,组成应用集群,接收负载均衡器分发的请求,进行业务处理并返回响应数据
负载均衡器:将用户请求根据对应的负载均衡算法分发到应用集群中的一台服务器进行处理
请添加图片描述
请添加图片描述
请添加图片描述
配置负载均衡:
upstream targetserver {      # upstream 指令可以定义一组服务器
        server 192.168.126.101:8080;
        server 192.168.126.101:8081;
}
server {
        listen 8080;
        server_name localhost;
        location / {
            proxy_pass http://targetserver;
        }
}
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
负载均衡策略:
请添加图片描述

前后端分离开发

介绍

前后端分离开发,就是指在项目开发过程中,对于前端代码的开发由专门的 前端开发人员 负责,后端代码则由 后端开发人员 负责,这样可以做到分工明确,各司其职,提高开发效率,前后端代码并行开发,可以加快项目开发进度。目前,前后端分离开发方式已经被越来越多的公司所采用,成为当前项目开发的主流开发方式。
前后端分离开发后,从工程结构上也会发生变化,即前后端代码不会混合在一个 Maven 工程中,而是分为 前端工程后端工程
请添加图片描述

开发流程

请添加图片描述
接口 / API 就是一个 http 的请求地址,主要就是去定义:请求路径、请求方式、请求参数、响应数据等内容。
请添加图片描述

前端技术栈

开发工具:VSCode、hbuilder
技术框架:node.js、vue、ElementUI、mock、webpack

YApi

介绍

YApi 是高效、易用、功能强大的 api 管理平台,旨在为开发、产品、测试人员提供更优雅的接口管理服务。可以帮助开发者轻松创建、发布、维护 API,YApi 还为用户提供了优秀的交互体验,开发人员只需利用平台提供的接口数据写入工具以及简单的点击操作就可以实现接口的管理。
源码地址:https://github.com/YMFE/yapi
要使用 YApi, 需要自己进行部署。
请添加图片描述
请添加图片描述
cd /usr/local/node/bin/my-yapi/
node vendors/server/app.js

请添加图片描述

使用方式

添加项目:
请添加图片描述
添加分类:
请添加图片描述
添加接口:
请添加图片描述
请添加图片描述
编辑接口:
请添加图片描述
请添加图片描述
请添加图片描述
导出接口:
请添加图片描述
请添加图片描述
导入接口:
请添加图片描述
请添加图片描述

Swagger

介绍

使用 Swagger 你只需要按照它的规范去定义接口及接口相关的信息,再通过 Swagger 衍生出来的一系列项目和工具,就可以做到生成各种格式的接口文档,以及在线接口调试页码等。
官网:https://swagger.io/
knife4j 是为 Java MVC 框架集成 Swagger 生成 API 文档的增强解决方案。

使用方式

导入 knife4j 的 maven 坐标

        <dependency>
            <groupId>com.github.xiaoymin</groupId>
            <artifactId>knife4j-spring-boot-starter</artifactId>
            <version>3.0.2</version>
        </dependency>

导入 knife4j 相关配置类 (WebMvcConfig)

@EnableSwagger2
@EnableKnife4j

    @Bean
    public Docket createRestApi() {
        // 文档类型
        return new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo())
                .select()
                .apis(RequestHandlerSelectors.basePackage("com.weixinyang.reggie.controller"))
                .paths(PathSelectors.any())
                .build();
    }

    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("瑞吉外卖")
                .version("1.0")
                .description("瑞吉外卖接口文档")
                .build();
    }

设置静态资源 (WebMvcConfig 类中的 addResourceHandlers 方法) ,否则接口文档页面无法访问

        registry.addResourceHandler("doc.html").addResourceLocations("classpath:/META-INF/resources/");
        registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");

在 LoginCheckFilter 中设置不需要处理的请求路径
请添加图片描述
启动项目后,浏览器访问 http://localhost:8080/doc.html:
请添加图片描述
调试:
请添加图片描述
请添加图片描述
请添加图片描述
导出文档:
请添加图片描述

常用注解

请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述

项目部署

部署架构

请添加图片描述

部署环境说明

服务器:
192.168.126.100 (服务器 A)
Nginx : 部署前端项目、配置反向代理
MySQL : 主从复制中的主库
192.168.126.101 (服务器 B)
jdk : 运行 java 项目
git : 版本控制工具
maven : 项目构建工具
jar : SpringBoot 项目打成 jar 包基于内置 Tomcat 运行
MySQL : 主从复制中的从库
Redis : 缓存中间件

部署前端项目

在服务器 A 中安装 Nginx, 将课程资料中的 dist 目录上传到 Nginx 的 html 目录下:
请添加图片描述
修改 Nginx 配置文件 nginx.conf

        location / {
            root   html/dist;
            index index.html;
        }

        # 反向代理配置
        location ^~ /api/ {
                rewrite ^/api/(.*)$ /$1 break;
                proxy_pass http://192.168.126.101:8080;
        }

请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述

部署后端项目

在服务器 B 中安装 jdk、git、maven、MySQL, 使用 git clone 命令将 git 远程仓库的代码克隆下来
git clone https://gitee.com/weixinyang1980/reggie_take_out.git
请添加图片描述
将资料中提供的 reggieStart.sh 文件上传到服务器 B,通过 chmod 命令设置执行权限:
chmod 777 reggieStart.sh
请添加图片描述
执行 reggieStart.sh 脚本文件,自动部署项目:
./reggieStart.sh
请添加图片描述
启动 Redis 服务:
请添加图片描述

再次点登录,登录成功:
请添加图片描述
问题:图片展示不出来:
请添加图片描述
查看报错日志:
请添加图片描述
请添加图片描述
修改图片存放路径 (application.yml) 并推送到远程仓库:
请添加图片描述
再次执行脚本文件:
请添加图片描述
将本地 d 盘下的 img 文件夹上传至 /usr/local:
请添加图片描述
再次访问,图片可以正常展示了:
请添加图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/365906.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

通过连接另一个数组的子数组得到一个数组

给你一个长度为 n 的二维整数数组 groups &#xff0c;同时给你一个整数数组 nums 。 你是否可以从 nums 中选出 n 个 不相交 的子数组&#xff0c;使得第 i 个子数组与 groups[i] &#xff08;下标从 0 开始&#xff09;完全相同&#xff0c;且如果 i > 0 &#xff0c;那么…

Python四大主题之一【 Web】 编程框架

目前Python的网络编程框架已经多达几十个&#xff0c;逐个学习它们显然不现实。但这些框架在系统架构和运行环境中有很多共通之处&#xff0c;本文带领读者学习基于Python网络框架开发的常用知识,及目前的4种主流Python网络框架&#xff1a;Django、Tornado、Flask、Twisted。 …

论文复现:风电、光伏与抽水蓄能电站互补调度运行(MATLAB-Yalmip全代码)

论文复现:风电、光伏与抽水蓄能电站互补调度运行(MATLAB-Yalmip全代码) 针对风电、光伏与抽水蓄能站互补运行的问题,已有大量通过启发式算法寻优的案例,但工程上更注重实用性和普适性。Yalmip工具箱则是一种基于MATLAB平台的优化软件工具箱,被广泛应用于工程界优化问题和…

JVM知识最强总结

类加载运行全过程1.java.exe调用底层jvm.dll创建java虚拟机&#xff0c;2.创建引导类加载器实例&#xff0c;3.完成实例的创建后需要一个启动器&#xff0c;这个是通过sun.misc.launcher类实现&#xff0c;通过该类的getLaunch()方法启动&#xff0c;4.然后调用该类的getClassL…

梳理数字资产,亚马逊云科技助力致盛咨询带来药物研发新福音

作为医疗保健领域的咨询公司&#xff0c;ZS需要保证服务可靠性、敏捷性和安全性的同时&#xff0c;获得经济效益。亚马逊云科技丰富的云服务产品简化了ZS基础架构的搭建&#xff0c;为ZS节省了大量的人力与资金成本。同时&#xff0c;缩短了ZS扩展基础设施的周转时间&#xff0…

一篇文章读懂Android Framework

本文旨在将Framework的框架描绘出来&#xff0c;希望抛砖引玉&#xff0c;对于读者有一定的帮助。前言写在前面&#xff1a;1、有没有必要学习linux内核&#xff1f;我认为是很有必要的。学习linux内核有助于我们加深对一些概念的理解&#xff0c;比如“进程”、“线程”。推荐…

JVM18运行时参数

4. JVM 运行时参数 4.1. JVM 参数选项 官网地址&#xff1a;https://docs.oracle.com/javase/8/docs/technotes/tools/windows/java.html 4.1.1. 类型一&#xff1a;标准参数选项 > java -help 用法: java [-options] class [args...](执行类)或 java [-options] -jar …

图解LeetCode——剑指 Offer 47. 礼物的最大价值

一、题目 在一个 m*n 的棋盘的每一格都放有一个礼物&#xff0c;每个礼物都有一定的价值&#xff08;价值大于 0&#xff09;。你可以从棋盘的左上角开始拿格子里的礼物&#xff0c;并每次向右或者向下移动一格、直到到达棋盘的右下角。给定一个棋盘及其上面的礼物的价值&…

mongoTemplate Aggregation 多表联查 排序失效问题解决

目录说明说明 接着上一个文章的例子来说&#xff1a;mongoTemplate支持多表联查 排序 条件筛选 分页 去重分组 在按照上一个demo的代码执行后&#xff0c;可能会发生排序失效的问题&#xff0c;为什么说可能呢&#xff1f;每个人负责业务不同&#xff0c;不可能是最简单的dem…

树莓派CM4基础设置

安装系统1.1 软件和硬件准备硬件&#xff1a;CM4&#xff08;4GB DDR32GB EMMC 板载WIFI和蓝牙&#xff09;CM4-to-Pi4-Adapter软件&#xff1a;Raspberry Pi或者 Win32DiskImagerRaspberry Pi下载链接&#xff1a;点击直接下载Win32DiskImager下载链接&#xff1a;链接&#x…

el-table大数据量渲染卡顿问题

1、场景描述 在项目开发中&#xff0c;遇到在表格中一次性加载完的需求&#xff0c;且加载数量不少&#xff0c;有几百几千条&#xff0c;并且每条都可能有自己的下拉框&#xff0c;输入框来做编辑功能&#xff0c;此时普通的el-table肯定会导致浏览器卡死&#xff0c;那么怎么…

【Python小程序】怀旧经典 | 特色玩法,代码版本的钢琴小游戏了解下?初学钢琴,能提高双手协调与反应能力哦~(源码分享)

导语 哈喽&#xff0c;我是木木子鸭&#xff01; 最近给大家悄悄的更新了一些关于爬虫的内容呢~有想学习爬虫的小可爱可以学习一整子啦。 今天来给大家写一款界面化的&#xff08;Tkinter&#xff09;电子钢琴小程序。 ​ 所有文章完整的素材源码都在&#x1f447;&#x1…

Qt程序使用路径方式和注意事项

Qt程序使用路径方式和注意事项 更多精彩内容&#x1f449;个人内容分类汇总 &#x1f448;&#x1f449;Qt开发经验 &#x1f448;文章目录Qt程序使用路径方式和注意事项[toc]前言一、Windows下Qt程序使用路径1.准备工作2.测试结果二、Linux下Qt程序使用路径1.准备工作2.测试结…

Python如何实现自动登录和下单的脚本,请看selenium的表演

前言 学python对selenium应该不陌生吧 Selenium 是最广泛使用的开源 Web UI&#xff08;用户界面&#xff09;自动化测试套件之一。Selenium 支持的语言包括C#&#xff0c;Java&#xff0c;Perl&#xff0c;PHP&#xff0c;Python 和 Ruby。目前&#xff0c;Selenium Web 驱动…

某餐厅系统网络故障分析案例

背景 针对食堂经营企业&#xff0c;某堂食软件为客户提供优化堂食就餐流程、提高食堂服务水平和管理效率。 某上海客户使用该堂食系统&#xff0c;在就餐高峰时段&#xff0c;总是出现支付、点餐等操作缓慢&#xff0c;动辄一个操作需要等待几十秒。该客户联系软件厂商&#…

浮点数在内存中的存储——“C”

各位CSDN的uu们你们好呀&#xff0c;今天&#xff0c;小雅兰的内容是浮点数在内存中的存储&#xff0c;昨天我们已经写过了整型在内存中的存储&#xff0c;那么&#xff0c;浮点数在内存中是怎样存储的呢&#xff1f;现在&#xff0c;就让我们进入浮点数在内存中的存储的世界吧…

超实用的公众号运营攻略分享,纯干货

很多小伙伴抱怨&#xff0c;公众号运营真的越来越难做了&#xff01; 每天会因为少得可怜的阅读量发愁&#xff0c;每天会因为纠结写什么选题发愁&#xff0c;每天更会因为公众号没有什么起色而感到无力。 现阶段公众号运营趋于饱和状态&#xff0c;公众号创建门槛低&#xf…

在Pandas中通过时间频率来汇总数据的三种常用方法

当我们的数据涉及日期和时间时&#xff0c;分析随时间变化变得非常重要。Pandas提供了一种方便的方法&#xff0c;可以按不同的基于时间的间隔(如分钟、小时、天、周、月、季度或年)对时间序列数据进行分组。 在Pandas中&#xff0c;有几种基于日期对数据进行分组的方法。我们将…

spark08-spark任务启动环境准备

内容来自尚硅谷1.submitApplication当spark执行任务时会启动java虚拟机&#xff0c;启动一个进程&#xff0c;该进程的名称为SparkSubmit&#xff0c;会执行SparkSubmit中的main方法&#xff0c;该方法中调用了super.doSubmit方法。org.apache.spark.deploy.SparkSubmitdoSubmi…

Python abs() 函数

Python abs() 函数Python 数字描述abs() 函数返回数字的绝对值。语法以下是 abs() 方法的语法:abs( x )参数x -- 数值表达式。返回值函数返回x&#xff08;数字&#xff09;的绝对值。实例以下展示了使用 abs() 方法的实例&#xff1a;#!/usr/bin/python print "abs(-45) …