瑞吉外卖项目详细分析笔记及所有功能补充代码

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

目录

项目刨析简介

#2022年末了记录一下学习的项目实战经验和笔记吧
这个是瑞吉外卖项目补充一些视频里面没有定义的功能和记录一些功能实现逻辑的笔记仅供学习参考本人代码可能不太规范也有可能自己写了有些错误自己没有察觉但是功能自己测试是没有问题的感谢各位的阅览如有问题欢迎指正如有遗漏后续继续补充


技术栈

涉及到的技术有SpringSpringbootMybatis-plusMySQLRedisLinuxGitSpring CacheSharding-JDBCNginxSwagger。Apifox这些工具应该不算技术吧用的工具就不列举了


项目介绍

该项目是一个外卖点餐系统它分为后台管理端和用户移动端两方面开发后台管理端为商家提供管理菜品套餐的服务移动端为用户提供点菜下单功能。最终通过git管理项目并用nginx部署前端tomcat部署后端使用mysql主从复制从库读取主库写入再用shell脚本部署到服务器上。


项目源码

项目码云地址https://gitee.com/dkgk8/reggie-git


一.架构搭建

1.初始化项目结构

新建一个springboot项目
pom导入的坐标

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

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

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

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

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

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

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: 123456
  redis:
    host: localhost
    port: 6379
    database: 0
  cache:
    redis:
      time-to-live: 1800000  #ms ->30min

mybatis-plus:
  configuration:
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      id-type: ASSIGN_ID
reggie:
  path: D:\SpringBoot_Reggie\reggie_take_out\src\main\resources\static\front\hello\

我后面将项目运行在服务器上了所以用了多环境开发本地跑的不用在意这步
在这里插入图片描述
项目大致结构如下
在这里插入图片描述

感觉使用mybatis-plus之后就是
实体类->mapper->service->serviceImpl->controller
这个步骤写程序了

2.数据库表结构设计

在这里插入图片描述
在这里插入图片描述
不每个表展示了这里拿典型的员工表来看
在这里插入图片描述

3.项目基本配置信息添加

导入前端资源
在默认页面和前台页面的情况下直接把这俩拖到resource目录下直接访问是访问不到的因为被mvc框架拦截了其实用springboot可以直接放在static目录下但是仍然不能直接访问前端页面所以这里也可以直接放行static就好了
所以我们要编写一个映射类放行这些资源
WebMvcConfig类
在这里插入图片描述

公共字段的自动填充

在这里插入图片描述

这个我在另一篇文章写了很详细链接自动填充公共字段

全局异常处理类

虽然遇到异常后可以使用try-catch来处理但是代码量一大起来许多的try catch就会很乱代码也不简洁不容易阅读所以我们使用全局异常处理在Common包下
在这里插入图片描述
自定义异常类
在这里插入图片描述

返回结果封装的实体类

为了便于前后端数据传递使用对象的形式封装数据更合适

@Data
public class R<T> implements Serializable {

    private Integer code; //编码1成功0和其它数字为失败

    private String msg; //错误信息

    private T data; //数据

    private Map map = new HashMap(); //动态数据

    public static <T> R<T> success(T object) {
        R<T> r = new R<T>();
        r.data = object;
        r.code = 1;
        return r;
    }

    public static <T> R<T> error(String msg) {
        R r = new R();
        r.msg = msg;
        r.code = 0;
        return r;
    }

    public R<T> add(String key, Object value) {
        this.map.put(key, value);
        return this;
    }

}

二.管理端业务开发

1.员工管理相关业务

1.1员工登录

登录逻辑如下
在这里插入图片描述

    @PostMapping("/login")
    public R<Employee> login(HttpServletRequest request, @RequestBody Employee employee){
        //1.将页面提交的明文密码进行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);
    }

1.2员工退出

就是清除员工登录时存入session的员工id

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

1.3过滤器拦截

现在没有过滤器用户直接不用登录通过url+资源名可以随便访问所以要加个过滤器没有登陆时拦截请求不给访问自动跳转到登陆页面
过滤器处理逻辑
在这里插入图片描述
在启动类上添加注解@ServletComponentScan
过滤器配置类注解@WebFilter(filterName=“拦截器类名首字母小写”urlPartten=“要拦截的路径比如/*”)

判断用户是否已经登录之前因为存入session里面有一个名为employee的对象里面放的时用户id那么只需要用getAttribute看看session里get的数据是否为null就知道他是否在登陆状态

这里提一嘴
调用Spring核心包的字符串匹配类的对象对路径进行匹配并且返回比较结果
如果相等就为true

public static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();

在这里插入图片描述
直接上代码

/**
 * 检查用户是否登录的过滤器
 */

@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();
        //定义不需要处理的请求路径
        String[] urls=new String[]{
                "/employee/login",
                "/employee/logout",
                "/backend/**",
                "/front/**",
                "/common/**",
                "/user/sendMsg",
                "/user/login",
                "/doc.html",
                "/webjars/**",
                "/swagger-resources",
                "/v2/api-docs"
        };
        //2.判断本次请求是否需要处理
        boolean check = check(urls, requestURI);
        //3.如果不需要处理则直接放行
        if (check){
            filterChain.doFilter(request,response);
            return;
        }
        //4-1.判断登录状态如果已经登录则直接放行
        if (request.getSession().getAttribute("employee")!=null){
            Long empId = (Long) request.getSession().getAttribute("employee");
            BaseContext.setCurrentId(empId);
            filterChain.doFilter(request,response);
            return;
        }
        //4-2.判断移动端登录状态如果已经登录则直接放行
        if (request.getSession().getAttribute("user")!=null){
            Long userId = (Long) request.getSession().getAttribute("user");
            BaseContext.setCurrentId(userId);
            filterChain.doFilter(request,response);
            return;
        }
        //5如果未登录则通过输出流方式向客户端页面响应数据
        response.getWriter().write(JSON.toJSONString(R.error("NOTLOGIN")));
        return;

    }

    /**
     * 路径匹配检查本次请求是否需要放行
     */
    public boolean check(String[] urls,String requestURI){
        //遍历的同时调用PATH_MATCHER来对路径进行匹配
        for (String url : urls){
            boolean match = PATH_MATCHER.match(url,requestURI);
            if (match){
                //匹配到了可以放行的路径直接放行
                return true;
            }
        }
        return false;
    }
}

1.4员工信息修改

员工状态修改
在这里插入图片描述

遇到了问题数据库id根据雪花算法有19位而js对Long型数据处理时会丢失精度只能保证前16位
解决办法 服务端给页面响应json数据时将Long型数据统一转为String字符串
在这里插入图片描述
在这里插入图片描述
将Long型的Id转换为String类型的数据

