苍穹外卖笔记

@Trenchance 2025/07/15

note后端启动方法:启动sky-server的SkyApplication,访问苍穹外卖

swagger接口文档:苍穹外卖项目接口文档

每一章后面的括号代表这一章的知识点

day0 Spring补充

Bean

Spring 的 Bean 就是被 Spring 容器管理的对象。也就是说,普通的 Java 对象一旦被交给 Spring 管理,就成了 “Spring Bean”。

在传统开发中,你自己创建对象,比如:

1
UserService userService = new UserService();

但在 Spring 中,对象由框架帮你创建、管理、销毁,比如:

1
2
@Autowired
UserService userService;

这就是 IoC(控制反转) 的体现。

只要你在类上加了如下注解之一,它就会变成 Bean,交由 Spring 容器管理:

注解 说明
@Component 最基础的通用组件注解
@Service 业务逻辑层组件
@Repository 数据访问组件
@Controller 控制器
@RestController @Controller + @ResponseBody

这些注解其实都隐含了 @Component 的功能。

举个例子,首先定义一个Bean:

1
2
3
4
5
6
@Service
public class UserService {
public void register() {
System.out.println("注册用户");
}
}

然后在控制器中使用它:

1
2
3
4
5
6
7
8
9
10
11
12
@RestController
public class UserController {

@Autowired
private UserService userService;

@GetMapping("/register")
public String register() {
userService.register();
return "注册成功";
}
}

注意,什么时候需要写@Autowired:

当你希望 Spring 自动将某个 Bean 注入到当前类中使用,并且注入方式不是通过 @Bean 方法参数或构造器参数时,你需要写 @Autowired

硬编码和软编码

硬编码(Hard Coding)是指在程序中直接将具体的值(如字符串、数字、配置项等)写死在代码中,而不是通过配置文件、参数、数据库等外部方式来获取这些值。

场景 错误做法(硬编码) 正确做法(软编码)
文件路径 "C:/myapp/logs/file.log" 使用配置文件定义
密码/密钥 "admin123" 使用环境变量或配置中心
页面跳转路径 "/home/index" 使用常量或路由映射
魔法数字 if (status == 3) 使用枚举或常量:if (status == Status.APPROVED)

反射

反射之中包含了一个「反」字,所以想要解释反射就必须先从「正」开始解释。

一般情况下,我们使用某个类时必定知道它是什么类,是用来做什么的。于是我们直接对这个类进行实例化,之后使用这个类对象进行操作。

1
2
Apple apple = new Apple(); //直接初始化,「正射」
apple.setPrice(4);

上面这样子进行类对象的初始化,我们可以理解为「正」。

而反射则是一开始并不知道我要初始化的类对象是什么,自然也无法使用 new 关键字来创建对象了。

这时候,我们使用 JDK 提供的反射 API 进行反射调用:

1
2
3
4
5
Class clz = Class.forName("com.chenshuyi.reflect.Apple");
Method method = clz.getMethod("setPrice", int.class);
Constructor constructor = clz.getConstructor();
Object object = constructor.newInstance();
method.invoke(object, 4);

上面两段代码的执行结果,其实是完全一样的。但是其思路完全不一样,第一段代码在未运行时就已经确定了要运行的类(Apple),而第二段代码则是在运行时通过字符串值才得知要运行的类(com.chenshuyi.reflect.Apple)。

所以说什么是反射?

反射就是在运行时才知道要操作的类是什么,并且可以在运行时获取类的完整构造,并调用对应的方法。

动态 SQL

你可以把 MyBatis 动态 SQL 理解为“在 XML 里写代码,让 SQL 语句按需自动拼接

常规使用JDBC拼接sql:

1
2
3
4
String sql = "select * from t_user where 1=1";
if (name != null) sql += " and name like concat('%', ?, '%')";
if (age != null) sql += " and age = ?";
if (email!= null) sql += " and email like concat('%', ?, '%')";

