day01

前端环境搭建

  • 前端工程基于 nginx
    在黑马提供的资料中找到前端运行的nginx,将其移动到一个非中文目录

    • Nginx目录必须放在没有中文的目录中才能正常运行!!
    • 当前的Nginx的配置文件已经配置了反向代理,通过此配置可以将前端请求转发到后端服务。
  • 启动nginx,访问测试
    双击nginx.exe即可启动nginx服务,默认访问端口为80。
    http://localhost:80

    80端口被占用
    • 如果访问页面是以下页面,说明80端口被IIS占用,此时选择关闭IIS服务
    1. 以管理员身份运行cmd
    2. 输入net stop http
    3. 提示是否停止该服务时,输入Y
    4. 完成后输入sc config http start=disabled
    5. 重新访问http://localhost:80,即可正常访问前端页面。

后端环境搭建

熟悉项目结构

导入后端资料到IDEA中

序号名称说明
1sky-take-outmaven父工程,统一管理依赖版本,聚合其他子模块
2sky-common子模块,存放公共类,例如:工具类、常量类、异常类等
3sky-pojo子模块,存放实体类、VO、DTO等
4sky-server子模块,后端服务,存放配置文件、Controller、Service、Mapper等
  • sky-common: 模块中存放的是公共类,供其他模块使用

    sky-common模块的每个包的作用:
    | 名称 | 说明 |
    | —————- | ——————————————— |
    | constant | 存放相关常量类 |
    | context | 存放上下文类 |
    | enumeration | 项目的枚举类存储 |
    | exception | 存放自定义异常类 |
    | json | 处理json转换的类 |
    | properties | 存放SpringBoot相关的配置属性类 |
    | result | 返回结果类的封装 |
    | utils | 常用工具类 |

  • sky-pojo: 模块中存放的是一些 entity、DTO、VO

    sky-pojo模块的每个包的作用:

名称说明
Entity实体,通常和数据库中的表对应
DTO数据传输对象,通常用于程序中各层之间传递数据
VO视图对象,为前端展示数据提供的对象
POJO普通Java对象,只有属性和对应的getter和setter
  • sky-server: 模块中存放的是 配置文件、配置类、拦截器、controller、service、mapper、启动类等

sky-server模块的每个包的作用:

名称说明
config存放配置类
controller存放controller类
interceptor存放拦截器类
mapper存放mapper接口
service存放service类
SkyApplication启动类

数据库环境搭建

  • 使用navicate打开资料中的sql文件并执行,创建数据库

  • 执行完毕后,共创建了11张表

    序号表名中文名
    1employee员工表
    2category分类表
    3dish菜品表
    4dish_flavor菜品口味表
    5setmeal套餐表
    6setmeal_dish套餐菜品关系表
    7user用户表
    8address_book地址表
    9shopping_cart购物车表
    10orders订单表
    11order_detail订单明细表

    这里只简单了解有哪些表,在提供的资料中的数据库设计文档中对表的结构和字段有详细介绍

前后端联调

后端的初始工程中已经实现了登录功能,直接进行前后端联调测试即可(别忘了到yml文件中配置数据库连接信息,否则会登录失败)


实现思路:

nginx反向代理和负载均衡

思考:前端发送的请求,是如何请求到后端服务的?

nginx反向代理

nginx反向代理就是将前端发送的动态请求通过nginx转发到后端服务器

nginx反向代理的好处:

  • 提高访问速度
    • 因为nginx本身可以缓存,如果访问同一接口并做了数据缓存,此时则不需要访问服务端,nginx就可以直接返回数据,进而提高了访问速度
  • 进行负载均衡
    • 负载均衡就是将大量请求按照我们指定的方式均衡的分配给集群中的每台服务器
  • 保证后端服务安全
    • 不暴露后端服务地址,将nginx作为请求访问的入口,到达nginx后转发到具体的后端服务器,从而保证后端服务器的安全

nginx反向代理的配置方式:

  • 在nginx目录下,修改nginx.config文件
    1
    2
    3
    4
    5
    6
    7
    8
    9
    server{
    listen 80;
    server_name localhost;

    # 当访问 http://localhost:80/api/../.. 这样的接口的时候,它会通过 location /api/ {} 这样的反向代理到 http://localhost:8080/admin/上来。
    location /api/{
    proxy_pass http://localhost:8080/admin/; #反向代理
    }
    }
    proxy_pass:该指令是用来设置代理服务器的地址,可以是主机名称,IP地址加端口号等形式。

nginx负载均衡

当服务以集群的方式部署时,nginx在转发请求到服务器时就需要做相应的负载均衡。负载均衡从本质上来说也是基于反向代理来实现的,最终效果都是转发请求

nginx负载均衡配置方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
upstream webservers{
# 声明了两组服务器
server 192.168.100.128:8080;
server 192.168.100.129:8080;
}
server{
listen 80;
server_name localhost;

location /api/{
proxy_pass http://webservers/admin; #负载均衡
}
}

upstream: 如果代理服务器是一组服务器的话,我们可以使用upstream指令配置后端服务器组。
注: upstream后面的名称可自定义,但要上下保持一致。

以上配置的含义是:监听80端口号,当访问http://localhost:80/api/../..这样的接口时,便会通过 location /api/ {} 这样的反向代理到 http://webservers/admin,根据webservers名称找到一组服务器,根据设置的负载均衡策略(默认是轮询)转发到具体的服务器。

负载均衡的策略:

名称说明
轮询默认方式
weight权重方式,默认为1,权重越高,被分配的客户端请求就越多
ip_hash依据ip分配方式,这样每个访客可以固定访问一个后端服务
least_conn依据最少连接方式,把请求优先分配给连接数少的后端服务
url_hash依据url分配方式,这样相同的url会被分配到同一个后端服务
fair依据响应时间方式,响应时间短的服务将会被优先分配

具体配置方式:

1
2
3
4
upstream webservers{
server 192.168.100.128:8080;
server 192.168.100.129:8080;
}
1
2
3
4
upstream webservers{
server 192.168.100.128:8080 weight=90;
server 192.168.100.129:8080 weight=10;
}
1
2
3
4
5
upstream webservers{
ip_hash;
server 192.168.100.128:8080;
server 192.168.100.129:8080;
}
1
2
3
4
5
upstream webservers{
least_conn;
server 192.168.100.128:8080;
server 192.168.100.129:8080;
}
1
2
3
4
5
upstream webservers{
hash &request_uri;
server 192.168.100.128:8080;
server 192.168.100.129:8080;
}
1
2
3
4
5
upstream webservers{
server 192.168.100.128:8080;
server 192.168.100.129:8080;
fair;
}

完善登录功能

需求: 员工表的密码字段直接明文存储在数据库中,存在安全风险,需要对密码进行加密后,再进行存储。

实现步骤:

  1. 打开数据库中的employee表,修改明文密码为加密后的123456:e10abc3949ba59abbe56e057f20f883e
  2. 修改Java代码,前端提交的密码进行MD5加密后再跟数据库中保存的密码进行比对
    打开EmployeeServiceImpl类,修改代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    /**
    * 员工登录
    *
    * @param employeeLoginDTO
    * @return
    */
    @Override
    public Employee login(EmployeeLoginDTO employeeLoginDTO) {
    String username = employeeLoginDTO.getUsername();
    String password = employeeLoginDTO.getPassword();

    //1、根据用户名查询数据库中的数据
    Employee employee = employeeMapper.getByUsername(username);

    //2、处理各种异常情况(用户名不存在、密码不对、账号被锁定)
    if (employee == null) {
    //账号不存在
    throw new AccountNotFoundException(MessageConstant.ACCOUNT_NOT_FOUND);
    }

    //密码比对
    password = DigestUtils.md5DigestAsHex(password.getBytes());
    if (!password.equals(employee.getPassword())) {
    //密码错误
    throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR);
    }
    if (employee.getStatus().equals(StatusConstant.DISABLE) ) {
    //账号被锁定
    throw new AccountLockedException(MessageConstant.ACCOUNT_LOCKED);
    }

    //3、返回实体对象
    return employee;
    }

导入接口文档

该项目使用的开发方式是当前企业主流的前后端分离开发,该方式要求在开发前先将接口定义好,这样前后端才能并行开发。

前后端分离开发流程

开发流程:

  1. 定义接口并确定接口的路径、请求方式、传入参数、返回参数。
  2. 前端开发人员和后端开发人员并行开发,开发过程中也可以自测
  3. 前后端人员进行联调测试
  4. 提交给测试人员进行最终测试

操作步骤

  • 课程中使用的是Yapi,因为种种原因无法使用,因此这里替换为ApiFox
  • ApiFox下载地址:https://apifox.com/

ApiFox -> 新建项目 -> 项目管理 -> 导入数据 -> 选择Yapi -> 将资料中的json文件导入

导入效果:

Swagger

介绍