/**
 * 对象映射器:基于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配置类中扩展一个消息转换器

    /**
     * 扩展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);
    }

员工信息修改
修改逻辑分为数据回显和数据保存
数据回显就是根据传来的员工id查询员工信息返回员工对象

    /**
     * 回显用户信息到修改框
     */
    @GetMapping("/{id}")
    public R<Employee> getById(@PathVariable Long id){
        Employee employee = employeeService.getById(id);
        if (employee!=null){

            return R.success(employee);
        }else {
            return R.error("没查到该员工");
        }
    }

数据保存就是将更改后的数据再update到员工表中

    /**
     * 修改员工信息
     */
    @PutMapping
    public R<String> update(@RequestBody Employee employee){
        log.info(employee.toString());

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

测试结果
在这里插入图片描述

1.5员工信息分页查询

分页查询老生常谈了
分页查询业务逻辑
在这里插入图片描述
浏览器发送的url
在这里插入图片描述
分页插件配置类
先弄个MP分页插件配置类config包下创建MybatisPlusConfig类

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

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

在这里插入图片描述
page对象内部

在这里插入图片描述

上代码

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

        //执行查询
        employeeService.page(pageInfo,queryWrapper);
        return R.success(pageInfo);
    }

1.6新增员工

前端传递过来的数据这里我们可以用一个employee员工对象将数据全部接收到
请求 URL: http://localhost:9001/employee POST请求
在这里插入图片描述

基本上都是mp封装好的CRUD直接调用save方法就行了这里不需要改造Employee实体类通用id雪花自增算法来新增id不需要像下面一样因为最开始我们yml中已经配置了雪花自增算法来新增id如下图当然也可以两个方法任选其一。
在这里插入图片描述
在这里插入图片描述

    /**
     * 新增员工
     */
    @PostMapping
    public R<String> save(@RequestBody Employee employee){
        log.info("新增员工员工信息{}",employee.toString());
        //设置初始密码123456需要进行md5加密处理
        employee.setPassword(DigestUtils.md5DigestAsHex("123456".getBytes()));

        employeeService.save(employee);
        return R.success("新增员工成功");
    }

2.分类管理相关业务

2.1分类的分页查询

在这里插入图片描述
还是那几步老生常谈

1.创建分页构造器 Page pageInfo = new Page(page,pageSize);
2.如果有需要条件过滤的加入条件过滤器LambaQueryWarpper
3.注入的service对象已经继承MP的BaseMapper接口去调用Page对象
4.service对象.page(分页信息,条件过滤器)
返回结果就可以了

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

2.2新增分类

在这里插入图片描述
根据前端发送的请求接收传递的数据再调用mp封装好的crud往数据库表里插入数据就完了没什么好说的

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

2.3菜品或套餐的分类修改

修改又是老套路先回显数据再修改数据
在这里插入图片描述
前端发送的请求
在这里插入图片描述
就两步走都是调用mp封装的方法

2.4菜品或套餐的分类删除

在这里插入图片描述
完善一下如果当前菜品分类下有菜品的话就不许删除
删除之前需要先做判断才可以删除若当前分类下有菜品我们要抛出异常进行提示
因为没有返回异常信息的类我们这里要做一个自定义的专门返回异常信息的类CustomerException
因为我们之前创建了一个全局异常处理也要用上因为要拦截异常统一处理
在这里插入图片描述

    /**
     * 根据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<>();
        setmealLambdaQueryWrapper.eq(Setmeal::getCategoryId,id);
        int count2 = setmealService.count(setmealLambdaQueryWrapper);
        if (count2>0){
            //已关联套餐抛出一个业务异常
            throw new CustomException("当前分类下关联了套餐不能删除");
        }
        //正常删除分类
        super.removeById(id);
    }

3.菜品管理相关业务

3.1分页查询

这个分页查询不是老生常谈如果只是菜品表的分页查询你会发现最后分页查询出来的菜品分类一栏为空白因为前端需要的菜品分类名称的数据dish表的分页查询数据中并没有所以我们需要使用到DishDto的分页查询了根据dish表的分类id来条件查询
在这里插入图片描述

打开dish表可以看到只有菜品分类id字段并没有菜品名称
在这里插入图片描述

创建一个DishDto类
在这里插入图片描述

在这里插入图片描述

这个是经典的Dto的分页查询上代码

    /**
     * 菜品管理的分页查询
     */
    @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();
            Category category=categoryService.getById(categoryId);
            if(category!=null){
                String categoryName=category.getName();
                dishDto.setCategoryName(categoryName);
            }
            return dishDto;
        }).collect(Collectors.toList());
        dishDtoPage.setRecords(list);
        return R.success(dishDtoPage);
    }

3.2图片上传下载

在这里插入图片描述

具体的存储路径写在配置文件里了用@Value注入到业务里就可以了

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

此时我们上传图片后是存放在临时位置关闭浏览器图片文件就不存在了无法再次浏览我们需要将上传的图片下载到本地磁盘存储这样浏览器上就可以进行图片回显访问的时候才能看到图片
在这里插入图片描述
前端展示图片发送请求的代码

在这里插入图片描述

在这里插入图片描述

用到了I/O的输入输出流算是复习了

    /**
     * 文件下载,图片回显浏览器
     */
    @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){//用while循环一直写写到-1证明写完了
                outputStream.write(bytes,0,len);
                outputStream.flush();
            }
            //关闭资源
            outputStream.close();
            fileInputStream.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

3.3新增菜品

场景描述
在这里插入图片描述
开发逻辑
在这里插入图片描述
在这里插入图片描述
type为1是菜品type为2是套餐
在这里插入图片描述

    /**
     * 根据条件查询分类数据,返回到菜品管理的下拉框里去
     */
    @GetMapping("/list")
    public R<List<Category>> list(Category category){
        //条件构造器
        LambdaQueryWrapper<Category> queryWrapper = new LambdaQueryWrapper<>();
        //添加条件 type为1是菜品为2是套餐
        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);
    }

接下来是多表存入mp没有提供对应的api接口需要自己写老套路service接口声明方法实现类完善实现业务控制层的controller直接调用

注意这里还要加入事务进行控制防止多表操作崩溃同成功或同失败。@Transactional 开启事务
@EnableTransactionManagement 在启动类加入支持事务开启
根据前端传递数据我们可以用DishDto对象来接收
分两张表存入先将基本信息存入dish表再将口味信息存入dish_flavor表
根据dish_flavor的表结构可以看到我们还需要把dish_id存入dish_flavor表中
在这里插入图片描述

    /**
     * 新增菜品同时保存对应的口味数据
     * @param dishDto
     */
    @Override
    public void saveWithFlavor(DishDto dishDto) {
        //保存菜品的基本信息到菜品表dish中
        this.save(dishDto);

        Long dishId = dishDto.getId();
        //菜品口味
        List<DishFlavor> flavors = dishDto.getFlavors();
        flavors = flavors.stream().map((item)->{
            item.setDishId(dishId);//把dish_id存入dish_flavor表中
            return item;
        }).collect(Collectors.toList());
        //保存菜品口味数据到菜品表中去dish_flavor
        dishFlavorService.saveBatch(flavors);
    }

