苍穹外卖

2023年8月25日 16点04分

这应该是写在简历上的项目了,待好好对待啊

1 项目概述&环境搭建

技术选型
20230826093518

前端环境搭建

?好像也没搭建啥,直接打开资料,双击nginx就ok了
2023-08-26102248

后端环境搭建

这后端代码已经写好一大部分了啊
20230826102836

  1. sky-take-out:maven父工程,统一管理依赖版本,聚合其他子模块
  2. sky-common:子模块,存放公共类,例如:工具类,常量类,异常类等
  3. sky-pojo:子模块,存放实体类,VO,DTO等
  4. sky-server:子模块,后端服务,存放配置文件,Controller,Service,Mapper等

sky-pojo模块介绍
20230826103421

  • Entity:实体,通常和数据库中的表对应
  • DTO:数据传输对象,通常用于程序中各层之间传递数据
  • VO:视图对象,为前端展示数据提供的对象
  • pojo:普通Java对象,只有属性和对应的getter和etter

使用Git进行版本控制

害,这比网,推了半天都推不到github上

推上去了,仓库地址

数据库环境搭建

这个比较好哎,有一个sql外加一个数据库设计文档

各个表介绍
20230826110410

前后端联调

后端的初始工程中已经实现了登录功能,直接进行前后端联调测试

这里用到了全局异常处理

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
package com.sky.handler;

import com.sky.exception.BaseException;
import com.sky.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
* 全局异常处理器,处理项目中抛出的业务异常
*/
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

/**
* 捕获业务异常
* @param ex
* @return
*/
@ExceptionHandler
public Result exceptionHandler(BaseException ex){
log.error("异常信息:{}", ex.getMessage());
return Result.error(ex.getMessage());
}

}

在用户登录接口中,会抛出一个异常:

1
2
3
4
if (employee == null) {
//账号不存在
throw new AccountNotFoundException(MessageConstant.ACCOUNT_NOT_FOUND);
}

这里如果出现异常,就会被 GlobalExceptionHandler捕获到,抛出的是AccountNotFoundException,能被Global捕获是因为该异常继承自BaseException

1
2
public class AccountNotFoundException extends BaseException {
}

下面用到了Builder,可以用来创建对象,特点是方法名即为属性名,使用前提是实体类上加上 @Builder注解

1
2
3
4
5
6
EmployeeLoginVO employeeLoginVO = EmployeeLoginVO.builder()
.id(employee.getId())
.userName(employee.getUsername())
.name(employee.getName())
.token(token)
.build();

Nginx反向代理与负载均衡

反向代理

刚看了Nginx的配置文件,原来是用了反向代理

前端请求地址:http://localhost/api/employee/login
后端接口地址:http://localhost:8080/admin/employee/login

所谓反向代理,就是将前端发送的动态请求由nginx转发到后端服务器

使用nginx的好处:

  1. 提高访问速度
    • nginx会对请求地址进行缓存,相同的请求不必再发送至后端那后端数据变了怎么办?
  2. 进行负载均衡
    • 所谓负载均衡,就是把大量的请求按照我们指定的方式均衡的分配给集群中的每台服务器
  3. 保证后端服务安全
    • 后端服务不暴露到外网

该项目中的反向代理:

1
2
3
4
5
6
7
8
9
server {
listen 80;
server_name localhost;

# 反向代理,处理管理端发送的请求
location /api/ {
proxy_pass http://localhost:8080/admin/;
}
}

就相当于,前端请求的 http://localhost/api,会被转发到 http://localhost:8080/admin/,后面的 employee/login是不变的


负载均衡

1
2
3
4
5
6
7
8
9
10
11
12
13
14
upstream webservers{
server 127.0.0.1:8080 weight=90 ;
#server 127.0.0.1:8088 weight=10 ;
}

server{
listen 80;
server_name localhost;

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

请求过来时,localhost:80/api/xxxx会被转发到 http://webservers/amdin/xxx,而这个 webservers就是配置中的多个 server,nginx会对这些server进行分配,server后面加上 weight表示权重,这是一种负载均衡的策略
20230826170157

完善登录功能

现在的问题:密码明文存储

  1. 将密码加密后存储,提高安全性
  2. 使用MD5加密方式对明文密码加密

123456加密后为:e10adc3949ba59abbe56e057f20f883e

依稀记得之前还自己写md5加密的代码,原来spring自带啊,一行代码就完成了

1
2
// TODO 后期需要进行md5加密,然后再进行比对
password = DigestUtils.md5DigestAsHex(password.getBytes());

md5DigestAsHex()函数就是将字符串(的bytes)进行md5加密

Swagger

在这之前还引入了yapi,不过暂时用不到

这一小节可以放到我的开发工具箱里

使用Swagger只需要按照它的规范去定义接口及接口相关的信息,就可以做到生成接口文档,以及在线接口调试页面,Knife4j是为Java MVC框架集成Swagger生成Api文档的增强解决方案

使用方式

  1. 导入依赖
1
2
3
4
5
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<version>3.0.2</version>
</dependency>
  1. 在配置类中加入 knife4j相关配置(某个带有 @Configuration的配置类中即可)
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;
}
  1. 设置静态资源映射,否则接口文档页面无法访问(要放在某个继承自 WebMvcConfigurationSupport类中),这个方法其实就是重写的父类的方法
    20230826201101
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/");
}

顺便把这个配置类也放着:

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
package com.sky.config;

import com.sky.interceptor.JwtTokenAdminInterceptor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;

/**
* 配置类,注册web层相关组件
*/
@Configuration
@Slf4j
public class WebMvcConfiguration extends WebMvcConfigurationSupport {

@Autowired
private JwtTokenAdminInterceptor jwtTokenAdminInterceptor;

/**
* 注册自定义拦截器
*
* @param registry
*/
protected void addInterceptors(InterceptorRegistry registry) {
log.info("开始注册自定义拦截器...");
registry.addInterceptor(jwtTokenAdminInterceptor)
.addPathPatterns("/admin/**")
.excludePathPatterns("/admin/employee/login");
}

/**
* 通过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;
}

/**
* 设置静态资源映射
* @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/");
}
}

这就配置好了,启动项目进入浏览器访问 localhost:8080/doc.html,即可进入接口文档页面
20230826201533


Swagger和yapi的区别

  1. yapi是设计阶段使用的工具,管理和维护接口
  2. Swagger是在开发阶段使用的框架,帮助后端开发人员做后端的接口测试

Swagger常用注解

20230826202517

具体使用案例

  1. @Api(tags = "员工相关接口"),给Controller上加,在页面上表示一级目录,**tags=**不能省略,否则无效,
1
2
3
@Api(tags = "员工相关接口")
public class EmployeeController {
}
  1. @ApiOperation(value = "员工登录接口"),给接口上加,表示具体接口的名称
1
2
3
@ApiOperation(value = "员工登录接口")
public Result<EmployeeLoginVO> login(@RequestBody EmployeeLoginDTO employeeLoginDTO) {
}
  1. @ApiModel@ApiModelProperty(),给实体类和属性上加
1
2
3
4
5
6
7
8
9
10
@ApiModel(description = "员工登录时传递的数据模型")
public class EmployeeLoginDTO implements Serializable {

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

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

}

2 员工管理

新增员工

产品原型:
20230826222241

这里又说了一下 DTO的用处,我们的实体类是跟数据库进行映射的,而 DTO,是用来封装前端接收的信息的,这个 EmployeeDTO,就是用来封装新增员工信息的,我们的实体类中的属性要比 DTO多一些(上面那个登录的DTO,也是和登录信息有关的)
20230826223342


代码开发

Controller,调用service的save方法,然后返回成功

1
2
3
4
5
6
7
8
@PostMapping
@ApiOperation("新增员工")
public Result save(@RequestBody EmployeeDTO employeeDTO) {
log.info("新增员工信息:{}", employeeDTO);

employeeService.save(employeeDTO);
return Result.success();
}

Service

  1. 就像下面说的,虽然接收前端的数据的是DTO,但是和数据库进行交互还是用实体类,所以这里涉及到类型转换,用 BeanUtils.copyProperties(source,target)能够很方便的copy对象,前提是属性名一致
  2. 密码是默认的,为 123456,存入数据库的要进行md5加密
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
/**
* 新增员工
*
* @param employeeDTO
*/
@Override
public void save(EmployeeDTO employeeDTO) {

// 虽然前端传来的是DTO,但是还是建议跟数据交互时使用实体类,所以这里要进行类型转换
Employee employee = new Employee();

// 对象属性拷贝--如果使用set一个一个写,是很繁琐的
// 前提是属性名一致
BeanUtils.copyProperties(employeeDTO, employee);

// 设置账号的状态 1 表示正常,这里使用常量类,防止硬编码
employee.setStatus(StatusConstant.ENABLE);
// 密码默认为123456-md5加密后再存
employee.setPassword(DigestUtils.md5DigestAsHex(PasswordConstant.DEFAULT_PASSWORD.getBytes()));

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

// 设置记录的创建人id和修改人id
// TODO 后期需要改为当前登录用户的id
employee.setCreateUser(10L);
employee.setCreateUser(10L);

employeeMapper.save(employee);
}

Mapper,这个就没什么说的了.

1
2
3
4
5
6
7
8
9
10
/**
* 插入员工数据
*
* @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 save(Employee employee);

明天见

代码完善

目前存在的问题

  1. 录入的用户名存在,抛出异常后没有处理
  2. 新增员工时,创建人id和修改人id设置为了固定值

解决第一个问题,使用异常处理

1
2
3
4
5
6
7
8
9
10
11
12
13
@ExceptionHandler
public Result exceptionHandler(SQLIntegrityConstraintViolationException ex) {
// Duplicate entry 'admin' for key 'employee.idx_username'
String message = ex.getMessage();
if (message.contains("Duplicate entry")) {
String[] messageArr = message.split(" ");
String repeatName = messageArr[2];
// return Result.error(repeatName + "该用户名已存");
return Result.error(repeatName + MessageConstant.ALREADY_EXISTS);
} else {
return Result.error(MessageConstant.UNKNOWN_ERROR);
}
}

由于 用户名存在导致sql执行时的唯一性的问题,会报错:Duplicate entry 'admin' for key 'employee.idx_username,所以我们可以根据这个异常信息来获取需要的内容,最终封装返回结果


解决第二个问题

这里就涉及到Jwt Token了,之前用过,但是说实话,我并不是很清楚怎么用
20230827101540

可以通过解析请求头中的token,来得到是哪个用户发起的请求
但是获取到之后,如何传递给service?

所以引入 ThreadLocal,ThreadLocal并不是一个 Thread,而是 Thread的局部变量
ThreadLocal为每个线程提供单独一份存储空间,具有线程隔离的效果,只有在线程内才能获取到对应的值,线程外则不能访问

每次请求,都是同一个线程

ThreadLocal常用方法

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

这里使用了一个工具类:

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

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

}

一共两处修改:

  1. JwtTokenAdminInterceptor拦截器,在拿到token中的empId时,将该empId存入ThreadLocal中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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);
// 将empId存入ThreadLocal
BaseContextByMe.setCurrentId(empId);
// 3、通过,放行
return true;
} catch (Exception ex) {
// 4、不通过,响应401状态码
response.setStatus(401);
return false;
}
  1. Service中,在设置登录用户的id处,取出ThreadLocal中的empId
1
2
3
4
5
6
// 设置记录的创建人id和修改人id
// 从ThreadLocal中获取到登录用户的id
Long empId = BaseContextByMe.getCurrentId();
employee.setCreateUser(empId);
employee.setUpdateUser(empId);

员工分页查询

业务规则

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

这里封装了一个PageResult对象,后面所有的分页查询,统一都封装成PageResult对象,然后最后返回给前端的还是Result,为 Result<PageResult>

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; //当前页数据集合

}

使用到的分页插件

1
2
3
4
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
</dependency>

感觉这个分页插件和mp的分页区别挺大的,但是说实话,我现在并不太想用mp,这玩意弱化sql的能力哎.

代码开发

Controller,这里是直接拿请求参数的,不是请求体,所以不用 @RequestBody注解,用到的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;

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

Service中,使用到了 PageHelper,重点在于 PageHelper.startPage(employeePageQueryDTO.getPage(), employeePageQueryDTO.getPageSize());,第一个参数是当前页,第二个参数是每页个数,这两个参数都是前端传来的.最后调用mapper查询数据库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 分页查询
*
* @return
*/
@Override
public PageResult pageQuery(EmployeePageQueryDTO employeePageQueryDTO) {

// 开发分页查询
PageHelper.startPage(employeePageQueryDTO.getPage(), employeePageQueryDTO.getPageSize());
Page<Employee> page = employeeMapper.pageQuery(employeePageQueryDTO);
// 这乍一看感觉Page和PageHelper没有传递参数的关系,但是其实startPage里面是用到了ThreadLocal来取值的

long total = page.getTotal();
List<Employee> records = page.getResult();
return new PageResult(total, records);
}

Mapper,因为使用了分页插件,这个分页插件会动态的拼接limit,所以这里不用再写limit了,只需要拼接可选参数姓名即可

1
2
3
4
5
6
7
8
9
10
<!-- Page<Employee> pageQuery(EmployeePageQueryDTO employeePageQueryDTO); -->
<select id="pageQuery" resultType="com.sky.entity.Employee">
select * from employee
<where>
<if test="name!=''and name!=null">
name like concat('%',#{name},'%')
</if>
</where>
order by create_time DESC
</select>

测试整体没什么问题,但是返回的create_time,也就是日期格式有问题

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
{
"code": 1,
"msg": null,
"data": {
"total": 5,
"records": [
{
"id": 3,
"username": "lisi",
"name": "李四",
"password": "e10adc3949ba59abbe56e057f20f883e",
"phone": "17513571211",
"sex": "0",
"idNumber": "412827200104013984",
"status": 1,
"createTime": [
2023,
8,
26,
23,
46,
12
],
"updateTime": [
2023,
8,
26,
23,
46,
12
],
"createUser": 10,
"updateUser": null
},
{
"id": 2,
"username": "zhuozhuo",
"name": "灼灼",
"password": "e10adc3949ba59abbe56e057f20f883e",
"phone": "17513741212",
"sex": "1",
"idNumber": "111111112313213",
"status": 1,
"createTime": [
2023,
8,
26,
23,
38,
16
],
"updateTime": [
2023,
8,
26,
23,
38,
16
],
"createUser": 10,
"updateUser": null
}
]
}
}

前端展示的是一个字符串:
20230827142346

代码完善

解决方式有以下两种

  1. 在属性上加入注解,对日期进行格式化
1
2
@JsonFormt(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
  1. 在WebMvcConfiguration中扩展SpringMVC的消息转换器,统一对日期类型进行格式化处理(推荐使用,一劳永逸)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 扩展SpringMVC框架的消息转换器
* 对后端返回给前端的数据进行 统一 的处理
*
* @param converters the list of configured converters to extend
*/
@Override
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
log.info("扩展消息转换器-用于日期格式化");
// 创建一个消息转换对象
MappingJackson2HttpMessageConverter converter =
new MappingJackson2HttpMessageConverter();
// 需要为消息转换器设置一个对象转换器,可以将Java对象序列化为json数据
converter.setObjectMapper(new JacksonObjectMapper());
// 将自己的消息转换器加入到容器中,第一个参数是代表索引,也即是这个转换器的优先级,越小越高
converters.add(0, converter);

}

ok,员工分类查询结束

启用/禁用员工账号

这个整体还是简单许多的,就是接受前端发来的状态,已经要修改的员工id

Controller,路径参数要用注解来获取,而地址栏传参不需要加注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 启用/禁用员工账号
* 参数有两个,一个是路径参数,路径参数要用注解来获取,而地址栏传参不需要加注解
*
* @param status
* @param id
* @return
*/
@PostMapping("/status/{status}")
@ApiOperation("启用/禁用员工账号")
public Result startOrStop(@PathVariable("status") Integer status, Long id) {
log.info("启用/禁用员工账号: status:{}, id:{}", status, id);
employeeService.startOrStop(status,id);
return Result.success();
}

Service,这里也用到了Builder,update方法可以根据传入对象的id,修改任意的字段-动态sql

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 启用/禁用员工账号
*
* @param status
* @param id
*/
@Override
public void startOrStop(Integer status, Long id) {

Employee employee = Employee.builder().id(id).status(status).build();

employeeMapper.update(employee);
}

Mapper,这里还不能写成 test="username != null and username != ''",还必须只能写成这样的..

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
<!--    void update(Employee employee);-->
<update id="update">
update employee
<set>
<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>
<if test="name!=null">
name = #{name},
</if>
</set>
where id = #{id}
</update>

编辑员工

编辑员工功能涉及到两个接口

  1. 根据id查询员工信息

Controller
这个还是很简单的

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 根据id查询员工信息
*
* @param id
* @return
*/
@GetMapping("/{id}")
@ApiOperation("根据id查询员工信息")
public Result<Employee> getById(@PathVariable Long id) {
Employee employee = employeeService.getById(id);
return Result.success(employee);
}

Service,主要就是注意密码要重写一遍,不能直接发过去,就算是加密过的也不能发

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 根据id查询员工信息
*
* @param id
* @return
*/
@Override
public Employee getById(Long id) {
Employee employee = employeeMapper.getById(id);
// 密码不会发送到前端
employee.setPassword("****");
return employee;
}
  1. 编辑员工信息

Controller,还是使用的 EmployeeDTO

1
2
3
4
5
6
@PutMapping
@ApiOperation("编辑员工信息")
public Result update(@RequestBody EmployeeDTO employeeDTO) {
employeeService.update(employeeDTO);
return Result.success();
}

Service,首先创建一个employee对象,因为给数据库存数据都是用的实体类,这里又用到了 BeanUtils来copy对象,最后再把 ThreadLocal中的当前用户id,也就是 谁更新的设置进来,再用上面更新员工状态的 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(BaseContextByMe.getCurrentId());

employeeMapper.update(employee);
}

3 分类管理

设计