使用动态sql:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<select id="listUsers" resultType="com.example.User">
SELECT * FROM t_user
<where> <!-- 自动处理前缀 AND/OR,去掉多余的 AND -->
<if test="name != null and name != ''">
AND name LIKE CONCAT('%', #{name}, '%')
</if>
<if test="age != null">
AND age = #{age}
</if>
<if test="email != null and email != ''">
AND email LIKE CONCAT('%', #{email}, '%')
</if>
</where>
</select>

这是另一个动态sql的例子:

1
2
3
4
5
6
7
<delete id="deleteByIds">
DELETE FROM t_user
WHERE id IN
<foreach collection="idList" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</delete>

他等效于这样的sql语句:

1
2
DELETE FROM t_user
WHERE id IN (101, 102, 103);

实际开发中,90% 的动态 SQL 都是 <if> + <where> + <foreach> 组合,掌握这三个就能解决大部分场景。

然后我们讲一下另一个使用动态SQL的场景,查询。有时候我们会有多种查询方法,通过名称,通过属性,通过ID等等,我们不能为每一个查询条件都写一个方法,而是选择写一个可重用的动态SQL,具体怎么实现呢?举个例子:

假如我们现在要查询菜品,方法一,根据每个条件写一个接口:

1
2
3
4
5
6
7
@Select("select * from dish where category_id = #{categoryId}")
List<Dish> getByCategoryId(Long categoryId);

@Select("select * from dish where name like concat('%',#{name},'%')")
List<Dish> getByName(String name);

List<Dish> getByCategoryIdAndName(Long categoryId, String name);

可以是可以,但是重用性不高。

方法二,使用一个统一接口:

1
List<Dish> list(Dish dish);

怎么实现统一接口呢?答案是构造一个带有查询条件的Entity对象,把该Entity对象传入接口

比较抽象,举个具体例子,假如我们要通过categoryId查询,那我就把构造一个Dish对象,这个对象categoryId字段是我要查询的条件,其他字段留空;如果我要查询categoryId且status是enable,那我就构造一个对象,这个对象categoryId是查询条件,且status是enable:

1
2
3
4
Setmeal setmeal = new Setmeal();
setmeal.setCategoryId(categoryId);
setmeal.setStatus(StatusConstant.ENABLE);
List<Setmeal> list = setmealService.list(setmeal);

然后调用mapper查询的时候,动态sql判断传进来的对象每个字段是否为空,如果不为空就代表这个是查询字段,以此为条件进行查询:

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

再举一个完整的例子吧,假如查询当前登录用户的所有地址信息:

controller:

1
2
3
4
5
6
7
8
@GetMapping("/list")
@ApiOperation("查询当前登录用户的所有地址信息")
public Result<List<AddressBook>> list() {
AddressBook addressBook = new AddressBook();
addressBook.setUserId(BaseContext.getCurrentId());
List<AddressBook> list = addressBookService.list(addressBook);
return Result.success(list);
}

service:

1
2
3
public List<AddressBook> list(AddressBook addressBook) {
return addressBookMapper.list(addressBook);
}

mapper.java:

1
List<AddressBook> list(AddressBook addressBook);

mapper.xml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<select id="list" parameterType="com.sky.entity.AddressBook" resultType="com.sky.entity.AddressBook">
select * from address_book
<where>
<if test="userId != null">
and user_id = #{userId}
</if>
<if test="phone != null">
and phone = #{phone}
</if>
<if test="isDefault != null">
and is_default = #{isDefault}
</if>
</where>
</select>

这里就构造一个AddressBook对象,对象除了userId,其他字段都留空。把这个对象传给mapper。动态sql判断这个对象哪些字段不为空就以哪些字段为查询条件,这里userId不为空,那么where user_id = #{userId}

day1-2 整体介绍

需求分析:需求规格说明书,产品原型(一个静态html页面展示)

day1-3 项目介绍

需求分析

产品原型

技术选型

day1-4 前端环境搭建

前端基于nginx服务器。

运行方式:双击nginx.exe,然后浏览器打开苍穹外卖

day1-5 后端环境搭建 (DTO, VO)

DTO(Data Transfer Object):前端传给后端的请求体封装为DTO

VO(Value Object):后端发回给前端的相应封装为VO

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
classDiagram
class 前端 {
+ 发送请求(JSON body)
+ 接收响应(JSON)
}

class 后端 {
+ @PostMapping("/login")
+ login(employeeLoginDTO)
}

class DTO {
<<数据传输对象>>
+ username
+ password
__接收前端请求体__
}

class VO {
<<视图对象>>
+ id
+ userName
+ token
__返回前端响应__
}

class Entity {
<<持久化实体>>
+ id
+ username
+ password
+ name
}

class 数据库 {
+ employee表
}

前端 --> 后端 : HTTP请求(包含JSON body)
后端 --> 前端 : HTTP响应(包含VO JSON)
后端 --> DTO : @RequestBody接收
后端 --> VO : 构造响应数据
DTO --> Entity : 转换(用于业务处理)
Entity --> VO : 转换(构建响应)
Entity --> 数据库 : ORM操作
数据库 --> Entity : 查询结果

day1-6 Git

Git分为本地和远程仓库,先配置本地仓库。

打开浏览器访问官网:https://git-scm.com/下载Git并安装。

在IDEA配置Git路径:

IDEA顶部菜单栏,VCS(版本控制系统)。点进去创建Git仓库:

创建完成后左上角可以进行Git操作:

在Github创建仓库,并复制下面的地址:

IDEA中选择推送,再点击定义远程(Define Remote):

如果添加了错误的远程仓库,可以在左侧项目栏中右键项目目录,然后:Git,管理远程。

粘贴上面复制的ssh地址:

如果没有配置公钥,需要先配置公钥:

然后在Github中点击设置,在ssh一栏中添加公钥。

现在再进入IDEA推送页面,页面如下所示:

点击推送即可。

推送成功。

day1-7 数据库环境搭建MySQL

首先安装MySQL,MySQL :: Download MySQL Installer,选择下面的下载:

端口设置:

密码设置:123456。 Windows服务名称:MySQL80。

安装完毕后从命令行进入 mysqlsh

连接数据库:

接下来就可以在这里写 sql 建表语句了。之后我们在IDEA配置数据源,点击右侧工具栏的数据库图标:

配置drivers:

配置数据源:

或者也可以不在 mysqlsh 写sql,直接在IDEA运行:

day1-8 前后端联调

在右侧maven工具栏中,对root进行编译,这样对四个模块都会进行编译。

启动sky-server的SkyApplication。启动之前记得修改yaml中的sql账号密码端口号:

启动后,即可在工作台进行登录。admin,123456。即可登录。登录截图略。

day1-9 Nginx

什么是代理:proxy(代理) 指的是一种中间服务器,它位于客户端和目标服务器之间,代替客户端向目标服务器发送请求,然后再把服务器的响应返回给客户端。

类型 特点
正向代理(Forward Proxy) 客户端使用代理访问外部资源,目标服务器看不到真实客户端。
反向代理(Reverse Proxy) 客户端不知道后端服务器的存在,代理接收请求并转发到后端服务器(如 Nginx)。

什么是 Nginx 反向代理:前端不直接向后端发出请求而是找Nginx。你不直接访问真实的服务器,而是先找中介Nginx,由它转发请求、拿到结果后再转交给你。

1
2
3
4
5
6
7
8
server {
listen 80; # 监听80端口,这是HTTP服务的默认端口
server_name localhost; # 指定服务器名称为localhost

location /api/ { # 定义一个location块,匹配以/api/开头的请求
proxy_pass http://localhost:8080/admin/; # 设置反向代理,将请求转发到本地的8080端口上的/admin/路径
}
}

上面这段代码的含义:将前端80端口以api开头的请求,转发给后端8080的admin路径上。

Nginx还能实现负载均衡,可以把请求分给不同服务器处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
http {
upstream my_backend {
server 127.0.0.1:8081;
server 127.0.0.1:8082;
}

server {
listen 80;
server_name localhost;

location / {
proxy_pass http://my_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
}

day1-11 完善登录密码加密

密码不能够明文存储。使用MD5进行加密。MD5加密不可逆,密码比对只能对密文进行比对。

Java代码修改如下:

1
2
3
4
5
6
//密码比对
password = DigestUtils.md5DigestAsHex(password.getBytes());
if (!password.equals(employee.getPassword())) {
//密码错误
throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR);
}

然后把数据库中密码改成md5密文。

day1-12 导入接口文档 YApi

YApi 是一个可视化接口管理平台,它是由腾讯开源的,主要用于 API 文档管理、接口调试、团队协作等。

首先登录网址 YApi

创建项目:

进入项目,点击数据管理,导入接口文档(支持json,swagger等等)

day1-13 Swagger

Knife4j 是一个用于 生成和增强 API 文档的工具,它基于 Swagger,是 Swagger 的一个增强 UI 实现。

使用方法:

第一步,maven配置:略

第二步,配置类中配置knife4j:最重要的就是RequestHandlerSelectors.basePackage(“com.sky.controller”),制定了扫描的包:

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

访问:苍穹外卖项目接口文档

阶段 Swagger 用于做什么 YApi 用于做什么
接口设计阶段(前) ❌(不适合) ✅ 可手动创建接口、模拟返回,前端可提前开发
后端开发中 ✅ 自动生成接口文档,方便开发者查看 ❌ 一般此时不是主要工具(除非需要共享接口)
前后端联调阶段 ✅ 配合 Knife4j 可调试接口、查看字段 ✅ Mock 接口 + 文档共享 + 接口测试,前端/测试可独立使用

day1-14 Swagger常用注解

注解 作用 位置 示例用法
@Api 定义一个 Controller,给接口类加注释 类上 @Api(tags="用户管理")
@ApiOperation 说明一个接口的用途 Controller 方法上 @ApiOperation("查询用户列表")
@ApiParam 描述方法参数的意义 方法参数上 @ApiParam(value="用户ID", required=true)
@ApiModel 用于实体类,说明实体含义 实体类上 @ApiModel("用户实体")
@ApiModelProperty 描述实体类字段 实体类字段上 @ApiModelProperty("用户名")
@ApiResponses 描述多个响应信息 Controller 方法上 包含多个 @ApiResponse
@ApiResponse 描述单个响应信息 @ApiResponses 内部 code=200, message="请求成功"
@ApiIgnore 忽略该接口或参数 类、方法、参数上 @ApiIgnore

例如:

1
2
3
4
5
6
7
8
9
@Data
@ApiModel(description = "员工登录时传递的数据模型")
public class EmployeeLoginDTO implements Serializable {
@ApiModelProperty("用户名")
private String username;

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

再如:

1
2
3
4
5
@PostMapping("/logout")
@ApiOperation("员工退出")
public Result<String> logout() {
return Result.success();
}

加完注解,接口文档中tag会变成注解的文字:

day2-1 新增员工-需求分析与设计

需求分析往往对着产品原型进行分析:

接下来进行接口设计:添加员工使用POST比较合适,使用json传递数据。

注:前端返回的msg是用于创建失败时候的提示弹窗。管理端url用admin前缀,用户端user前缀。

接口设计完毕进行数据库设计

day2-2 新增员工-代码开发 (Lombok, implements Serializable)

现在我们需要设计DTO。当然也可以直接用Employee类进行接收,但是由于Employee有很多前端用不到的属性,因此封装为DTO。DTO的字段是根据前面接口设计中Body的字段确定的。

DTO代码如下:

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

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Employee implements Serializable {

private static final long serialVersionUID = 1L;

private Long id;

private String username;

private String name;

private String password;

private String phone;

private String sex;

private String idNumber;

private Integer status;

//@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;

//@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;

private Long createUser;

private Long updateUser;

}

让我们来解释一些东西:

首先是Lombok。Lombok是一个 Java 库,它通过使用注解来简化 Java 代码的编写。它提供了一系列的注解,用于自动生成常见的代码,如gettersetter方法、构造函数、equalshashCode方法等,以减少开发者的重复劳动。Lomlok可以在maven中引入依赖使用。

注解 作用
@Data 自动生成 getter/setter、toString、equals、hashCode
@ToString 自动生成 toString() 方法
@NoArgsConstructor 自动生成无参构造方法
@AllArgsConstructor 自动生成全参构造方法
@Builder 生成建造者模式代码(Builder Pattern)
@Slf4j 自动生成 private static final Logger log 日志对象
@Value 不可变对象类(final + private + 只读)

例如:

1
2
3
4
5
6
7
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
private String name;
private int age;
}

@Builder 用法:

1
2
3
4
5
6
7
@Builder
@Data
public class Employee {
private Long id;
private String name;
private String email;
}
1
2
3
4
5
Employee emp = Employee.builder()
.id(1L)
.name("张三")
.email("zhangsan@example.com")
.build();

然后是序列化,Java中的实体类为什么要 implements Serializable?

序列化:把对象转换为字节序列的过程称为对象的序列化。反序列化:把字节序列恢复为对象的过程称为对象的反序列化。

序列化操作用于存储时,一般是对于 NoSql 数据库,而在使用 Nosql 数据库进行存储时,如 redis,它就没有 varchar,int 之类的数据结构。 而在没有的情况下,我们又确实需要进行存储,那么我们就需要将对象进行序列化。

为什么要显示声明 serialVersionUID?

serialVersionUID 的作用是验证序列化和反序列化的过程中,对象是否保持一致。所以在一般情况下我们需要显示的声明serialVersionUID。如果接受者加载的该对象的类的 serialVersionUID 和发送者的类版本号不同的话,反序列化会爆出 InvalidClassException 错误。

解释完DTO,还有一个东西需要解释。看上面的接口文档,看返回数据,这个对应着我们sky-common模块下的Result类(sky-common/src/main/java/com/sky/result/Result.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
public class Result<T> implements Serializable {

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

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

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

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

也就是,请求参数Body对应着DTO,返回数据对应着Result。

现在开始写代码,首先写控制器,控制器的参数是DTO,控制器的返回值是Result对象。这里我们没有返回Data,所以直接返回Result.success()也就是只有code和msg。(观察Result类代码可以知道,这里success()方法返回的是一个Result的对象)

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

控制器调用service接口,因此先在service中加上我们需要的接口,然后在implement中进行实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void save(EmployeeDTO employeeDTO) {
Employee employee = new Employee();
//对象属性拷贝
BeanUtils.copyProperties(employeeDTO,employee);
employee.setStatus(StatusConstant.ENABLE);
employee.setPassword(DigestUtils.md5DigestAsHex(PasswordConstant.DEFAULT_PASSWORD.getBytes()));
employee.setCreateTime(LocalDateTime.now());
employee.setUpdateTime(LocalDateTime.now());
// TODO 后期需要改为当前登录用户ID
employee.setCreateUser(10L);
employee.setUpdateUser(10L);

employeeMapper.insert(employee);
}

service又需要通过mapper进行插入,因此要在mapper中加入insert方法。

1
2
3
@Insert("INSERT INTO employee(name, username, password, phone, sex, id_number, create_time, update_time, create_user, update_user) " +
"VALUES(#{name}, #{username}, #{password}, #{phone}, #{sex}, #{idNumber}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser})")
void insert(Employee employee);

day2-3 新增员工-测试

两种后端测试方式:Swagger,前后端联调。开发阶段以 Swagger 测试为主。

注意,我们现在直接进行测试是会返回401的,因为在com/sky/interceptor/JwtTokenAdminInterceptor.java中定义了JWT令牌的拦截器,我们当前令牌为空,因此返回401异常。解决方案:在“员工登录”这里调试,并获取返回的token:

然后把复制下来的token放在全局参数中即可。

现在直接调试就可以成功了。

当然我们现在也可以进行前后端联调,测试也通过。

注意!!!JWT令牌是有有效期的,可能隔一段时间我们再用这个令牌操作就失效了!这个时候按照前面操作再生成即可

day2-4 新增员工-增加相同用户异常处理

如果我们新增一个相同的员工,状态码500:

IDEA终端出现异常:java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'zhang' for key 'employee.idx_username'

com/sky/handler/GlobalExceptionHandler.java是我们处理异常的类,在这里添加一个方法用来处理SQL异常:

注意传入的参数就是上面的SQL异常类,通过Duplicate entry关键字识别进行用户友好提示。注意有些异常提示已经封装为常量,尽量避免直接写字符串。

1
2
3
4
5
6
7
8
9
10
11
12
public Result exceptionHandler(SQLIntegrityConstraintViolationException ex){
String msg=ex.getMessage();
log.error("异常信息:{}", ex.getMessage());
if(msg.contains("Duplicate entry")){
String[] tmp=msg.split(" ");
String username=tmp[2];
String errMsg=username+" 已存在";
return Result.error(errMsg);
} else{
return Result.error(MessageConstant.UNKNOWN_ERROR);
}
}

现在如果再新增一个相同的员工,状态码就会是200,且返回用户友好的信息。

1
2
3
4
5
{
"code": 0,
"msg": "'zhang' 已存在",
"data": null
}

day2-5 新增员工-创建人修改人ID替换 (ThreadLocal)

注意一个概念,Tomcat中,客户端每发起一次请求就是一个新的线程

当客户端发起 HTTP 请求时,服务器为了并发处理,会为这个请求分配一个线程来处理它(通常是线程池中的一个线程)。这个线程会处理业务逻辑、数据库、响应输出等任务。服务端会为每个请求分配一个线程(或复用线程池中的线程)来处理它,彼此互不干扰,支持并发。

ThreadLocal 是 Java 提供的一种线程局部变量,就像是每个线程都有一个自己的空间,放进去的变量只有当前线程能看见、能拿出来用,其他线程完全无法访问。

ThreadLocal 通常有下面的作用:

场景 说明
用户登录上下文 每个请求绑定当前用户 ID、Token,存在线程中
数据库事务管理 保存每个线程独立的数据库连接对象
日志跟踪 将 traceId 绑定到线程中,贯穿调用链
请求上下文传递 Spring 的 RequestContextHolder 就是用的 ThreadLocal

登录后想保存当前用户的 ID,让业务层可以随时访问。这就是一个 ThreadLocal 常见的应用场景,他可以做到存储一个线程的上下文的作用。

在我们项目的com/sky/context/BaseContext.java中,封装了 ThreadLocal 的方法。

1
2
3
4
5
6
7
8
9
10
11
12
public class BaseContext {
public static ThreadLocal<Long> threadLocal = new ThreadLocal<>();
public static void setCurrentId(Long id) {
threadLocal.set(id);
}
public static Long getCurrentId() {
return threadLocal.get();
}
public static void removeCurrentId() {
threadLocal.remove();
}
}

为了在当前线程共享当前登录用户的ID,我们可以从JWT中拿到用户ID,存在ThreadLocal变量中。

1
2
3
4
5
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);
BaseContext.setCurrentId(empId);

最后,在service中把该变量取出,赋值即可:

1
2
employee.setCreateUser(BaseContext.getCurrentId());
employee.setUpdateUser(BaseContext.getCurrentId());

测试发现可以正常新增,且修改人ID正确。新增员工开发完毕,别忘了Git Push。

day2-6 员工分页查询-需求分析与设计

day2-7 员工分页查询-代码开发 (PageHelper, MyBatis动态SQL与XML配置)

老规矩,DTO对应着接口的请求参数Query(前面是POST请求,是Body,这里是GET请求,是Query),Result对应着接口的返回值。

DTO代码,和上面的Query字段完全一致:

1
2
3
4
5
public class EmployeePageQueryDTO implements Serializable {
private String name;
private int page;
private int pageSize;
}

至于Result,由于这里由于是分页查询,我们在sky-common/src/main/java/com/sky/result包中还封装了PageResult类。

1
2
3
4
public class PageResult implements Serializable {
private long total; //总记录数
private List records; //当前页数据集合
}
1
2
3
4
5
public class Result<T> implements Serializable {
private Integer code; //编码:1成功,0和其它数字为失败
private String msg; //错误信息
private T data; //数据
}

之前的Result类正好对应着我们接口返回值,Result中的data正好对应着PageResult类。这样的话,Result的泛型就是PageResult也就是:Result<PageResult>

确定了参数(EmployeePageQueryDTO)和返回值(Result<PageResult>),我们就可以开始写controller了(注意,这里是Query,不是json请求,不需要写@RequestBody注解):

1
2
3
4
5
6
@GetMapping("/page")
@ApiOperation("员工分页查询")
public Result<PageResult> page(EmployeePageQueryDTO employeePageQueryDTO){
PageResult pageResult=employeeService.pageQuary(employeePageQueryDTO);
return Result.success(pageResult);
}

至此我们controller就写好了,不过service的pageQuary方法还没写。

其实你会发现,controller是很好写的,只是明确参数和返回值,调用一下service,构造一下结果就可以了。至于具体实现,那是service干的事情。

(我在写到这的时候有一个疑问,我们是怎么直接拿到employeeService对象的?答案是employeeService对象是EmployeeController的private字段,并加上了@Autowired注解,同时service的实现类需要加上@Service注解)

1
2
3
4
5
6
public class EmployeeController {
@Autowired
private EmployeeService employeeService;
@Autowired
private JwtProperties jwtProperties;
}

(其实controller和service的关系,和service与mapper的关系一样,mapper要加上@Mapper注解,同时在service类中自动注入mapper)

OK,下面开始实现service:

在实现service之前先介绍一个用于实现分页的插件:PageHelper ,它可以自动为你的 SQL 查询添加分页语句(LIMIT 语句),让你只写一条查询语句就能实现分页。该插件需要通过maven进行引入。

假设你有个用户表,想分页查询用户列表(每页 10 条),传统写法要手动拼接 LIMIT 语句,很麻烦,PageHelper 就无需这么麻烦。

service代码如下:

1
2
3
4
5
6
7
public PageResult pageQuery(EmployeePageQueryDTO employeePageQueryDTO) {
PageHelper.startPage(employeePageQueryDTO.getPage(),employeePageQueryDTO.getPageSize());
Page<Employee> page=employeeMapper.pageQuery(employeePageQueryDTO);
long total=page.getTotal();
List<Employee> records=page.getResult();
return new PageResult(total,records);
}

接下来让我们实现mapper的pageQuery方法:

实现mapper之前,让我们介绍一下动态SQL。动态 SQL 就是写一段“会变”的 SQL,具体内容根据参数、逻辑在运行时自动拼出来的。MyBatis 提供了一套标签实现动态SQL,我们可以尽量把 SQL 逻辑放在 XML 中。

在一个 标准的 Java Web 项目(MyBatis)中,XML文件(Mapper 映射文件)的放置位置如下:

1
2
3
4
5
6
7
8
9
src
├── main
│ ├── java
│ │ └── com.example.project
│ │ └── mapper
│ │ └── EmployeeMapper.java <-- 接口
│ └── resources
│ └── mapper
│ └── EmployeeMapper.xml <-- XML 映射文件

当然,Spring Boot 项目通常使用 application.yml 配置 MyBatis 路径。

在我们现在要实现的分页查询中,我们使用动态SQL,而不再使用注解的方式,因此需要使用xml映射文件:

该项目xml在sky-server/src/main/resources/mapper/EmployeeMapper.xml中。

首先我们回到mapper,把pageQuery方法创建,并右键点击生成:

最终xml如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.EmployeeMapper">
<select id="pageQuery" resultType="com.sky.entity.Employee"
parameterType="com.sky.dto.EmployeePageQueryDTO">
select * from employee
<where>
<if test="name!=null and name!=''">
and name like concat('%',#{name},'%')
</if>
</where>
order by create_time desc
</select>
</mapper>

那么上面的代码是怎么实现分页的呢?

PageHelper 是 MyBatis 的分页插件,通过拦截器机制拦截你的查询语句,并自动在 SQL 后面加上分页的 LIMIT 子句

PageHelper.startPage(...) 会启用分页上下文;MyBatis 执行 SQL 时,PageHelper 的拦截器就会捕捉到这个 SQL;然后插件会在末尾加上 limit ? offset ?,最后结果封装为 Page<T> 返回。(PageHelper也是基于ThreadLocal实现的)

OK,总结一下,一个功能需求实现的步骤就是:接口文档,DTO和Result,控制器,service,mapper,测试,异常处理与其他。

day2-9 员工分页查询-完善日期显示 (序列化, 反序列化, 消息转换)

时间显示格式有问题,需要修改。

sky-common/src/main/java/com/sky/json/JacksonObjectMapper.java,一个自定义的 Jackson 配置类,用于控制 Java 对象和 JSON 之间的转换格式,尤其是 时间类(LocalDateLocalTimeLocalDateTime)的序列化与反序列化格式。

序列化(Serialization):把 Java 对象 ➜ 转换为字节流(例如保存到文件、传输给网络)

反序列化(Deserialization):把字节流 ➜ 转换回 Java 对象。

在com/sky/config/WebMvcConfiguration.java中添加如下语句:

1
2
3
4
5
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
MappingJackson2HttpMessageConverter converter=new MappingJackson2HttpMessageConverter();
converter.setObjectMapper(new JacksonObjectMapper());
converters.add(0,converter);
}

这段代码是 Spring MVC 中的一种配置方式,用于扩展 Spring 的消息转换器(MessageConverter),实现 自定义对象序列化和反序列化的行为

在JacksonObjectMapper中(也就是上面提到的自定义Jackson配置类)就可以写日期转换逻辑,具体逻辑略。

day2-10 启用禁用-需求分析与设计

原型与需求:

接口:

注意,{status}:这是一个路径参数,表示在实际请求时,需要将这个占位符替换为具体的员工状态值。例如,如果员工的状态是1,那么完整的url将是 /admin/employee/status/1

day2-11 启用禁用-代码开发与测试

对于路径参数,我们需要使用@PathVariable注解来获取路径参数:

1
2
3
4
5
6
@PostMapping("/status/{status}")
@ApiOperation("启用禁用员工账号")
public Result startOrStop(@PathVariable Integer status, Long id){
employeeService.startOrStop(status, id);
return Result.success();
}

实现service方法:

1
2
3
4
5
6
7
public void startOrStop(Integer status, Long id) {
Employee employee=Employee.builder()
.status(status)
.id(id)
.build();
employeeMapper.update(employee);
}

注意这里的service。因为我们想让employeeMapper.update方法更具有通用性,也就是以后想要修改别的属性也可以通过employeeMapper.update方法使用,我们就不能只传入status和id了,而是传入一个employee对象(为什么这么做有点不好理解,可以结合后面动态sql语句理解),这个employee对象中id是我们要修改的id,status是目标stauts,其余都是null。

接下来是mapper,这里传入了我们创建的employee对象:

1
void update(Employee employee);

一样的右键生成代码,然后在xml配置动态sql:

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

解释一下这个sql:以第一个if语句为例 <if test="name != null">name = #{name},</if>

位置 含义
test="name != null" 中的 name 是传入的参数 Java 对象 employee 的属性:employee.getName()
name = #{name} 中的左边 name 数据库字段名(表字段),对应 employee 表中的列名
#{name} 中的 name 是传入的参数 Java 对象 employee 的属性,用于参数占位符绑定

假设调用如下代码:

1
2
3
4
Employee emp = new Employee();
emp.setId(1L);
emp.setName("张三");
employeeMapper.update(emp);

那么:name != null → 会判断 emp.getName() != null,返回 truename = #{name} → 会生成 SQL 片段:name = '张三'。而其他字段都是空,因此不会做更新。

最终生成的 SQL 是:

1
2
3
update employee
set name = '张三'
where id = 1;

至此,我们的代码开发完毕。

测试略,没有新东西。

day2-12 编辑员工-需求分析与设计

需要两个接口,一个是修改前用于页面回显(查询),一个是更新。

day2-13 编辑员工-代码开发

首先是查询,这个功能非常简单,直接上代码:

控制器:

1
2
3
4
5
6
@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
public Employee getById(Long id) {
Employee employee=employeeMapper.getById(id);
employee.setPassword("****");
return employee;
}

mapper,比较简单,直接写sql:

1
2
@Select("select * from employee where id = #{id}")
Employee getById(Long id);

下面是编辑员工信息:

控制器,注意观察接口文档中的Body字段,容易看出来我们这时候应该用DTO而不是Employee本身:

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

service,注意把DTO转成Employee,符合update接口:

1
2
3
4
5
6
7
8
public void update(EmployeeDTO employeeDTO) {
Employee employee=new Employee();
BeanUtils.copyProperties(employeeDTO,employee);
employee.setUpdateTime(LocalDateTime.now());
employee.setCreateUser(BaseContext.getCurrentId());
employee.setUpdateUser(BaseContext.getCurrentId());
employeeMapper.update(employee);
}

mapper,和前面2-11一样,xml代码不再重复:

1
void update(Employee employee);

day2-15 分类管理代码导入

由于代码和之前很类似,因此直接导入。

原型:

分类分为两种大的类型,菜品分类和套餐分类。

六个接口:新增分类,分类分页查询,根据id删除分类,修改分类,启用禁用分类,根据类型查询分类。

数据库设计:

代码导入:略。

测试:略。

day3-1 公共字段自动填充 (AOP, 面向切面编程)

由于表中含有公共字段,冗余代码过多,不便于后期维护。

创建时间会在insert赋值,修改时间会在insert和update赋值。

实现思路:

技术点:枚举,注解,AOP,反射。

AOP(面向切面编程)是一种编程模式,让开发者在不修改核心代码的前提下,给程序动态添加通用功能(比如日志、权限检查、事务管理)。

假设你有一本小说(核心业务逻辑),现在想给每一章的开头加一句“本章由AI生成”(通用功能)。传统做法:手动修改每一章的开头 → 代码重复、侵入性强。AOP做法:直接给整本书套一个“自动盖章机” → 非侵入式、集中管理

AOP通过 切面(Aspect)横切关注点 从业务代码中分离,包含以下核心组件:

  1. Join Point(连接点):程序执行中的特定点(如方法调用、异常抛出)。
  2. Pointcut(切点):匹配一组连接点的表达式(定义在哪里插入代码)。
  3. Advice(通知):在连接点执行的具体逻辑(如前置、后置、环绕通知)。
  4. Aspect(切面):包含切点和通知的模块化单元。

AOP通过在程序执行的不同阶段织入切面代码来实现其功能。织入(weaving)是指将切面代码与目标代码合并的过程。

有两种主要的织入方式:编译时织入和运行时织入。

day3-2 公共字段自动填充 代码开发 (AOP, 反射)

步骤:

  • 自定义注解 AutoFill:创建一个名为 AutoFill 的注解,用于标记那些需要自动填充公共字段的方法。
  • 自定义切面类 AutoFillAspect:创建一个名为 AutoFillAspect 的切面类。在这个切面类中,拦截所有使用了 AutoFill 注解的方法,并通过反射机制为这些方法中的公共字段自动赋值。
  • 在 Mapper 的方法上加入 AutoFill 注解:这样这些方法在执行时就会自动触发切面逻辑,完成公共字段的自动填充。

自定义注解

首先创建包 sky-server/src/main/java/com/sky/annotation,并在包下创建注解 AutoFill:

自定义注解如下(注解本质上是一个特殊的接口,编译器会帮你生成实现类):

1
2
3
4
5
6
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {
// 数据库操作类型
OperationType value();
}

解释一下:@Target(ElementType.METHOD)代表表示这个注解只能用在“方法”上,如果你用在字段、类上,就会报错,例如下面就是正确使用:

1
2
3
4
@AutoFill(OperationType.INSERT)
public void insertUser(User user) {
// 插入逻辑
}

@Retention(RetentionPolicy.RUNTIME)代表这个注解在运行时依然存在,JVM 会保留它,程序可以通过 反射 获取到它。这条在AOP中是必须写的,因为只有运行时保留,切面才能拦截到这个注解。

OperationType value();是什么意思呢?这是注解的参数,表示你要传入一个值,类型是 OperationType(一个自定义的枚举)。

举个例子,如果注解参数这样写:

1
2
3
4
public @interface LogInfo {
String action();
String module();
}

那么使用应该:

1
@LogInfo(action = "添加用户", module = "用户管理")

如果注解参数这样写:

1
2
3
public @interface MyTag {
String value(); // 这就是一个“参数”
}

那么使用应该:

1
@MyTag(value = "重要的方法")

注意,这个等同于

1
@MyTag("重要的方法")

因此,如果我们注解参数是 OperationType value(); 的话,可以直接写:

1
@AutoFill(OperationType.INSERT)

这里 value() 就会等于 OperationType.INSERT

自定义切面类

创建包sky-server/src/main/java/com/sky/aspect,创建切面类AutoFillAspect:

切面=通知+切入点,即要“切”的地方(即:拦截哪些方法)+要“切”进去干的事(具体要做什么)。

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
@Aspect
@Component
@Slf4j
public class AutoFillAspect {
/**
* 切入点
*/
@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
public void autoFillPointCut(){}

@Before("autoFillPointCut()")
public void autoFill(JoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
AutoFill autoFill=signature.getMethod().getAnnotation(AutoFill.class);
OperationType operationType=autoFill.value();

Object[] args=joinPoint.getArgs();
if(args==null || args.length==0){
return;
}
Object entity=args[0];
LocalDateTime now=LocalDateTime.now();
Long currentId= BaseContext.getCurrentId();

if(operationType==OperationType.INSERT){
try {
Method setCreateTime=entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME,LocalDateTime.class);
Method setCreateUser=entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER,Long.class);
Method setUpdateTime=entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME,LocalDateTime.class);
Method setUpdateUser=entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER,Long.class);

setCreateTime.invoke(entity, now);
setCreateUser.invoke(entity, currentId);
setUpdateTime.invoke(entity, now);
setUpdateUser.invoke(entity, currentId);
} catch (Exception e) {
throw new RuntimeException(e);
}
} else if (operationType==OperationType.UPDATE) {
try {
Method setUpdateTime=entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME,LocalDateTime.class);
Method setUpdateUser=entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER,Long.class);

setUpdateTime.invoke(entity, now);
setUpdateUser.invoke(entity, currentId);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
}

让我们解释一下上面的代码:

注解

1
2
3
4
@Aspect
@Component
@Slf4j
public class AutoFillAspect

@Aspect声明这是一个切面类,@Component将该类注册为Bean,由 Spring 管理这个类。@Slf4j之前解释过(Lombok),略。

切入点

1
2
@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
public void autoFillPointCut(){}

@Pointcut 注解定义了要拦截的方法范围execution(* com.sky.mapper.*.*(..)),匹配 com.sky.mapper 包下的 任意类任意方法,方法参数不限。

&& @annotation(com.sky.annotation.AutoFill),只匹配那些被 @AutoFill 注解标注的方法。

注意:切入点函数可以随意命名,@Before 的 value 必须和切入点函数名一样,切入点函数里面不可以写代码。

通知

1
2
3
4
@Before("autoFillPointCut()")
public void autoFill(JoinPoint joinPoint) {
...
}

@Before 是一个通知(Advice),表示:在目标方法执行前,先执行 autoFill() 方法。

当你调用 employeeMapper.insert(...),如果这个方法有 @AutoFill 注解,就会先自动执行 autoFill() 方法。

这里的参数 JoinPoint 是 Spring AOP 提供的一个对象,表示当前连接点(方法)的信息。你可以通过它拿到:方法参数,注解内容等等。

举个例子,如果我们mapper有:

1
2
@AutoFill(OperationType.INSERT)
void insert(Employee employee);

在service层有如下代码:

1
employeeMapper.insert(emp);

那么,insert() 就是一个连接点

业务实现与反射

1
2
3
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
AutoFill autoFill=signature.getMethod().getAnnotation(AutoFill.class);
OperationType operationType=autoFill.value();

第一步获取方法的签名对象:获取方法签名对象的作用,是为了在 AOP 通知中拿到目标方法的详细信息,尤其是方法上的注解、方法名、参数类型等。这里我们用来获得注解的value。

1
Object[] args = joinPoint.getArgs();

这句话获取目标方法在运行时接收到的实参数组,得到一个 Object[],里面就是实际调用 insert() 时传进来的那些参数对象。(这里约定第一个参数为需要修改的对象)

1
2
3
4
5
6
7
8
9
Object[] args=joinPoint.getArgs();
Object entity=args[0];

Method setUpdateTime=entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME,
LocalDateTime.class);
Method setUpdateUser=entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER,
Long.class);
setUpdateTime.invoke(entity, now);
setUpdateUser.invoke(entity, currentId);