3.4修改菜品

修改菜品第一步回显数据第二步更新数据

这里回显数据就涉及到了多表联查先根据前端传递的菜品id查询出菜品基本信息将菜品信息拷贝到dishDto对象中再根据菜品id对dish_flavor表进行条件查询将查询出来的口味信息也拷贝到dishDto对象里最终返回dishDto

更新数据也是两表分别更新先调用mp的updateById方法更新dish表里的数据然后dish_flavor表需要先清除该菜品下的口味信息再将修改的口味信息插入到dish_flavor表中更新dish_flavor表时对于前端没有传递的字段数据需要我们自己set进去
在这里插入图片描述

在这里插入图片描述
回显代码

    @Override
    public DishDto getByIdWithFlavor(Long id) {
        //查询菜品基本信息从dish表查询
        Dish dish = this.getById(id);
        DishDto dishDto = new DishDto();
        //将菜品基本信息拷贝到dishDto中
        BeanUtils.copyProperties(dish,dishDto);
        //查询当前菜品对应的口味信息从dish_flavor表查询
        LambdaQueryWrapper<DishFlavor> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(DishFlavor::getDishId,dish.getId());
        //将该菜品的口味信息查询出来存入list集合中
        List<DishFlavor> flavors = dishFlavorService.list(queryWrapper);
        //set到dto的属性里
        dishDto.setFlavors(flavors);
        return dishDto;
    }

更新代码

    @Override
    @Transactional
    public void updateWithFlavor(DishDto dishDto) {

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

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

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

        dishFlavorService.saveBatch(flavors);//批量保存
    }

3.5删除菜品

前端发送请求的url
在这里插入图片描述

在DishFlavor实体类中在private Integer isDeleted;字段上加上@TableLogic注解表示删除是逻辑删除由mybatis-plus提供的,由于删除前需要判断菜品的售状态这里将remove方法抽出来写再service实现类中

   /**
     *套餐批量删除和单个删除
     * @param ids
     */
    @Override
    @Transactional
    public void deleteByIds(List<Long> ids) {

        //构造条件查询器
        LambdaQueryWrapper<Dish> queryWrapper = new LambdaQueryWrapper<>();
        //先查询该菜品是否在售卖如果是则抛出业务异常
        queryWrapper.in(ids!=null,Dish::getId,ids);
        List<Dish> list = this.list(queryWrapper);
        for (Dish dish : list) {
            Integer status = dish.getStatus();
            //如果不是在售卖,则可以删除
            if (status == 0){
                this.removeById(dish.getId());
            }else {
                //此时应该回滚,因为可能前面的删除了但是后面的是正在售卖
                throw new CustomException("删除菜品中有正在售卖菜品,无法全部删除");
            }
        }

    }

controller直接调用service中的方法即可

    /**
     * 套餐批量删除和单个删除
     * @return
     */
    @DeleteMapping
    public R<String> delete(@RequestParam("ids") List<Long> ids){
        //删除菜品  这里的删除是逻辑删除
        dishService.deleteByIds(ids);
        //删除菜品对应的口味  也是逻辑删除
        LambdaQueryWrapper<DishFlavor> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.in(DishFlavor::getDishId,ids);
        dishFlavorService.remove(queryWrapper);

        //清理所有菜品的缓存数据
        Set keys = redisTemplate.keys("dish_*");
        redisTemplate.delete(keys);
        return R.success("菜品删除成功");
    }

3.6菜品停售与起售补充

前端发送的url
在这里插入图片描述

业务逻辑
将前端传递的id集合来进行菜品的条件查询然后遍历查询出来的数据集合将前端传递的status直接set到每个dish对象中完成菜品状态修改

    /**
     * 对菜品批量或者是单个 进行停售或者是起售
     * @return
     */
    @PostMapping("/status/{status}")
//这个参数这里一定记得加注解才能获取到参数否则这里非常容易出问题
    public R<String> status(@PathVariable("status") Integer status,@RequestParam List<Long> ids){
        //log.info("status:{}",status);
        //log.info("ids:{}",ids);
        LambdaQueryWrapper<Dish> queryWrapper = new LambdaQueryWrapper();
        queryWrapper.in(ids !=null,Dish::getId,ids);
        //根据传入的id集合进行批量查询
        List<Dish> list = dishService.list(queryWrapper);

        for (Dish dish : list) {
            if (dish != null){
                dish.setStatus(status);
                dishService.updateById(dish);
            }
        }
        //清理所有菜品的缓存数据
        Set keys = redisTemplate.keys("dish_*");
        redisTemplate.delete(keys);

        return R.success("售卖状态修改成功");
    }

4.套餐管理相关业务

4.1分页查询

和菜品分页差不多将套餐信息分页查询出来通过stream流方式将套餐信息拷贝到SetmealDto中再根据套餐id查询套餐分类对象将套餐分类信息也拷贝到SetmealDto中最后返回一个dtoPage和菜品管理的分页几乎一样就不上代码了

4.2新增套餐

在这里插入图片描述

和新增菜品差不多根据前端发送的url请求这里也是多表的操作分别操作setmeal表和setmeal_dish表前端提交的数据save到setmeal_dish表再自己set补全etmeal_dish表数据。

4.3修改套餐

老套路将setmeal表和setmeal_dish表两表数据查询出来回显再分别更新两表内容。和dish菜品管理一样的套路不再赘述
我把它俩都抽到service实现类里写了

    @Override
    public SetmealDto getByIdWithDishes(Long id) {
        //查询套餐基本信息从setmeal表查询
        Setmeal setmeal = this.getById(id);
        SetmealDto setmealDto = new SetmealDto();
        BeanUtils.copyProperties(setmeal,setmealDto);
        //查询当前套餐对应的菜品信息从setmeal_dish表查询
        LambdaQueryWrapper<SetmealDish> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(SetmealDish::getSetmealId,setmeal.getId());
        List<SetmealDish> dishes = setmealDishService.list(queryWrapper);
        setmealDto.setSetmealDishes(dishes);
        return setmealDto;
    }

    @Override
    @Transactional
    public void updateWithDishes(SetmealDto setmealDto) {
        //更新setmeal表的基本信息
        this.updateById(setmealDto);
        //清理当前套餐对应的菜品数据---setmeal_dish表的delete操作
        LambdaQueryWrapper<SetmealDish> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(SetmealDish::getSetmealId,setmealDto.getId());
        setmealDishService.remove(queryWrapper);
        //添加当前提交过来的菜品数据---setmeal_dish表的insert操作
        List<SetmealDish> dishes = setmealDto.getSetmealDishes();
        dishes = dishes.stream().map((item)->{
            item.setId(IdWorker.getId());
            item.setSetmealId(setmealDto.getId());
            return item;
        }).collect(Collectors.toList());
        setmealDishService.saveBatch(dishes);
    }