  1. 分类名称必须唯一
  2. 分类按照类型可以分为菜品分类套餐分类
  3. 新添加的分类状态默认为禁用

接口

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

老师的意思是直接cv了,因为整体逻辑和员工管理差不多,我自己写一遍吧

新增分类

Controller,直接用categoryDTO接收前端发来的信息

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 新增分类信息
*
* @param categoryDTO
* @return
*/
@PostMapping
@ApiOperation("新增分类信息")
public Result save(@RequestBody CategoryDTO categoryDTO) {
categoryService.save(categoryDTO);
return Result.success();
}

Service,既然是新增,加到数据库的依然是 Category而不是 CategoryDTO,所以这里要进行类型转换,注意新增的类型是默认禁止的,最后取出修改创建人的id一起封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public void save(CategoryDTO categoryDTO) {
Category category = new Category();
BeanUtils.copyProperties(categoryDTO, category);

// 新增的菜单默认是禁止的
category.setStatus(StatusConstant.DISABLE);

// 然后是创建时间,修改时间,创建人,修改人
category.setCreateTime(LocalDateTime.now());
category.setUpdateTime(LocalDateTime.now());
category.setCreateUser(BaseContextByMe.getCurrentId());
category.setUpdateUser(BaseContextByMe.getCurrentId());

categoryMapper.insert(category);
}

分类分页查询

Controller,遇到分页查询,既需要用 PageResult

1
2
3
4
5
6
7
8
9
10
11
/**
* 分类分页查询,请求参数有name分类名称,page,pageSize,type分类类型
*
* @return
*/
@GetMapping("/page")
@ApiOperation("分类分页查询")
public Result<PageResult> page(CategoryPageQueryDTO categoryPageQueryDTO) {
PageResult pageResult = categoryService.pageQuery(categoryPageQueryDTO);
return Result.success(pageResult);
}

Service,这个接口依然不算难,照着之前写的那个就能写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 分类分页查询
*
* @param categoryPageQueryDTO
* @return
*/
@Override
public PageResult pageQuery(CategoryPageQueryDTO categoryPageQueryDTO) {

PageHelper.startPage(categoryPageQueryDTO.getPage(), categoryPageQueryDTO.getPageSize());
Page<Category> page = categoryMapper.pageQuery(categoryPageQueryDTO);

long total = page.getTotal();
List<Category> records = page.getResult();
return new PageResult(total, records);
}

Mapper,也是常规的内容,注意分类名要模糊查询,而分类类型是直接判断

1
2
3
4
5
6
7
8
9
10
11
12
<select id="pageQuery" resultType="com.sky.entity.Category">
select * from category
<where>
<if test="name != null and name != ''">
name like concat('%',#{name},'%')
</if>
<if test="type != null and type != ''">
and type = #{type}
</if>
</where>
order by sort asc , create_time desc
</select>

根据id删除分类

Controller,那就直接调用service进行删除

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 删除分类
*
* @param id
* @return
*/
@DeleteMapping
@ApiOperation("删除分类")
public Result<String> deleteById(Long id) {
categoryService.deleteById(id);
return Result.success();
}

Service,主要就是注意分类是否包含菜品或套餐,引入另外的两个mapper,来根据分类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
/**
* 根据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);
}

修改分类

Controller,直接传,没什么好说的

1
2
3
4
5
6
7
8
9
10
11
/**
* 修改分类
* @param categoryDTO
* @return
*/
@PutMapping
@ApiOperation("修改分类")
public Result update(@RequestBody CategoryDTO categoryDTO) {
categoryService.update(categoryDTO);
return Result.success();
}

Service,主要是将 categoryDTO封装为 category

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 修改分类信息
*
* @param categoryDTO
*/
@Override
public void update(CategoryDTO categoryDTO) {
Category category = new Category();
BeanUtils.copyProperties(categoryDTO, category);

category.setUpdateUser(BaseContextByMe.getCurrentId());
category.setUpdateTime(LocalDateTime.now());

categoryMapper.update(category);

}

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
<update id="update" parameterType="Category">
update category
<set>
<if test="type != null">
type = #{type},
</if>
<if test="name != null">
name = #{name},
</if>
<if test="sort != null">
sort = #{sort},
</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>

启用禁用分类

Controller,也是和之前一样

1
2
3
4
5
6
7
8
9
10
11
/**
* 修改分类状态
* @param status
* @param id
* @return
*/
@PostMapping("/status/{status}")
public Result startOrStop(@PathVariable Integer status, Long id) {
categoryService.startOrStop(status, id);
return Result.success();
}

Service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 禁用/启用 分类状态
*
* @param status
* @param id
*/
@Override
public void startOrStop(Integer status, Long id) {
Category category =
Category.builder().id(id).status(status)
.updateTime(LocalDateTime.now()).updateUser(BaseContextByMe.getCurrentId()).
build();
categoryMapper.update(category);
}

根据类型查询分类

Controller

1
2
3
4
5
6
7
8
9
10
11
/**
* 根据类型查询分类
* @param type
* @return
*/
@GetMapping("/list")
@ApiOperation("根据类型查询分类")
public Result<List<Category>> list(Integer type) {
List<Category> list = categoryService.list(type);
return Result.success(list);
}

Service

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

就到这了.分类接口写完,也全部测试通过.

4 菜品管理

公共字段自动填充

什么意思呢,就是之前写的员工管理接口,以及分类管理,这些业务表中都有公共字段:

序号 字段名 含义 数据类型 操作类型
1 create_time 创建时间 datetime insert
2 create_user 创建人id bigint insert
3 update_time 修改时间 datatime insert,update
4 update_user 修改人id bigint insert,update

对应的代码就是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 然后是创建时间,修改时间,创建人,修改人
category.setCreateTime(LocalDateTime.now());
category.setUpdateTime(LocalDateTime.now());
category.setCreateUser(BaseContextByMe.getCurrentId());
category.setUpdateUser(BaseContextByMe.getCurrentId());

// 类似这种代码

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

// 设置记录的创建人id和修改人id
// 从ThreadLocal中获取到登录用户的id
Long empId = BaseContextByMe.getCurrentId();
employee.setCreateUser(empId);
employee.setUpdateUser(empId);

问题就是,代码冗余,不便于后期维护

果然是用到切面

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

这里还闹了个乌龙,当时在token中取数据并存入ThreadLocal时,是用的我自己写的,而刚才写切面时,却用的是之前的,两个不同的类具有不同的ThreadLocal,所以获取不到数据,下面是ChatGPT给出的答案

1
2
3
由于每个线程都有独立的线程上下文,如果你在不同的线程中使用了不同的 ThreadLocal 实例,那么它们存储的内容就是隔离的。在你的代码中,BaseContext 和 BaseContextByMe 都使用了不同的 ThreadLocal 实例,因此在切面方法中,使用不同的 ThreadLocal 实例就会导致获取到不同的线程上下文。

如果你在使用 BaseContext 时无法获取到 currentId,很可能是因为你的切面所在的线程没有在 BaseContext 中设置过 currentId。而在使用 BaseContextByMe 时能够获取到 currentId,可能是因为切面所在的线程在某个地方已经设置过了 currentId,例如在某个请求的上下文中。

自定义注解AutoFill,这个要好好记,或者经常看看,之前都没写过自定义注解

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
package com.sky.annotation;

import com.sky.enumeration.OperationType;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* @author zzmr
* @create 2023-08-28 14:32
* 自定义注解,用于标识方法需要进行公共字段的自动填充
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {

/**
* 数据库操作类型 update,insert
* @return
*/
OperationType value();

}

里面用到的枚举类型OperationType

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

/**
* 数据库操作类型
*/
public enum OperationType {

/**
* 更新操作
*/
UPDATE,

/**
* 插入操作
*/
INSERT

}

切面类AutoFillAspect,之前学的用到了一点,之前用的是Signature,但是这个签名是拿不到具体方法上的注解的,所以要用这个MethodSignature,.getMethod().getAnnotation(AutoFill.class);,即可拿到指定注解的对象,然后通过autoFill.value();就可取到注解的参数,究竟是update还是insert,entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME,LocalDateTime.class);这个就是通过反射拿到对象的方法,固定写法,然后再通过invoke来设置方法的参数setCreateTime.invoke(entity, now);

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
package com.sky.aspect;

import com.sky.annotation.AutoFill;
import com.sky.constant.AutoFillConstant;
import com.sky.context.BaseContext;
import com.sky.context.BaseContextByMe;
import com.sky.enumeration.OperationType;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.time.LocalDateTime;

/**
* @author zzmr
* @create 2023-08-28 14:40
* 自定义切面,实现公共字段自动填充
*/
@Aspect
@Component
@Slf4j
public class AutoFillAspect {
/**
* 切入点
*/
@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
public void autoFillPointCut() {
}

/**
* 这里要使用前置通知,在目标方法执行前,就将这几个公共字段加入进去,如果使用后置通知,那就晚了,目标方法执行结束,sql都执行结束了
*/
@Before("autoFillPointCut()")
public void autoFill(JoinPoint joinPoint) {
log.info("开始进行公共字段的自动填充,");

// 1. 先获取当前被拦截的方法的操作类型-update/insert
// 向下转型,不用的话,拿不到对应方法标识的注解对象
MethodSignature signature = (MethodSignature) joinPoint.getSignature(); // 获得方法签名对象
AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class); // 获取方法上标识的注解对象
OperationType operationType = autoFill.value();// 获取数据库操作类型


// 2. 获取实体-应该就是获取参数吧,用getArgs()就行 但是要注意,这里有一个约定,就是参数可以有多个的,但是实体类必须放在第一个
Object[] args = joinPoint.getArgs();
if (args == null || args.length == 0) {
return;
}
// 实体类型不确定,所以要使用Object
Object entity = args[0];
// 3. 准备数据
LocalDateTime now = LocalDateTime.now();
Long currentId = BaseContextByMe.getCurrentId();
// 4. 根据操作类型不同,通过反射给对应的属性进行赋值
if (operationType == OperationType.INSERT) {
// 为4个公共字段赋值
try {
// 这里使用常量类来替换字符串,其实就是`setCreateTime....`
Method setCreateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME,
LocalDateTime.class);
Method setCreateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER,
Long.class);
Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER,
Long.class);
Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME,
LocalDateTime.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 setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER,
Long.class);
Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME,
LocalDateTime.class);

// 通过反射invoke赋值
setUpdateTime.invoke(entity, now);
setUpdateUser.invoke(entity, currentId);
} catch (Exception e) {
throw new RuntimeException(e);
}
}


}
}

Mapper改写,既然要使用切面,那肯定就要给想要使用切面的方法上加上注解,并指定类型

1
2
3
4
5
6
7
8
9
10
11
/**
* 插入员工数据
*
* @param employee
*/
@AutoFill(value = OperationType.INSERT)
@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 save(Employee employee);

Service,已经使用了切面了,那就不需要再在service中写了

1
2
3
4
5
6
7
8
9
// 设置当前记录的创建和修改时间
// employee.setCreateTime(LocalDateTime.now());
// employee.setUpdateTime(LocalDateTime.now());

// 设置记录的创建人id和修改人id
// 从ThreadLocal中获取到登录用户的id
// Long empId = BaseContextByMe.getCurrentId();
// employee.setCreateUser(empId);
// employee.setUpdateUser(empId);

新增菜品

这个就是一个表单,重要的是有一个文件上传,这个待好好学

业务规则

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

接口设计

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

文件上传这个接口详情:
20230828225643

而下面的dish表-菜品表中,有一个category_id,它是逻辑外键-数据库中并没有设置外键,这个外键是通过程序实现的
在dish_flavor口味表中,也是有一个逻辑外键dish_id,一个菜品-多个口味

文件上传接口

看来要买OSS了

整体流程:

别流程了,先去把OSS的教程看了再说,今天不早了,就到这吧,课程链接

好了,大概知道oss怎么用了,新版教程中已经将access Key和key secret都给封装到环境变量里了,这里我先把这两个配置注释掉,看看一会怎么写

配置文件中新增,注意,这个不是系统自带的配置属性,而是根据一个自定义的配置类写的,而且真正的配置信息是在dev配置文件中配置的,这个主配置文件是引用,因为以后可能会存在生产模式,方便更换配置信息

1
2
3
4
# dev配置文件中,注意endpoint不能加https://
alioss:
endpoint: oss-cn-beijing.aliyuncs.com
bucket-name: zzmr-sky-take-out
1
2
3
4
# 不知道好用不用设置access Key,因为系统变量里已经设置了
alioss:
endpoint: ${sky.alioss.endpoint}
bucket-name: ${sky.alioss.bucket-name}

这个就是配置类,注解@ConfigurationProperties可以自动获取配置文件中的信息,然后加入到容器中

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

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "sky.alioss")
@Data
public class AliOssProperties {

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

}

除了上面的配置文件以及配置类,还有一个创建工具类的配置类,这个配置类可以获取到上面的properties类中的信息,是系统在运行时自动掉用的

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
package com.sky.config;

import com.sky.properties.AliOssProperties;
import com.sky.utils.AliOssUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* 配置类,用于创建AliOssUtil对象
*
* @author zzmr
* @create 2023-08-29 12:55
*/
@Configuration
@Slf4j
public class OssConfiguration {

@Bean
@ConditionalOnMissingBean
public AliOssUtil getAliOssUtil(AliOssProperties aliOssProperties) {
log.info("开始创建阿里云文件上传工具类对象");
log.info("参数为:{}", aliOssProperties);
AliOssUtil aliOssUtil = new AliOssUtil(aliOssProperties.getEndpoint(), aliOssProperties.getBucketName());
return aliOssUtil;
}

}

最后就是工具类,这个工具类也是被我改写过,之前都是将key信息配置到配置文件中,现在是将key从环境变量中取出,所以这里只需要用到剩下的两个参数,上面的配置类直接通过一个构造方法,构造一个工具类.

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
package com.sky.utils;

import com.aliyun.oss.ClientException;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.OSSException;
import com.aliyun.oss.common.auth.CredentialsProviderFactory;
import com.aliyun.oss.common.auth.EnvironmentVariableCredentialsProvider;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;

import java.io.ByteArrayInputStream;

@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) {

/**
* 自己新增的配置
*/
EnvironmentVariableCredentialsProvider credentialsProvider = null;
try {
credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();
} catch (com.aliyuncs.exceptions.ClientException e) {
e.printStackTrace();
}

// 创建OSSClient实例。原版是将accessKeyId和accessKeySecret直接传入,而新版这两个值是通过环境变量获取的
// OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
OSS ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);

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

到此文件上传的配置已经搞完了,但是接口还没开始写呢


文件上传接口开发

这个就是上传的接口,流程就是,先通过MultipartFile来获取到前端发来的文件,然后获取到文件的原文件名,截取后缀,通过UUID来获取一个新的文件名来防止文件名重复的问题,最后拼在一起,存储后返回url给前端

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
package com.sky.controller.admin;

import com.sky.constant.MessageConstant;
import com.sky.result.Result;
import com.sky.utils.AliOssUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.util.UUID;

/**
* @author zzmr
* @create 2023-08-28 23:10
* 通用接口
*/
@RestController
@Slf4j
@RequestMapping("/admin/common")
@Api(tags = "通用接口")
public class CommonController {

@Autowired
private AliOssUtil aliOssUtil;

/**
* 参数名必须和前端传来的一致,这里是file
*
* @param file
* @return
*/
@PostMapping("/upload")
@ApiOperation("文件上传")
public Result<String> upload(MultipartFile file) {
log.info("文件上传:{}", file);

// 上传到阿里云服务器 第一个参数是文件的字节,第二个参数是要上传到阿里云oss的文件名
try {
// 先截取原文件的后缀,因为文件都是有后最的
String originalFilename = file.getOriginalFilename();
// 数组的最后一项才是真正的后缀,因为文件名中可能也存在点 不过看来老师不用这种方法
// String[] split = originalFilename.split(".");
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);
e.printStackTrace();
}

// return Result.error("文件上传失败");
return Result.error(MessageConstant.UPLOAD_FAILED);
}

}

新增菜品接口

这里要用到事务@Transactional,想要使用注解的事务,要在启动类上加上@EnableTransactionManagement注解

前端传来的DTO清晰明了
20230829144106

Controller,应该controller算是最简单的了,只负责将dishDTO往service上传就ok

1
2
3
4
5
6
7
@PostMapping
@ApiOperation("新增菜品")
public Result save(@RequestBody DishDTO dishDTO) {
log.info("新增菜品");
dishService.saveWithFlavor(dishDTO);
return Result.success();
}

Service,在这个Service中,用到了两个Mapper,其中一个就是菜品Mapper,因为要新增菜品,所以肯定要用菜品的Mapper,每个菜品对应的有口味,要将口味也存入数据库就要用到口味的Mapper

  1. 首先将dto中的数据赋值给dish实体,以便操作数据库:BeanUtils.copyProperties
  2. 这里用到了Mapper中的插入回显Id,具体配置见Mapper,所以这里可以直接getId()来获取菜品Id,为什么要用菜品Id?因为口味是和菜品绑定的,一个菜品对应多个口味,在口味表中,每条数据都有一个菜品Id:dish_id,所以要获取到该条菜品的id才行
  3. 取出集合,将口味这个集合插入数据库,可以在代码中直接进行遍历,也可以在mapper中使用动态sql来实现批量插入
  4. 因为前端传来的flavors中肯定是没有dish_id的,因为这个dish_id只有插入了数据库之后才能得到,此时就要给集合中的每一项都赋值一个dish_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
    @Service
    public class DishServiceImpl implements DishService {

    @Autowired
    private DishMapper dishMapper;

    @Autowired
    private DishFlavorMapper dishFlavorMapper;

    @Override
    @Transactional
    public void saveWithFlavor(DishDTO dishDTO) {
    // 向菜品表插入1条数据
    // 前端传来的dishDTO,中包含口味信息,但是菜品表中是没有口味信息的,所以还是要把这个DTO转换成Dish来传入数据库
    Dish dish = new Dish();
    // 将dto中需要的数据拷贝给dish
    BeanUtils.copyProperties(dishDTO, dish);
    // 终于给idea配置好了数据库,可以实现字段提示了
    dishMapper.insert(dish);

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

    // 先将dto中存放口味数据的集合取出
    List<DishFlavor> flavors = dishDTO.getFlavors();

    if (flavors != null && flavors.size() > 0) {
    // 向口味表插入n条数据 - 因为1个菜品对应多个口味
    // 两种方法,可以遍历集合,然后插入,也可以批量插入
    flavors.forEach(dishFlavor -> dishFlavor.setDishId(dishId));
    dishFlavorMapper.insertBatch(flavors);
    }


    }
    }