上面的代码是典型的Java反射机制,动态调用对象的方法。

首先通过反射获取方法对象:

entity.getClass():获取传入实体对象的运行时类,比如 Employee.class

.getDeclaredMethod(...):通过方法名和参数类型来获取这个类中的指定方法对象(Method

然后调用方法(反射执行):

setUpdateTime.invoke(entity, now); 第一个参数:调用方法的对象;后面的参数:是方法调用时传入的实际参数。

1
setUpdateUser.invoke(entity, currentId);

等价于:

1
entity.setUpdateUser(currentId);

为什么需要反射:在 AOP 的 @Before 通知中,我们拿到的是 Object entity它是一个未知类型的对象,为了让这个 AOP 能适配多个实体类(如 Employee、Dish、Category),就不能写死调用。反射在运行时 getClass() 才得知要运行的类。

通过反射调用方法的一般步骤:

  1. 获取类的 Class 对象实例:Class clz = entity.getClass();
  2. 获取方法的 Method 对象:Method setPriceMethod = clz.getMethod("setPrice", int.class);
  3. 使用 invoke 调用方法:setPriceMethod.invoke(appleObj, 14);

注意,凡是涉及硬编码(指在程序中直接将具体的值,如字符串,数字,写死在代码中)的地方,都通过配置文件,常量类,枚举类,实现软编码。

如这里:

1
Method setCreateUser=entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER,Long.class);