4.4删除套餐

和删除菜品一样也是需要先判断套餐状态删除的时候套餐下的关联关系也要删除掉要处理setmeal和setmeal_dish两张表

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

        int count = this.count(queryWrapper);
        if (count>0){
            //如果不能删除抛出一个业务异常
            throw new CustomException("套餐正在售卖中不能删除");
        }
        //如果可以删除先删除套餐表中的数据——setmeal
        this.removeByIds(ids);

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

4.5套餐停售与起售补充

前端发送的url请求
在这里插入图片描述
跟菜品的起售和停售差不多

    /**
     * 批量起售停售
     */
    @PostMapping("/status/{status}")
    @CacheEvict(value = "setmealCache", allEntries = true)
    public R<String> status(@PathVariable("status") Integer status,@RequestParam List<Long> ids) {
        LambdaQueryWrapper<Setmeal> queryWrapper=new LambdaQueryWrapper<>();
        queryWrapper.in(ids!=null,Setmeal::getId,ids);
        List<Setmeal> setmeals = setmealService.list(queryWrapper);
        for (Setmeal setmeal : setmeals) {
            if (setmeal!=null){
                setmeal.setStatus(status);
                setmealService.updateById(setmeal);
            }
        }
        return R.success("售卖状态修改成功");
    }

5.订单明细补充

根据后台管理端的订单明细发出的url可以判断就是个order表的分页查询
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

其实这样就很简单了只是order的单表分页查询

    /**
     * 后台显示订单信息
     */
    @GetMapping("/page")
    public R<Page> page(int page, int pageSize, Long number, String beginTime, String endTime) {
        log.info("page={},pageSize={},number={},beginTime={},endTime={}",page,pageSize,number,beginTime,endTime);
        //分页构造器对象
        Page<Orders> pageInfo = new Page<>(page,pageSize);
        //构造条件查询对象
        LambdaQueryWrapper<Orders> queryWrapper = new LambdaQueryWrapper<>();
        //链式编程写查询条件
        queryWrapper.like(number!=null,Orders::getNumber,number)
                //前面加上判定条件是十分必要的用户没有填写该数据查询条件上就不添加它
                .gt(StringUtils.isNotBlank(beginTime),Orders::getOrderTime,beginTime)//大于起始时间
                .lt(StringUtils.isNotBlank(endTime),Orders::getOrderTime,endTime);//小于结束时间
        ordersService.page(pageInfo,queryWrapper);
        return R.success(pageInfo);
    }

但是我们发现一个问题后端展示数据并没有用户名
在这里插入图片描述

其实没有用户名很正常因为我们的order表中并没有username字段所以自然是查不出来但是可以看到order表里是有user_id字段的但是user表里也没有username这个字段因此我想到了两个方法
在这里插入图片描述
方法一user表中先添加username字段然后后台显示订单信息这个分页查询得数据username

方法二我喜欢简单偷懒点直接把order表里的consignee收货人的名字取出来作为这里分页查询页面的用户名

在这里插入图片描述
这里将前端的username换成consignee就可以显示用户名了
在这里插入图片描述

在这里插入图片描述

后台订单状态的修改

在这里插入图片描述

携带参数为status这样就很明了是根据订单id修改订单的状态就是一个修改操作
在这里插入图片描述

    /**
     * 修改订单状态
     */
    @PutMapping
    public R<String> orderStatusChange(@RequestBody Map<String,String> map){

        String id = map.get("id");
        Long orderId = Long.parseLong(id);//将接收到的id转为Long型
        Integer status = Integer.parseInt(map.get("status"));//转为Integer型

        if(orderId == null || status==null){
            return R.error("传入信息非法");
        }
        Orders orders = ordersService.getById(orderId);//根据订单id查询订单数据
        orders.setStatus(status);//修改订单对象里的数据
        ordersService.updateById(orders);

        return R.success("订单状态修改成功");

    }

三.移动端业务开发

1.用户登录与退出退出为补充

用户登录
点击登录发送请求
在这里插入图片描述

负载将手机号和验证码一起提交过来我们可以用map的key-value的形式来接收key是phonevalue是code
在这里插入图片描述
代码实现
在这里插入图片描述

用户退出根据请求的url编写退出功能
在这里插入图片描述

    /**
     * 用户退出
     * @param request
     * @return
     */
    @PostMapping("/loginout")
    public R<String> loginout(HttpServletRequest request){
        request.getSession().removeAttribute("user");
        return R.success("退出成功");
    }

在这里插入图片描述

2.阿里云短信验证码

utils包下导入这个两个工具类其实也可以去阿里云api文档复制
在这里插入图片描述

在这里插入图片描述

至于如何获取accessKeyId如下步骤
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
给新建用户添加权限
在这里插入图片描述

在UserController的发送验证码的方法中调用阿里云提供的短信发送的api

在这里插入图片描述

@PostMapping("/sendMsg")
    public R<String> sendMsg(@RequestBody User user, HttpSession session){
        //获取手机号
        String phone = user.getPhone();
        if (StringUtils.isNotBlank(phone)){
            //生成随机的4位验证码
            String code = ValidateCodeUtils.generateValidateCode(4).toString();
            log.info("code={}",code);
            //调用阿里云提供的短信服务API完成发送短信
            SMSUtils.sendMessage("你自己的签名","你自己的模板code",phone,code);

            //需要将生成的验证码保存到Session
            //session.setAttribute(phone,code);

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

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

在这里插入图片描述

最后效果如下
在这里插入图片描述

3.收货地址

首先分析这个地址管理要实现的功能这里肯定是要把所有地址展示出来所以需要一个查询所有的功能其次就是新增地址有一个新增操作还有修改地址修改地址逻辑又分回显和修改回显就是根据传的id查询基本的sql可以通过mp提供的方法直接调用即可。
注意这个查询所有并不能直接用mp提供的list方法来查询需要使用到条件查询因为这里是根据user_id来查询的并不是要展示地址表里所有的数据

在这里插入图片描述
查询所有的代码如下

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

还有一个是将地址设置为默认地址其实就是根据条件修改地址表里的is_default字段给设置为1即为默认地址但默认地址只能由一个其逻辑为若要更改默认地址则将该用户的所有地址的is_default字段给更新为0在把用户传来要设置成默认地址的地址id来修改该条地址的is_default字段为1即可。

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

下面是查询默认地址的代码

    /**
     * 查询默认地址
     */
    @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);
        }
    }

4.菜品和套餐展示