Mapper

  1. DishMapper菜品,在菜品Mapper中,要加上属性useGeneratedKeyskeyProperty来得到插入数据库得到的id
    1
    2
    3
    4
    5
    6
    7
    <!--    useGeneratedKeys就是表示要插入数据后将id返回,而keyProperty就是返回赋值给哪个属性上-->
    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
    insert into dish(name, category_id, price, image, description, status, create_time, update_time, create_user,
    update_user)
    values (#{name}, #{categoryId}, #{price}, #{image}, #{description}, #{status}, #{createTime}, #{updateTime},
    #{createUser}, #{updateUser})
    </insert>
  2. DishFlavorMapper,这个Mapper就是通过动态Sql来实现的批量插入,注意forEach的语法
    1
    2
    3
    4
    5
    6
    <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>

这个接口终于写完了,奶奶滴,因为字段写错了搞了老半天.好困啊,打会游戏

菜品分页查询

业务规则

  1. 根据页码展示菜品信息
  2. 每页展示10条数据
  3. 分页查询时可以根据需要输入菜品名称,菜品分类,菜品状态进行查询

接口详情:
20230829154556

当使用动态sql时,即使用<if>如果这个<if>是写在<where>中,那就不必在每行的最后写逗号,而如果在<set>里面,那么就要注意逗号的使用了,除了最后一个以外,都需要加逗号,这也就是查询和插入的区别吧,简单记:查询不加逗号,插入要加逗号

这个分页写的也真是曲折啊

Controller,controller还是这些活

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

Service,由于前端需要用到字段categoryName,所以不能使用Dish,而要用DishVO,这个VO就刚好和前端要求的数据一样
20230829174741

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 菜品分页查询
*
* @param dishPageQueryDTO
* @return
*/
@Override
public PageResult pageQuery(DishPageQueryDTO dishPageQueryDTO) {

PageHelper.startPage(dishPageQueryDTO.getPage(), dishPageQueryDTO.getPageSize());

/**
* 数据库直接查到的是Dish,而前端要求的是DishVO,因为DishVO中才有分类名称的,那怎么查呢?
* 原来是用到了多表联查,只不过返回结果是VO罢了,只需要改一下Sql,改一下返回结果泛型就OK了
*/
Page<DishVO> page = dishMapper.pageQuery(dishPageQueryDTO);

log.info("查询到的分页信息:{}", page);
long total = page.getTotal();
List<DishVO> records = page.getResult();
return new PageResult(total, records);
}

Mapper,还是没想明白为什么有的地方不能写status != null and status != '',写成这样条件就不成立,就很麻烦,那以后先全用status != null这种形式吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!--    这里要注意,加了关联查询后,下面if中的字段要加上对应的表名,不然会不出结果-->
<select id="pageQuery" resultType="com.sky.vo.DishVO">
select dish.*,
category.`name` category_name from dish left join category on dish.category_id = category.id
<where>
<if test="name != null">
and dish.name like concat('%',#{name},'%')
</if>
<if test="categoryId != null">
and dish.category_id = #{categoryId}
</if>
<if test="status != null">
and dish.status = #{status}
</if>
</where>
order by dish.create_time DESC
</select>

删除菜品

这个我自己写了,功能好像也没问题

?但是一看视频,就知道自己写的大有问题了

业务规则:

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

好家伙,就实现了第一条

Controller,之前写的是用一个数组来接收,也是能实现删除的,但是按照规范,还是要用List集合,而且这里要用@RequestParam注解才能封装上进去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 批量删除菜品
*
* @param ids
* @return
*/
@DeleteMapping
@ApiOperation("批量删除菜品")
public Result deleteBatchDish(@RequestParam List<Long> ids) {

// 是可以拿到的,一个数组
log.info("传入的ids: {}", ids.size());
dishService.deleteBatchDish(ids);
return Result.success();
}

Service,就像上面说的,在删除之前,要先进行判断,

  1. 在判断菜品是否在某个套餐中时,使用了setmealDishMapper,这个是一个新的Mapper,具体内容如下,根据dishId来查询是否有套餐关联,只要有一个菜品中关联了套餐,那就不能删除
    1
    2
    3
    4
    5
    6
    <select id="getSetmealIdsByDishIds" resultType="java.lang.Long">
    select setmeal_id from setmeal_dish where dish_id in
    <foreach collection="dishIds" separator="," item="dishId" open="(" close=")">
    #{dishId}
    </foreach>
    </select>
  2. 在删除了菜品后,也要删除菜品对应的口味,这里就要根据dishId来删除
    1
    2
    @Delete("delete from dish_flavor where dish_id = #{dishId};")
    void deleteByDishId(Long dishId);
  3. 最终的代码:
    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
    /**
    * 批量删除菜品
    * @param ids
    */
    @Override
    public void deleteBatchDish(List<Long> ids) {

    // 1. 判断当前菜品是否能够删除-是否存在起售中
    for (Long id : ids) {
    Dish dish = dishMapper.getById(id);
    if (dish.getStatus() == StatusConstant.ENABLE) {
    // 当前菜品处于起售中,不能删除
    throw new DeletionNotAllowedException(MessageConstant.DISH_ON_SALE);
    }
    }
    // 2. 是否在某个套餐中
    List<Long> setmealIds = setmealDishMapper.getSetmealIdsByDishIds(ids);
    if (setmealIds != null && setmealIds.size() > 0) {
    // 查到了对应的套餐-该菜品被套餐关联了,不能删除
    throw new DeletionNotAllowedException(MessageConstant.CATEGORY_BE_RELATED_BY_SETMEAL);
    }

    for (Long id : ids) {
    // 3. 删除菜品数据,还有菜品关联的口味数据
    dishMapper.deleteById(id);
    // 删除口味相关--有就删除,没有就算了,不用查
    dishFlavorMapper.deleteByDishId(id);
    }
    }

修改菜品

修改菜品需要的接口

  1. 根据id查询菜品(同时也把菜品关联的口味也查出来)
  2. 根据类型(菜品类型/套餐类型)查询分类
  3. 文件上传
  4. 修改菜品

根据id查询菜品

这个还是很简单的

Controller,只是起名字起的有点特别,因为要查询菜品和菜品对应的口味,所以这里用的还是DishVO

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

Service,这里分两步,第一步先查出菜品信息,第二步根据菜品信息的菜品dish_id再查询对应的口味,最后封装到一个VO中

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

// 根据id查询菜品数据
Dish dish = dishMapper.getById(id);
// 根据菜品id查询口味数据
List<DishFlavor> flavors = dishFlavorMapper.getByDishId(id);
// 将查询到的口味数据封装到VO
DishVO dishVO = new DishVO();
BeanUtils.copyProperties(dish, dishVO);
dishVO.setFlavors(flavors);
return dishVO;
}

Mapper中就是很简单的单表查询了

修改菜品

Controller,没什么说的,因为这里更新数据传来的json是可以封装为一个DTO的,所以用不到VO了(VO只是比DTO多一个分类名称)

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

Service,update菜品还是老办法,动态Sql,但是更改口味就换个方法了,先删再加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 修改菜品
* 学到新东西了,如果**一个东西直接修改很难办,可以考虑先删除再添加**
*
* @param dishDTO
*/
@Override
public void updateWithFlavor(DishDTO dishDTO) {
// 先将dishDTO中的基本信息存入数据库
Dish dish = new Dish();
// 还是使用的老办法,拷贝对象的属性
BeanUtils.copyProperties(dishDTO, dish);
dishMapper.update(dish);
// 再将更新后的口味信息存入数据库,这里要用到dishFlavorMapper
// 但是由于口味修改很难确定,是多了是少了,还是没改,所以我们可以选择,先将原先的口味删除,再添加新的口味
dishFlavorMapper.deleteByDishId(dishDTO.getId());

List<DishFlavor> flavors = dishDTO.getFlavors();
if (flavors != null && flavors.size() > 0) {
flavors.forEach(flavor -> flavor.setDishId(dishDTO.getId()));
dishFlavorMapper.insertBatch(flavors);
}
}

Mapper,再写最后一次Mapper,主要是这动态sql的格式太容易弄错了,到底加不加逗号,加不加and,现在我确定了set中不加and,加逗号,查询时用的where,要加and,不加逗号(原本就是通过and链接起来的),

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">
update dish
<set>
<if test="name != null">
name = #{name},
</if>
<if test="categoryId != null">
category_id = #{categoryId},
</if>
<if test="status != null">
status = #{status},
</if>
<if test="price != null">
price = #{price},
</if>
<if test="description != null">
description = #{description},
</if>
<if test="image != null">
image = #{image},
</if>
<if test="updateTime != null">
update_time = #{updateTime},
</if>
<if test="updateUser != null">
update_user = #{updateUser}
</if>
</set>
where id = #{id}
</update>

5 套餐管理

这个就要自己写了,虽然给了答案,但是还是自己敲一遍吧,这个套餐管理应该和分类管理有点像?或者就是菜品管理

?费了半天劲写出了的分页,发现没有套餐的数据,明天继续吧

算是写的差不多?

暂时完成了分页和新增,显示的也没什么问题

写完咯!

对对答案

新增套餐

在新增套餐里面,会用到根据分类id查询类型对应的菜品的接口:
20230831160805
所以这里要先写一个根据分类id查询对应菜品的接口

Controller,前端只传来一个id,然后根据id查询

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

Service,这里我刚开始写的时候出问题了,没考虑到菜品如果下架的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 根据分类id查询菜品
* 对比答案,发现问题了,这里查的是所有菜品,不管状态如何
*
* @param categoryId
* @return
*/
@Override
public List<Dish> getByCategoryId(Long categoryId) {
// 之前写的
// List<Dish> list = dishMapper.getByCategoryId(categoryId);
// return list;

// 修改后
Dish dish = Dish.builder().categoryId(categoryId).status(StatusConstant.ENABLE).build();
return dishMapper.getByCategoryId(dish);
}

Mapper,因为有两个值要传,所以这里直接使用dish对象来传递参数

1
2
3
4
5
6
7
8
9
/**
* 根据分类id查询菜品-但是要查询状态为1的,也就是启用的菜品
*
*
* @param dish
* @return
*/
@Select("select * from dish where category_id = #{categoryId} and status = #{status}")
List<Dish> getByCategoryId(Dish dish);

注意,我这里写的和文档里面是不一样的


下面才是新增套餐的接口

Controller,前端传来的数据是DTO,因为里面不但包含套餐的基本信息,还包含套餐中对应的菜品信息

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 新增套餐的接口
* @param setmealDTO
* @return
*/
@PostMapping
@ApiOperation("新增套餐")
public Result save(@RequestBody SetmealDTO setmealDTO) {
log.info("新增套餐数据:{}", setmealDTO);
setmealService.saveWithDish(setmealDTO);
return Result.success();
}

Service,这里就稍微复杂点了

  1. 首先将dto转化为setmeal对象,然后直接进行插入,这里有一个全局异常处理,如果出现唯一键问题,会自动进行异常处理
  2. 通过mybatis中的插入后返回主键的功能,获取到插入套餐后对应的主键值,并得到每个setmealDish,对每个setmealDish的setmealId进行赋值,这样这个setmealDish中就有了菜品id(自带,因为菜品传过来肯定是要有id的),还有setmealId,最后执行插入
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    /**
    * 新增套餐
    *
    * @param setmealDTO 前端传来的带有套餐内菜品的套餐数据
    */
    @Override
    public void saveWithDish(SetmealDTO setmealDTO) {

    // 先讲setmealDTO转换成setmeal,然后执行一次插入
    Setmeal setmeal = new Setmeal();
    BeanUtils.copyProperties(setmealDTO, setmeal);
    setmealMapper.insert(setmeal);
    // 插入该套餐信息后,由于套餐和菜品是根据id绑定的,也就是说,在插入这条数据后,要获取到该条记录的id值
    Long setmealId = setmeal.getId();
    List<SetmealDish> setmealDishes = setmealDTO.getSetmealDishes();
    /**
    * 批量插入套餐-菜品,前端发来的数据中肯定是有dish_id的,所以要想插入,应该先把前面获取的setmealId赋值给setmealDishes的每一项
    */
    setmealDishes.forEach(setmealDish -> setmealDish.setSetmealId(setmealId));
    setmealDishMapper.insert(setmealDishes);
    }

    Mapper

  3. Setmeal.mapper,常规的插入语句
    1
    2
    3
    4
    5
    6
    <insert id="insert" 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>
  4. SetmealDish.mapper,使用了foreach,因为会有很多条数据,是一个集合
    1
    2
    3
    4
    5
    6
    <insert id="insert" parameterType="java.util.List">
    insert into setmeal_dish (setmeal_id,dish_id,name,price,copies) values
    <foreach collection="setmealDishes" item="item" separator=",">
    (#{item.setmealId},#{item.dishId},#{item.name},#{item.price},#{item.copies})
    </foreach>
    </insert>

这样就完成了这个新增套餐的接口

套餐分页查询

这个也是犯了一个很蠢的错误

分页查询时根本用不到套餐对应的菜品信息

Controller,正常写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 套餐分页查询-但是套餐是没数据的,所以不出来内容
*
* @param setmealPageQueryDTO
* @return
*/
@GetMapping("/page")
@ApiOperation("分页查询套餐")
public Result<PageResult> page(SetmealPageQueryDTO setmealPageQueryDTO) {
log.info("收到了分页请求,参数为: {}", setmealPageQueryDTO);
// service返回一个pageResult对象
PageResult pageResult = setmealService.pageQuery(setmealPageQueryDTO);

return Result.success(pageResult);
}

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
43
44
45
46
47
48
/**
* 套餐分页查询
*
* @param setmealPageQueryDTO
* @return
*/
/* @Override
public PageResult pageQuery(SetmealPageQueryDTO setmealPageQueryDTO) {

PageHelper.startPage(setmealPageQueryDTO.getPage(), setmealPageQueryDTO.getPageSize());

Page<SetmealVO> page = setmealMapper.pageQuery(setmealPageQueryDTO);
// List<SetmealVO> result = page.getResult();

*/

/**
* 出现严重bug,下面的获取类型名是通过分页的第一项的类型名的,比如第一个是商务套餐,那么后面的就全是商务套餐,那肯定不行
*//*

*//* if (result.size() > 0) {
// 临时变量
SetmealVO setmealVOD = result.get(0);
String categoryName = categoryMapper.getById(setmealVOD.getCategoryId());
page.forEach(setmealVO -> setmealVO.setCategoryName(categoryName));
}*//*

// 改成这样应该就行了,是遍历整个页面的所有项,分别获取每一项的类型名
for (SetmealVO setmealVO : page) {
String categoryName = categoryMapper.getById(setmealVO.getCategoryId());
setmealVO.setCategoryName(categoryName);
// 我明明记得给这个VO加入了这个对应的套餐列表,可就是没有?
setmealVO.setSetmealDishes(setmealDishMapper.getBySetmealId(setmealVO.getId()));
}

// 根据分类id查询分类名,然后将分类名置入这个套餐VO中
return new PageResult(page.getTotal(), page.getResult());
}*/


// 新写的一个Service 这个新的和之前写的区别在于,之前的分类名是后来查了存进去的,新的这个是通过关联查询出来的
// 对啊,上面把套餐对应的菜品也放进去了,但是前端展示分页,那个页面就用不到套餐对应的菜品信息,只是修改时会用到
@Override
public PageResult pageQuery(SetmealPageQueryDTO setmealPageQueryDTO) {
PageHelper.startPage(setmealPageQueryDTO.getPage(), setmealPageQueryDTO.getPageSize());
Page<SetmealVO> page = setmealMapper.pageQueryBySql(setmealPageQueryDTO);
return new PageResult(page.getTotal(), page.getResult());
}

Mapper,一个很简单的关联查询,只是为了将分类名查出来,因为只是需要分类名,其他的并不需要…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<select id="pageQueryBySql" 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>

删除套餐

业务规则:

  • 可以一次删除一个套餐,也可以批量删除套餐
  • 起售中的套餐不能删除

这一看果然是正常逻辑,只有起售中的套餐不能删除,那我写的应该是没问题吧

还是这个简单些

Controller,前端传来一个数组,用@RequestParam来讲数组封装为一个Long型的集合

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 批量删除菜品
*
* @param ids
* @return
*/
@DeleteMapping
@ApiOperation("批量删除菜品")
public Result deleteBitch(@RequestParam List<Long> ids) {
log.info("批量删除菜品,参数:{}", ids);
setmealService.deleteBitch(ids);
return Result.success();
}

Service,这里既然涉及到多表,所以用到了@Transactional事务处理

  1. 遍历这个ids,得到每一项
  2. 对每一项进行判断状态,如果有一个状态是起售的,就直接抛出不能删除的异常
  3. 最后在删除就行了
    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 ids
    */
    @Override
    @Transactional
    public void deleteBitch(List<Long> ids) {
    // 1. 根据ids查询套餐
    for (Long id : ids) {
    Setmeal setmeal = setmealMapper.getById(id);
    if (setmeal.getStatus() == StatusConstant.ENABLE) {
    // 起售中,不能删除,抛出异常
    throw new DeletionNotAllowedException(MessageConstant.SETMEAL_ON_SALE);
    }
    }

    // 好又发现问题了,这里只删除了

    // 2. 都是停售的,进行批量删除
    setmealMapper.deleteBitch(ids);
    for (Long id : ids) {
    setmealDishMapper.deleteBySetmealId(id);
    }
    }

    Mapper

  4. SetmealMapper,这里因为是批量删除,所以使用了foreach(老师的是单个删除,然后外面套了一层遍历ids的循环)
    1
    2
    3
    4
    5
    6
    7
    8
    <delete id="deleteBitch" parameterType="java.util.List">
    delete
    from setmeal
    where id in
    <foreach collection="ids" item="id" separator="," open="(" close=")">
    #{id}
    </foreach>
    </delete>
  5. SetmealDishMapper,就是普通的删除方法了,根据套餐id删
    1
    2
    3
    4
    5
    6
    /**
    * 根据setmealId来删除套餐与菜品的对应关系
    * @param setmealId
    */
    @Delete("delete from setmeal_dish where setmeal_id = #{setmealId}")
    void deleteBySetmealId(Long setmealId);

修改套餐

Controller,先查,进行回显,再改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 根据id查询套餐以及套餐对应的菜品列表
*
* @param id
* @return
*/
@GetMapping("/{id}")
@ApiOperation("根据id查询套餐以及套餐对应的菜品列表")
public Result<SetmealVO> getWithSetmealDishesById(@PathVariable Long id) {
log.info("根据id查询套餐以及套餐对应的菜品列表,参数为:{}", id);
SetmealVO setmealVO = setmealService.getWithSetmealDishById(id);
return Result.success(setmealVO);
}

@PutMapping
@ApiOperation("修改套餐")
public Result update(@RequestBody SetmealDTO setmealDTO) {
log.info("修改的参数:{}", setmealDTO);
setmealService.update(setmealDTO);
return Result.success();
}

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
43
44
45
46
47
48
49
50
51
52
53
54
55
/**
* 根据id查询套餐以及套餐对应的菜品列表
*
* @param id
* @return
*/
@Override
public SetmealVO getWithSetmealDishById(Long id) {
// 第一步,先根据id查询套餐表
Setmeal setmeal = setmealMapper.getById(id);
log.info("获取到的套餐信息:{}", setmeal);
SetmealVO setmealVO = new SetmealVO();
BeanUtils.copyProperties(setmeal, setmealVO);

// 第二步,根据id在 setmeal_dish表中查询关联的信息
List<SetmealDish> list = setmealDishMapper.getBySetmealId(id);
setmealVO.setSetmealDishes(list);

// 别忘了还有分类名 通过分类id获取类型名----后加,好像用不到分类名
/* String categoryName = categoryMapper.getById(setmeal.getCategoryId());
setmealVO.setCategoryName(categoryName);*/

return setmealVO;
}


/**
* 修改套餐
*
* @param setmealDTO
*/
@Override
@Transactional
public void update(SetmealDTO setmealDTO) {
// 还是要分成两步修改啊
// 1. 先直接修改套餐的基本信息
Setmeal setmeal = new Setmeal();
BeanUtils.copyProperties(setmealDTO, setmeal);
setmealMapper.update(setmeal);


// 2. 修改套餐对应的菜品信息,或者说是setmeal_dish表中的信息
List<SetmealDish> setmealDishes = setmealDTO.getSetmealDishes();

// 2.1 先删除-根据setmealId删除
Long setmealId = setmealDTO.getId();
setmealDishMapper.deleteBySetmealId(setmealId);
// 2.2 再赋值
for (SetmealDish setmealDish : setmealDishes) {
setmealDish.setSetmealId(setmealId);
}
log.info("修改后的套餐菜品对象为:{}", setmealDishes);
setmealDishMapper.insert(setmealDishes);
}

Mapper,在SetmealMapper中用到的这个动态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
25
26
27
28
29
30
<update id="update" parameterType="com.sky.entity.Setmeal">
update setmeal
<set>
<if test="categoryId != null">
category_id = #{categoryId},
</if>
<if test="name != null">
name = #{name},
</if>
<if test="price != null">
price = #{price},
</if>
<if test="status != null">
status = #{status},
</if>
<if test="description != null">
description = #{description},
</if>
<if test="image">
image = #{image},
</if>
<if test="updateTime != null">
update_time = #{updateTime},
</if>
<if test="updateUser != null">
update_user = #{updateUser}
</if>
</set>
where id = #{id}
</update>

修改套餐状态

Controller,这个不难吧

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

???原来这个也这么麻烦
实属没想到

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

第三条,根本么考虑到

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
43
44
    /**
* 套餐起售/停售
*
* @param status
* @param id
*/
@Override
public void startOrStop(Integer status, Long id) {

/**
* 第一种是我写的,第二种是老师写的,两种方式,都能实现效果
*/

if (status == StatusConstant.ENABLE){
// 根据套餐id查询套餐关联的菜品id
List<SetmealDish> setmealDishes = setmealDishMapper.getBySetmealId(id);
for (SetmealDish setmealDish : setmealDishes) {
Dish dish = dishMapper.getById(setmealDish.getDishId());
if (dish.getStatus() == StatusConstant.DISABLE) {
throw new SetmealEnableFailedException(MessageConstant.SETMEAL_ENABLE_FAILED);
}
}
}

// 起售套餐时,判断套餐内是否有停售菜品,有停售菜品提示"套餐内包含未启售菜品,无法启售"
/* 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,我写的那个主要就是简单的sql,老师写的那个只是多个根据setmealId在dish表中查,是一个关联查询,就是他一条Sql解决了,而我两条

1
2
3
4
5
6
7
8
/**
* 根据套餐id查询菜品
*
* @param
* @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);

6 店铺营业状态设置

Redis

Redis复习?

20230903120240

这一部分就不看了,直接跳到在Java中操作Redis

Java操作Redis

一共两种操作方式:

  1. Redis的Java客户端
  2. Spring Data Redis使用方式

重点放在Spring Data Redis上面

操作步骤

  1. 导入Spring Data Redis的maven坐标
  2. 配置Redis数据源
    这里还是分开配置的
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
      redis:
    host: ${sky.redis.host}
    port: ${sky.redis.port}
    password: ${sky.redis.password}
    database: ${sky.redis.database}

    # dev文件
    redis:
    host: 1.14.102.xx
    port: 6379
    password: "010203"
    database: 2
  3. 编写配置类,创建RedisTemplate对象
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    @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;
    }

    }
  4. 通过RedisTemplate对象操作Redis,直接打印对象
    1
    2
    3
    4
    5
    6
    7
    @Autowired
    private RedisTemplate redisTemplate;

    @Test
    public void testRedisTemplate() {
    System.out.println(redisTemplate);
    }

基本操作

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
package com.sky.test;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.connection.DataType;
import org.springframework.data.redis.core.*;

import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;

/**
* @author zzmr
* @create 2023-09-03 12:23
*/
@SpringBootTest
public class SpringDataRedisTest {

@Autowired
private RedisTemplate redisTemplate;

@Test
public void testRedisTemplate() {
System.out.println(redisTemplate);
ValueOperations valueOperations = redisTemplate.opsForValue();
HashOperations hashOperations = redisTemplate.opsForHash();
ListOperations listOperations = redisTemplate.opsForList();
SetOperations setOperations = redisTemplate.opsForSet();
ZSetOperations zSetOperations = redisTemplate.opsForZSet();
}

@DisplayName("操作字符串类型的数据")
@Test
public void testString() {
redisTemplate.opsForValue().set("city", "汉中");
String city = (String) redisTemplate.opsForValue().get("city");
System.out.println(city);

// 30s过期
// redisTemplate.opsForValue().set("county", "留坝", 30, TimeUnit.SECONDS);
String county = (String) redisTemplate.opsForValue().get("county");
System.out.println(county);

// setnx
redisTemplate.opsForValue().setIfAbsent("city", "成都");
}

@Test
public void testHash() {
// hset hget hdel hkeys hvals
HashOperations hashOperations = redisTemplate.opsForHash();
hashOperations.put("101", "name", "tom");
hashOperations.put("101", "age", "20");
String name = (String) hashOperations.get("101", "name");
System.out.println(name);

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

hashOperations.delete("101", "name");
}

@Test
public void testList() {
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);
}

@Test
public void testSet() {
SetOperations setOperations = redisTemplate.opsForSet();
setOperations.add("set02", "a", "b", "c", "d");
setOperations.add("set03", "e", "f", "g", "h");

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

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

Set intersect = setOperations.intersect("set02", "set03");
System.out.println(intersect);

Set union = setOperations.union("set02", "set03");
System.out.println(union);

setOperations.remove("set02", "a", "b");

}

@Test
public void testZSet() {
ZSetOperations zSetOperations = redisTemplate.opsForZSet();
zSetOperations.add("zset01", "a", 10);
zSetOperations.add("zset01", "b", 20);
zSetOperations.add("zset01", "c", 30);

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

zSetOperations.incrementScore("zset01", "a", 30);
zSetOperations.remove("zset01", "a", "b");

}

@Test
public void testCommon() {
Set keys = redisTemplate.keys("*");
System.out.println(keys);

Boolean myList = redisTemplate.hasKey("myList");
Boolean xx = redisTemplate.hasKey("xx");
System.out.println(myList);
System.out.println(xx);

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

redisTemplate.delete("li");
}


}

店铺营业状态设置

  1. 查询状态(由于路径规范,管理端和用户端是不同的接口路径,所以需要两个查询接口)
    • 管理端查询营业状态
    • 用户端查询营业状态
  2. 修改状态

营业状态数据存储方式,基于Redis的字符串来进行存储

20230903194414


Controller,这里指定了bean的别名,因为会有两个ShopController,一个是admin下的,一个是user下的,所以需要指定,不然就会报错,除了Controller,其他也就不需要了,因为这里的数据是直接存储到redis中的,因为就只有一条数据,放到redis中非常快速

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
@RestController("adminShopController")
@Slf4j
@RequestMapping("/admin/shop")
@Api(tags = "店铺相关接口")
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 ? "营业" : "打烊中");

// 将状态存储到redis中
redisTemplate.opsForValue().set(KEY, status);

return Result.success();
}

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