这里的AutoFillConstant.SET_CREATE_USER就用常量类代表这个方法的字符串:

1
2
3
4
5
6
7
8
9
public class AutoFillConstant {
/**
* 实体类中的方法名称
*/
public static final String SET_CREATE_TIME = "setCreateTime";
public static final String SET_UPDATE_TIME = "setUpdateTime";
public static final String SET_CREATE_USER = "setCreateUser";
public static final String SET_UPDATE_USER = "setUpdateUser";
}

加上自定义注解

1
2
3
4
@Insert("INSERT INTO employee(name, username, password, phone, sex, id_number, create_time, update_time, create_user, update_user) " +
"VALUES(#{name}, #{username}, #{password}, #{phone}, #{sex}, #{idNumber}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser})")
@AutoFill(OperationType.INSERT)
void insert(Employee employee);
1
2
@AutoFill(value = OperationType.UPDATE)
void update(Employee employee);

这里我们带和不带value=都可以。

把原先service类中手动赋值代码删除

略。

day3-5 新增菜品-需求分析

要求:菜名唯一,菜品必须属于某个分类,口味不必须,图片必须。

菜品分类栏需要实现菜品查询,该接口已经实现过了,不需要再实现。

现在只需要实现文件上传和菜品新增两个接口。

注意,这里返回的data是必须的,需要把图片的服务器端路径返回。

数据库设计如下:

逻辑外键:数据库没有创建外键,我们在java代码中自行处理。

菜品和口味是一对多的关系,因此在口味中设置菜品的主键。一个菜品有多种口味,应该在一个菜品中选择不同的口味,比如你在点菜时候想点一条鱼,我可以选择辣或者不辣;但是先选择辣或不辣再选菜品是没有意义的。

day3-6 新增菜品-代码开发 (对象存储服务OSS, 读取yml配置文件到java代码)

阿里云的对象存储服务 OSS(Object Storage Service),是一种云端文件存储服务,可以让你把任意格式的数据(如图片、视频、文档、日志、备份文件等)存储在云端,并随时随地访问、下载或共享

Bucket(存储空间)类似于文件夹,用来组织和隔离你的文件资源。每个 Bucket 的名称是唯一的。

Object(对象)就是你存储的文件,比如图片、视频、压缩包。每个 Object 有一个唯一的 Key(路径名)。

Endpoint(访问域名):用来访问你存储资源的地址。

AccessKey ID / Secret:访问 OSS 所需的身份凭证,用于身份认证。

在使用阿里云OSS之前,需要在application.yml进行配置:

1
2
3
4
5
6
sky:
alioss:
endpoint: ${sky.alioss.endpoint}
access-key-id: ${sky.alioss.access-key-id}
access-key-secret: ${sky.alioss.access-key-secret}
bucket-name: ${sky.alioss.bucket-name}

为什么要使用引用方式配置?

我们的项目会经历开发阶段、测试阶段和生产阶段,不同阶段对应的服务器(如数据库、OSS、Redis 等)配置往往不同。

因此,我们会将通用配置放在主配置文件 application.yml 中,而将环境相关的配置分别放在 application-dev.ymlapplication-test.ymlapplication-prod.yml 文件中。

Spring Boot 会通过设置 spring.profiles.active 属性来决定当前使用哪一个环境配置文件。

例如:

1
2
3
4
5
6
7
8
9
10
11
spring:
profiles:
active: dev
main:
allow-circular-references: true
datasource:
druid:
driver-class-name: ${sky.datasource.driver-class-name}
url: jdbc:mysql://${sky.datasource.host}:${sky.datasource.port}/${sky.datasource.database}?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
username: ${sky.datasource.username}
password: ${sky.datasource.password}

在这里,active的就是dev,也就是application-dev.yml是我们当前有效的配置文件。下面的sql配置也是使用引用方式,具体值写在dev配置文件中:

1
2
3
4
5
6
7
8
sky:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
host: localhost
port: 3030
database: sky_take_out
username: root
password: ****

因此我们主配置文件使用引用方式,而不是写死,具体配置的值在dev,test,prod等配置文件中写,根据开发阶段,测试阶段,生产阶段的不同,主配置文件上面会写当前有效的是哪个配置文件。

让我们回到阿里云OSS配置。(这里需要我们在阿里云申请一个OSS,申请过程略)完善application-dev.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
sky:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
host: localhost
port: 5005
database: sky_take_out
username: root
password: 123456
alioss:
endpoint: oss-cn-beijing.aliyuncs.com
bucket-name: ***
access-key-secret: ***
access-key-id: ***

该项目已经开发好了一个用于OSS上传文件的工具类:sky-common/src/main/java/com/sky/utils/AliOssUtil.java

1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
@AllArgsConstructor
@Slf4j
public class AliOssUtil {

private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String bucketName;
public String upload(byte[] bytes, String objectName) {
//上传逻辑
}
}

这个类的字段没有被初始化,我们拿不到这些字段值无法进行上传。我们现在的任务就是从配置文件中读取配置,然后把配置赋值给这里的字段。怎么做呢?

Spring Boot 项目中,我们可以非常优雅地将 application.yml 中的配置读取到 Java 代码中使用

方法就是通过创建一个配置属性类,我们使用 @ConfigurationProperties 注解来绑定配置,@Component@Configuration 让它成为 Spring Bean。@ConfigurationProperties(prefix = "aliyun.oss") 指的是你要读取的配置路径。使用 @Data(Lombok)自动生成 getter/setter。

在我们的项目中,我们创建了一个配置属性类sky-common/src/main/java/com/sky/properties/AliOssProperties.java

1
2
3
4
5
6
7
8
9
@Component
@ConfigurationProperties(prefix = "sky.alioss")
@Data
public class AliOssProperties {
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String bucketName;
}

接下来再创建一个配置类sky-server/src/main/java/com/sky/config/OssConfiguration.java。

这个类基于AliOssProperties创建一个 OSS 工具类 AliOssUtil,并将其注册为 Spring 的 Bean,在项目中全局可用:

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
@Slf4j
public class OssConfiguration {
@Bean
@ConditionalOnMissingBean
public AliOssUtil aliOssUtil(AliOssProperties aliOssProperties){
return new AliOssUtil(aliOssProperties.getEndpoint(),
aliOssProperties.getAccessKeyId(),
aliOssProperties.getAccessKeySecret(),
aliOssProperties.getBucketName());
}
}

这里 @BeanaliOssUtil(...) 方法返回的对象注入到 Spring 容器中,这样这个方法就自动被执行,把这个AliOssProperties对象创建出来并交给 Spring 管理。@ConditionalOnMissingBean保证只创建一次。