此时后端已经将数据传递过来了从负载可以看到传来的json但前端没有进行展示
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

但是现在之前写后台管理端的那段查询所有菜品的代码已经不适用了因为移动端还需要展示分类名称和口味数据所以这里查询不能再返回dish对象应该使用DishDto返回一个DishDto泛型的list集合代码需要修改一下

    @GetMapping("/list")
    public R<List<DishDto>> list(Dish dish){
        List<DishDto> dishDtoList = null;

        //动态构造key
        String key = "dish_" + dish.getCategoryId() + "_" + dish.getStatus();//dish_13494852934_1
        //从redis中获取缓存数据移动端使用redis缓存将每个分类下查询的数据都放到缓存避免重复查询降低服务器压力
        dishDtoList = (List<DishDto>) redisTemplate.opsForValue().get(key);
        //如果从redis获取的数据不为空证明redis缓存了该数据直接取出来返回就无需查询数据库
        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 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不存在该数据需要查询数据库将查询菜品数据缓存到redis中
        redisTemplate.opsForValue().set(key,dishDtoList,60, TimeUnit.MINUTES);
        return R.success(dishDtoList);
    }

套餐数据的展示也是类似根据发送的url用setmeal对象来接收数据写后端代码展示的套餐没有口味之类的数据所以直接返回Setmeal的list集合就行
在这里插入图片描述

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

5.菜品选规格

点击菜品旁边的选规格需要展示选择口味数据弹窗在list查询方法追加以下代码
在这里插入图片描述

在这里插入图片描述

代码debug调试可以看到
在这里插入图片描述

6.套餐点击展示补充

当点击套餐时会发送一个get请求url如下我猜测应该是展示该套餐中的菜品数据我们可以给他返回一个DishDto因为这个展示还涉及到一个菜品份数数据根据f12响应那栏看出这个数据光dish是展示不出来的所以我们返回DishDto对象。
在这里插入图片描述

在这里插入图片描述

    /**
     * 点击查看套餐中的菜品
     */
    @GetMapping("/dish/{id}")
    public R<List<DishDto>> dish(@PathVariable("id") Long SetmealId) {
        LambdaQueryWrapper<SetmealDish> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(SetmealDish::getSetmealId, SetmealId);
        //获取套餐里面的所有菜品  这个就是SetmealDish表里面的数据
        List<SetmealDish> list = setmealDishService.list(queryWrapper);

        List<DishDto> dishDtos = list.stream().map((setmealDish) -> {
            DishDto dishDto = new DishDto();
            //将套餐菜品关系表中的数据拷贝到dishDto中
            BeanUtils.copyProperties(setmealDish, dishDto);
            //这里是为了把套餐中的菜品的基本信息填充到dto中比如菜品描述菜品图片等菜品的基本信息
            Long dishId = setmealDish.getDishId();
            Dish dish = dishService.getById(dishId);
            //将菜品信息拷贝到dishDto中
            BeanUtils.copyProperties(dish, dishDto);

            return dishDto;
        }).collect(Collectors.toList());

        return R.success(dishDtos);
    }

在这里插入图片描述

7.购物车

购物车的表结构
在这里插入图片描述

记得把之前main.js下的注释打开前面再展示菜品套餐时给注释了。
在这里插入图片描述

需求分析
按下图来看首先加入购物车是新增操作往购物车表里save数据还有购物车展示是查询该用户下的购物车所有数据按用户id查询加和减就是对应的修改表中的number字段。

但是注意加的时候如果购物车没有数据就需要save如果有数据则直接修改number字段加1即可减的时候如果购物车中的number为1了再减就是remove该条数据否则直接修改number字段减1即可。

    /**
     * 从购物车中减掉
     */
    @PostMapping("/sub")
    public R<String> remove(@RequestBody ShoppingCart shoppingCart){
        //设置用户id指定当前时哪个用户的购物车数据
        Long currentId = BaseContext.getCurrentId();
        shoppingCart.setUserId(currentId);
        //查询当前菜品或者套餐是否在购物车中
        Long dishId = shoppingCart.getDishId();
        LambdaQueryWrapper<ShoppingCart> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(ShoppingCart::getUserId,currentId);

        if (dishId!=null){
            //添加到购物车的是菜品
            queryWrapper.eq(ShoppingCart::getDishId,dishId);
        }else {
            //添加到购物车的是套餐
            queryWrapper.eq(ShoppingCart::getSetmealId,shoppingCart.getSetmealId());
        }
        //SQL:select * from shopping_cart where user_id = ? and dish_id = ?
        ShoppingCart cartServiceOne = shoppingCartService.getOne(queryWrapper);

        if (cartServiceOne.getNumber()>1){
            //如果已经存在就在原来数量基础上减去一
            Integer number = cartServiceOne.getNumber();
            cartServiceOne.setNumber(number-1);
            shoppingCartService.updateById(cartServiceOne);
        }else {

            shoppingCartService.remove(queryWrapper);
        }
        return R.success("减去成功");
    }

清空购物车就是根据user_idremove该用户下的所有数据

在这里插入图片描述

在这里插入图片描述

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

8.下订单

需求分析
在这里插入图片描述

在这里插入图片描述

点击去支付根据发送的url请求根据前端传递的数据我们可以用order对象来接收
在这里插入图片描述
由于传过来的只有三条数据order对象的其他属性需要我们补全再插入到订单表中
在这里插入图片描述

具体编码步骤如下
1.获取当前用户id
2.查询当前用户的购物车数据
3.查询用户数据
4.查询地址数据
5.向订单表插入数据一条数据由于前端只传递了addressBookId,payMethod,remark三条数据其他的需要我们上面查询出来的购物车用户地址数据set到order对象中去然后调用mp的save方法向数据库存入order
6.向订单明细表插入数据多条数据和上面一样需要手动将数据存入orderDetails对象中然后调用mp的saveBatch方法将orderDetails插入表中
7.清空购物车数据

9.收货地址删除补充

根据浏览器发送的url不难看出大概就是一个地址表根据id删除的操作但这个ids不是restful风格是直接拼接的形式所以形参那里传值为 @RequestParam(“ids”) Long id
在这里插入图片描述

    @DeleteMapping()
    public R<String> detele(@RequestParam("ids") Long id){
        log.info("id={}",id);

//        if (id == null){
//            return R.error("请求异常");
//        }  //感觉这个判断没太大必要前端传的id必不能为空为空的话地址就不会展示出来更不会有这个删除按钮存在简单说为空的话连删除的机会都没有所以判断没太大必要

//        LambdaQueryWrapper<AddressBook> queryWrapper = new LambdaQueryWrapper<>();
//        queryWrapper.eq(AddressBook::getId,id).eq(AddressBook::getUserId,BaseContext.getCurrentId());
//        addressBookService.remove(queryWrapper);

        //别人说直接使用这个removeById不太严谨但我个人认为就是没登录状态进入该页面是执行不了删除操作的别说删除连查询这个地址信息都不会展示全被过滤器拦截了
        //所以用上面的条件查询好像意义不大当然你也可以放弃这个简单的removeById用上面注释的条件删除
        addressBookService.removeById(id);
        return R.success("删除成功");
    }