user下的Controller,两个逻辑其实是一样的,只是有请求路径与点区别,这里的bean别名也设置了

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("userShopController")
@Slf4j
@RequestMapping("/user/shop")
@Api(tags = "店铺相关接口")
public class ShopController {

public static final String KEY = "SHOP_STATUS";


@Autowired
private RedisTemplate redisTemplate;

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

7 微信登录-商品预览

HttpClient

HttpClient是Apache Jakarta Common下的子项目,可以用来提供高效的,最新的,功能丰富的支持HTTP协议的客户端编程工具包,并且它支持HTTP协议最新的版本和建议

依赖,这个依赖在OSS中已经引入了

1
2
3
4
5
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.4.1</version>
</dependency>

发送请求步骤

  1. 创建HttpClient对象
  2. 创建Http请求对象(get/post)
  3. 调用HttpClient的execute方法发送请求

20230904094835

发送GET请求

就是上面那三步,看起来也没啥难得,就是要记几个API

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
@Test
public void testGet() {
// HttpClient的实现类
CloseableHttpClient httpClient = HttpClients.createDefault();

// 创建请求对象
HttpGet httpGet = new HttpGet("http://localhost:8080/user/shop/status");

// 发送请求,并接收响应结果
try {
CloseableHttpResponse response = httpClient.execute(httpGet);
// 获取服务端返回的状态码
int statusCode = response.getStatusLine().getStatusCode();
System.out.println("服务端返回的状态码:" + statusCode);

// 具体相应的数据
HttpEntity entity = response.getEntity();
String body = EntityUtils.toString(entity);
System.out.println("服务端返回的数据:" + body);
// 关闭资源
response.close();
httpClient.close();
} catch (IOException e) {
e.printStackTrace();
}
}

发送POST请求

没啥难的,只是记得封装请求体要使用到fastJson

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
/**
* 通过httpClient发送GET方式请求
*/
@Test
public void testPost() throws IOException {
CloseableHttpClient httpClient = HttpClients.createDefault();
HttpPost httpPost = new HttpPost("http://localhost:8080/admin/employee/login");

/**
* 构造请求体 StringEntity
*/
JSONObject jsonObject = new JSONObject();
jsonObject.put("username", "admin");
jsonObject.put("password", "123456");
StringEntity entity = new StringEntity(jsonObject.toString());
// 指定请求的编码方式
entity.setContentEncoding("utf-8");
// 指定传输的数据格式
entity.setContentType("application/json");


httpPost.setEntity(entity);
// 发送请求
CloseableHttpResponse response = httpClient.execute(httpPost);

// 解析返回结果
int statusCode = response.getStatusLine().getStatusCode();
System.out.println("响应状态码:" + statusCode);

HttpEntity responseEntity = response.getEntity();
String body = EntityUtils.toString(responseEntity);
System.out.println("响应体:" + body);

// 关闭资源
response.close();
httpClient.close();

}

封装的工具类

但是项目中使用还是用的封装好的工具类,不然一次又一次创建HttpClient多麻烦

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
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
package com.sky.utils;

import com.alibaba.fastjson.JSONObject;
import org.apache.http.NameValuePair;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;

import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
* Http工具类
*/
public class HttpClientUtil {

static final int TIMEOUT_MSEC = 5 * 1000;

/**
* 发送GET方式请求
* @param url
* @param paramMap
* @return
*/
public static String doGet(String url,Map<String,String> paramMap){
// 创建Httpclient对象
CloseableHttpClient httpClient = HttpClients.createDefault();

String result = "";
CloseableHttpResponse response = null;

try{
URIBuilder builder = new URIBuilder(url);
if(paramMap != null){
for (String key : paramMap.keySet()) {
builder.addParameter(key,paramMap.get(key));
}
}
URI uri = builder.build();

//创建GET请求
HttpGet httpGet = new HttpGet(uri);

//发送请求
response = httpClient.execute(httpGet);

//判断响应状态
if(response.getStatusLine().getStatusCode() == 200){
result = EntityUtils.toString(response.getEntity(),"UTF-8");
}
}catch (Exception e){
e.printStackTrace();
}finally {
try {
response.close();
httpClient.close();
} catch (IOException e) {
e.printStackTrace();
}
}

return result;
}

/**
* 发送POST方式请求
* @param url
* @param paramMap
* @return
* @throws IOException
*/
public static String doPost(String url, Map<String, String> paramMap) throws IOException {
// 创建Httpclient对象
CloseableHttpClient httpClient = HttpClients.createDefault();
CloseableHttpResponse response = null;
String resultString = "";

try {
// 创建Http Post请求
HttpPost httpPost = new HttpPost(url);

// 创建参数列表
if (paramMap != null) {
List<NameValuePair> paramList = new ArrayList();
for (Map.Entry<String, String> param : paramMap.entrySet()) {
paramList.add(new BasicNameValuePair(param.getKey(), param.getValue()));
}
// 模拟表单
UrlEncodedFormEntity entity = new UrlEncodedFormEntity(paramList);
httpPost.setEntity(entity);
}

httpPost.setConfig(builderRequestConfig());

// 执行http请求
response = httpClient.execute(httpPost);

resultString = EntityUtils.toString(response.getEntity(), "UTF-8");
} catch (Exception e) {
throw e;
} finally {
try {
response.close();
} catch (IOException e) {
e.printStackTrace();
}
}

return resultString;
}

/**
* 发送POST方式请求
* @param url
* @param paramMap
* @return
* @throws IOException
*/
public static String doPost4Json(String url, Map<String, String> paramMap) throws IOException {
// 创建Httpclient对象
CloseableHttpClient httpClient = HttpClients.createDefault();
CloseableHttpResponse response = null;
String resultString = "";

try {
// 创建Http Post请求
HttpPost httpPost = new HttpPost(url);

if (paramMap != null) {
//构造json格式数据
JSONObject jsonObject = new JSONObject();
for (Map.Entry<String, String> param : paramMap.entrySet()) {
jsonObject.put(param.getKey(),param.getValue());
}
StringEntity entity = new StringEntity(jsonObject.toString(),"utf-8");
//设置请求编码
entity.setContentEncoding("utf-8");
//设置数据类型
entity.setContentType("application/json");
httpPost.setEntity(entity);
}

httpPost.setConfig(builderRequestConfig());

// 执行http请求
response = httpClient.execute(httpPost);

resultString = EntityUtils.toString(response.getEntity(), "UTF-8");
} catch (Exception e) {
throw e;
} finally {
try {
response.close();
} catch (IOException e) {
e.printStackTrace();
}
}

return resultString;
}
private static RequestConfig builderRequestConfig() {
return RequestConfig.custom()
.setConnectTimeout(TIMEOUT_MSEC)
.setConnectionRequestTimeout(TIMEOUT_MSEC)
.setSocketTimeout(TIMEOUT_MSEC).build();
}

}

微信小程序开发

入门案例

看看这个课和培训老师讲的哪个好

获取用户的头像信息,要把基础库版本调到2.10左右才能看到弹窗,不然只能得到默认的用户名和头像,应该是接口换了

1
2
3
4
5
6
7
8
9
10
11
12
13
// 获取用户的头像和昵称
getUserInfo() {
wx.getUserProfile({
desc: '获取用户信息',
success: (res => {
console.log(res.userInfo)
this.setData({
nickName: res.userInfo.nickName,
url: res.userInfo.avatarUrl
})
})
})
}

用户登录,会返回一个授权码,是随机的,且一个授权码只能使用一次

1
2
3
4
5
6
7
8
// 微信登录-获取用户的授权码
wxlogin() {
wx.login({
success: (res) => {
console.log(res.code)
},
})
}

发送异步请求

1
2
3
4
5
6
7
8
9
10
11
12
13
// 发送请求
sendRequest() {
wx.request({
url: 'http://localhost:8080/user/shop/status',
method: 'GET',
success: (res) => {
console.log(res.data)
this.setData({
status: res.data.data
})
}
})
}

导入小程序代码

真就是直接导入,一点都不用改

20230904222503

微信登录流程

登录流程

就是

  1. 小程序端调wx.login得到code(用户授权码)
  2. 小程序端发送请求,携带code
  3. 服务端接收到code和请求,然后调用微信接口服务(appId,appsecret,code)
  4. 微信接口返回session_keyopenId
  5. 服务端自定义登陆状态,与openIdsession_key关联,返回自定义状态
  6. 小程序存入自定义登录状态,每次发送请求时,携带登录状态

wx26b00f9454de88a6
6c4ad5db6efb880425969b7afe4099c7

用postman发送请求试一下,是没问题的
20230905082536

接口设计

业务规则

  • 基于微信登陆实现小程序的的登录功能
  • 如果是新用户需要自动完成注册

代码开发

jwt令牌-用户和管理端要分开配置

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

wx的id

1
2
3
wechat:
appid: ${sky.wechat.appid}
secret: ${sky.wechat.secret}

当时以为这个HttpClient是用来分模块开发的,没想到只是用来请求微信接口的


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
/**
* @author zzmr
* @create 2023-09-05 8:51
*/
@RestController
@Slf4j
@RequestMapping("/user/user")
@Api(tags = "用户相关接口")
public class UserController {

@Autowired
private UserService userService;

@Autowired
private JwtProperties jwtProperties;

@ApiOperation("微信登录")
@PostMapping("/login")
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("userId", user.getId());
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();
log.info("登陆成功:{}", userLoginVO);
return Result.success(userLoginVO);
}

}

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
/**
* 微信登陆
* <p>
* 如果是新用户,就会自动完成注册,封装user然后返回
* 如果不是,则直接在数据库中就查出了该user对象,也是返回
*
* @param userLoginDTO
* @return
*/
@Override
public User wxLogin(UserLoginDTO userLoginDTO) {
// 差不多就是,先拿着code和用户id进行查询,然后封装到一个user里,最后返回

String openId = getString(userLoginDTO);

// 1.1 判断openId是否为空,如果为空,则抛出异常
if (openId == null) {
throw new LoginFailedException(MessageConstant.LOGIN_FAILED);
}

// 2. 判断当前用户是否为新用户-根据openId
User user = userMapper.getByOpenId(openId);
// 2.1 是新用户,则自动完成注册
if (user == null) {
// 是新的用户 -- 构造用户信息-完成注册 -- 现在只能拿到openId和创建时间,其余的是拿不到的
user = User.builder().openid(openId).createTime(LocalDateTime.now()).build();
userMapper.insert(user);
}
// 2.2 返回用户对象
return user;
}

现在小程序端发出的请求都会携带请求头token

我终于知道为什么有些会要求有请求头的token,有些不用了,是这个拦截器在起作用,也就是从请求头中获取到token

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

然后就是将拦截器注册进去

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
@Configuration
@Slf4j
public class WebMvcConfiguration extends WebMvcConfigurationSupport {

@Autowired
private JwtTokenAdminInterceptor jwtTokenAdminInterceptor;

@Autowired
private JwtTokenUserInterceptor jwtTokenUserInterceptor;

/**
* 注册自定义拦截器
*
* @param registry
*/
protected void addInterceptors(InterceptorRegistry registry) {
log.info("开始注册自定义拦截器...");
registry.addInterceptor(jwtTokenAdminInterceptor)
.addPathPatterns("/admin/**")
.excludePathPatterns("/admin/employee/login");
registry.addInterceptor(jwtTokenUserInterceptor)
.addPathPatterns("/user/**")
.excludePathPatterns("/user/user/login")
.excludePathPatterns("/user/shop/status");
}

......

商品预览

这里还涉及到种类?,就是有口味,就会有规格,没有口味,就是选个数

  1. 查询分类
  2. 根据分类id查询菜品
  3. 根据分类id查询套餐
  4. 根据套餐id查询包含的菜品

查询所有分类-CategoryController,进入小程序后自动发起请求,请求所有的分类和套餐,这里可以根据分类类型进行查询-分类/套餐

1
2
3
4
5
6
7
8
9
10
/**
* 查询所有的分类
* @param type
* @return
*/
@GetMapping("/list")
public Result<List<Category>> list(Integer type) {
List<Category> list = categoryService.list(type);
return Result.success(list);
}

根据分类id查询菜品,就是点击某个分类,进行查询该分类的菜品,规则就是要求是起售的