综上,理一遍过程。

先在阿里云OSS进行申请;

然后在application.yml配置阿里云OSS,注意使用引用方式;然后在application-dev.yml配置具体的值;

实现用于OSS上传文件的工具类AliOssUtil,注意使用全部参数构造方法注解@AllArgsConstructor,便于后面对象创建;

接下来创建AliOssProperties类,使用 @ConfigurationProperties 注解来绑定配置,自动将 application.yml 中的配置项绑定到 Java 对象里,方便后续直接用对象访问配置值;

然后创建OssConfiguration类,基于AliOssProperties创建一个 OSS 工具类 AliOssUtil,并将其注册为 Spring 的 Bean,在项目中全局可用。

day3-7 新增菜品-图片上传控制器 (UUID)

编写控制器代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RestController
@RequestMapping("/admin/common")
@Api(tags = "通用接口")
@Slf4j
public class CommonController {
@Autowired
private AliOssUtil aliOssUtil;

@ApiOperation("文件上传")
@PostMapping("/upload")
public Result<String> upload(MultipartFile file){
try {
String originalFilename=file.getOriginalFilename();
String extension=originalFilename.substring(originalFilename.lastIndexOf("."));
String objectName=UUID.randomUUID().toString() + extension;
String filePath=aliOssUtil.upload(file.getBytes(), objectName);
return Result.success(filePath);
} catch (Exception e) {
log.error(String.valueOf(e));
}
return Result.error(MessageConstant.UPLOAD_FAILED);
}
}

自动注入我们前面的工具类AliOssUtil并调用方法。注意这里使用UUID生成唯一的文件名,避免被覆盖。

day3-8 新增菜品-代码开发 (多表插入, 事务注解, 主键回填)

回顾一下现在需要开发的接口的文档:

我们可以把这个Body封装为DTO:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Data
public class DishDTO implements Serializable {
private Long id;
//菜品名称
private String name;
//菜品分类id
private Long categoryId;
//菜品价格
private BigDecimal price;
//图片
private String image;
//描述信息
private String description;
//0 停售 1 起售
private Integer status;
//口味
private List<DishFlavor> flavors = new ArrayList<>();
}

其中DishFlavor是口味实体类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DishFlavor implements Serializable {

private static final long serialVersionUID = 1L;
private Long id;
//菜品id
private Long dishId;
//口味名称
private String name;
//口味值
private String value;
}

接下来写控制器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequestMapping("/admin/dish")
@Api(tags = "菜品接口")
@Slf4j
public class DishController {
@Autowired
private DishService dishService;

@PostMapping
@ApiOperation("新增菜品")
public Result save(@RequestBody DishDTO dishDTO){
dishService.saveWithFlavor(dishDTO);
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
@Service
@Slf4j
public class DishServiceImpl implements DishService {
@Autowired
private DishMapper dishMapper;

@Autowired
private DishFlavorMapper dishFlavorMapper;

@Transactional
public void saveWithFlavor(DishDTO dishDTO) {
Dish dish=new Dish();
BeanUtils.copyProperties(dishDTO,dish);
dishMapper.insert(dish);
Long dishId=dish.getId();
List<DishFlavor> flavors=dishDTO.getFlavors();
if(flavors!=null && flavors.size()>0){
flavors.forEach(dishFlavor -> {
dishFlavor.setDishId(dishId);
});
dishFlavorMapper.insertBatch(flavors);
}
}
}

注意这里@Transactional 是 Spring 框架中用于 声明式事务管理 的注解,确保一组数据库操作要么全部成功,要么全部失败(回滚)。

@Transactional要用在 Service 层而不要用在其他层。

@Transactional要使用需要在启动类开启注解方式的事务管理。

启动类:

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

回到service代码,解释一下这里的业务逻辑:由于我们添加菜品的时候需要对两个表进行操作,一个是菜品表(Dish)一个是口味表(DishFlavor),因此需要调用两个不同的mapper。

在添加菜品(Dish)的时候,由于数据库中存的是菜品,因此要把DTO转成实体,用BeanUtils.copyProperties属性拷贝即可,创建一个新的Dish对象,再把它插入数据库,很简单。

问题在于口味表(DishFlavor),口味表中有一个字段叫做dish_id,也就是菜品的ID,但是前端传来的DTO中是不包含dish_id的(dish_id是dish插入数据库后,数据库自己生成的),这样我们需要把这个主键值回传回java代码中。

要实现这个需求,我们先看看DishMapper.xml是怎么写的:

DishMapper:

1
2
@AutoFill(OperationType.INSERT)
void insert(Dish dish);

DishMapper.xml:

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

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

重点在于useGeneratedKeys=”true”以及keyProperty=”id”。前者表示:获取数据库生成的主键(如自增主键),然后将这个主键值回填到 Java 对象中,后者表示:MyBatis 会把数据库生成的主键值,赋值给 Java 对象中的哪个属性。这里是 id,表示主键值将回填到 Dish 对象的 id 字段。

这样让我们再看service代码:

1
2
dishMapper.insert(dish);
Long dishId=dish.getId();

前面调用insert,mapper通过回填把dish的id回填到java对象dish中,后面就可以直接调用getter把id取出来。

取出来之后,通过setter赋值,给口味赋值dishId。

最后调用dishFlavorMapper.insertBatch()方法把口味存到数据库。

DishFlavorMapper.xml如下(mapper接口就一句话,这里不单列出了):

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

<insert id="insertBatch">
insert into dish_flavor (dish_id, name, value) VALUES
<foreach collection="flavors" item="df" separator=",">
(#{df.dishId}, #{df.name}, #{df.value})
</foreach>
</insert>
</mapper>

这里使用了foreach,解释一下:

collection="flavors":表示要遍历的集合(参数中传入的 List 或数组);

item="df":每次遍历出来的单个元素,变量名为 df

separator=","每个项之间用英文逗号 , 连接,生成合法的 VALUES 语句;

#{df.xxx}:就是取出这个对象的字段值。

其他都好理解,因为我们传入了一个List,但separator=”,”是什么意思?

设置 separator="," 是为了构造合法的 SQL 语句,确保多个 VALUES 子句之间用逗号分隔:

如果你希望执行的是如下格式的 SQL:

1
2
3
4
insert into dish_flavor (dish_id, name, value) VALUES
(1, '辣', '微辣'),
(1, '甜', '中甜'),
(1, '麻', '中麻');

可以看到多个 (值, 值, 值) 之间是用英文逗号 , 分隔的,separator=”,”正是让每次循环之间用逗号隔开,符合 SQL 写法。

现在新增菜品的业务代码已经全部实现了。

day3-10 菜品分页查询-需求分析

注意!!!返回数据中有一个叫做categoryName,但是我们菜品表中只有分类的ID,并没有存名称!

day3-11 菜品分页查询-代码开发 (多表查询, 外连接)

首先根据query字段封装DTO:

1
2
3
4
5
6
7
8
@Data
public class DishPageQueryDTO implements Serializable {
private int page;
private int pageSize;
private String name;
private Integer categoryId;
private Integer status;
}

然后根据返回数据设计VO(因为要返回给前端的数据和目前的类都不匹配,也即categoryName属性在菜品表中并不存在,因此创建一个VO,VO字段和返回数据字段匹配):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DishVO implements Serializable {
private Long id;
private String name;
private Long categoryId;
private BigDecimal price;
private String image;
private String description;
private Integer status;
private LocalDateTime updateTime;
private String categoryName;
private List<DishFlavor> flavors = new ArrayList<>();
}

控制器:

1
2
3
4
5
6
@GetMapping("/page")
@ApiOperation("菜品分页查询")
public Result<PageResult> page(DishPageQueryDTO dishPageQueryDTO){
PageResult pageResult=dishService.pageQuery(dishPageQueryDTO);
return Result.success(pageResult);
}

复习一下PageResult:

1
2
3
4
5
6
7
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PageResult implements Serializable {
private long total; //总记录数
private List records; //当前页数据集合
}

我们可以看到有total和records,完美符合接口文档返回数据的设计,同时records中存的VO和接口文档返回数据也一致。

service,其实就是分页查询的模板了:

1
2
3
4
5
public PageResult pageQuery(DishPageQueryDTO dishPageQueryDTO) {
PageHelper.startPage(dishPageQueryDTO.getPage(),dishPageQueryDTO.getPageSize());
Page<DishVO> page=dishMapper.pageQuery(dishPageQueryDTO);
return new PageResult(page.getTotal(),page.getResult());
}

接下来要实现mapper,写mapper之前,我们可以先在查询终端写sql看看能不能查询出来:

1
select d.*,c.name as categoryName from dish d left outer join category c on d.category_id = c.id

上面的sql语句可以正常执行。注意这里必须改别名为categoryName,不然mybatis无法进行封装。

下面我们来写mapper.xml:

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

解释一下,Page<DishVO> page=dishMapper.pageQuery(dishPageQueryDTO);这句话意味着,mapper传入参数是DTO,返回值是VO。

这里传入的DTO其实就是查询的条件。如果DTO是空的就没查询条件;如果DTO只有status意味着只需要进行状态筛选。这个逻辑对应着mapper.xml中的3个if语句,例如<if test="categoryId!=null">and d.category_id = #{categoryId}</if>这句话,categoryId就是DTO的字段,category_id是数据库表字段。

至此代码编写完毕。

day3-12 删除菜品-需求分析

可以一次删除一个,也可以批量删除。起售中的菜品不能删除。被套餐关联的不能删除。被套餐关联的菜品不能删除。删除菜品后把口味也删除。

day3-13 删除菜品-代码开发 (@RequestParam, @PathVariable, @RestControllerAdvice)

控制器:

1
2
3
4
5
6
@DeleteMapping
@ApiOperation("菜品批量删除")
public Result delete(@RequestParam List<Long> ids){
dishService.deleteBatch(ids);
return Result.success();
}

当 Spring 看到 @RequestParam List<Long> ids 时,会自动将 ids 里的字符串 1,2,3 转换为 List<Long>,即每个逗号分隔的数字会变成 Long 类型并放入 List 中。

注意区别@RequestParam和之前的@PathVariable

1
2
3
4
5
6
@PostMapping("/status/{status}")
@ApiOperation("启用禁用员工账号")
public Result startOrStop(@PathVariable Integer status, Long id){
employeeService.startOrStop(status, id);
return Result.success();
}
对比项 @PathVariable @RequestParam
来源 路径参数 /user/{id} 查询参数 ?id=123
示例路径 /user/123 /user?id=123

service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Transactional
public void deleteBatch(List<Long> ids) {
for (Long id : ids) {
Dish dish=dishMapper.getByID(id);
if (dish.getStatus().equals(StatusConstant.ENABLE)){
throw new DeletionNotAllowedException(MessageConstant.DISH_ON_SALE);
}
}
List<Long> setmealIds=setmealDishMapper.getSetmealIdsByDishIds(ids);
if(setmealIds!=null && setmealIds.size()>0){
throw new DeletionNotAllowedException(MessageConstant.DISH_BE_RELATED_BY_SETMEAL);
}
for (Long id : ids) {
dishMapper.deleteById(id);
dishFlavorMapper.deleteByDishId(id);
}
}

我们在写这个代码的时候有一个问题,为什么在service中throw了异常,且controller中没有try-catch却没有报错?

答案是我们用了全局异常捕获类sky-server/src/main/java/com/sky/handler/GlobalExceptionHandler.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
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

@ExceptionHandler
public Result exceptionHandler(BaseException ex){
log.error("异常信息:{}", ex.getMessage());
return Result.error(ex.getMessage());
}

@ExceptionHandler
public Result exceptionHandler(SQLIntegrityConstraintViolationException ex){
String msg=ex.getMessage();
log.error("异常信息:{}", ex.getMessage());
if(msg.contains("Duplicate entry")){
String[] tmp=msg.split(" ");
String username=tmp[2];
String errMsg=username+" 已存在";
return Result.error(errMsg);
} else{
return Result.error(MessageConstant.UNKNOWN_ERROR);
}
}
}

其中这里BaseException extends RuntimeException,而我们的自定义异常类都继承BaseException。

BaseException也是我们自定义的,sky-common/src/main/java/com/sky/exception/BaseException.java。这样写就可以把所有自定义的运行时异常全部全局捕获。

如果你没有在 Controller 中显式地捕获异常,Spring 可以通过 @RestControllerAdvice 来集中处理所有控制器中的异常。@RestControllerAdvice 可以作为全局异常处理器,捕获控制器中抛出的异常,并返回统一的错误响应。

继续写mapper(套餐):

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" item="dishId" separator="," open="(" close=")">
#{dishId}
</foreach>
</select>

解释一下这个动态sql:

这段sql最终等效的sql应该是:select setmeal_id from setmeal_dish where dish_id in (1,2,3)

注意后面的(1,2,3),这里我们需要加括号(使用open和close),同时每个dishId用逗号分隔(separator)。

其余的deleteById等mapper接口,太简单,直接注解sql语句即可,这里略。

修改菜品day3-15 修改菜品-需求分析

复杂点主要在回显,修改很简单。

day3-16 修改菜品-查询接口开发

查询采用路径参数/{id},别忘了@PathVariable。

返回值由于和实体类差别较大,因此封装为VO对象,VO对象和返回值字段一一对应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DishVO implements Serializable {
private Long id;
private String name;
private Long categoryId;
private BigDecimal price;
private String image;
private String description;
private Integer status;
private LocalDateTime updateTime;
private String categoryName;
private List<DishFlavor> flavors = new ArrayList<>();
}

很简单,建议不看。

controller:

1
2
3
4
5
6
@ApiOperation("根据id查询菜品")
@GetMapping("/{id}")
public Result<DishVO> getById(@PathVariable Long id){
DishVO dishVO=dishService.getByIdWithFlavor(id);
return Result.success(dishVO);
}

service:

1
2
3
4
5
6
7
8
public DishVO getByIdWithFlavor(Long id) {
Dish dish=dishMapper.getByID(id);
List<DishFlavor> dishFlavors=dishFlavorMapper.getByDishId(id);
DishVO dishVO=new DishVO();
BeanUtils.copyProperties(dish, dishVO);
dishVO.setFlavors(dishFlavors);
return dishVO;
}

mapper:

1
2
3
4
@Select("select * from dish where id = #{id}")
Dish getByID(Long id);
@Select("select * from dish_flavor where dish_id = #{dishId}")
List<DishFlavor> getByDishId(Long dishId);

开发完毕。

day3-17 修改菜品-修改接口开发

很简单,建议不看。

controller:

1
2
3
4
5
6
@PutMapping
@ApiOperation("修改菜品")
public Result update(@RequestBody DishDTO dishDTO){
dishService.updateWithFlavor(dishDTO);
return Result.success();
}

service:

1
2
3
4
5
6
7
8
9
10
11
12
13
public void updateWithFlavor(DishDTO dishDTO) {
Dish dish=new Dish();
BeanUtils.copyProperties(dishDTO, dish);
dishMapper.update(dish);
dishFlavorMapper.deleteByDishId(dishDTO.getId());
List<DishFlavor> flavors=dishDTO.getFlavors();
if(flavors!=null && flavors.size()>0){
flavors.forEach(dishFlavor -> {
dishFlavor.setDishId(dishDTO.getId());
});
dishFlavorMapper.insertBatch(flavors);
}
}

逻辑:先把之前该菜品的口味全删了,然后重新批量插入。批量插入之前,设置dishId。

mapper(这里只写更新的):

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

都是老套路了,记住就行。

day4 套餐管理模块

略,没有新知识。

day5-2 Redis入门

Redis和Mysql都是数据库,但是Redis是内存存储(读写性能高),Mysql在磁盘存储。

Redis基于key-value存储数据。

Redis适合存储热点数据(相当短的时间内大量的访问)。

下载安装启动

redis直接解压即可使用。

启动方法:在项目目录下打开终端,输入 .\redis-server.exe .\redis.windows.conf

上面成功打开了服务端,现在打开客户端,新开终端:.\redis-cli.exe

当然客户端可以指定IP和Port:.\redis-cli.exe -h localhost -p 6379

现在通过修改配置文件 redis.windows.conf 修改密码,添加这一行:

1
requirepass 123456

现在再在客户端输入命令:

1
2
127.0.0.1:6379> keys *
(error) NOAUTH Authentication required.

发现无权限。我们通过-a参数输入密码打开客户端:

1
2
3
.\redis-cli.exe -a 123456
127.0.0.1:6379> keys *
(empty list or set)

Redis也可以通过图形界面操作。下载Another-Redis-Desktop-Manager.1.5.5并安装,安装和连接都很简单,略。

点击左上角的终端符号进入终端:

在最底下有命令输入栏。

在这里可以选择数据库(总共有16个),注意不同数据库之间的数据完全隔离:

day5-3 Redis数据类型

Redis存储k-v数据类型,其中key是字符串,value对应5种常用数据类型:string,hash,list,set,zset (有序set)。

hash适合存对象(属性+值);list适用于有顺序的,如朋友圈,可以左插可以右插;set集合,可以计算并集交集等,如计算两人共同朋友;zset,适用于排行榜。

day5-4 Redis命令

  1. 字符串类型
1
2
3
SET key value         # 设置一个字符串值
GET key # 获取字符串值
DEL key # 删除键
1
2
3
4
> set name jack
OK
> get name
jack
  1. 哈希类型
1
2
3
4
5
HSET key field value
HGET key field
HDEL key field
HKEYS key #获取哈希表中所有字段
HVALS key #获取哈希表所有值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
> hset 100 name xiaoming
1
> hset 100 age 22
1
> hget 100 name
xiaoming
> hget 100 age
22
> hkeys 100
name
age
> hvals 100
xiaoming
22
  1. 列表类型

    左侧为头部,右侧为尾部,下图帮助理解:

1
2
3
4
5
LPUSH key val1 [val2]              # 向列表头部插入元素(L代表Left,若尾部则RPUSH)
LRANGE key start stop # 获取列表指定范围内元素
LRANGE key 0 -1 # 获取列表所有元素
RPOP key # 获取并移除最右侧元素
LLEN key # 获取元素个数
1
2
3
4
5
6
7
8
9
10
11
> lpush mylist a b c
3
> lpush mylist d
4
> lrange mylist 0 -1
d
c
b
a
> rpop mylist
a
  1. 集合类型
1
2
3
4
5
6
7
SADD key e1 [e2]              # 向集合添加元素
SREM key e1 [e2] # 集合删除元素
SMEMBERS key # 获取集合的所有成员
SCARD key # 获取集合成员数
SISMEMBER key e1 # 检查成员是否在集合中
SINTER key1 key2 [key3] # 交集
SUNION key1 key2 [key3] # 并集
1
2
3
4
5
6
7
8
9
> sadd set1 a b c d
4
> scard set1
4
> sadd set2 a b x y
4
> sinter set1 set2
b
a
  1. 有序集合类型
1
2
3
4
ZADD key score1 member1 [score2 member2]           # 向有序集合添加成员及其分数
ZRANGE key start stop [WITHSCORES] # 获取有序集合的所有成员[及其分数]
ZINCRBY key increment member # 对指定成员分数加上increment(这个命令是指increase by)
ZREM key member1 [menber2] # 移除成员
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
> zadd zset1 10.0 a
1
> zadd zset1 10.5 b
1
> zadd zset1 10.2 c
1
> ZINCRBY zset1 5.0 a
15
> zrange zset1 0 -1 withscores
c
10.199999999999999
b
10.5
a
15
  1. 通用命令
1
2
3
4
KEYS pattern	# pattren是指某一个模式,例如*返回所有key
EXISTS key
TYPE key
DEL key
1
2
3
4
5
6
7
8
9
10
> keys *
zset1
set1
name
set2
100
mylist
> keys set*
set1
set2

day5-10 在 Java 中操作 Redis

使用官方提供的 Spring Data Redis,它提供了便捷的封装和自动配置,让我们可以像操作普通对象一样使用 Redis。

步骤:引入maven,配置Redis数据源(application.yml),编写配置类,创建RedisTemplate对象,使用RedisTemplate对象操作Redis。

首先是maven:

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

配置数据源,application.yml:

1
2
3
4
5
6
7
8
9
10
11
server:
port: 8080

spring:
profiles:
active: dev
redis:
host: ${sky.redis.host}
port: ${sky.redis.port}
password: ${sky.redis.password}
database: ${sky.redis.database}

application-dev.yml:

1
2
3
4
5
6
sky:
redis:
host: localhost
port: 6379
password: 123456
database: 10

编写配置类,sky-server/src/main/java/com/sky/config/RedisConfiguration.java,对key和value做序列化,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
@Configuration
@Slf4j
public class RedisConfiguration {

@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){
RedisTemplate redisTemplate=new RedisTemplate<>();

// 设置redis的连接工厂对象
redisTemplate.setConnectionFactory(redisConnectionFactory);

// 设置redis key的序列化
redisTemplate.setKeySerializer(new StringRedisSerializer());

// 使用Jackson做value的序列化
Jackson2JsonRedisSerializer<Object> jacksonSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
redisTemplate.setValueSerializer(jacksonSerializer);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(jacksonSerializer);

redisTemplate.afterPropertiesSet();

return redisTemplate;
}
}

创建一个测试类sky-server/src/test/java/com/sky/test/SpringDataRedisTest.java,采用单元测试 JUnit 5(Jupiter)测试框架,自动注入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
package com.sky.test;

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.core.*;

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

@Test
public void testString(){
ValueOperations valueOperations=redisTemplate.opsForValue();
valueOperations.set("city","北京beijing");
System.out.println((String)valueOperations.get("city")); // 输出 北京beijing
}

}