Swagger是一个规范和完整的框架,用于生成、描述、调用和可视化RESTful风格的Web服务(https://swagger.io/)
作用:

  1. 使前后端分离开发更加方便,利于团队协作
  2. 接口文档自动生成,后端开发只需要关注业务逻辑,无需手动编写接口文档
  3. Spring将Swagger纳入了自身标准。可以通过在项目中引入Swaggerfox,来简单快捷的使用Swagger

使用步骤

knife4j是为JavaMVC框架集成Swagger生成Api文档的增强解决方案,前身是swagger-bootstrap-ui,取名kni4j是希望它能像一把匕首一样小巧,轻量,并且功能强悍!

  1. 导入knife4j的maven坐标
    1
    2
    3
    4
    <dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-spring-boot-starter</artifactId>
    </dependency>
  2. WebMvcConfiguration配置类中加入knife4j的配置
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    /**
    * 通过knife4j生成接口文档
    * @return
    */
    @Bean
    public Docket docket() {
    ApiInfo apiInfo = new ApiInfoBuilder()
    .title("苍穹外卖项目接口文档")
    .version("2.0")
    .description("苍穹外卖项目接口文档")
    .build();
    Docket docket = new Docket(DocumentationType.SWAGGER_2)
    .apiInfo(apiInfo)
    .select()
    .apis(RequestHandlerSelectors.basePackage("com.sky.controller"))
    .paths(PathSelectors.any())
    .build();
    return docket;
    }
  3. 继续在该配置类下设置静态资源映射,否则无法访问knife4j生成的接口文档
    1
    2
    3
    4
    5
    6
    7
    8
    /**
    * 设置静态资源映射
    * @param registry
    */
    protected void addResourceHandlers(ResourceHandlerRegistry registry) {
    registry.addResourceHandler("/doc.html").addResourceLocations("classpath:/META-INF/resources/");
    registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
    }
  4. 接口文档的访问地址:http://localhost:8080/doc.html

还可以在这里直接测试接口

思考: 通过Swagger就可以生成接口文档了,还需要ApiFox吗?

  1. ApiFox是设计阶段使用的工具,管理和维护接口

  2. Swagger是在开发阶段使用的框架,帮助后端开发人员做后端的接口测试

常用注解

通过注解可以控制生成的接口文档,使接口文档拥有更好的可读性

注解说明
@Api用在类上,例如Controller,表示对类的说明
@ApiModel用在类上,例如entity、DTO、VO
@ApiModelProperty用在属性上,描述属性信息
@ApiOperation用在方法上,例如Controller的方法,说明方法的用途、作用

通过在各种类上添加上述注解,生成更清晰,可读性更好的接口文档(下面添加的注解,在提供的资料中已经默认添加)

sky-pojo模块中:

1
2
3
4
5
6
7
8
9
10
11
@Data
@ApiModel(description = "员工登录时传递的数据模型")
public class EmployeeLoginDTO implements Serializable {

@ApiModelProperty("用户名")
private String username;

@ApiModelProperty("密码")
private String password;

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ApiModel(description = "员工登录返回的数据格式")
public class EmployeeLoginVO implements Serializable {

@ApiModelProperty("主键值")
private Long id;

@ApiModelProperty("用户名")
private String userName;

@ApiModelProperty("姓名")
private String name;

@ApiModelProperty("jwt令牌")
private String token;

}

sky-server模块中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* 员工管理
*/
@RestController
@RequestMapping("/admin/employee")
@Slf4j
@Api(tags = "员工相关接口")
public class EmployeeController {

@Autowired
private EmployeeService employeeService;
@Autowired
private JwtProperties jwtProperties;

/**
* 登录
*
* @param employeeLoginDTO
* @return
*/
@PostMapping("/login")
@ApiOperation(value = "员工登录")
public Result<EmployeeLoginVO> login(@RequestBody EmployeeLoginDTO employeeLoginDTO) {
//..............


}

/**
* 退出
*
* @return
*/
@PostMapping("/logout")
@ApiOperation("员工退出")
public Result<String> logout() {
return Result.success();
}

}

day02

课程内容

  • 新增员工
  • 员工分页查询
  • 启用禁用员工账号
  • 编辑员工
  • 导入分类模块功能代码

功能实现:员工管理、菜品分类管理。

新增员工

需求分析和设计

点击新增员工按钮后,弹出新增员工的弹窗


当填写完表单信息,点击保存按钮后,会将表单的数据提交到服务端,在服务端中接收数据,并调用相关方法保存到数据库

注意:

  1. 账号必须唯一
  2. 手机号必须是合法的十一位数字(1开头,且第二位数字不为2)
  3. 身份证号为合法的18位数字(前17位为数字,最后一位为数字或X)
  4. 密码默认为123456

在资料中找到项目接口文档 -> 苍穹外卖-管理端接口.html


明确新增员工接口的请求路径、请求方式、请求参数、返回数据

本项目约定:

  • 管理端 发出的请求,统一使用 /admin 作为前缀。
  • 用户端 发出的请求,统一使用 /user 作为前缀。

代码开发

设置DTO类

根据接口来设计对应的DTO
前端传递参数列表:


思考: 为什么不能使用对应实体类,还要专门封装DTO类?

当前端提交数据和实体类中对应属性差别较大时,建议使用DTO来封装数据
在sky-pojo模块中已经定义了EmployeeDTO

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Data
public class EmployeeDTO implements Serializable {

private Long id;

private String username;

private String name;

private String phone;

private String sex;

private String idNumber;

}

创建接口和实现类

Controller层

在sky-server模块的EmployeeController类中创建新增员工方法,接收前端提交的参数

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 新增员工
* @param employeeDTO
* @return
*/
@PostMapping
@ApiOperation("新增员工")
public Result save(@RequestBody EmployeeDTO employeeDTO){
log.info("新增员工:{}",employeeDTO);
employeeService.save(employeeDTO);//该方法后续步骤会定义
return Result.success();
}

注: Result类定义了后端统一返回结果格式。

在sky-common模块的com.sky.result包中定义了Result类用来统一返回结果格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* 后端统一返回结果
* @param <T>
*/
@Data
public class Result<T> implements Serializable {

private Integer code; //编码:1成功,0和其它数字为失败
private String msg; //错误信息
private T data; //数据

public static <T> Result<T> success() {
Result<T> result = new Result<T>();
result.code = 1;
return result;
}

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

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

}

Service层接口

在sky-server模块的com.sky.server.EmployeeService中声明新增员工方法

1
2
3
4
5
/**
* 新增员工
* @param employeeDTO
*/
void save(EmployeeDTO employeeDTO);

Service层实现类

在sky-server模块的com.sky.server.impl.EmployeeServiceImpl中实现新增员工方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* 新增员工
*
* @param employeeDTO
*/
@Override
public void save(EmployeeDTO employeeDTO) {
Employee employee = new Employee();

//对象属性拷贝
BeanUtils.copyProperties(employeeDTO, employee);

//设置账号的状态,默认正常状态 1表示正常 0表示锁定
employee.setStatus(StatusConstant.ENABLE);

//设置密码,默认密码123456
employee.setPassword(DigestUtils.md5DigestAsHex(PasswordConstant.DEFAULT_PASSWORD.getBytes()));

//设置当前记录的创建时间和修改时间
employee.setCreateTime(LocalDateTime.now());
employee.setUpdateTime(LocalDateTime.now());

//设置当前记录创建人id和修改人id
employee.setCreateUser(10L);//目前写个假数据,后期修改
employee.setUpdateUser(10L);

employeeMapper.insert(employee);//后续步骤定义
}

Mapper层
在EmployeeMapper接口中添加新增员工方法,并通过注解的方式来映射SQL语句

1
2
3
4
5
6
7
8
/**
* 插入员工数据
* @param employee
*/
@Insert("insert into employee (name, username, password, phone, sex, id_number, create_time, update_time, create_user, update_user,status) " +
"values " +
"(#{name},#{username},#{password},#{phone},#{sex},#{idNumber},#{createTime},#{updateTime},#{createUser},#{updateUser},#{status})")
void insert(Employee employee);

在application.yml中已开启驼峰命名,故id_number和idNumber可对应。

功能测试

功能测试实现方式:

  • 通过接口文档测试
  • 通前后端联调测试

接口文档测试

启动服务:访问[http://localhost:8080/doc.html][http://localhost:8080/doc.html],进入新增员工接口

Json数据:

1
2
3
4
5
6
7
8
{
"id": 0,
"idNumber": "111222333444555666",
"name": "xiaozhi",
"phone": "13812344321",
"sex": "1",
"username": "小智"
}

发送请求后,返回的响应码是401报错

通过控制台打印的信息可以知道是JwtTokenAdminInterceptor拦截器出现的问题

报错原因: JWT令牌失效导致EmployeeControllersave方法没有被调用
解决方法: 调用员工登录接口获得一个合法的JWT令牌

获得令牌:
使用admin用户登录获取令牌

添加令牌:

将合法的JWT令牌添加到全局参数中
文档管理—>全局参数设置—>添加参数

接口测试:


其中,请求头部含有JWT令牌

前后端联调测试

启动服务后,访问localhost,就能访问到登录界面
登录 -> 员工管理 -> 添加员工

期间可能会出现一些错误不必处理,因为那是分页查询没有完成的问题

填写信息并保存后就可以在数据库中看到对应的信息了

由于开发阶段前端和后端是并行开发的,后端完成某个功能后,此时前端对应的功能可能还没有开发完成,导致无法进行前后端联调测试。所以在开发阶段,后端测试主要以接口文档测试为主。

代码完善

目前存在的问题主要有两个:

  1. 如果录入的用户名已存在,报出的异常没有进行处理,导致程序异常退出
  2. 新增员工时,现在是将创建人和修改人的id设置为固定值,后期需要根据登录用户来动态设置

问题一

问题描述: 数据库中username字段设置了唯一索引,当用户录入的用户名已存在时会直接抛出异常而没有进行处理

解决方法: 添加全局异常处理器

在sky-server模块的com.sky.hander包下有一个GlobalExceptionHandler类,用于处理全局异常,在其中添加以下代码用于处理SQL异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 处理SQL异常
* @param ex
* @return
*/
@ExceptionHandler
public Result exceptionHandler(SQLIntegrityConstraintViolationException ex){
//Duplicate entry 'zhangsan' for key 'employee.idx_username'
String message = ex.getMessage();
if(message.contains("Duplicate entry")){
String[] split = message.split(" ");
String username = split[2];
String msg = username + MessageConstant.ALREADY_EXISTS;
return Result.error(msg);
}else{
return Result.error(MessageConstant.UNKNOWN_ERROR);
}
}

在sky-common模块的MessageConstant类中添加以下常量

1
public static final String ALREADY_EXISTS = "已存在";

此时重启服务器,再次添加一个已经存在的用户名,就会返回已存在的错误信息了

问题二

问题描述: 新增员工时,现在是将创建人和修改人的id设置为固定值,后期需要根据登录用户来动态设置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
public void save(EmployeeDTO employeeDTO) {
Employee employee = new Employee();

//对象属性拷贝
BeanUtils.copyProperties(employeeDTO, employee);

//设置账号的状态,默认正常状态 1表示正常 0表示锁定
employee.setStatus(StatusConstant.ENABLE);

//设置密码,默认密码123456
employee.setPassword(DigestUtils.md5DigestAsHex(PasswordConstant.DEFAULT_PASSWORD.getBytes()));

//设置当前记录的创建时间和修改时间
employee.setCreateTime(LocalDateTime.now());
employee.setUpdateTime(LocalDateTime.now());

//设置当前记录创建人id和修改人id
employee.setCreateUser(10L);//目前写个假数据,后期修改
employee.setUpdateUser(10L);

//后续步骤定义
employeeMapper.insert(employee);
}

解决方法: 通过某种方式动态获取当前员工的id


员工登录成功后就会生成JWT令牌并响应给前端

在EmployeeController的login方法中已经添加过以下生成JWT令牌的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@RestController
@RequestMapping("/admin/employee")
@Slf4j
@Api(tags = "员工相关接口")
public class EmployeeController {

@Autowired
private EmployeeService employeeService;
@Autowired
private JwtProperties jwtProperties;

/**
* 登录
*
* @param employeeLoginDTO
* @return
*/
@PostMapping("/login")
@ApiOperation(value = "员工登录")
public Result<EmployeeLoginVO> login(@RequestBody EmployeeLoginDTO employeeLoginDTO) {
//.........

//登录成功后,生成jwt令牌
Map<String, Object> claims = new HashMap<>();
claims.put(JwtClaimsConstant.EMP_ID, employee.getId());
String token = JwtUtil.createJWT(
jwtProperties.getAdminSecretKey(),
jwtProperties.getAdminTtl(),
claims);

//............

return Result.success(employeeLoginVO);
}

}

在后续请求中,前端都会携带JWT令牌,通过JWT令牌可以解析出当前员工的id(该方法写在sky-server模块中的com.sky.interceptor包下)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* jwt令牌校验的拦截器
*/
@Component
@Slf4j
public class JwtTokenAdminInterceptor implements HandlerInterceptor {

@Autowired
private JwtProperties jwtProperties;

/**
* 校验jwt
*
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

//..............

//1、从请求头中获取令牌
String token = request.getHeader(jwtProperties.getAdminTokenName());

//2、校验令牌
try {
log.info("jwt校验:{}", token);
Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());
log.info("当前员工id:", empId);
//3、通过,放行
return true;
} catch (Exception ex) {
//4、不通过,响应401状态码
response.setStatus(401);
return false;
}
}
}

思考: 光是解析出员工id,如何传递给Service的save方法呢?
通过ThreadLocal来传递

ThreadLocal

简介:
ThreadLocal并不是一个线程,而是一个Thread的局部变量
ThreadLocal为每个线程提供单独一份存储空间,具有线程隔离的效果,只有在线程内才能获取到对应的值,线程外则不能访问,而每一次用户的请求都是一个独立的线程,所以可以通过ThreadLocal来传递当前员工的id

常用方法:

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

解决问题:

在初始工程中的sky-common模块已经封装了ThreadLocal操作的工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class BaseContext {

public static ThreadLocal<Long> threadLocal = new ThreadLocal<>();

public static void setCurrentId(Long id) {
threadLocal.set(id);
}

public static Long getCurrentId() {
return threadLocal.get();
}

public static void removeCurrentId() {
threadLocal.remove();
}

}

在拦截器中解析出当前登录员工的id,并放入线程局部变量中(setCurrentId方法)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* jwt令牌校验的拦截器
*/
@Component
@Slf4j
public class JwtTokenAdminInterceptor implements HandlerInterceptor {

@Autowired
private JwtProperties jwtProperties;

/**
* 校验jwt
*
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

//.............................

//2、校验令牌
try {
//.................
Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());
log.info("当前员工id:", empId);
/////将用户id存储到ThreadLocal////////
BaseContext.setCurrentId(empId);
////////////////////////////////////
//3、通过,放行
return true;
} catch (Exception ex) {
//......................
}
}
}

在Service中直接调用BaseContext.getCurrentId()方法获取当前登录员工的id
1
2
employee.setCreateUser(BaseContext.getCurrentId());
employee.setUpdateUser(BaseContext.getCurrentId());

测试:使用admin用户来添加一条记录,查看是否能保留创建者的id

员工分页查询

需求分析和设计

需求分析: 当系统之员工很多时,将数据以分页的方式来展示更加清晰明确。在分页查询页面,除了分页条件以外,还有一个查询条件”员工姓名”


业务规则:

  • 根据页码展示员工信息
  • 每页展示10条数据
  • 分页查询时可以根据需要,输入员工姓名进行查询

接口设计:
资料 -> 项目接口文档 -> 苍穹外卖-管理端接口.html

注意:

  • 请求参数类型为Query,不是json格式提交,在路径后直接拼接。/admin/employee/page?name=zhangsan
  • 返回数据中records数组中使用Employee实体类对属性进行封装。

代码开发

设置DTO类

根据请求参数进行封装,在sky-pojo模块中创建EmployeePageQueryDTO类

1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
public class EmployeePageQueryDTO implements Serializable {

//员工姓名
private String name;

//页码
private int page;

//每页显示记录数
private int pageSize;

}

封装分页结果类

后面所有的分页查询,统一都封装为PageResult对象。

放在sky-common模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 封装分页查询结果
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PageResult implements Serializable {

private long total; //总记录数

private List records; //当前页数据集合

}


员工信息分页查询后端返回的对象类型为: Result<PageResult>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* 后端统一返回结果
* @param <T>
*/
@Data
public class Result<T> implements Serializable {

private Integer code; //编码:1成功,0和其它数字为失败
private String msg; //错误信息
private T data; //数据

public static <T> Result<T> success() {
Result<T> result = new Result<T>();
result.code = 1;
return result;
}

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

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

}

创建接口和实现类

Controller层
在员工控制层添加分页查询方法

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 员工分页查询
* @param employeePageQueryDTO
* @return
*/
@GetMapping("/page")
@ApiOperation("员工分页查询")
public Result<PageResult> page(EmployeePageQueryDTO employeePageQueryDTO){
log.info("员工分页查询,参数为:{}", employeePageQueryDTO);
PageResult pageResult = employeeService.pageQuery(employeePageQueryDTO);//后续定义
return Result.success(pageResult);
}

Service层接口
声明pageQuery方法

1
2
3
4
5
6
/**
* 分页查询
* @param employeePageQueryDTO
* @return
*/
PageResult pageQuery(EmployeePageQueryDTO employeePageQueryDTO);

Service层实现类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 分页查询
*
* @param employeePageQueryDTO
* @return
*/
public PageResult pageQuery(EmployeePageQueryDTO employeePageQueryDTO) {
// select * from employee limit 0,10
//开始分页查询
PageHelper.startPage(employeePageQueryDTO.getPage(), employeePageQueryDTO.getPageSize());

Page<Employee> page = employeeMapper.pageQuery(employeePageQueryDTO);//后续定义

long total = page.getTotal();
List<Employee> records = page.getResult();

return new PageResult(total, records);
}

注意: 该功能是使用mybatis提供的分页插件PageHelper来简化分页代码的开发,底层基于mybatis的拦截器实现,该功能需要在pom.xml文中添加依赖(初始工程已添加)

1
2
3
4
5
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>${pagehelper}</version>
</dependency>

Mapper层

在EmployeeMapper中声明pageQuery方法

1
2
3
4
5
6
/**
* 分页查询
* @param employeePageQueryDTO
* @return
*/
Page<Employee> pageQuery(EmployeePageQueryDTO employeePageQueryDTO);

因为该分页操作涉及动态条件查询,所以通过配置文件来写sql语句,在 src/main/resources/mapper/EmployeeMapper.xml 中编写SQL
1
2
3
4
5
6
7
8
9
<select id="pageQuery" resultType="com.sky.entity.Employee">
select * from employee
<where>
<if test="name != null and name != ''">
and name like concat('%',#{name},'%')
</if>
</where>
order by create_time desc
</select>

功能测试

接口文档测试

重启服务:访问http://localhost:8080/doc.html,进入员工分页查询


如果一直报401错误,可能是token失效的原因,此时需要重新登录获取token


响应结果:

前后端联调测试

点击员工管理并输入员工姓名

此时员工的分页查询功能已经顺利完成了,但是仔细观察会发现,显示的 最后操作时间 格式很怪,这个问题在 代码完善 中解决

代码完善

问题描述:操作时间字段显示有问题。

解决方式:

  • 方式一: 在属性上加上注解@JsonFormat,指定日期格式
  • 方式二: 添加消息转换器,统一日期类型的格式处理

这里推荐使用方式二,因为方式一的处理方式,只对单一属性进行处理,需要每个时间类型都加上@JsonFormat注解,效率较低,而方式二的处理方式,是对所有日期类型进行统一处理,效率较高

解决方法
在sky-server模块下的WebMvcConfiguration中扩展SpringMVC的消息转换器,统一对日期类型进行格式处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 扩展Spring MVC框架的消息转化器
* @param converters
*/
@Override
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
log.info("扩展消息转换器...");
//创建一个消息转换器对象
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
//需要为消息转换器设置一个对象转换器,对象转换器可以将Java对象序列化为json数据
converter.setObjectMapper(new JacksonObjectMapper());
//将自己的消息转化器加入容器中
converters.add(0,converter);
}

消息转化器配置中的对象转换器,是基础工程中已经配置好的JacksonObjectMapper,它已经对日期类型进行了格式处理,我们只需要在WebMvcConfiguration中扩展消息转换器,将其加入容器中即可

此时再次测试,就能看到正确的显示时间格式了

启用/禁用员工账号

需求分析和设计

需求分析: 在员工管理列表页面,可以对某个员工账号进行启用或者禁用操作。账号禁用的员工不能登录系统,启用后的员工可以正常登录。如果某个员工账号状态为正常,则按钮显示为 “禁用”,如果员工账号状态为已禁用,则按钮显示为”启用”,关于按钮的显示切换在前端已经完成,后端只需要改变员工账号的状态即可。

业务规则:

  • 可以对状态为“启用” 的员工账号进行“禁用”操作
  • 可以对状态为“禁用”的员工账号进行“启用”操作
  • 状态为“禁用”的员工账号不能登录系统

接口设计


代码开发

Controller层

根据接口设计中的请求参数形式对应的在EmployeeController中创建启用/禁用员工账号的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 启用禁用员工账号
* @param status
* @param id
* @return
*/
@PostMapping("/status/{status}")
@ApiOperation("启用禁用员工账号")
public Result startOrStop(@PathVariable Integer status,Long id){
log.info("启用禁用员工账号:{},{}",status,id);
employeeService.startOrStop(status,id);//后绪步骤定义
return Result.success();
}

Service层接口

EmployeeService接口中声明启用/禁用员工账号的业务方法:

1
2
3
4
5
6
/**
* 启用禁用员工账号
* @param status
* @param id
*/
void startOrStop(Integer status, Long id);

Service层实现类

EmployeeServiceImpl中实现启用/禁用员工账号的业务方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 启用禁用员工账号
*
* @param status
* @param id
*/
public void startOrStop(Integer status, Long id) {
Employee employee = Employee.builder()
.status(status)
.id(id)
.build();

employeeMapper.update(employee);
}

Mapper层

EmployeeMapper接口中声明 update 方法:

1
2
3
4
5
/**
* 根据主键动态修改属性
* @param employee
*/
void update(Employee employee);

EmployeeMapper.xml中编写SQL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<update id="update" parameterType="Employee">
update employee
<set>
<if test="name != null">name = #{name},</if>
<if test="username != null">username = #{username},</if>
<if test="password != null">password = #{password},</if>
<if test="phone != null">phone = #{phone},</if>
<if test="sex != null">sex = #{sex},</if>
<if test="idNumber != null">id_Number = #{idNumber},</if>
<if test="updateTime != null">update_Time = #{updateTime},</if>
<if test="updateUser != null">update_User = #{updateUser},</if>
<if test="status != null">status = #{status},</if>
</set>
where id = #{id}
</update>

功能测试

接口文档测试

在测试前,查询员工id和账号状态,在接口文档中作为相应的参数进行测试,测试完毕后,再次查询员工账号状态,查看是否成功启用/禁用员工账号

前后端联调测试

点击相应的按钮,会对员工账号进行启用/禁用,并提示账号状态是否更改成功

编辑员工

需求分析和设计

需求分析: 员工管理界面点击编辑按钮即可跳转到员工编辑页面,页面中显示当前员工信息,用户可以修改员工信息并保存。

员工编辑页面应当回显当前员工信息,用户在当前员工信息的基础上进行修改

接口设计:
编辑员工共涉及到两个接口:

  • 根据id查询员工信息
  • 编辑员工信息
  1. 根据id查询员工信息

  2. 编辑员工信息

修改功能,所以请求方式可设置为POST

代码开发

回显员工信息

  1. Controller层
    EmployeeController 中创建 getById 方法:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    /**
    * 根据id查询员工信息
    * @param id
    * @return
    */
    @GetMapping("/{id}")
    @ApiOperation("根据id查询员工信息")
    public Result<Employee> getById(@PathVariable Long id){
    Employee employee = employeeService.getById(id);
    return Result.success(employee);
    }
  2. Service层接口
    在 EmployeeService 接口中声明 getById 方法:
    1
    2
    3
    4
    5
    6
    /**
    * 根据id查询员工
    * @param id
    * @return
    */
    Employee getById(Long id);
  3. Service层实现类
    EmployeeServiceImpl 中实现 getById 方法:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
     /**
    * 根据id查询员工
    * @param id
    * @return
    */
    public Employee getById(Long id) {
    Employee employee = employeeMapper.getById(id);
    employee.setPassword("****");
    return employee;
    }
  4. Mapper层
    在 EmployeeMapper 接口中声明 getById 方法:
    1
    2
    3
    4
    5
    6
    7
    /**
    * 根据id查询员工信息
    * @param id
    * @return
    */
    @Select("select * from employee where id = #{id}")
    Employee getById(Long id);

修改员工信息功能

  1. Controller层
    EmployeeController 中创建 update 方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    /**
    * 编辑员工信息
    * @param employeeDTO
    * @return
    */
    @PutMapping
    @ApiOperation("编辑员工信息")
    public Result update(@RequestBody EmployeeDTO employeeDTO){
    log.info("编辑员工信息:{}", employeeDTO);
    employeeService.update(employeeDTO);
    return Result.success();
    }
  2. Service层接口
    EmployeeService 接口中声明 update 方法:

    1
    2
    3
    4
    5
    /**
    * 编辑员工信息
    * @param employeeDTO
    */
    void update(EmployeeDTO employeeDTO);
  3. Service层实现类
    EmployeeServiceImpl 中实现 update 方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    /**
    * 编辑员工信息
    *
    * @param employeeDTO
    */
    @Override
    public void update(EmployeeDTO employeeDTO) {
    Employee employee = new Employee();
    BeanUtils.copyProperties(employeeDTO, employee);

    employee.setUpdateTime(LocalDateTime.now());
    employee.setUpdateUser(BaseContext.getCurrentId());

    employeeMapper.update(employee);
    }

在实现启用禁用员工账号功能时,已实现employeeMapper.update(employee),在此不需写Mapper层代码,直接调用即可

功能测试

接口文档测试

分别测试根据id查询员工信息编辑员工信息接口

  1. 根据id查询员工信息
    获得了对应id的相关员工信息

  2. 编辑员工信息
    修改id=4的员工信息,namezhangsan改为张三丰username张三改为zhangsanfeng

查看employee表数据

前后端联调测试

进入到员工列表查询


对员工姓名为杰克的员工数据修改,点击修改,数据已回显

修改后,点击保存

导入分类模块功能代码

需求分析和设计

需求分析

后台系统中可以管理分类信息,分类包括两种类型,分别是 菜品分类套餐分类

分析菜品分类相关功能:

  • 新增菜品分类:当我们在后台系统中添加菜品时需要选择一个菜品分类,在移动端也会按照菜品分类来展示对应的菜品。

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

  • 根据id删除菜品分类:在分类管理列表页面,可以对某个分类进行删除操作。需要注意的是当分类关联了菜品或者套餐时,此分类不允许删除。

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

  • 启用禁用菜品分类:在分类管理列表页面,可以对某个分类进行启用或者禁用操作。

  • 分类类型查询:当点击分类类型下拉框时,从数据库中查询所有的菜品分类数据进行展示。

分类管理原型:

业务规则:

  • 分类名称必须是唯一的
  • 分类按照类型可以分为菜品分类和套餐分类
  • 新添加的分类状态默认为“禁用”

接口设计

根据上述分析,菜品分类模块共涉及6个接口:

  • 新增分类
  • 分类分页查询
  • 根据id删除分类
  • 修改分类
  • 启用禁用分类
  • 根据类型查询分类
  1. 新增分类
  2. 分类分页查询
  3. 根据id删除分类
  1. 修改分类
  2. 启用禁用分类
  3. 根据类型查询分类

    表设计

    category表结构:
字段名数据类型说明备注
idbigint主键自增
namevarchar(32)分类名称唯一
typeint分类类型1菜品分类 2套餐分类
sortint排序字段用于分类数据的排序
statusint状态1启用 0禁用
create_timedatetime创建时间
update_timedatetime最后修改时间
create_userbigint创建人id
update_userbigint最后修改人id

代码导入

直接将资料中的分类管理模块功能代码导入即可

Mapper层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Mapper
public interface CategoryMapper {

/**
* 插入数据
* @param category
*/
@Insert("insert into category(type, name, sort, status, create_time, update_time, create_user, update_user)" +
" VALUES" +
" (#{type}, #{name}, #{sort}, #{status}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser})")
void insert(Category category);

/**
* 分页查询
* @param categoryPageQueryDTO
* @return
*/
Page<Category> pageQuery(CategoryPageQueryDTO categoryPageQueryDTO);

/**
* 根据id删除分类
* @param id
*/
@Delete("delete from category where id = #{id}")
void deleteById(Long id);

/**
* 根据id修改分类
* @param category
*/
void update(Category category);

/**
* 根据类型查询分类
* @param type
* @return
*/
List<Category> list(Integer type);
}
1
2
3
4
5
6
7
8
9
10
11
12
13

@Mapper
public interface DishMapper {

/**
* 根据分类id查询菜品数量
* @param categoryId
* @return
*/
@Select("select count(id) from dish where category_id = #{categoryId}")
Integer countByCategoryId(Long categoryId);

}
1
2
3
4
5
6
7
8
9
10
11
12
@Mapper
public interface SetmealMapper {

/**
* 根据分类id查询套餐的数量
* @param id
* @return
*/
@Select("select count(id) from setmeal where category_id = #{categoryId}")
Integer countByCategoryId(Long id);

}

Service层

接口类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public interface CategoryService {

/**
* 新增分类
* @param categoryDTO
*/
void save(CategoryDTO categoryDTO);

/**
* 分页查询
* @param categoryPageQueryDTO
* @return
*/
PageResult pageQuery(CategoryPageQueryDTO categoryPageQueryDTO);

/**
* 根据id删除分类
* @param id
*/
void deleteById(Long id);

/**
* 修改分类
* @param categoryDTO
*/
void update(CategoryDTO categoryDTO);

/**
* 启用、禁用分类
* @param status
* @param id
*/
void startOrStop(Integer status, Long id);

/**
* 根据类型查询分类
* @param type
* @return
*/
List<Category> list(Integer type);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public interface EmployeeService {

/**
* 员工登录
* @param employeeLoginDTO
* @return
*/
Employee login(EmployeeLoginDTO employeeLoginDTO);

/**
* 新增员工
* @param employeeDTO
*/
void save(EmployeeDTO employeeDTO);

/**
* 分页查询
* @param employeePageQueryDTO
* @return
*/
PageResult pageQuery(EmployeePageQueryDTO employeePageQueryDTO);

void startOrStop(Integer status, Long id);


/**
* 编辑员工信息
* @param employeeDTO
*/
void update(EmployeeDTO employeeDTO);

/**
* 根据id查询员工
* @param id
* @return
*/
Employee getById(Long id);
}


实现类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
/**
* 分类业务层
*/
@Service
@Slf4j
public class CategoryServiceImpl implements CategoryService {

@Autowired
private CategoryMapper categoryMapper;
@Autowired
private DishMapper dishMapper;
@Autowired
private SetmealMapper setmealMapper;

/**
* 新增分类
* @param categoryDTO
*/
@Override
public void save(CategoryDTO categoryDTO) {
Category category = new Category();
//属性拷贝
BeanUtils.copyProperties(categoryDTO, category);

//分类状态默认为禁用状态0
category.setStatus(StatusConstant.DISABLE);

//设置创建时间、修改时间、创建人、修改人
category.setCreateTime(LocalDateTime.now());
category.setUpdateTime(LocalDateTime.now());
category.setCreateUser(BaseContext.getCurrentId());
category.setUpdateUser(BaseContext.getCurrentId());

categoryMapper.insert(category);
}

/**
* 分页查询
* @param categoryPageQueryDTO
* @return
*/
@Override
public PageResult pageQuery(CategoryPageQueryDTO categoryPageQueryDTO) {
PageHelper.startPage(categoryPageQueryDTO.getPage(),categoryPageQueryDTO.getPageSize());
//下一条sql进行分页,自动加入limit关键字分页
Page<Category> page = categoryMapper.pageQuery(categoryPageQueryDTO);
return new PageResult(page.getTotal(), page.getResult());
}

/**
* 根据id删除分类
* @param id
*/
@Override
public void deleteById(Long id) {
//查询当前分类是否关联了菜品,如果关联了就抛出业务异常
Integer count = dishMapper.countByCategoryId(id);
if(count > 0){
//当前分类下有菜品,不能删除
throw new DeletionNotAllowedException(MessageConstant.CATEGORY_BE_RELATED_BY_DISH);
}

//查询当前分类是否关联了套餐,如果关联了就抛出业务异常
count = setmealMapper.countByCategoryId(id);
if(count > 0){
//当前分类下有菜品,不能删除
throw new DeletionNotAllowedException(MessageConstant.CATEGORY_BE_RELATED_BY_SETMEAL);
}

//删除分类数据
categoryMapper.deleteById(id);
}

/**
* 修改分类
* @param categoryDTO
*/
@Override
public void update(CategoryDTO categoryDTO) {
Category category = new Category();
BeanUtils.copyProperties(categoryDTO,category);

//设置修改时间、修改人
category.setUpdateTime(LocalDateTime.now());
category.setUpdateUser(BaseContext.getCurrentId());

categoryMapper.update(category);
}

/**
* 启用、禁用分类
* @param status
* @param id
*/
@Override
public void startOrStop(Integer status, Long id) {
Category category = Category.builder()
.id(id)
.status(status)
.updateTime(LocalDateTime.now())
.updateUser(BaseContext.getCurrentId())
.build();
categoryMapper.update(category);
}

/**
* 根据类型查询分类
* @param type
* @return
*/
@Override
public List<Category> list(Integer type) {
return categoryMapper.list(type);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
@Service
public class EmployeeServiceImpl implements EmployeeService {

@Autowired
private EmployeeMapper employeeMapper;

/**
* 员工登录
*
* @param employeeLoginDTO
* @return
*/
@Override
public Employee login(EmployeeLoginDTO employeeLoginDTO) {
String username = employeeLoginDTO.getUsername();
String password = employeeLoginDTO.getPassword();

//1、根据用户名查询数据库中的数据
Employee employee = employeeMapper.getByUsername(username);

//2、处理各种异常情况(用户名不存在、密码不对、账号被锁定)
if (employee == null) {
//账号不存在
throw new AccountNotFoundException(MessageConstant.ACCOUNT_NOT_FOUND);
}

//密码比对
// TODO 后期需要进行md5加密,然后再进行比对
password = DigestUtils.md5DigestAsHex(password.getBytes());
if (!password.equals(employee.getPassword())) {
//密码错误
throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR);
}

if (employee.getStatus().equals(StatusConstant.DISABLE)) {
//账号被锁定
throw new AccountLockedException(MessageConstant.ACCOUNT_LOCKED);
}

//3、返回实体对象
return employee;
}

/**
* 新增员工
*
* @param employeeDTO
*/
@Override
public void save(EmployeeDTO employeeDTO) {
Employee employee = new Employee();

//对象属性拷贝
BeanUtils.copyProperties(employeeDTO, employee);

//设置账号的状态,默认正常状态 1表示正常 0表示锁定
employee.setStatus(StatusConstant.ENABLE);

//设置密码,默认密码123456
employee.setPassword(DigestUtils.md5DigestAsHex(PasswordConstant.DEFAULT_PASSWORD.getBytes()));

//设置当前记录的创建时间和修改时间
employee.setCreateTime(LocalDateTime.now());
employee.setUpdateTime(LocalDateTime.now());

//设置当前记录创建人id和修改人id
employee.setCreateUser(BaseContext.getCurrentId());//目前写个假数据,后期修改
employee.setUpdateUser(BaseContext.getCurrentId());

employeeMapper.insert(employee);
}

/**
* 分页查询
*
* @param employeePageQueryDTO
* @return
*/
@Override
public PageResult pageQuery(EmployeePageQueryDTO employeePageQueryDTO) {
// select * from employee limit 0,10
//开始分页查询
PageHelper.startPage(employeePageQueryDTO.getPage(), employeePageQueryDTO.getPageSize());

Page<Employee> page = employeeMapper.pageQuery(employeePageQueryDTO);

long total = page.getTotal();
List<Employee> records = page.getResult();

return new PageResult(total, records);
}

/**
* 启用禁用员工账号
*
* @param status
* @param id
*/
@Override
public void startOrStop(Integer status, Long id) {
Employee employee = Employee.builder()
.status(status)
.id(id)
.build();

employeeMapper.update(employee);
}

/**
* 根据id查询员工
*
* @param id
* @return
*/
@Override
public Employee getById(Long id) {
Employee employee = employeeMapper.getById(id);
employee.setPassword("****");
return employee;
}

/**
* 编辑员工信息
*
* @param employeeDTO
*/
@Override
public void update(EmployeeDTO employeeDTO) {
Employee employee = new Employee();
BeanUtils.copyProperties(employeeDTO, employee);

employee.setUpdateTime(LocalDateTime.now());
employee.setUpdateUser(BaseContext.getCurrentId());

employeeMapper.update(employee);
}

}

Controller层

CategoryController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
/**
* 分类管理
*/
@RestController
@RequestMapping("/admin/category")
@Api(tags = "分类相关接口")
@Slf4j
public class CategoryController {

@Autowired
private CategoryService categoryService;

/**
* 新增分类
* @param categoryDTO
* @return
*/
@PostMapping
@ApiOperation("新增分类")
public Result<String> save(@RequestBody CategoryDTO categoryDTO){
log.info("新增分类:{}", categoryDTO);
categoryService.save(categoryDTO);
return Result.success();
}

/**
* 分类分页查询
* @param categoryPageQueryDTO
* @return
*/
@GetMapping("/page")
@ApiOperation("分类分页查询")
public Result<PageResult> page(CategoryPageQueryDTO categoryPageQueryDTO){
log.info("分页查询:{}", categoryPageQueryDTO);
PageResult pageResult = categoryService.pageQuery(categoryPageQueryDTO);
return Result.success(pageResult);
}

/**
* 删除分类
* @param id
* @return
*/
@DeleteMapping
@ApiOperation("删除分类")
public Result<String> deleteById(Long id){
log.info("删除分类:{}", id);
categoryService.deleteById(id);
return Result.success();
}

/**
* 修改分类
* @param categoryDTO
* @return
*/
@PutMapping
@ApiOperation("修改分类")
public Result<String> update(@RequestBody CategoryDTO categoryDTO){
categoryService.update(categoryDTO);
return Result.success();
}

/**
* 启用、禁用分类
* @param status
* @param id
* @return
*/
@PostMapping("/status/{status}")
@ApiOperation("启用禁用分类")
public Result<String> startOrStop(@PathVariable("status") Integer status, Long id){
categoryService.startOrStop(status,id);
return Result.success();
}

/**
* 根据类型查询分类
* @param type
* @return
*/
@GetMapping("/list")
@ApiOperation("根据类型查询分类")
public Result<List<Category>> list(Integer type){
List<Category> list = categoryService.list(type);
return Result.success(list);
}
}

功能测试

分页查询和分类类型


启用禁用

修改
回显

修改后

新增

删除

day03

课程内容

  • 公共字段自动填充
  • 新增菜品
  • 菜品分页查询
  • 删除菜品
  • 修改菜品

功能实现:菜品管理

菜品管理效果图:

公共字段自动填充

需求分析

在前面学习的员工管理功能菜品分类功能,在 添加员工 或者 添加菜品分类 时都需要设置创建时间、创建人、修改时间、修改人等字段,在 编辑员工编辑菜品 分类时需要设置修改时间、修改人等字段。这些字段属于公共字段也就是在系统中很多表中都会有这些字段,如下:

序号字段名含义数据类型
1create_time创建时间datetime
2create_user创建人idbigint
3update_time修改时间datetime
4update_user修改人idbigint

针对这些字段的赋值方式通常为:

  1. 新增数据时, 将createTimeupdateTime 设置为当前时间, createUserupdateUser设置为当前登录用户ID。
  2. 更新数据时, 将updateTime设置为当前时间, updateUser设置为当前登录用户ID。

目前处理这些字段都是通过在对应的添加、修改方法中手动设置这些字段的值来实现,但是这样存在问题,比如新增、修改方法中如果忘记设置这些字段的值,那么这些字段的值就会丢失,且处理方法相对冗余、繁琐。因此,我们可以通过AOP切面编程来实现这些公共字段的自动填充。

实现思路

公共字段自动填充,也就是在插入或者更新的时候为指定字段赋予指定的值,使用它的好处就是可以统一对这些字段进行处理,避免了重复代码,前面提到的共有四个公共字段需要进行自动填充,分别是:createTimeupdateTimecreateUserupdateUser

序号字段名含义数据类型操作类型
1create_time创建时间datetimeinsert
2create_user创建人idbigintinsert
3update_time修改时间datetimeinsert、update
4update_user修改人idbigintinsert、update

实现步骤:

  1. 自定义注解AutoFill,用于标识需要进行公共字段自动填充的方法。
  2. 自定义切面类AutoFillAspect,统一拦截加入了AutoFill注解的方法,通过反射为公共字段赋值。
  3. 在Mapper的方法上加入AutoFill注解

技术点: 枚举、注解、AOP、反射

代码开发

自定义注解

进入sky-server模块,创建com.sky.annotation包

1
2
3
4
5
6
7
8
9
/**
* 自定义注解,用于标识某个方法需要进行功能字段自动填充处理
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {
//数据库操作类型:UPDATE INSERT
OperationType value();
}

其中OperationType已在sky-common模块中定义
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 数据库操作类型
*/
public enum OperationType {
/**
* 更新操作
*/
UPDATE,

/**
* 插入操作
*/
INSERT
}

自定义切面

在sky-server模块,创建com.sky.aspect包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 自定义切面,实现公共字段自动填充处理逻辑
*/
@Aspect
@Component
@Slf4j
public class AutoFillAspect {

/**
* 切入点
*/
@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
public void autoFillPointCut(){}

/**
* 前置通知,在通知中进行公共字段的赋值
*/
@Before("autoFillPointCut()")
public void autoFill(JoinPoint joinPoint){
/////////////////////重要////////////////////////////////////
//可先进行调试,是否能进入该方法 提前在mapper方法添加AutoFill注解
log.info("开始进行公共字段自动填充...");
}
}

完善自定义切面 AutoFillAspectautoFill 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
/**
* 自定义切面,实现公共字段自动填充处理逻辑
*/
@Aspect
@Component
@Slf4j
public class AutoFillAspect {

/**
* 切入点
*/
@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
public void autoFillPointCut(){}

/**
* 前置通知,在通知中进行公共字段的赋值
*/
@Before("autoFillPointCut()")
public void autoFill(JoinPoint joinPoint){
log.info("开始进行公共字段自动填充...");

//获取到当前被拦截的方法上的数据库操作类型
MethodSignature signature = (MethodSignature) joinPoint.getSignature();//方法签名对象
AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class);//获得方法上的注解对象
OperationType operationType = autoFill.value();//获得数据库操作类型

//获取到当前被拦截的方法的参数--实体对象
Object[] args = joinPoint.getArgs();
if(args == null || args.length == 0){
return;
}

Object entity = args[0];

//准备赋值的数据
LocalDateTime now = LocalDateTime.now();
Long currentId = BaseContext.getCurrentId();

//根据当前不同的操作类型,为对应的属性通过反射来赋值
if(operationType == OperationType.INSERT){
//为4个公共字段赋值
try {
Method setCreateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class);
Method setCreateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER, Long.class);
Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);

//通过反射为对象属性赋值
setCreateTime.invoke(entity,now);
setCreateUser.invoke(entity,currentId);
setUpdateTime.invoke(entity,now);
setUpdateUser.invoke(entity,currentId);
} catch (Exception e) {
e.printStackTrace();
}
}else if(operationType == OperationType.UPDATE){
//为2个公共字段赋值
try {
Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);

//通过反射为对象属性赋值
setUpdateTime.invoke(entity,now);
setUpdateUser.invoke(entity,currentId);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}

添加注解

CategoryMapper为例,分别在新增和修改方法添加@AutoFill()注解,也需要EmployeeMapper做相同操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Mapper
public interface CategoryMapper {
/**
* 插入数据
* @param category
*/
@Insert("insert into category(type, name, sort, status, create_time, update_time, create_user, update_user)" +
" VALUES" +
" (#{type}, #{name}, #{sort}, #{status}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser})")
@AutoFill(value = OperationType.INSERT)
void insert(Category category);
/**
* 根据id修改分类
* @param category
*/
@AutoFill(value = OperationType.UPDATE)
void update(Category category);

}

执行完这三步操作以后,就可以将业务层中为公共字段赋值的代码注释掉了

功能测试

重启项目并新增一个菜品分类

新增之后,可以去数据库查看category表,新增的分类数据已经包含了公共字段的赋值:

新增菜品

需求分析和设计

需求分析

在后台系统可以管理菜品的信息,通过新增功能来添加新的菜品,在添加时需要选择菜品所属分类并且上传菜品图片

业务规则:

  • 菜品名称必须是唯一的
  • 菜品必须属于某个分类下,不能单独存在
  • 新增菜品时可以根据情况选择菜品的口味
  • 每个菜品必须对应一张图片

接口设计

  • 根据类型查询分类(已完成)
  • 文件上传
  • 新增菜品

表设计

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

表名说明
dish菜品表
dish_flavor菜品口味表
字段名数据类型说明备注
idbigint主键自增
namevarchar(32)菜品名称唯一
category_idbigint分类id逻辑外键
pricedecimal(10,2)菜品价格
imagevarchar(255)图片路径
descriptionvarchar(255)菜品描述
statusint售卖状态1起售 0停售
create_timedatetime创建时间
update_timedatetime最后修改时间
create_userbigint创建人id
update_userbigint最后修改人id
字段名数据类型说明备注
idbigint主键自增
dish_idbigint菜品id逻辑外键
namevarchar(32)口味名称
valuevarchar(255)口味值

代码开发

文件上传功能

新增菜品时,需要上传菜品的图片,因此需要使用到文件上传功能。
文件上传是将本地图片、视频、音频等文件上传到服务器,来供其他用户浏览或下载的过程。实现文件上传服务分为对文件进行存储和取出存储的文件两个部分,对于文件的存储解决方案通常有以下几种:

  1. 直接将图片保存到服务的硬盘(springmvc中的文件上传)
    • 优点:开发便捷,成本低
    • 缺点:扩容困难
  2. 使用分布式文件系统进行存储
    • 优点:容易实现扩容
    • 缺点:开发复杂度稍大(有成熟的产品可以使用,比如:FastDFS,MinIO)
  3. 使用第三方的存储服务(例如OSS)
    • 优点:开发简单,拥有强大功能,免维护
    • 缺点:付费(个人使用的话费用很低)

此处使用的是阿里云的OSS服务进行文件存储,具体的阿里云OSS内容在最新版的Web课程中有过介绍,这里就不重复介绍了。
Web在线笔记

实现步骤:

  1. 定义OSS相关配置
    在sky-server模块的application-dev.yml中添加以下配置,其中access-key-idaccess-key-secret需要从阿里云OSS控制台获取
    1
    2
    3
    4
    5
    6
    sky:
    alioss:
    endpoint: oss-cn-beijing.aliyuncs.com
    access-key-id: id
    access-key-secret: secret
    bucket-name: sky-take-out-silvan
    • 此处的access-key-idaccess-key-secret需要从阿里云OSS控制台获取,请勿泄露
    • endpoint的区域也需要根据bucket所在地区进行选择,如在杭州,则endpointoss-cn-hangzhou.aliyuncs.com
  2. 读取OSS配置
    在sky-common模块中,基础工程已定义
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @Component
    @ConfigurationProperties(prefix = "sky.alioss")
    @Data
    public class AliOssProperties {

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

    }
  3. 生成OSS工具类对象
    在sky-server模块的config包下新建OSS配置类
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    /**
    * 配置类,用于创建AliOssUtil对象
    */
    @Configuration
    @Slf4j
    public class OssConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public AliOssUtil aliOssUtil(AliOssProperties aliOssProperties){
    log.info("开始创建阿里云文件上传工具类对象:{}",aliOssProperties);
    return new AliOssUtil(aliOssProperties.getEndpoint(),
    aliOssProperties.getAccessKeyId(),
    aliOssProperties.getAccessKeySecret(),
    aliOssProperties.getBucketName());
    }
    }
    其中的AliOssUtil.java已经在sky-common模块中定义了
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    @Data
    @AllArgsConstructor
    @Slf4j
    public class AliOssUtil {

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

    /**
    * 文件上传
    *
    * @param bytes
    * @param objectName
    * @return
    */
    public String upload(byte[] bytes, String objectName) {

    // 创建OSSClient实例。
    OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);

    try {
    // 创建PutObject请求。
    ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(bytes));
    } catch (OSSException oe) {
    System.out.println("Caught an OSSException, which means your request made it to OSS, "
    + "but was rejected with an error response for some reason.");
    System.out.println("Error Message:" + oe.getErrorMessage());
    System.out.println("Error Code:" + oe.getErrorCode());
    System.out.println("Request ID:" + oe.getRequestId());
    System.out.println("Host ID:" + oe.getHostId());
    } catch (ClientException ce) {
    System.out.println("Caught an ClientException, which means the client encountered "
    + "a serious internal problem while trying to communicate with OSS, "
    + "such as not being able to access the network.");
    System.out.println("Error Message:" + ce.getMessage());
    } finally {
    if (ossClient != null) {
    ossClient.shutdown();
    }
    }

    //文件访问路径规则 https://BucketName.Endpoint/ObjectName
    StringBuilder stringBuilder = new StringBuilder("https://");
    stringBuilder
    .append(bucketName)
    .append(".")
    .append(endpoint)
    .append("/")
    .append(objectName);

    log.info("文件上传到:{}", stringBuilder.toString());

    return stringBuilder.toString();
    }
    }
  4. 定义文件上传接口
    在Controller层中定义接口
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    /**
    * 通用接口
    */
    @RestController
    @RequestMapping("/admin/common")
    @Api(tags = "通用接口")
    @Slf4j
    public class CommonController {

    @Autowired
    private AliOssUtil aliOssUtil;

    /**
    * 文件上传
    * @param file
    * @return
    */
    @PostMapping("/upload")
    @ApiOperation("文件上传")
    public Result<String> upload(MultipartFile file){
    log.info("文件上传:{}",file);

    try {
    //原始文件名
    String originalFilename = file.getOriginalFilename();
    //截取原始文件名的后缀 dfdfdf.png
    String extension = originalFilename.substring(originalFilename.lastIndexOf("."));
    //构造新文件名称
    String objectName = UUID.randomUUID().toString() + extension;

    //文件的请求路径
    String filePath = aliOssUtil.upload(file.getBytes(), objectName);
    return Result.success(filePath);
    } catch (IOException e) {
    log.error("文件上传失败:{}", e);
    }

    return Result.error(MessageConstant.UPLOAD_FAILED);
    }
    }

    新增菜品实现

  5. 设计DTO类
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    @Data
    public class DishDTO implements Serializable {

    private Long id;
    //菜品名称
    private String name;
    //菜品分类id
    private Long categoryId;
    //菜品价格
    private BigDecimal price;
    //图片
    private String image;
    //描述信息
    private String description;
    //0 停售 1 起售
    private Integer status;
    //口味
    private List<DishFlavor> flavors = new ArrayList<>();
    }
  6. Controller层
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    /**
    * 菜品管理
    */
    @RestController
    @RequestMapping("/admin/dish")
    @Api(tags = "菜品相关接口")
    @Slf4j
    public class DishController {

    @Autowired
    private DishService dishService;

    /**
    * 新增菜品
    *
    * @param dishDTO
    * @return
    */
    @PostMapping
    @ApiOperation("新增菜品")
    public Result save(@RequestBody DishDTO dishDTO) {
    log.info("新增菜品:{}", dishDTO);
    dishService.saveWithFlavor(dishDTO);//后绪步骤开发
    return Result.success();
    }
    }
  7. Service层
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public interface DishService {

    /**
    * 新增菜品和对应的口味
    *
    * @param dishDTO
    */
    public void saveWithFlavor(DishDTO dishDTO);

    }
  8. ServiceImpl层
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    @Service
    @Slf4j
    public class DishServiceImpl implements DishService {

    @Autowired
    private DishMapper dishMapper;
    @Autowired
    private DishFlavorMapper dishFlavorMapper;

    /**
    * 新增菜品和对应的口味
    *
    * @param dishDTO
    */
    @Transactional
    public void saveWithFlavor(DishDTO dishDTO) {

    Dish dish = new Dish();
    BeanUtils.copyProperties(dishDTO, dish);

    //向菜品表插入1条数据
    dishMapper.insert(dish);//后绪步骤实现

    //获取insert语句生成的主键值
    Long dishId = dish.getId();

    List<DishFlavor> flavors = dishDTO.getFlavors();
    if (flavors != null && flavors.size() > 0) {
    flavors.forEach(dishFlavor -> {
    dishFlavor.setDishId(dishId);
    });
    //向口味表插入n条数据
    dishFlavorMapper.insertBatch(flavors);//后绪步骤实现
    }
    }

    }
  9. Mapper层
  10. 在DishMapper中添加

    1
    2
    3
    4
    5
    6
    7
    /**
    * 插入菜品数据
    *
    * @param dish
    */
    @AutoFill(value = OperationType.INSERT)
    void insert(Dish dish);

    在/resources/mapper中新建DishMapper.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
    <mapper namespace="com.sky.mapper.DishMapper">

    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
    insert into dish (name, category_id, price, image, description, create_time, update_time, create_user,update_user, status)
    values (#{name}, #{categoryId}, #{price}, #{image}, #{description}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser}, #{status})
    </insert>
    </mapper>

    DishFlavorMapper

    在DishFlavorMapper中添加

    1
    2
    3
    4
    5
    6
    7
    8
        @Mapper
    public interface DishFlavorMapper {
    /**
    * 批量插入口味数据
    * @param flavors
    */
    void insertBatch(List<DishFlavor> flavors);
    }

    在/resources/mapper中新建DishFlavorMapper.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
    <mapper namespace="com.sky.mapper.DishFlavorMapper">
    <insert id="insertBatch">
    insert into dish_flavor (dish_id, name, value) VALUES
    <foreach collection="flavors" item="df" separator=",">
    (#{df.dishId},#{df.name},#{df.value})
    </foreach>
    </insert>
    </mapper>

功能测试

登录 -> 菜品管理 -> 新增菜品 -> 输入菜品信息 -> 保存


暂时还没有实现菜品查询功能,所以保存后通过数据库查看添加的数据

菜品分页查询

需求分析和设计

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


菜品信息展示时,菜品的图片字段和菜品分类字段较特殊

  • 图片字段在数据库中查询到的仅仅是图片名称,想要回显到页面则需要下载该图片
  • 分类名称存储形式是分类id,需要根据id来查询分类名称并展示

接口设计

代码开发

设计DTO类

根据菜品分页查询接口定义设计对应的DTO:

在sky-pojo模块中提供了DishPageQueryDTO类用于接收菜品分页查询的请求参数

1
2
3
4
5
6
7
8
9
10
11

@Data
public class DishPageQueryDTO implements Serializable {

private int page;
private int pageSize;
private String name;
private Integer categoryId; //分类id
private Integer status; //状态 0表示禁用 1表示启用

}

设计VO类

根据菜品分页查询接口定义设计对应的VO:

在sky-pojo模块中提供了DishVO类用于返回菜品分页查询的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DishVO implements Serializable {

private Long id;
//菜品名称
private String name;
//菜品分类id
private Long categoryId;
//菜品价格
private BigDecimal price;
//图片
private String image;
//描述信息
private String description;
//0 停售 1 起售
private Integer status;
//更新时间
private LocalDateTime updateTime;
//分类名称
private String categoryName;
//菜品关联的口味
private List<DishFlavor> flavors = new ArrayList<>();
}

Controller层

根据接口定义添加DishController的page分页查询方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 菜品分页查询
*
* @param dishPageQueryDTO
* @return
*/
@GetMapping("/page")
@ApiOperation("菜品分页查询")
public Result<PageResult> page(DishPageQueryDTO dishPageQueryDTO) {
log.info("菜品分页查询:{}", dishPageQueryDTO);
PageResult pageResult = dishService.pageQuery(dishPageQueryDTO);//后绪步骤定义
return Result.success(pageResult);
}

Service层接口

在 DishService中添加分页查询方法:

1
2
3
4
5
6
7
/**
* 菜品分页查询
*
* @param dishPageQueryDTO
* @return
*/
PageResult pageQuery(DishPageQueryDTO dishPageQueryDTO);

Service层实现类

在 DishServiceImpl 中实现分页查询方法:

1
2
3
4
5
6
7
8
9
10
11
/**
* 菜品分页查询
*
* @param dishPageQueryDTO
* @return
*/
public PageResult pageQuery(DishPageQueryDTO dishPageQueryDTO) {
PageHelper.startPage(dishPageQueryDTO.getPage(), dishPageQueryDTO.getPageSize());
Page<DishVO> page = dishMapper.pageQuery(dishPageQueryDTO);//后绪步骤实现
return new PageResult(page.getTotal(), page.getResult());
}

Mapper层

在 DishMapper 接口中声明 pageQuery 方法:

1
2
3
4
5
6
7
/**
* 菜品分页查询
*
* @param dishPageQueryDTO
* @return
*/
Page<DishVO> pageQuery(DishPageQueryDTO dishPageQueryDTO);

DishMapper.xml中编写SQL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<select id="pageQuery" resultType="com.sky.vo.DishVO">
select d.* , c.name as categoryName from dish d left outer join category c on d.category_id = c.id
<where>
<if test="name != null">
and d.name like concat('%',#{name},'%')
</if>
<if test="categoryId != null">
and d.category_id = #{categoryId}
</if>
<if test="status != null">
and d.status = #{status}
</if>
</where>
order by d.create_time desc
</select>

功能测试

删除菜品

需求分析和设计

菜品列表页面中每个菜品后面对应的操作分别为修改删除停售,可通过删除功能完成对菜品及相关的数据进行删除。

业务规则:

  • 可以一次删除一个菜品,也可以批量删除菜品
  • 起售中的菜品不能删除
  • 被套餐关联的菜品不能删除
  • 删除菜品后,关联的口味数据也需要删除掉

接口设计

根据原型图,设计出相应的接口。

注意: 删除一个菜品和批量删除菜品共用一个接口,区别只是参数个数不同。

表设计

删除菜品操作时,会涉及到以下三张表。

注意事项:

  • 在dish表中删除菜品基本数据时,同时,也要把关联在dish_flavor表中的数据一块删除。
  • setmeal_dish表为菜品和套餐关联的中间表。
  • 若删除的菜品数据关联着某个套餐,此时,删除失败。
  • 若要删除套餐关联的菜品数据,先解除两者关联,再对菜品进行删除。

代码开发

Controller层

在DishController中添加批量删除菜品的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 菜品批量删除
*
* @param ids
* @return
*/
@DeleteMapping
@ApiOperation("菜品批量删除")
public Result delete(@RequestParam List<Long> ids) {
log.info("菜品批量删除:{}", ids);
dishService.deleteBatch(ids);//后绪步骤实现
return Result.success();
}

Service层接口

在DishService接口中声明deleteBatch方法:

1
2
3
4
5
6
/**
* 菜品批量删除
*
* @param ids
*/
void deleteBatch(List<Long> ids);

4.2.3 Service层实现类

在DishServiceImpl中实现deleteBatch方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
   @Autowired
private SetmealDishMapper setmealDishMapper;
/**
* 菜品批量删除
*
* @param ids
*/
@Transactional//事务
public void deleteBatch(List<Long> ids) {
//判断当前菜品是否能够删除---是否存在起售中的菜品??
for (Long id : ids) {
Dish dish = dishMapper.getById(id);//后绪步骤实现
if (dish.getStatus() == StatusConstant.ENABLE) {
//当前菜品处于起售中,不能删除
throw new DeletionNotAllowedException(MessageConstant.DISH_ON_SALE);
}
}

//判断当前菜品是否能够删除---是否被套餐关联了??
List<Long> setmealIds = setmealDishMapper.getSetmealIdsByDishIds(ids);
if (setmealIds != null && setmealIds.size() > 0) {
//当前菜品被套餐关联了,不能删除
throw new DeletionNotAllowedException(MessageConstant.DISH_BE_RELATED_BY_SETMEAL);
}

//删除菜品表中的菜品数据
for (Long id : ids) {
dishMapper.deleteById(id);//后绪步骤实现
//删除菜品关联的口味数据
dishFlavorMapper.deleteByDishId(id);//后绪步骤实现
}
}

Mapper层

在DishMapper中添加getById方法

1
2
3
4
5
6
7
8
/**
* 根据主键查询菜品
*
* @param id
* @return
*/
@Select("select * from dish where id = #{id}")
Dish getById(Long id);

创建SetmealDishMapper,声明getSetmealIdsByDishIds方法,并在xml文件中编写SQL:

1
2
3
4
5
6
7
8
9
10
11
@Mapper
public interface SetmealDishMapper {
/**
* 根据菜品id查询对应的套餐id
*
* @param dishIds
* @return
*/
//select setmeal_id from setmeal_dish where dish_id in (1,2,3,4)
List<Long> getSetmealIdsByDishIds(List<Long> dishIds);
}
1
2
3
4
5
6
7
8
9
10
11
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.SetmealDishMapper">
<select id="getSetmealIdsByDishIds" resultType="java.lang.Long">
select setmeal_id from setmeal_dish where dish_id in
<foreach collection="dishIds" item="dishId" separator="," open="(" close=")">
#{dishId}
</foreach>
</select>
</mapper>

在DishMapper.java中声明deleteById方法并配置SQL:

1
2
3
4
5
6
7
/**
* 根据主键删除菜品数据
*
* @param id
*/
@Delete("delete from dish where id = #{id}")
void deleteById(Long id);

在DishFlavorMapper中声明deleteByDishId方法并配置SQL:

1
2
3
4
5
6
/**
* 根据菜品id删除对应的口味数据
* @param dishId
*/
@Delete("delete from dish_flavor where dish_id = #{dishId}")
void deleteByDishId(Long dishId);

修改菜品

需求分析和设计

点击修改按钮后跳转到菜品修改页面,修改页面会回显菜品相关信息,点击保存则完成修改操作

接口设计
分析可得该页面涉及四个接口:

  • 根据id查询菜品
  • 根据类型查询分类(已实现)
  • 文件上传(已实现)
  • 修改菜品
  • 根据id查询菜品

代码开发

根据id查询菜品实现

在DishController中创建方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 根据id查询菜品
*
* @param id
* @return
*/
@GetMapping("/{id}")
@ApiOperation("根据id查询菜品")
public Result<DishVO> getById(@PathVariable Long id) {
log.info("根据id查询菜品:{}", id);
DishVO dishVO = dishService.getByIdWithFlavor(id);//后绪步骤实现
return Result.success(dishVO);
}

在DishService接口中声明getByIdWithFlavor方法:

1
2
3
4
5
6
7
/**
* 根据id查询菜品和对应的口味数据
*
* @param id
* @return
*/
DishVO getByIdWithFlavor(Long id);

在DishServiceImpl中实现getByIdWithFlavor方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 根据id查询菜品和对应的口味数据
*
* @param id
* @return
*/
public DishVO getByIdWithFlavor(Long id) {
//根据id查询菜品数据
Dish dish = dishMapper.getById(id);

//根据菜品id查询口味数据
List<DishFlavor> dishFlavors = dishFlavorMapper.getByDishId(id);//后绪步骤实现

//将查询到的数据封装到VO
DishVO dishVO = new DishVO();
BeanUtils.copyProperties(dish, dishVO);
dishVO.setFlavors(dishFlavors);

return dishVO;
}

在DishFlavorMapper中声明getByDishId方法,并配置SQL:

1
2
3
4
5
6
7
/**
* 根据菜品id查询对应的口味数据
* @param dishId
* @return
*/
@Select("select * from dish_flavor where dish_id = #{dishId}")
List<DishFlavor> getByDishId(Long dishId);

修改菜品实现

根据修改菜品的接口定义在DishController中创建方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 修改菜品
*
* @param dishDTO
* @return
*/
@PutMapping
@ApiOperation("修改菜品")
public Result update(@RequestBody DishDTO dishDTO) {
log.info("修改菜品:{}", dishDTO);
dishService.updateWithFlavor(dishDTO);
return Result.success();
}

在DishService接口中声明updateWithFlavor方法:

1
2
3
4
5
6
/**
* 根据id修改菜品基本信息和对应的口味信息
*
* @param dishDTO
*/
void updateWithFlavor(DishDTO dishDTO);

在DishServiceImpl中实现updateWithFlavor方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 根据id修改菜品基本信息和对应的口味信息
*
* @param dishDTO
*/
public void updateWithFlavor(DishDTO dishDTO) {
Dish dish = new Dish();
BeanUtils.copyProperties(dishDTO, dish);

//修改菜品表基本信息
dishMapper.update(dish);

//删除原有的口味数据
dishFlavorMapper.deleteByDishId(dishDTO.getId());

//重新插入口味数据
List<DishFlavor> flavors = dishDTO.getFlavors();
if (flavors != null && flavors.size() > 0) {
flavors.forEach(dishFlavor -> {
dishFlavor.setDishId(dishDTO.getId());
});
//向口味表插入n条数据
dishFlavorMapper.insertBatch(flavors);
}
}

在DishMapper中,声明update方法:

1
2
3
4
5
6
7
/**
* 根据id动态修改菜品数据
*
* @param dish
*/
@AutoFill(value = OperationType.UPDATE)
void update(Dish dish);

并在DishMapper.xml文件中编写SQL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<update id="update">
update dish
<set>
<if test="name != null">name = #{name},</if>
<if test="categoryId != null">category_id = #{categoryId},</if>
<if test="price != null">price = #{price},</if>
<if test="image != null">image = #{image},</if>
<if test="description != null">description = #{description},</if>
<if test="status != null">status = #{status},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
<if test="updateUser != null">update_user = #{updateUser},</if>
</set>
where id = #{id}
</update>

菜品的起售/停售

day04

完成内容

完成套餐管理模块的相关业务功能,包括:

  • 新增套餐
  • 套餐分页查询
  • 删除套餐
  • 修改套餐
  • 套餐的起售/停售

新增套餐

需求分析和设计

业务规则:

  • 套餐名称唯一
  • 套餐必须属于某个分类
  • 套餐必须包含菜品
  • 名称、分类、价格、图片为必填项
  • 添加菜品窗口需要根据分类类型来展示菜品
  • 新增的套餐默认为停售状态

接口设计
涉及四个接口:

  • 根据类型查询分类(已完成)
  • 根据分类id查询菜品
  • 图片上传(已完成)
  • 新增套餐

数据库设计

setmeal表为套餐表,用于存储套餐的信息。

字段名数据类型说明备注
idbigint主键自增
namevarchar(32)套餐名称唯一
category_idbigint分类id逻辑外键
pricedecimal(10,2)套餐价格
imagevarchar(255)图片路径
descriptionvarchar(255)套餐描述
statusint售卖状态1起售 0停售
create_timedatetime创建时间
update_timedatetime最后修改时间
create_userbigint创建人id
update_userbigint最后修改人id

setmeal_dish表为套餐菜品关系表,用于存储套餐和菜品的关联关系

字段名数据类型说明备注
idbigint主键自增
setmeal_idbigint套餐id逻辑外键
dish_idbigint菜品id逻辑外键
namevarchar(32)菜品名称冗余字段
pricedecimal(10,2)菜品单价冗余字段
copiesint菜品份数

代码实现

Controller层

在DishController中添加根据分类id查询菜品的方法:

1
2
3
4
5
6
7
8
9
10
11
/**
* 根据分类id查询菜品
* @param categoryId
* @return
*/
@GetMapping("/list")
@ApiOperation("根据分类id查询菜品")
public Result<List<Dish>> list(Long categoryId){
List<Dish> list = dishService.list(categoryId);
return Result.success(list);
}

在SetmealController中添加新增套餐的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 套餐管理
*/
@RestController
@RequestMapping("/admin/setmeal")
@Api(tags = "套餐相关接口")
@Slf4j
public class SetmealController {

@Autowired
private SetmealService setmealService;

/**
* 新增套餐
* @param setmealDTO
* @return
*/
@PostMapping
@ApiOperation("新增套餐")
public Result save(@RequestBody SetmealDTO setmealDTO) {
setmealService.saveWithDish(setmealDTO);
return Result.success();
}
}

Service层

在DishService中添加根据分类id查询菜品的方法:

1
2
3
4
5
6
/**
* 根据分类id查询菜品
* @param categoryId
* @return
*/
List<Dish> list(Long categoryId);

在DishServiceImpl中实现该方法:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 根据分类id查询菜品
* @param categoryId
* @return
*/
public List<Dish> list(Long categoryId) {
Dish dish = Dish.builder()
.categoryId(categoryId)
.status(StatusConstant.ENABLE)
.build();
return dishMapper.list(dish);
}

在SetmealService中添加新增套餐的方法

1
2
3
4
5
6
7
8
public interface SetmealService {

/**
* 新增套餐,同时需要保存套餐和菜品的关联关系
* @param setmealDTO
*/
void saveWithDish(SetmealDTO setmealDTO);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* 套餐业务实现
*/
@Service
@Slf4j
public class SetmealServiceImpl implements SetmealService {

@Autowired
private SetmealMapper setmealMapper;
@Autowired
private SetmealDishMapper setmealDishMapper;
@Autowired
private DishMapper dishMapper;

/**
* 新增套餐,同时需要保存套餐和菜品的关联关系
* @param setmealDTO
*/
@Transactional
public void saveWithDish(SetmealDTO setmealDTO) {
Setmeal setmeal = new Setmeal();
BeanUtils.copyProperties(setmealDTO, setmeal);

//向套餐表插入数据
setmealMapper.insert(setmeal);

//获取生成的套餐id
Long setmealId = setmeal.getId();

List<SetmealDish> setmealDishes = setmealDTO.getSetmealDishes();
setmealDishes.forEach(setmealDish -> {
setmealDish.setSetmealId(setmealId);
});

//保存套餐和菜品的关联关系
setmealDishMapper.insertBatch(setmealDishes);
}
}

Mapper层

在DishMapper中添加list方法:

1
2
3
4
5
6
/**
* 动态条件查询菜品
* @param dish
* @return
*/
List<Dish> list(Dish dish);

在DishMapper.xml文件中编写SQL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<select id="list" resultType="Dish" parameterType="Dish">
select * from dish
<where>
<if test="name != null">
and name like concat('%',#{name},'%')
</if>
<if test="categoryId != null">
and category_id = #{categoryId}
</if>
<if test="status != null">
and status = #{status}
</if>
</where>
order by create_time desc
</select>

在SetmealMapper中添加insert方法:

1
2
3
4
5
6
/**
* 新增套餐
* @param setmeal
*/
@AutoFill(OperationType.INSERT)
void insert(Setmeal setmeal);

在SetmealMapper.xml文件中编写SQL:
1
2
3
4
5
6
<insert id="insert" parameterType="Setmeal" useGeneratedKeys="true" keyProperty="id">
insert into setmeal
(category_id, name, price, status, description, image, create_time, update_time, create_user, update_user)
values (#{categoryId}, #{name}, #{price}, #{status}, #{description}, #{image}, #{createTime}, #{updateTime},
#{createUser}, #{updateUser})
</insert>

在SetmealDishMapper中添加insertBatch方法:

1
2
3
4
5
/**
* 批量保存套餐和菜品的关联关系
* @param setmealDishes
*/
void insertBatch(List<SetmealDish> setmealDishes);

在SetmealDishMapper.xml文件中编写SQL:
1
2
3
4
5
6
7
8
<insert id="insertBatch" parameterType="list">
insert into setmeal_dish
(setmeal_id,dish_id,name,price,copies)
values
<foreach collection="setmealDishes" item="sd" separator=",">
(#{sd.setmealId},#{sd.dishId},#{sd.name},#{sd.price},#{sd.copies})
</foreach>
</insert>

功能测试

点击新增套餐按钮,并填写相关套餐信息


点击保存按钮后,去数据库的setmeal表中查看数据是否添加成功

套餐分页查询

需求分析和设计

业务规则:

  • 根据页码进行分页展示
  • 每页展示10条数据
  • 可以根据需要,按照套餐名称、分类、售卖状态进行查询

接口设计

代码实现

Controller层

在SetmealController中添加分页查询方法:

1
2
3
4
5
6
7
8
9
10
11
/**
* 分页查询
* @param setmealPageQueryDTO
* @return
*/
@GetMapping("/page")
@ApiOperation("分页查询")
public Result<PageResult> page(SetmealPageQueryDTO setmealPageQueryDTO) {
PageResult pageResult = setmealService.pageQuery(setmealPageQueryDTO);
return Result.success(pageResult);
}

Service层

在SetmealService中添加分页查询方法:

1
2
3
4
5
6
/**
* 分页查询
* @param setmealPageQueryDTO
* @return
*/
PageResult pageQuery(SetmealPageQueryDTO setmealPageQueryDTO);

在SetmealServiceImpl中实现该方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 分页查询
* @param setmealPageQueryDTO
* @return
*/
public PageResult pageQuery(SetmealPageQueryDTO setmealPageQueryDTO) {
int pageNum = setmealPageQueryDTO.getPage();
int pageSize = setmealPageQueryDTO.getPageSize();

PageHelper.startPage(pageNum, pageSize);
Page<SetmealVO> page = setmealMapper.pageQuery(setmealPageQueryDTO);
return new PageResult(page.getTotal(), page.getResult());
}

Mapper层

在SetmealMapper中添加分页查询方法:

1
2
3
4
5
6
/**
* 分页查询
* @param setmealPageQueryDTO
* @return
*/
Page<SetmealVO> pageQuery(SetmealPageQueryDTO setmealPageQueryDTO);

在SetmealMapper.xml文件中编写SQL:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<select id="pageQuery" resultType="com.sky.vo.SetmealVO">
select
s.*,c.name categoryName
from
setmeal s
left join
category c
on
s.category_id = c.id
<where>
<if test="name != null">
and s.name like concat('%',#{name},'%')
</if>
<if test="status != null">
and s.status = #{status}
</if>
<if test="categoryId != null">
and s.category_id = #{categoryId}
</if>
</where>
order by s.create_time desc
</select>

功能测试

重启服务器,访问http://localhost,点击套餐页面的数据已经能够正常显示在浏览器中了

删除套餐

需求分析和设计

点击删除套餐按钮即可在数据库中删除该套餐,套餐可以单个删除,也可以批量删除,起售中的套餐不能删除

接口设计

代码实现

Controller层

在SetmealController中添加批量删除方法

1
2
3
4
5
6
7
8
9
10
11
/**
* 批量删除套餐
* @param ids
* @return
*/
@DeleteMapping
@ApiOperation("批量删除套餐")
public Result delete(@RequestParam List<Long> ids){
setmealService.deleteBatch(ids);
return Result.success();
}

Service层

在SetmealService中添加批量删除方法

1
2
3
4
5
/**
* 批量删除套餐
* @param ids
*/
void deleteBatch(List<Long> ids);

在SetmealServiceImpl中实现该方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 批量删除套餐
* @param ids
*/
@Transactional
public void deleteBatch(List<Long> ids) {
ids.forEach(id -> {
Setmeal setmeal = setmealMapper.getById(id);
if(StatusConstant.ENABLE == setmeal.getStatus()){
//起售中的套餐不能删除
throw new DeletionNotAllowedException(MessageConstant.SETMEAL_ON_SALE);
}
});

ids.forEach(setmealId -> {
//删除套餐表中的数据
setmealMapper.deleteById(setmealId);
//删除套餐菜品关系表中的数据
setmealDishMapper.deleteBySetmealId(setmealId);
});
}

Mapper层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 根据id查询套餐
* @param id
* @return
*/
@Select("select * from setmeal where id = #{id}")
Setmeal getById(Long id);

/**
* 根据id删除套餐
* @param setmealId
*/
@Delete("delete from setmeal where id = #{id}")
void deleteById(Long setmealId);
1
2
3
4
5
6
/**
* 根据套餐id删除套餐和菜品的关联关系
* @param setmealId
*/
@Delete("delete from setmeal_dish where setmeal_id = #{setmealId}")
void deleteBySetmealId(Long setmealId);

功能测试

可以通过前后端联调,也可以通过接口文档测试,此处通过接口文档简单进行测试:

再次刷新数据库,该id对应的套餐就被删除了

修改套餐

需求分析和设计

和之前一样,修改套餐需要有数据的回显和对数据的更新

接口设计
共涉及5个接口

  • 根据id查询套餐
  • 根据类型查询分类(已完成)
  • 根据分类id查询菜品(已完成)
  • 图片上传(已完成)
  • 修改套餐

代码实现

Controller层

在SetmealController中添加数据回显修改套餐的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 根据id查询套餐,用于修改页面回显数据
*
* @param id
* @return
*/
@GetMapping("/{id}")
@ApiOperation("根据id查询套餐")
public Result<SetmealVO> getById(@PathVariable Long id) {
SetmealVO setmealVO = setmealService.getByIdWithDish(id);
return Result.success(setmealVO);
}

/**
* 修改套餐
*
* @param setmealDTO
* @return
*/
@PutMapping
@ApiOperation("修改套餐")
public Result update(@RequestBody SetmealDTO setmealDTO) {
setmealService.update(setmealDTO);
return Result.success();
}

Service层

在SetmealService中添加数据回显修改套餐的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 根据id查询套餐,用于修改页面回显数据
*
* @param id
* @return
*/
SetmealVO getByIdWithDish(Long id);

/**
* 修改套餐
*
* @param setmealDTO
*/
void update(SetmealDTO setmealDTO);

在SetmealServiceImpl中实现这两个方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* 根据id查询套餐和套餐菜品关系
*
* @param id
* @return
*/
public SetmealVO getByIdWithDish(Long id) {
Setmeal setmeal = setmealMapper.getById(id);
List<SetmealDish> setmealDishes = setmealDishMapper.getBySetmealId(id);

SetmealVO setmealVO = new SetmealVO();
BeanUtils.copyProperties(setmeal, setmealVO);
setmealVO.setSetmealDishes(setmealDishes);

return setmealVO;
}

/**
* 修改套餐
*
* @param setmealDTO
*/
@Transactional
public void update(SetmealDTO setmealDTO) {
Setmeal setmeal = new Setmeal();
BeanUtils.copyProperties(setmealDTO, setmeal);

//1、修改套餐表,执行update
setmealMapper.update(setmeal);

//套餐id
Long setmealId = setmealDTO.getId();

//2、删除套餐和菜品的关联关系,操作setmeal_dish表,执行delete
setmealDishMapper.deleteBySetmealId(setmealId);

List<SetmealDish> setmealDishes = setmealDTO.getSetmealDishes();
setmealDishes.forEach(setmealDish -> {
setmealDish.setSetmealId(setmealId);
});
//3、重新插入套餐和菜品的关联关系,操作setmeal_dish表,执行insert
setmealDishMapper.insertBatch(setmealDishes);
}

Mapper层

在SetmealDishMapper中添加查询套餐和套餐菜品关系的方法

1
2
3
4
5
6
7
/**
* 根据套餐id查询套餐和菜品的关联关系
* @param setmealId
* @return
*/
@Select("select * from setmeal_dish where setmeal_id = #{setmealId}")
List<SetmealDish> getBySetmealId(Long setmealId);

功能测试

  • 直接通过前后端联调进行简单测试即可,首先新增一个套餐
  • 对该套餐各项数据都进行修改,修改完后点击保存

起售/停售套餐

需求分析和设计

和对菜品的起售/停售操作一样


业务规则:

  • 对状态为起售的套餐进行停售操作,对状态为停售的套餐进行起售操作
  • 起售的套餐可以展示在用户端,停售的套餐不能展示在用户端
  • 起售套餐时,如果套餐内包含停售的菜品,则不能起售

接口设计

代码实现

Controller层

在SetmealController中添加起售/停售套餐的方法

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 套餐起售停售
* @param status
* @param id
* @return
*/
@PostMapping("/status/{status}")
@ApiOperation("套餐起售停售")
public Result startOrStop(@PathVariable Integer status, Long id) {
setmealService.startOrStop(status, id);
return Result.success();
}

Service层

在SetmealService中添加起售/停售套餐的方法

1
2
3
4
5
6
/**
* 套餐起售、停售
* @param status
* @param id
*/
void startOrStop(Integer status, Long id);

在SetmealServiceImpl中实现该方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 套餐起售、停售
* @param status
* @param id
*/
public void startOrStop(Integer status, Long id) {
//起售套餐时,判断套餐内是否有停售菜品,有停售菜品提示"套餐内包含未启售菜品,无法启售"
if(status == StatusConstant.ENABLE){
//select a.* from dish a left join setmeal_dish b on a.id = b.dish_id where b.setmeal_id = ?
List<Dish> dishList = dishMapper.getBySetmealId(id);
if(dishList != null && dishList.size() > 0){
dishList.forEach(dish -> {
if(StatusConstant.DISABLE == dish.getStatus()){
throw new SetmealEnableFailedException(MessageConstant.SETMEAL_ENABLE_FAILED);
}
});
}
}

Setmeal setmeal = Setmeal.builder()
.id(id)
.status(status)
.build();
setmealMapper.update(setmeal);
}

Mapper层

在DishMapper中添加查询菜品的方法

1
2
3
4
5
6
7
/**
* 根据套餐id查询菜品
* @param setmealId
* @return
*/
@Select("select a.* from dish a left join setmeal_dish b on a.id = b.dish_id where b.setmeal_id = #{setmealId}")
List<Dish> getBySetmealId(Long setmealId);

功能测试

通过接口文档,测试套餐起售停售功能

day05

课程入门

  • Redis入门
  • Redis数据类型
  • Redis常用命令
  • 在Java中操作Redis
  • 店铺营业状态设置

Redis相关的内容之前学习过并做了相关笔记,此处不再详细记录

功能实现:营业状态设置

效果图:


商家可以自己选择在线状态,状态为营业时客户可以在小程序下单,状态为打样时,客户无法在小程序端下单

Java中操作Redis

Redis的Java客户端

在java程序中操作Redis就需要使用Redis的Java客户端,就如同我们使用JDBC操作MySQL数据库一样。
Redis 的 Java 客户端很多,常用的几种:

  • Jedis
  • Lettuce
  • Spring Data Redis

Spring 对 Redis 客户端进行了整合,提供了 Spring Data Redis,在Spring Boot项目中还提供了对应的Starter,即 spring-boot-starter-data-redis

Spring Data Redis使用方式

介绍

Spring Data Redis 是 Spring 的一部分,提供了在 Spring 应用中通过简单的配置就可以访问 Redis 服务,对 Redis 底层开发包进行了高度封装。
网址:https://spring.io/projects/spring-data-redis

Spring Boot提供了对应的Starter,maven坐标:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Spring Data Redis中提供了一个高度封装的类:RedisTemplate,对相关api进行了归类封装,将同一类型操作封装为operation接口,具体分类如下:

  • ValueOperations:string数据操作
  • SetOperations:set类型数据操作
  • ZSetOperations:zset类型数据操作
  • HashOperations:hash类型的数据操作
  • ListOperations:list类型的数据操作

环境搭建

进入到sky-server模块

  1. 导入Spring Data Redis的maven坐标(已完成)
    1
    2
    3
    4
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
  1. 配置Redis数据源
    在application-dev.yml中添加
    1
    2
    3
    4
    5
    6
    sky:
    redis:
    host: localhost
    port: 6379
    password: password
    database: 10

说明:
database:指定使用Redis的哪个数据库,Redis服务启动后默认有16个数据库,编号分别是从0到15。
可以通过修改Redis配置文件来指定数据库的数量。

在application.yml中添加读取application-dev.yml中的相关Redis配置

1
2
3
4
5
6
7
8
spring:
profiles:
active: dev
redis:
host: ${sky.redis.host}
port: ${sky.redis.port}
password: ${sky.redis.password}
database: ${sky.redis.database}

  1. 编写配置类,创建RedisTemplate对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    @Configuration
    @Slf4j
    public class RedisConfiguration {

    @Bean
    public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){
    log.info("开始创建redis模板对象...");
    RedisTemplate redisTemplate = new RedisTemplate();
    //设置redis的连接工厂对象
    redisTemplate.setConnectionFactory(redisConnectionFactory);
    //设置redis key的序列化器
    redisTemplate.setKeySerializer(new StringRedisSerializer());
    return redisTemplate;
    }
    }

    注意
    当前配置类不是必须的,因为 Spring Boot 框架会自动装配 RedisTemplate 对象,但是默认的key序列化器为JdkSerializationRedisSerializer,导致我们存到Redis中后的数据和原始数据有差别,故设置为StringRedisSerializer序列化器。

  2. 通过RedisTemplate对象操作Redis
    在test下新建测试类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    @SpringBootTest
    public class SpringDataRedisTest {
    @Autowired
    private RedisTemplate redisTemplate;

    @Test
    public void testRedisTemplate(){
    System.out.println(redisTemplate);
    //string数据操作
    ValueOperations valueOperations = redisTemplate.opsForValue();
    //hash类型的数据操作
    HashOperations hashOperations = redisTemplate.opsForHash();
    //list类型的数据操作
    ListOperations listOperations = redisTemplate.opsForList();
    //set类型数据操作
    SetOperations setOperations = redisTemplate.opsForSet();
    //zset类型数据操作
    ZSetOperations zSetOperations = redisTemplate.opsForZSet();
    }
    }

    测试:

    说明RedisTemplate对象注入成功,并且通过该RedisTemplate对象获取操作5种数据类型相关对象。

常见数据类型

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 操作字符串类型的数据
*/
@Test
public void testString(){
// set get setex setnx
redisTemplate.opsForValue().set("name","小明");
String city = (String) redisTemplate.opsForValue().get("name");
System.out.println(city);
redisTemplate.opsForValue().set("code","1234",3, TimeUnit.MINUTES);
redisTemplate.opsForValue().setIfAbsent("lock","1");
redisTemplate.opsForValue().setIfAbsent("lock","2");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 操作哈希类型的数据
*/
@Test
public void testHash(){
//hset hget hdel hkeys hvals
HashOperations hashOperations = redisTemplate.opsForHash();

hashOperations.put("100","name","tom");
hashOperations.put("100","age","20");

String name = (String) hashOperations.get("100", "name");
System.out.println(name);

Set keys = hashOperations.keys("100");
System.out.println(keys);

List values = hashOperations.values("100");
System.out.println(values);

hashOperations.delete("100","age");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 操作列表类型的数据
*/
@Test
public void testList(){
//lpush lrange rpop llen
ListOperations listOperations = redisTemplate.opsForList();

listOperations.leftPushAll("mylist","a","b","c");
listOperations.leftPush("mylist","d");

List mylist = listOperations.range("mylist", 0, -1);
System.out.println(mylist);

listOperations.rightPop("mylist");

Long size = listOperations.size("mylist");
System.out.println(size);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 操作集合类型的数据
*/
@Test
public void testSet(){
//sadd smembers scard sinter sunion srem
SetOperations setOperations = redisTemplate.opsForSet();

setOperations.add("set1","a","b","c","d");
setOperations.add("set2","a","b","x","y");

Set members = setOperations.members("set1");
System.out.println(members);

Long size = setOperations.size("set1");
System.out.println(size);

Set intersect = setOperations.intersect("set1", "set2");
System.out.println(intersect);

Set union = setOperations.union("set1", "set2");
System.out.println(union);

setOperations.remove("set1","a","b");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 操作有序集合类型的数据
*/
@Test
public void testZset(){
//zadd zrange zincrby zrem
ZSetOperations zSetOperations = redisTemplate.opsForZSet();

zSetOperations.add("zset1","a",10);
zSetOperations.add("zset1","b",12);
zSetOperations.add("zset1","c",9);

Set zset1 = zSetOperations.range("zset1", 0, -1);
System.out.println(zset1);

zSetOperations.incrementScore("zset1","c",10);

zSetOperations.remove("zset1","a","b");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 通用命令操作
*/
@Test
public void testCommon(){
//keys exists type del
Set keys = redisTemplate.keys("*");
System.out.println(keys);

Boolean name = redisTemplate.hasKey("name");
Boolean set1 = redisTemplate.hasKey("set1");

for (Object key : keys) {
DataType type = redisTemplate.type(key);
System.out.println(type.name());
}

redisTemplate.delete("mylist");
}

店铺营业状态设置

需求分析和设计

进到苍穹外卖后台,显示餐厅的营业状态,营业状态分为营业中打烊中,若当前餐厅处于营业状态,自动接收任何订单,客户可在小程序进行下单操作;若当前餐厅处于打烊状态,不接受任何订单,客户便无法在小程序进行下单操作。

接口设计:

  • 设置营业状态
  • 管理端查询营业状态
  • 用户端查询营业状态

注:从技术层面分析,其实管理端和用户端查询营业状态时,可通过一个接口去实现即可。因为营业状态是一致的。但是,本项目约定:

  • 管理端发出的请求,统一使用/admin作为前缀。
  • 用户端发出的请求,统一使用/user作为前缀。

可以通过一张表来存储营业状态数据,但整个表就一个字段的意义不大,所以营业状态存储方式:基于Redis的字符串来存储
约定: 1表示营业 0表示打烊

代码开发

设置营业状态

在sky-server模块中,创建ShopController.java

根据接口定义创建ShopController的setStatus设置营业状态方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@RestController("adminShopController")
@RequestMapping("/admin/shop")
@Api(tags = "店铺相关接口")
@Slf4j
public class ShopController {

public static final String KEY = "SHOP_STATUS";

@Autowired
private RedisTemplate redisTemplate;

/**
* 设置店铺的营业状态
* @param status
* @return
*/
@PutMapping("/{status}")
@ApiOperation("设置店铺的营业状态")
public Result setStatus(@PathVariable Integer status){
log.info("设置店铺的营业状态为:{}",status == 1 ? "营业中" : "打烊中");
redisTemplate.opsForValue().set(KEY,status);
return Result.success();
}
}

管理端查询营业状态

根据接口定义创建ShopController的getStatus查询营业状态方法:

1
2
3
4
5
6
7
8
9
10
11
/**
* 获取店铺的营业状态
* @return
*/
@GetMapping("/status")
@ApiOperation("获取店铺的营业状态")
public Result<Integer> getStatus(){
Integer status = (Integer) redisTemplate.opsForValue().get(KEY);
log.info("获取到店铺的营业状态为:{}",status == 1 ? "营业中" : "打烊中");
return Result.success(status);
}

用户端查询营业状态

创建com.sky.controller.user包,在该包下创建ShopController.java

根据接口定义创建ShopController的getStatus查询营业状态方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RestController("userShopController")
@RequestMapping("/user/shop")
@Api(tags = "店铺相关接口")
@Slf4j
public class ShopController {

public static final String KEY = "SHOP_STATUS";

@Autowired
private RedisTemplate redisTemplate;

/**
* 获取店铺的营业状态
* @return
*/
@GetMapping("/status")
@ApiOperation("获取店铺的营业状态")
public Result<Integer> getStatus(){
Integer status = (Integer) redisTemplate.opsForValue().get(KEY);
log.info("获取到店铺的营业状态为:{}",status == 1 ? "营业中" : "打烊中");
return Result.success(status);
}
}

day06

课程内容

  • HttpClient
  • 微信小程序开发
  • 微信登录
  • 导入商品浏览功能代码

微信小程序开发

介绍

小程序是一种新的开放能力,开发者可以快速地开发一个小程序。可以在微信内被便捷地获取和传播,同时具有出色的使用体验。
官方网址: https://mp.weixin.qq.com/cgi-bin/wx?token=&lang=zh_CN

  1. 进行小程序开发时,需要先才注册一个小程序,可以以个人的身份来注册,也可以以企业或者其他组织的方式来注册,不同的主题注册小程序,最终的开放权限也不同,比如以个人身份来注册,则无法开通支付权限。
  2. 微信官方提供了一系列工具来帮助开发者快速完成小程序的开发,提供了完善的开发文档和开发者工具,还提供了相应的设计指南,同时也提供了一些小程序体验DEMO,可以快速的体验小程序实现的功能。
  3. 开发完的小程序想要上线,也提供了详细的接入教程

准备工作

开发微信小程序之前需要做如下准备工作:

  • 注册小程序
  • 完善小程序信息
  • 下载开发者工具
  1. 注册小程序,注册地址:https://mp.weixin.qq.com/wxopen/waregister?action=step1

  2. 完善小程序信息
    登录小程序后台:https://mp.weixin.qq.com/
    完善小程序信息、小程序类目

    查看小程序的 AppID

  3. 下载开发者工具,下载地址:https://developers.weixin.qq.com/miniprogram/dev/devtools/stable.html

  4. 创建小程序项目

    熟悉开发者工具布局

  5. 设置不校验合法域名

注: 开发阶段,小程序发出请求到后端的Tomcat服务器,若不勾选,请求发送失败。

入门案例

小程序开发本质上属于前端开发,主要使用JavaScript进行开发,简单了解即可,后面会直接给出小程序的代码

小程序目录结构

小程序包含一个描述整体程序的 app 和多个描述各自页面的 page。一个小程序主体部分由三个文件组成,必须放在项目的根目录,如下:

文件说明:

app.js: 必须存在,主要存放小程序的逻辑代码

app.json: 必须存在,小程序配置文件,主要存放小程序的公共配置

app.wxss: 非必须存在,主要存放小程序公共样式表,类似于前端的CSS样式

对小程序主体三个文件了解后,其实一个小程序又有多个页面。比如说,有商品浏览页面、购物车的页面、订单支付的页面、商品的详情页面等等。那这些页面会放在哪呢?
会存放在pages目录。

一个小程序会有许多个页面,比如商品浏览页面、购物车的页面、订单支付的页面、商品的详情页面等等,这些页面会存放在pages目录下,每个小程序页面主要由四个文件组成

文件说明:

js文件: 必须存在,存放页面业务逻辑代码,编写的js代码。

wxml文件: 必须存在,存放页面结构,主要是做页面布局,页面效果展示的,类似于HTML页面。

json文件: 非必须,存放页面相关的配置。

wxss文件: 非必须,存放页面样式表,相当于CSS文件。

编写和编译小程序

  1. 编写
    进入到index.wxml,编写页面布局
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <view class="container">
      <view>{{msg}}</view>
      <view>
        <button type="default" bindtap="getUserInfo">获取用户信息</button>
        <image style="width: 100px;height: 100px;" src="{{avatarUrl}}"></image>
        {{nickName}}
      </view>
      <view>
        <button type="primary" bindtap="wxlogin">微信登录</button>
        授权码:{{code}}
      </view>
      <view>
        <button type="warn" bindtap="sendRequest">发送请求</button>
        响应结果:{{result}}
      </view>
    </view>
    进入到index.js,编写业务逻辑代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    Page({
      data:{
        msg:'hello world',
        avatarUrl:'',
        nickName:'',
        code:'',
        result:''
      },
      getUserInfo:function(){
        wx.getUserProfile({
          desc'获取用户信息',
          success:(res) => {
            console.log(res)
            this.setData({
              avatarUrl:res.userInfo.avatarUrl,
              nickName:res.userInfo.nickName
            })
          }
        })
      },
      wxlogin:function(){
        wx.login({
          success(res) => {
            console.log("授权码:"+res.code)
            this.setData({
              code:res.code
            })
          }
        })
      },
      sendRequest:function(){
        wx.request({
          url'http://localhost:8080/user/shop/status',
          method:'GET',
          success:(res) => {
            console.log("响应结果:" + res.data.data)
            this.setData({
              result:res.data.data
            })
          }
        })
      }})
  2. 编译小程序
    点击开发者工具的编译按钮,编译小程序,就能看到运行效果了

发布小程序

想要将小程序发布上线,让所有用户都用到该小程序
点击上传按钮:


上传成功:

  • 将代码上传到微信服务端并不是表示小程序已经发布了,当前小程序只是一个开发版本,需提交审核,变成审核版本,审核通过后,进行发布,变成线上版本。

  • 一旦成为线上版本,这就说明小程序就已经发布上线了,微信用户就可以在微信里面去搜索和使用这个小程序了。

微信登录

导入小程序代码

  1. 在微信小程序开发者工具中导入小程序代码
  2. 输入自己的AppID
  3. 查看项目结构
  4. 修改配置
    将小程序请求的后端服务url修改为自己后端服务的ip地址和端口号(默认不需要修改)

微信登录流程

官方给出了小程序微信登录的详细流程:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html

流程图:

步骤分析:

  1. 小程序端,调用wx.login()获取code,就是授权码。
  2. 小程序端,调用wx.request()发送请求并携带code,请求开发者服务器(自己编写的后端服务)。
  3. 开发者服务端,通过HttpClient向微信接口服务发送请求,并携带appId+appsecret+code三个参数。
  4. 开发者服务端,接收微信接口服务返回的数据,session_key+opendId等。opendId是微信用户的唯一标识。
  5. 开发者服务端,自定义登录态,生成令牌(token)和openid等数据返回给小程序端,方便后绪请求身份校验。
  6. 小程序端,收到自定义登录态,存储storage。
  7. 小程序端,后绪通过wx.request()发起业务请求时,携带token。
  8. 开发者服务端,收到请求后,通过携带的token,解析当前登录用户的id。
  9. 开发者服务端,身份校验通过后,继续相关的业务逻辑处理,最终返回业务数据。

下面使用Postman来进行测试:

  1. 调用 wx.login() 获取 临时登录凭证code ,并回传到开发者服务器。
  2. 调用 auth.code2Session 接口,换取 用户唯一标识 OpenID 、 用户在微信开放平台帐号下的唯一标识UnionID(若当前小程序已绑定到微信开放平台帐号) 和 会话密钥 session_key

之后开发者服务器可以根据用户标识来生成自定义登录态,用于后续业务逻辑中前后端交互时识别用户身份。

实现步骤

  1. 获取授权码
    每次登录小程序时,小程序会弹出提示框,点击确定按钮,即可获取授权码,每个授权码只对应一次登录,因此每次测试都需要重新获取授权码
  2. 明确请求接口
    请求方式、请求路径、请求参数
  3. 发送请求
    获取session_keyopenid
    如果出现code been used错误提示,说明授权码已被使用过,需要重新获取

需求分析和设计

产品原型

用户进入小程序后,点击授权后才能点餐,需要获取当前用户的相关信息,比如昵称、头像等。该方式基于微信登录来实现小程序的登录,若用户第一次使用小程序进行点餐,则说明是新用户,需要将新用户的信息保存到数据库中完成自动注册

接口设计

通过微信登录就需要获得微信用户的openid,在小程序端获取授权码后,向后端服务发送请求并携带授权码,这样后端服务在收到授权码后就可以去请求微信你接口服务,最终后端向小程序返回openid和token等数据

说明: 请求路径/user/user/login,第一个user代表用户端,第二个user代表用户模块。

表设计

用户第一次使用小程序时会自动注册账号,并将用户信息存储到User表。

字段名数据类型说明备注
idbigint主键自增
openidvarchar(45)微信用户的唯一标识
namevarchar(32)用户姓名
phonevarchar(11)手机号
sexvarchar(2)性别
id_numbervarchar(18)身份证号
avatarvarchar(500)微信用户头像路径
create_timedatetime注册时间

说明: 手机号字段比较特殊,个人身份注册的小程序没有权限获取到微信用户的手机号。如果是以企业的资质
注册的小程序就能够拿到微信用户的手机号。

代码开发

添加相关配置

  • 添加微信登录所需的配置项
    1
    2
    3
    4
    sky:
    wechat:
    appid: wxffb3637a228223b8
    secret: 84311df9199ecacdf4f12d27b6b9522d
    1
    2
    3
    4
    sky:
    wechat:
    appid: ${sky.wechat.appid}
    secret: ${sky.wechat.secret}

application.yml中配置为用户生成jwt令牌时需要的配置项:

1
2
3
4
5
6
7
8
9
10
11
sky:
jwt:
# 设置jwt签名加密时使用的秘钥
admin-secret-key: itcast
# 设置jwt过期时间
admin-ttl: 7200000
# 设置前端传递过来的令牌名称
admin-token-name: token
user-secret-key: itheima
user-ttl: 7200000
user-token-name: authentication

DTO设计

根据传入参数设计DTO类:


在sky-pojo模块中已经给出了UserLoginDTO类

1
2
3
4
5
6
7
8
9
/**
* C端用户登录
*/
@Data
public class UserLoginDTO implements Serializable {

private String code;

}

VO设计

根据返回数据设计VO类:


在sky-pojo模块中已经给出了UserLoginVO类

1
2
3
4
5
6
7
8
9
10
11
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserLoginVO implements Serializable {

private Long id;
private String openid;
private String token;

}

Controller层

根据接口定义创建UserController的login方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

@RestController
@RequestMapping("/user/user")
@Api(tags = "C端用户相关接口")
@Slf4j
public class UserController {

@Autowired
private UserService userService;
@Autowired
private JwtProperties jwtProperties;

/**
* 微信登录
* @param userLoginDTO
* @return
*/
@PostMapping("/login")
@ApiOperation("微信登录")
public Result<UserLoginVO> login(@RequestBody UserLoginDTO userLoginDTO){
log.info("微信用户登录:{}",userLoginDTO.getCode());

//微信登录
User user = userService.wxLogin(userLoginDTO);//后绪步骤实现

//为微信用户生成jwt令牌
Map<String, Object> claims = new HashMap<>();
claims.put(JwtClaimsConstant.USER_ID,user.getId());
String token = JwtUtil.createJWT(jwtProperties.getUserSecretKey(), jwtProperties.getUserTtl(), claims);

UserLoginVO userLoginVO = UserLoginVO.builder()
.id(user.getId())
.openid(user.getOpenid())
.token(token)
.build();
return Result.success(userLoginVO);
}
}

Service层

创建UserService接口:

1
2
3
4
5
6
7
8
9
public interface UserService {

/**
* 微信登录
* @param userLoginDTO
* @return
*/
User wxLogin(UserLoginDTO userLoginDTO);
}

创建UserServiceImpl实现类: 实现获取微信用户的openid和微信登录功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@Service
@Slf4j
public class UserServiceImpl implements UserService {

//微信服务接口地址
public static final String WX_LOGIN = "https://api.weixin.qq.com/sns/jscode2session";

@Autowired
private WeChatProperties weChatProperties;
@Autowired
private UserMapper userMapper;

/**
* 微信登录
* @param userLoginDTO
* @return
*/
public User wxLogin(UserLoginDTO userLoginDTO) {
String openid = getOpenid(userLoginDTO.getCode());

//判断openid是否为空,如果为空表示登录失败,抛出业务异常
if(openid == null){
throw new LoginFailedException(MessageConstant.LOGIN_FAILED);
}

//判断当前用户是否为新用户
User user = userMapper.getByOpenid(openid);

//如果是新用户,自动完成注册
if(user == null){
user = User.builder()
.openid(openid)
.createTime(LocalDateTime.now())
.build();
userMapper.insert(user);//后绪步骤实现
}

//返回这个用户对象
return user;
}

/**
* 调用微信接口服务,获取微信用户的openid
* @param code
* @return
*/
private String getOpenid(String code){
//调用微信接口服务,获得当前微信用户的openid
Map<String, String> map = new HashMap<>();
map.put("appid",weChatProperties.getAppid());
map.put("secret",weChatProperties.getSecret());
map.put("js_code",code);
map.put("grant_type","authorization_code");
String json = HttpClientUtil.doGet(WX_LOGIN, map);

JSONObject jsonObject = JSON.parseObject(json);
String openid = jsonObject.getString("openid");
return openid;
}
}

Mapper层

创建UserMapper接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Mapper
public interface UserMapper {

/**
* 根据openid查询用户
* @param openid
* @return
*/
@Select("select * from user where openid = #{openid}")
User getByOpenid(String openid);

/**
* 插入数据
* @param user
*/
void insert(User user);
}

创建UserMapper.xml映射文件:

1
2
3
4
5
6
7
8
9
10
11
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.UserMapper">

<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into user (openid, name, phone, sex, id_number, avatar, create_time)
values (#{openid}, #{name}, #{phone}, #{sex}, #{idNumber}, #{avatar}, #{createTime})
</insert>

</mapper>

编写拦截器

编写拦截器JwtTokenUserInterceptor: 统一拦截用户端发送的请求并进行jwt校验

注意此处handler instanceof HandlerMethod中的HandlerMethod导入的是org.springframework.web.method包下的,之前引入的org.springframework.Message.handler包下的HandlerMethod类,导致此处判断条件不成立,从而导致拦截器无法生效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

/**
* jwt令牌校验的拦截器
*/
@Component
@Slf4j
public class JwtTokenUserInterceptor implements HandlerInterceptor {

@Autowired
private JwtProperties jwtProperties;

/**
* 校验jwt
*
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//判断当前拦截到的是Controller的方法还是其他资源
if (!(handler instanceof HandlerMethod)) {
//当前拦截到的不是动态方法,直接放行
return true;
}

//1、从请求头中获取令牌
String token = request.getHeader(jwtProperties.getUserTokenName());

//2、校验令牌
try {
log.info("jwt校验:{}", token);
Claims claims = JwtUtil.parseJWT(jwtProperties.getUserSecretKey(), token);
Long userId = Long.valueOf(claims.get(JwtClaimsConstant.USER_ID).toString());
log.info("当前用户的id:", userId);
BaseContext.setCurrentId(userId);
//3、通过,放行
return true;
} catch (Exception ex) {
//4、不通过,响应401状态码
response.setStatus(401);
return false;
}
}
}

在WebMvcConfiguration配置类中注册拦截器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Autowired
private JwtTokenUserInterceptor jwtTokenUserInterceptor;
/**
* 注册自定义拦截器
* @param registry
*/
protected void addInterceptors(InterceptorRegistry registry) {
log.info("开始注册自定义拦截器...");
//.........

registry.addInterceptor(jwtTokenUserInterceptor)
.addPathPatterns("/user/**")
.excludePathPatterns("/user/user/login")
.excludePathPatterns("/user/shop/status");
}

功能测试

重新编译并登录,能成功获取到openid和token即可


此时查看数据库的user表,就能看到成功将第一次登录的用户信息注册到表里了

导入商品浏览功能代码

需求分析和设计

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

接口设计

  • 查询分类
  • 根据分类id查询菜品
  • 根据分类id查询套餐
  • 根据套餐id查询包含的菜品

明确每个接口的请求方式、请求路径、传入参数和返回值。

代码导入

导入资料中的商品浏览功能代码即可

Mapper层

在SetmealMapper.java中添加listgetDishItemBySetmealId两个方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 动态条件查询套餐
* @param setmeal
* @return
*/
List<Setmeal> list(Setmeal setmeal);

/**
* 根据套餐id查询菜品选项
* @param setmealId
* @return
*/
@Select("select sd.name, sd.copies, d.image, d.description " +
"from setmeal_dish sd left join dish d on sd.dish_id = d.id " +
"where sd.setmeal_id = #{setmealId}")
List<DishItemVO> getDishItemBySetmealId(Long setmealId);

创建SetmealMapper.xml文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.SetmealMapper">

<select id="list" parameterType="Setmeal" resultType="Setmeal">
select * from setmeal
<where>
<if test="name != null">
and name like concat('%',#{name},'%')
</if>
<if test="categoryId != null">
and category_id = #{categoryId}
</if>
<if test="status != null">
and status = #{status}
</if>
</where>
</select>
</mapper>

Service层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public interface SetmealService {

/**
* 条件查询
* @param setmeal
* @return
*/
List<Setmeal> list(Setmeal setmeal);

/**
* 根据id查询菜品选项
* @param id
* @return
*/
List<DishItemVO> getDishItemById(Long id);

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* 套餐业务实现
*/
@Service
@Slf4j
public class SetmealServiceImpl implements SetmealService {

@Autowired
private SetmealMapper setmealMapper;
@Autowired
private SetmealDishMapper setmealDishMapper;
@Autowired
private DishMapper dishMapper;

/**
* 条件查询
* @param setmeal
* @return
*/
public List<Setmeal> list(Setmeal setmeal) {
List<Setmeal> list = setmealMapper.list(setmeal);
return list;
}

/**
* 根据id查询菜品选项
* @param id
* @return
*/
public List<DishItemVO> getDishItemById(Long id) {
return setmealMapper.getDishItemBySetmealId(id);
}
}
1
2
3
4
5
6
/**
* 条件查询菜品和口味
* @param dish
* @return
*/
List<DishVO> listWithFlavor(Dish dish);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 条件查询菜品和口味
* @param dish
* @return
*/
public List<DishVO> listWithFlavor(Dish dish) {
List<Dish> dishList = dishMapper.list(dish);

List<DishVO> dishVOList = new ArrayList<>();

for (Dish d : dishList) {
DishVO dishVO = new DishVO();
BeanUtils.copyProperties(d,dishVO);

//根据菜品id查询对应的口味
List<DishFlavor> flavors = dishFlavorMapper.getByDishId(d.getId());

dishVO.setFlavors(flavors);
dishVOList.add(dishVO);
}

return dishVOList;
}

Controller层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@RestController("userDishController")
@RequestMapping("/user/dish")
@Slf4j
@Api(tags = "C端-菜品浏览接口")
public class DishController {
@Autowired
private DishService dishService;

/**
* 根据分类id查询菜品
*
* @param categoryId
* @return
*/
@GetMapping("/list")
@ApiOperation("根据分类id查询菜品")
public Result<List<DishVO>> list(Long categoryId) {
Dish dish = new Dish();
dish.setCategoryId(categoryId);
dish.setStatus(StatusConstant.ENABLE);//查询起售中的菜品

List<DishVO> list = dishService.listWithFlavor(dish);

return Result.success(list);
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RestController("userCategoryController")
@RequestMapping("/user/category")
@Api(tags = "C端-分类接口")
public class CategoryController {

@Autowired
private CategoryService categoryService;

/**
* 查询分类
* @param type
* @return
*/
@GetMapping("/list")
@ApiOperation("查询分类")
public Result<List<Category>> list(Integer type) {
List<Category> list = categoryService.list(type);
return Result.success(list);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@RestController("userSetmealController")
@RequestMapping("/user/setmeal")
@Api(tags = "C端-套餐浏览接口")
public class SetmealController {
@Autowired
private SetmealService setmealService;

/**
* 条件查询
*
* @param categoryId
* @return
*/
@GetMapping("/list")
@ApiOperation("根据分类id查询套餐")
public Result<List<Setmeal>> list(Long categoryId) {
Setmeal setmeal = new Setmeal();
setmeal.setCategoryId(categoryId);
setmeal.setStatus(StatusConstant.ENABLE);

List<Setmeal> list = setmealService.list(setmeal);
return Result.success(list);
}

/**
* 根据套餐id查询包含的菜品列表
*
* @param id
* @return
*/
@GetMapping("/dish/{id}")
@ApiOperation("根据套餐id查询包含的菜品列表")
public Result<List<DishItemVO>> dishList(@PathVariable("id") Long id) {
List<DishItemVO> list = setmealService.getDishItemById(id);
return Result.success(list);
}
}

day07

课程内容

  • 缓存菜品
  • 缓存套餐
  • 添加购物车
  • 查看购物车
  • 清空购物车

功能实现:缓存商品购物车
效果图:

缓存菜品

问题说明

用户端小程序展示的菜品数据都是通过查询数据库获得,如果用户端访问量比较大,数据库访问压力随之增大。

结果: 系统响应慢、用户体验差

优化思路

通过Redis来缓存菜品数据,减少数据库查询操作。


缓存逻辑:

  • 每个分类下的菜品保存一份缓存数据
  • 数据库中菜品数据有变更时清理缓存数据

代码开发

修改用户端的DishCOntroller的list方法,在展示时加入缓存处理逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Autowired
private RedisTemplate redisTemplate;
/**
* 根据分类id查询菜品
*
* @param categoryId
* @return
*/
@GetMapping("/list")
@ApiOperation("根据分类id查询菜品")
public Result<List<DishVO>> list(Long categoryId) {

//构造redis中的key,规则:dish_分类id
String key = "dish_" + categoryId;

//查询redis中是否存在菜品数据
List<DishVO> list = (List<DishVO>) redisTemplate.opsForValue().get(key);
if(list != null && list.size() > 0){
//如果存在,直接返回,无须查询数据库
return Result.success(list);
}
////////////////////////////////////////////////////////
Dish dish = new Dish();
dish.setCategoryId(categoryId);
dish.setStatus(StatusConstant.ENABLE);//查询起售中的菜品

//如果不存在,查询数据库,将查询到的数据放入redis中
list = dishService.listWithFlavor(dish);
////////////////////////////////////////////////////////
redisTemplate.opsForValue().set(key, list);

return Result.success(list);
}

为了保证 数据库Redis 中的数据保持一致,修改管理端接口DishController 的相关方法,加入清理缓存逻辑,在更新或删除菜品时同时清除缓存。

需要改造的方法:

  • 新增菜品
  • 修改菜品
  • 批量删除菜品
  • 起售、停售菜品
    在管理端DishController中添加
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Autowired
    private RedisTemplate redisTemplate;
    /**
    * 清理缓存数据
    * @param pattern
    */
    private void cleanCache(String pattern){
    Set keys = redisTemplate.keys(pattern);
    redisTemplate.delete(keys);
    }

优化DishController中的相关方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 新增菜品
*
* @param dishDTO
* @return
*/
@PostMapping
@ApiOperation("新增菜品")
public Result save(@RequestBody DishDTO dishDTO) {
log.info("新增菜品:{}", dishDTO);
dishService.saveWithFlavor(dishDTO);

//清理缓存数据
String key = "dish_" + dishDTO.getCategoryId();
cleanCache(key);
return Result.success();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 菜品批量删除
*
* @param ids
* @return
*/
@DeleteMapping
@ApiOperation("菜品批量删除")
public Result delete(@RequestParam List<Long> ids) {
log.info("菜品批量删除:{}", ids);
dishService.deleteBatch(ids);

//将所有的菜品缓存数据清理掉,所有以dish_开头的key
cleanCache("dish_*");

return Result.success();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 修改菜品
*
* @param dishDTO
* @return
*/
@PutMapping
@ApiOperation("修改菜品")
public Result update(@RequestBody DishDTO dishDTO) {
log.info("修改菜品:{}", dishDTO);
dishService.updateWithFlavor(dishDTO);

//将所有的菜品缓存数据清理掉,所有以dish_开头的key
cleanCache("dish_*");

return Result.success();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 菜品起售停售
*
* @param status
* @param id
* @return
*/
@PostMapping("/status/{status}")
@ApiOperation("菜品起售停售")
public Result<String> startOrStop(@PathVariable Integer status, Long id) {
dishService.startOrStop(status, id);

//将所有的菜品缓存数据清理掉,所有以dish_开头的key
cleanCache("dish_*");

return Result.success();
}

缓存套餐

Spring Cache

介绍

Spring Cache是一个框架,实现了基于注解的缓存功能,只需要简单地加一个注解,就能实现缓存功能。

Spring Cache提供了一层抽象,底层可以切换不同的缓存实现,例如:

  • EHCache
  • Caffeine
  • Redis(常用)

起步依赖:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>

常用注解

在SpringCache中提供了很多缓存操作的注解,常见的是以下的几个:

注解说明
@EnableCaching开启缓存注解功能,通常加在启动类上
@Cacheable在方法执行前先查询缓存中是否有数据,如果有数据,则直接返回缓存数据;如果没有缓存数据,调用方法并将方法返回值放到缓存中
@CachePut将方法的返回值放到缓存中
@CacheEvict将一条或多条数据从缓存中删除

在spring boot项目中,使用缓存技术只需在项目中导入相关缓存技术的依赖包,并在启动类上使用@EnableCaching开启缓存支持即可。

例如,使用Redis作为缓存技术,只需要导入Spring data Redis的maven坐标即可。

入门案例

  1. 环境准备

导入基础工程:底层已使用Redis缓存实现

资料中提供了入门案例的基本环境代码,直接导入进来即可。导入进来的工程结构如下:

数据库准备:

创建名为spring_cache_demo数据库,将springcachedemo.sql脚本直接导入数据库中。

引导类上加@EnableCaching:

1
2
3
4
5
6
7
8
9
@Slf4j
@SpringBootApplication
@EnableCaching//开启缓存注解功能
public class CacheDemoApplication {
public static void main(String[] args) {
SpringApplication.run(CacheDemoApplication.class,args);
log.info("项目启动成功...");
}
}
  1. @CachePut注解

@CachePut 说明:

  • 作用: 将方法返回值,放入缓存
  • value: 缓存的名称, 每个缓存名称下面可以有很多key
  • key: 缓存的key -> 支持Spring的表达式语言SPEL语法

在save方法上添加@CachePut注解

当前UserController的save方法是用来保存用户信息的,我们希望在该用户信息保存到数据库的同时,也往缓存中缓存一份数据,我们可以在save方法上加上注解 @CachePut,用法如下:

1
2
3
4
5
6
7
8
9
10
11
/**
* CachePut:将方法返回值放入缓存
* value:缓存的名称,每个缓存名称下面可以有多个key
* key:缓存的key
*/
@PostMapping
@CachePut(value = "userCache", key = "#user.id")//key的生成:userCache::1
public User save(@RequestBody User user){
userMapper.insert(user);
return user;
}

说明: key的写法如下

user.id : #user指的是方法形参的名称, id指的是user的id属性 , 也就是使用user的id属性作为key ;

result.id : #result代表方法返回值,该表达式 代表以返回对象的id属性作为key ;

p0.id:#p0指的是方法中的第一个参数,id指的是第一个参数的id属性,也就是使用第一个参数的id属性作为key ;

a0.id:#a0指的是方法中的第一个参数,id指的是第一个参数的id属性,也就是使用第一个参数的id属性作为key ;

root.args[0].id:#root.args[0]指的是方法中的第一个参数,id指的是第一个参数的id属性,也就是使用第一个参数

的id属性作为key ;

启动服务,通过swagger接口文档测试,访问UserController的save()方法

因为id是自增,所以不需要设置id属性

image-20221210191702887

查看user表中的数据

image-20221210192325931

查看Redis中的数据

image-20221210192418204

3. @Cacheable注解

@Cacheable 说明:

​ 作用: 在方法执行前,spring先查看缓存中是否有数据,如果有数据,则直接返回缓存数据;若没有数据,调用方法并将方法返回值放到缓存中

​ value: 缓存的名称,每个缓存名称下面可以有多个key

​ key: 缓存的key —————> 支持Spring的表达式语言SPEL语法

在getById上加注解@Cacheable

1
2
3
4
5
6
7
8
9
10
11
/**
* Cacheable:在方法执行前spring先查看缓存中是否有数据,如果有数据,则直接返回缓存数据;若没有数据, *调用方法并将方法返回值放到缓存中
* value:缓存的名称,每个缓存名称下面可以有多个key
* key:缓存的key
*/
@GetMapping
@Cacheable(cacheNames = "userCache",key="#id")
public User getById(Long id){
User user = userMapper.getById(id);
return user;
}

重启服务,通过swagger接口文档测试,访问UserController的getById()方法

第一次访问,会请求我们controller的方法,查询数据库。后面再查询相同的id,就直接从Redis中查询数据,不用再查询数据库了,就说明缓存生效了。

提前在redis中手动删除掉id=1的用户数据

image-20221210193834150

查看控制台sql语句:说明从数据库查询的用户数据

image-20221210193948896

查看Redis中的缓存数据:说明已成功缓存

image-20221210194112334

再次查询相同id的数据时,直接从redis中直接获取,不再查询数据库。

4). @CacheEvict注解

@CacheEvict 说明:

​ 作用: 清理指定缓存

​ value: 缓存的名称,每个缓存名称下面可以有多个key

​ key: 缓存的key —————> 支持Spring的表达式语言SPEL语法

在 delete 方法上加注解@CacheEvict

1
2
3
4
5
6
7
8
9
10
11
@DeleteMapping
@CacheEvict(cacheNames = "userCache",key = "#id")//删除某个key对应的缓存数据
public void deleteById(Long id){
userMapper.deleteById(id);
}

@DeleteMapping("/delAll")
@CacheEvict(cacheNames = "userCache",allEntries = true)//删除userCache下所有的缓存数据
public void deleteAll(){
userMapper.deleteAll();
}

重启服务,通过swagger接口文档测试,访问UserController的deleteAll()方法

image-20221210195254874

查看user表:数据清空

image-20221210195332101

查询Redis缓存数据

image-20221210195500014

实现思路

实现步骤:

1). 导入Spring Cache和Redis相关maven坐标

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

3). 在用户端接口SetmealController的 list 方法上加入@Cacheable注解

4). 在管理端接口SetmealController的 save、delete、update、startOrStop等方法上加入CacheEvict注解

代码开发

按照上述实现步骤:

1). 导入Spring Cache和Redis相关maven坐标(已实现)

1
2
3
4
5
6
7
8
9
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.sky;

import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.transaction.annotation.EnableTransactionManagement;

@SpringBootApplication
@EnableTransactionManagement //开启注解方式的事务管理
@Slf4j
@EnableCaching
public class SkyApplication {
public static void main(String[] args) {
SpringApplication.run(SkyApplication.class, args);
log.info("server started");
}
}

3). 在用户端接口SetmealController的 list 方法上加入@Cacheable注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 条件查询
*
* @param categoryId
* @return
*/
@GetMapping("/list")
@ApiOperation("根据分类id查询套餐")
@Cacheable(cacheNames = "setmealCache",key = "#categoryId") //key: setmealCache::100
public Result<List<Setmeal>> list(Long categoryId) {
Setmeal setmeal = new Setmeal();
setmeal.setCategoryId(categoryId);
setmeal.setStatus(StatusConstant.ENABLE);

List<Setmeal> list = setmealService.list(setmeal);
return Result.success(list);
}

4). 在管理端接口SetmealController的 save、delete、update、startOrStop等方法上加入CacheEvict注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/**
* 新增套餐
*
* @param setmealDTO
* @return
*/
@PostMapping
@ApiOperation("新增套餐")
@CacheEvict(cacheNames = "setmealCache",key = "#setmealDTO.categoryId")//key: setmealCache::100
public Result save(@RequestBody SetmealDTO setmealDTO) {
setmealService.saveWithDish(setmealDTO);
return Result.success();
}
/**
* 批量删除套餐
*
* @param ids
* @return
*/
@DeleteMapping
@ApiOperation("批量删除套餐")
@CacheEvict(cacheNames = "setmealCache",allEntries = true)
public Result delete(@RequestParam List<Long> ids) {
setmealService.deleteBatch(ids);
return Result.success();
}
/**
* 修改套餐
*
* @param setmealDTO
* @return
*/
@PutMapping
@ApiOperation("修改套餐")
@CacheEvict(cacheNames = "setmealCache",allEntries = true)
public Result update(@RequestBody SetmealDTO setmealDTO) {
setmealService.update(setmealDTO);
return Result.success();
}

/**
* 套餐起售停售
*
* @param status
* @param id
* @return
*/
@PostMapping("/status/{status}")
@ApiOperation("套餐起售停售")
@CacheEvict(cacheNames = "setmealCache",allEntries = true)
public Result startOrStop(@PathVariable Integer status, Long id) {
setmealService.startOrStop(status, id);
return Result.success();
}

2.4 功能测试

通过前后端联调方式来进行测试,同时观察redis中缓存的套餐数据。和缓存菜品功能测试基本一致,不再赘述。

2.5 代码提交

image-20221210203035708

后续步骤和其它功能代码提交一致,不再赘述。

添加购物车

需求分析和设计

产品原型

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

接口设计

设计出对应的添加购物车接口。

说明: 添加购物车时,有可能添加菜品,也有可能添加套餐。故传入参数要么是菜品id,要么是套餐id。

表设计

购物车对应的数据表为shopping_cart表,具体表结构如下:

字段名数据类型说明备注
idbigint主键自增
namevarchar(32)商品名称冗余字段
imagevarchar(255)商品图片路径冗余字段
user_idbigint用户id逻辑外键
dish_idbigint菜品id逻辑外键
setmeal_idbigint套餐id逻辑外键
dish_flavorvarchar(50)菜品口味
numberint商品数量
amountdecimal(10,2)商品单价冗余字段
create_timedatetime创建时间

说明:

  • 购物车数据是关联用户的,在表结构中,我们需要记录,每一个用户的购物车数据是哪些
  • 菜品列表展示出来的既有套餐,又有菜品,如果用户选择的是套餐,就保存套餐ID(setmeal_id),如果用户选择的是菜品,就保存菜品ID(dish_id)
  • 对同一个菜品/套餐,如果选择多份不需要添加多条记录,增加数量number即可

代码开发

DTO设计

根据添加购物车接口的参数设计DTO:

在sky-pojo模块中已经提供了ShoppingCartDTO

1
2
3
4
5
6
7
8
@Data
public class ShoppingCartDTO implements Serializable {

private Long dishId;
private Long setmealId;
private String dishFlavor;

}

Controller层

根据添加购物车接口创建ShoppingCartController:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 购物车
*/
@RestController
@RequestMapping("/user/shoppingCart")
@Slf4j
@Api(tags = "C端-购物车接口")
public class ShoppingCartController {

@Autowired
private ShoppingCartService shoppingCartService;

/**
* 添加购物车
* @param shoppingCartDTO
* @return
*/
@PostMapping("/add")
@ApiOperation("添加购物车")
public Result<String> add(@RequestBody ShoppingCartDTO shoppingCartDTO){
log.info("添加购物车:{}", shoppingCartDTO);
shoppingCartService.addShoppingCart(shoppingCartDTO);//后绪步骤实现
return Result.success();
}
}

Service层

创建ShoppingCartService接口:

1
2
3
4
5
6
7
8
public interface ShoppingCartService {

/**
* 添加购物车
* @param shoppingCartDTO
*/
void addShoppingCart(ShoppingCartDTO shoppingCartDTO);
}

创建ShoppingCartServiceImpl实现类,并实现add方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@Service
public class ShoppingCartServiceImpl implements ShoppingCartService {

@Autowired
private ShoppingCartMapper shoppingCartMapper;
@Autowired
private DishMapper dishMapper;
@Autowired
private SetmealMapper setmealMapper;

/**
* 添加购物车
*
* @param shoppingCartDTO
*/
public void addShoppingCart(ShoppingCartDTO shoppingCartDTO) {
ShoppingCart shoppingCart = new ShoppingCart();
BeanUtils.copyProperties(shoppingCartDTO, shoppingCart);
//只能查询自己的购物车数据
shoppingCart.setUserId(BaseContext.getCurrentId());

//判断当前商品是否在购物车中
List<ShoppingCart> shoppingCartList = shoppingCartMapper.list(shoppingCart);

if (shoppingCartList != null && shoppingCartList.size() == 1) {
//如果已经存在,就更新数量,数量加1
shoppingCart = shoppingCartList.get(0);
shoppingCart.setNumber(shoppingCart.getNumber() + 1);
shoppingCartMapper.updateNumberById(shoppingCart);
} else {
//如果不存在,插入数据,数量就是1

//判断当前添加到购物车的是菜品还是套餐
Long dishId = shoppingCartDTO.getDishId();
if (dishId != null) {
//添加到购物车的是菜品
Dish dish = dishMapper.getById(dishId);
shoppingCart.setName(dish.getName());
shoppingCart.setImage(dish.getImage());
shoppingCart.setAmount(dish.getPrice());
} else {
//添加到购物车的是套餐
Setmeal setmeal = setmealMapper.getById(shoppingCartDTO.getSetmealId());
shoppingCart.setName(setmeal.getName());
shoppingCart.setImage(setmeal.getImage());
shoppingCart.setAmount(setmeal.getPrice());
}
shoppingCart.setNumber(1);
shoppingCart.setCreateTime(LocalDateTime.now());
shoppingCartMapper.insert(shoppingCart);
}
}
}

Mapper层

创建ShoppingCartMapper接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Mapper
public interface ShoppingCartMapper {
/**
* 条件查询
*
* @param shoppingCart
* @return
*/
List<ShoppingCart> list(ShoppingCart shoppingCart);

/**
* 更新商品数量
*
* @param shoppingCart
*/
@Update("update shopping_cart set number = #{number} where id = #{id}")
void updateNumberById(ShoppingCart shoppingCart);

/**
* 插入购物车数据
*
* @param shoppingCart
*/
@Insert("insert into shopping_cart (name, user_id, dish_id, setmeal_id, dish_flavor, number, amount, image, create_time) " +
" values (#{name},#{userId},#{dishId},#{setmealId},#{dishFlavor},#{number},#{amount},#{image},#{createTime})")
void insert(ShoppingCart shoppingCart);

}

创建ShoppingCartMapper.xml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.ShoppingCartMapper">
<select id="list" parameterType="ShoppingCart" resultType="ShoppingCart">
select * from shopping_cart
<where>
<if test="userId != null">
and user_id = #{userId}
</if>
<if test="dishId != null">
and dish_id = #{dishId}
</if>
<if test="setmealId != null">
and setmeal_id = #{setmealId}
</if>
<if test="dishFlavor != null">
and dish_flavor = #{dishFlavor}
</if>
</where>
order by create_time desc
</select>
</mapper>

功能测试

在小程序端点击添加菜品,添加菜品后,因为当前还没有实现查看购物车功能,因此可以去数据库的购物车表查看新填的数据

如果java程序显示插入一条语句,但是数据库表中没有数据,可能是因为前面微信登录处拦截器包导入错误,导致拦截器不生效而没有解析出user_id,插入字段为null导致的插入失败

查看购物车

需求分析和设计

产品原型

当用户添加完菜品和套餐后,可进入到购物车中,查看购物中的菜品和套餐。

接口设计

代码开发

Controller层

在ShoppingCartController中创建查看购物车的方法:

1
2
3
4
5
6
7
8
9
/**
* 查看购物车
* @return
*/
@GetMapping("/list")
@ApiOperation("查看购物车")
public Result<List<ShoppingCart>> list(){
return Result.success(shoppingCartService.showShoppingCart());
}

Service层

在ShoppingCartService接口中声明查看购物车的方法:

1
2
3
4
5
/**
* 查看购物车
* @return
*/
List<ShoppingCart> showShoppingCart();

在ShoppingCartServiceImpl中实现查看购物车的方法:

1
2
3
4
5
6
7
8
9
10
/**
* 查看购物车
* @return
*/
public List<ShoppingCart> showShoppingCart() {
return shoppingCartMapper.list(ShoppingCart.
builder().
userId(BaseContext.getCurrentId()).
build());
}

清空购物车

需求分析和设计

点击清空按钮时,会把购物车中的数据全部清空。

接口设计

代码开发

Controller层

在ShoppingCartController中创建清空购物车的方法:

1
2
3
4
5
6
7
8
9
10
/**
* 清空购物车商品
* @return
*/
@DeleteMapping("/clean")
@ApiOperation("清空购物车商品")
public Result<String> clean(){
shoppingCartService.cleanShoppingCart();
return Result.success();
}

Service层

在ShoppingCartService接口中声明清空购物车的方法:

1
2
3
4
/**
* 清空购物车商品
*/
void cleanShoppingCart();

在ShoppingCartServiceImpl中实现清空购物车的方法:

1
2
3
4
5
6
/**
* 清空购物车商品
*/
public void cleanShoppingCart() {
shoppingCartMapper.deleteByUserId(BaseContext.getCurrentId());
}

Mapper层

在ShoppingCartMapper接口中创建删除购物车数据的方法:

1
2
3
4
5
6
7
/**
* 根据用户id删除购物车数据
*
* @param userId
*/
@Delete("delete from shopping_cart where user_id = #{userId}")
void deleteByUserId(Long userId);

功能测试

点击清空

查看数据库中的数据

说明当前用户的购物车数据已全部删除。

day08

课程内容

  • 导入地址簿功能代码
  • 用户下单
  • 订单支付

功能实现:用户下单订单支付
用户下单效果图:

导入地址簿功能代码

需求分析和设计

产品原型

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

效果图:

对于地址簿管理,需要实现以下几个功能:

  • 查询地址列表
  • 新增地址
  • 修改地址
  • 删除地址
  • 设置默认地址
  • 查询默认地址

接口设计

根据上述原型图先 粗粒度 设计接口,共包含7个接口。

接口设计:

  • 新增地址
  • 查询登录用户所有地址
  • 查询默认地址
  • 根据id修改地址
  • 根据id删除地址
  • 根据id查询地址
  • 设置默认地址

接下来细粒度 分析每个接口,明确每个接口的请求方式、请求路径、传入参数和返回值。

表设计

用户的地址信息会存储在address_book表,即地址簿表中。具体表结构如下:

字段名数据类型说明备注
idbigint主键自增
user_idbigint用户id逻辑外键
consigneevarchar(50)收货人
sexvarchar(2)性别
phonevarchar(11)手机号
province_codevarchar(12)省份编码
province_namevarchar(32)省份名称
city_codevarchar(12)城市编码
city_namevarchar(32)城市名称
district_codevarchar(12)区县编码
district_namevarchar(32)区县名称
detailvarchar(200)详细地址信息具体到门牌号
labelvarchar(100)标签公司、家、学校
is_defaulttinyint(1)是否默认地址1是 0否

这里面有一个字段is_default,实际上在设置默认地址时,只需要更新这个字段就可以了。

代码导入

对于这一类的单表增删改查,基本的开发思路都是一样的,本小节的用户地址簿管理的增删改查功能就不再一一实现了,直接导入资料中提供的代码即可

Mapper层

创建AddressBookMapper.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@Mapper
public interface AddressBookMapper {

/**
* 条件查询
* @param addressBook
* @return
*/
List<AddressBook> list(AddressBook addressBook);

/**
* 新增
* @param addressBook
*/
@Insert("insert into address_book" +
" (user_id, consignee, phone, sex, province_code, province_name, city_code, city_name, district_code," +
" district_name, detail, label, is_default)" +
" values (#{userId}, #{consignee}, #{phone}, #{sex}, #{provinceCode}, #{provinceName}, #{cityCode}, #{cityName}," +
" #{districtCode}, #{districtName}, #{detail}, #{label}, #{isDefault})")
void insert(AddressBook addressBook);

/**
* 根据id查询
* @param id
* @return
*/
@Select("select * from address_book where id = #{id}")
AddressBook getById(Long id);

/**
* 根据id修改
* @param addressBook
*/
void update(AddressBook addressBook);

/**
* 根据 用户id修改 是否默认地址
* @param addressBook
*/
@Update("update address_book set is_default = #{isDefault} where user_id = #{userId}")
void updateIsDefaultByUserId(AddressBook addressBook);

/**
* 根据id删除地址
* @param id
*/
@Delete("delete from address_book where id = #{id}")
void deleteById(Long id);

}

创建AddressBookMapper.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.AddressBookMapper">

<select id="list" parameterType="AddressBook" resultType="AddressBook">
select * from address_book
<where>
<if test="userId != null">
and user_id = #{userId}
</if>
<if test="phone != null">
and phone = #{phone}
</if>
<if test="isDefault != null">
and is_default = #{isDefault}
</if>
</where>
</select>

<update id="update" parameterType="addressBook">
update address_book
<set>
<if test="consignee != null">
consignee = #{consignee},
</if>
<if test="sex != null">
sex = #{sex},
</if>
<if test="phone != null">
phone = #{phone},
</if>
<if test="detail != null">
detail = #{detail},
</if>
<if test="label != null">
label = #{label},
</if>
<if test="isDefault != null">
is_default = #{isDefault},
</if>
</set>
where id = #{id}
</update>

</mapper>

Service层

创建AddressBookService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface AddressBookService {

List<AddressBook> list(AddressBook addressBook);

void save(AddressBook addressBook);

AddressBook getById(Long id);

void update(AddressBook addressBook);

void setDefault(AddressBook addressBook);

void deleteById(Long id);

}

创建AddressBookServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
@Service
@Slf4j
public class AddressBookServiceImpl implements AddressBookService {
@Autowired
private AddressBookMapper addressBookMapper;

/**
* 条件查询
*
* @param addressBook
* @return
*/
public List<AddressBook> list(AddressBook addressBook) {
return addressBookMapper.list(addressBook);
}

/**
* 新增地址
*
* @param addressBook
*/
public void save(AddressBook addressBook) {
addressBook.setUserId(BaseContext.getCurrentId());
addressBook.setIsDefault(0);
addressBookMapper.insert(addressBook);
}

/**
* 根据id查询
*
* @param id
* @return
*/
public AddressBook getById(Long id) {
AddressBook addressBook = addressBookMapper.getById(id);
return addressBook;
}

/**
* 根据id修改地址
*
* @param addressBook
*/
public void update(AddressBook addressBook) {
addressBookMapper.update(addressBook);
}

/**
* 设置默认地址
*
* @param addressBook
*/
@Transactional
public void setDefault(AddressBook addressBook) {
//1、将当前用户的所有地址修改为非默认地址 update address_book set is_default = ? where user_id = ?
addressBook.setIsDefault(0);
addressBook.setUserId(BaseContext.getCurrentId());
addressBookMapper.updateIsDefaultByUserId(addressBook);

//2、将当前地址改为默认地址 update address_book set is_default = ? where id = ?
addressBook.setIsDefault(1);
addressBookMapper.update(addressBook);
}

/**
* 根据id删除地址
*
* @param id
*/
public void deleteById(Long id) {
addressBookMapper.deleteById(id);
}

}

Controller层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
@RestController
@RequestMapping("/user/addressBook")
@Api(tags = "C端地址簿接口")
public class AddressBookController {

@Autowired
private AddressBookService addressBookService;

/**
* 查询当前登录用户的所有地址信息
*
* @return
*/
@GetMapping("/list")
@ApiOperation("查询当前登录用户的所有地址信息")
public Result<List<AddressBook>> list() {
AddressBook addressBook = new AddressBook();
addressBook.setUserId(BaseContext.getCurrentId());
List<AddressBook> list = addressBookService.list(addressBook);
return Result.success(list);
}

/**
* 新增地址
*
* @param addressBook
* @return
*/
@PostMapping
@ApiOperation("新增地址")
public Result save(@RequestBody AddressBook addressBook) {
addressBookService.save(addressBook);
return Result.success();
}

@GetMapping("/{id}")
@ApiOperation("根据id查询地址")
public Result<AddressBook> getById(@PathVariable Long id) {
AddressBook addressBook = addressBookService.getById(id);
return Result.success(addressBook);
}

/**
* 根据id修改地址
*
* @param addressBook
* @return
*/
@PutMapping
@ApiOperation("根据id修改地址")
public Result update(@RequestBody AddressBook addressBook) {
addressBookService.update(addressBook);
return Result.success();
}

/**
* 设置默认地址
*
* @param addressBook
* @return
*/
@PutMapping("/default")
@ApiOperation("设置默认地址")
public Result setDefault(@RequestBody AddressBook addressBook) {
addressBookService.setDefault(addressBook);
return Result.success();
}

/**
* 根据id删除地址
*
* @param id
* @return
*/
@DeleteMapping
@ApiOperation("根据id删除地址")
public Result deleteById(Long id) {
addressBookService.deleteById(id);
return Result.success();
}

/**
* 查询默认地址
*/
@GetMapping("default")
@ApiOperation("查询默认地址")
public Result<AddressBook> getDefault() {
//SQL:select * from address_book where user_id = ? and is_default = 1
AddressBook addressBook = new AddressBook();
addressBook.setIsDefault(1);
addressBook.setUserId(BaseContext.getCurrentId());
List<AddressBook> list = addressBookService.list(addressBook);

if (list != null && list.size() == 1) {
return Result.success(list.get(0));
}

return Result.error("没有查询到默认地址");
}

}

功能测试

可以通过如下方式进行测试:

  • 查看控制台sql和数据库中的数据变化
  • Swagger接口文档测试
  • 前后端联调

这里直接使用前后端联调测试:

启动后台服务,编译小程序

登录进入首页—>进入个人中心—>进入地址管理

用户下单

需求分析和设计

产品原型

用户下单业务说明:
在电商系统中,用户通过下单的方式通知商家,用户已经购买了商品,需要商家进行备货和发货。
用户下单后会产生订单相关数据,订单数据需要能够体现如下信息:

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

用户点餐业务流程(效果图):

接口设计

接口分析:

接口设计:

表设计

用户下单业务对应的数据表为orders表和order_detail表(一对多关系,一个订单关联多个订单明细):

表名含义说明
orders订单表主要存储订单的基本信息(如: 订单号、状态、金额、支付方式、下单用户、收件地址等)
order_detail订单明细表主要存储订单详情信息(如: 该订单关联的套餐及菜品的信息)

具体的表结构如下:

说明: 用户提交订单时,需要往订单表orders中插入一条记录,需要往order_detail中插入一条或多条记录。

代码开发

DTO设计

根据用户下单接口的参数设计DTO:

在sky-pojo模块,OrdersSubmitDTO.java已定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Data
public class OrdersSubmitDTO implements Serializable {
//地址簿id
private Long addressBookId;
//付款方式
private int payMethod;
//备注
private String remark;
//预计送达时间
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime estimatedDeliveryTime;
//配送状态 1立即送出 0选择具体时间
private Integer deliveryStatus;
//餐具数量
private Integer tablewareNumber;
//餐具数量状态 1按餐量提供 0选择具体数量
private Integer tablewareStatus;
//打包费
private Integer packAmount;
//总金额
private BigDecimal amount;
}

VO设计

根据用户下单接口的返回结果设计VO:

在sky-pojo模块,OrderSubmitVO.java已定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderSubmitVO implements Serializable {
//订单id
private Long id;
//订单号
private String orderNumber;
//订单金额
private BigDecimal orderAmount;
//下单时间
private LocalDateTime orderTime;
}

Controller层

创建OrderController并提供用户下单方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* 订单
*/
@RestController("userOrderController")
@RequestMapping("/user/order")
@Slf4j
@Api(tags = "C端-订单接口")
public class OrderController {

@Autowired
private OrderService orderService;

/**
* 用户下单
*
* @param ordersSubmitDTO
* @return
*/
@PostMapping("/submit")
@ApiOperation("用户下单")
public Result<OrderSubmitVO> submit(@RequestBody OrdersSubmitDTO ordersSubmitDTO) {
log.info("用户下单:{}", ordersSubmitDTO);
OrderSubmitVO orderSubmitVO = orderService.submitOrder(ordersSubmitDTO);
return Result.success(orderSubmitVO);
}

}

Service层

创建OrderService接口,并声明用户下单方法:

1
2
3
4
5
6
7
8
9
public interface OrderService {

/**
* 用户下单
* @param ordersSubmitDTO
* @return
*/
OrderSubmitVO submitOrder(OrdersSubmitDTO ordersSubmitDTO);
}

创建OrderServiceImpl实现OrderService接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
/**
* 订单
*/
@Service
@Slf4j
public class OrderServiceImpl implements OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private OrderDetailMapper orderDetailMapper;
@Autowired
private ShoppingCartMapper shoppingCartMapper;
@Autowired
private AddressBookMapper addressBookMapper;


/**
* 用户下单
*
* @param ordersSubmitDTO
* @return
*/
@Transactional
public OrderSubmitVO submitOrder(OrdersSubmitDTO ordersSubmitDTO) {
//异常情况的处理(收货地址为空、超出配送范围、购物车为空)
AddressBook addressBook = addressBookMapper.getById(ordersSubmitDTO.getAddressBookId());
if (addressBook == null) {
throw new AddressBookBusinessException(MessageConstant.ADDRESS_BOOK_IS_NULL);
}

Long userId = BaseContext.getCurrentId();
ShoppingCart shoppingCart = new ShoppingCart();
shoppingCart.setUserId(userId);

//查询当前用户的购物车数据
List<ShoppingCart> shoppingCartList = shoppingCartMapper.list(shoppingCart);
if (shoppingCartList == null || shoppingCartList.size() == 0) {
throw new ShoppingCartBusinessException(MessageConstant.SHOPPING_CART_IS_NULL);
}

//构造订单数据
Orders order = new Orders();
BeanUtils.copyProperties(ordersSubmitDTO,order);
order.setPhone(addressBook.getPhone());
order.setAddress(addressBook.getDetail());
order.setConsignee(addressBook.getConsignee());
order.setNumber(String.valueOf(System.currentTimeMillis()));
order.setUserId(userId);
order.setStatus(Orders.PENDING_PAYMENT);
order.setPayStatus(Orders.UN_PAID);
order.setOrderTime(LocalDateTime.now());

//向订单表插入1条数据
orderMapper.insert(order);

//订单明细数据
List<OrderDetail> orderDetailList = new ArrayList<>();
for (ShoppingCart cart : shoppingCartList) {
OrderDetail orderDetail = new OrderDetail();
BeanUtils.copyProperties(cart, orderDetail);
orderDetail.setOrderId(order.getId());
orderDetailList.add(orderDetail);
}

//向明细表插入n条数据
orderDetailMapper.insertBatch(orderDetailList);

//清理购物车中的数据
shoppingCartMapper.deleteByUserId(userId);

//封装返回结果
OrderSubmitVO orderSubmitVO = OrderSubmitVO.builder()
.id(order.getId())
.orderNumber(order.getNumber())
.orderAmount(order.getAmount())
.orderTime(order.getOrderTime())
.build();

return orderSubmitVO;
}

}

Mapper层

创建OrderMapper接口和对应的xml映射文件:

1
2
3
4
5
6
7
8
@Mapper
public interface OrderMapper {
/**
* 插入订单数据
* @param order
*/
void insert(Orders order);
}

创建OrderDetailMapper接口和对应的xml映射文件:

1
2
3
4
5
6
7
8
9
10
@Mapper
public interface OrderDetailMapper {

/**
* 批量插入订单明细数据
* @param orderDetails
*/
void insertBatch(List<OrderDetail> orderDetails);

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.OrderDetailMapper">

<insert id="insertBatch" parameterType="list">
insert into order_detail
(name, order_id, dish_id, setmeal_id, dish_flavor, number, amount, image)
values
<foreach collection="orderDetails" item="od" separator=",">
(#{od.name},#{od.orderId},#{od.dishId},#{od.setmealId},#{od.dishFlavor},
#{od.number},#{od.amount},#{od.image})
</foreach>
</insert>

</mapper>

功能测试

登录小程序,完成下单操作

下单操作时,同时会删除购物车中的数据

去结算—>去支付

订单支付

微信支付介绍

前面已经实现了用户下单,接下来就需要对订单进行支付。在现实生活中经常购买商品并且使用支付功能来付款,在付款的时候可能使用比较多的就是微信支付支付宝支付了。在苍穹外卖项目中,选择的就是微信支付这种支付方式。

要实现微信支付就需要注册微信支付的一个商户号,这个商户号是必须要有一家企业并且有正规的营业执照。只有具备了这些资质之后,才可以去注册商户号,才能开通支付权限。

个人不具备这种资质,无法注册商户号,所以在这里

微信支付产品:

image-20221214223302651

本项目选择小程序支付

参考:https://pay.weixin.qq.com/static/product/product_index.shtml

微信支付接入流程:

image-20221214223509246

微信小程序支付时序图:

image-20221214223910840

微信支付相关接口:

JSAPI下单:商户系统调用该接口在微信支付服务后台生成预支付交易单(对应时序图的第5步)

image-20221214224409174

微信小程序调起支付:通过JSAPI下单接口获取到发起支付的必要参数prepay_id,然后使用微信支付提供的小程序方法调起小程序支付(对应时序图的第10步)

image-20221214224551220

3.2 微信支付准备工作

3.2.1 如何保证数据安全?

完成微信支付有两个关键的步骤:

第一个就是需要在商户系统当中调用微信后台的一个下单接口,就是生成预支付交易单。

第二个就是支付成功之后微信后台会给推送消息。

这两个接口数据的安全性,要求其实是非常高的。

解决:微信提供的方式就是对数据进行加密、解密、签名多种方式。要完成数据加密解密,需要提前准备相应的一些文件,其实就是一些证书。

获取微信支付平台证书、商户私钥文件:

image-20221214234038395

在后绪程序开发过程中,就会使用到这两个文件,需要提前把这两个文件准备好。

3.2.2 如何调用到商户系统?

微信后台会调用到商户系统给推送支付的结果,在这里我们就会遇到一个问题,就是微信后台怎么就能调用到我们这个商户系统呢?因为这个调用过程,其实本质上也是一个HTTP请求。

目前,商户系统它的ip地址就是当前自己电脑的ip地址,只是一个局域网内的ip地址,微信后台无法调用到。

解决:内网穿透。通过cpolar软件可以获得一个临时域名,而这个临时域名是一个公网ip,这样,微信后台就可以请求到商户系统了。

cpolar软件的使用:

1). 下载与安装

下载地址:https://dashboard.cpolar.com/get-started

image-20221215184407217

在资料中已提供,可无需下载。

image-20221215184446260

安装过程中,一直下一步即可,不再演示。

2). cpolar指定authtoken

复制authtoken:

image-20221215184746092

执行命令:

image-20221215185152869

3). 获取临时域名

执行命令:

image-20221215185749163

获取域名:

image-20221215185833157

4). 验证临时域名有效性

访问接口文档

使用localhost:8080访问

image-20221215190440717

使用临时域名访问

image-20221215190525166

证明临时域名生效。

3.3 代码导入

导入资料中的微信支付功能代码即可

image-20221215192120424

3.3.1 微信支付相关配置

application-dev.yml

1
2
3
4
5
6
7
8
9
10
11
sky:
wechat:
appid: wxcd2e39f677fd30ba
secret: 84fbfdf5ea288f0c432d829599083637
mchid : 1561414331
mchSerialNo: 4B3B3DC35414AD50B1B755BAF8DE9CC7CF407606
privateKeyFilePath: D:\apiclient_key.pem
apiV3Key: CZBK51236435wxpay435434323FFDuv3
weChatPayCertFilePath: D:\wechatpay_166D96F876F45C7D07CE98952A96EC980368ACFC.pem
notifyUrl: https://www.weixin.qq.com/wxpay/pay.php
refundNotifyUrl: https://www.weixin.qq.com/wxpay/pay.php

application.yml

1
2
3
4
5
6
7
8
9
10
11
12
sky:
wechat:
appid: ${sky.wechat.appid}
secret: ${sky.wechat.secret}
mchid : ${sky.wechat.mchid}
mchSerialNo: ${sky.wechat.mchSerialNo}
privateKeyFilePath: ${sky.wechat.privateKeyFilePath}
apiV3Key: ${sky.wechat.apiV3Key}
weChatPayCertFilePath: ${sky.wechat.weChatPayCertFilePath}
notifyUrl: ${sky.wechat.notifyUrl}
refundNotifyUrl: ${sky.wechat.refundNotifyUrl}

WeChatProperties.java:读取配置(已定义)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.sky.properties;

import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "sky.wechat")
@Data
public class WeChatProperties {

private String appid; //小程序的appid
private String secret; //小程序的秘钥
private String mchid; //商户号
private String mchSerialNo; //商户API证书的证书序列号
private String privateKeyFilePath; //商户私钥文件
private String apiV3Key; //证书解密的密钥
private String weChatPayCertFilePath; //平台证书
private String notifyUrl; //支付成功的回调地址
private String refundNotifyUrl; //退款成功的回调地址
}

3.3.2 Mapper层

在OrderMapper.java中添加getByNumberAndUserId和update两个方法

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 根据订单号和用户id查询订单
* @param orderNumber
* @param userId
*/
@Select("select * from orders where number = #{orderNumber} and user_id= #{userId}")
Orders getByNumberAndUserId(String orderNumber, Long userId);

/**
* 修改订单信息
* @param orders
*/
void update(Orders orders);

在OrderMapper.xml中添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<update id="update" parameterType="com.sky.entity.Orders">
update orders
<set>
<if test="cancelReason != null and cancelReason!='' ">
cancel_reason=#{cancelReason},
</if>
<if test="rejectionReason != null and rejectionReason!='' ">
rejection_reason=#{rejectionReason},
</if>
<if test="cancelTime != null">
cancel_time=#{cancelTime},
</if>
<if test="payStatus != null">
pay_status=#{payStatus},
</if>
<if test="payMethod != null">
pay_method=#{payMethod},
</if>
<if test="checkoutTime != null">
checkout_time=#{checkoutTime},
</if>
<if test="status != null">
status = #{status},
</if>
<if test="deliveryTime != null">
delivery_time = #{deliveryTime}
</if>
</set>
where id = #{id}
</update>

3.3.3 Service层

在OrderService.java中添加payment和paySuccess两个方法定义

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 订单支付
* @param ordersPaymentDTO
* @return
*/
OrderPaymentVO payment(OrdersPaymentDTO ordersPaymentDTO) throws Exception;

/**
* 支付成功,修改订单状态
* @param outTradeNo
*/
void paySuccess(String outTradeNo);

在OrderServiceImpl.java中实现payment和paySuccess两个方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
	@Autowired
private UserMapper userMapper;
@Autowired
private WeChatPayUtil weChatPayUtil;
/**
* 订单支付
*
* @param ordersPaymentDTO
* @return
*/
public OrderPaymentVO payment(OrdersPaymentDTO ordersPaymentDTO) throws Exception {
// 当前登录用户id
Long userId = BaseContext.getCurrentId();
User user = userMapper.getById(userId);

//调用微信支付接口,生成预支付交易单
JSONObject jsonObject = weChatPayUtil.pay(
ordersPaymentDTO.getOrderNumber(), //商户订单号
new BigDecimal(0.01), //支付金额,单位 元
"苍穹外卖订单", //商品描述
user.getOpenid() //微信用户的openid
);

if (jsonObject.getString("code") != null && jsonObject.getString("code").equals("ORDERPAID")) {
throw new OrderBusinessException("该订单已支付");
}

OrderPaymentVO vo = jsonObject.toJavaObject(OrderPaymentVO.class);
vo.setPackageStr(jsonObject.getString("package"));

return vo;
}

/**
* 支付成功,修改订单状态
*
* @param outTradeNo
*/
public void paySuccess(String outTradeNo) {
// 当前登录用户id
Long userId = BaseContext.getCurrentId();

// 根据订单号查询当前用户的订单
Orders ordersDB = orderMapper.getByNumberAndUserId(outTradeNo, userId);

// 根据订单id更新订单的状态、支付方式、支付状态、结账时间
Orders orders = Orders.builder()
.id(ordersDB.getId())
.status(Orders.TO_BE_CONFIRMED)
.payStatus(Orders.PAID)
.checkoutTime(LocalDateTime.now())
.build();

orderMapper.update(orders);
}

3.3.4 Controller层

在OrderController.java中添加payment方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 订单支付
*
* @param ordersPaymentDTO
* @return
*/
@PutMapping("/payment")
@ApiOperation("订单支付")
public Result<OrderPaymentVO> payment(@RequestBody OrdersPaymentDTO ordersPaymentDTO) throws Exception {
log.info("订单支付:{}", ordersPaymentDTO);
OrderPaymentVO orderPaymentVO = orderService.payment(ordersPaymentDTO);
log.info("生成预支付交易单:{}", orderPaymentVO);
return Result.success(orderPaymentVO);
}

PayNotifyController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
package com.sky.controller.notify;

import com.alibaba.druid.support.json.JSONUtils;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.sky.annotation.IgnoreToken;
import com.sky.properties.WeChatProperties;
import com.sky.service.OrderService;
import com.wechat.pay.contrib.apache.httpclient.util.AesUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.entity.ContentType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;

/**
* 支付回调相关接口
*/
@RestController
@RequestMapping("/notify")
@Slf4j
public class PayNotifyController {
@Autowired
private OrderService orderService;
@Autowired
private WeChatProperties weChatProperties;

/**
* 支付成功回调
*
* @param request
*/
@RequestMapping("/paySuccess")
public void paySuccessNotify(HttpServletRequest request, HttpServletResponse response) throws Exception {
//读取数据
String body = readData(request);
log.info("支付成功回调:{}", body);

//数据解密
String plainText = decryptData(body);
log.info("解密后的文本:{}", plainText);

JSONObject jsonObject = JSON.parseObject(plainText);
String outTradeNo = jsonObject.getString("out_trade_no");//商户平台订单号
String transactionId = jsonObject.getString("transaction_id");//微信支付交易号

log.info("商户平台订单号:{}", outTradeNo);
log.info("微信支付交易号:{}", transactionId);

//业务处理,修改订单状态、来单提醒
orderService.paySuccess(outTradeNo);

//给微信响应
responseToWeixin(response);
}

/**
* 读取数据
*
* @param request
* @return
* @throws Exception
*/
private String readData(HttpServletRequest request) throws Exception {
BufferedReader reader = request.getReader();
StringBuilder result = new StringBuilder();
String line = null;
while ((line = reader.readLine()) != null) {
if (result.length() > 0) {
result.append("\n");
}
result.append(line);
}
return result.toString();
}

/**
* 数据解密
*
* @param body
* @return
* @throws Exception
*/
private String decryptData(String body) throws Exception {
JSONObject resultObject = JSON.parseObject(body);
JSONObject resource = resultObject.getJSONObject("resource");
String ciphertext = resource.getString("ciphertext");
String nonce = resource.getString("nonce");
String associatedData = resource.getString("associated_data");

AesUtil aesUtil = new AesUtil(weChatProperties.getApiV3Key().getBytes(StandardCharsets.UTF_8));
//密文解密
String plainText = aesUtil.decryptToString(associatedData.getBytes(StandardCharsets.UTF_8),
nonce.getBytes(StandardCharsets.UTF_8),
ciphertext);

return plainText;
}

/**
* 给微信响应
* @param response
*/
private void responseToWeixin(HttpServletResponse response) throws Exception{
response.setStatus(200);
HashMap<Object, Object> map = new HashMap<>();
map.put("code", "SUCCESS");
map.put("message", "SUCCESS");
response.setHeader("Content-type", ContentType.APPLICATION_JSON.toString());
response.getOutputStream().write(JSONUtils.toJSONString(map).getBytes(StandardCharsets.UTF_8));
response.flushBuffer();
}
}

3.4 功能测试

测试过程中,可通过断点方式查看后台每一步执行情况。

下单:

image-20221215205122716

去支付:

image-20221215205308701

确认支付:

image-20221215205434552

进行扫码支付即可。

3.5 代码提交

image-20221215205746248

后续步骤和其它功能代码提交一致,不再赘述。

day09

用户端历史订单模块

查询历史订单

需求分析和设计

业务规则

  • 分页查询历史订单
  • 可以根据订单状态查询
  • 展示订单数据时,需要展示的数据包括:下单时间、订单状态、订单金额、订单明细(商品名称、图片)

接口设计:详见接口文档

代码实现

Controller层
在OrderController中添加page方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 历史订单查询
*
* @param page
* @param pageSize
* @param status 订单状态 1待付款 2待接单 3已接单 4派送中 5已完成 6已取消
* @return
*/
@GetMapping("/historyOrders")
@ApiOperation("历史订单查询")
public Result<PageResult> page(int page, int pageSize, Integer status) {
PageResult pageResult = orderService.pageQuery4User(page, pageSize, status);
return Result.success(pageResult);
}

Service层
在OrderService中添加以下方法:
1
2
3
4
5
6
7
8
/**
* 用户端订单分页查询
* @param page
* @param pageSize
* @param status
* @return
*/
PageResult pageQuery4User(int page, int pageSize, Integer status);

在OrderServiceImpl中实现该方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* 用户端订单分页查询
*
* @param pageNum
* @param pageSize
* @param status
* @return
*/
public PageResult pageQuery4User(int pageNum, int pageSize, Integer status) {
// 设置分页
PageHelper.startPage(pageNum, pageSize);

OrdersPageQueryDTO ordersPageQueryDTO = new OrdersPageQueryDTO();
ordersPageQueryDTO.setUserId(BaseContext.getCurrentId());
ordersPageQueryDTO.setStatus(status);

// 分页条件查询
Page<Orders> page = orderMapper.pageQuery(ordersPageQueryDTO);

List<OrderVO> list = new ArrayList();

// 查询出订单明细,并封装入OrderVO进行响应
if (page != null && page.getTotal() > 0) {
for (Orders orders : page) {
Long orderId = orders.getId();// 订单id

// 查询订单明细
List<OrderDetail> orderDetails = orderDetailMapper.getByOrderId(orderId);

OrderVO orderVO = new OrderVO();
BeanUtils.copyProperties(orders, orderVO);
orderVO.setOrderDetailList(orderDetails);

list.add(orderVO);
}
}
return new PageResult(page.getTotal(), list);
}

Mapper层

在OrderMapper中添加方法

1
2
3
4
5
/**
* 分页条件查询并按下单时间排序
* @param ordersPageQueryDTO
*/
Page<Orders> pageQuery(OrdersPageQueryDTO ordersPageQueryDTO);

在映射文件中添加SQL语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<select id="pageQuery" resultType="Orders">
select * from orders
<where>
<if test="number != null and number!=''">
and number like concat('%',#{number},'%')
</if>
<if test="phone != null and phone!=''">
and phone like concat('%',#{phone},'%')
</if>
<if test="userId != null">
and user_id = #{userId}
</if>
<if test="status != null">
and status = #{status}
</if>
<if test="beginTime != null">
and order_time &gt;= #{beginTime}
</if>
<if test="endTime != null">
and order_time &lt;= #{endTime}
</if>
</where>
order by order_time desc
</select>

在OrderDetailMapper中添加方法:

1
2
3
4
5
6
7
/**
* 根据订单id查询订单明细
* @param orderId
* @return
*/
@Select("select * from order_detail where order_id = #{orderId}")
List<OrderDetail> getByOrderId(Long orderId);

查询订单详情

需求分析和设计

接口设计:参见接口文档

代码实现

controller层
在OrderController中添加方法

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 查询订单详情
*
* @param id
* @return
*/
@GetMapping("/orderDetail/{id}")
@ApiOperation("查询订单详情")
public Result<OrderVO> details(@PathVariable("id") Long id) {
OrderVO orderVO = orderService.details(id);
return Result.success(orderVO);
}

service层
在OrderService中添加方法
1
2
3
4
5
6
/**
* 查询订单详情
* @param id
* @return
*/
OrderVO details(Long id);

在OrderServiceImpl中实现该方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 查询订单详情
*
* @param id
* @return
*/
public OrderVO details(Long id) {
// 根据id查询订单
Orders orders = orderMapper.getById(id);

// 查询该订单对应的菜品/套餐明细
List<OrderDetail> orderDetailList = orderDetailMapper.getByOrderId(orders.getId());

// 将该订单及其详情封装到OrderVO并返回
OrderVO orderVO = new OrderVO();
BeanUtils.copyProperties(orders, orderVO);
orderVO.setOrderDetailList(orderDetailList);

return orderVO;
}


Mapper层
在OrderMapper中添加方法
1
2
3
4
5
6
/**
* 根据id查询订单
* @param id
*/
@Select("select * from orders where id=#{id}")
Orders getById(Long id);

取消订单

需求分析和设计

业务规则:

  • 待支付和待接单状态下,用户可直接取消订单
  • 商家已接单状态下,用户取消订单需电话沟通商家
  • 派送中状态下,用户取消订单需电话沟通商家
  • 如果在待接单状态下取消订单,需要给用户退款
  • 取消订单后需要将订单状态修改为“已取消”

接口设计:参见接口文档

代码实现

Controller层
在OrderController中添加方法

1
2
3
4
5
6
7
8
9
10
11
/**
* 用户取消订单
*
* @return
*/
@PutMapping("/cancel/{id}")
@ApiOperation("取消订单")
public Result cancel(@PathVariable("id") Long id) throws Exception {
orderService.userCancelById(id);
return Result.success();
}

Service层

在OrderService中添加方法

1
2
3
4
5
/**
* 用户取消订单
* @param id
*/
void userCancelById(Long id) throws Exception;

在OrderServiceImpl中实现该方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* 用户取消订单
*
* @param id
*/
public void userCancelById(Long id) throws Exception {
// 根据id查询订单
Orders ordersDB = orderMapper.getById(id);

// 校验订单是否存在
if (ordersDB == null) {
throw new OrderBusinessException(MessageConstant.ORDER_NOT_FOUND);
}

//订单状态 1待付款 2待接单 3已接单 4派送中 5已完成 6已取消
if (ordersDB.getStatus() > 2) {
throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR);
}

Orders orders = new Orders();
orders.setId(ordersDB.getId());

// 订单处于待接单状态下取消,需要进行退款
if (ordersDB.getStatus().equals(Orders.TO_BE_CONFIRMED)) {
// //调用微信支付退款接口
// weChatPayUtil.refund(
// ordersDB.getNumber(), //商户订单号
// ordersDB.getNumber(), //商户退款单号
// new BigDecimal(0.01),//退款金额,单位 元
// new BigDecimal(0.01));//原订单金额

//支付状态修改为 退款
orders.setPayStatus(Orders.REFUND);
}

// 更新订单状态、取消原因、取消时间
orders.setStatus(Orders.CANCELLED);
orders.setCancelReason("用户取消");
orders.setCancelTime(LocalDateTime.now());
orderMapper.update(orders);
}

再来一单

需求分析和设计

在历史订单详情页面,点击再来一单后会将订单数据添加到购物车中,并跳转到购物车页面。

接口设计:参见接口文档

代码实现

Controller层
在OrderController中添加方法

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 再来一单
*
* @param id
* @return
*/
@PostMapping("/repetition/{id}")
@ApiOperation("再来一单")
public Result repetition(@PathVariable Long id) {
orderService.repetition(id);
return Result.success();
}

Service层
在OrderService中添加方法
1
2
3
4
5
6
/**
* 再来一单
*
* @param id
*/
void repetition(Long id);

在OrderServiceImpl中实现方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* 再来一单
*
* @param id
*/
public void repetition(Long id) {
// 查询当前用户id
Long userId = BaseContext.getCurrentId();

// 根据订单id查询当前订单详情
List<OrderDetail> orderDetailList = orderDetailMapper.getByOrderId(id);

// 将订单详情对象转换为购物车对象
List<ShoppingCart> shoppingCartList = orderDetailList.stream().map(x -> {
ShoppingCart shoppingCart = new ShoppingCart();

// 将原订单详情里面的菜品信息重新复制到购物车对象中
BeanUtils.copyProperties(x, shoppingCart, "id");
shoppingCart.setUserId(userId);
shoppingCart.setCreateTime(LocalDateTime.now());

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

// 将购物车对象批量添加到数据库
shoppingCartMapper.insertBatch(shoppingCartList);
}

Mapper层
在ShoppingCartMapper中添加方法
1
2
3
4
5
6
/**
* 批量插入购物车数据
*
* @param shoppingCartList
*/
void insertBatch(List<ShoppingCart> shoppingCartList);

在ShoppingCartMapper的映射文件中添加对应的SQL语句
1
2
3
4
5
6
7
8
<insert id="insertBatch" parameterType="list">
insert into shopping_cart
(name, image, user_id, dish_id, setmeal_id, dish_flavor, number, amount, create_time)
values
<foreach collection="shoppingCartList" item="sc" separator=",">
(#{sc.name},#{sc.image},#{sc.userId},#{sc.dishId},#{sc.setmealId},#{sc.dishFlavor},#{sc.number},#{sc.amount},#{sc.createTime})
</foreach>
</insert>

商家端订单管理模块

订单搜索

需求分析和设计

业务规则:

  • 输入订单号/手机号进行搜索,支持模糊搜索
  • 根据订单状态进行筛选
  • 下单时间进行时间筛选
  • 搜索内容为空,提示未找到相关订单
  • 搜索结果页,展示包含搜索关键词的内容
  • 分页展示搜索到的订单数据

接口设计:参见接口文档

代码实现

Controller层
在admin包下创建OrderController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 订单管理
*/
@RestController("adminOrderController")
@RequestMapping("/admin/order")
@Slf4j
@Api(tags = "订单管理接口")
public class OrderController {

@Autowired
private OrderService orderService;

/**
* 订单搜索
*
* @param ordersPageQueryDTO
* @return
*/
@GetMapping("/conditionSearch")
@ApiOperation("订单搜索")
public Result<PageResult> conditionSearch(OrdersPageQueryDTO ordersPageQueryDTO) {
PageResult pageResult = orderService.conditionSearch(ordersPageQueryDTO);
return Result.success(pageResult);
}
}

Service层
在OrderService中添加以下方法
1
2
3
4
5
6
/**
* 条件搜索订单
* @param ordersPageQueryDTO
* @return
*/
PageResult conditionSearch(OrdersPageQueryDTO ordersPageQueryDTO);

在OrderServiceImpl中实现方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/**
* 订单搜索
*
* @param ordersPageQueryDTO
* @return
*/
public PageResult conditionSearch(OrdersPageQueryDTO ordersPageQueryDTO) {
PageHelper.startPage(ordersPageQueryDTO.getPage(), ordersPageQueryDTO.getPageSize());

Page<Orders> page = orderMapper.pageQuery(ordersPageQueryDTO);

// 部分订单状态,需要额外返回订单菜品信息,将Orders转化为OrderVO
List<OrderVO> orderVOList = getOrderVOList(page);

return new PageResult(page.getTotal(), orderVOList);
}

private List<OrderVO> getOrderVOList(Page<Orders> page) {
// 需要返回订单菜品信息,自定义OrderVO响应结果
List<OrderVO> orderVOList = new ArrayList<>();

List<Orders> ordersList = page.getResult();
if (!CollectionUtils.isEmpty(ordersList)) {
for (Orders orders : ordersList) {
// 将共同字段复制到OrderVO
OrderVO orderVO = new OrderVO();
BeanUtils.copyProperties(orders, orderVO);
String orderDishes = getOrderDishesStr(orders);

// 将订单菜品信息封装到orderVO中,并添加到orderVOList
orderVO.setOrderDishes(orderDishes);
orderVOList.add(orderVO);
}
}
return orderVOList;
}

/**
* 根据订单id获取菜品信息字符串
*
* @param orders
* @return
*/
private String getOrderDishesStr(Orders orders) {
// 查询订单菜品详情信息(订单中的菜品和数量)
List<OrderDetail> orderDetailList = orderDetailMapper.getByOrderId(orders.getId());

// 将每一条订单菜品信息拼接为字符串(格式:宫保鸡丁*3;)
List<String> orderDishList = orderDetailList.stream().map(x -> {
String orderDish = x.getName() + "*" + x.getNumber() + ";";
return orderDish;
}).collect(Collectors.toList());

// 将该订单对应的所有菜品信息拼接在一起
return String.join("", orderDishList);
}

各个状态的订单数量统计

需求分析和设计

接口设计:参见接口文档

代码实现

Controller层
在admin包下的OrderController类中添加方法

1
2
3
4
5
6
7
8
9
10
11
/**
* 各个状态的订单数量统计
*
* @return
*/
@GetMapping("/statistics")
@ApiOperation("各个状态的订单数量统计")
public Result<OrderStatisticsVO> statistics() {
OrderStatisticsVO orderStatisticsVO = orderService.statistics();
return Result.success(orderStatisticsVO);
}

Service层

在OrderService中添加以下方法

1
2
3
4
5
/**
* 各个状态的订单数量统计
* @return
*/
OrderStatisticsVO statistics();

在OrderServiceImpl中实现方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 各个状态的订单数量统计
*
* @return
*/
public OrderStatisticsVO statistics() {
// 根据状态,分别查询出待接单、待派送、派送中的订单数量
Integer toBeConfirmed = orderMapper.countStatus(Orders.TO_BE_CONFIRMED);
Integer confirmed = orderMapper.countStatus(Orders.CONFIRMED);
Integer deliveryInProgress = orderMapper.countStatus(Orders.DELIVERY_IN_PROGRESS);

// 将查询出的数据封装到orderStatisticsVO中响应
OrderStatisticsVO orderStatisticsVO = new OrderStatisticsVO();
orderStatisticsVO.setToBeConfirmed(toBeConfirmed);
orderStatisticsVO.setConfirmed(confirmed);
orderStatisticsVO.setDeliveryInProgress(deliveryInProgress);
return orderStatisticsVO;
}

Mapper层

在OrderMapper中添加以下方法

1
2
3
4
5
6
/**
* 根据状态统计订单数量
* @param status
*/
@Select("select count(id) from orders where status = #{status}")
Integer countStatus(Integer status);

查询订单详情

需求分析和设计

产品原型:

业务规则:

  • 订单详情页面需要展示订单基本信息(状态、订单号、下单时间、收货人、电话、收货地址、金额等)
  • 订单详情页面需要展示订单明细数据(商品名称、数量、单价)

接口设计:参见接口文档

代码实现

在OrderController中添加方法

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 订单详情
*
* @param id
* @return
*/
@GetMapping("/details/{id}")
@ApiOperation("查询订单详情")
public Result<OrderVO> details(@PathVariable("id") Long id) {
OrderVO orderVO = orderService.details(id);
return Result.success(orderVO);
}

Service层
在OrderService中添加方法

1
2
3
4
5
6
/**
* 查询订单详情
* @param id
* @return
*/
OrderVO details(Long id);

在OrderServiceImpl中实现方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 查询订单详情
*
* @param id
* @return
*/
public OrderVO details(Long id) {
// 根据id查询订单
Orders orders = orderMapper.getById(id);

// 查询该订单对应的菜品/套餐明细
List<OrderDetail> orderDetailList = orderDetailMapper.getByOrderId(orders.getId());

// 将该订单及其详情封装到OrderVO并返回
OrderVO orderVO = new OrderVO();
BeanUtils.copyProperties(orders, orderVO);
orderVO.setOrderDetailList(orderDetailList);

return orderVO;
}

Mapper层
在OrderMapper中添加方法
1
2
3
4
5
6
/**
* 根据id查询订单
* @param id
*/
@Select("select * from orders where id=#{id}")
Orders getById(Long id);

接单

需求分析和设计

业务规则:

  • 商家接单其实就是将订单的状态修改为“已接单”

接口设计:参见接口文档

代码实现

Controller层
在OrderController中添加方法

1
2
3
4
5
6
7
8
9
10
11
/**
* 接单
*
* @return
*/
@PutMapping("/confirm")
@ApiOperation("接单")
public Result confirm(@RequestBody OrdersConfirmDTO ordersConfirmDTO) {
orderService.confirm(ordersConfirmDTO);
return Result.success();
}

Service层
在OrderService中添加方法

1
2
3
4
5
6
/**
* 接单
*
* @param ordersConfirmDTO
*/
void confirm(OrdersConfirmDTO ordersConfirmDTO);

在OrderServiceImpl中实现方法

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 接单
*
* @param ordersConfirmDTO
*/
public void confirm(OrdersConfirmDTO ordersConfirmDTO) {
Orders orders = Orders.builder()
.id(ordersConfirmDTO.getId())
.status(Orders.CONFIRMED)
.build();

orderMapper.update(orders);
}

拒单

需求分析和设计

业务规则:

  • 商家拒单其实就是将订单状态修改为“已取消”
  • 只有订单处于“待接单”状态时可以执行拒单操作
  • 商家拒单时需要指定拒单原因
  • 商家拒单时,如果用户已经完成了支付,需要为用户退款

接口设计:参见接口文档

代码实现

Controller层
在DishController中创建方法

1
2
3
4
5
6
7
8
9
10
11
/**
* 拒单
*
* @return
*/
@PutMapping("/rejection")
@ApiOperation("拒单")
public Result rejection(@RequestBody OrdersRejectionDTO ordersRejectionDTO) throws Exception {
orderService.rejection(ordersRejectionDTO);
return Result.success();
}

Service层

1
2
3
4
5
6
/**
* 拒单
*
* @param ordersRejectionDTO
*/
void rejection(OrdersRejectionDTO ordersRejectionDTO) throws Exception;

在OrderServiceImpl中实现方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 拒单
*
* @param ordersRejectionDTO
*/
public void rejection(OrdersRejectionDTO ordersRejectionDTO) throws Exception {
// 根据id查询订单
Orders ordersDB = orderMapper.getById(ordersRejectionDTO.getId());

// 订单只有存在且状态为2(待接单)才可以拒单
if (ordersDB == null || !ordersDB.getStatus().equals(Orders.TO_BE_CONFIRMED)) {
throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR);
}

//支付状态
Integer payStatus = ordersDB.getPayStatus();
Orders orders = new Orders();
if (payStatus == Orders.PAID) {
//用户已支付,需要退款
// String refund = weChatPayUtil.refund(
// ordersDB.getNumber(),
// ordersDB.getNumber(),
// new BigDecimal(0.01),
// new BigDecimal(0.01));
// log.info("申请退款:{}", refund);


// 拒单需要退款,根据订单id更新订单状态、拒单原因、取消时间

orders.setId(ordersDB.getId());
orders.setStatus(Orders.CANCELLED);
orders.setRejectionReason(ordersRejectionDTO.getRejectionReason());
orders.setCancelTime(LocalDateTime.now());
}



orderMapper.update(orders);
}

取消订单

需求分析和设计

产品原型:

业务规则:

  • 取消订单其实就是将订单状态修改为“已取消”
  • 商家取消订单时需要指定取消原因
  • 商家取消订单时,如果用户已经完成了支付,需要为用户退款

接口设计:参见接口文档

代码实现

Controller层

1
2
3
4
5
6
7
8
9
10
11
/**
* 取消订单
*
* @return
*/
@PutMapping("/cancel")
@ApiOperation("取消订单")
public Result cancel(@RequestBody OrdersCancelDTO ordersCancelDTO) throws Exception {
orderService.cancel(ordersCancelDTO);
return Result.success();
}

Service层
在OrderService中添加方法

1
2
3
4
5
6
/**
* 商家取消订单
*
* @param ordersCancelDTO
*/
void cancel(OrdersCancelDTO ordersCancelDTO) throws Exception;

在OrderServiceImpl中实现方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* 取消订单
*
* @param ordersCancelDTO
*/
public void cancel(OrdersCancelDTO ordersCancelDTO) throws Exception {
// 根据id查询订单
Orders ordersDB = orderMapper.getById(ordersCancelDTO.getId());

// 管理端取消订单需要退款,根据订单id更新订单状态、取消原因、取消时间
Orders orders = new Orders();
//支付状态
Integer payStatus = ordersDB.getPayStatus();
if (payStatus == 1) {
//用户已支付,需要退款
// String refund = weChatPayUtil.refund(
// ordersDB.getNumber(),
// ordersDB.getNumber(),
// new BigDecimal(0.01),
// new BigDecimal(0.01));
// log.info("申请退款:{}", refund);


orders.setId(ordersCancelDTO.getId());
orders.setStatus(Orders.CANCELLED);
orders.setCancelReason(ordersCancelDTO.getCancelReason());
orders.setCancelTime(LocalDateTime.now());
}



orderMapper.update(orders);
}

派送订单

需求分析和设计

产品原型:

业务规则:

  • 派送订单其实就是将订单状态修改为“派送中”
  • 只有状态为“待派送”的订单可以执行派送订单操作

接口设计:参见接口文档

代码实现

Controller层
在OrderController中添加方法

1
2
3
4
5
6
7
8
9
10
11
/**
* 派送订单
*
* @return
*/
@PutMapping("/delivery/{id}")
@ApiOperation("派送订单")
public Result delivery(@PathVariable("id") Long id) {
orderService.delivery(id);
return Result.success();
}

Service层
在OrderService中添加方法

1
2
3
4
5
6
/**
* 派送订单
*
* @param id
*/
void delivery(Long id);

在OrderServiceImpl中实现方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 派送订单
*
* @param id
*/
public void delivery(Long id) {
// 根据id查询订单
Orders ordersDB = orderMapper.getById(id);

// 校验订单是否存在,并且状态为3
if (ordersDB == null || !ordersDB.getStatus().equals(Orders.CONFIRMED)) {
throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR);
}

Orders orders = new Orders();
orders.setId(ordersDB.getId());
// 更新订单状态,状态转为派送中
orders.setStatus(Orders.DELIVERY_IN_PROGRESS);

orderMapper.update(orders);
}

完成订单

需求分析和设计

业务规则:

  • 完成订单其实就是将订单状态修改为“已完成”
  • 只有状态为“派送中”的订单可以执行订单完成操作

接口设计:参见接口文档

代码实现

Controller层
在OrderController中添加方法

1
2
3
4
5
6
7
8
9
10
11
/**
* 完成订单
*
* @return
*/
@PutMapping("/complete/{id}")
@ApiOperation("完成订单")
public Result complete(@PathVariable("id") Long id) {
orderService.complete(id);
return Result.success();
}

Service层
在OrderService中添加方法
1
2
3
4
5
6
/**
* 完成订单
*
* @param id
*/
void complete(Long id);

在OrderServiceImpl中实现方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 完成订单
*
* @param id
*/
public void complete(Long id) {
// 根据id查询订单
Orders ordersDB = orderMapper.getById(id);

// 校验订单是否存在,并且状态为4
if (ordersDB == null || !ordersDB.getStatus().equals(Orders.DELIVERY_IN_PROGRESS)) {
throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR);
}

Orders orders = new Orders();
orders.setId(ordersDB.getId());
// 更新订单状态,状态转为完成
orders.setStatus(Orders.COMPLETED);
orders.setDeliveryTime(LocalDateTime.now());

orderMapper.update(orders);
}

校验收货地址是否超出配送范围

1. 环境准备

注册账号:https://passport.baidu.com/v2/?reg&tt=1671699340600&overseas=&gid=CF954C2-A3D2-417F-9FE6-B0F249ED7E33&tpl=pp&u=https%3A%2F%2Flbsyun.baidu.com%2Findex.php%3Ftitle%3D%E9%A6%96%E9%A1%B5

登录百度地图开放平台:https://lbsyun.baidu.com/

进入控制台,创建应用,获取AK:

image-20221222170049729

image-20221222170256927

相关接口:

https://lbsyun.baidu.com/index.php?title=webapi/guide/webservice-geocoding

https://lbsyun.baidu.com/index.php?title=webapi/directionlite-v1

2. 代码开发

2.1 application.yml

配置外卖商家店铺地址和百度地图的AK:

image-20221222170819582

2.2 OrderServiceImpl

改造OrderServiceImpl,注入上面的配置项:

1
2
3
4
5
@Value("${sky.shop.address}")
private String shopAddress;

@Value("${sky.baidu.ak}")
private String ak;

在OrderServiceImpl中提供校验方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
/**
* 检查客户的收货地址是否超出配送范围
* @param address
*/
private void checkOutOfRange(String address) {
Map map = new HashMap();
map.put("address",shopAddress);
map.put("output","json");
map.put("ak",ak);

//获取店铺的经纬度坐标
String shopCoordinate = HttpClientUtil.doGet("https://api.map.baidu.com/geocoding/v3", map);

JSONObject jsonObject = JSON.parseObject(shopCoordinate);
if(!jsonObject.getString("status").equals("0")){
throw new OrderBusinessException("店铺地址解析失败");
}

//数据解析
JSONObject location = jsonObject.getJSONObject("result").getJSONObject("location");
String lat = location.getString("lat");
String lng = location.getString("lng");
//店铺经纬度坐标
String shopLngLat = lat + "," + lng;

map.put("address",address);
//获取用户收货地址的经纬度坐标
String userCoordinate = HttpClientUtil.doGet("https://api.map.baidu.com/geocoding/v3", map);

jsonObject = JSON.parseObject(userCoordinate);
if(!jsonObject.getString("status").equals("0")){
throw new OrderBusinessException("收货地址解析失败");
}

//数据解析
location = jsonObject.getJSONObject("result").getJSONObject("location");
lat = location.getString("lat");
lng = location.getString("lng");
//用户收货地址经纬度坐标
String userLngLat = lat + "," + lng;

map.put("origin",shopLngLat);
map.put("destination",userLngLat);
map.put("steps_info","0");

//路线规划
String json = HttpClientUtil.doGet("https://api.map.baidu.com/directionlite/v1/driving", map);

jsonObject = JSON.parseObject(json);
if(!jsonObject.getString("status").equals("0")){
throw new OrderBusinessException("配送路线规划失败");
}

//数据解析
JSONObject result = jsonObject.getJSONObject("result");
JSONArray jsonArray = (JSONArray) result.get("routes");
Integer distance = (Integer) ((JSONObject) jsonArray.get(0)).get("distance");

if(distance > 5000){
//配送距离超过5000米
throw new OrderBusinessException("超出配送范围");
}
}

在OrderServiceImpl的submitOrder方法中调用上面的校验方法:

image-20221222171444981

day10

课程内容

  • Spring Task
  • 订单状态定时处理
  • WebSocket
  • 来单提醒
  • 客户催单

功能实现:订单状态定时处理来单提醒客户催单

订单状态定时处理:

来单提醒

客户催单:

Spring Task

介绍

Spring Task 是Spring框架提供的任务调度工具,可以按照约定的时间自动执行某个代码逻辑。

定位: 定时任务框架
作用: 定时自动执行某段Java代码

Java程序中使用SpringTask的几个应用场景:

只要是任何需要定时处理的场景都可以使用SpringTask

cron表达式

cron表达式 其实就是一个字符串,通过cron表达式可以定义任务触发的时间

构成规则: 分为6或7个域,由空格分隔开,每个域代表一个含义

每个域的含义分别为:秒、分钟、小时、日、月、周、年(可选)

举例:

2022年10月12日上午9点整 对应的cron表达式为:0 0 9 12 10 ? 2022

说明: 一般的值不同时设置,其中一个设置,另一个用?表示。

比如: 描述2月份的最后一天,最后一天具体是几号呢?可能是28号,也有可能是29号,此时就不能写具体数字。可以通过一些特殊字符来描述这些信息。这些具体的细节,我们就不用自己去手写,因为cron表达式有许多在线生成器。

cron表达式在线生成器:https://cron.qqe2.com/

直接输入自己的需求就能够生成相应的cron表达式,因此一般不用自己去写cron表达式,简单了解即可。

通配符:

  • \* 表示所有值;

  • ? 表示未说明的值,即不关心它为何值;

  • - 表示一个指定的范围;

  • , 表示附加一个可能值;

  • / 符号前表示开始时间,符号后表示每次递增的值;

cron表达式案例:

  • /5 * ? 每隔5秒执行一次

  • 0 /1 ? 每隔1分钟执行一次

  • 0 0 5-15 ? 每天5-15点整点触发

  • 0 0/3 * ? 每三分钟触发一次

  • 0 0-5 14 ? 在每天下午2点到下午2:05期间的每1分钟触发

  • 0 0/5 14 ? 在每天下午2点到下午2:55期间的每5分钟触发

  • 0 0/5 14,18 ? 在每天下午2点到2:55期间和下午6点到6:55期间的每5分钟触发

  • 0 0/30 9-17 ? 朝九晚五工作时间内每半小时

  • 0 0 10,14,16 ? 每天上午10点,下午2点,4点

入门案例

Spring Task使用步骤

  1. 导入maven坐标 spring-context(已存在)
  1. 启动类添加注解 @EnableScheduling 开启任务调度

  2. 自定义定时任务类

代码开发

编写定时任务类:

进入sky-server模块中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 自定义定时任务类
*/
@Component
@Slf4j
public class MyTask {

/**
* 定时任务 每隔5秒触发一次
*/
@Scheduled(cron = "0/5 * * * * ?")
public void executeTask(){
log.info("定时任务开始执行:{}",new Date());
}
}

开启任务调度:

在启动类上添加@EnableScheduling注解

1
2
3
4
5
6
7
8
9
10
11
@SpringBootApplication
@EnableTransactionManagement //开启注解方式的事务管理
@Slf4j
@EnableCaching
@EnableScheduling
public class SkyApplication {
public static void main(String[] args) {
SpringApplication.run(SkyApplication.class, args);
log.info("server started");
}
}

功能测试

启动服务,查看日志

任务每隔5秒执行一次。

订单状态定时处理

需求分析

用户下单后可能存在的情况:

  • 下单后未支付,订单一直处于待支付状态
  • 用户收货后管理端未点击完成按钮,订单一直处于派送中状态

上面两种情况都需要通过定时任务来修改订单状态,具体逻辑为:

  • 通过定时任务每分钟检查一次是否存在支付超时订单(下单后超过15分钟仍未支付则判定为支付超时订单),如果存在则修改订单状态为已取消
  • 通过定时任务每天凌晨1点检查一次是否存在派送中的订单,如果存在则修改订单状态为已完成

代码开发

  1. 自定义定时任务类OrderTask(待完善):
    新建Task包,并创建定时任务类OrderTask
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28

    /**
    * 自定义定时任务,实现订单状态定时处理
    */
    @Component
    @Slf4j
    public class OrderTask {

    @Autowired
    private OrderMapper orderMapper;

    /**
    * 处理支付超时订单
    */
    @Scheduled(cron = "0 * * * * ?")
    public void processTimeoutOrder(){
    log.info("处理支付超时订单:{}", new Date());
    }

    /**
    * 处理“派送中”状态的订单
    */
    @Scheduled(cron = "0 0 1 * * ?")
    public void processDeliveryOrder(){
    log.info("处理派送中订单:{}", new Date());
    }

    }
  1. 在OrderMapper接口中扩展方法:
1
2
3
4
5
6
7
/**
* 根据状态和下单时间查询订单
* @param status
* @param orderTime
*/
@Select("select * from orders where status = #{status} and order_time < #{orderTime}")
List<Orders> getByStatusAndOrdertimeLT(Integer status, LocalDateTime orderTime);
  1. 完善定时任务类的processTimeoutOrder方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 处理支付超时订单
*/
@Scheduled(cron = "0 * * * * ?")
public void processTimeoutOrder(){
log.info("处理支付超时订单:{}", new Date());

LocalDateTime time = LocalDateTime.now().plusMinutes(-15);

// select * from orders where status = 1 and order_time < 当前时间-15分钟
List<Orders> ordersList = orderMapper.getByStatusAndOrdertimeLT(Orders.PENDING_PAYMENT, time);
if(ordersList != null && ordersList.size() > 0){
ordersList.forEach(order -> {
order.setStatus(Orders.CANCELLED);
order.setCancelReason("支付超时,自动取消");
order.setCancelTime(LocalDateTime.now());
orderMapper.update(order);
});
}
}
  1. 完善定时任务类的processDeliveryOrder方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 处理“派送中”状态的订单
*/
@Scheduled(cron = "0 0 1 * * ?")
public void processDeliveryOrder(){
log.info("处理派送中订单:{}", new Date());
// select * from orders where status = 4 and order_time < 当前时间-1小时
LocalDateTime time = LocalDateTime.now().plusMinutes(-60);
List<Orders> ordersList = orderMapper.getByStatusAndOrdertimeLT(Orders.DELIVERY_IN_PROGRESS, time);

if(ordersList != null && ordersList.size() > 0){
ordersList.forEach(order -> {
order.setStatus(Orders.COMPLETED);
orderMapper.update(order);
});
}
}

功能测试

可以通过如下方式进行测试:

  • 查看控制台sql
  • 查看数据库中数据变化

支付超时的订单测试:

1. 查看订单表

有一条订单,状态为1。订单状态 1待付款 2待接单 3已接单 4派送中 5已完成 6已取消

2. 开启定时任务

启动服务,观察控制台日志。处理支付超时订单任务每隔1分钟执行一次。

3. 再次查看订单表

状态已更改为6,已取消。

证明定时任务已生效。

处理“派送中”状态的订单任务的测试步骤和上述一致。可适当修改cron表达式,改变任务执行频率,方便测试。

WebSocket

介绍

WebSocket 是基于 TCP 的一种新的网络协议。它实现了浏览器与服务器全双工通信——浏览器和服务器只需要完成一次握手,两者之间就可以创建持久性的连接, 并进行双向数据传输。

HTTP协议和WebSocket协议对比:

  • HTTP是短连接
  • WebSocket是长连接
  • HTTP通信是单向的,基于请求响应模式
  • WebSocket支持双向通信
  • HTTP和WebSocket底层都是TCP连接

思考: 既然WebSocket支持双向通信,功能看似比HTTP强大,那么我们是不是可以基于WebSocket开发所有的业务功能?

WebSocket缺点:

  • 服务器长期维护长连接需要一定的成本
  • 各个浏览器支持程度不一
  • WebSocket 是长连接,受网络限制比较大,需要处理好重连

结论: WebSocket并不能完全取代HTTP,它只适合在特定的场景下使用

WebSocket应用场景:

入门案例

案例分析

需求: 实现浏览器与服务器全双工通信。浏览器既可以向服务器发送消息,服务器也可主动向浏览器推送消息。

效果展示:

实现步骤:

  1. 直接使用websocket.html页面作为WebSocket客户端

  2. 导入WebSocket的maven坐标

  3. 导入WebSocket服务端组件WebSocketServer,用于和客户端通信

  4. 导入配置类WebSocketConfiguration,注册WebSocket的服务端组件

  5. 导入定时任务类WebSocketTask,定时向客户端推送数据

代码开发

  1. 定义websocket.html页面(资料中已提供)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<!DOCTYPE HTML>
<html>
<head>
<meta charset="UTF-8">
<title>WebSocket Demo</title>
</head>
<body>
<input id="text" type="text" />
<button onclick="send()">发送消息</button>
<button onclick="closeWebSocket()">关闭连接</button>
<div id="message">
</div>
</body>
<script type="text/javascript">
var websocket = null;
var clientId = Math.random().toString(36).substr(2);

//判断当前浏览器是否支持WebSocket
if('WebSocket' in window){
//连接WebSocket节点
websocket = new WebSocket("ws://localhost:8080/ws/"+clientId);
}
else{
alert('Not support websocket')
}

//连接发生错误的回调方法
websocket.onerror = function(){
setMessageInnerHTML("error");
};

//连接成功建立的回调方法
websocket.onopen = function(){
setMessageInnerHTML("连接成功");
}

//接收到消息的回调方法
websocket.onmessage = function(event){
setMessageInnerHTML(event.data);
}

//连接关闭的回调方法
websocket.onclose = function(){
setMessageInnerHTML("close");
}

//监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
window.onbeforeunload = function(){
websocket.close();
}

//将消息显示在网页上
function setMessageInnerHTML(innerHTML){
document.getElementById('message').innerHTML += innerHTML + '<br/>';
}

//发送消息
function send(){
var message = document.getElementById('text').value;
websocket.send(message);
}

//关闭连接
function closeWebSocket() {
websocket.close();
}
</script>
</html>
  1. 导入maven坐标

在sky-server模块pom.xml中已定义

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
  1. 定义WebSocket服务端组件(资料中已提供)

直接导入到sky-server模块即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/**
* WebSocket服务
*/
@Component
@ServerEndpoint("/ws/{sid}")
public class WebSocketServer {

//存放会话对象
private static Map<String, Session> sessionMap = new HashMap();

/**
* 连接建立成功调用的方法
*/
@OnOpen
public void onOpen(Session session, @PathParam("sid") String sid) {
System.out.println("客户端:" + sid + "建立连接");
sessionMap.put(sid, session);
}

/**
* 收到客户端消息后调用的方法
*
* @param message 客户端发送过来的消息
*/
@OnMessage
public void onMessage(String message, @PathParam("sid") String sid) {
System.out.println("收到来自客户端:" + sid + "的信息:" + message);
}

/**
* 连接关闭调用的方法
*
* @param sid
*/
@OnClose
public void onClose(@PathParam("sid") String sid) {
System.out.println("连接断开:" + sid);
sessionMap.remove(sid);
}

/**
* 群发
*
* @param message
*/
public void sendToAllClient(String message) {
Collection<Session> sessions = sessionMap.values();
for (Session session : sessions) {
try {
//服务器向客户端发送消息
session.getBasicRemote().sendText(message);
} catch (Exception e) {
e.printStackTrace();
}
}
}

}
  1. 定义配置类,注册WebSocket的服务端组件(从资料中直接导入即可)
1
2
3
4
5
6
7
8
9
10
11
12
/**
* WebSocket配置类,用于注册WebSocket的Bean
*/
@Configuration
public class WebSocketConfiguration {

@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}

}
  1. 定义定时任务类,定时向客户端推送数据(从资料中直接导入即可)
1
2
3
4
5
6
7
8
9
10
11
12
13
@Component
public class WebSocketTask {
@Autowired
private WebSocketServer webSocketServer;

/**
* 通过WebSocket每隔5秒向客户端发送消息
*/
@Scheduled(cron = "0/5 * * * * ?")
public void sendMessageToClient() {
webSocketServer.sendToAllClient("这是来自服务端的消息:" + DateTimeFormatter.ofPattern("HH:mm:ss").format(LocalDateTime.now()));
}
}

功能测试

启动服务,打开websocket.html页面

浏览器向服务器发送数据:

image-20221222192759049

服务器向浏览器间隔5秒推送数据:

image-20221222192926954

来单提醒

需求分析和设计

用户下单并且支付成功后,需要第一时间通知外卖商家。通知的形式有如下两种:

  • 语音播报
  • 弹出提示框

设计思路:

  • 通过WebSocket实现管理端页面和服务端保持长连接状态
  • 当客户支付后,调用WebSocket的相关API实现服务端向客户端推送消息
  • 客户端浏览器解析服务端推送的消息,判断是来单提醒还是客户催单,进行相应的消息提示和语音播报
  • 约定服务端发送给客户端浏览器的数据格式为JSON,字段包括:type,orderId,content
    • type 为消息类型,1为来单提醒 2为客户催单
    • orderId 为订单id
    • content 为消息内容

代码开发

在OrderServiceImpl中注入WebSocketServer对象,修改paySuccess方法,加入如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Autowired
private WebSocketServer webSocketServer;
/**
* 支付成功,修改订单状态
*
* @param outTradeNo
*/
public void paySuccess(String outTradeNo) {
// 当前登录用户id
Long userId = BaseContext.getCurrentId();

// 根据订单号查询当前用户的订单
Orders ordersDB = orderMapper.getByNumberAndUserId(outTradeNo, userId);

// 根据订单id更新订单的状态、支付方式、支付状态、结账时间
Orders orders = Orders.builder()
.id(ordersDB.getId())
.status(Orders.TO_BE_CONFIRMED)
.payStatus(Orders.PAID)
.checkoutTime(LocalDateTime.now())
.build();

orderMapper.update(orders);
//////////////////////////////////////////////
Map map = new HashMap();
map.put("type", 1);//消息类型,1表示来单提醒
map.put("orderId", orders.getId());
map.put("content", "订单号:" + outTradeNo);

//通过WebSocket实现来单提醒,向客户端浏览器推送消息
webSocketServer.sendToAllClient(JSON.toJSONString(map));
///////////////////////////////////////////////////
}

功能测试

可以通过如下方式进行测试:

  • 查看浏览器调试工具数据交互过程
  • 前后端联调

1. 登录管理端后台

登录成功后,浏览器与服务器建立长连接

image-20221222200842731

查看控制台日志

image-20221222200941497

2. 小程序端下单支付

修改回调地址,利用内网穿透获取域名

image-20221222201350616

下单支付

image-20221222201718622 image-20221222201754866 image-20221222201826173 image-20221222202101677

3. 查看来单提醒

支付成功后,后台收到来单提醒,并有语音播报

image-20221222202310953

客户催单

需求分析和设计

用户在小程序中点击催单按钮后,需要第一时间通知外卖商家。通知的形式有如下两种:

  • 语音播报
  • 弹出提示框

设计思路:

  • 通过WebSocket实现管理端页面和服务端保持长连接状态
  • 当用户点击催单按钮后,调用WebSocket的相关API实现服务端向客户端推送消息
  • 客户端浏览器解析服务端推送的消息,判断是来单提醒还是客户催单,进行相应的消息提示和语音播报
    约定服务端发送给客户端浏览器的数据格式为JSON,字段包括:type,orderId,content
    • type 为消息类型,1为来单提醒 2为客户催单
    • orderId 为订单id
    • content 为消息内容

当用户点击催单按钮时,向服务端发送请求。

接口设计(催单):

代码开发

Controller层

根据用户催单的接口定义,在user包下的OrderController中创建催单方法:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 用户催单
*
* @param id
* @return
*/
@GetMapping("/reminder/{id}")
@ApiOperation("用户催单")
public Result reminder(@PathVariable("id") Long id) {
orderService.reminder(id);
return Result.success();
}

Service层

在OrderService接口中声明reminder方法:

1
2
3
4
5
/**
* 用户催单
* @param id
*/
void reminder(Long id);

在OrderServiceImpl中实现reminder方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 用户催单
*
* @param id
*/
public void reminder(Long id) {
// 查询订单是否存在
Orders orders = orderMapper.getById(id);
if (orders == null) {
throw new OrderBusinessException(MessageConstant.ORDER_NOT_FOUND);
}

//基于WebSocket实现催单
Map map = new HashMap();
map.put("type", 2);//2代表用户催单
map.put("orderId", id);
map.put("content", "订单号:" + orders.getNumber());
webSocketServer.sendToAllClient(JSON.toJSONString(map));
}

Mapper层

在OrderMapper中添加getById:

1
2
3
4
5
6
/**
* 根据id查询订单
* @param id
*/
@Select("select * from orders where id=#{id}")
Orders getById(Long id);

功能测试

可以通过如下方式进行测试:

  • 查看浏览器调试工具数据交互过程
  • 前后端联调

1. 登录管理端后台

登录成功后,浏览器与服务器建立长连接

查看控制台日志

2. 用户进行催单

用户可在订单列表或者订单详情,进行催单

3. 查看催单提醒

既有催单弹窗,同时语音播报

day11

课程内容

  • Apache ECharts
  • 营业额统计
  • 用户统计
  • 订单统计
  • 销量排名Top10

功能实现:数据统计

数据统计效果图:

Apache ECharts

介绍

Apache ECharts 是一款基于 Javascript 的数据可视化图表库,提供直观,生动,可交互,可个性化定制的数据可视化图表。
官网地址:https://echarts.apache.org/zh/index.html

常见效果展示:

总结: 不管是哪种形式的图形,最本质的东西实际上是数据,这只是对数据的一种可视化展示。

入门案例

Apache Echarts官方提供的快速入门:https://echarts.apache.org/handbook/zh/get-started/

效果展示:

实现步骤:

  1. 引入echarts.js 文件(当天资料已提供)

  2. 为 ECharts 准备一个设置宽高的 DOM

  3. 初始化echarts实例

  4. 指定图表的配置项和数据

  5. 使用指定的配置项和数据显示图表

代码开发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>ECharts</title>
<!-- 引入刚刚下载的 ECharts 文件 -->
<script src="echarts.js"></script>
</head>
<body>
<!-- 为 ECharts 准备一个定义了宽高的 DOM -->
<div id="main" style="width: 600px;height:400px;"></div>
<script type="text/javascript">
// 基于准备好的dom,初始化echarts实例
var myChart = echarts.init(document.getElementById('main'));

// 指定图表的配置项和数据
var option = {
title: {
text: 'ECharts 入门示例'
},
tooltip: {},
legend: {
data: ['销量']
},
xAxis: {
data: ['衬衫', '羊毛衫', '雪纺衫', '裤子', '高跟鞋', '袜子']
},
yAxis: {},
series: [
{
name: '销量',
type: 'bar',
data: [5, 20, 36, 10, 10, 20]
}
]
};

// 使用刚指定的配置项和数据显示图表。
myChart.setOption(option);
</script>
</body>
</html>

测试:(当天资料中已提供)

使用浏览器方式打开即可。

总结: 使用Echarts,重点在于研究当前图表所需的数据格式。通常是需要后端提供符合格式要求的动态数据,然后响应给前端来展示图表。

营业额统计

需求分析和设计

产品原型

营业额统计是基于折现图来展现,并且按照天来展示的。实际上,就是某一个时间范围之内的每一天的营业额。同时,不管光标放在哪个点上,那么它就会把具体的数值展示出来。并且还需要注意日期并不是固定写死的,是由上边时间选择器来决定。比如选择是近7天、或者是近30日,或者是本周,就会把相应这个时间段之内的每一天日期通过横坐标展示。

原型图:

业务规则:

  • 营业额指订单状态为已完成的订单金额合计
  • 基于可视化报表的折线图展示营业额数据,X轴为日期,Y轴为营业额
  • 根据时间选择区间,展示每天的营业额数据

接口设计

通过上述原型图,设计出对应的接口。

注意: 具体返回数据一般由前端来决定,前端展示图表,具体折现图对应数据是什么格式,是有固定的要求的。所以说,后端需要去适应前端,它需要什么格式的数据,我们就给它返回什么格式的数据。

代码开发

VO设计

根据接口定义设计对应的VO:

在sky-pojo模块,TurnoverReportVO.java已定义

1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TurnoverReportVO implements Serializable {

//日期,以逗号分隔,例如:2022-10-01,2022-10-02,2022-10-03
private String dateList;

//营业额,以逗号分隔,例如:406.0,1520.0,75.0
private String turnoverList;

}

Controller层

根据接口定义创建ReportController:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 报表
*/
@RestController
@RequestMapping("/admin/report")
@Slf4j
@Api(tags = "统计报表相关接口")
public class ReportController {

@Autowired
private ReportService reportService;

/**
* 营业额数据统计
*
* @param begin
* @param end
* @return
*/
@GetMapping("/turnoverStatistics")
@ApiOperation("营业额数据统计")
public Result<TurnoverReportVO> turnoverStatistics(
@DateTimeFormat(pattern = "yyyy-MM-dd")
LocalDate begin,
@DateTimeFormat(pattern = "yyyy-MM-dd")
LocalDate end) {
return Result.success(reportService.getTurnover(begin, end));
}

}

Service层

创建ReportService接口,声明getTurnover方法:

1
2
3
4
5
6
7
8
9
10
public interface ReportService {

/**
* 根据时间区间统计营业额
* @param beginTime
* @param endTime
* @return
*/
TurnoverReportVO getTurnover(LocalDate beginTime, LocalDate endTime);
}

创建ReportServiceImpl实现类,实现getTurnover方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Service
@Slf4j
public class ReportServiceImpl implements ReportService {

@Autowired
private OrderMapper orderMapper;

/**
* 根据时间区间统计营业额
* @param begin
* @param end
* @return
*/
public TurnoverReportVO getTurnover(LocalDate begin, LocalDate end) {
List<LocalDate> dateList = new ArrayList<>();
dateList.add(begin);

while (!begin.equals(end)){
begin = begin.plusDays(1);//日期计算,获得指定日期后1天的日期
dateList.add(begin);
}

List<Double> turnoverList = new ArrayList<>();
for (LocalDate date : dateList) {
LocalDateTime beginTime = LocalDateTime.of(date, LocalTime.MIN);
LocalDateTime endTime = LocalDateTime.of(date, LocalTime.MAX);
Map map = new HashMap();
map.put("status", Orders.COMPLETED);
map.put("begin",beginTime);
map.put("end", endTime);
Double turnover = orderMapper.sumByMap(map);
turnover = turnover == null ? 0.0 : turnover;
turnoverList.add(turnover);
}

//数据封装
return TurnoverReportVO.builder()
.dateList(StringUtils.join(dateList,","))
.turnoverList(StringUtils.join(turnoverList,","))
.build();
}
}

Mapper层

在OrderMapper接口声明sumByMap方法:

1
2
3
4
5
/**
* 根据动态条件统计营业额
* @param map
*/
Double sumByMap(Map map);

在OrderMapper.xml文件中编写动态SQL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<select id="sumByMap" resultType="java.lang.Double">
select sum(amount) from orders
<where>
<if test="status != null">
and status = #{status}
</if>
<if test="begin != null">
and order_time &gt;= #{begin}
</if>
<if test="end != null">
and order_time &lt;= #{end}
</if>
</where>
</select>

功能测试

可以通过如下方式进行测试:

  • 接口文档测试
  • 前后端联调测试

启动服务器,启动nginx,直接采用前后端联调测试。

进入数据统计模块

1. 查看近7日营业额统计

image-20230101172807757

进入开发者模式,查看返回数据

image-20230101173031357

2. 查看近30日营业额统计

image-20230101173201667

进入开发者模式,查看返回数据

image-20230101173304127

也可通过断点方式启动,查看每步执行情况。

用户统计

需求分析和设计

产品原型

所谓用户统计,实际上统计的是用户的数量。通过折线图来展示,上面这根蓝色线代表的是用户总量,下边这根绿色线代表的是新增用户数量,是具体到每一天。所以说用户统计主要统计两个数据,一个是总的用户数量,另外一个是新增用户数量

原型图:

image-20230102213727736

业务规则:

  • 基于可视化报表的折线图展示用户数据,X轴为日期,Y轴为用户数
  • 根据时间选择区间,展示每天的用户总量和新增用户量数据

接口设计

根据上述原型图设计接口。

image-20230102213809414 image-20230102213818334

代码开发

VO设计

根据用户统计接口的返回结果设计VO:

image-20230102211004237

在sky-pojo模块,UserReportVO.java已定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserReportVO implements Serializable {

//日期,以逗号分隔,例如:2022-10-01,2022-10-02,2022-10-03
private String dateList;

//用户总量,以逗号分隔,例如:200,210,220
private String totalUserList;

//新增用户,以逗号分隔,例如:20,21,10
private String newUserList;

}

Controller层

根据接口定义,在ReportController中创建userStatistics方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 用户数据统计
* @param begin
* @param end
* @return
*/
@GetMapping("/userStatistics")
@ApiOperation("用户数据统计")
public Result<UserReportVO> userStatistics(
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin,
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end){

return Result.success(reportService.getUserStatistics(begin,end));
}

Service层

在ReportService接口中声明getUserStatistics方法:

1
2
3
4
5
6
7
/**
* 根据时间区间统计用户数量
* @param begin
* @param end
* @return
*/
UserReportVO getUserStatistics(LocalDate begin, LocalDate end);

在ReportServiceImpl实现类中实现getUserStatistics方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Override
public UserReportVO getUserStatistics(LocalDate begin, LocalDate end) {
List<LocalDate> dateList = new ArrayList<>();
dateList.add(begin);

while (!begin.equals(end)){
begin = begin.plusDays(1);
dateList.add(begin);
}
List<Integer> newUserList = new ArrayList<>(); //新增用户数
List<Integer> totalUserList = new ArrayList<>(); //总用户数

for (LocalDate date : dateList) {
LocalDateTime beginTime = LocalDateTime.of(date, LocalTime.MIN);
LocalDateTime endTime = LocalDateTime.of(date, LocalTime.MAX);
//新增用户数量 select count(id) from user where create_time > ? and create_time < ?
Integer newUser = getUserCount(beginTime, endTime);
//总用户数量 select count(id) from user where create_time < ?
Integer totalUser = getUserCount(null, endTime);

newUserList.add(newUser);
totalUserList.add(totalUser);
}

return UserReportVO.builder()
.dateList(StringUtils.join(dateList,","))
.newUserList(StringUtils.join(newUserList,","))
.totalUserList(StringUtils.join(totalUserList,","))
.build();
}

在ReportServiceImpl实现类中创建私有方法getUserCount:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 根据时间区间统计用户数量
* @param beginTime
* @param endTime
* @return
*/
private Integer getUserCount(LocalDateTime beginTime, LocalDateTime endTime) {
Map map = new HashMap();
map.put("begin",beginTime);
map.put("end", endTime);
return userMapper.countByMap(map);
}

Mapper层

在UserMapper接口中声明countByMap方法:

1
2
3
4
5
6
/**
* 根据动态条件统计用户数量
* @param map
* @return
*/
Integer countByMap(Map map);

在UserMapper.xml文件中编写动态SQL:

1
2
3
4
5
6
7
8
9
10
11
<select id="countByMap" resultType="java.lang.Integer">
select count(id) from user
<where>
<if test="begin != null">
and create_time &gt;= #{begin}
</if>
<if test="end != null">
and create_time &lt;= #{end}
</if>
</where>
</select>

功能测试

可以通过如下方式进行测试:

  • 接口文档测试
  • 前后端联调测试

进入数据统计模块

1. 查看近7日用户统计

image-20230107191339668

进入开发者模式,查看返回数据

image-20230107191532175

2. 查看近30日用户统计

image-20230107191613369

进入开发者模式,查看返回数据

image-20230107191707568

也可通过断点方式启动,查看每步执行情况。

订单统计

需求分析和设计

产品原型

订单统计通过一个折现图来展现,折线图上有两根线,这根蓝色的线代表的是订单总数,而下边这根绿色的线代表的是有效订单数,指的就是状态是已完成的订单就属于有效订单,分别反映的是每一天的数据。上面还有3个数字,分别是订单总数、有效订单、订单完成率,它指的是整个时间区间之内总的数据。

原型图:

image-20230107192859270

业务规则:

  • 有效订单指状态为 “已完成” 的订单
  • 基于可视化报表的折线图展示订单数据,X轴为日期,Y轴为订单数量
  • 根据时间选择区间,展示每天的订单总数和有效订单数
  • 展示所选时间区间内的有效订单数、总订单数、订单完成率,订单完成率 = 有效订单数 / 总订单数 * 100%

接口设计

根据上述原型图设计接口。

image-20230107192942872 image-20230107192952958

代码开发

VO设计

根据订单统计接口的返回结果设计VO:

image-20230107195325915

在sky-pojo模块,OrderReportVO.java已定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderReportVO implements Serializable {

//日期,以逗号分隔,例如:2022-10-01,2022-10-02,2022-10-03
private String dateList;

//每日订单数,以逗号分隔,例如:260,210,215
private String orderCountList;

//每日有效订单数,以逗号分隔,例如:20,21,10
private String validOrderCountList;

//订单总数
private Integer totalOrderCount;

//有效订单数
private Integer validOrderCount;

//订单完成率
private Double orderCompletionRate;

}

Controller层

在ReportController中根据订单统计接口创建orderStatistics方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 订单数据统计
* @param begin
* @param end
* @return
*/
@GetMapping("/ordersStatistics")
@ApiOperation("用户数据统计")
public Result<OrderReportVO> orderStatistics(
@DateTimeFormat(pattern = "yyyy-MM-dd")
LocalDate begin,
@DateTimeFormat(pattern = "yyyy-MM-dd")
LocalDate end){

return Result.success(reportService.getOrderStatistics(begin,end));
}

Service层

在ReportService接口中声明getOrderStatistics方法:

1
2
3
4
5
6
7
/**
* 根据时间区间统计订单数量
* @param begin
* @param end
* @return
*/
OrderReportVO getOrderStatistics(LocalDate begin, LocalDate end);

在ReportServiceImpl实现类中实现getOrderStatistics方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
* 根据时间区间统计订单数量
* @param begin
* @param end
* @return
*/
public OrderReportVO getOrderStatistics(LocalDate begin, LocalDate end){
List<LocalDate> dateList = new ArrayList<>();
dateList.add(begin);

while (!begin.equals(end)){
begin = begin.plusDays(1);
dateList.add(begin);
}
//每天订单总数集合
List<Integer> orderCountList = new ArrayList<>();
//每天有效订单数集合
List<Integer> validOrderCountList = new ArrayList<>();
for (LocalDate date : dateList) {
LocalDateTime beginTime = LocalDateTime.of(date, LocalTime.MIN);
LocalDateTime endTime = LocalDateTime.of(date, LocalTime.MAX);
//查询每天的总订单数 select count(id) from orders where order_time > ? and order_time < ?
Integer orderCount = getOrderCount(beginTime, endTime, null);

//查询每天的有效订单数 select count(id) from orders where order_time > ? and order_time < ? and status = ?
Integer validOrderCount = getOrderCount(beginTime, endTime, Orders.COMPLETED);

orderCountList.add(orderCount);
validOrderCountList.add(validOrderCount);
}

//时间区间内的总订单数
Integer totalOrderCount = orderCountList.stream().reduce(Integer::sum).get();
//时间区间内的总有效订单数
Integer validOrderCount = validOrderCountList.stream().reduce(Integer::sum).get();
//订单完成率
Double orderCompletionRate = 0.0;
if(totalOrderCount != 0){
orderCompletionRate = validOrderCount.doubleValue() / totalOrderCount;
}
return OrderReportVO.builder()
.dateList(StringUtils.join(dateList, ","))
.orderCountList(StringUtils.join(orderCountList, ","))
.validOrderCountList(StringUtils.join(validOrderCountList, ","))
.totalOrderCount(totalOrderCount)
.validOrderCount(validOrderCount)
.orderCompletionRate(orderCompletionRate)
.build();

}

在ReportServiceImpl实现类中提供私有方法getOrderCount:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 根据时间区间统计指定状态的订单数量
* @param beginTime
* @param endTime
* @param status
* @return
*/
private Integer getOrderCount(LocalDateTime beginTime, LocalDateTime endTime, Integer status) {
Map map = new HashMap();
map.put("status", status);
map.put("begin",beginTime);
map.put("end", endTime);
return orderMapper.countByMap(map);
}

Mapper层

在OrderMapper接口中声明countByMap方法:

1
2
3
4
5
/**
*根据动态条件统计订单数量
* @param map
*/
Integer countByMap(Map map);

在OrderMapper.xml文件中编写动态SQL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<select id="countByMap" resultType="java.lang.Integer">
select count(id) from orders
<where>
<if test="status != null">
and status = #{status}
</if>
<if test="begin != null">
and order_time &gt;= #{begin}
</if>
<if test="end != null">
and order_time &lt;= #{end}
</if>
</where>
</select>

功能测试

可以通过如下方式进行测试:

  • 接口文档测试
  • 前后端联调

重启服务,直接采用前后端联调测试。

进入数据统计模块

1). 查看近7日订单统计

image-20230107202854533

进入开发者模式,查看返回数据

image-20230107202953128

2). 查看近30日订单统计

image-20230107203025165

进入开发者模式,查看返回数据

image-20230107203127308

也可通过断点方式启动,查看每步执行情况。

销量排名Top10

需求分析和设计

产品原型

所谓销量排名,销量指的是商品销售的数量。项目当中的商品主要包含两类:一个是套餐,一个是菜品,所以销量排名其实指的就是菜品和套餐销售的数量排名。通过柱形图来展示销量排名,这些销量是按照降序来排列,并且只需要统计销量排名前十的商品。

原型图:

image-20230107203622747

业务规则:

  • 根据时间选择区间,展示销量前10的商品(包括菜品和套餐)
  • 基于可视化报表的柱状图降序展示商品销量
  • 此处的销量为商品销售的份数

接口设计

根据上述原型图设计接口。

image-20230107203720606 image-20230107203730681

代码开发

VO设计

根据销量排名接口的返回结果设计VO:

image-20230107204028895

在sky-pojo模块,SalesTop10ReportVO.java已定义

1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SalesTop10ReportVO implements Serializable {

//商品名称列表,以逗号分隔,例如:鱼香肉丝,宫保鸡丁,水煮鱼
private String nameList;

//销量列表,以逗号分隔,例如:260,215,200
private String numberList;

}

Controller层

在ReportController中根据销量排名接口创建top10方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 销量排名统计
* @param begin
* @param end
* @return
*/
@GetMapping("/top10")
@ApiOperation("销量排名统计")
public Result<SalesTop10ReportVO> top10(
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin,
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end){
return Result.success(reportService.getSalesTop10(begin,end));
}

5.2.3 Service层接口

在ReportService接口中声明getSalesTop10方法:

1
2
3
4
5
6
7
/**
* 查询指定时间区间内的销量排名top10
* @param begin
* @param end
* @return
*/
SalesTop10ReportVO getSalesTop10(LocalDate begin, LocalDate end);

Service层实现类

在ReportServiceImpl实现类中实现getSalesTop10方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 查询指定时间区间内的销量排名top10
* @param begin
* @param end
* @return
* */
public SalesTop10ReportVO getSalesTop10(LocalDate begin, LocalDate end){
LocalDateTime beginTime = LocalDateTime.of(begin, LocalTime.MIN);
LocalDateTime endTime = LocalDateTime.of(end, LocalTime.MAX);
List<GoodsSalesDTO> goodsSalesDTOList = orderMapper.getSalesTop10(beginTime, endTime);

String nameList = StringUtils.join(goodsSalesDTOList.stream().map(GoodsSalesDTO::getName).collect(Collectors.toList()),",");
String numberList = StringUtils.join(goodsSalesDTOList.stream().map(GoodsSalesDTO::getNumber).collect(Collectors.toList()),",");

return SalesTop10ReportVO.builder()
.nameList(nameList)
.numberList(numberList)
.build();
}

Mapper层

在OrderMapper接口中声明getSalesTop10方法:

1
2
3
4
5
6
/**
* 查询商品销量排名
* @param begin
* @param end
*/
List<GoodsSalesDTO> getSalesTop10(LocalDateTime begin, LocalDateTime end);

在OrderMapper.xml文件中编写动态SQL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<select id="getSalesTop10" resultType="com.sky.dto.GoodsSalesDTO">
select od.name name,sum(od.number) number from order_detail od ,orders o
where od.order_id = o.id
and o.status = 5
<if test="begin != null">
and order_time &gt;= #{begin}
</if>
<if test="end != null">
and order_time &lt;= #{end}
</if>
group by name
order by number desc
limit 0, 10
</select>

功能测试

可以通过如下方式进行测试:

  • 接口文档测试
  • 前后端联调

重启服务,直接采用前后端联调测试。

查看近30日销量排名Top10统计

若查询的某一段时间没有销量数据,则显示不出效果。

image-20230107210518821

进入开发者模式,查看返回数据

image-20230107210711326

也可通过断点方式启动,查看每步执行情况。

day12

课程内容

  • 工作台
  • Apache POI
  • 导出运营数据Excel报表

功能实现:工作台数据导出

工作台效果图:

数据导出效果图:

在数据统计页面点击数据导出:生成Excel报表

工作台

需求分析和设计

产品原型

工作台是系统运营的数据看板,并提供快捷操作入口,可以有效提高商家的工作效率。

工作台展示的数据:

  • 今日数据
  • 订单管理
  • 菜品总览
  • 套餐总览
  • 订单信息

原型图:

名词解释:

  • 营业额:已完成订单的总金额
  • 有效订单:已完成订单的数量
  • 订单完成率:有效订单数 / 总订单数 * 100%
  • 平均客单价:营业额 / 有效订单数
  • 新增用户:新增用户的数量

接口设计

通过上述原型图分析,共包含6个接口。

接口设计:

  • 今日数据接口
  • 订单管理接口
  • 菜品总览接口
  • 套餐总览接口
  • 订单搜索(已完成)
  • 各个状态的订单数量统计(已完成)

代码导入

直接导入课程资料中的工作台模块功能代码即可:

Controller层

添加WorkSpaceController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/**
* 工作台
*/
@RestController
@RequestMapping("/admin/workspace")
@Slf4j
@Api(tags = "工作台相关接口")
public class WorkSpaceController {

@Autowired
private WorkspaceService workspaceService;

/**
* 工作台今日数据查询
* @return
*/
@GetMapping("/businessData")
@ApiOperation("工作台今日数据查询")
public Result<BusinessDataVO> businessData(){
//获得当天的开始时间
LocalDateTime begin = LocalDateTime.now().with(LocalTime.MIN);
//获得当天的结束时间
LocalDateTime end = LocalDateTime.now().with(LocalTime.MAX);

BusinessDataVO businessDataVO = workspaceService.getBusinessData(begin, end);
return Result.success(businessDataVO);
}

/**
* 查询订单管理数据
* @return
*/
@GetMapping("/overviewOrders")
@ApiOperation("查询订单管理数据")
public Result<OrderOverViewVO> orderOverView(){
return Result.success(workspaceService.getOrderOverView());
}

/**
* 查询菜品总览
* @return
*/
@GetMapping("/overviewDishes")
@ApiOperation("查询菜品总览")
public Result<DishOverViewVO> dishOverView(){
return Result.success(workspaceService.getDishOverView());
}

/**
* 查询套餐总览
* @return
*/
@GetMapping("/overviewSetmeals")
@ApiOperation("查询套餐总览")
public Result<SetmealOverViewVO> setmealOverView(){
return Result.success(workspaceService.getSetmealOverView());
}
}

Service层

添加WorkspaceService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

public interface WorkspaceService {

/**
* 根据时间段统计营业数据
* @param begin
* @param end
* @return
*/
BusinessDataVO getBusinessData(LocalDateTime begin, LocalDateTime end);

/**
* 查询订单管理数据
* @return
*/
OrderOverViewVO getOrderOverView();

/**
* 查询菜品总览
* @return
*/
DishOverViewVO getDishOverView();

/**
* 查询套餐总览
* @return
*/
SetmealOverViewVO getSetmealOverView();

}

添加WorkspaceServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143

@Service
@Slf4j
public class WorkspaceServiceImpl implements WorkspaceService {

@Autowired
private OrderMapper orderMapper;
@Autowired
private UserMapper userMapper;
@Autowired
private DishMapper dishMapper;
@Autowired
private SetmealMapper setmealMapper;

/**
* 根据时间段统计营业数据
* @param begin
* @param end
* @return
*/
public BusinessDataVO getBusinessData(LocalDateTime begin, LocalDateTime end) {
/**
* 营业额:当日已完成订单的总金额
* 有效订单:当日已完成订单的数量
* 订单完成率:有效订单数 / 总订单数
* 平均客单价:营业额 / 有效订单数
* 新增用户:当日新增用户的数量
*/

Map map = new HashMap();
map.put("begin",begin);
map.put("end",end);

//查询总订单数
Integer totalOrderCount = orderMapper.countByMap(map);

map.put("status", Orders.COMPLETED);
//营业额
Double turnover = orderMapper.sumByMap(map);
turnover = turnover == null? 0.0 : turnover;

//有效订单数
Integer validOrderCount = orderMapper.countByMap(map);

Double unitPrice = 0.0;

Double orderCompletionRate = 0.0;
if(totalOrderCount != 0 && validOrderCount != 0){
//订单完成率
orderCompletionRate = validOrderCount.doubleValue() / totalOrderCount;
//平均客单价
unitPrice = turnover / validOrderCount;
}

//新增用户数
Integer newUsers = userMapper.countByMap(map);

return BusinessDataVO.builder()
.turnover(turnover)
.validOrderCount(validOrderCount)
.orderCompletionRate(orderCompletionRate)
.unitPrice(unitPrice)
.newUsers(newUsers)
.build();
}


/**
* 查询订单管理数据
*
* @return
*/
public OrderOverViewVO getOrderOverView() {
Map map = new HashMap();
map.put("begin", LocalDateTime.now().with(LocalTime.MIN));
map.put("status", Orders.TO_BE_CONFIRMED);

//待接单
Integer waitingOrders = orderMapper.countByMap(map);

//待派送
map.put("status", Orders.CONFIRMED);
Integer deliveredOrders = orderMapper.countByMap(map);

//已完成
map.put("status", Orders.COMPLETED);
Integer completedOrders = orderMapper.countByMap(map);

//已取消
map.put("status", Orders.CANCELLED);
Integer cancelledOrders = orderMapper.countByMap(map);

//全部订单
map.put("status", null);
Integer allOrders = orderMapper.countByMap(map);

return OrderOverViewVO.builder()
.waitingOrders(waitingOrders)
.deliveredOrders(deliveredOrders)
.completedOrders(completedOrders)
.cancelledOrders(cancelledOrders)
.allOrders(allOrders)
.build();
}

/**
* 查询菜品总览
*
* @return
*/
public DishOverViewVO getDishOverView() {
Map map = new HashMap();
map.put("status", StatusConstant.ENABLE);
Integer sold = dishMapper.countByMap(map);

map.put("status", StatusConstant.DISABLE);
Integer discontinued = dishMapper.countByMap(map);

return DishOverViewVO.builder()
.sold(sold)
.discontinued(discontinued)
.build();
}

/**
* 查询套餐总览
*
* @return
*/
public SetmealOverViewVO getSetmealOverView() {
Map map = new HashMap();
map.put("status", StatusConstant.ENABLE);
Integer sold = setmealMapper.countByMap(map);

map.put("status", StatusConstant.DISABLE);
Integer discontinued = setmealMapper.countByMap(map);

return SetmealOverViewVO.builder()
.sold(sold)
.discontinued(discontinued)
.build();
}
}

Mapper层

在SetmealMapper中添加countByMap方法定义

1
2
3
4
5
6
/**
* 根据条件统计套餐数量
* @param map
* @return
*/
Integer countByMap(Map map);

在SetmealMapper.xml中添加对应SQL实现

1
2
3
4
5
6
7
8
9
10
11
<select id="countByMap" resultType="java.lang.Integer">
select count(id) from setmeal
<where>
<if test="status != null">
and status = #{status}
</if>
<if test="categoryId != null">
and category_id = #{categoryId}
</if>
</where>
</select>

在DishMapper中添加countByMap方法定义

1
2
3
4
5
6
/**
* 根据条件统计菜品数量
* @param map
* @return
*/
Integer countByMap(Map map);

在DishMapper.xml中添加对应SQL实现

1
2
3
4
5
6
7
8
9
10
11
<select id="countByMap" resultType="java.lang.Integer">
select count(id) from dish
<where>
<if test="status != null">
and status = #{status}
</if>
<if test="categoryId != null">
and category_id = #{categoryId}
</if>
</where>
</select>

功能测试

可以通过如下方式进行测试:

  • 通过接口文档测试
  • 前后端联调测试

接口文档测试

启动服务,访问http://localhost:8080/doc.html,进入工作台相关接口

前后端联调测试

启动nginx,访问 http://localhost,进入工作台

进入开发者模式,分别查看今日数据、订单管理、菜品总览、套餐总览

Apache POI

介绍

Apache POI 是一个处理Miscrosoft Office各种文件格式的开源项目。简单来说就是,可以使用 POI 在 Java 程序中对Miscrosoft Office各种文件进行读写操作。
一般情况下,POI 都是用于操作 Excel 文件。

Apache POI 的应用场景:

入门案例

Apache POI既可以将数据写入Excel文件,也可以读取Excel文件中的数据,接下来分别进行实现。

Apache POI的maven坐标:(项目中已导入)

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>3.16</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>3.16</version>
</dependency>

将数据写入Excel文件

1. 代码开发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class POITest {

/**
* 基于POI向Excel文件写入数据
* @throws Exception
*/
public static void write() throws Exception{
//在内存中创建一个Excel文件对象
XSSFWorkbook excel = new XSSFWorkbook();
//创建Sheet页
XSSFSheet sheet = excel.createSheet("itcast");

//在Sheet页中创建行,0表示第1行
XSSFRow row1 = sheet.createRow(0);
//创建单元格并在单元格中设置值,单元格编号也是从0开始,1表示第2个单元格
row1.createCell(1).setCellValue("姓名");
row1.createCell(2).setCellValue("城市");

XSSFRow row2 = sheet.createRow(1);
row2.createCell(1).setCellValue("张三");
row2.createCell(2).setCellValue("北京");

XSSFRow row3 = sheet.createRow(2);
row3.createCell(1).setCellValue("李四");
row3.createCell(2).setCellValue("上海");

FileOutputStream out = new FileOutputStream(new File("D:\\itcast.xlsx"));
//通过输出流将内存中的Excel文件写入到磁盘上
excel.write(out);

//关闭资源
out.flush();
out.close();
excel.close();
}
public static void main(String[] args) throws Exception {
write();
}
}

2. 实现效果

在D盘中生成itcast.xlsx文件,创建名称为itcast的Sheet页,同时将内容成功写入。

读取Excel文件中的数据

1. 代码开发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class POITest {
/**
* 基于POI读取Excel文件
* @throws Exception
*/
public static void read() throws Exception{
FileInputStream in = new FileInputStream(new File("D:\\itcast.xlsx"));
//通过输入流读取指定的Excel文件
XSSFWorkbook excel = new XSSFWorkbook(in);
//获取Excel文件的第1个Sheet页
XSSFSheet sheet = excel.getSheetAt(0);

//获取Sheet页中的最后一行的行号
int lastRowNum = sheet.getLastRowNum();

for (int i = 0; i <= lastRowNum; i++) {
//获取Sheet页中的行
XSSFRow titleRow = sheet.getRow(i);
//获取行的第2个单元格
XSSFCell cell1 = titleRow.getCell(1);
//获取单元格中的文本内容
String cellValue1 = cell1.getStringCellValue();
//获取行的第3个单元格
XSSFCell cell2 = titleRow.getCell(2);
//获取单元格中的文本内容
String cellValue2 = cell2.getStringCellValue();

System.out.println(cellValue1 + " " +cellValue2);
}

//关闭资源
in.close();
excel.close();
}

public static void main(String[] args) throws Exception {
read();
}
}

2. 实现效果

将itcast.xlsx文件中的数据进行读取

导出运营数据Excel报表

需求分析和设计

产品原型

在数据统计页面,有一个数据导出的按钮,点击该按钮时,其实就会下载一个文件。这个文件实际上是一个Excel形式的文件,文件中主要包含最近30日运营相关的数据。表格的形式已经固定,主要由概览数据和明细数据两部分组成。真正导出这个报表之后,相对应的数字就会填充在表格中,就可以进行存档。

原型图:

导出的Excel报表格式:

业务规则:

  • 导出Excel形式的报表文件
  • 导出最近30天的运营数据

接口设计

通过上述原型图设计对应的接口。

注意:

  • 当前接口没有传递参数,因为导出的是最近30天的运营数据,后端计算即可,所以不需要任何参数
  • 当前接口没有返回数据,因为报表导出功能本质上是文件下载,服务端会通过输出流将Excel文件下载到客户端浏览器

代码开发

实现步骤

  1. 设计Excel模板文件

  2. 查询近30天的运营数据

  3. 将查询到的运营数据写入模板文件

  4. 通过输出流将Excel文件下载到客户端浏览器

Controller层

根据接口定义,在ReportController中创建export方法:

1
2
3
4
5
6
7
8
9
/**
* 导出运营数据报表
* @param response
*/
@GetMapping("/export")
@ApiOperation("导出运营数据报表")
public void export(HttpServletResponse response){
reportService.exportBusinessData(response);
}

Service层

在ReportService接口中声明导出运营数据报表的方法:

1
2
3
4
5
/**
* 导出近30天的运营数据报表
* @param response
**/
void exportBusinessData(HttpServletResponse response);

在ReportServiceImpl实现类中实现导出运营数据报表的方法:

提前将资料中的运营数据报表模板.xlsx拷贝到项目的resources/template目录中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/**导出近30天的运营数据报表
* @param response
**/
public void exportBusinessData(HttpServletResponse response) {
LocalDate begin = LocalDate.now().minusDays(30);
LocalDate end = LocalDate.now().minusDays(1);
//查询概览运营数据,提供给Excel模板文件
BusinessDataVO businessData = workspaceService.getBusinessData(LocalDateTime.of(begin,LocalTime.MIN), LocalDateTime.of(end, LocalTime.MAX));
InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream("template/运营数据报表模板.xlsx");
try {
//基于提供好的模板文件创建一个新的Excel表格对象
XSSFWorkbook excel = new XSSFWorkbook(inputStream);
//获得Excel文件中的一个Sheet页
XSSFSheet sheet = excel.getSheet("Sheet1");

sheet.getRow(1).getCell(1).setCellValue(begin + "至" + end);
//获得第4行
XSSFRow row = sheet.getRow(3);
//获取单元格
row.getCell(2).setCellValue(businessData.getTurnover());
row.getCell(4).setCellValue(businessData.getOrderCompletionRate());
row.getCell(6).setCellValue(businessData.getNewUsers());
row = sheet.getRow(4);
row.getCell(2).setCellValue(businessData.getValidOrderCount());
row.getCell(4).setCellValue(businessData.getUnitPrice());
for (int i = 0; i < 30; i++) {
LocalDate date = begin.plusDays(i);
//准备明细数据
businessData = workspaceService.getBusinessData(LocalDateTime.of(date,LocalTime.MIN), LocalDateTime.of(date, LocalTime.MAX));
row = sheet.getRow(7 + i);
row.getCell(1).setCellValue(date.toString());
row.getCell(2).setCellValue(businessData.getTurnover());
row.getCell(3).setCellValue(businessData.getValidOrderCount());
row.getCell(4).setCellValue(businessData.getOrderCompletionRate());
row.getCell(5).setCellValue(businessData.getUnitPrice());
row.getCell(6).setCellValue(businessData.getNewUsers());
}
//通过输出流将文件下载到客户端浏览器中
ServletOutputStream out = response.getOutputStream();
excel.write(out);
//关闭资源
out.flush();
out.close();
excel.close();

}catch (IOException e){
e.printStackTrace();
}
}

功能测试

直接使用前后端联调测试。

进入数据统计

点击数据导出:Excel报表下载成功