  1. DishController
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    /**
    * 根据分类id查询菜品
    *
    * @param categoryId
    * @return
    */
    @GetMapping("/list")
    @ApiOperation("根据分类id查询菜品")
    public Result<List<DishVO>> list(Long categoryId) {
    // 构建一个有分类id,和状态为起售的菜品
    Dish dish = Dish.builder().status(StatusConstant.ENABLE).categoryId(categoryId).build();

    List<DishVO> list = dishService.listWithFlavor(dish);
    return Result.success(list);
    }
  2. DishServiceImpl,首选根据分类的id查询所有的菜品,用一个List<Dish>封装,然后就是遍历这个集合,创建一个DishVO对象,然后将每一个Dish对象复制给DishVO,再根据dishId查询所有的口味,最后将口味封装到DishVO中,最后返回.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    /**
    * 根据dish的分类id进行查询
    *
    * @param dish
    * @return
    */
    @Override
    public List<DishVO> listWithFlavor(Dish dish) {

    // 先根据dish中的分类id进行查询,查到对应分类所有的菜品
    List<Dish> dishList = dishMapper.getByCategoryId(dish);
    List<DishVO> dishVOList = new ArrayList<>();

    // 遍历这个dishList,拿到每一个dish,然后将每一个dish的口味都查出来,封装到一个dishVO中,再添加到dishVOList中
    for (Dish d : dishList) {
    DishVO dishVO = new DishVO();
    BeanUtils.copyProperties(d, dishVO);
    List<DishFlavor> flavors = dishFlavorMapper.getByDishId(dish.getId());
    dishVO.setFlavors(flavors);
    dishVOList.add(dishVO);
    }
    return dishVOList;
    }

根据分类id查询套餐

1
2
3
4
5
6
7
@GetMapping("/list")
@ApiOperation("根据分类id查询套餐")
public Result<List<Setmeal>> list(Long categoryId) {
Setmeal setmeal = Setmeal.builder().status(StatusConstant.ENABLE).categoryId(categoryId).build();
List<Setmeal> list = setmealService.list(setmeal);
return Result.success(list);
}

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

1
2
3
4
5
6
@GetMapping("/dish/{id}")
@ApiOperation("根据套餐id查询包含的菜品")
public Result<List<DishItemVO>> dishList(@PathVariable Long id) {
List<DishItemVO> list = setmealService.getDishItemById(id);
return Result.success(list);
}

还有Service中

1
2
3
4
5
6
7
8
9
10
11
/**
* 根据套餐id查询包含的菜品
*
* @param id
* @return
*/
@Override
public List<DishItemVO> getDishItemById(Long id) {
List<DishItemVO> list = setmealMapper.getDishItemById(id);
return list;
}

这里用到了多表联查

1
2
3
@Select("SELECT setmeal_dish.copies,dish.description,dish.image,dish.`name`" +
"FROM setmeal_dish LEFT JOIN dish ON setmeal_dish.dish_id = dish.id WHERE setmeal_id = #{id} ")
List<DishItemVO> getDishItemById(Long id);

8 缓存-购物车

缓存菜品

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

结果就是,系统响应慢,用户体验差

实现思路
通过Redis来缓存菜品数据,减少数据库查询操作
2023-09-06ddfa

就是优先读取缓存数据,有的话就用缓存的,没有再去数据库中查,然后将查到的数据写入缓存中

缓存逻辑分析

小程序端是按照分类展示的菜品,所以我们可以根据分类来进行缓存,一个分类是一份缓存数据

而缓存的key就可以用分类id来表示,value可以用菜品数据的string字符串来保存
20230906091732

还有一点很重要的就是:数据库中的菜品数据有变更时,清理缓存数据

改造上面的userDishController,这个主要是查询时加入redis,当接口接收到请求,先去redis中获取,如果有就直接返回,没有再去数据库中查询,最后返回

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
/**
* 根据分类id查询菜品
* 改造接口-实现缓存分类菜品
*
* redis放入的类型和取出的类型是一样的,不用担心类型问题
*
* @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);
}
// 如果不存在,查询数据库,将查询到的数据存入redis中

// 构建一个有分类id,和状态为起售的菜品
Dish dish = Dish.builder().status(StatusConstant.ENABLE).categoryId(categoryId).build();

list = dishService.listWithFlavor(dish);

// 查询完之后,将数据写入redis
redisTemplate.opsForValue().set(key, list);

return Result.success(list);
}

但是数据一致性还没有保证,如果现在更改了数据库中的数据,缓存的数据是不会变的

所以要在更新完数据中的数据之后,马上删除缓存中的数据,或者更新缓存中的数据?也有可能这项数据并不是立即使用的,所以可以不用立即更新,只是删除掉

好像已经猜到了该怎么做了,在修改和添加,起售停售以及删除的地方(好像是,除了查询,都要清空缓存),都要进行清空缓存,应该是用到切面类的

但是呢,目前缓存的地方只有菜品相关的,也就是说,其他接口不用进行缓存相关的操作,所以只需要操作管理端的DishController,那涉及到的方法就不多了,完全不需要用切面

根据通配符来查找
20230906100903

好多修改的地方,如果不能直接拿到分类id,那就直接全部清空,因为如果没有分类id,还要查询数据库来获取分类id,本来加入缓存就是为了减少数据库的IO操作,这样就有点得不偿失了

更改后的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
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
@RestController
@Api(tags = "菜品相关接口")
@RequestMapping("/admin/dish")
@Slf4j
public class DishController {

@Autowired
private DishService dishService;

@Autowired
private RedisTemplate redisTemplate;

@PostMapping
@ApiOperation("新增菜品")
public Result save(@RequestBody DishDTO dishDTO) {
log.info("新增菜品");
dishService.saveWithFlavor(dishDTO);

// new
// 清理缓存数据-清理对应类型的缓存数据,先确定这个菜品的分类id,然后清空该分类id的缓存
cleanCache("dish_" + dishDTO.getCategoryId());

return Result.success();
}

/**
* 分页查询菜品
*
* @param dishPageQueryDTO
* @return
*/
@ApiOperation("分页查询菜品")
@GetMapping("/page")
public Result<PageResult> page(DishPageQueryDTO dishPageQueryDTO) {
log.info("菜品分页查询开始");
PageResult pageResult = dishService.pageQuery(dishPageQueryDTO);
return Result.success(pageResult);
}

/**
* 批量删除菜品
*
* @param ids
* @return
*/
@DeleteMapping
@ApiOperation("批量删除菜品")
public Result deleteBatchDish(@RequestParam List<Long> ids) {

// 是可以拿到的,一个数组
log.info("传入的ids: {}", ids.size());
dishService.deleteBatchDish(ids);

// new
// 批量清空缓存
/*for (Long id : ids) {
// 获取每一个菜品的分类id,这里要用到 一个 根据菜品id查询菜品的信息的方法
Dish dish = dishService.getById(id);
String key = "dish_" + dish.getCategoryId();
redisTemplate.delete(key);
}*/

// 那么还有一种方法,就是直接清空全部的缓存,所有以dish_开头的key--简单粗暴且有效
cleanCache("dish_*");

return Result.success();
}

/**
* 提取出清空缓存的方法
*/
private void cleanCache(String pattern) {
Set keys = redisTemplate.keys(pattern);
redisTemplate.delete(keys);
}

/**
* 菜品起售、停售
*
* @param status 新状态
* @param id 菜品id
* @return
*/
@PostMapping("status/{status}")
@ApiOperation("菜品的起售/停售")
public Result startOrStop(@PathVariable Integer status, Long id) {

// 将要更改的状态和被更改菜品的id传入
dishService.startOrStop(status, id);

cleanCache("dish_*");

return Result.success();
}

/**
* 根据id查询菜品,和对应的口味
*
* @param id
* @return
*/
@ApiOperation("根据id查询菜品,和对应的口味")
@GetMapping("/{id}")
public Result<DishVO> getById(@PathVariable Long id) {
log.info("根据id查询菜品:{}", id);
DishVO dishVO = dishService.getByIdWithFlavor(id);
return Result.success(dishVO);
}

/**
* 修改菜品
*
* @return
*/
@PutMapping
@ApiOperation("修改菜品")
public Result update(@RequestBody DishDTO dishDTO) {
log.info("修改菜品:{} ", dishDTO);
dishService.updateWithFlavor(dishDTO);

// new -清空缓存--但是修改时是可以修改菜品的分类的,如果菜品的分类修改了,那就要清空原本的和新的分类菜品的缓存了,也是直接全部清空?---直接清空全部
cleanCache("dish_*");

return Result.success();
}

/**
* 根据分类id查询菜品
*
* @param categoryId
* @return
*/
@GetMapping("/list")
@ApiOperation("根据分类id查询菜品")
public Result<List<Dish>> getByCategoryId(Long categoryId) {
log.info("根据分类id查询菜品,参数为{}", categoryId);
List<Dish> list = dishService.getByCategoryId(categoryId);
return Result.success(list);
}

}

Spring Cache

可以替代redis?

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

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

  • EHCache
  • Caffeine
  • Redis

导入依赖

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

然后完全不需要配置,这里Spring Cache就使用的是redis作为缓存的底层了,因为前面已经导入过spring-boot-starter-data-redis


常用注解

注解 说明
@EnableCaching 开启缓存注解功能,通常加在启动类上
@Cacheable 在方法执行前先查询缓存中是否有数据,如果有数据,则直接返回缓存数据,如果没有,调用方法,并将方法的返回值放到缓存中
@CachePut 将方法的返回值放到缓存中
@CacheEvict 将一条或多条数据从缓存中删除
  • @Cacheable可以用在小程序端获取数据时,先查有没有缓存数据,然后在进行下一步操作
  • @CachePut可以用在管理端,当修改了某些数据时,将修改后的数据放到缓存中

案例

这里是一个很简单的新demo

  1. 给启动类上加@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("项目启动成功...");
    }
    }
  2. @CachePut注解的使用,就是注意key的拼接,写法啥的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    /**
    * 如果使用Spring Cache缓存数据,key的生成是和cacheNames有关
    * 为 userCache:id userCache::1
    * 写法是多样的,还可以写`result.id`,result就是方法的返回值
    * p0,pxx,代表第几个参数
    * a0,axx,代表第几个参数
    *
    * @param user
    * @return
    */
    @PostMapping
    // @CachePut(cacheNames = "userCache",key="#result.id")
    // @CachePut(cacheNames = "userCache",key="#p0.id")
    @CachePut(cacheNames = "userCache", key = "#user.id")
    public User save(@RequestBody User user) {
    userMapper.insert(user);
    return user;
    }
  3. @Cacheable注解,先是根据cacheNames和key进行拼接,得到缓存数据的key,然后在redis中查找是否有该数据,有的话,会直接返回,跳过该接口的执行,发现一个小问题,如果不存在这条数据,那么缓存中也会存一个数据,不过为空,好像也没啥问题
    20230906163035

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    /**
    * @param id
    * @return
    * @Cacheable 这个注解挺神奇的,如果缓存中有数据,则连这个接口都不会调用,直接将缓存中的数据返回
    */
    @Cacheable(cacheNames = "userCache",key = "#id")
    @GetMapping
    public User getById(Long id) {
    User user = userMapper.getById(id);
    return user;
    }
  4. @CacheEvict清除缓存,注意清除全部缓存,要用allEntries = true

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    @DeleteMapping
    @CacheEvict(cacheNames = "userCache", key = "#id")
    public void deleteById(Long id) {
    userMapper.deleteById(id);
    }

    /**
    * 清除全部的缓存
    */
    @DeleteMapping("/delAll")
    @CacheEvict(cacheNames = "userCache", allEntries = true)
    public void deleteAll() {
    userMapper.deleteAll();
    }

缓存套餐

  1. 在启动类上加入@EnableCaching
  2. 在用户端接口SetmealController的list方法上加入@Cacheable注解
    • 这样看也太简单了
      1
      2
      3
      4
      5
      6
      7
      8
      9
      @GetMapping("/list")
      @ApiOperation("根据分类id查询套餐")
      // 动态计算除key setmealCache::20
      @Cacheable(cacheNames = "setmealCache",key = "#categoryId")
      public Result<List<Setmeal>> list(Long categoryId) {
      Setmeal setmeal = Setmeal.builder().status(StatusConstant.ENABLE).categoryId(categoryId).build();
      List<Setmeal> list = setmealService.list(setmeal);
      return Result.success(list);
      }
  3. 在服务端接口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
    /**
    * 新增套餐的接口
    *
    * @param setmealDTO
    * @return
    */
    @PostMapping
    @ApiOperation("新增套餐")
    // 新增的套餐一定有对应的分类,所以根据分类id来删除这个缓存,精确删除
    @CacheEvict(cacheNames = "setmealCache", key = "#setmealDTO.categoryId")
    public Result save(@RequestBody SetmealDTO setmealDTO) {}


    @PostMapping("status/{status}")
    @ApiOperation("套餐起售/停售")
    // 由于不能直接拿到套餐的分类id,所以选择直接清空缓存
    @CacheEvict(cacheNames = "setmealCache", allEntries = true)
    public Result startOrStop(@PathVariable Integer status, Long id) {}

    @PutMapping
    @ApiOperation("修改套餐")
    // 理由是更改的信息具有多样性,所以选择直接清空缓存
    @CacheEvict(cacheNames = "setmealCache", allEntries = true)
    public Result update(@RequestBody SetmealDTO setmealDTO) {}

    /**
    * 批量删除菜品
    *
    * @param ids
    * @return
    */
    @DeleteMapping
    @ApiOperation("批量删除菜品")
    // 由于不能直接拿到套餐的分类id,所以选择直接清空缓存
    @CacheEvict(cacheNames = "setmealCache", allEntries = true)
    public Result deleteBitch(@RequestParam List<Long> ids) {}

缓存翻篇


购物车

添加购物车

购物车表:
20230906203501

添加购物车的两种情况

  1. 添加一条菜品的数据,这时要先查询该用户的购物车中是否有该菜品,且口味相同,如果有则直接数量加1
    • 相关的sql:select * from shopping_cart where user_id = ? and dish_id = ? and dish_flavor
  2. 添加一条套餐的数据,这时要先查询该用户的购物车中是否有该套餐,如果有则直接数量加1
    • 相关的sql:select * from shopping_cart where user_id = ? and setmeal_id = ?

所以要用动态sql来解决这个问题

ShoppingCartController,前端只需要传来一个有setmeal_id,dish_id,以及dish_flavor的对象即可,这个dish_flavor只是一个字符串,包括选中的口味,如微辣

1
2
3
4
5
6
7
@PostMapping("/add")
@ApiOperation("添加购物车")
public Result add(@RequestBody ShoppingCartDTO shoppingCartDTO) {
log.info("添加购物车:{}", shoppingCartDTO);
shoppingCartService.addShoppingCard(shoppingCartDTO);
return Result.success();
}

Service,虽然代码长,但是逻辑不是很难,就是先判断这条新加的数据是否在购物车中出现过,如果有就加数量,没有就是新增一条数据,而封装这条购物车数据需要用到name和image等,这些字段需要查dish或者setmeal表,所以要判断这条数据是菜品还是套餐,再进一步封装数据

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 shoppingCartDTO
*/
@Override
public void addShoppingCard(ShoppingCartDTO shoppingCartDTO) {
// 判断当前加入到购物车中的商品是否已经存在
ShoppingCart shoppingCart = new ShoppingCart();
BeanUtils.copyProperties(shoppingCartDTO, shoppingCart);
Long userId = BaseContextByMe.getCurrentId();
shoppingCart.setUserId(userId);

// 虽然返回的是一个集合,但是按照以上的条件,返回的结果应该只有一条
List<ShoppingCart> list = shoppingCartMapper.list(shoppingCart);
// 如果已经存在,则只需要数量加1
if (list != null && list.size() > 0) {
// 获取该条记录,然后
ShoppingCart cart = list.get(0);
// 数量加一
cart.setNumber(cart.getNumber() + 1); // 执行sql update shopping_cart set number = ? where id = ?
shoppingCartMapper.updateNumberById(cart);
} else {
// 如果不存在,需要插入一条购物车数据
// 那就要构造这个购物车数据,只有前端传来的setmeal_id/dish_id 和dishFlavor 以及获取的userId,是不够的,还需要name,image
// 先判断是菜品还是套餐
Long dishId = shoppingCart.getDishId();


if (dishId != null) {
// 为菜品,先根据dishId查询该菜品信息,需要name和image
Dish dish = dishMapper.getById(dishId);
shoppingCart.setName(dish.getName());
shoppingCart.setImage(dish.getImage());
shoppingCart.setAmount(dish.getPrice());
// 默认的数量
} else {
// 为套餐
Setmeal setmeal = setmealMapper.getById(shoppingCart.getSetmealId());
shoppingCart.setName(setmeal.getName());
shoppingCart.setImage(setmeal.getImage());
shoppingCart.setAmount(setmeal.getPrice());
// 默认的数量
}
shoppingCart.setCreateTime(LocalDateTime.now());
shoppingCart.setNumber(1);
// 不管是插入哪一个,到这里时数据就已经封装好了
shoppingCartMapper.insert(shoppingCart);
}
}

Mapper

1
2
3
4
5
6
7
8
/**
* 插入一条购物车数据
*
* @param shoppingCart
*/
@Insert("insert into shopping_cart(name, image, user_id, dish_id, setmeal_id, dish_flavor, amount, create_time) " +
"values (#{name},#{image},#{userId},#{dishId},#{setmealId},#{dishFlavor},#{amount},#{createTime})")
void insert(ShoppingCart shoppingCart);

动态sql

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!--    动态条件查询-->
<select id="list" resultType="com.sky.entity.ShoppingCart">
select *
from shopping_cart
<where>
<if test="userId != null">
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">
and dish_flavor = #{dishFlavor}
</if>
</where>
</select>

查询购物车

这个接口也没啥难的

Controller,直接调用service

1
2
3
4
5
6
7
8
9
10
11
/**
* 这个接口不需要任何参数,因为用户的id可以直接取出
*
* @return
*/
@ApiOperation("查询购物车")
@GetMapping("/list")
public Result<List<ShoppingCart>> list() {
List<ShoppingCart> list = shoppingCartService.getByUserId();
return Result.success(list);
}

Service,Service中调用上一个接口用到的list方法,就是动态sql查询购物车,但是由于只传入了一个userId,所以可以查询到该用户的所有购物车数据

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 根据userId查询购物车
*
* @return
*/
@Override
public List<ShoppingCart> getByUserId() {
// 获取到当前用户的id
Long userId = BaseContextByMe.getCurrentId();
ShoppingCart shoppingCart = ShoppingCart.builder().userId(userId).build();
List<ShoppingCart> list = shoppingCartMapper.list(shoppingCart);
return list;
}

清空购物车

Controller,这个就更简单了,甚至不需要前端传来任何的数据,只需要从线程中取出userId即可

1
2
3
4
5
6
@ApiOperation("清空购物车")
@DeleteMapping("/clean")
public Result cleanCart() {
shoppingCartService.cleanCart();
return Result.success();
}

Service

1
2
3
4
5
6
@Override
public void cleanCart() {
// 获取到当前用户的id 然后根据用户的id,删除该用户的所有购物车数据
Long userId = BaseContextByMe.getCurrentId();
shoppingCartMapper.deleteByUserId(userId);
}

删除一项

Controller,依然是使用shoppingCartDTO来实现传参

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 传来的数据其实和添加差不多
*
* @param shoppingCartDTO
* @return
*/
@ApiOperation("删除购物车的一个商品")
@PostMapping("/sub")
public Result subOne(@RequestBody ShoppingCartDTO shoppingCartDTO) {
log.info("删除购物车的一个商品:{}", shoppingCartDTO);
shoppingCartService.subShoppingCart(shoppingCartDTO);
return Result.success();
}

Service,也没啥难得,就是要判断这条数据的number,如果大于1则是–,如果为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
/**
* 删除购物车的一个商品
*
* @param shoppingCartDTO
*/
@Override
public void subShoppingCart(ShoppingCartDTO shoppingCartDTO) {

// 这里涉及到数量问题,如果这个商品的数量减去1还是大于1,那么执行删除只是number-1,而如果为1,那么就是直接删除这条数据了
ShoppingCart shoppingCart = new ShoppingCart();
BeanUtils.copyProperties(shoppingCartDTO, shoppingCart);

Long userId = BaseContextByMe.getCurrentId();
shoppingCart.setUserId(userId);
// 这样肯定还是一条数据 毋庸置疑
List<ShoppingCart> list = shoppingCartMapper.list(shoppingCart);

// 漏加了判断这个list集合是否为空的问题了
if (list != null && list.size() > 0) {
ShoppingCart cart = list.get(0);
// 判断number
if (cart.getNumber() == 1) {
// 为1,则直接删除这条记录
shoppingCartMapper.deleteByCartId(cart);
} else {
// 大于1,则number-1
cart.setNumber(cart.getNumber() - 1);
shoppingCartMapper.updateNumberById(cart);
}
}
}

测试也没问题啊没问题

9 用户下单-订单支付

导入地址簿

?

看看接口敲一遍得了

保存地址簿

新增地址

Controller

1
2
3
4
5
6
7
@ApiOperation("新增地址")
@PostMapping
public Result add(@RequestBody AddressBook addressBook) {
log.info("新增地址:{}", addressBook);
addressBookService.add(addressBook);
return Result.success();
}