执行后成功在redis查找到存入的数据:

我们还可以继续测试 Hash 数据类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
public void testHash() {
HashOperations hashOperations=redisTemplate.opsForHash();
hashOperations.put("100","名字","张哥");
hashOperations.put("100","年龄","19");
String name=(String)hashOperations.get("100","名字");
System.out.println(name); //输出张哥

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

输出:

1
2
3
张哥
[名字, 年龄]
[张哥, 19]

其他类型都差不多,略过了。

day5-15 店铺营业状态设置-需求分析

那么我们怎么存储这个营业状态呢?可以基于Redis的字符串存储!

key:SHOP_STATUS

value:0或1

day5-16 店铺营业状态设置-代码开发

先把测试类注释掉。

然后直接写控制器(因为逻辑简单,直接写在控制器里了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RestController
@RequestMapping("/admin/shop")
@Slf4j
public class ShopController {

@Autowired
private RedisTemplate redisTemplate;

@PutMapping("/{status}")
public Result setStatus(@PathVariable Integer status){
redisTemplate.opsForValue().set("SHOP_STATUS",status);
return Result.success();
}

@GetMapping("/status")
public Result<Integer> getStatus(){
return Result.success((Integer)redisTemplate.opsForValue().get("SHOP_STATUS"));
}
}

然后在用户端也创建一个控制器,这是我们的第一个用户端控制器:

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController("userShopController")
@RequestMapping("/user/shop")
@Slf4j
public class ShopController {

@Autowired
private RedisTemplate redisTemplate;

@GetMapping("/status")
public Result<Integer> getStatus(){
return Result.success((Integer)redisTemplate.opsForValue().get("SHOP_STATUS"));
}
}

由于两个都叫ShopController,Spring管理Bean时候发现这两个Bean重名,因此冲突。我们解决方案就是加上@RestController(“userShopController”)。

现在我们修改一下swagger配置,因为现在用户和管理员的接口混在一起,不好区分,因此我们分成两页面:

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
@Bean
public Docket docket() {
ApiInfo apiInfo = new ApiInfoBuilder()
.title("苍穹外卖项目接口文档")
.version("2.0")
.description("苍穹外卖项目接口文档")
.build();
Docket docket = new Docket(DocumentationType.SWAGGER_2)
.groupName("管理端接口")
.apiInfo(apiInfo)
.select()
.apis(RequestHandlerSelectors.basePackage("com.sky.controller.admin"))
.paths(PathSelectors.any())
.build();
return docket;
}

@Bean
public Docket docket2() {
ApiInfo apiInfo = new ApiInfoBuilder()
.title("苍穹外卖项目接口文档")
.version("2.0")
.description("苍穹外卖项目接口文档")
.build();
Docket docket = new Docket(DocumentationType.SWAGGER_2)
.groupName("用户端接口")
.apiInfo(apiInfo)
.select()
.apis(RequestHandlerSelectors.basePackage("com.sky.controller.user"))
.paths(PathSelectors.any())
.build();
return docket;
}

day6-2 HttpClient

HttpClient 是一个用于在Java程序通过编码的方式发送 HTTP 请求的工具。

可以模拟发 GET、POST、PUT、DELETE 等请求,并接收请求的响应。

发送GET请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void testGET() throws Exception{
//创建httpClient对象
//CloseableHttpClient 实现了 HttpClient
CloseableHttpClient httpClient= HttpClients.createDefault();

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

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

//获取状态码
System.out.println("status code:"+response.getStatusLine().getStatusCode());//status code:200

//获取响应数据
HttpEntity entity=response.getEntity();
String body=EntityUtils.toString(entity);
System.out.println("data:"+body);//data:{"code":1,"msg":null,"data":0}

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

发送POST请求

需要用JSONObject构建一个json的请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public void testPOST() throws Exception{
CloseableHttpClient httpClient= HttpClients.createDefault();
HttpPost httpPost=new HttpPost("http://localhost:8080/admin/employee/login");

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

HttpEntity httpEntity=response.getEntity();
String body=EntityUtils.toString(httpEntity);
System.out.println("data:"+body);

response.close();
httpClient.close();
}

为了便于后续使用,封装为工具类:sky-common/src/main/java/com/sky/utils/HttpClientUtil.java。

day6-6 微信小程序开发-准备工作

首先注册。

已注册就右侧点击立即登录,完善信息。

保存小程序ID和密钥。

下载微信开发者工具。

点击加号新建小程序,选择如下:

在详情-本地设置中把以下选项打勾:

调试基础库调整为2.xx.xx:

小程序目录结构:

day6-8 微信小程序开发入门

wxml中的<view>标签,类似于html中的<div>标签。

Vue Template WXML 语法 说明
<div>{{ msg }}</div> <view>{{ msg }}</view> 只能使用小程序内置组件
v-if="ok" wx:if="{{ok}}" 条件渲染
v-for="item in list" wx:for="{{list}}" 列表渲染
@click="clickFn" bindtap="clickFn" 事件绑定

js类似于vue:

1
2
3
4
5
6
7
8
9
10
11
12
13
// index.js
Page({
data: {
msg: "Hello World",
items: [{id: 1, name: "A"}, {id: 2, name: "B"}]
},
onLoad() {
console.log("页面加载")
},
clickFn() {
this.setData({ msg: "Clicked!" });
}
})

现在让我们写一个简单的小程序:

1
2
3
4
5
6
7
8
9
10
11
12
<view class="container">
<text class="title">欢迎来到小程序</text>
<text class="message">{{message}}</text>

<button bindtap="changeList">更新列表</button>

<view class="list">
<view wx:for="{{items}}" wx:key="id" class="item">
{{index + 1}}. {{item.name}}
</view>
</view>
</view>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Page({
data: {
message: '初始列表如下:',
items: [
{ id: 1, name: '苹果' },
{ id: 2, name: '香蕉' },
{ id: 3, name: '橘子' }
]
},

changeList() {
this.setData({
message: '列表已更新:',
items: [
{ id: 4, name: '葡萄' },
{ id: 5, name: '西瓜' },
{ id: 6, name: '芒果' }
]
});
}
});

效果:

day6-15 微信登录 (JWT, HttpClient)

这里的开发者服务器就是指我们的后端。

后端服务器请求微信接口服务,需要使用之前的HttpClient。

自定义登录态:我们这里使用Token。

现在进入后端代码开发,首先配置微信小程序id和密钥:

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

配置用户端的jwt:

1
2
3
4
5
6
7
8
sky:
jwt:
admin-secret-key: itcast
admin-ttl: 7200000
admin-token-name: token
user-secret-key: itheima
user-ttl: 7200000
user-token-name: authentication

接下来开始写控制器,在写控制器之前先确定参数和返回值,虽然这里参数只有一个code,但还是给封装到DTO中:

1
2
3
4
@Data
public class UserLoginDTO implements Serializable {
private String code;
}

同时封装VO:

1
2
3
4
5
6
7
8
9
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserLoginVO implements Serializable {
private Long id;
private String openid;
private String token;
}

创建controller:

这里User类的字段,和上面的数据库一致。

先调用service,获取User对象(获取openid),然后给用户创建jwt令牌token,随后把openid,token,用户主键封装为VO返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
@PostMapping("/login")
public Result<UserLoginVO> login(@RequestBody UserLoginDTO userLoginDTO){
User user = userService.wxLogin(userLoginDTO);
Map<String, Object> claims=new HashMap<>();
claims.put(JwtClaimsConstant.USER_ID,user.getId());
String token=JwtUtil.createJWT(jwtProperties.getUserSecretKey(), jwtProperties.getUserTtl(), claims);
UserLoginVO userLoginVO=UserLoginVO.builder()
.id(user.getId())
.openid(user.getOpenid())
.token(token)
.build();
return Result.success(userLoginVO);
}

现在写service:

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
@Service
public class UserServiceImpl implements UserService {
public static final String WX_LOGIN="https://api.weixin.qq.com/sns/jscode2session";
@Autowired
private WeChatProperties weChatProperties;
@Autowired
private UserMapper userMapper;

public User wxLogin(UserLoginDTO userLoginDTO) {
Map<String,String> map=new HashMap<>();
map.put("appid",weChatProperties.getAppid());
map.put("secret",weChatProperties.getSecret());
map.put("js_code", userLoginDTO.getCode());
map.put("grant_type", "authorization_code");
String json= HttpClientUtil.doGet(WX_LOGIN,map);

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

if(openid==null){
throw new LoginFailedException(MessageConstant.LOGIN_FAILED);
}

User user=userMapper.getByOpenid(openid);
if(user==null){
user=User.builder()
.openid(openid)
.createTime(LocalDateTime.now())
.build();
userMapper.insert(user);
}

return user;
}
}

首先构造请求向微信的api通过HttpClient发送,返回并提取openid,然后判断该用户是否在数据库存在,如果不存在就插入用户数据,如果存在直接返回user。

mapper代码略。

现在通过小程序来测试一下登录:

最后,我们需要创建JWT拦截器,用于身份的校验。首先在interceptor包下面创建一个JwtTokenUserInterceptor:

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

import com.sky.constant.JwtClaimsConstant;
import com.sky.context.BaseContext;
import com.sky.properties.JwtProperties;
import com.sky.utils.JwtUtil;
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

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

@Autowired
private JwtProperties jwtProperties;

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

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

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

然后在配置类WebMvcConfiguration中进行注册:

1
2
3
4
5
6
7
8
9
10
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");
}

day6-19 商品浏览-代码导入

产品原型:

导入代码(略)

day7-2 缓存菜品-代码开发 (Redis)

key:分类ID,dish_categoryId

value:字符串,每个分类下的菜品List

菜品有变更的时候需要及时清理缓存数据。

现在让我们开始实现代码:

首先是user下的DishController:

先注入RedisTemplate:

1
2
@Autowired
private RedisTemplate redisTemplate;

改造控制器逻辑!!!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@GetMapping("/list")
@ApiOperation("根据分类id查询菜品")
public Result<List<DishVO>> list(Long categoryId) {
String key="dish_"+categoryId;//构造key
List<DishVO> list= (List<DishVO>) redisTemplate.opsForValue().get(key);//查询redis
if(list!=null&&list.size()>0){
return Result.success(list);//返回缓存
}

//缓存不存在,从数据库查
Dish dish = new Dish();
dish.setCategoryId(categoryId);
dish.setStatus(StatusConstant.ENABLE);//查询起售中的菜品

list = dishService.listWithFlavor(dish);
redisTemplate.opsForValue().set(key,list);//存入缓存

return Result.success(list);
}

然后测试。测试过程中出现报错:Jackson 默认 不支持 Java 8 时间类(如 LocalDateTime)的序列化与反序列化,Redis 序列化时使用的是默认的 ObjectMapper,从而无法识别 LocalDateTime

1
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type java.time.LocalDateTime not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (through reference chain: java.util.ArrayList[0]->com.sky.vo.DishVO["updateTime"])

解决方案:我们之前写过一个自定义的 ObjectMapper

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
public class JacksonObjectMapper extends ObjectMapper {

public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
//public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm";
public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss";

public JacksonObjectMapper() {
super();
//收到未知属性时不报异常
this.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);

//反序列化时,属性不存在的兼容处理
this.getDeserializationConfig().withoutFeatures(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);

SimpleModule simpleModule = new SimpleModule()
.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)))
.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)));