在这里插入图片描述

10.用户支付后查看订单补充

浏览器发送的url如下就是将order_detail表里的数据根据订单id进行条件查询本来以为只是一个简单单表分页查询结果踩坑了

orderDetail的表结构
在这里插入图片描述

order.html前端页面还需要下面的数据需要后端传递过去只是一个单表分页查询出来的orderDetail对象是没有订单名称等数据的

在这里插入图片描述

在这里插入图片描述

因此这里我们需要使用OrderDto对象将订单的数据和订单明细的数据都存入OrderDto对象中返回的是Dto分页查询的数据
创建一个OrderDto

@Data
public class OrderDto extends Orders {
    private List<OrderDetail> orderDetails;
}

OrderService接口声明一个根据订单id来查询订单明细的数据的方法

public List<OrderDetail> getOrderDetailListByOrderId(Long orderId);

OrdersServiceImpl实现类中实现这个条件查询方法

    public List<OrderDetail> getOrderDetailListByOrderId(Long orderId){
        LambdaQueryWrapper<OrderDetail> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(OrderDetail::getOrderId, orderId);
        //根据order表的条件查询出order_detail的数据因为一个订单可能有多条菜品数据
        List<OrderDetail> orderDetailList = orderDetailService.list(queryWrapper);
        return orderDetailList;
    }

在这里插入图片描述
订单数据的分页查询
在这里插入图片描述

OrderController类下支付后查看订单功能的代码

    @GetMapping("/userPage")
    public R<Page> page(int page, int pageSize){
        //分页构造器对象
        Page<Orders> pageInfo = new Page<>(page,pageSize);
        Page<OrderDto> pageDto = new Page<>(page,pageSize);
        //构造条件查询对象
        LambdaQueryWrapper<Orders> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(Orders::getUserId, BaseContext.getCurrentId());
        //这里是直接把当前用户分页的全部结果查询出来要添加用户id作为查询条件否则会出现用户可以查询到其他用户的订单情况
        //添加排序条件根据更新时间降序排列
        queryWrapper.orderByDesc(Orders::getOrderTime);
        //这里是把所有的订单分页查询出来
        ordersService.page(pageInfo,queryWrapper);

        //对OrderDto进行属性赋值
        List<Orders> records = pageInfo.getRecords();
        List<OrderDto> orderDtoList = records.stream().map((item) ->{//item其实就是分页查询出来的每个订单对象
            OrderDto orderDto = new OrderDto();
            //此时的orderDto对象里面orderDetails属性还是空 下面准备为它赋值
            Long orderId = item.getId();//获取订单id
            //调用根据订单id条件查询订单明细数据的方法把查询出来订单明细数据存入orderDetailList
            List<OrderDetail> orderDetailList = ordersService.getOrderDetailListByOrderId(orderId);

            BeanUtils.copyProperties(item,orderDto);//把订单对象的数据复制到orderDto中
            //对orderDto进行OrderDetails属性的赋值
            orderDto.setOrderDetails(orderDetailList);
            return orderDto;
        }).collect(Collectors.toList());

        //将订单分页查询的订单数据以外的内容复制到pageDto中不清楚可以对着图看
        BeanUtils.copyProperties(pageInfo,pageDto,"records");
        pageDto.setRecords(orderDtoList);
        return R.success(pageDto);
    }

因为前端页面传的是分页数据所以后端就实现了OrderDto的分页查询其实我也有想过只查询当前支付的这个订单就是根据订单id查询OrderDto也不是很难就根据订单id将查询的订单数据和订单明细数据都存入DishDto对象里然后返回DishDto对象就完了。

但前端传递过来的是分页查询的数据有pageSize和page摆明了是分页查询并没有传订单id参数所以我也无法判断当前订单对应的id无法根据订单id查询订单明细数据。
在这里插入图片描述
在这里插入图片描述

11.再来一单补充

在这里插入图片描述

传递过来的是订单id

在这里插入图片描述
①通过上面传递的orderId获取订单明细的数据
②把订单明细的数据的数据塞到购物车表中不过在此之前要先把购物车表中的数据给清除(清除的是当前登录用户的购物车表中的数据)

    /**
     * 再来一单
     * @param map
     * @return
     */
    @PostMapping("/again")
    public R<String> againSubmit(@RequestBody Map<String,String> map){
        String ids = map.get("id");

        long id = Long.parseLong(ids);

        LambdaQueryWrapper<OrderDetail> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(OrderDetail::getOrderId,id);
        //获取该订单对应的所有的订单明细表
        List<OrderDetail> orderDetailList = orderDetailService.list(queryWrapper);

        //通过用户id把原来的购物车给清空这里的clean方法就是之前的购物车清空方法我给写到service中去了这样可以通过接口复用代码
        shoppingCartService.clean();

        //获取用户id
        Long userId = BaseContext.getCurrentId();
        List<ShoppingCart> shoppingCartList = orderDetailList.stream().map((item) -> {
            //把从order表中和order_details表中获取到的数据赋值给这个购物车对象
            ShoppingCart shoppingCart = new ShoppingCart();
            shoppingCart.setUserId(userId);
            shoppingCart.setImage(item.getImage());
            Long dishId = item.getDishId();
            Long setmealId = item.getSetmealId();
            if (dishId != null) {
                //如果是菜品那就添加菜品的查询条件
                shoppingCart.setDishId(dishId);
            } else {
                //添加到购物车的是套餐
                shoppingCart.setSetmealId(setmealId);
            }
            shoppingCart.setName(item.getName());
            shoppingCart.setDishFlavor(item.getDishFlavor());
            shoppingCart.setNumber(item.getNumber());
            shoppingCart.setAmount(item.getAmount());
            shoppingCart.setCreateTime(LocalDateTime.now());
            return shoppingCart;
        }).collect(Collectors.toList());

        //把携带数据的购物车批量插入购物车表  这个批量保存的方法要使用熟练
        shoppingCartService.saveBatch(shoppingCartList);

        return R.success("操作成功");
    }

在order.html中可以看见这样一段前端代码:


<div class="btn" v-if="order.status === 4">  //状态是4才会让你点击下面这个再来一单
     <div class="btnAgain" @click="addOrderAgain(order)">再来一单
     </div>
</div>

在这里插入图片描述
由于这里没有写后台的确认订单功能所以这里通过数据库修改订单状态来完成测试
在这里插入图片描述

测试结果购物车回显了数据
在这里插入图片描述

四.项目优化

1.使用Redis缓存