Service,主要就是注意这里的业务逻辑-设置是否默认-0,还有设置用户id

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 添加地址簿数据
*
* @param addressBook
*/
@Override
public void add(AddressBook addressBook) {
// 不出所料,userId前端并没有传过来,还是要自己从ThreadLocal中获取
Long userId = BaseContextByMe.getCurrentId();
// 忘了设置这个状态了
addressBook.setIsDefault(0);
addressBook.setUserId(userId);
addressBookMapper.insert(addressBook);
}

Mapper

1
2
3
4
5
6
@Insert("insert into address_book(user_id, consignee, sex, phone, province_code, province_name, city_code, " +
"city_name, district_code, district_name, detail, label,is_default) VALUES (#{userId},#{consignee}," +
"#{sex},#{phone}," +
"#{provinceCode},#{provinceName},#{cityCode},#{cityName},#{districtCode},#{districtName},#{detail}," +
"#{label},#{isDefault})")
void insert(AddressBook addressBook);

地址簿列表

查询当前登录用户的所有地址信息

Controller

1
2
3
4
5
6
@ApiOperation("查询登录用户的所有地址")
@GetMapping("/list")
public Result<List<AddressBook>> getByUserId() {
List<AddressBook> list = addressBookService.list();
return Result.success(list);
}

Service

1
2
3
4
5
6
7
8
9
10
11
/**
* 查询用户的所有地址
*
* @return
*/
@Override
public List<AddressBook> list() {
Long userId = BaseContextByMe.getCurrentId();
List<AddressBook> list = addressBookMapper.list(userId);
return list;
}

Mapper

1
2
@Select("select * from address_book where user_id = #{userId}")
List<AddressBook> list(Long userId);

修改地址簿

  1. 根据id查询地址

    Controller

    1
    2
    3
    4
    5
    6
    @ApiOperation("根据id查询地址")
    @GetMapping("/{id}")
    public Result<AddressBook> getById(@PathVariable Long id) {
    AddressBook addressBook = addressBookService.getById(id);
    return Result.success(addressBook);
    }

Service

1
2
3
4
5
6
7
8
9
10
11
/**
* 根据id查询一条地址信息
*
* @param id
* @return
*/
@Override
public AddressBook getById(Long id) {
AddressBook addressBook = addressBookMapper.getById(id);
return addressBook;
}

Mapper

1
2
@Select("select * from address_book where id = #{id}")
AddressBook getById(Long id);
  1. 修改

    Controller

    1
    2
    3
    4
    5
    6
    7
    @ApiOperation("修改地址")
    @PutMapping
    public Result update(@RequestBody AddressBook addressBook) {
    log.info("修改地址:{}", addressBook);
    addressBookService.update(addressBook);
    return Result.success();
    }

Service

1
2
3
4
5
6
7
8
9
/**
* 修改地址
*
* @param addressBook
*/
@Override
public void update(AddressBook addressBook) {
addressBookMapper.update(addressBook);
}

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
40
41
42
<update id="update">
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="provinceCode != null">
province_code = #{provinceCode},
</if>
<if test="provinceName != null">
province_name = #{provinceName},
</if>
<if test="cityCode != null">
city_code = #{cityCode},
</if>
<if test="cityName != null">
city_name = #{cityName},
</if>
<if test="districtCode != null">
district_code = #{districtCode},
</if>
<if test="districtName != null">
district_name = #{districtName},
</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>

设置默认地址

Controller

1
2
3
4
5
6
7
@ApiOperation("设置默认地址")
@PutMapping("/default")
public Result setDefault(@RequestBody AddressBook addressBook){
// 这个倒是用了请求体的id了
addressBookService.setDefault(addressBook);
return Result.success();
}

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
/**
* 设置默认地址
*/
@Override
public void setDefault(AddressBook addressBook) {
/* Long id = addressBook.getId();
addressBookMapper.setDefault(id);
// 只设置这个默认的可不行,还要把其余的地址全都设置成不是默认的
// 根据用户id查出该用户的所有地址
List<AddressBook> list = addressBookMapper.list(BaseContextByMe.getCurrentId());
for (AddressBook book : list) {
if (book.getId() == id) {
continue;
}
// 不是要设置默认的地址,就要将该地址的默认设为0
book.setIsDefault(0);
// 然后将该条数据更新数据库
addressBookMapper.update(book);
}*/

// =====================

// 或者说,老师的逻辑是更清楚地,先将该用户的所有地址都设置成不是默认,然后再设置指定地址为默认
addressBookMapper.updateAllAddressByUserId(BaseContextByMe.getCurrentId());

// 然后设置默认
addressBookMapper.setDefault(addressBook.getId());
}

查询默认地址

Controller

1
2
3
4
5
6
@ApiOperation("查询默认地址")
@GetMapping("/default")
public Result<AddressBook> getDefault() {
AddressBook addressBook = addressBookService.getDefault();
return Result.success(addressBook);
}

Service,这里也是和老师写的不太一样的地方,老师是查不到直接返回查不到的信息,我这里是查不到就默认第一个

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 查询默认地址
* 不知道这个逻辑对不对
*
* @return
*/
@Override
public AddressBook getDefault() {
Long userId = BaseContextByMe.getCurrentId();

// 获取该用户的默认地址
// select * from address_book where is_default = 1
AddressBook addressBook = addressBookMapper.getDefault(userId);
if (addressBook == null) {
// 如果没有默认地址怎么办?
List<AddressBook> list = addressBookMapper.list(userId);
// 查询所有的地址,然后选择第一个?
addressBook = list.get(0);
}
// 不管是否存在默认地址,都可以直接return
// 如果存在,则不会进入if,不存在,进入if后该addressBook也已经被赋值
return addressBook;
}

删除地址簿

根据id删除地址

这个就没什么说的了,直接删就ok

1
2
3
4
5
6
@ApiOperation("根据id删除地址")
@DeleteMapping
public Result deleteById(Long id) {
addressBookService.deleteById(id);
return Result.success();
}

用户下单

用户下单业务说明
在电商系统中,用户是通过下单的方式通知商家,用户已经购买了商品,需要商家进行备货和发货

用户下单后会产生订单相关数据,订单数据需要能够体现如下信息

  1. 买的商品和数量
  2. 收货地址
  3. 订单总金额
  4. 哪个用户下的单
  5. 手机号多少

用户下单流程:
20230908084554

订单相关的数据库设计
20230908085847

Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 用户下单
*
* @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,虽然代码多,但是并不难

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 ordersSubmitDTO
* @return
*/
@Override
@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 = BaseContextByMe.getCurrentId();
List<ShoppingCart> shoppingCartList = shoppingCartMapper.getByUserId(userId);
if (shoppingCartList == null || shoppingCartList.size() == 0) {
// 没有查到购物车数据,购物车为空
throw new ShoppingCartBusinessException(MessageConstant.SHOPPING_CART_IS_NULL);
}

// =================

// 向订单表插入一条数据
Orders orders = new Orders();
BeanUtils.copyProperties(ordersSubmitDTO, orders);

orders.setOrderTime(LocalDateTime.now());
orders.setPayStatus(Orders.UN_PAID);
// 设置订单状态为待付款
orders.setStatus(Orders.PENDING_PAYMENT);
orders.setNumber(String.valueOf(System.currentTimeMillis()));
orders.setPhone(addressBook.getPhone());
orders.setConsignee(addressBook.getConsignee());
orders.setUserId(userId);
// 插入之后会将主键返回
orderMapper.insert(orders);
// 向订单明细表插入n条数据 由购物车的数据决定

List<OrderDetail> orderDetailList = new ArrayList<>();

for (ShoppingCart shoppingCart : shoppingCartList) {
OrderDetail orderDetail = new OrderDetail();
// 将购物车的数据拷贝给orderDetail
BeanUtils.copyProperties(shoppingCart, orderDetail);
orderDetail.setOrderId(orders.getId());
orderDetailList.add(orderDetail);
}

// 批量插入订单详情数据
orderDetailMapper.insert(orderDetailList);

// 清空购物车
shoppingCartMapper.deleteByUserId(userId);

// 封装返回结果
OrderSubmitVO orderSubmitVO =
OrderSubmitVO.builder().id(orders.getId()).orderTime(orders.getOrderTime()).orderNumber(orders.getNumber()).orderAmount(orders.getAmount()).build();

return orderSubmitVO;
}

Mapper,整体都不难

1
2
3
4
5
6
7
8
9
10
11
<!--    插入订单数据<,且要返回订单的id-->
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into orders(number, status, user_id, address_book_id, order_time, checkout_time, pay_method, pay_status,
amount, remark, phone, address, user_name, consignee, cancel_reason, rejection_reason,
cancel_time, estimated_delivery_time, delivery_status, delivery_time, pack_amount,
tableware_number, tableware_status)
VALUES (#{number}, #{status}, #{userId}, #{addressBookId}, #{orderTime}, #{checkoutTime}, #{payMethod},
#{payStatus}, #{amount}, #{remark}, #{phone}, #{address}, #{userName}, #{consignee}, #{cancelReason},
#{rejectionReason}, #{cancelTime}, #{estimatedDeliveryTime}, #{deliveryStatus}, #{deliveryTime},
#{packAmount}, #{tablewareNumber}, #{tablewareStatus})
</insert>

订单支付

这个支付是实现不了的,但是可以了解流程,阅读文档

微信支付介绍

整体的流程还是挺复杂的.
20230908132800

JSPI下单:商户系统调用该接口在微信支付服务后台生成预支付交易单

这块是真做不了,直接点击支付就支付成功得了

内网穿透

不管别的,先把内网穿透啥的搞好再说,之前用的是linux版的,这次试试win版

打开软件安装目录
20230908135603

没啥了,直接执行cpolar.exe http 8080,就能给本机的8080端口一个公网的域名

能够直接进行访问接口
20230908140309

代码还导入吗?

不用了吧,导入了别到时候全G了

前端发请求就直接返回success,然后改变支付状态得了

看一下代码,然后了解一下流程即可

哈哈哈哈哈哈,以前的感觉又回来了…

?

还是有问题,现在前端需要数据,但是那些数据根本获取不到

要不就是直接更改订单状态?

试试

好了,反正现在能改变订单的状态了,其他就不管了

10 订单管理

这个模块又是自己写的

要完成用户端历史订单模块,商家端订单管理模块

还要对之前的功能进行优化

用户端历史订单模块

  1. 查询历史订单

Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 订单分页查询
*
* @param ordersPageQueryDTO
* @return
*/
@GetMapping("/historyOrders")
@ApiOperation("历史订单分页查询")
public Result<PageResult> getHistoryOrders(OrdersPageQueryDTO ordersPageQueryDTO) {
log.info("订单分页查询");
PageResult pageResult = orderService.pageQuery(ordersPageQueryDTO);
return Result.success(pageResult);
}

Service,这里就比较奇怪了,当时没看明白OrderVO是干嘛的,后来才发现是继承的Orders,可以简单理解为OrderVO是order和orderDetail的结合体!

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 ordersPageQueryDTO
* @return
*/
@Override
public PageResult pageQuery(OrdersPageQueryDTO ordersPageQueryDTO) {

PageHelper.startPage(ordersPageQueryDTO.getPage(), ordersPageQueryDTO.getPageSize());
ordersPageQueryDTO.setUserId(BaseContextByMe.getCurrentId());

Page<Orders> page = orderMapper.list(ordersPageQueryDTO);

List<OrderVO> list = new ArrayList<>();

// 问题出在没有查出来订单明细,这个要封装到OrderVo中才行
if (page != null && page.size() > 0) {
for (Orders orders : page) {
// 根据订单id查询订单明细
Long ordersId = orders.getId();
List<OrderDetail> orderDetailList = orderDetailMapper.getByOrderId(ordersId);
OrderVO orderVO = new OrderVO();

// 我不太明白这一步是干什么?明明orderVO只能两个字段,还有一个字符串,属性名和orders里还没有对应上的.
// 奶奶滴,原来这里是继承!!!!!
BeanUtils.copyProperties(orders, orderVO);
orderVO.setOrderDetailList(orderDetailList);
list.add(orderVO);
}
}

return new PageResult(page.getTotal(), list);
}
  1. 查询订单详情

Controller

1
2
3
4
5
6
7
8
9
@GetMapping("/orderDetail/{id}")
@ApiOperation("订单详情")
public Result<OrderVO> getOrderDetail(@PathVariable Long id) {

// 传来的是orderId,要根据orderId先在order表中查到订单的基本信息,然后在order_detail表中查到详细信息,也就是菜品啥的

OrderVO orderVO = orderService.getOrderDetail(id);
return Result.success(orderVO);
}

Service,这里更能看出,orderVO是继承的orders

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public OrderVO getOrderDetail(Long id) {

Orders orders = orderMapper.getById(id);
OrderVO orderVO = new OrderVO();
BeanUtils.copyProperties(orders, orderVO);
// 将查询到的Order信息赋值给OrderVO,因为OrderVO中可以封装订单详情
List<OrderDetail> orderDetailList = orderDetailMapper.getByOrderId(id);
orderVO.setOrderDetailList(orderDetailList);
return orderVO;

}
  1. 取消订单

Controller

1
2
3
4
5
6
7
@ApiOperation("取消订单")
@PutMapping("/cancel/{id}")
public Result cancel(@PathVariable Long id) {
log.info("取消订单id为:{}", id);
orderService.userCancelById(id);
return Result.success();
}

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
/**
* 用户取消订单
*
* @param id
*/
@Override
public void userCancelById(Long id) {

// 所以要先判断 当前订单的状态-根据订单id来查询
Orders orders = orderMapper.getById(id);

// 先判断订单是否为空
if (orders == null) {
throw new OrderBusinessException(MessageConstant.ORDER_NOT_FOUND);
}

// 然后就是判断状态了,这里直接判断状态是否大于2,如果大于2就不让取消订单,虽然理应是协商是否能退
if (orders.getStatus() > 2) {
throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR);
}

// 剩下的情况就是正常了,能够退款 1 待支付 2 待接单

// 退款的接口肯定写不了了,就放着吧

// 待支付和待接单状态下,用户可直接取消订单
Orders cancelOrder =
Orders.builder().id(id).status(Orders.CANCELLED)
.cancelReason("用户取消").cancelTime(LocalDateTime.now()).build();

orderMapper.update(cancelOrder);
}
  1. 再来一单

Controller

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 再来一单就是将原订单中的商品重新加入到购物车中
*
* @return
*/
@PostMapping("/repetition/{id}")
@ApiOperation("再来一单")
public Result repetitionOrder(@PathVariable Long id) {
log.info("要重新下单的订单id:{}", id);
orderService.repetitionOrder(id);
return Result.success();
}

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
/**
* 再次下单
*
* @param id
*/
@Override
public void repetitionOrder(Long id) {

// 那整体步骤就是
// 1.根据订单id查询订单详情
// 2.将订单详情的每一项都赋值给一个购物车,然后将该购物车数据加入数据库

List<OrderDetail> orderDetailList = orderDetailMapper.getByOrderId(id);

// 有了订单详情,也有了订单的基本信息
for (OrderDetail orderDetail : orderDetailList) {
ShoppingCart shoppingCart = new ShoppingCart();
BeanUtils.copyProperties(orderDetail, shoppingCart);
shoppingCart.setUserId(BaseContextByMe.getCurrentId());
shoppingCart.setCreateTime(LocalDateTime.now());
shoppingCartMapper.insert(shoppingCart);
}

}

用户端结束

商家端订单管理模块

  1. 订单搜索

或者说是管理端

  • 业务规则
    1. 输入订单号/手机号进行搜索,支持模糊搜索
    2. 根据订单状态搜索
    3. 下单时间进行时间筛选
    4. 搜索内容为空,提示未找到相关订单
    5. 搜索结果页,展示包含搜索关键词的内容
    6. 分页展示搜索到的订单数据

Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 订单搜索,可以根据
* 订单号/手机号进行搜索,支持模糊搜索
* 根据订单状态搜索
* 下单时间进行时间筛选
* 下单时间进行时间筛选
*
* @param ordersPageQueryDTO
* @return
*/
@GetMapping("/conditionSearch")
@ApiOperation("订单搜索")
public Result<PageResult> conditionSearchOrder(OrdersPageQueryDTO ordersPageQueryDTO) {
PageResult pageResult = orderService.conditionSearch(ordersPageQueryDTO);
return Result.success(pageResult);
}

Service,这个Service主要还是封装OrderVO信息,以及拼接菜品的信息,比如娃娃菜*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
41
42
43
/**
* 管理端订单分页查询
*
* @param ordersPageQueryDTO
* @return
*/
@Override
public PageResult conditionSearch(OrdersPageQueryDTO ordersPageQueryDTO) {
// 开启分页
PageHelper.startPage(ordersPageQueryDTO.getPage(), ordersPageQueryDTO.getPageSize());

// 这个应该就不需要用户id了,直接查看所有用户的

Page<Orders> page = orderMapper.list(ordersPageQueryDTO);

// 还要再封装 订单详情的信息 这里的page是所有的订单!?

List<OrderVO> list = new ArrayList<>();

if (page != null && page.size() > 0) {
for (Orders orders : page) {
// 拿到订单id,根据订单id查询订单详情
OrderVO orderVO = new OrderVO();
BeanUtils.copyProperties(orders, orderVO);
// 这个要拼接一下
orderVO.setOrderDishes(getOrderDishes(orders));
list.add(orderVO);
}
}
return new PageResult(page.getTotal(), list);
}


private String getOrderDishes(Orders orders) {
List<OrderDetail> orderDetails = orderDetailMapper.getByOrderId(orders.getId());
// 将每一条菜品的信息拼接成一个字符串
StringBuffer stringBuffer = new StringBuffer();
for (OrderDetail orderDetail : orderDetails) {
String orderDish = orderDetail.getName() + "*" + orderDetail.getNumber() + ";";
stringBuffer.append(orderDish);
}
return stringBuffer.toString();
}
  1. 各个状态的订单数量统计

Controller

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 各个状态的订单数量统计
* 待接单,待派送,派送中
*
* @return
*/
@GetMapping("/statistics")
@ApiOperation("各个状态的订单数量统计")
public Result<OrderStatisticsVO> statistics() {
OrderStatisticsVO orderStatisticsVO = orderService.statistics();
return Result.success(orderStatisticsVO);
}

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
43
44
45
46
47
/**
* 各个状态的订单数量统计
* 待接单,待派送,派送中
*
* @return
*/
@Override
public OrderStatisticsVO statistics() {
/*OrderStatisticsVO orderStatisticsVO = new OrderStatisticsVO();
orderStatisticsVO.setToBeConfirmed(0);
orderStatisticsVO.setConfirmed(0);
orderStatisticsVO.setDeliveryInProgress(0);
// 查询所有的订单
List<Orders> list = orderMapper.getAllOrders();

// 遍历整个订单集合
for (Orders orders : list) {
Integer status = orders.getStatus();
if (status == Orders.TO_BE_CONFIRMED) {
orderStatisticsVO.setToBeConfirmed(orderStatisticsVO.getToBeConfirmed() + 1);
}
if (status == Orders.CONFIRMED) {
orderStatisticsVO.setConfirmed(orderStatisticsVO.getConfirmed() + 1);
}
if (status == Orders.DELIVERY_IN_PROGRESS) {
orderStatisticsVO.setDeliveryInProgress(orderStatisticsVO.getDeliveryInProgress() + 1);
}
}

return orderStatisticsVO;*/

//=================

// 上面的代码太麻烦了
// 根据状态-分别查询出待接单,带派送,派送中的订单数量
Integer toBeConfirmed = orderMapper.countStatus(Orders.TO_BE_CONFIRMED);
Integer confirmed = orderMapper.countStatus(Orders.CONFIRMED);
Integer deliveryInProgress = orderMapper.countStatus(Orders.DELIVERY_IN_PROGRESS);

// 封装数据
OrderStatisticsVO orderStatisticsVO = new OrderStatisticsVO();
orderStatisticsVO.setToBeConfirmed(toBeConfirmed);
orderStatisticsVO.setConfirmed(confirmed);
orderStatisticsVO.setDeliveryInProgress(deliveryInProgress);

return orderStatisticsVO;
}