//注册功能模块 例如,可以添加自定义序列化器和反序列化器
this.registerModule(simpleModule);
}
}

在Redis配置中,手动传入自定义的 ObjectMapperJackson2JsonRedisSerializer即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){
RedisTemplate redisTemplate=new RedisTemplate<>();

// 设置redis的连接工厂对象
redisTemplate.setConnectionFactory(redisConnectionFactory);

// 设置redis key的序列化
redisTemplate.setKeySerializer(new StringRedisSerializer());

// 使用自定义的 ObjectMapper
ObjectMapper objectMapper = new JacksonObjectMapper();
Jackson2JsonRedisSerializer<Object> jacksonSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
jacksonSerializer.setObjectMapper(objectMapper);
// 使用Jackson做value的序列化
redisTemplate.setValueSerializer(jacksonSerializer);
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(jacksonSerializer);

redisTemplate.afterPropertiesSet();

return redisTemplate;
}

现在,有一个问题,缓存和数据库的数据可能出现不一致的问题,当管理员修改/停售/菜品/删除菜品的时候,redis缓存的数据需要被清除!!

因此还需要修改管理端的controller:

以新增菜品举例:

1
2
3
4
5
6
7
8
@PostMapping
@ApiOperation("新增菜品")
public Result save(@RequestBody DishDTO dishDTO){
dishService.saveWithFlavor(dishDTO);
String key="dish_"+dishDTO.getCategoryId();
redisTemplate.delete(key);
return Result.success();
}

其实这里可以抽取这个公共方法,然后采用面向切片编程,时间紧张,先略过。

day7-6 Spring Cache 框架使用

基于注解的缓存功能,只需要加一个注解就可以实现缓存。

要使用需要导入maven。

注解 功能
@EnableCaching 开启 Spring 缓存功能,通常在启动类
@Cacheable 方法执行前查询缓存是否有数据,如果有就返回缓存数据,如果没有就调用方法并把返回值放到缓存中
@CachePut 执行方法并把返回值放入缓存(更新缓存)
@CacheEvict 清除一条或多条缓存
@Caching 组合多个缓存注解使用

让我们结合一个示例工程了解一下用法。第一步在启动类上加@EnableCaching,代码就不贴了。

第二步就是在controller上面加注解:

1
2
3
4
5
6
7
8
@PostMapping
@CachePut(cacheNames = "userCache", key = "#user.id")
//SpringCache框架中,key的生成为 cacheNames::key
//例如上面就是userCache_1, "#user.id"中user是方法传入的参数, 这个是SpEL表达式
public User save(@RequestBody User user){
userMapper.insert(user);
return user;
}

测试结果:redis和mysql都被插入这条数据。

接下来使用@Cacheable,在查询的controller上面加上该注解:

1
2
3
4
5
6
@GetMapping
@Cacheable(cacheNames = "userCache", key = "#id")
public User getById(Long id){
User user = userMapper.getById(id);
return user;
}

测试结果:成功从缓存查询。如果数据库有数据,缓存没有数据,会调用方法把数据放到缓存里。如果在缓存能查到,他压根不会执行控制器方法。

删除也加上:

1
2
3
4
5
6
7
8
9
10
11
@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();
}

day7-11 缓存套餐-代码开发 (Spring Cache)

com/sky/controller/user/SetmealController.java:

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

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