1.1缓存验证码

1.pom中导入redis坐标

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

2.UserController中加入对应操作
先自动装配RedisTemplate

@Autowired
    private RedisTemplate redisTemplate;

然后把之前验证码放入session中给替换成redis

redisTemplate.opsForValue().set(phone,code,5, TimeUnit.MINUTES);

在这里插入图片描述

接下来在用户登录的方法里我们需要从redis中获取验证码而且登录成功后把该验证码删除

Object codeInSession = redisTemplate.opsForValue().get(phone);
            //如果登录成功删除redis中的验证码
            redisTemplate.delete(phone);

在这里插入图片描述

创建一个redis的配置类对key进行序列化不然redis客户端中可以看到key并非就是你创建的key的名称它是\xAC\xED\x00\x05t\x00\key名大概是这种类型不便于阅读我们让它序列化value就不必序列化了因为最后idea会对其序列化

@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()); // key序列化
        //redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // value序列化

        redisTemplate.setConnectionFactory(connectionFactory);
        return redisTemplate;
    }
}

在这里插入图片描述

1.2缓存菜品查询数据

首先分析移动端菜品查询每个分类比如湘菜川菜每次点击都需要再次重新查询数据库不仅压力更大而且造成资源浪费我们可以把这些查询的数据按菜品分类给存入redis中设置其30分钟生存周期这样再次点击查看就不会再查询数据库直接从redis中获取数据降低服务器压力也避免资源浪费。

缓存逻辑
我们先动态构造唯一key值然后根据key来获取value接下来判断value是否为空若不为空则表示redis中有该分类下的数据直接返回若为空则需要去数据库查询数据然后再把查询的数据放入redis缓存中下次再查询直接走redis缓存不用再次查询数据库。

在这里插入图片描述

在这里插入图片描述

可以看到redis已经缓存了菜品信息

在这里插入图片描述

但是使用缓存的话修改新增和删除是要清理缓存的不然如果后端数据更改后再次查询仍然是走的缓存而缓存的数据没有改变查出来的就不是最新的数据了造成了数据偏差。因此我们在修改新增删除方法中清理缓存

        //清理所有菜品的缓存数据
        //Set keys = redisTemplate.keys("dish_*");
        //redisTemplate.delete(keys);

        //只清理该修改菜品的分类下缓存的数据精确清理因为redis可能已经缓存了好几个分类下的数据全删太浪费
        String key = "dish_" + dishDto.getCategoryId() + "_1";
        redisTemplate.delete(key);

在这里插入图片描述

1.3Spring Cache缓存套餐数据

1.pom文件中导入Spring Cache坐标

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

2.启动类上加上注解开启缓存功能

@EnableCaching

在这里插入图片描述

3.使用Spring Cache注解的方式开启缓存
在这里插入图片描述

注意这里方法返回的结果R它的类需要实现序列化接口否则会报错无法缓存。
在这里插入图片描述

在这里插入图片描述

缓存查询的套餐信息

@Cacheable(value = "setmealCache", key = "#setmeal.categoryId + '_' + #setmeal.status")

在这里插入图片描述

清除缓存需要在新增修改删除方法上添加该注解

@CacheEvict(value = "setmealCache", allEntries = true)

在这里插入图片描述

再次访问可以看到redis下已经有了套餐数据仍然是按套餐分类缓存的
在这里插入图片描述

2.读写分离

读与写所有压力都由一台数据库承担压力大数据库服务器磁盘损坏或数据丢失单点故障
在这里插入图片描述

2.1mysql主从复制

MySQL主从复制就是一个异步复制过程从库slave从主库master进行日志的复制然后再解析日志并应用到自身最终实现从库数据和主库数据保持一致。MySQL主从复制是MySQL数据库自带功能。
在这里插入图片描述

注意这里至少要有两台服务器分别安装MySQL并且启动成功可以用虚拟机再克隆出一个作为从库

1.master将改变记录到二进制日志binary log
修改MySQL数据库的配置文件 vim /etc/my.cnf
[mysqld]下添加如下代码

log-bin=mysql-bin  #启用二进制日志
server-id=100  #id作为服务器唯一标识不一定要100只要不重复即可

在这里插入图片描述
保存退出后重启mysql服务

systemctl restart mysqld

我们需要在master下创建一个用户给他授予权限slave才能通过该用户来拷贝它记录的日志文件先登录到mysql

mysql -uroot -p

创建一个用户叫xiaoming密码是Root@123456并给该用户授予REPLICATION SLAVE权限

GRANT REPLICATION SLAVE ON *.* to 'xiaoming'@'%' identified by 'Root@123456';

如果报错
在这里插入图片描述
mysql8需要先创建用户才能授权用下面的代码

create user xiaoming identified by 'Root@123456';
grant replication slave on *.* to xiaoming;

查看主库状态

show master status;

在这里插入图片描述
接下里主库就不要操作了一旦执行操作记录位置会发生变化就不是698了这个位置和文件名一会在从库中使用到。

2.slave将master的binary log拷贝到它的中继日志relay log
先修改MySQL数据库的配置文件 vim /etc/my.cnf
[mysqld]下添加如下代码

server-id=101 #必须是唯一的id不能重复

在这里插入图片描述

保存退出后重启mysql服务

systemctl restart mysqld

登录MySQL执行以下代码

mysql -uroot -p
change master to master_host='填入master的ip',master_user='上面创建的用户',master_password='上面设置的',master_log_file='主库刚查的日志名称',master_log_pos=刚查的记录位置;

启动slave

start slave

在这里插入图片描述

查看从库状态

show slave status\G

如果你是MySQL8且报错信息为
在这里插入图片描述
可以看看这个解决方法https://www.modb.pro/db/29919

Slave_IO_Running和Slave_SQL_Running都为no可以看看这个解决方法https://www.cnblogs.com/MENGSHIYU/p/11978489.html
完事后一定要重启MySQL服务

systemctl restart mysqld

改好后从库的两个io都为yes即可
在这里插入图片描述

3.slave重做中继日志中的事件将改变应用到自身的数据库中

Navicat中根据ip新建主从两个连接主库创建数据库和表后从库刷新直接显示出来
在这里插入图片描述
主库对表的任何修改操作从库的表F5即可看到更新。

2.2Sharding-JDBC实现读写分离

主库执行写操作从库执行读操作Sharding-JDBC是轻量级java框架在java的JDBC层提供服务只需要导入它的坐标即可使用它封装的api轻松实现数据库的读写分离。
1.pom文件导入Sharding-JDBC坐标

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

2.yml配置文件中添加配置信息ip换成你自己的主从库ippassword也替换成自己的密码

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.231.128:3306/rw?characterEncoding=utf-8
        username: root
        password: 123456
      # 从数据源
    slave:
        type: com.alibaba.druid.pool.DruidDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://192.168.231.129:3306/rw?characterEncoding=utf-8
        username: root
        password: 123456
    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