Mapper

1
2
3
4
5
6
7
/**
* 根据状态查询数量
* @param toBeConfirmed
* @return
*/
@Select("select count(id) from orders where status = #{status} ")
Integer countStatus(Integer toBeConfirmed);
  1. 查询订单详情

业务规则
- 订单详情页面需要展示订单基本信息(状态,订单号,下单时间,收货人,电话,收货地址,金额等)
- 订单详情页面需要展示订单明细数据(商品名称,数量,单价)

Controller,这个是之前用户端的业务,直接调用对应的service即可

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 查询订单详情
*
* @param id
* @return
*/
@GetMapping("/details/{id}")
@ApiOperation("查询订单详情")
public Result<OrderVO> details(@PathVariable Long id) {
OrderVO orderVO = orderService.getOrderDetail(id);
return Result.success(orderVO);
}
  1. 接单

我是没想到这个id竟然还用一个DTO来封装

Controller

1
2
3
4
5
6
@PutMapping("/confirm")
@ApiOperation("接单")
public Result confirm(@RequestBody OrdersConfirmDTO ordersConfirmDTO) {
orderService.confirm(ordersConfirmDTO);
return Result.success();
}

Service,从这一步就能看出来,根本不需要封装这个DTO,直接用id就可以了.

1
2
3
4
5
6
7
8
9
10
/**
* 接单
*/
@Override
public void confirm(OrdersConfirmDTO ordersConfirmDTO) {
// 设置id和状态为已接单
Orders orders = Orders.builder().id(ordersConfirmDTO.getId()).status(Orders.CONFIRMED).build();
// 调用之前的动态sql来修改
orderMapper.update(orders);
}
  1. 拒单

这个又忘了所谓的业务规则了
- 商家拒单就是将订单状态设置为已取消
- 只有当订单处于待接单的状态是可以执行拒单操作
- 需要指定原因
- 如果已完成支付,要退款?

Controller

1
2
3
4
5
6
7
@PutMapping("/rejection")
@ApiOperation("拒单")
public Result rejection(@RequestBody OrdersRejectionDTO ordersRejectionDTO) {
// 这个用DTO我能理解,因为还有一个拒单原因呢
orderService.rejection(ordersRejectionDTO);
return Result.success();
}

Service 这个跟老师写的也不一样,老师写的还要判断状态什么的,然后还要进行退款..

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 拒单
*
* @param ordersRejectionDTO
*/
@Override
public void rejection(OrdersRejectionDTO ordersRejectionDTO) {
// 其实就是设置状态为已取消
// 还要先判断是否订单已支付
Integer status = orderMapper.getById(ordersRejectionDTO.getId()).getStatus();
log.info("当前状态为:{}", status);
Orders orders =
Orders.builder().id(ordersRejectionDTO.getId()).status(Orders.CANCELLED).cancelTime(LocalDateTime.now())
.rejectionReason(ordersRejectionDTO.getRejectionReason()).build();
orderMapper.update(orders);
}
  1. 取消订单

取消订单,就是设置订单状态为已取消
商家取消订单需要指定取消原因
如果用户已经付款,则要进行退款

Controller

1
2
3
4
5
6
@PutMapping("/cancel")
@ApiOperation("取消订单")
public Result cancel(@RequestBody OrdersCancelDTO ordersCancelDTO) {
orderService.adminCancel(ordersCancelDTO);
return Result.success();
}

Service,忘了还有一个payStatus字段了,可以根据这个字段来进行判断是否已经支付

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public void adminCancel(OrdersCancelDTO ordersCancelDTO) {

// 退款的代码依然不用写 那也写一下判断吧
Orders ordersDB = orderMapper.getById(ordersCancelDTO.getId());
if (ordersDB.getPayStatus() == 1) {
log.info("退款中....");
}


Orders orders =
Orders.builder().id(ordersCancelDTO.getId()).cancelTime(LocalDateTime.now())
.cancelReason(ordersCancelDTO.getCancelReason()).status(Orders.CANCELLED).build();

orderMapper.update(orders);
}
  1. 派送订单

业务规则
- 派送订单其实就是将订单状态修改为“派送中”
- 只有状态为“待派送”的订单可以执行派送订单操作

Controller

1
2
3
4
5
6
7
@PutMapping("/delivery/{id}")
@ApiOperation("派送订单")
public Result delivery(@PathVariable Long id) {
log.info("派送订单的id:{}", id);
orderService.delivery(id);
return Result.success();
}

Service,主要就是注意状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 派送订单
*
* @param id
*/
@Override
public void delivery(Long id) {

// 判断状态
if (orderMapper.getById(id).getStatus() != Orders.CONFIRMED) {
// 只有这个已接单的状态才能派送
throw new OrderBusinessException("订单状态错误");
}

// 修改派送的订单的订单状态
Orders orders = Orders.builder().id(id).status(Orders.DELIVERY_IN_PROGRESS).build();
orderMapper.update(orders);
}
  1. 完成订单

Controller

1
2
3
4
5
6
7
@PutMapping("/complete/{id}")
@ApiOperation("完成订单")
public Result complete(@PathVariable Long id){
orderService.complete(id);
return Result.success();
}

Service,注意状态即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 完成订单
*
* @param id
*/
@Override
public void complete(Long id) {
Orders orderDB = orderMapper.getById(id);
// 只有派送中的订单才能完成
if (orderDB == null || orderDB.getStatus() != Orders.DELIVERY_IN_PROGRESS) {
throw new OrderBusinessException("订单状态错误");
}
Orders orders = Orders.builder().id(id).status(Orders.COMPLETED).build();
orderMapper.update(orders);
}

接口这块是完成了…

还有优化啥的,看看怎么做

百度地图AK:cXa9RUd5ZOkF7zcvBxAXFGm12PRLVGvP

配置文件:

1
2
3
4
shop:
address: 北京市海淀区上地十街10号
baidu:
ak: cXa9RUd5ZOkF7zcvBxAXFGm12PRLVGvP

在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
64
65
66
67
68
69
70
@Value("${sky.shop.address}")
private String shopAddress;

@Value("${sky.baidu.ak}")
private String ak;

/**
* 检查客户的收货地址是否超出配送范围
*
* @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("超出配送范围");
}
}

在用户下单的地方加入

1
2
// 检查用户的收货地址是否超出配送范围
checkOutOfRange(addressBook.getCityName() + addressBook.getDistrictName() + addressBook.getDetail());

那么这段代码,需不需要记下来呢?

嗯,先到这吧

11 订单状态定时处理-来单提醒和客户催单

两种提示

  1. 支付超时的订单如何处理
  2. 派送中的订单一直不点击完成如何处理

Spring Task

消息?

Spring Task是Spring框架提供的任务调度工具,可以按照约定的时间自动执行某个代码逻辑

定时任务框架

作用:定时自动执行某段代码

cron表达式

cron表达式其实就是一个字符串,通过cron表达式可以定义任务触发的时间

构成规则:分成6或7个域,由空格分隔开,每个域代表一个含义

每个域的含义分别为:秒,分钟,小时,日,月,周,年(可选)

描述一个时间:2022年10月12日上午9点整,对应的cron表达式为
20230910185659
日和周只能写一个

但是有些表达式是不能直接描述的,需要一些符号

所以可以使用corn在线生成器

入门案例

  1. 导入maven坐标:spring-context(已存在)
  2. 启动类上添加注解:@EnableScheduling开启任务调度
  3. 自定义定时任务类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* @author zzmr
* @create 2023-09-10 19:37
* 自定义定时任务类
*/
@Component
@Slf4j
public class MyTask {

/**
* 从第0秒开始,每5秒执行一次
*/
@Scheduled(cron = "0/5 * * * * ?")
public void executeTask() {
log.info("定时任务开始执行:{}", new Date());
}

}

就会每隔5秒自动调用一次

20230910194231

订单状态定时处理

用户下单后可能存在的情况

  1. 下单后未支付,订单一直处于待支付状态
  2. 用户收货后管理端未点完成按钮,订单一直处于派送中状态

对于上面两种情况需要通过定时任务来修改订单状态,具体逻辑为

  • 通过定时任务每分钟检查一次是否存在支付超时订单(下单后超过15分钟仍未支付则判定为支付超时订单),如果存在则修改订单状态为已取消
  • 通过定时任务每天凌晨1点检查一次是否存在派送中的订单,如果存在则修改订单状态为已完成

OrderTask.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
package com.sky.task;

import com.sky.entity.Orders;
import com.sky.mapper.OrderMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.List;

/**
* @author zzmr
* @create 2023-09-10 19:56
*/
@Component
@Slf4j
public class OrderTask {

@Autowired
private OrderMapper orderMapper;

/**
* 处理超时订单的方法
* 每分钟执行一次
*/
@Scheduled(cron = "0 * * * * ? ")
public void processTimeoutOrder() {
log.info("定时处理超时订单:{}", LocalDateTime.now());

// time这个,老师用的是plusMinutes(-15),那为什么不直接用minus呢?

// 查询超时订单 select * from orders where status = ? and order_time < (当前时间-15分钟)
List<Orders> ordersList = orderMapper.getByStatusAndOrderTimeLT(Orders.PENDING_PAYMENT,
LocalDateTime.now().minusMinutes(15));

if (ordersList != null && ordersList.size() > 0) {
for (Orders orders : ordersList) {
// 设置状态为取消
orders.setStatus(Orders.CANCELLED);
orders.setCancelReason("订单超时,自动取消");
orders.setCancelTime(LocalDateTime.now());
orderMapper.update(orders);
}
}
}

/**
* 每天凌晨一点触发
* 处理一直处于派送中的订单-自动完成
*/
@Scheduled(cron = "0 0 1 * * ?")
public void processDeliveryOrder() {
log.info("定时处理一直处于派送中的订单:{}", LocalDateTime.now());

// 当前时间 一点,减去一个小时,得到就是0点,可以用来整理昨天的订单
List<Orders> ordersList = orderMapper.getByStatusAndOrderTimeLT(Orders.DELIVERY_IN_PROGRESS,
LocalDateTime.now().minusHours(1));

if (ordersList != null && ordersList.size() > 0) {
for (Orders orders : ordersList) {
// 设置状态为取消
orders.setStatus(Orders.COMPLETED);
orderMapper.update(orders);
}
}
}

}

Mapper

1
2
3
4
5
6
7
8
9
10
/**
* 根据订单状态和下单时间查询
* 这里传入的orderTime是已经处理过的.
*
* @param status
* @param orderTime
* @return
*/
@Select("select * from orders where status = #{status} and order_time < #{orderTime}")
List<Orders> getByStatusAndOrderTimeLT(Integer status, LocalDateTime orderTime);

测试也没问题啊没问题

WebSocket

WebSocket是基于TCP的一种新的网络协议,它实现了浏览器与服务器全双工通信-浏览器和服务器只需要完成一次握手,两者之间就可以创建持久性连接,并进行双向数据传输

那这个WebSocket和Http协议有什么区别呢?
20230910203637
20230910203650

哦,原来是长连接?握一次手,然后可以双向通讯,也就是说服务器可以主动请求浏览器?

底层二者都是TCP连接

案例

实现步骤

  1. maven依赖
    1
    2
    3
    4
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>
  2. 直接使用websocket.html页面作为WebSocket客户端
    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>
  3. 导入WebSocket服务端组件WebSocketServer,用于和客户端通讯
    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
    package com.sky.websocket;

    import org.springframework.stereotype.Component;
    import javax.websocket.OnClose;
    import javax.websocket.OnMessage;
    import javax.websocket.OnOpen;
    import javax.websocket.Session;
    import javax.websocket.server.PathParam;
    import javax.websocket.server.ServerEndpoint;
    import java.util.Collection;
    import java.util.HashMap;
    import java.util.Map;

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

    }
  4. 导入配置类WebSocketConfiguration,注册WebSocket的服务端组件
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    package com.sky.config;

    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.web.socket.server.standard.ServerEndpointExporter;

    /**
    * WebSocket配置类,用于注册WebSocket的Bean
    */
    @Configuration
    public class WebSocketConfiguration {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
    return new ServerEndpointExporter();
    }

    }
  5. 导入定时任务类WebSocketTask,定时向客户端推送数据
    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()));
    }
    }

简历-投简历

来单提醒

用户下单并且支付成功后,需要第一时间通知外卖商家,通知的形式有如下两种

  1. 语音播报
  2. 弹出提示框

就是服务端通知客户端

  1. 通过WebSocket实现管理端页面和服务端保持长连接状态
  2. 当客户支付后,调用WebSocket相关的API实现服务端向客户端推送消息
  3. 客户端浏览器解析服务端推送的消息,判断是来单提醒还是客户端催单,进行相应的消息提示和语音播报
  4. 约定服务端发送给客户端浏览器的数据格式为JSON,字段包括:type,orderId,contetn
    • type为消息类型,1为来单提醒,2为客户催单
    • orderId为订单id
    • content为消息内容

在导入上面的WebSocketServer后,这时运行前端就已经可以进行WebSocket连接了
20230911202207
20230911202225

但是有没有发现,前端请求是ws://localhost/ws/nigh7mb27z,也就是80端口,而后端相当于是ws://localhost:8080/ws/{sid}

这里就是Nginx的反向代理:

1
2
3
4
5
6
7
8
# WebSocket
location /ws/ {
proxy_pass http://webservers/ws/;
proxy_http_version 1.1;
proxy_read_timeout 3600s;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "$connection_upgrade";
}

当前端服务请求到nginx上的ws://localhost/ws/xxx就会转发到后端服务器上的ws://localhost:8080/ws/xxx


这里又要和老师写的不太一样了,因为这个老师是将这个提醒放在了支付成功的回调中,而这个回调是做不了的,所以我直接放到自己写的模拟支付的接口中就行了

更改OrderServiceImplpaymentWithNoMoney,就是在下面加上几行代码就行了

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
/**
* 模拟的支付,直接支付成功就完了
*
* @param ordersPaymentDTO
*/
@Override
public void paymentWithNoMoney(OrdersPaymentDTO ordersPaymentDTO) {
// 获取到订单号
String orderNumber = ordersPaymentDTO.getOrderNumber();

// 可能要加个付款时间
LocalDateTime checkoutTime = LocalDateTime.now();

// 直接修改订单状态
orderMapper.changeStatusAndCheckoutTime(orderNumber, checkoutTime);


// 要获取个订单id才行,根据订单号获取订单id
Orders orders = orderMapper.getByNumber(ordersPaymentDTO.getOrderNumber());

// 通过webSocket向客户端浏览器推送消息 type,orderId,content
Map map = new HashMap<>();
map.put("type", 1); // 1表示来单提醒
map.put("orderId", orders.getId());
map.put("content", "订单号:" + orderNumber);
String json = JSON.toJSONString(map);
webSocketServer.sendToAllClient(json);

}

效果没问题
20230911204259
20230911205147

如果不点击提示,就会一直响!

客户催单

Controller

1
2
3
4
5
6
7
@GetMapping("/reminder/{id}")
@ApiOperation("用户催单")
public Result reminder(@PathVariable Long id) {
// 用户催单,服务端接收到催单请求,然后再提醒浏览器
orderService.reminder(id);
return Result.success();
}

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

/**
* 用户催单
*
* @param id
*/
@Override
public void reminder(Long id) {
// 先根据订单id查询到订单号
Orders orderDB = orderMapper.getById(id);

// 校验订单是否存在
if (orderDB == null) {
throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR);
}

String number = orderDB.getNumber();

// 封装返回信息
Map map = new HashMap();
map.put("type", 2);
map.put("orderId", id);
map.put("content", "用户催单:" + number);
webSocketServer.sendToAllClient(JSON.toJSONString(map));
}

总体来看也没啥难的.

没问题
20230911205850

12 数据统计

Apache ECharts

Apache ECharts是一款基于JS的数据可视化图表库,提供直观,生动,可交互,可个性化定制的数据可视化图表

官网

直接看快速开始就知道怎么简单的使用了

重点在于研究某个图表所需要的数据格式,通常是需要后端提供符合格式要求的动态数据,然后相应给前端来展示图标

嗯..好像可以用这个echarts改进一下那个脱裤子放屁的东西

营业额统计

产品原型:一个折线图
20230911231332

所以就要获取每天的营业额,而日期要根据时间选择器来决定

业务规则

  • 营业额指订单状态为已完成的订单金额合计
  • 基于可视化报表的折线图展示营业额数据,x轴为日期,y轴为营业额
  • 根据时间选择区间,展示每天的营业额数据

前端提交开始日期和结束日期

返回的数据应包括x轴的日期和y轴的营业额数据

Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 营业额统计
*
* @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) {
log.info("营业额数据统计:{},{}", begin, end);
TurnoverReportVO reportVO = reportService.turnoverStatistics(begin, end);
return Result.success(reportVO);
}

Service,果然重头戏都在service中,这里涉及到日期的叠加,有LocalDateTime.of(LocalDate)这种类型转换,使用LocalTime.MAX来获取当天的最后一时刻,还有StringUtils.join(dateList, ",")来获取集合的每一项,然后用逗号分隔,拼成一个字符串

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
@Override
public TurnoverReportVO turnoverStatistics(LocalDate begin, LocalDate end) {

// 基于begin和end得到区间内的日期,可以无需查询数据库
List<LocalDate> dateList = new ArrayList<>(); // 当前集合用于存放从begin到end范围内的所有日期
dateList.add(begin);

/**
* 日期计算
*/
while (!begin.equals(end)) {
begin = begin.plusDays(1);
dateList.add(begin);
}

// 存放每天的营业额
List<Double> turnoverList = new ArrayList<>();
for (LocalDate localDate : dateList) {
// 遍历这个日期集合,然后根据每一天的日期查询数据库,返回营业额数据--- 状态为已完成的订单金额合计
// select sum(amount) from orders where status = ? and order_time > ? and order_time < ?
// 获取到的是该日期的0点0分0秒和23:59:59.999999999
LocalDateTime beginTime = LocalDateTime.of(localDate, LocalTime.MIN);
LocalDateTime endTime = LocalDateTime.of(localDate, LocalTime.MAX);
Map map = new HashMap();
map.put("begin", beginTime);
map.put("end", endTime);
map.put("status", Orders.COMPLETED);
Double turnover = orderMapper.sumByMap(map);
// 三元运算,判断是否为空,如果为空则赋值为0.0
turnover = turnover == null ? 0.0 : turnover;
turnoverList.add(turnover);
}

// 工具类,取出dateList集合中的每一项,然后以逗号分隔,得到一个String字符串
return TurnoverReportVO.builder()
.dateList(StringUtils.join(dateList, ","))
.turnoverList(StringUtils.join(turnoverList, ","))
.build();
}

Mapper

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>

响应结果:
20230912092915

就是数据有点少

用户统计

业务规则

  1. 基于可视化报表的折线图展示营业额数据,x轴为日期,y轴为用户数量
  2. 根据时间选择区间,展示每天的用户总量和新增用户量数据