com/sky/controller/admin/SetmealController.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
@PostMapping
@CacheEvict(cacheNames = "setmealCache", key = "#setmealDTO.categoryId")
public Result save(@RequestBody SetmealDTO setmealDTO){
setmealService.saveWithDish(setmealDTO);
return Result.success();
}

@DeleteMapping
@CacheEvict(cacheNames = "setmealCache", allEntries = true)
public Result delete(@RequestParam List<Long> ids){
setmealService.delete(ids);
return Result.success();
}

其余代码略。

day7-13 添加购物车

可能传入菜品(无口味),菜品(有口味),套餐,因此接口设计:

设计一下购物车的数据库表,每个用户的购物车需要区分开来(注意购物车也需要持久化,因为我可能查询历史购物信息,还有就是如果我还没点完退出了,下一次登录,我应该还能保留上次没点完的继续点):

现在开始代码开发,DTO已经封装完毕,字段和Body一致,略。

控制器很简单,直接调用service就结束了,略。

服务层,注意userId是通过BaseContext拿到的,也就是jwt解析token并放到ThreadLocal中,我们可以直接取。

代码就不写了。看一下结果:

注意同一个菜品不同口味是分开不同项的。

day7-18 查看购物车-清空购物车

查看购物车和清空购物车完全不需要传入参数,因为用户ID通过ThreadLocal即可获取。

1
2
3
4
5
6
7
public List<ShoppingCart> showShoppingCart() {
Long userId=BaseContext.getCurrentId();
ShoppingCart shoppingCart=ShoppingCart.builder()
.userId(userId)
.build();
return shoppingCartMapper.list(shoppingCart);
}

其他代码都很简单,略。

day8-2 地址簿代码导入

导入略。

day8-5 用户下单

只给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
@Transactional
public OrderSubmitVO submitOrder(OrdersSubmitDTO ordersSubmitDTO) {
//处理业务异常,地址为空?购物车为空?
AddressBook addressBook=addressBookMapper.getById(ordersSubmitDTO.getAddressBookId());
if(addressBook==null){
throw new AddressBookBusinessException(MessageConstant.ADDRESS_BOOK_IS_NULL);
}
Long userId= BaseContext.getCurrentId();
ShoppingCart shoppingCart=new ShoppingCart();
shoppingCart.setUserId(userId);
List<ShoppingCart> shoppingCartList=shoppingCartMapper.list(shoppingCart);
if(shoppingCartList==null||shoppingCartList.size()==0){
throw new ShoppingCartBusinessException(MessageConstant.SHOPPING_CART_IS_NULL);
}

//订单表插入一条
//手动检查每一个字段是否填充
Orders 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 cart:shoppingCartList){
OrderDetail orderDetail=new OrderDetail();
BeanUtils.copyProperties(cart,orderDetail);
orderDetail.setOrderId(orders.getId());
orderDetailList.add(orderDetail);
}
orderDetailMapper.insertBatch(orderDetailList);

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

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

return orderSubmitVO;
}

day8-12 微信支付 (cpolar 内网穿透)

这个我们实现不了因为拿不到证书。

5.后端调用微信下单接口:

9.用户确认支付,前端调用 wx.requestPayment,此时小程序界面就弹出支付界面。

13.微信后台向后端发出回调接口,14.后端更新订单已完成状态。

接下来介绍cpolar:将本地服务映射到公网,使外部可以访问本地服务器。

常用于本地写了个微信/支付宝支付接口,提供给第三方回调这种场景。

概念 说明
隧道(Tunnel) 映射规则:本地端口 ➝ 公网地址
公网地址 系统自动生成的 xxxxx.cpolar.cn 地址
临时/固定地址 免费版每天地址变化,专业版可自定义固定域名
Web UI 控制台 访问 localhost:9200 可查看隧道状态

使用方法:下载安装,在官网找到隧道Authtoken复制,到安装目录打开cmd:

1
2
./cpolar.exe authtoken ********
Authtoken saved to configuration file: C:\Users\30516/.cpolar/cpolar.yml

接下来输入(8080取决于我们后端的端口号):

1
./cpolar http 8080

出现这样的界面:

现在我们就可以通过上面的域名访问了,比如我们访问接口文档:苍穹外卖项目接口文档,是能够正常访问的

在配置微信支付的时候,回调的网址就可以以这个为前缀。

我们拿不到证书,直接改前端代码,按支付按钮直接跳转到支付成功界面即可。

day10-1 订单定时处理 (SpringTask)

CRON表达式手写较困难,一般采用AI生成。

接下来让我们进入定时处理的开发。

用户下单后会出现两种情况,用户下单后一直未支付,完成后管理端一直不点完成。前者我们可以每分钟检查一次,后者一天检查一次(凌晨检查)。

这里不需要接口设计,和前端无关。

注意要加@Component注解,代码实现如下:

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
@Component
@Slf4j
public class OrderTask {
@Autowired
private OrderMapper orderMapper;

@Scheduled(cron = "0 * * * * ?") // 每分钟触发一次
public void processTimeoutOrder() {
LocalDateTime time = LocalDateTime.now().plusMinutes(-15);
List<Orders> ordersList = orderMapper.getByStatusAndOrderTimeLT(Orders.PENDING_PAYMENT, time);
if (ordersList != null && !ordersList.isEmpty()) {
for (Orders order : ordersList) {
order.setStatus(Orders.CANCELLED);
order.setCancelReason("订单超时未支付,已自动取消");
order.setCancelTime(LocalDateTime.now());
orderMapper.update(order);
}
}
}

@Scheduled(cron = "0 0 1 * * ?") // 每天凌晨一点触发一次
public void processDeliveryOrder(){
LocalDateTime time = LocalDateTime.now().plusMinutes(-60);
List<Orders> ordersList = orderMapper.getByStatusAndOrderTimeLT(Orders.DELIVERY_IN_PROGRESS, time);
if (ordersList != null && !ordersList.isEmpty()) {
for (Orders order : ordersList) {
order.setStatus(Orders.COMPLETED);
orderMapper.update(order);
}
}
}
}

day10-7 来单提醒-客户催单 (WebSocket)

WebSocket是基于TCP的网络协议,实现浏览器和服务器的全双工通信:一次握手,持久连接,进行双向数据传输。适合实现聊天软件等。

使用websocket需要导入maven。

配置:

1
2
3
4
5
6
7
@Configuration
public class WebSocketConfiguration {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}

简单的示例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
@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) {
sessionMap.put(sid, session); // 保存会话
}

@OnMessage
public void onMessage(String message, @PathParam("sid") String sid) {
// 处理客户端消息(当前仅日志记录)
}

@OnClose
public void onClose(@PathParam("sid") String sid) {
sessionMap.remove(sid); // 移除会话
}

public void sendToAllClient(String message) {
// 遍历所有会话发送消息(全量广播)
}
}

下面我们正式开始开发来单提醒+客户催单代码:

websocket服务类不变。

修改service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void paySuccess(String outTradeNo) {
// 根据订单号查询订单
Orders ordersDB = orderMapper.getByNumber(outTradeNo);
// 根据订单id更新订单的状态、支付方式、支付状态、结账时间
Orders orders = Orders.builder()
.id(ordersDB.getId())
.status(Orders.TO_BE_CONFIRMED)
.payStatus(Orders.PAID)
.checkoutTime(LocalDateTime.now())
.build();
orderMapper.update(orders);
Map map=new HashMap<>();
map.put("type",1);
map.put("orderId",ordersDB.getId());
map.put("content","订单号: "+outTradeNo);
String json=JSON.toJSONString(map);
websocketService.sendToAllClient(json);
}

上面解决了来单提醒,下面开发客户催单。用户可以按一个按钮来进行催单,该按钮的api如下:

1
2
3
4
5
@GetMapping("/reminder/{id}")
public Result reminder(@PathVariable Long id) {
orderService.reminder(id);
return Result.success();
}

service中调用websocket,对后台管理客户端发出消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void reminder(Long id) {
// 根据id查询订单
Orders ordersDB = orderMapper.getById(id);

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

Map map=new HashMap<>();
map.put("type",2);
map.put("orderId",id);
map.put("content","订单号: "+ordersDB.getNumber());
String json=JSON.toJSONString(map);
websocketService.sendToAllClient(json);
}

代码实现完毕。

day11-1 后台数据统计 (Apache ECharts)

Apache ECharts是前端技术,是一个由 Apache 软件基金会孵化和维护的 开源、纯 JavaScript 实现的可视化图表库,用于在网页中绘制丰富、交互性强的数据可视化图表。链接:快速上手 - 使用手册 - Apache ECharts

这块没啥新东西,纯无聊,只要大三数据库好好写这些sql都会写。

也就这个sql能讲讲:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<select id="sumByMap" resultType="java.lang.Double">
select sum(amount) from orders
<where>
<if test="status != null">
and status = #{status}
</if>
<if test="begin != null">
and order_time &gt;= #{begin}
</if>
<if test="end != null">
and order_time &lt;= #{end}
</if>
</where>
</select>

这里的符号是转义字符,是大于小于的意思。意思就是把这段时间内,status是status的营业额相加。

day12-5 导出运营数据Excel报表 (Apache POI)

POI可以让我们在java程序中对Office文件进行读写,一般都是操作excel文件。

我们一般不会从0开始构建一个excel,而是做一个模板放在template文件夹下,然后往这个模板里面填数字。

模板:

控制器:

1
2
3
4
5
@GetMapping("/export")
@ApiOperation("导出运营数据报表")
public void export(HttpServletResponse response){
reportService.exportBusinessData(response);
}

这里参数要获取HttpServletResponse response,因为:

1
2
ServletOutputStream out = response.getOutputStream();
excel.write(out);

response.getOutputStream():获取一个 ServletOutputStream,这是一个专门用于 HTTP 响应的输出流。

excel.write(out):将生成的 Excel 文件内容写入到输出流中,从而将文件发送给客户端。

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
/**导出近30天的运营数据报表
* @param response
**/
public void exportBusinessData(HttpServletResponse response) {
LocalDate begin = LocalDate.now().minusDays(30);
LocalDate end = LocalDate.now().minusDays(1);
//查询概览运营数据,提供给Excel模板文件
BusinessDataVO businessData = workspaceService.getBusinessData(LocalDateTime.of(begin,LocalTime.MIN), LocalDateTime.of(end, LocalTime.MAX));
InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream("template/运营数据报表模板.xlsx");
try {
//基于提供好的模板文件创建一个新的Excel表格对象
XSSFWorkbook excel = new XSSFWorkbook(inputStream);
//获得Excel文件中的一个Sheet页
XSSFSheet sheet = excel.getSheet("Sheet1");

sheet.getRow(1).getCell(1).setCellValue(begin + "至" + end);
//获得第4行
XSSFRow row = sheet.getRow(3);
//获取单元格
row.getCell(2).setCellValue(businessData.getTurnover());
row.getCell(4).setCellValue(businessData.getOrderCompletionRate());
row.getCell(6).setCellValue(businessData.getNewUsers());
row = sheet.getRow(4);
row.getCell(2).setCellValue(businessData.getValidOrderCount());
row.getCell(4).setCellValue(businessData.getUnitPrice());
for (int i = 0; i < 30; i++) {
LocalDate date = begin.plusDays(i);
//准备明细数据
businessData = workspaceService.getBusinessData(LocalDateTime.of(date,LocalTime.MIN), LocalDateTime.of(date, LocalTime.MAX));
row = sheet.getRow(7 + i);
row.getCell(1).setCellValue(date.toString());
row.getCell(2).setCellValue(businessData.getTurnover());
row.getCell(3).setCellValue(businessData.getValidOrderCount());
row.getCell(4).setCellValue(businessData.getOrderCompletionRate());
row.getCell(5).setCellValue(businessData.getUnitPrice());
row.getCell(6).setCellValue(businessData.getNewUsers());
}
//通过输出流将文件下载到客户端浏览器中
ServletOutputStream out = response.getOutputStream();
excel.write(out);
//关闭资源
out.flush();
out.close();
excel.close();

}catch (IOException e){
e.printStackTrace();
}
}

苍穹外卖结束。