3.配置文件中开启允许bean定义覆盖
因为Sharding-JDBC和Druid都有数据源定义的配置类都想创建数据源对象产生冲突我们要打开允许bean的覆盖后创建的datasource会覆盖前面的具体代码如下

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

配置完毕就可以了自动读写分离查询走从库增删改走主库。

2.3项目实现读写分离

1.在前面建主库中新建一个reggie数据库运行之前的sql文件刷新可以看到项目的表结构和数据已经导入了从库刷新数据也出来了。
2.和上面一样导坐标yml加入对应的配置信息把数据库名字改成reggie就行了就完成了读写分离。

3.使用Nginx服务器

Nginx是一款轻量级Web服务器/反向代理服务器及电子邮件代理服务器。其优点是占用内存少并发能力强
nginx的配置文件分为三个部分
全局块events块之前的配置和nginx运行相关的全局配置
events块和网络连接相关配置
http块代理缓存日志记录虚拟主机配置一般主要是配置这块内容

3.1Nginx部署静态资源

只需要把静态资源直接放在nginx的html目录下即可
在这里插入图片描述
访问时就是ip/页面名称

3.2反向代理

正向代理说简单点就是梯子客户端通过代理服务器向目标服务器访问发生在客户端客户端知晓代理服务。

反向代理是客户端访问代理服务器代理服务器转发给目标服务器发生在服务端客户端并不知道代理服务器的存在。
正向代理隐藏的是用户反向代理隐藏的是服务器

在这里插入图片描述

配置反向代理就是在反向代理的服务器上配置其nginx.conf配置文件在http块中加入以下代码

    server {
        listen       82;
        server_name  localhost;
        location / {
                proxy_pass http://目标服务器ip:8080;#将请求转发到指定服务器
        }
    }

并开放代理服务器的82端口号重新加载防火墙

firewall-cmd --zone=public --add-port=82/tcp --permanent

firewall-cmd --reload

重新加载nginx
注意这里nginx要给配置成环境变量详情见nginx的基本配置

nginx -s reload

访问时访问的是反向代理服务器的ip反向代理服务器再把请求转发到目标服务器上

3.3负载均衡

应用集群将同一应用部署到多台服务器上组成应用集群接收负载均衡器分发的请求进行业务处理并返回响应数据
负载均衡器将用户请求根据对应的负载均衡算法分发到应用集群中的一台服务器进行处理
在这里插入图片描述

负载均衡服务器上配置nginx.conf配置

在这里插入图片描述
默认是轮询策略第一次这台服务器第二次下台服务器
配置完毕后重新加载nginx服务

nginx -s reload

这样使用集群方式降低单台服务器的压力提高访问效率避免了单点故障问题。

4.前后端分离开发

工程结构进行拆分项目部署也发生变化
在这里插入图片描述

开发流程
在这里插入图片描述
接口API接口就是一个http的请求地址主要就是去定义请求路径请求方式请求参数响应数据等内容。

4.1YApi

就是提供接口管理服务让接口开发更简单高效让接口管理更具可读性和维护性。感觉还不如Apifox方便这里我就用Apifox了。

4.2Swagger

1.pom导入swagger的解决方案Knife4j的坐标

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

2.导入kknife4j相关配置WebMvcConfig)
WebMvcConfig配置类下开启swagger文档功能加上以下注解

@EnableKnife4j
@Configuration

定义以下两个方法

@Bean
    public Docket createRestApi() {
        // 文档类型
        return new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo())
                .select()
                .apis(RequestHandlerSelectors.basePackage("com.controller"))//扫描controller包下的所有api接口
                .paths(PathSelectors.any())
                .build();
    }

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

3.设置静态资源映射否则接口文档页面无法访问
就是在addResourceHandlers方法中添加以下两行代码

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

在这里插入图片描述

4.LoginCheckFilter过滤器设置放行这些url设置为不需要处理的请求路径
在LoginCheckFilter的放行列表中添加以下url

                "/doc.html",
                "/webjars/**",
                "/swagger-resources",
                "/v2/api-docs"

项目启动后直接访问http://localhost:8080/doc.html即可看到。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

4.3项目部署

部署架构
![在这里插入图片描述](https://img-blog.csdnimg.cn/5e6e464e0a164384a34bd15f01163891.png

部署环境说明三台服务器
部署环境说明

前端项目部署
1.前端服务器安装nginx将前端资源上传到nginx下的html目录中

2.配置conf目录下的nginx.conf文件
配置信息如下

server{
  listen 80;
  server_name localhost;
#静态资源配置
  location /{
    root html/dist;
    index index.html;
  }
#请求转发代理重写URL+转发
  location ^~ /api/{
          rewrite ^/api/(.*)$ /$1 break;
          proxy_pass http://后端服务ip:端口号;
  }
#其他
  error_page 500 502 503 504 /50x.html;
  location = /50x.html{
      root html;
  }
}

在这里插入图片描述

反向代理的配置分析
在这里插入图片描述

后端项目部署
采用脚本自动部署
1.后端服务器要安装jdk,maven,git,mysql从git仓库克隆项目下来

git clone 远程仓库的url

2.添加一个reggieStart脚本具体代码如下放在/usr/local/app目录下执行脚本即可自动拉取代码打包并后台部署。

#!/bin/sh
echo =================================
echo  自动化部署脚本启动
echo =================================

echo 停止原来运行中的工程
APP_NAME=reggie_take_out

tpid=`ps -ef|grep $APP_NAME|grep -v grep|grep -v kill|awk '{print $2}'`
if [ ${tpid} ]; then
    echo 'Stop Process...'
    kill -15 $tpid
fi
sleep 2
tpid=`ps -ef|grep $APP_NAME|grep -v grep|grep -v kill|awk '{print $2}'`
if [ ${tpid} ]; then
    echo 'Kill Process!'
    kill -9 $tpid
else
    echo 'Stop Success!'
fi

echo 准备从Git仓库拉取最新代码
cd /usr/local/app/reggie_take_out

echo 开始从Git仓库拉取最新代码
git pull
echo 代码拉取完成

echo 开始打包
output=`mvn clean package -Dmaven.test.skip=true`

cd target

echo 启动项目
nohup java -jar reggie_take_out-0.0.1-SNAPSHOT.jar &> server.log &
echo 项目启动完成

给脚本授予执行权限

chmod 777 reggieStart.sh

运行脚本即完成后台项目部署,具体的jar包名称在项目文件夹下的target目录下
在这里插入图片描述

不知不觉已经写了5w字了后面如果我对项目有新的理解再继续补充完善吧个人水平有限不足之处多多包涵也欢迎各位指正感谢您的阅览。
在这里插入图片描述

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6