这里的日期选择器是全局的,会同时改变营业额统计,用户统计,订单统计,销量排名Top10的数据
20230912095011

Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 用户统计
*
* @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) {
log.info("用户统计:{},{}", begin, end);
UserReportVO userReportVO = reportService.userStatistics(begin, end);
return Result.success(userReportVO);
}

Service,跟上面的唯一区别就是多了一个集合,然后封装集合的方式不太一样,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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* 用户统计
*
* @param begin
* @param end
* @return
*/
@Override
public UserReportVO userStatistics(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<>();
// 那新增的用户/用户总量怎么求呢
// select count(id) from user where create_time < end and createTime > begin
// select count(id) from user where create_time < end
for (LocalDate localDate : dateList) {
LocalDateTime beginTime = LocalDateTime.of(localDate, LocalTime.MIN);
LocalDateTime endTime = LocalDateTime.of(localDate, LocalTime.MAX);
Map map = new HashMap();
map.put("end", endTime);
totalUserList.add(userMapper.countByMap(map));
map.put("begin", beginTime);
newUserList.add(userMapper.countByMap(map));
}


return UserReportVO.builder()
.dateList(StringUtils.join(dateList, ","))
.newUserList(StringUtils.join(newUserList, ","))
.totalUserList(StringUtils.join(totalUserList, ","))
.build();
}

Mapper

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="end != null">
and create_time &lt; #{end}
</if>
<if test="begin != null">
and create_time &gt; #{begin}
</if>
</where>
</select>

订单统计

这几个接口说实话都不难.

产品原型:
20230912174101

业务规则

  1. 状态为已完成的是有效订单
  2. x轴依然是日期
  3. 根据时间选择区间,展示每天的订单总数和有效订单数
  4. 展示所选时间区间内的有效订单数,总订单数,订单完成率,订单完成率=有效订单数/总订单数*100%

提取了一个公用的方法,用来根据前端传来的日期区间来获取日期列表

1
2
3
4
5
6
7
8
9
10
private static List<LocalDate> getLocalDateList(LocalDate begin, LocalDate end) {
List<LocalDate> dateList = new ArrayList<>();
dateList.add(begin);

while (!begin.equals(end)) {
begin = begin.plusDays(1);
dateList.add(begin);
}
return dateList;
}

Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 订单统计
*
* @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) {
log.info("订单统计:{},{}", begin, end);
OrderReportVO orderReportVO = reportService.orderStatistics(begin, end);
return Result.success(orderReportVO);
}

Service,刚开始写的是没有提取出一个方法来查询数据库的,所以是自己封装map来实现的条件,而新的是定义一个方法,传入三个参数(begin,end,status),然后在方法中定义map,最后查询时能根据传入的参数进行动态查询,这样能少些好多行代码哎,而且总数量那两个是不要用查数据库的.

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
/**
* 订单统计
*
* @param begin
* @param end
* @return
*/
@Override
public OrderReportVO orderStatistics(LocalDate begin, LocalDate end) {

List<LocalDate> dateList = getLocalDateList(begin, end);

// 得到日期集合

// 要求的数据:有效订单总数 订单总数 来得到完成率 订单数列表 有效订单数列表
// select count(id) from orders where status = ? and order_time < ? and order_time >?

List<Integer> orderCountList = new ArrayList<>();
List<Integer> validOrderCountList = new ArrayList<>();

for (LocalDate localDate : dateList) {
// 根据日期,得到该日期的订单总数和有效订单数
LocalDateTime beginTime = LocalDateTime.of(localDate, LocalTime.MIN);
LocalDateTime endTime = LocalDateTime.of(localDate, LocalTime.MAX);
// 没有状态条件,查的是该日期的所有订单的数量
orderCountList.add(getOrderCount(beginTime, endTime, null));
// 查的是该日期的有效订单数量
validOrderCountList.add(getOrderCount(beginTime, endTime, Orders.COMPLETED));
}

/**
* 不查数据库,而去遍历集合,即可得到区间内的订单总数量和有效订单数量
* 使用Stream流更简单
*/
/*Integer totalOrderCount = 0;
for (Integer i : orderCountList) {
totalOrderCount += i;
}

Integer validOrderCount = 0;
for (Integer i : validOrderCountList) {
validOrderCount += i;
}*/
// stream流的形式,只需要两行
Integer totalOrderCount = orderCountList.stream().reduce(Integer::sum).get();
Integer validOrderCount = validOrderCountList.stream().reduce(Integer::sum).get();

// 订单完成率 这里又没注意到一个细节,就是总订单数为0的情况
Double orderCompletionRate = 0.0;
if (totalOrderCount > 0) {
orderCompletionRate = (validOrderCount / totalOrderCount.doubleValue());
}

return OrderReportVO.builder()
.dateList(StringUtils.join(dateList, ","))
.totalOrderCount(totalOrderCount)
.validOrderCount(validOrderCount)
.orderCountList(StringUtils.join(orderCountList, ","))
.validOrderCountList(StringUtils.join(validOrderCountList, ","))
.orderCompletionRate(orderCompletionRate)
.build();
}

private Integer getOrderCount(LocalDateTime beginTime, LocalDateTime endTime, Integer status) {
Map timeMap = new HashMap();
timeMap.put("begin", beginTime);
timeMap.put("end", endTime);
timeMap.put("status", status);
return orderMapper.countOrderByMap(timeMap);
}

销量排名Top10

销量排名,这个东西

业务规则

  1. 根据时间选择区间,展示销量前10的商品(包括菜品和套餐),这个和之前的时间选择也差不多
  2. 基于可视化报表的柱状图降序展示商品销量
  3. 此处的销量为商品销售的份数

销量怎么算?

order_detail表中根据dish_idsetmeal_id来算

这样的sql?select count(id) from order_detail where dish_id = ?

的确能查,但是这么多菜品呢,总不能全部查一遍吧?,而且要的是菜品的名字和数量

而且还不对,因为order_detail表中还有一个字段是number,表示数量

!而且,还有就是订单如果是未完成的,那么这个数据也需要进行筛选

最终的实现方式:通过一个多表联查,同时查order和order_detail两个表,查询名称和对应的数量,od.name,sum(od.number) number,然后通过group by进行分组,得到的是一个泛型为GoodsSalesDTO的集合,这个GoodsSalesDTO有两个字段,分别是String类型的name和Integer类型的number,刚好可以封装查出来的数据,又因为有很多的条,所以是一个集合,最后取出集合中的name和number,进行拼接字符串

Controller

1
2
3
4
5
6
7
8
@GetMapping("/top10")
@ApiOperation("销量排名")
public Result<SalesTop10ReportVO> getTop10(@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin,
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end) {
log.info("销量排名:{},{}", begin, end);
SalesTop10ReportVO salesTop10ReportVO = reportService.getTop10(begin, end);
return Result.success(salesTop10ReportVO);
}

Service,这个Stream流看起来就是很好用啊.

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 begin
* @param end
* @return
*/
@Override
public SalesTop10ReportVO getTop10(LocalDate begin, LocalDate end) {
LocalDateTime beginTime = LocalDateTime.of(begin, LocalTime.MIN);
LocalDateTime endTime = LocalDateTime.of(end, LocalTime.MAX);

List<GoodsSalesDTO> top10List = orderMapper.getTop10(beginTime, endTime);

/**
* 使用Stream流处理
*/
List<String> names = top10List.stream().map(GoodsSalesDTO::getName).collect(Collectors.toList());
String nameList = StringUtils.join(names, ",");
List<Integer> numbers = top10List.stream().map(GoodsSalesDTO::getNumber).collect(Collectors.toList());
String numberList = StringUtils.join(numbers, ",");

return SalesTop10ReportVO.builder()
.nameList(nameList)
.numberList(numberList)
.build();
}

总算是搞完了,刚才测试才发现之前写的插入订单明细有问题,少插入了一个number,所以所有的菜品数量都是1..

13 数据统计-Excel报表

工作台接口

工作台,就是进入后台管理后的第一个页面

看着接口文档写一遍吧

没啥难的,写完了已经.

直接全放着:

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
/**
* @author zzmr
* @create 2023-09-13 15:10
*/
@RestController
@RequestMapping("/admin/workspace")
@Slf4j
@Api(tags = "工作台接口")
public class WorkSpaceController {

@Autowired
private WorkSpaceService workSpaceService;

/**
* 查询今日运营数据
*
* @return
*/
@GetMapping("/businessData")
@ApiOperation("查询今日运营数据")
public Result<BusinessDataVO> businessData() {
log.info("查询今日运营数据");
BusinessDataVO businessDataVO = workSpaceService.businessData();
return Result.success(businessDataVO);
}


/**
* 查询套餐总览
*
* @return
*/
@GetMapping("/overviewSetmeals")
@ApiOperation("查询套餐总览")
public Result<SetmealOverViewVO> overviewSetmeals() {
log.info("查询套餐总览");
SetmealOverViewVO setmealOverViewVO = workSpaceService.overviewSetmeals();
return Result.success(setmealOverViewVO);
}

/**
* 查询菜品总览
*/
@GetMapping("/overviewDishes")
@ApiOperation("查询菜品总览")
public Result<DishOverViewVO> overviewDishes() {
log.info("查询套餐总览");
DishOverViewVO dishOverViewVO = workSpaceService.overviewDishes();
return Result.success(dishOverViewVO);
}

/**
* 订单总览
*
* @return
*/
@GetMapping("/overviewOrders")
@ApiOperation("订单管理")
public Result<OrderOverViewVO> overviewOrder() {
OrderOverViewVO orderOverViewVO = workSpaceService.overviewOrder();
return Result.success(orderOverViewVO);
}

}

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
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
/**
* @author zzmr
* @create 2023-09-13 15:14
*/
@Service
@Slf4j
public class WorkSpaceServiceImpl implements WorkSpaceService {

@Autowired
private UserMapper userMapper;

@Autowired
private OrderMapper orderMapper;

@Autowired
private SetmealMapper setmealMapper;

@Autowired
private DishMapper dishMapper;

/**
* 查询查询今日运营数据
*
* @return
*/
@Override
public BusinessDataVO businessData() {

/**
* 今日的数据,也就是当前日期的数据
* 这样就拿到了今天的时间区间
*/
LocalDateTime begin = LocalDateTime.of(LocalDate.now(), LocalTime.MIN);
LocalDateTime end = LocalDateTime.of(LocalDate.now(), LocalTime.MAX);

/**
* 需要的数据
* newUsers 新增用户数
* orderCompletionRate 订单完成率
* turnover 营业额
* unitPrice 平均客单价
* validOrderCount 有效订单数
*/

// 1. newUsers
Map map = new HashMap();
map.put("begin", begin);
map.put("end", end);
Integer newUser = userMapper.countByMap(map); // 可以直接用之前写的动态sql

// 2. orderCompletionRate
Integer orderCount = orderMapper.countOrderByMap(map);
map.put("status", Orders.COMPLETED);
Integer validOrderCount = orderMapper.countOrderByMap(map);
Double orderCompletionRate = 0.0;
if (orderCount != 0) {
// 不是零,再除
orderCompletionRate = validOrderCount / orderCount.doubleValue();
}

// 3. turnover 当天所有订单的总额 还是用之前的mapper
Double turnover = orderMapper.sumByMap(map);
// 没考虑到这个问题,当turnover为0时要赋值0.0,不然是空的
turnover = turnover == null ? 0.0 : turnover;

// 4. unitPrice 平均客单价 要有当天下单的用户量,然后拿上面的总额一除就完了
Integer totalOrderUser = orderMapper.getDistinctUser(map);
Double unitPrice = 0.0;
if (totalOrderUser != 0) {
unitPrice = turnover / totalOrderUser.doubleValue();
}

return BusinessDataVO.builder()
.validOrderCount(validOrderCount)
.orderCompletionRate(orderCompletionRate)
.newUsers(newUser)
.turnover(turnover)
.unitPrice(unitPrice)
.build();
}

/**
* 查询套餐总览
*
* @return
*/
@Override
public SetmealOverViewVO overviewSetmeals() {

/**
* 套餐总览 1. 已停售套餐数量 2. 已启售套餐数量
*/
// 启售
Integer startCount = setmealMapper.getCountByStatus(StatusConstant.ENABLE);
Integer stopCount = setmealMapper.getCountByStatus(StatusConstant.DISABLE);

return SetmealOverViewVO.builder().sold(startCount).discontinued(stopCount).build();
}

/**
* 查询菜品总览
*
* @return
*/
@Override
public DishOverViewVO overviewDishes() {

Integer startCount = dishMapper.getCountByStatus(StatusConstant.ENABLE);
Integer stopCount = dishMapper.getCountByStatus(StatusConstant.DISABLE);

return DishOverViewVO.builder().sold(startCount).discontinued(stopCount).build();
}

/**
* 订单总览
*
* @return
*/
@Override
public OrderOverViewVO overviewOrder() {

Map map = new HashMap();
Integer allOrders = orderMapper.countOrderByMap(map);
map.put("status", Orders.TO_BE_CONFIRMED);
Integer waitingOrders = orderMapper.countOrderByMap(map);
map.put("status", Orders.CONFIRMED);
Integer deliveredOrders = orderMapper.countOrderByMap(map);
map.put("status", Orders.COMPLETED);
Integer completedOrders = orderMapper.countOrderByMap(map);
map.put("status", Orders.CANCELLED);
Integer cancelledOrders = orderMapper.countOrderByMap(map);

return OrderOverViewVO.builder()
.allOrders(allOrders)
.cancelledOrders(cancelledOrders)
.completedOrders(completedOrders)
.deliveredOrders(deliveredOrders)
.waitingOrders(waitingOrders)
.build();
}
}

总体来说还是很简单的.

就是封装数据查询封装数据查询封装数据查询封装数据查询封装数据查询封装数据查询封装数据查询

Apache POI

介绍

Apache POI是一个处理Miscrosoft Office各种文件格式的开源项目,我们可以用POI在java程序中对Miscrosoft Office各种文件进行读写操作

一般情况下,POI都是用于操作Excel文件


应用场景:

  1. 银行网银系统导出交易明细
  2. 各种业务系统导出Excel报表
  3. 批量导入业务数据

基本使用

导入依赖

1
2
3
4
5
6
7
8
9
<!-- poi -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
</dependency>

写操作测试类,基本语法就是

  1. 创建Excel对象new XSSFWorkbook
  2. 根据excel对象创建页sheet
  3. 根据sheet创建行
  4. 根据row创建单元格,并给单元格置入数据
  5. 注意,行和单元格的下标都是从0开始的.
    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
    public class POITest {

    public static void write() throws IOException {
    // 在内存中创建的文件
    XSSFWorkbook excel = new XSSFWorkbook();

    XSSFSheet sheet = excel.createSheet("info");

    // 在sheet页中创建行对象 参数,为第几行 从零开始
    XSSFRow row = sheet.createRow(1);

    // 创建单元格并写入文件内容
    row.createCell(1).setCellValue("姓名");
    row.createCell(2).setCellValue("城市");

    // 创建新的一行
    row = sheet.createRow(2);
    row.createCell(1).setCellValue("杨某");
    row.createCell(2).setCellValue("汉中");

    row = sheet.createRow(3);
    row.createCell(1).setCellValue("周某");
    row.createCell(2).setCellValue("苏州");

    // 写入到磁盘中
    FileOutputStream out = new FileOutputStream(new File("E:\\info.xlsx"));
    excel.write(out);

    // 关闭资源
    excel.close();
    out.close();
    }

    public static void main(String[] args) throws IOException {
    write();
    }

    }

读操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 通过POI读取excel中的数据
*/
public static void read() throws IOException {
// 读取磁盘上的excel文件
XSSFWorkbook excel = new XSSFWorkbook(new FileInputStream("E:\\info.xlsx"));

// 读取excel中的第一个sheet页
XSSFSheet sheet = excel.getSheetAt(0);

// 得到最后的行号,最后有数据的行号
int lastRowNum = sheet.getLastRowNum();

for (int i = 1; i <= lastRowNum; i++) {
XSSFRow row = sheet.getRow(i);
// 获得单元格对象
String cellValue1 = row.getCell(1).getStringCellValue();
String cellValue2 = row.getCell(2).getStringCellValue();
System.out.println(cellValue1 + " " + cellValue2);
}
}

也是非常易懂
20230913173631

导出运营数据Excel报表

报表形式:
20230913191943

业务规则

  1. 导出Excel形式的报表文件
  2. 到处最近30天的运营数据

当前接口是没有返回数据的,因为报表的本质是文件下载,服务端会通过输出流将Excel文件下载到客户端浏览器


先创建这个Excel文件,那么问题就来了,这个表格有背景,有字号的区别,还有合并单元格,用POI是可以实现的,但是非常麻烦,在真正的项目中,是先把表格的样式定好,然后读取这个表格,最后修改指定位置的数据即可

然后就是查询近30天的数据,通过POI写入数据,再通过输出流将Excel文件下载到客户端浏览器

Controller

1
2
3
4
5
@GetMapping("/export")
@ApiOperation("导出excel")
public void export(HttpServletResponse response) {
reportService.exportBusiness(response);
}

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
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
/**
* 导出报表
*
* @param response
*/
@Override
public void exportBusiness(HttpServletResponse response) {
/**
* 1. 查询数据库,得到相应的数据
* 数据有,营业额,订单完成率,新增用户数,有效订单,平均客单价
* 明细数据就是每一天的以上数据
*/
// 往前倒30天
LocalDate dateBegin = LocalDate.now().minusDays(30);
// 截至至昨天
LocalDate dateEnd = LocalDate.now().minusDays(1);

// 转化为LocalDateTime
LocalDateTime begin = LocalDateTime.of(dateBegin, LocalTime.MIN);
LocalDateTime end = LocalDateTime.of(dateEnd, LocalTime.MAX);
// vo中就有需要的概览数据了
BusinessDataVO businessDataVO = workSpaceService.businessData(begin, end);

// 2. 将数据写入到excel文件中 -- 基于模板文件来实现
InputStream in = this.getClass().getClassLoader().getResourceAsStream("template/运营数据报表模板.xlsx");
try {
XSSFWorkbook excel = new XSSFWorkbook(in);
// 填充数据-时间
XSSFSheet sheet = excel.getSheetAt(0);
sheet.getRow(1).getCell(1).setCellValue("时间: " + dateBegin + "至" + dateEnd);
// 填充概览数据
sheet.getRow(3).getCell(2).setCellValue(businessDataVO.getTurnover());
sheet.getRow(3).getCell(4).setCellValue(businessDataVO.getOrderCompletionRate());
sheet.getRow(3).getCell(6).setCellValue(businessDataVO.getNewUsers());
sheet.getRow(4).getCell(2).setCellValue(businessDataVO.getValidOrderCount());
sheet.getRow(4).getCell(4).setCellValue(businessDataVO.getUnitPrice());
// 明细数据呢.还是通过上面的workSpaceService,只不过时间要变一变

for (int i = 0; i < 30; i++) {

LocalDate date = dateBegin.plusDays(i);
// 当天的数据
begin = LocalDateTime.of(date, LocalTime.MIN);
end = LocalDateTime.of(date, LocalTime.MAX);
BusinessDataVO businessData = workSpaceService.businessData(begin, end);

// 将每一天的数据封装一遍 这里借助一下i,因为第一条数据就是在第8行,而i的值最开始是0
XSSFRow row = sheet.getRow(i + 7);
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());
}

// 3. 通过输出流下载到浏览器
ServletOutputStream outputStream = response.getOutputStream();
excel.write(outputStream);

// 关闭资源
outputStream.close();
excel.close();
} catch (IOException e) {
e.printStackTrace();
}
}

结束

2023年9月13日 21点53分