文章目录
一、准备工作
1.1软件开发基本介绍
软件开发流程:
- 需求分析:需求规格说明书、产品原型。
- 设计:
UI设计(用户界面,小到按钮,大到页面布局,人机交互)、数据库设计、接口设计。 - 编写代码:项目代码、单元测试。
- 测试:测试用例、测试报告。
- 上线运维:软件环境安装、配置。
在开发过程中的角色分工包括:
- 项目经理:对整个项目负责,进行任务分配、把控进度。
- 产品经理:进行需求调研,输出需求调研文档、产品原型等。
UI设计师:根据产品原型设计界面效果图。- 架构师:项目整体架构设计、技术选型等。
- 开发工程师:完成代码实现。
- 测试工程师:编写测试用例,输出测试报告。
- 运维工程师:软件环境搭建、项目上线。
在软件开发和部署中,往往需要不同的开发工具、参数配置等,可分为以下几种软件环境:
- 开发环境:指开发人员在本地进行运行、调试。
- 测试环境:测试人员验证功能、发现
Bug。 - 生产环境(线上环境):最终用户访问的环境。
1.2项目介绍
1.2.1产品原型

苍穹外卖项目是一个基于Spring Boot + Vue的前后端分离外卖管理系统,包含商家端后台和用户端小程序两部分:
- 管理端原型:右侧内容区显示当前模块页面,左侧菜单则包含工作台、员工管理、菜品管理、分类管理、套餐管理、订单管理、数据统计、来单管理功能。产品原型如下图所示:

- 用户端原型:模拟顾客下单流程,包含首页、菜品详情页、购物车、下单页面、订单列表功能。产品原型如下图所示:

1.2.2技术选型

如上图所示,苍穹外卖的技术选型是Spring Boot + MyBatis Plus + Redis + MySQL(后端) + Vue + Element UI(管理端) + 微信小程序(用户端)。
1.3环境搭建
1.3.1前端环境搭建
本项目着重在于后端开发,前端资源默认已提供。
从所给资料中打开nginx目录下的html/sky目录,其内部已提供了所需的前端资源:
运行nginx-1.20.2/nginx.exe,在浏览器访问http://localhost/(注意,nginx所在目录路径中不能含有中文):

nginx默认监听80端口以接收客户端请求,而该端口时常会被占用。打开nginx-1.20.2/conf/nginx.conf,将端口号改为81即可:

因此,之后在访问时应使用81端口,如http://localhost:81/。
1.3.2后端项目导入
注意,本项目使用JDK8,更高的版本在导入依赖时会出现问题。
资料中提供了后端初始工程sky-take-out,使用IDEA打开:


常见的对象职责说明:
Entity:JavaBean实体,通常与数据库表相对应。DTO:在实际开发中,Entity字段太多,且往往密码、权限等敏感字段,因此并不直接使用Entity对象传输数据。DTO表示数据传输对象,用于在不同系统层之间传递数据,例如:- 请求
DTO:接收前端传来的数据,如LoginRequestDTO { username, password }。 - 响应
DTO:返回给前端的数据,如UserInfoDTO { id, nickname, avatarUrl }。 - 内部转换
DTO:组合多个实体的部分字段,如OrderDetailDTO { orderId, userName, productList }。
- 请求
VO:视图对象,表示返回给前端的视图数据。
1.3.3版本控制
【1.创建本地仓库】
通过VCS创建本地仓库:

将项目代码提交到本地仓库:

【2.连接远程仓库】
关联Gitee账号:

将当前项目推送到Gitee:

在Gitee上添加了开源许可证文件LICENSE,直接拉取到本地即可:

1.3.4数据库环境搭建

在资料中提供了SQL脚本sky.sql,共包含以上11张数据库表。创建数据库sky_take_out,并执行该SQL脚本即可完成数据库的创建。
1.3.5前后端联调

在后端初始工程中已实现了登录功能,可直接进行前后端联调测试。在application-dev.yml中将数据库连接参数改为自己的:
yml
sky:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
host: localhost
port: 3306
database: sky_take_out
username: root
password: 123456789
之后运行sky-server下的启动类SkyApplication,在浏览器中访问http://localhost:81并点击登录(账号为admin,密码为123456):

1.3.6断点调试登录功能
com.sky.controller.admin.EmployeeController中实现了登录功能的控制器方法:
java
@PostMapping("/login")
@ApiOperation(value = "员工登录接口")
public Result<EmployeeLoginVO> login(@RequestBody EmployeeLoginDTO employeeLoginDTO) {...}
前端使用POST请求传输数据,原因如下:
- 语义上 :
GET:获取资源,应该是幂等的,不会改变服务端状态。POST:向服务器提交数据,用于处理/修改资源,不保证幂等,适合登录这种会涉及会话、状态变化的操作。
- 安全性 :
GET:请求参数会放在URL上,容易被日志、浏览器历史、代理服务器缓存记录下来。POST:把数据放在请求体中,相对安全。
打上断点:

使用Debug模式启动项目,并再次访问http://localhost/:81进行登录。得到:

employeeLoginDTO中封装了前端传递的登录数据。给EmployeeServiceImpl.login(...)方法中的语句打上断点,并通过运行至光标处(Alt+F9)运行到该断点处再执行步过,即可看到封装了数据库数据的employee对象的内容:

之后进行条件判断employee == null(表示账号是否存在),当其为true时会抛出异常AccountNotFoundException,该异常实际是com.sky.exception.BaseException的子类:
java
public class BaseException extends RuntimeException {
public BaseException() {
}
public BaseException(String msg) {
super(msg);
}
}
全局异常处理器com.sky.handler.GlobalExceptionHandler:
java
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/**
* 捕获业务异常
* @param ex
* @return
*/
@ExceptionHandler
public Result exceptionHandler(BaseException ex){
log.error("异常信息:{}", ex.getMessage());
return Result.error(ex.getMessage());
}
}
当控制器方法执行过程中抛出异常时,Spring MVC根据方法参数的类型能否接收异常来找到能处理该异常的方法。而Java的异常机制和方法参数绑定规则决定了如果一个方法参数是BaseException,那么其子类对象都可以传入,从而实现统一处理所有业务异常
同理,当密码错误、账号被锁定时会分别抛出异常PasswordErrorException、AccountLockedException:
java
//密码比对
// TODO 后期需要进行md5加密,然后再进行比对
if (!password.equals(employee.getPassword())) {
//密码错误
throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR);
}
if (employee.getStatus() == StatusConstant.DISABLE) {
//账号被锁定
throw new AccountLockedException(MessageConstant.ACCOUNT_LOCKED);
}
只有上述情况都不出现时会返回实体类对象employee
java
return employee;
继续步过,在完成登录后生成了JWT登录令牌token:

之后,数据被封装为视图对象EmployeeLoginVO:
java
EmployeeLoginVO employeeLoginVO = EmployeeLoginVO.builder()
.id(employee.getId())
.userName(employee.getUsername())
.name(employee.getName())
.token(token)
.build();
查看源码:
java
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ApiModel(description = "员工登录返回的数据格式")
public class EmployeeLoginVO implements Serializable {
@ApiModelProperty("主键值")
private Long id;
@ApiModelProperty("用户名")
private String userName;
@ApiModelProperty("姓名")
private String name;
@ApiModelProperty("jwt令牌")
private String token;
}
@Builder是Lombok提供的注解,用于在编译时为类自动生成建造者模式的代码,用于将对象构建步骤拆开,可通过更灵活的方式来创建对象。例如,有User类:
java
public class User {
private Long id;
private String username;
private String password;
private String email;
private String phone;
}
不使用该注解时创建对象的方式:
java
// 构造方法创建: 构造函数重载过多, 容易写错参数顺序
User user1 = new User("张三", "123456");
User user2 = new User("张三", "123456", "xxx@email.com");
// setter方法创建: 写起来麻烦, 尤其是字段很多时
User user = new User();
user.setUsername("张三");
user.setPassword("123456");
user.setEmail("xxx@email.com");
通过建造者模式创建对象:
java
// 链式调用, 代码简洁可读; 参数安全, 不会搞错顺序
User user = User.builder()
.id(1L)
.username("张三")
.password("123456")
.email("xxx@email.com")
.phone("13800000000")
.build();
若直接将employeeLoginVO返回:
java
@PostMapping("/login")
public EmployeeLoginVO login(...) {
return employeeLoginVO;
}
返回给前端的数据:
json
{
"id": 1,
"userName": "admin",
"name": "管理员",
"token": "xxxxx.yyyyy.zzzzz"
}
此时不同接口可能返回不同格式,前端处理麻烦,也无法携带状态码、错误信息等元数据。因此,在sky-common下定义了com.sky.result.Result<T>:
java
/**
* 后端统一返回结果
* @param <T>
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
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;
}
}
包含了执行成功且无返回值、执行成功且有返回值、执行失败且返回错误信息三种情况。将employeeLoginVO封装后返回:
java
return Result.success(employeeLoginVO);
前端收到的数据:
json
{
"code": 1,
"msg": "success",
"data": {
"id": 1,
"userName": "admin",
"name": "管理员",
"token": "xxxxx.yyyyy.zzzzz"
}
}
在Vue + Axios拿到数据后,即可根据状态码判断接口是否执行成功。
1.3.7Nginx
在项目中,Nginx作为Web服务器主要用于前后端分离部署、请求转发、静态资源处理等功能。核心功能包括:
- 反向代理(核心) :统一对外暴露一个入口(
http://域名),根据URL路径把请求转发到不同服务。例如:- 场景一 :用户通过
http://域名/访问前端页面时,Nginx直接返回前端index.html给浏览器。 - 场景二 :前端调用
/api/user/login接口,Nginx根据配置,如proxy_pass http://127.0.0.1:8080/;,将请求转发到后端Spring Boot服务http://127.0.0.1:8080/user/login。后端处理请求并返回JSON数据给Nginx,其再将响应结果转发给前端。
- 场景一 :用户通过
在上述过程中,外部用户只能访问Nginx,不会直接暴露后端应用服务器。而后端只需提供REST API并默认监听8080端口即可。nginx-1.20.2\conf\nginx.conf中配置了反向代理的实现:
bash
server {
# Nginx 监听 81 端口, 域名为 localhost , 因此需通过 http://localhost:81 访问
listen 81;
server_name localhost;
# 访问 http://localhost:81 时直接返回 html/sky 下的 index.html 页面
location / {
root html/sky;
index index.html index.htm;
}
# 当后端报 500、502、503、504 时直接返回 html/50x.html
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
# 反向代理: 将前端 /api/ 前缀的请求转发到后端的 /admin/ 路径
## 例如,前端调用 http://localhost:81/api/login , 会被转发给后端的 http://localhost:8080/admin/login
location /api/ {
proxy_pass http://localhost:8080/admin/;
}
# 反向代理: 将前端 /user/ 前缀的请求转发到后端的 webservers/user/ 路径
## webservers 可能是一个负载均衡池
location /user/ {
proxy_pass http://webservers/user/;
}
# 反向代理:将前端 /ws/ 前缀的请求转发到后端的 /webservers/ws/ 路径, 主要用于长连接通信, 比如订单状态实时推送
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读取index.html、js、css等前端静态文件,减少后端服务器压力。

由上文配置文件内容可知,当客户端访问http://localhost:81/开头的路径时,Nginx会在安装跟目录下(root)的html/sky文件夹内进行查找,因此:
bash
http://localhost:81/ → 实际访问 html/sky/index.html
http://localhost:81/css/style.css → 实际访问 html/sky/css/style.css
此外,当后端返回500、502、503、504错误时,Nginx不直接把错误抛给用户,而是返回静态错误页面。
- 负载均衡 :指将大量的请求按照指定的方式均衡地分配给集群中的每台服务器。当后端有多台
Spring Boot服务器时,Nginx可以轮询分发请求。
配置文件:
bash
# 定义后端服务器池
upstream webservers{ # 定义名为webservers的upstream(Nginx上游服务器组)
server 127.0.0.1:8080 weight=90 ;
#server 127.0.0.1:8088 weight=10 ;
}
server {
# Nginx监听81端口,域名为localhost,因此需通过http://localhost:81访问
listen 81;
server_name localhost;
...
# 反向代理:将前端/user/前缀的请求转发到后端的webservers/user/路径
## webservers可能是一个负载均衡池
location /user/ {
proxy_pass http://webservers/user/;
}
}
upstream webservers:定义了Nginx上游服务器组来做负载均衡,其中配置了多台后端服务器。两台后端服务器都启用后,Nginx转发请求时90%走8080,10%走8088(按权重轮询),此处只启用8080,所以所有请求都走8080。server:- 定义监听服务 :对外提供访问路径
http://localhost:81。 - 反向代理
/user/路径 :当用户访问http://localhost:81/user/order/list时,Nginx转发到upstream webservers,实际转发到后端http://127.0.0.1:8080/user/order/list。后端将结果响应给Nginx,并由其转发给浏览器。
- 定义监听服务 :对外提供访问路径
Nginx中常用的负载均衡策略:
- 轮询(默认):默认按顺序轮询分发请求。
bash
# 配置
upstream backend {
server 192.168.1.10:8080;
server 192.168.1.11:8080;
}
# 访问顺序
第1个请求 → 192.168.1.10
第2个请求 → 192.168.1.11
第3个请求 → 192.168.1.10
...
- 权重:给不同后端设置权重,权重越高分配请求越多。
bash
upstream backend {
server 192.168.1.10:8080 weight=3; # 处理75%请求
server 192.168.1.11:8080 weight=1; # 处理25%请求
}
- 哈希 :根据客户端
IP计算哈希,同一个IP的请求总是分配到同一台后端。
bash
upstream backend {
ip_hash;
server 192.168.1.10:8080;
server 192.168.1.11:8080;
}
- 最少连接数:
bash
upstream backend {
least_conn;
server 192.168.1.10:8080;
server 192.168.1.11:8080;
}
1.3.8完善登录功能

目前存在的问题是,密码在数据库中明文存储,安全性太低。此处引入MD5加密策略:
MD5:用于生成固定长度的128位散列值的哈希函数,常用于数据完整性校验和密码存储。MD5是一种单向散列函数,意味着从散列值无法反推出原始数据。
密码用MD5方式加密后存储,当前端输入的密码时,将其经MD5转换的结果与数据库中存储的密文对比即可。上图中123456转换结果为e10adc3949ba59abbe56e057f20f883e,直接手动修改数据库即可。
修改sky-server下com.sky.service.impl.EmployeeServiceImpl的login():
java
// 将前端明文密码MD5加密后与数据库对比
password = DigestUtils.md5DigestAsHex(password.getBytes());

1.4导入接口文档

在开发之前需要先将接口定义好,然后前后端人员并行开发。课程资料中提供了前后端接口文档:

在YAPI中分别创建苍穹外卖-用户端接口与苍穹外卖-管理端接口两个项目,并导入上述数据文件即可:

查看管理端接口文档:

1.5Swagger
Swagger是一个API文档生成和测试工具,主要用来自动生成后端接口文档,其提供一个可交互的在线API测试界面,让前端、测试、后端对接口对齐更方便。
1.5.1配置方式
Spring Boot中常用Springfox-Swagger2或Knife4j(Swagger增强版),本项目中使用Knife4j,并在pom.xml中已引入了相关依赖:
xml
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<version>3.0.2</version>
</dependency>
并在配置类中加入了相关配置:
java
package com.sky.config;
...
@Configuration
@Slf4j
public class WebMvcConfiguration extends WebMvcConfigurationSupport {
...
/**
* 通过knife4j生成接口文档
* @return
*/
@Bean
public Docket docket() {
ApiInfo apiInfo = new ApiInfoBuilder()
.title("苍穹外卖项目接口文档") // 文档标题
.version("2.0") // 文档版本号
.description("苍穹外卖项目接口文档") // 文档描述
.build();
Docket docket = new Docket(DocumentationType.SWAGGER_2) // 指定Swagger 2.0规范
.apiInfo(apiInfo) // 绑定上述配置信息
.select() // 开始选择要扫描的接口
.apis(RequestHandlerSelectors.basePackage("com.sky.controller")) // 扫描com.sky.controller下的Controller
.paths(PathSelectors.any()) // 匹配所有接口的路径
.build();
return docket;
}
/**
* 设置静态资源映射
* @param registry
*/
// Swagger UI页面(doc.html)和前端依赖的JS/CSS(/webjars/**)是放在jar包的META-INF/resources目录,Spring Boot默认只会映射
// classpath:/static/、classpath:/public/里的资源,因此需手动告诉Spring Boot当访问/doc.html或/webjars/**时,到
//META-INF/resources/里面找.
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
// 配置后可通过http://localhost:8080/doc.html访问Swagger/Knife4j首页
registry.addResourceHandler("/doc.html").addResourceLocations("classpath:/META-INF/resources/");
// http://localhost:8080/webjars/**保存Swagger依赖的静态文件
registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
}
}
启动程序后访问http://localhost:8080/doc.html:

可直接进行在线测试:

点击发送,得到响应报文:

1.5.2常用注解

Swagger提供了注解来控制生成的接口文档,常用注解如上图所示。例如:
java
/**
* 员工管理
*/
@RestController
@RequestMapping("/admin/employee")
@Slf4j
@Api(tags = "员工相关接口")
public class EmployeeController {
...
/**
* 登录
*/
@PostMapping("/login")
@ApiOperation(value = "员工登录接口")
public Result<EmployeeLoginVO> login(@RequestBody EmployeeLoginDTO employeeLoginDTO) {...}
/**
* 退出
*/
@ApiOperation(value = "员工退出接口")
@PostMapping("/logout")
public Result<String> logout() {...}
}
@Api:标注在类上进行说明、分组,常用于Controller类上。常用属性:tags:分组名。description:详细描述。
@ApiOperation:给具体接口方法进行说明,常用于Controller类的方法上。常用属性:value:接口标题。notes:详细说明。
java
@Data
@ApiModel(description = "员工登录时传递的数据模型")
public class EmployeeLoginDTO implements Serializable {
@ApiModelProperty("用户名")
private String username;
@ApiModelProperty("密码")
private String password;
}
@ApiModel:描述DTO/VO类。@ApiModelProperty:描述DTO/VO类的字段。常用属性:value:字段说明。required:是否必填。example:实例值。
访问http://localhost:8080/doc.html:

二、员工模块开发

员工模块开发主要需实现以下功能:
- 添加员工功能
- 员工分页查询功能
- 启用/禁用员工账号功能
- 编辑员工信息功能
2.1新增员工
2.1.1需求分析

产品原型如上图所示,其对功能设计有如下要求:
- 1.账号必须唯一。
- 2.手机号必须是
11位。 - 3.身份证号必须是
18位。 - 4.密码默认为
123456,登录后可进行修改。

接口设计如上图所示,本项目中有如下约定:
- 管理端发出的请求统一使用
/admin作为前缀,且相关后端代码放在com.sky.controller.admin包下。 - 用户端发出的请求统一使用
/user作为前缀,且相关后端代码放在com.sky.controller.user包下。
接口基本信息:
- 请求方式:
POST - 接口路径:
/admin/employee - 权限控制:仅管理员角色可访问(可结合
JWT / Spring Security权限校验)
请求参数设计:前端在新增员工页面提交表单,后端接收一个JSON对象,求请体示例:
json
{
"username": "zhangsan", // 员工账号(String)
"name": "张三", // 员工姓名(String)
"phone": "13800001111", // 手机号(String)
"sex": "1", // 性别(String)
"idNumber": "110101199001010011" // 身份证号(String)
}
其中,密码不需要前端传,后端自动初始化默认密码并加密存储。
返回结果统一使用JSON格式:
json
// 成功
{
"code": 1,
"msg": "success"
}
// 失败
{
"code": 0,
"msg": "该用户名已存在,请重新输入"
}
2.1.2代码开发
【com.sky.dto.EmployeeDTO】
java
@Data
@ApiModel("新增员工请求参数")
public class EmployeeDTO implements Serializable {
@ApiModelProperty("账号")
private String username;
@ApiModelProperty("姓名")
private String name;
@ApiModelProperty("手机号")
private String phone;
@ApiModelProperty("性别 1男 2女")
private String sex;
@ApiModelProperty("身份证号")
private String idNumber;
}
【EmployeeController】
java
@PostMapping
@ApiOperation(value = "新增员工接口")
public Result<String> save(@RequestBody EmployeeDTO employeeDTO) {
log.info("新增员工:{}", employeeDTO);
employeeService.save(employeeDTO);
return Result.success();
}
当前接口没有业务数据需要返回,返回值类型Result<String>用于保证接口的统一。
【EmployeeService】
java
/**
* 新增员工
* @Param employeeDTO
* */
void save(EmployeeDTO employeeDTO);
【EmployeeServiceImpl】
java
@Override
public void save(EmployeeDTO employeeDTO) {
// 1.将 EmployeeDTO 对象转回 Employee 对象(EmployeeDTO 不能直接存入数据库)
Employee employee = new Employee();
BeanUtils.copyProperties(employeeDTO, employee);
// 2.设置其他属性
employee.setStatus(StatusConstant.ENABLE);
employee.setPassword(DigestUtils.md5DigestAsHex(PasswordConstant.DEFAULT_PASSWORD.getBytes()));
employee.setCreateTime(LocalDateTime.now());
employee.setUpdateTime(LocalDateTime.now());
employee.setCreateUser(10L); // TODO 后续需要改为当前登录用户的 id
employee.setUpdateUser(10L);
// 3.执行数据库操作
employeeMapper.insert(employee);
}
其中,employee.setStatus(StatusConstant.ENABLE);本质就是将状态码设为1,不直接使用数字以避免硬编码。此外,BeanUtils.copyProperties(Object source, Object target)是Spring提供的工具类方法,把一个对象中的属性值,复制到另一个对象中,常用于DTO ↔ VO ↔ Entity之间的数据拷贝。拷贝规则:
- 按属性名匹配:两个类中属性名相同且类型兼容的字段才会被复制。
- 浅拷贝:仅复制属性值的引用,不会递归拷贝内部对象。因此,集合、对象,两个对象会共享同一个引用。
- 忽略
null:如果source中某个属性是null,就会直接覆盖target中已有的值。
【EmployeeMapper】
java
/**
* 插入员工数据
* @Param employee
* */
@Insert("insert into employee(name,username,password,phone,sex,id_number,status,create_time,update_time,create_user,update_user)"+
"values"+
"(#{name},#{username},#{password},#{phone},#{sex},#{idNumber},#{status},#{createTime},#{updateTime},#{createUser},#{updateUser})")
void insert(Employee employee);
此外,由于数据库表中字段与实体类属性之间存在驼峰映射关系,因此需在application.yml中开启驼峰映射:
yml
mybatis:
#mapper配置文件
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.sky.entity
configuration:
#开启驼峰命名
map-underscore-to-camel-case: true
2.1.3功能测试
在实际开发中,后端接口完成编写而前端页面可能还未完成开发,因此无法直接使用前后端联调测试,可通过Swagger进行测试。允许项目,访问http://localhost:8080/doc.html:

事实上,项目中设置了拦截器com.sky.interceptor.JwtTokenAdminInterceptor,其会从请求头中获取令牌并进行校验:
java
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 判断当前拦截到的是控制器方法还是静态资源请求
if (!(handler instanceof HandlerMethod)) {
// 静态资源请求直接放行
return true;
}
// 1. 从请求头中获取令牌
String token = request.getHeader(jwtProperties.getAdminTokenName());
// 2. 校验令牌
try {
log.info("jwt校验:{}", token);
Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());
log.info("当前员工id:", empId);
// 3. 通过,放行
return true;
} catch (Exception ex) {
//4. 不通过,响应 401 状态码
response.setStatus(401);
return false;
}
}
当拦截器返回false时表示不放行,请求不会再进入控制器方法。上述代码中手动设置了状态码401(未认证),因此前端收到的就是一个空响应体 + 状态码。事实上,前端的处理包含两种方式:
- 正常请求 :返回
Result<T>。 - 拦截器拒绝:返回状态码。
此时前端会根据状态码来判断是否需要重新登录。
由于当前请求头并不携带token令牌,因此响应401。先在Swagger中调用登录接口来获取token令牌:

将该令牌添加至全局参数:

再次发送请求时,请求头会自动带上该参数:


启动Nginx进行前后端联调:


2.1.4代码完善
当前代码存在以下问题:
- 1.录入的用户名已存在时会抛出异常, 该异常未编写处理代码。
- 2.插入员工数据时,创建人
id与修改人id均设置为固定值。
【问题一】
具体地,当新增同username员工时会返回以下信息:

java
### Error updating database. Cause: java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'zhangsan' for key 'employee.idx_username'
### The error may exist in com/sky/mapper/EmployeeMapper.java (best guess)
### The error may involve com.sky.mapper.EmployeeMapper.insert-Inline
### The error occurred while setting parameters
### SQL: insert into employee(name,username,password,phone,sex,id_number,status,create_time,update_time,create_user,update_user)values(?,?,?,?,?,?,?,?,?,?,?)
### Cause: java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'zhangsan' for key 'employee.idx_username'
; Duplicate entry 'zhangsan' for key 'employee.idx_username'; nested exception is java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'zhangsan' for key 'employee.idx_username'] with root cause
java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'zhangsan' for key 'employee.idx_username'
为处理上述异常,在com.sky.handler.GlobalExceptionHandler中增加方法:
java
@ExceptionHandler
public Result exceptionHandler(SQLIntegrityConstraintViolationException ex) {
String message = ex.getMessage();
if(message.contains("Duplicate entry")){
String[] split = message.split(" ");
String username = split[2];
String msg = username + MessageConstant.ALREADY_EXISTS; // ALREADY_EXISTS = "已存在";
return Result.error(msg);
}else{
return Result.error(MessageConstant.UNKNOWN_ERROR);
}
}
【问题二】

JWT认证机制原理如上图所示,可归纳为:
- 1.用户发起请求发送用户名和密码,后端进行校验,如果验证通过就生成
JWT Token。该过程在EmployeeController.login()方法中被实现:
java
//登录成功后,生成jwt令牌
Map<String, Object> claims = new HashMap<>();
claims.put(JwtClaimsConstant.EMP_ID, employee.getId());
String token = JwtUtil.createJWT(
jwtProperties.getAdminSecretKey(),
jwtProperties.getAdminTtl(),
claims);
- 2.登录成功后,前端在浏览器本地保存该
Token,并在后续请求中自动放入请求头。前端实现逻辑:
javascript
// 保存 Token
axios.post("/api/login", loginForm).then(res => {
if (res.data.code === 1) {
const token = res.data.data.token
localStorage.setItem("token", token)
router.push("/index") // 跳转首页
}
})
// 定义全局请求拦截器
axios.interceptors.request.use(config => {
// 从 localStorage 取出 token
const token = localStorage.getItem("token");
if (token) {
// 在请求头中携带 token,名称一般在 jwtProperties 中配置
config.headers['Authorization'] = token
}
return config;
}, error => {
return Promise.reject(error);
});
- 3.请求被后端拦截器拦截时会检查
Token,通过就会展示数据,否则会返回错误信息。在JwtTokenAdminInterceptor.preHandle()方法中的实现:
java
try {
log.info("jwt校验:{}", token);
Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());
log.info("当前员工id:", empId);
// 3、通过,放行
return true;
} catch (Exception ex) {
// 4、不通过,响应 401 状态码
response.setStatus(401);
return false;
}
因此,可通过JWT令牌获取当前调用新增员工操作的员工id,即为创建人id与修改人id。而在解析出该id后,需传递给Service层的save()方法,若直接作为参数进行传递,则在Controller层的save()方法中就需要解析出该id,并在Service层声明save(EmployeeDTO employeeDTO, Long currentId)。此时,代码臃肿,Service接口签名不纯粹。
事实上,SpringMVC是多线程环境,每个HTTP请求都会由一个独立的线程来处理,这一点可在controller、service和拦截器代码中输出当前线程id进行验证:
java
System.out.println("当前线程的id:" + Thread.currentThread().getId());
若使用普通的static Long userId来存放用户ID,则当多个请求同时访问,变量会互相覆盖。ThreadLocal作为Thread的局部变量能够为每个线程提供单独一份存储空间,具有线程隔离效果。因此,在sky-common模块下创建工具类com.sky.context.BaseContext封装ThreadLocal基本操作:
java
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();
}
}
若不将threadLocal定义为static,则必须先创建BaseContext的对象,然后才能调用,此时每个BaseContext对象都有自己的threadLocal。可能出现,拦截器创建BaseContext对象后存入用户数据,Controller/Service中为获取数据又创建BaseContext对象,由于二者并非同一对象,因此无法获取到存入的数据。在使用static修饰后,整个JVM里threadLocal就是唯一的实例,无论在拦截器、Controller还是Service,用的都是同一个threadLocal对象。
完善JwtTokenAdminInterceptor.preHandle():
java
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);
BaseContext.setCurrentId(empId);
// 3、通过,放行
return true;
} catch (Exception ex) {
// 4、不通过,响应 401 状态码
response.setStatus(401);
return false;
}
完善EmployeeServiceImpl.save():
java
employee.setCreateUser(BaseContext.getCurrentId());
employee.setUpdateUser(BaseContext.getCurrentId());
使用admin账号新增员工:


2.2分页查询
2.2.1需求分析

产品原型如上图所示,要求:
- 1.能根据页码查询数据,每页展示
10条数据。 - 2.可以输入员工姓名进行查询。

接口设计如上图所示,其中,Query请求表示带查询参数的HTTP请求。其URL结构如下:
bash
协议://域名:端口/路径?查询参数
Query请求参数就是?后的键值对。
接口信息:
- 接口地址 :
/admin/employee/page - 请求方式 :
GET - 请求参数 :
page:int类型,表示当前页码。pageSize:int类型,表示每页显示条数。name:String类型,表示员工姓名。
返回数据为Result<PageResult>对象,PageResult封装了分页信息。
2.2.2代码开发
接口执行流程:
- 1.前端请求:
bash
GET /admin/employee/page?page=1&pageSize=10&name=张
Authorization: Bearer <JWT>
- 2.
JwtTokenAdminInterceptor验证Token合法性并将id存入ThreadLocal,之后放行请求,进入控制器。 - 3.控制层通过
@RequestParam接收page、pageSize、name参数,调用employeeService.pageQuery(page, pageSize, name),返回Result.success(PageResult)给前端。 - 4.
Service层调用分页插件执行分页(由MyBatis-Plus生成SQL并执行),返回PageResult对象。 - 5.前端渲染分页表格,显示员工列表 + 分页控件。
【com.sky.dto.EmployeePageQueryDTO】
java
@Data
public class EmployeePageQueryDTO implements Serializable {
// 员工姓名
private String name;
// 页码
private int page;
// 每页显示记录数
private int pageSize;
}
【com.sky.result.PageResult】
java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PageResult implements Serializable {
private long total; // 总记录数
private List records; // 当前页数据集合
}
PageResult可作为Result的data属性直接返回。
【EmployeeController】
java
@GetMapping("/page")
@ApiOperation("员工分页查询接口")
public Result<PageResult> page(EmployeePageQueryDTO employeePageQueryDTO){
log.info("员工分页查询,参数:{}", employeePageQueryDTO);
PageResult pageResult = employeeService.pageQuery(employeePageQueryDTO);
return Result.success(pageResult);
}
【EmployeeService】
java
/**
* 分页查询
* */
PageResult pageQuery(EmployeePageQueryDTO employeePageQueryDTO);
【EmployeeServiceImpl】
java
/**
* 分页查询
* @param employeePageQueryDTO
* @return
* */
@Override
public PageResult pageQuery(EmployeePageQueryDTO employeePageQueryDTO) {
PageHelper.startPage(employeePageQueryDTO.getPage(), employeePageQueryDTO.getPageSize());
// import com.github.pagehelper.Page;
Page<Employee> page = employeeMapper.pageQuery(employeePageQueryDTO);
long total = page.getTotal();
List<Employee> result = page.getResult();
return new PageResult(total, result);
}
【EmployeeMapper】
java
/**
* 分页查询
* @param employeePageQueryDTO
* @return
* */
Page<Employee> pageQuery(EmployeePageQueryDTO employeePageQueryDTO);
由于需使用动态SQL,故不再使用注解。
【EmployeeMapper.xml】
xml
<select id="pageQuery" resultType="com.sky.entity.Employee">
select * from employee
<where>
<if test="name != null and name != ''">
and name like concat('%',#{name},'%')
</if>
</where>
order by create_time desc
</select>
PageHelper会自动追加拼写LIMIT语句。当传入员工姓名不为空时,会返回模糊查询结果,否则会返回所有员工数据。
2.2.3功能测试

返回数据:
json
{
"code": 1,
"msg": null,
"data": {
"total": 4,
"records": [
{
"id": 5,
"username": "wangwu",
"name": "王五",
"password": "e10adc3949ba59abbe56e057f20f883e",
"phone": "13312345678",
"sex": "1",
"idNumber": "111222333444555666",
"status": 1,
"createTime": [
2025,
7,
27,
18,
21,
35
],
"updateTime": [
2025,
7,
27,
18,
21,
35
],
"createUser": 1,
"updateUser": 1
},
{
"id": 3,
"username": "lisi",
"name": "李四",
"password": "e10adc3949ba59abbe56e057f20f883e",
"phone": "13212345678",
"sex": "1",
"idNumber": "111222333444555666",
"status": 1,
"createTime": [
2025,
7,
26,
22,
51,
15
],
"updateTime": [
2025,
7,
26,
22,
51,
15
],
"createUser": 10,
"updateUser": 10
}
]
}
}

可见,显示时间出现格式错误。
2.2.4代码完善
对于上述时间格式问题有两种解决方案:
- 1.在属性上加
@JsonFormat,对日期进行格式化。 - 2.在
WebMvcConfiguration中扩展SpringMVC消息转换器,统一对日期类型进行格式化处理。
二者实现一个即可。
【1.方法一】
在Employee的createTime字段上加上注解:
java
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@JsonFormat是Jackson提供的注解,主要用于控制对象字段在JSON序列化与反序列化时的格式,通常用于格式化Date或LocalDateTime类型的时间字段。常用注解参数:
| 参数 | 含义 |
|---|---|
pattern |
格式化样式,例如 "yyyy-MM-dd"、"yyyy-MM-dd HH:mm:ss" |
timezone |
时区设置,例如 "GMT+8"(默认是 UTC) |
locale |
地区语言设置,例如 "zh_CN" |
该注解支持序列化与反序列化,即,在请求报文中能将LocalDateTime对象转为"yyyy-MM-dd HH:mm:ss"格式,也能将响应报文中"yyyy-MM-dd HH:mm:ss"格式的数据转为LocalDateTime对象。
查看数据格式:

【2.方法二】
当前项目创建了WebMvcConfigurationSupport的子类com.sky.config.WebMvcConfiguration,在内部添加了消息转换器:
java
/**
* 扩展消息转换器
* @param converters
* */
@Override
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
// 1.创建JSON消息转换器
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
// 2.绑定自定义的ObjectMapper
converter.setObjectMapper(new JacksonObjectMapper());
// 3.放在转换器列表最前面,覆盖Spring默认的JSON处理行为
converters.add(0, converter);
}
其中,extendMessageConverters()是WebMvcConfigurer接口提供的扩展点,用来给Spring MVC的HttpMessageConverter列表添加或修改转换器,以实现请求参数和响应结果在Java对象与JSON间自动转换。
- 创建
JSON消息转换器 :MappingJackson2HttpMessageConverter是Spring提供的Jackson JSON转换器,专门用来处理application/json类型的请求和响应。 - 绑定自定义的
ObjectMapper:ObjectMapper负责Java 对象 ↔ JSON数据之间做转换。 - 插到转换器链最前面 :
Spring MVC处理JSON时会按顺序找能支持的HttpMessageConverter,被放在最前面后,会覆盖默认的JSON转换逻辑。
其中,com.sky.json.JacksonObjectMapper源码如下:
java
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();
// 当 JSON 中存在 Java 对象中不存在的字段时直接忽略, 不抛异常, 使得即使前后端字段不一致也能正常解析.
this.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);
//反序列化时, 对应属性不存在的兼容处理
this.getDeserializationConfig().withoutFeatures(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
SimpleModule simpleModule = new SimpleModule()
// 定义反序列化: JSON -> Java 对象
.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)))
// 定义序列化: Java 对象 -> JSON
.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)));
// 注册到 ObjectMapper
this.registerModule(simpleModule);
}
}
ObjectMapper是Jackson的核心类,SpringMVC默认使用它做JSON与Java对象之间的转换。上述代码中定义了日期/时间类型的转换逻辑,对于普通字段,Jackson的ObjectMapper内部已经内置了大量序列化/反序列化器,直接走内置规则即可。
过程举例:
| Java对象 | JSON表现 |
|---|---|
LocalDate.of(2023,1,1) |
"2023-01-01" |
LocalDateTime.of(2023,1,1,14,30) |
"2023-01-01 14:30" |
2.3启禁账号
2.3.1需求分析

产品原型如上图所示,业务规则:
- 可对状态为启用的员工账号进行禁用,可对状态为禁用的员工账号进行启用。
- 状态为禁用的员工账号不能登录系统。
接口设计:

其中,请求参数status是由路径传递,如/admin/employee/status/{status},而请求参数id是query方法直接拼接到地址栏。
2.3.2代码开发
【EmployeeController】
java
@PostMapping("/status/{status}")
@ApiOperation("启禁用账号")
public Result startOrStop(@PathVariable Integer status, Long id) { // 非查询类方法无需返回数据, 也就每必要加泛型
log.info("启用禁用员工账号:{},{}",status,id);
employeeService.startOrStop(status,id);
return Result.success();
}
两种参数的获取方式:
status:通过@PathVariable从URL路径获取。id:从请求参数(query param或表单参数)里取,也可使用@RequestParam。
例如,前端调用:
bash
POST http://localhost:8080/admin/employee/status/1?id=10
也可使用表单方式传递id,如前端使用:
bash
POST Content-Type: application/x-www-form-urlencoded
id = 10会被放入请求体中,SpringMVC也会自动绑定到Long id上。
值得注意,POST请求也能携带query参数。HTTP协议本身没有限制:
GET请求 :数据一般放在URL query string里(?key=value)。POST请求:数据一般放在请求体里。
但是URL query string在任何请求方法里都是允许的。所以即使是 POST,也可以写成:
bash
POST http://localhost:8080/admin/employee/status/1?id=10
使用@RequestParam后可从query string或表单字段取。
【EmployeeService】
java
void startOrStop(Integer status, Long id);
【EmployeeServiceImpl】
java
@Override
public void startOrStop(Integer status, Long id) {
// UPDATE employee SET status = ? WHERE id = ?
Employee employee = Employee.builder()
.status(status)
.id(id)
.build();
employeeMapper.update(employee);
}
【EmployeeMapper】
java
/**
* 根据主键动态修改属性
* @param employee
*/
void update(Employee employee);
【EmployeeMapper.xml】
xml
<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>
2.3.3功能测试
访问http://localhost:8080/doc.html进行测试:

得到:

访问http://localhost:81/#/login:

查看控制台:
2.4编辑员工
2.4.1需求分析

编辑员工功能指在员工管理列表页面点击编辑按钮跳转到编辑页面,在编辑页面回显员工信息并进行修改,最后点击保存按钮完成编辑操作。涉及接口:


2.4.2代码开发
实现根据id查询员工信息:
【EmployeeController】
java
@GetMapping("/{id}")
@ApiOperation("根据id查询员工信息")
public Result<Employee> getById(@PathVariable Long id) {
log.info("根据id查询员工信息");
Employee employee = employeeService.getById(id);
if(employee == null) return Result.error("未查询到对应员工信息");
else return Result.success(employee);
}
【EmployeeService】
java
/**
* 根据id查询员工信息
* @param id
* @return Employee
* */
Employee getById(Long id);
【EmployeeServiceImpl】
java
/**
* 根据id查询员工信息
* @param id
* @return Employee
* */
@Override
public Employee getById(Long id) {
Employee employee = employeeMapper.getById(id);
employee.setPassword("***"); // 查询出的密码虽已加密, 但为避免显示到前端, 设置为***
return employee;
}
【EmployeeMapper】
java
/**
* 根据id查询员工信息
* @param id 员工id(Long)
* */
@Select("select * from employee where id = #{id}")
Employee getById(Long id);
访问http://localhost:81,点击员工管理-修改即可回显员工信息:

编写更新员工信息接口:
【EmployeeController】
java
@PutMapping
@ApiOperation("编辑员工信息")
public Result update(@RequestBody EmployeeDTO employeeDTO) {
log.info("编辑员工信息:{}",employeeDTO);
employeeService.update(employeeDTO);
return Result.success();
}
此处URL与新增员工接口一致,但HTTP方法不同。事实上,同一个URL可以有不同的HTTP方法:
HTTP方法 |
URL |
语义 |
|---|---|---|
POST |
/employee |
新增资源(Create) |
PUT |
/employee |
更新资源(Update) |
GET |
/employee |
查询资源(Read) |
DELETE |
/employee |
删除资源(Delete) |
SpringMVC根据HTTP方法 + URL来匹配请求。
【EmployeeService】
java
/**
* 编辑员工信息
* @param employeeDTO 员工DTO(employeeDTO)
* */
void update(EmployeeDTO employeeDTO);
【EmployeeServiceImpl】
java
/**
* 编辑员工信息
* @param employeeDTO 员工DTO(employeeDTO)
* */
@Override
public void update(EmployeeDTO employeeDTO) {
Employee employee = new Employee();
BeanUtils.copyProperties(employeeDTO,employee); //属性拷贝
employee.setUpdateTime(LocalDateTime.now());
employee.setUpdateUser(BaseContext.getCurrentId());
employeeMapper.update(employee); // 已实现
}
访问http://localhost:81,点击员工管理修改员工信息:

三、菜品管理
3.1导入分类管理功能

业务规则:
- 1.业务名称必须唯一。
- 2.分类按照类型可分为菜品分类和套餐分类。
- 3.新添加的分类状态默认为禁用。

由于本部分与员工设计重复,故从资料\day02\分类管理模块功能代码\下直接将代码复制到项目中即可:

访问http://localhost:81,功能测试均正常可用:

3.2公共字段自动填充
3.2.1需求分析

如上图所示,很多表都有一些通用字段(公共字段),若在插入或更新数据时都手动去赋值,就会存在大量冗余代码。例如:
java
// 新增员工接口
employee.setCreateTime(LocalDateTime.now());
employee.setUpdateTime(LocalDateTime.now());
employee.setCreateUser(BaseContext.getCurrentId());
employee.setUpdateUser(BaseContext.getCurrentId());
// 新增分类接口
category.setCreateTime(LocalDateTime.now());
category.setUpdateTime(LocalDateTime.now());
category.setCreateUser(BaseContext.getCurrentId());
category.setUpdateUser(BaseContext.getCurrentId());
分析可知,create_time、create_user字段仅在执行INSERT语句时会被更新,而update_time、update_user字段在执行INSERT/UPDATE语句时会被更新。
因此:
- 1.自定义注解
@AutoFill,用于标识需进行公共字段自动填充的方法(Mapper方法)。 - 2.自定义切面类
AutoFillAspect,统一拦截加入了@AutoFill的方法,并通过反射为公共字段赋值。
3.2.2代码开发
【package com.sky.annotation.AutoFill】
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {
// 指明数据库操作类型: UPDATE INSERT
OperationType value();
}
其中,OperationType是自定义的枚举类型:
java
public enum OperationType {
UPDATE,
INSERT
}
【2.AutoFillAspect】
java
@Aspect
@Component
@Slf4j
public class AutoFillAspect {
// 切点要求: Mapper接口方法 + 被@AutoFill标注
@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
public void autoFillPointCut(){}
@Before("autoFillPointCut()")
public void autoFill(JoinPoint joinPoint){
log.info("开始进行公共字段自动填充...");
// 1.通过方法签名获取注解对象、操作类型
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class);
OperationType operationType = autoFill.value();
// 2.获取方法参数
Object[] args = joinPoint.getArgs();
if(args == null || args.length==0 ){
return;
}
// 3.获取方法参数中的实体参数(约定放在第一个位置)
Object entity = args[0];
LocalDateTime now = LocalDateTime.now();
Long currentId = BaseContext.getCurrentId();
if(operationType == OperationType.INSERT){
// 4.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){
e.printStackTrace();
}
} else if(operationType == OperationType.UPDATE){
// 5.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){
e.printStackTrace();
}
}
}
}
将EmployeeMapper下的相关方法加上注解:
java
/**
* 插入员工数据
*
* @Param employee
*/
@Insert("insert into employee(name,username,password,phone,sex,id_number,status,create_time,update_time,create_user,update_user)" +
"values" +
"(#{name},#{username},#{password},#{phone},#{sex},#{idNumber},#{status},#{createTime},#{updateTime},#{createUser},#{updateUser})")
@AutoFill(OperationType.INSERT)
void insert(Employee employee);
/**
* 根据主键动态修改属性
* @param employee
*/
@AutoFill(OperationType.UPDATE)
void update(Employee employee);
将CategoryMapper下的相关方法加上注解:
java
/**
* 插入数据
* @param category
*/
@Insert("insert into category(type, name, sort, status, create_time, update_time, create_user, update_user)" +
" VALUES" +
" (#{type}, #{name}, #{sort}, #{status}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser})")
@AutoFill(OperationType.INSERT)
void insert(Category category);
/**
* 根据id修改分类
* @param category
*/
@AutoFill(OperationType.UPDATE)
void update(Category category);
3.2.3功能测试
访问http://localhost:81,点击修改分类信息:
xml
开始进行公共字段自动填充...
==> Preparing: update category SET name = ?, sort = ?, update_time = ?, update_user = ? where id = ?
==> Parameters: 商务套餐(String), 13(Integer), 2025-08-19T17:43:16.997557800(LocalDateTime), 1(Long), 15(Long)
<== Updates: 1
3.3新增菜品
3.3.1需求分析

业务规则:
- 1.菜品名称必须唯一。
- 2.菜品必须属于某个分类下,不能单独存在。
- 3.新增菜品时可根据情况选择菜品口味。
- 4.每个菜品必须对应一张图片。
相关接口:
- 根据类型查询分类(已完成)

- 文件上传

- 新增菜品

数据库设计:

其中,菜品表存储菜品的基本信息,而口味表存储某个菜品的口味信息。关系: - 一对多 :一个菜品(
dish)可能对应多个口味(dish_flavor),如鱼香肉丝菜品可以对应微辣、中辣、特辣等多种口味。
例如,菜品表有记录:
sql
id | name | price
1 | 鱼香肉丝 | 25.00
对应的口味表:
bash
id | dish_id | name | value
1 | 1 | 辣度 | 微辣
2 | 1 | 辣度 | 中辣
3 | 1 | 辣度 | 特辣
4 | 1 | 配菜 | 加葱
5 | 1 | 配菜 | 不要葱
在实际应用场景中:
- 新增菜品:前端需提交菜品基本信息及其口味列表,后端需同时保存到两张表。
- 查询菜品:需将菜品信息及对应口味信息共同返回。
- 修改菜品:同时更新菜品表与口味表。
3.3.2代码开发
文件上传主要用于菜品图片、套餐图片等静态资源,若这些图片只保存在本地服务器,会存在以下问题:
- 1.存储有限:服务器磁盘空间有限,随着图片数量增加,可能会很快耗尽磁盘。
- 2.访问性能差 :图片访问需要经过应用服务器(
Tomcat),会占用带宽和性能,降低并发能力。 - 3.不利于负载均衡和扩展 :项目部署在多台服务器时,本地存储的文件不同步,可能出现
A服务器上传,B服务器访问不到的情况。
阿里云OSS(Object Storage Service,对象存储服务)能够存储任何类型的非结构化数据(比如图片、音频、视频、日志、备份文件等),并提供HTTP/HTTPS访问接口,用户只要有一个URL,就能直接访问存储的文件。优势如下:
- 海量存储:几乎无限的存储能力,不用担心磁盘不足。
- 支持负载均衡与分布式部署 :无论多少台服务器,都能访问同一份
OSS文件,不存在本地存储不一致的问题。
本项目中,商家在后台管理系统上传菜品/套餐图片后,系统将图片存储到OSS,并返回访问URL。前端页面展示时,直接通过URL访问OSS文件,而不是走应用服务器。
相关概念:
endpoint:指明访问的OSS服务所在的地域名。阿里云在全国/全球有多个机房(北京、上海、杭州、香港等),创建存储空间(Bucket)时选择了一个地域,访问时就必须用对应的endpoint,否则会报错或延迟很高。- 一般格式 :
https://oss-cn-<region>.aliyuncs.com,如https://oss-cn-beijing.aliyuncs.com。
- 一般格式 :
accessKeyId:访问凭证ID,用于标识身份。accessKeySecret:账号密钥(密码)。bucketName:存储空间名称,用于隔离不同业务的数据,相当于大型文件夹。
在阿里云中创建Bucket,获取到上述信息后写入配置文件application-dev.yml:
yml
sky:
alioss:
endpoint: oss-cn-shenzhen.aliyuncs.com
accessKeyId: LTAI5tHzX4fMjySKcCoCvYci
accessKeySecret: vPKhVa4Kux8jUP6fU4614CQ3FW0wiC
bucketName: cangqiongwaimaipbj
在application.yml中配置:
yml
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}
项目中已提供了对应的属性类sky-common/src/main/java/com/sky/properties/AliOssProperties:
java
@Component
@ConfigurationProperties(prefix = "sky.alioss")
@Data
public class AliOssProperties {
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String bucketName;
}
同样还有工具类sky-common/src/main/java/com/sky/utils/AliOssUtil.java实现了文件上传功能。
【OssConfiguration】
在config包下创建配置类OssConfiguration:
java
@Configuration
@Slf4j
public class OssConfiguration {
@Bean
@ConditionalOnMissingBean
public AliOssUtil aliOssUtil(AliOssProperties aliOssProperties){
log.info("开始创建阿里云文件上传工具类对象: {}",aliOssProperties);
return new AliOssUtil(aliOssProperties.getEndpoint(),
aliOssProperties.getAccessKeyId(),
aliOssProperties.getAccessKeySecret(),
aliOssProperties.getBucketName());
}
}
上述代码在Spring容器中注册一个OSS文件上传工具类AliOssUtil,并注入配置文件的参数,保证全局可用且不会重复创建。
【CommonController】
在controller.admin包下创建通用控制器CommonController及控制器方法以实现文件上传功能。由于方法返回阿里云URL,因此泛型为String:
java
@RestController
@RequestMapping("/admin/common")
@Api(tags = "通用接口")
@Slf4j
public class CommonController {
@Autowired
private AliOssUtil aliOssUtil;
/**
* @param file 文件对象(MultipartFile)
*/
@PostMapping("/upload")
@ApiOperation("文件上传")
public Result<String> upload(MultipartFile file) {
log.info("文件上传: {}", file.getOriginalFilename());
try {
String originalFilename = file.getOriginalFilename();//原始文件名
// 截取原始文件名的的后缀
String extention = originalFilename.substring(originalFilename.lastIndexOf("."));
// 构造新文件名称
String objectName = UUID.randomUUID().toString() + extention;
// 文件的请求路径
String filePath = aliOssUtil.upload(file.getBytes(), objectName);
return Result.success(filePath);
} catch (IOException e) {
log.error("文件上传失败:{}", e);
}
return null;
}
}
【DishController】
在controller.admin下创建DishController
java
@RestController
@RequestMapping("/admin/dish")
@Api(tags = "菜品相关接口")
@Slf4j
public class DishController {
@Autowired
private DishService dishService;
@PostMapping
@ApiOperation("新增菜品")
public Result save(@RequestBody DishDTO dishDTO) {
log.info("新增菜品: {}", dishDTO);
dishService.saveWithFlavor(dishDTO);
return Result.success();
}
}
【DishService】
在sky.service下创建DishService
java
public interface DishService {
/**
* 新增菜品及对应口味
* @param dishDTO
* */
public void saveWithFlavor(DishDTO dishDTO);
}
【DishServiceImpl】
java
@Service
@Slf4j
public class DishServiceImpl implements DishService {
@Autowired
private DishMapper dishMapper;
@Autowired
private DishFlavorMapper dishFlavorMapper;
/**
* 新增菜品及对应口味
* */
@Transactional
@Override
public void saveWithFlavor(DishDTO dishDTO) {
Dish dish = new Dish();
BeanUtils.copyProperties(dishDTO, dish);
// 向菜品表插入1条数据
dishMapper.insert(dish);
// 主键回显
Long dishId = dish.getId();
// 向口味表插入n条数据
List<DishFlavor> flavors = dishDTO.getFlavors();
if (flavors != null && flavors.size() > 0) {
flavors.forEach(dishFlavor -> {
dishFlavor.setDishId(dishId);
});
dishFlavorMapper.insertBatch(flavors);
}
}
}
在sky.service.Impl下创建DishServiceImpl,由于操作涉及多个表,因此需添加@Transactional,并在启动类加上@EnableTransactionManagement。
使用@Transactional并在启动类加上@EnableTransactionManagement后,当该方法被调用时会开启数据库事务以保证原子性:
- 插入菜品表成功,插入口味表也必须成功。
- 如果插入口味表失败,则会回滚之前插入的菜品表数据,避免数据不一致。
此外,在原生MyBatis中可通过以下方式实现主键回显:
xml
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO dish(name, price, category_id, status)
VALUES(#{name}, #{price}, #{categoryId}, #{status})
</insert>
此时数据库生成的自增主键会被设置回dish.id属性。而MyBatis-plus中可在实体类Dish上使用:
java
@TableId(type = IdType.AUTO)
private Long id;
MyBatis-Plus会自动在执行insert(dish)后,把数据库生成的主键回填到dish.id。
【mapper.DishMapper】
java
/**
* 插入菜品
* */
void insert(Dish dish);
【mapper.DishMapper.xml】
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.sky.mapper.DishMapper">
<insert id="insert">
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>
【mapper.DishFlavorMapper】
java
@Mapper
public interface DishFlavorMapper {
@AutoFill(value = OperationType.INSERT)
void insertBatch(List<DishFlavor> dishFlavors);
}
【mapper.DishFlavorMapper.xml】
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://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>
3.3.3功能测试


3.4分页查询
3.4.1需求分析

业务规则:
- 1.根据页码展示菜品信息。
- 2.每页展示
10条数据。 - 3.分页查询时可根据需要输入菜品名称、菜品分类、菜品状态进行查询。

3.4.2代码开发
【DishController】
java
@GetMapping("/page")
@ApiOperation("菜品分页查询")
public Result<PageResult> page(DishPageQueryDTO dishPageQueryDTO){
log.info("菜品分页查询:{}", dishPageQueryDTO);
PageResult pageResult = dishService.pageQuery(dishPageQueryDTO);
return Result.success(pageResult);
}
【DishService】
java
/**
* 菜品分页查询
* */
public PageResult pageQuery(DishPageQueryDTO dishPageQueryDTO);
【DishServiceImpl】
java
public PageResult pageQuery(DishPageQueryDTO dishPageQueryDTO){
PageHelper.startPage(dishPageQueryDTO.getPage(), dishPageQueryDTO.getPageSize());
Page<DishVO> page = dishMapper.pageQuery(dishPageQueryDTO);
return new PageResult(page.getTotal(), page.getResult());
}
【mapper.DishMapper】
java
/**
* 菜品分页查询
* */
Page<DishVO> pageQuery(DishPageQueryDTO dishPageQueryDTO);
【mapper.DishMapper.xml】
xml
<select id="pageQuery" resultType="com.sky.vo.DishVO">
select d.* , c.name as categoryName from dish d left outer join category c on d.category_id = c.id
<where>
<if test="name != null">
and d.name like concat('%',#{name},'%')
</if>
<if test="categoryId != null">
and d.category_id = #{categoryId}
</if>
<if test="status != null">
and d.status = #{status}
</if>
</where>
</select>
3.4.3功能测试


3.5删除菜品
3.5.1需求分析

业务规则:
- 1.可以一次删除一个菜品也可批量删除菜品。
- 2.起售中的菜品不能被删除。
- 3.被套餐关联的菜品不能删除。
- 4.删除菜品后,关联的口味数据也需被删除。


相关数据库表:
dish:菜品表。id(菜品主键)name(菜品名)price(单价)category_id(分类id)
setmeal:套餐表。id(套餐主键)name(套餐名)price(套餐价)category_id(分类id)
setmeal_dish:套餐菜品关系表。id(主键)setmeal_id(套餐id,外键关联setmeal.id)dish_id(菜品id,外键关联dish.id)copies(份数,比如一个套餐里要2份米饭)
关系:
- 多对多:一个套餐可以包含多个菜品,一个菜品也可以属于多个套餐。
而中间表起到多对多的关联作用。应用场景:
- 新增套餐 :前端会传套餐基本信息 + 菜品列表,后端需插入
setmeal表和setmeal_dish表中。 - 查询套餐详情:将套餐信息和相关菜品信息共同返回。
- 删除套餐:需要同时删除套餐表和套餐菜品关系表的数据。
3.5.2代码开发
【controller.DishController】
java
@DeleteMapping
@ApiOperation("菜品批量删除")
public Result delete(@RequestParam List<Long> ids){
log.info("菜品批量删除:{}", ids);
dishService.deleteBatch(ids);
return Result.success();
}
该控制器方法映射路径等同于新增菜品方法,但HTTP方法为DELETE。
【com.sky.service.DishService】
java
/*
* 批量删除菜品
* */
void deleteBatch(List<Long> ids);
【DishServiceImpl】
java
@Autowired
private SetmealDishMapper setmealDishMapper;
@Transactional
public void deleteBatch(List<Long> ids){
// 存在起售中的菜品不能删除
for (Long id : ids) {
Dish dish = dishMapper.getById(id);
// StatusConstant.ENABLE表示状态为起售中
if(dish.getStatus()== StatusConstant.ENABLE){ //状态为1起售中
throw new DeletionNotAllowedException(MessageConstant.DISH_ON_SALE);
}
}
// 被套餐关联的菜品不能删除
List<Long> setmealIds = setmealDishMapper.getSetmealIdsByDishIds(ids);
if(setmealIds != null && setmealIds.size()>0){
throw new DeletionNotAllowedException(MessageConstant.DISH_BE_RELATED_BY_SETMEAL);
}
// 删除菜品数据
for (Long id : ids) {
dishMapper.deleteById(id);
// 删除关联的口味数据
dishFlavorMapper.deleteByDishId(id);
}
}
【mapper.DishMapper】
java
/**
* 菜品查询
* */
@Select("select * from dish where id = #{id}")
Dish getById(Long id);
/**
* 菜品删除
* */
@Delete("delete from dish where id = #{id}")
void deleteById(Long id);
【mapper.DishFlavorMapper】
java
/**
* 删除口味数据
* */
@Delete("delete from dish_flavor where dish_id = #{dishId}")
void deleteByDishId(Long dishId);
【mapper.SetmealDishMapper】
java
@Mapper
public interface SetmealDishMapper {
/**
* 根据菜品id查询套餐id
* */
List<Long> getSetmealIdsByDishIds(List<Long> dishIds);
}
【mapper.SetmealDishMapper.xml】
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.sky.mapper.SetmealDishMapper">
<select id="getSetmealIdsByDishIds" resultType="java.lang.Long">
select setmeal_id from setmeal_dish where dish_id in
<foreach collection="dishIds" item="dishId" separator="," open="(" close=")">
#{dishId}
</foreach>
</select>
</mapper>
3.6修改菜品
3.6.1需求分析

接口设计:
- 1.根据
id查询菜品。 - 2.根据类型查询分类(已实现)。
- 3.文件上传(已实现)。
- 4.修改菜品。


3.6.2代码开发
实现根据id查询菜品功能:
【DishController】
java
@GetMapping("/{id}")
@ApiOperation("根据id查询菜品")
public Result<DishVO> getById(@PathVariable Long id){
log.info("根据id查询菜品:{}", id);
DishVO dishVO = dishService.getByIdWithFlavor(id);
return Result.success(dishVO);
}
【DishService】
java
DishVO getByIdWithFlavor(Long id);
【DishServiceImpl】
java
// 根据 id 查询菜品和对应的口味数据
public DishVO getByIdWithFlavor(Long id){
// 根据 id 查询菜品数据
Dish dish = dishMapper.getById(id);
// 根据菜品 id 查询口味数据
List<DishFlavor> dishFlavors = dishFlavorMapper.getByDishId(id);
// 将查询到的数据封装到 VO
DishVO dishVO = new DishVO() ;
BeanUtils.copyProperties(dish, dishVO);
dishVO.setFlavors(dishFlavors);
return dishVO;
}
【DishFlavorMapper】
java
@Select("select * from dish_flavor where dish_id = #{dishId}")
List<DishFlavor> getByDishId(Long dishId);
实现修改菜品功能:
【DishController】
java
@PutMapping
@ApiOperation("修改菜品")
public Result update(@RequestBody DishDTO dishDTO){
log.info("修改菜品;{}", dishDTO);
dishService.updateWithFlavor(dishDTO);
return Result.success();
}
【DishService】
java
void updateWithFlavor(DishDTO dishDTO);
【DishServiceImpl】
java
// 根据id修改菜品基本信息和对应的口味信息
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);
}
由于DishDTO包含口味数据,而修改菜品不应该包含口味,因此dishMapper.update(dish);传入Dish对象。
【dishMapper】
java
@AutoFill(value = OperationType.UPDATE)
void update(Dish dish);
【dishMapper.xml】
xml
<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>
四、整合Redis
4.1Java操作Redis
SpringData Redis是Spring提供的Redis操作框架,核心组件:
RedisTemplate<Object, Object>:核心操作类,使用前需指定序列化器,否则存入二进制字节数组。StringRedisTemplate:RedisTemplate的特殊实现,key和value都是String类型。
常见操作:
java
// String 操作
redisTemplate.opsForValue().set("k1", "v1");
System.out.println(redisTemplate.opsForValue().get("k1"));
// Hash 操作
redisTemplate.opsForHash().put("user:1", "name", "张三");
redisTemplate.opsForHash().put("user:1", "age", 20);
System.out.println(redisTemplate.opsForHash().get("user:1", "name"));
// List 操作
redisTemplate.opsForList().leftPush("list", "a");
redisTemplate.opsForList().leftPush("list", "b");
System.out.println(redisTemplate.opsForList().range("list", 0, -1));
// Set 操作
redisTemplate.opsForSet().add("set", "x", "y", "z");
System.out.println(redisTemplate.opsForSet().members("set"));
// ZSet 操作
redisTemplate.opsForZSet().add("zset", "A", 1);
redisTemplate.opsForZSet().add("zset", "B", 2);
System.out.println(redisTemplate.opsForZSet().range("zset", 0, -1));
使用步骤:
- 1.导入依赖。
- 2.配置
Redis数据源。 - 3.编写配置类,创建
RedisTemplate对象 - 4.通过
RedisTemplate对象操作Redis。
【1.导入依赖】
java
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
【2.配置Redis数据源】
在application-dev.yml配置:
yml
sky:
redis:
host: localhost
port: 6379
database: 0
在application.yml引用:
yml
spring:
redis:
host: ${sky.redis.host}
port: ${sky.redis.port}
database: ${sky.redis.database}
【3.编写配置类】
创建com.sky.config.RedisConfiguration:
java
@Configuration
@Slf4j
public class RedisConfiguration {
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){
log.info("开始创建redis模板对象...");
RedisTemplate redisTemplate = new RedisTemplate();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
return redisTemplate;
}
}
redisTemplate(...)方法会向容器注入RedisTemplate Bean对象,方法内部:
RedisTemplate redisTemplate = new RedisTemplate();:创建RedisTemplate对象。redisTemplate.setConnectionFactory(redisConnectionFactory);:设置Redis连接工厂。redisTemplate.setKeySerializer(new StringRedisSerializer());:默认情况下,RedisTemplate使用JDK序列化,存储到Redis的key会是二进制字节数组。此处将key的序列化方式改为StringRedisSerializer,存入的key就是字符串,方便查看和调试。
例如,向Redis存入数据:
java
User user = new User(1L, "张三", 20);
redisTemplate.opsForValue().set("user:1", user);
查看数据:
sql
127.0.0.1:6379> get user:1
"\xac\xed\x00\x05sr\x00\x0bcom.demo.User..."
可见,键正常被转为字符串存储在Redis,而值则被JDK序列化。在实际开发中,往往使用JSON序列化处理值。如:
java
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
查看数据:
json
127.0.0.1:6379> get user:1
{"@class":"com.demo.User","id":1,"name":"张三","age":20}
【4.通过RedisTemplate对象操作Redis】
java
@RestController
@RequestMapping("/redis")
public class RedisController {
@Autowired
private RedisTemplate redisTemplate;
@GetMapping("/set")
public String setValue() {
// 存储字符串
redisTemplate.opsForValue().set("name", "苍穹外卖");
return "OK";
}
@GetMapping("/get")
public Object getValue() {
// 获取字符串
return redisTemplate.opsForValue().get("name");
}
}
4.2店铺营业状态
4.2.1需求分析

如上图所示,店铺营业状态用于控制前台点餐功能是否开放:
- 店铺处于营业状态时,用户可以在小程序/网页端浏览菜品、下单。
- 店铺处于打烊状态时,用户端会显示提示并禁止用户下单。
在后台管理端,可动态切换营业/打烊状态,底层通过修改数据库中的相关字段实现。事实上,营业状态数据有以下特点:
- 高频访问 :每一个用户在进入点餐页面时,都需要先判断店铺是否营业。假设有成千上万的用户同时访问,如果每次都查
MySQL,数据库压力会非常大。 - 低频修改:营业状态一般一天只改几次(比如早上开门、晚上关门),修改频率非常低。
这种读多写少的场景非常适合使用Redis缓存存储。
接口设计:
-
1.设置营业状态。

-
2.管理端查询营业状态。

- 3.用户端查询营业状态。

4.2.2代码开发
【controller.admin.ShopController】
java
@RestController("adminShopController") // 避免与用户端 Bean 重名
@RequestMapping("/admin/shop")
@Api(tags="管理端营业状态接口")
@Slf4j
public class ShopController {
public static final String KEY="SHOP_STATUS";
@Autowired
private RedisTemplate redisTemplate;
@PutMapping("/{status}")
@ApiOperation("设置店铺的营业状态")
public Result setStatus(@PathVariable Integer status){
log.info("设置店铺的营业状态为:{}", status== 1 ? "营业中" : "打烊中");
redisTemplate.opsForValue().set("SHOP_STATUS", status);
return Result.success();
}
@GetMapping("/status")
@ApiOperation("获取店铺的营业状态")
public Result<Integer> getStatus(){
Integer status = (Integer) redisTemplate.opsForValue().get(KEY);
log.info("获取到店铺的营业状态为:{}", status== 1 ? "营业中" : "打烊中");
return Result.success(status);
}
}
【controller.user.ShopController】
java
@RestController("userShopController")
@RequestMapping("/user/shop")
@Api(tags="店铺相关接口")
@Slf4j
public class ShopController {
public static final String KEY="SHOP_STATUS";
@Autowired
private RedisTemplate redisTemplate;
@GetMapping("/status")
@ApiOperation("获取店铺的营业状态")
public Result<Integer> getStatus(){
Integer status = (Integer) redisTemplate.opsForValue().get(KEY);
log.info("获取到店铺的营业状态为:{}", status== 1 ? "营业中" : "打烊中");
return Result.success(status);
}
}
五、小程序开发
5.1HttpClient
小程序在苍穹外卖中的作用:
- 1.作为用户端入口 :用户不需要下载额外
App,直接在微信小程序里使用点餐、下单、支付、订单查询 等功能。 - 2.对接后端服务 :小程序通过
HTTP接口调用SpringBoot后端。例如:- 获取店铺营业状态
/shop/status - 获取菜品分类
/category/list - 提交订单
/order/submit - 查询订单
/order/history
- 获取店铺营业状态
HttpClient能够让Java程序发起HTTP/HTTPS请求并处理响应,相当于Java程序里的浏览器。使用前需引入依赖:
xml
<!--HttpClient-->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.13</version>
</dependency>
代码编写步骤:
- 1.创建
HttpClient对象。 - 2.创建
Http请求对象,如HttpGet。 - 3.调用
HttpClient.execute(...)发送请求。
例如:
java
@SpringBootTest
public class HttpClientTest {
@Test
public void testPOST() throws Exception{
// 创建 HttpClient
CloseableHttpClient httpClient = HttpClients.createDefault();
// 创建 POST 请求对象
HttpPost httpPost = new HttpPost("http://localhost:8080/admin/employee/login");
// 构造 JSON 请求体
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);
// 获取响应信息 ( JSON 格式)
int statusCode = response.getStatusLine().getStatusCode();
System.out.println("响应码为:"+statusCode);
HttpEntity entity1 = response.getEntity();
String body = EntityUtils.toString(entity1);
System.out.println("响应数据为:"+body);
// 关闭资源
response.close();
httpClient.close();
}
}

在测试时应当先运行SkyApplication再运行上述测试代码。事实上,SpringApplication.run(SkyApplication.class, args);会启动内嵌Tomcat以监听端口,等待客户端发请求。而IDEA中的测试类并不与main()方法共用一个线程,此时HttpClientTest是另外一个JUnit测试进程在运行,因此二者可同时执行。
5.2开发者工具

在链接中注册小程序,并获取小程序ID、小程序密钥:

下载开发者工具并安装:

进入开发工具:

进入开发页面:

微信小程序的目录结构是固定的,有一些必须存在的文件和约定的目录,整体如下:
bash
project
├── pages/ # 页面文件夹
│ ├── index/ # 首页页面(一个页面对应一个目录)
│ │ ├── index.js # 页面逻辑(必须)
│ │ ├── index.json # 页面配置(非必须)
│ │ ├── index.wxml # 页面结构(必须)
│ │ └── index.wxss # 页面样式表(非必须)
│ └── logs/ # 日志页面
│ ├── logs.js
│ ├── logs.json
│ ├── logs.wxml
│ └── logs.wxss
├── utils/ # 工具类文件(可选)
│ └── util.js
├── app.js # 小程序入口逻辑文件
├── app.json # 全局配置(路由、窗口表现、tabBar 等)
├── app.wxss # 全局样式表
├── project.config.json # 项目配置文件(小程序 IDE 自动生成)
└── sitemap.json # 小程序索引配置(SEO、搜索用,可选)
各部分说明:
pages/:页面目录。每个页面对应一个独立目录,其中必须包含.js、.json、.wxml、.wxss。app.js:小程序入口文件,定义全局生命周期、全局数据。app.json:全局配置文件,声明有哪些页面、窗口表现等。app.wxss:全局样式,作用于所有页面。utils/:工具类文件。project.config.json:微信开发者工具的项目配置。sitemap.json:配置小程序页面是否能被微信搜索到。
5.3Token
Token(令牌)本质上是一个字符串,由服务端生成并颁发给客户端,用来标识用户身份、会话状态或访问权限。在Web开发中,Token常用于:
- 用户登录认证(例如
JWT登录后返回给前端) - 接口访问权限控制(如
OAuth2的access_token)
常见的生成方式:
JWTUUID
5.3.1JWT
JWT(JSON Web Token)由三部分组成:
bash
Header.Payload.Signature
Header:头部。描述JWT的元信息,包含签名算法和类型。
bash
{
"alg": "HS256", # 签名算法
"typ": "JWT" # 类型, 固定为 JWT
}
Payload:负载。存储声明(Claims),如用户ID、角色、过期时间等。由于Payload是明文的,因此不能存放敏感信息(如密码)。
bash
{
"userId": 12345,
"username": "admin",
"role": "merchant",
"exp": 1723873965 # 过期时间
}
Signature:签名。保证数据完整性,防止篡改,由头部与负载共同生成:
bash
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret # secret 是后端服务器保存的密钥
)
以上三部分军需经过Base64URL编码后才能进行传输:
bash
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJ1c2VySWQiOiIxMjM0NSIsInVzZXJuYW1lIjoiYWRtaW4iLCJleHAiOjE3MjM4NzM5NjV9.
TjB8aQdwp4gC0kzO2J2oUoYlH7qLB7ssrmJQJz8yEMQ
在苍穹外卖小程序用户登录中的工作流程:
- 1.用户在前端(小程序)登录,后端验证身份成功后生成
JWT。 - 2.后端把
JWT返回给前端,前端保存到本地(如wx.setStorageSync("token"))。 - 3.前端以后每次请求接口时,在
HTTP请求头中携带token:
bash
Authorization: Bearer <JWT>
- 4.后端拦截请求并验证
JWT是否正确,验证通过则放行,否则返回401 Unauthorized。
这一过程通过JwtUtil工具类来完成:
createJWT()
java
/**
* 生成jwt
* 使用Hs256算法, 私匙使用固定秘钥
*
* @param secretKey jwt秘钥
* @param ttlMillis jwt存活时间(毫秒)
* @param claims 设置的信息
* @return
*/
public static String createJWT(String secretKey, long ttlMillis, Map<String, Object> claims) {
// 选择签名算法
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// 计算过期时间 = 当前时间 + ttlMillis ( 存活时间 )
long expMillis = System.currentTimeMillis() + ttlMillis;
Date exp = new Date(expMillis);
// 构建 JWT
JwtBuilder builder = Jwts.builder()
.setClaims(claims) // 设置 Claims, 存放业务数据, 如用户id、用户名
.signWith(signatureAlgorithm, secretKey.getBytes(StandardCharsets.UTF_8)) // 生成 Signature 签名
.setExpiration(exp); // 过期校验
return builder.compact(); // 压缩成字符串: header.payload.signature
}
parseJWT()
java
public static Claims parseJWT(String secretKey, String token) {
Claims claims = Jwts.parser()
.setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8)) // 设置与生成 JWT 时相同的密钥
.parseClaimsJws(token) // 解析并校验 token, 防止有人篡改, 若过期则抛出异常
.getBody(); // 获取 Payload
return claims;
}
在EmployeeController中生成了JWT令牌:
java
Map<String, Object> claims = new HashMap<>();
claims.put(JwtClaimsConstant.EMP_ID, employee.getId());
String token = JwtUtil.createJWT(
jwtProperties.getAdminSecretKey(),
jwtProperties.getAdminTtl(),
claims);
在JwtTokenAdminInterceptor中解析了JWT令牌:
java
Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());
5.3.2UUID
UUID(Universally Unique Identifier,全局唯一标识符),是一个128位的数字,通常表现为32个16进制字符,分成5段,如:
bash
550e8400-e29b-41d4-a716-446655440000
特点:
- 1.几乎不会重复。
- 2.生成速度快。
使用UUID为用户生成Token的唯一性强、安全性好,即便系统部署在多台服务器,也不用担心发生冲突。
可直接使用UUID.randomUUID()生成:
java
public class TokenGenerator {
public static String generateToken() {
return UUID.randomUUID().toString();
}
public static void main(String[] args) {
System.out.println(generateToken()); // 9a1c0192-2c9f-41c1-b520-3a9827b986b0
}
}
也可去掉-得到32位Token:
java
public static String generateToken() {
return UUID.randomUUID().toString().replace("-", "");
}
5.4微信登录
5.4.1代码导入
将\资料\day06\微信小程序代码\mp-weixin解压并导入,注意,项目根目录需设置为\资料_苍穹外卖\资料\day06\微信小程序代码\mp-weixin\mp-weixin,否则找不到app.json。

打开common/vendor.js:

将baseUrl改为:
bash
var baseUrl = 'http://localhost:81';
以与前文nginx对应。
5.4.2微信登录流程

点击链接,查看官方提供的小程序登录文档。微信小程序登录流程:
- 1.前端调用微信提供的
wx.login()接口获取临时登录凭证code并发送给服务端。 - 2.服务端调用微信接口(携带
code及前文获取的AppID、AppSecret)换取openid、unionid和session_key:
bash
# 服务端发送数据
GET https://api.weixin.qq.com/sns/jscode2session
?appid=APPID
&secret=APPSECRET
&js_code=CODE
&grant_type=authorization_code # 固定值 authorization_code
# 微信返回数据
{
"openid": "OPENID", # 唯一标识用户
"session_key": "SESSIONKEY", # 会话密钥
"unionid": "UNIONID"
}
openid安全性不够,不能直接返回给用户。因此基于服务端openid生成自己的token(JWT或UUID)返回给前端,并将其作为用户会话状态存入Redis。- 前端拿到服务端返回的
token后保存到本地(wx.setStorageSync("token", token)),后续请求都在header里带上:
js
wx.request({
url: 'https://your-server.com/api/order',
method: 'POST',
header: {
"Authorization": "Bearer " + wx.getStorageSync("token")
},
data: {...}
})
例如,点击确定:

前端代码(已写好)调用微信提供的wx.login()接口获取到code并打印在控制台:
bash
{errMsg: "login:ok", code: "0b3273ll2Lzzag4g6xll297UuW1273l5"}
使用PostMan调用https://api.weixin.qq.com/sns/jscode2session接口:

5.4.3需求分析

业务规则:
- 1.基于微信登录实现小程序的登录功能。
- 2.新用户需自动完成注册。


5.4.4代码开发1
【applicaton-dev.yml】
yml
sky:
wechat:
appid: wxc97ca64ef3273a2d
secret: 2d439760892fcf1e99d945e39cbea2e4
【application.yml】
yml
sky:
jwt:
# 设置jwt签名加密时使用的秘钥
admin-secret-key: itcast
# 设置jwt过期时间
admin-ttl: 7200000
# 设置前端传递过来的令牌名称
admin-token-name: token
# 设置jwt签名加密时使用的秘钥
user-secret-key: itcast
# 设置jwt过期时间
user-ttl: 7200000
# 设置前端传递过来的令牌名称
user-token-name: token
wechat:
appid: ${sky.wechat.appid}
secret: ${sky.wechat.secret}
【user.UserController】
java
@RestController
@RequestMapping("/user/user")
@Api(tags="C端用户相关接口")
@Slf4j
public class UserController {
@Autowired
private UserService userService;
@Autowired
private JwtProperties jwtProperties;
@PostMapping("/login")
@ApiOperation("微信登录")
public Result<UserLoginVO> login(@RequestBody UserLoginDTO userLoginDTO){
log.info("微信用户登录;{}",userLoginDTO.getCode());
User user = userService.wxLogin(userLoginDTO);
// 生成 jwt令牌
Map<String,Object> claims = new HashMap<>();
claims.put(JwtClaimsConstant.USER_ID,user.getId());
String token = JwtUtil.createJWT(jwtProperties.getUserSecretKey(),jwtProperties.getUserTtl(),claims);
UserLoginVO userLoginVO = UserLoginVO.builder()
.id(user.getId())
.openid(user.getOpenid())
.token(token)
.build();
return Result.success(userLoginVO);
}
}
在苍穹外卖中共有两处使用到了JWT令牌:
- 1.商家(后台)登录 :员工登录后后端签发
JWT,前端/后台管理系统把这个token带到每次请求以校验管理员权限。 - 2.客户(小程序)登录 :微信小程序登录成功后后端签发
JWT,小程序把token保存并在后续请求的请求头中携带,用于鉴权、识别当前用户。
两种场景的权限和身份不同:
- 管理员 (
Admin):代表后台系统员工,能做增删改查、运营配置等高敏感操作。 - 用户 (
User):代表点餐小程序的普通顾客,只能下单、查订单等。
因此使用两套配置、两套拦截器,且在配置上:
secretKey:admin和user用不同的密钥,避免一处泄露全线失守。TTL:admin token更短,user token稍长。tokenName:admin和user的请求头不同,方便区分。
【service.UserService】
java
public interface UserService {
User wxLogin(UserLoginDTO userLoginDTO);
}
【service.UserServiceImpl】
java
@Service
@Slf4j
public class UserServiceImpl implements UserService {
//微信服务接口地址
public static final String WX_LOGIN="http://api.weixin.qq.com/sns/jscode2session";
@Autowired
private WeChatProperties weChatProperties; //配置文件中传入的值会被放入配置类,所以只需要自动注入然后获取即可
@Autowired
private UserMapper userMapper;
@Override
public User wxLogin(UserLoginDTO userLoginDTO){
// 获取OpenId
String openid = getOpenid(userLoginDTO.getCode());
// OpenId为空则抛出业务异常
if(openid==null){
throw new LoginFailedException(MessageConstant.LOGIN_FAILED);
}
// 根据OpenId在用户表查找用户, 判断是否是新用户
User user = userMapper.getByOpenid(openid);
// 新用户则自动完成注册
if(user == null){
user = User.builder()
.openid(openid)
.createTime(LocalDateTime.now())
.build();
userMapper.insert(user);
}
// 返回用户对象
return user;
}
// 调用微信接口获取OpenId
private String getOpenid(String code){
Map<String,String> map = new HashMap<>();
map.put("appid",weChatProperties.getAppid());
map.put("secret",weChatProperties.getSecret());
map.put("js_code",code);
map.put("grant_type","authorization_code");
// 发送GET请求到微信服务器
String json = HttpClientUtil.doGet(WX_LOGIN, map);
JSONObject jsonObject = JSON.parseObject(json);
// 获取OpenId
String openid = jsonObject.getString("openid");
return openid;
}
}
微信官方文档给出了返回的JSON数据格式:

【mapper.UserMapper】
java
@Mapper
public interface UserMapper {
// 插入数据
void insert(User user);
// 根据OpenId查询用户
@Select("select * from user where openid = #{openid};")
User getByOpenid(String openId);
}
【mapper.UserMapper.xml】
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.sky.mapper.UserMapper">
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into user(openid,name,phone,sex,id_number,avatar,create_time)
values (#{openid}, #{name}, #{phone}, #{sex}, #{idNumber}, #{avatar}, #{createTime})
</insert>
</mapper>
上述代码执行流程:
- 1.前端处理逻辑 :小程序先调用
wx.login()获取code,并将code放到请求体里,调用/login接口。
bash
POST /login
Content-Type: application/json
{
"code": "021yXXX0hV7..."
}
- 2.后端处理逻辑 :调用
userService.wxLogin(userLoginDTO)返回用户对象并生成JWT令牌,封装成UserLoginVO(含id/openid/token)返回给前端。
json
{
"code": 1,
"msg": "success",
"data": {
"id": 10001,
"openid": "o2V0x5bX12345678",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6..."
}
}
- 3.前端(小程序)收到返回值后的处理 :拿到
JSON后从中得到JWT令牌并存储(团队协作中,前后端会交换DTO的结构逻辑),在后续请求中会将Token放入请求头。如:
bash
GET /user/info
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6...
因此,后端还应将加上拦截器校验Token,实现逻辑:
- 1.后端解析
JWT,拿到userId。 - 2.确认
Token没过期 & 签名有效。 - 3.放行请求,否则返回
401 Unauthorized。
5.4.4代码开发2
【JwtTokenUserInterceptor】
java
@Component
@Slf4j
public class JwtTokenUserInterceptor implements HandlerInterceptor {
@Autowired
private JwtProperties jwtProperties;
// 校验jwt
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 非控制器方法直接放行(如静态资源请求)
if (!(handler instanceof HandlerMethod)) {
return true;
}
// 1. 从请求头中获取令牌
String token = request.getHeader(jwtProperties.getUserTokenName());
// 2. 校验令牌
try {
log.info("jwt校验:{}", token);
if(token == null || token.isEmpty()) {
response.setStatus(401);
return false;
}
// 通过密钥解码并验证token
Claims claims = JwtUtil.parseJWT(jwtProperties.getUserSecretKey(), token);
// 从 claims 中取出用户 ID 并存入 BaseContext(基于 ThreadLocal 的上下文工具类), 方便后续 Controller/Service 中获取当前用户
Long userId = Long.valueOf(claims.get(JwtClaimsConstant.USER_ID).toString());
BaseContext.setCurrentId(userId);
log.info("当前员工id:{}", userId);
// 3. 放行
return true;
} catch (Exception ex) {
// 4. 响应 401
response.setStatus(401);
return false;
}
}
}
【config.WebMvcConfiguration】
java
@Autowired
private JwtTokenUserInterceptor jwtTokenUserInterceptor;
并在addInterceptors()中加入:
java
registry.addInterceptor(jwtTokenUserInterceptor)
.addPathPatterns("/user/**")
.excludePathPatterns("/user/user/login")
.excludePathPatterns("/user/shop/status");
5.5浏览菜品
5.5.1需求分析

相关接口:
- 查询分类
- 根据分类
id查询菜品 - 根据分类
id查询套餐 - 根据套餐
id查询包含的菜品




5.5.2导入代码
将资料\day06\代码导入\商品浏览、资料\day04\项目实战-套餐管理 - 参考答案.md中的代码导入。
六、商品缓存与购物车
6.1缓存菜品
6.1.1需求分析
考虑使用Redis缓存菜品数据,减少数据库查询操作。具体地,每个分类下保存一份菜品缓存数据,当数据库中菜品数据有变更时要清理缓存数据。
6.1.2代码开发
实现缓存功能:
【controller.user.DishController】
java
/**
* 根据分类id查询菜品
* @param categoryId
* @return
*/
@GetMapping("/list")
@ApiOperation("根据分类id查询菜品")
public Result<List<DishVO>> list(Long categoryId) {
// 构造key: dish_分类id
String key = "dish_" + categoryId;
// 查询 Redis 中是否已存在
List<DishVO> list = ( List<DishVO> ) redisTemplate.opsForValue().get(key);
// 缓存命中则直接返回
if (list != null || list.size() > 0) {
return Result.success(list);
}
Dish dish = new Dish();
dish.setCategoryId(categoryId);
dish.setStatus(StatusConstant.ENABLE);//查询起售中的菜品
// 不命中则查询数据库并放入 Redis
list = dishService.listWithFlavor(dish);
redisTemplate.opsForValue().set(key, list);
return Result.success(list);
}
key以分类id为维度进行存储,若系统有10个分类,则Redis至少会有10个key,每个key对应一个List<DishVO>,包含该分类下所有的菜品数据。即,缓存存的是分类级别的完整菜品列表,而不是单个菜品。优点:
- 查询某个分类时,直接返回该分类下所有菜品(连口味信息都带了),前端页面渲染很快。
缺点:
- 如果分类下有很多菜品(比如
1000个),一次性都塞进Redis,内存占用会很大。
此外,当菜品的价格被修改,如果继续从Redis从取数据,会导致数据的不一致。因此,新增菜品、修改菜品、批量删除菜品、起售和停售菜品的时候需要清理缓存。实现清理缓存功能:
【admin.DishController】
java
@Autowired
private RedisTemplate redisTemplate;
// 清理缓存数据
private void clearCache(String pattern) {
Set keys = redisTemplate.keys(pattern);
redisTemplate.delete(keys);
}
因此在所有update、delete方法调用完service的方法后应执行:
java
clearCache("dish_*");
而在save方法中调用service的方法后应执行:
java
String key = "dish_" + dishDTO.getCategoryId();
cleanCache(key);
6.2SpringCache
6.2.1基本介绍
SpringCache屏蔽了不同缓存实现的差异,使得程序员只需要关心业务逻辑,而不用关心底层缓存实现。常用注解:
【@EnableCaching】
写在启动类上,用于启用缓存功能。
java
@SpringBootApplication
@EnableCaching
public class Application { ... }
【@Cacheable】
用于缓存读取。先查缓存,如果有就直接返回,不调用方法;如果没有,就调用方法并把结果放入缓存。
java
@Cacheable(value = "users", key = "#id")
public User getUserById(Long id) {
return userRepository.findById(id).orElse(null);
}
value:缓存的名字。key:缓存的键。
【@CachePut】
用于缓存更新。每次调用方法都会执行,并更新缓存。
java
@CachePut(value = "users", key = "#user.id")
public User updateUser(User user) {
return userRepository.save(user);
}
【@CacheEvict】
用于缓存清除。
java
@CacheEvict(value = "users", key = "#id")
public void deleteUser(Long id) {
userRepository.deleteById(id);
}
支持属性:
allEntries = true:清空整个缓存。beforeInvocation = true:在方法执行前清除缓存(默认是执行后)。
6.2.2入门案例
【引入依赖】
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
【application.yml】
yml
spring:
cache:
type: redis # 使用Redis作为缓存实现
redis:
host: localhost
port: 6379
【启动类配置】
java
@SpringBootApplication
@EnableCaching // 开启缓存功能
public class CacheApplication {
public static void main(String[] args) {
SpringApplication.run(CacheApplication.class, args);
}
}
【Service层】
java
@Service
public class UserService {
private static final Map<Long, User> DATABASE = new HashMap<>();
static {
DATABASE.put(1L, new User(1L, "Tom"));
DATABASE.put(2L, new User(2L, "Jerry"));
}
// ① 查询用户(缓存读写)
@Cacheable(value = "users", key = "#id")
public User getUser(Long id) {
System.out.println("查询数据库 id=" + id);
return DATABASE.get(id);
}
// ② 更新用户(缓存更新)
@CachePut(value = "users", key = "#user.id")
public User updateUser(User user) {
System.out.println("更新数据库 id=" + user.getId());
DATABASE.put(user.getId(), user);
return user;
}
// ③ 删除用户(缓存清除)
@CacheEvict(value = "users", key = "#id")
public void deleteUser(Long id) {
System.out.println("删除数据库 id=" + id);
DATABASE.remove(id);
}
}
【Controller层】
java
@RestController
@RequestMapping("/users")
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
return userService.getUser(id);
}
@PutMapping("/")
public User updateUser(@RequestBody User user) {
return userService.updateUser(user);
}
@DeleteMapping("/{id}")
public String deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return "删除成功";
}
}
6.3套餐缓存
在启动类SkyApplication上加上注解@EnableCaching,在user.SetmealController.list()上加上注解:
java
@Cacheable(cacheNames="setmealCache",key="#categoryId")
在admin.SetmealController.save()上加上:
java
@CacheEvict(cacheNames="setmealCache",key="#setmealDTO.categoryId")
在admin.SetmealController的update()、delete()、startOrStop()方法上加
java
@CacheEvict(cacheNames = "setmealCache",allEntries = true)
6.4购物车
6.4.1需求分析

购物车用于暂时存放所选商品,需要记录选的是什么商品、选购商品的个数,且不同用户有不同购物车。对于套餐,直接点击+即可加入购物车。对于菜品,若没设置口味数据,则直接点击+就可以加入购物车;如果有口味数据,就点击选择规格然后选择口味加入购物车。

数据库表中存在大量冗余字段,用于提高数据的显示速度,不必去连接其它表查询。

6.4.2代码开发
实现添加购物车功能:
【user.ShoppingCartController】
java
@RestController
@RequestMapping("/user/shoppingCart")
@Slf4j
@Api(tags="C端购物车相关接口")
public class ShoppingCartController {
@Autowired
private ShoppingCartService shoppingCartService;
@PostMapping("/add")
@ApiOperation("添加购物车")
public Result add(@RequestBody ShoppingCartDTO shoppingCartDTO){
log.info("添加购物车, 商品信息为:{}", shoppingCartDTO);
shoppingCartService.addShoppingCart(shoppingCartDTO);
return Result.success();
}
}
【ShoppingCartService】
java
public interface ShoppingCartService {
//添加购物车
void addShoppingCart(ShoppingCartDTO shoppingCartDTO);
}
【ShoppingCartServiceImpl】
java
@Service
@Slf4j
public class ShoppingCartServiceImpl implements ShoppingCartService {
@Autowired
private ShoppingCartMapper shoppingCartMapper;
@Autowired
private DishMapper dishMapper;
@Autowired
private SetmealMapper setmealMapper;
@Override
public void addShoppingCart(ShoppingCartDTO shoppingCartDTO) {
// 判断购物车是否已有当前商品
ShoppingCart shoppingCart = new ShoppingCart();
BeanUtils.copyProperties(shoppingCartDTO, shoppingCart);
// 从 ThreadLocal 获取 userid
Long userId = BaseContext.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);
shoppingCartMapper.updateNumberById(cart);
} else { // 不存在则插入数据
Long dishId = shoppingCartDTO.getDishId();
if(dishId != null) {
// 添加菜品
Dish dish = dishMapper.getById(dishId);
shoppingCart.setName(dish.getName());
shoppingCart.setImage(dish.getImage());
shoppingCart.setAmount(dish.getPrice());
} else {
// 添加套餐
Long setmealId = shoppingCartDTO.getSetmealId();
Setmeal setmeal = setmealMapper.getById(setmealId);
shoppingCart.setName(setmeal.getName());
shoppingCart.setImage(setmeal.getImage());
shoppingCart.setAmount(setmeal.getPrice());
}
shoppingCart.setNumber(1);
shoppingCart.setCreateTime(LocalDateTime.now());
shoppingCartMapper.insert(shoppingCart);
}
}
}
【ShoppingCartMapper】
java
@Mapper
public interface ShoppingCartMapper {
@Insert("insert into shopping_cart(name,user_id,dish_id,setmeal_id,dish_flavor,number,amount,image,create_time)" +
"value (#{name},#{userId},#{dishId},#{setmealId},#{dishFlavor},#{number},#{amount},#{image},#{createTime})")
void insert(ShoppingCart shoppingCart);
List<ShoppingCart> list(ShoppingCart shoppingCart) ;
// 根据id修改商品数量
@Update("update shopping_cart set number = #{number} where id = #{id}")
void updateNumberById(ShoppingCart shoppingCart);
}
【ShoppingCartMapper.xml】
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.ShoppingCartMapper">
<select id="list" resultType="com.sky.entity.ShoppingCart">
select * from shopping_cart
<where>
<if test="userId != null">
and user_id = #{userId}
</if>
<if test="setmealId != null">
and setmeal_id = #{setmealId}
</if>
<if test="dishId != null">
and dish_id = #{dishId}
</if>
<if test="dishFlavor != null">
and dish_flavor = #{dishFlavor}
</if>
</where>
</select>
</mapper>
实现查看购物车功能:
【user.ShoppingCartController】
java
@ApiOperation("查看购物车")
@GetMapping("/list")
public Result<List<ShoppingCart>> list(){
List<ShoppingCart> list = shoppingCartService.showShoppingCart();
return Result.success(list);
}
【ShoppingCartService】
java
List<ShoppingCart> showShoppingCart();
【ShoppingCartServiceImpl】
java
@Override
public List<ShoppingCart> showShoppingCart() {
Long userId = BaseContext.getCurrentId();
ShoppingCart shoppingCart = ShoppingCart.builder()
.userId(userId)
.build();
List<ShoppingCart> list = shoppingCartMapper.list(shoppingCart);//只需要传userid即可
return list;
}
实现清空购物车功能:
【user.ShoppingCartController】
java
@ApiOperation("清空购物车")
@DeleteMapping("/clean")
public Result clean(){
shoppingCartService.clean();
return Result.success();
}
【ShoppingCartService】
java
//清空购物车
void clean();
【ShoppingCartServiceImpl】
java
//清空购物车
public void clean() {
Long userId = BaseContext.getCurrentId();
shoppingCartMapper.deleteByUserId(userId);
}
【ShoppingCartMapper】
java
@Delete("delete from shopping_cart where user_id=#{userId}")
void deleteByUserId(Long userId);
七、用户下单
7.1导入地址簿代码

接口设计:
- 新增地址
- 查询当前登录用户的所有地址信息
- 查询默认地址
- 根据
id修改地址 - 根据
id删除地址 - 根据
id查询地址 - 设置默认地址

将资料\day08\地址簿模块功能代码导入即可。
7.2用户下单
7.2.1需求设计


7.2.2代码开发
【OrderController】
java
@RestController("userOrderController")
@RequestMapping("/user/order")
@Api(tags="用户端订单相关接口")
@Slf4j
public class OrderController {
@Autowired
private OrderService orderService;
@PostMapping("/submit")
@ApiOperation("用户下单")
public Result<OrderSubmitVO> submit(@RequestBody OrdersSubmitDTO orderSubmitDTO){
log.info("用户下单,参数为:{}",orderSubmitDTO);
OrderSubmitVO orderSubmitVO = orderService.submitOrder(orderSubmitDTO);
return Result.success(orderSubmitVO);
}
}
【OrderService】
java
public interface OrderService {
// 用户下单
OrderSubmitVO submitOrder(OrdersSubmitDTO orderSubmitDTO);
}
【OrderServiceImpl】
java
@Service
public class OrderServiceImpl implements OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private OrderDetailMapper orderDetailMapper;
@Autowired
private AddressBookMapper addressBookMapper;
@Autowired
private ShoppingCartMapper shoppingCartMapper;
public OrderSubmitVO submitOrder(OrdersSubmitDTO orderSubmitDTO){
AddressBook addressBook = addressBookMapper.getById(orderSubmitDTO.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.ADDRESS_BOOK_IS_NULL);
}
// 向订单表插入数据
Orders orders = new Orders();
BeanUtils.copyProperties(orderSubmitDTO,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);
List<OrderDetail> orderDetailList = new ArrayList<>();
// 向订单明细表插入n条数据
for(ShoppingCart cart: shoppingCartList){
OrderDetail orderDetail = new OrderDetail();//订单明细
BeanUtils.copyProperties(cart,orderDetail);
orderDetail.setOrderId(orders.getId());//设置当前订单明细关联的订单id
orderDetailList.add(orderDetail);
}
orderDetailMapper.insertBatch(orderDetailList);
// 清空当前用户的购物车数据
shoppingCartMapper.deleteByUserId(userId);
// 封装VO返回结果
OrderSubmitVO orderSubmitVO = OrderSubmitVO.builder()
.id(orders.getId())
.orderTime(orders.getOrderTime())
.orderAmount(orders.getAmount())
.build();
return orderSubmitVO;
}
}
【OrderMapper】
java
@Mapper
public interface OrderMapper {
void insert(Orders orders);
}
【OrderMapper.xml】
xml
<?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.OrderMapper">
<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,consignee,
estimated_delivery_time,delivery_status,pack_amount,tableware_number,
tableware_status)
values
(#{number},#{status},#{userId},#{addressBookId},#{orderTime}
,#{checkoutTime},#{payMethod},#{payStatus},#{amount},#{remark}
,#{phone},#{address},#{consignee},#{estimatedDeliveryTime}
,#{deliveryStatus},#{packAmount},#{tablewareNumber},#{tablewareStatus})
</insert>
</mapper>
【OrderDetailMapper】
java
@Mapper
public interface OrderMapper {
void insert(Orders orders);
}
【OrderDetailMapper.xml】
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.OrderDetailMapper">
<insert id="insertBatch">
insert into order_detail(name,image,order_id,dish_id,setmeal_id,
dish_flavor,number,amount)
values
<foreach collection="orderDetailList" item="od" separator=",">
(#{od.name},#{od.image},#{od.orderId},#{od.dishId},
#{od.setmealId},#{od.dishFlavor},#{od.number},#{od.amount})
</foreach>
</insert>
</mapper>
7.3订单支付
7.3.1需求分析


JSAPI下单是微信支付提供的一种支付场景,专门用于公众号和小程序。工作流程:
- 用户下单 :用户在微信小程序中选择商品并提交订单,小程序端会把用户的
openid和订单信息一并传给后端。 - 商户后端生成订单 :后端生成订单记录,并创建唯一的商户订单号
out_trade_no。订单信息(金额、描述、用户openid等)准备好后,调用微信支付的JSAPI下单接口。0 - 调用微信支付统一下单接口 :请求地址
https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi,请求参数包括:mchid:商户号out_trade_no:订单号appid:应用的idnotify_url:回调地址amount:总金额payer.openid:当前付款用户的openid。
微信支付平台验证请求后,返回一个prepay_id(预交易支付标识)。
- 后端返回支付参数 :商户后端拿到
prepay_id后,需要再生成一组签名参数,如timeStamp、nonceStr、package等,返回给小程序端。 - 小程序端调起支付 :用户确认支付后,小程序端调用
wx.requestPayment,把后端返回的参数传入。微信客户端弹出支付页面,用户确认支付。 - 用户完成支付:用户输入支付密码并完成支付,此时微信支付会异步通知商户系统,告知支付结果。
- 商户后端处理支付结果:后端接收到微信的支付结果通知后,需要验签确认消息真实性,验签通过后,修改订单状态(如:已支付)。
为保证调用过程中的数据安全,需申请以下文件:
- 微信支付平台证书 :微信支付平台颁发的证书(包含微信支付的公钥),
.pem格式。商户系统收到微信的回调通知/返回结果时,报文会带有签名,商户需要用微信支付平台证书中的公钥去验证签名。 - 商户私钥文件 :商户在申请
API v3密钥和证书时,自行生成的一对密钥,.pem格式。商户请求微信支付接口时,需要用私钥对请求进行签名(放在HTTP Authorization头)。微信支付后台会用商户上传到平台的公钥来验证签名。
7.3.2代码开发
导入资料\day08\微信支付功能代码:

其中,OrderMapper.xml中的update应替换为:
xml
<update id="update" parameterType="Orders">
update orders
<set>
<if test="number != null"> number=#{number}, </if>
<if test="status != null"> status=#{status}, </if>
<if test="addressBookId != null"> address_book_id=#{addressBookId}, </if>
<if test="orderTime != null"> order_time=#{orderTime},</if>
<if test="checkoutTime != null"> checkout_time=#{checkoutTime}, </if>
<if test="payMethod != null"> pay_method=#{payMethod}, </if>
<if test="payStatus != null"> pay_status=#{payStatus}, </if>
<if test="amount != null"> amount=#{amount}, </if>
<if test="remark != null"> remark=#{remark}, </if>
<if test="phone != null"> phone=#{phone}, </if>
<if test="address != null"> address=#{address}, </if>
<if test="userName != null"> user_name=#{userName}, </if>
<if test="consignee != null"> consignee=#{consignee} ,</if>
<if test="cancelReason != null"> cancel_reason=#{cancelReason}, </if>
<if test="rejectionReason != null"> rejection_reason=#{rejectionReason}, </if>
<if test="cancelTime != null"> cancel_time=#{cancelTime}, </if>
<if test="estimatedDeliveryTime != null"> estimated_delivery_time=#{estimatedDeliveryTime}, </if>
<if test="deliveryStatus != null"> delivery_status=#{deliveryStatus}, </if>
<if test="deliveryTime != null"> delivery_Time=#{deliveryTime}, </if>
<if test="packAmount != null"> pack_amount=#{packAmount},</if>
<if test="tablewareNumber != null"> tableware_number=#{tablewareNumber}, </if>
<if test="tablewareStatus != null"> tableware_status=#{tablewareStatus}, </if>
</set>
where id=#{id}
</update>
并在UserMapper中写入:
java
@Select("select * from user where id=#{id}")
User getById(Long userId);
【application.yml】
yml
sky:
wechat:
appid: ${sky.wechat.appid} # 微信小程序的 AppID(小程序端需要的)
secret: ${sky.wechat.secret} # 小程序的 AppSecret(用于调用微信小程序 API,比如获取openid)
mchid: ${sky.wechat.mchid} # 微信支付商户号(MchID)
mchSerialNo: ${sky.wechat.mchSerialNo} # 商户证书的序列号(注意你这里写成了 mchid,应该是证书的 serial_no)
privateKeyFilePath: ${sky.wechat.privateKeyFilePath} # 商户私钥文件路径 apiclient_key.pem
apiV3Key: ${sky.wechat.apiV3Key} # 商户 APIv3 密钥(用于解密微信返回的数据)
weChatPayCertFilePath: ${sky.wechat.weChatPayCertFilePath} # 微信支付平台证书文件路径 wechatpay_cert.pem
notifyUrl: ${sky.wechat.notifyUrl} # 支付成功的异步通知地址(微信回调商户)
refundNotifyUrl: ${sky.wechat.refundNotifyUrl} # 退款成功的异步通知地址
【application-dev.yml】
yml
sky:
wechat:
appid: wx3910b10fd7db38d1
secret: f274884c2a56466016ebbcf009404e63
mchid: 1561414331
mchSerialNo: 4B3B3DC35414AD50B1B755BAF8DE9CC7CF407606
privateKeyFilePath: C:\software\apiclient_key.pem
apiV3Key: CZBK51236435wxpay435434323FFDuv3
weChatPayCertFilePat: C:\software\wechatpay_166D96F876F45C7D07CE98952A96EC980368ACFC.pem
notifyUrl: https://9ea0754.r19.cpolar.top/notify/paySuccess
refundNotifyUrl: https://9ea0754.r19.cpolar.top/notify/refundSuccess
此外,还需导入部分代码,见CSDN。
八、SpringTask
8.1基本介绍
Spring Task提供定义调度定时任务调度的方法,核心注解:
@EnableScheduling:开启Spring的定时任务功能。@Scheduled:标记在方法上,定义定时任务的执行策略。
其中,@Scheduled提供了几种常见执行策略:
fixedRate:按照固定速率执行任务,时间间隔从任务开始时间计算。即使任务还没执行完,也会等到间隔到了再次触发(可能会并发执行)。fixedDelay:按照固定延迟执行任务,时间间隔是从任务结束时间开始计算的。适合需要任务串行、不并发的场景。initialDelay:应用启动后,延迟一段时间再开始执行。cron:使用Cron表达式定义复杂的定时规则,可在网站生成。- 格式:秒 分 时 日 月 星期 [年]
- 示例:
0 0 12 * * ?→ 每天中午12点执行、0/5 * * * * ?→ 每5秒执行一次、0 0/10 * * * ?→ 每10分钟执行一次
注意,Spring Task默认是单线程调度,所有任务共用一个线程池,若某个任务执行时间过长,会影响其他任务。此时可自定义线程池:
java
@Configuration
@EnableScheduling
public class TaskConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5);
scheduler.setThreadNamePrefix("my-task-");
scheduler.initialize();
taskRegistrar.setTaskScheduler(scheduler);
}
}
使用步骤:
【1.开启定时任务功能】
java
@SpringBootApplication
@EnableScheduling // 开启定时任务
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
【2.定义定时任务】
在某个@Component或@Service方法上添加@Scheduled注解即可。
java
@Component
public class MyTask {
// 每隔 5 秒执行一次
@Scheduled(fixedRate = 5000)
public void task1() {
System.out.println("固定间隔任务执行: " + LocalDateTime.now());
}
// 上一次任务结束后 3 秒再执行
@Scheduled(fixedDelay = 3000)
public void task2() {
System.out.println("延迟执行任务: " + LocalDateTime.now());
}
// 初始延迟 2 秒,然后每隔 10 秒执行一次
@Scheduled(initialDelay = 2000, fixedRate = 10000)
public void task3() {
System.out.println("延迟启动定时任务: " + LocalDateTime.now());
}
// 使用 Cron 表达式(每天凌晨 2 点执行)
@Scheduled(cron = "0 0 2 * * ?")
public void task4() {
System.out.println("Cron 定时任务: " + LocalDateTime.now());
}
}
8.2订单状态定时处理
8.2.1需求分析

上述两种清空需通过定时任务来修改订单状态,具体逻辑:
- 1.通过定时任务每分钟检查一次是否存在支付超时订单(下单超
15分钟仍未支付则判定为支付超时订单),存在则修改订单状态为已取消。 - 2.每天凌晨
1点检查一次是否存在派送超时的订单,如果存在则修改订单状态为已完成。
8.2.2代码开发
【task.OrderTask】
java
@Component
@Slf4j
public class OrderTask {
@Autowired
private OrderMapper orderMapper;
// 处理超时订单
@Scheduled(cron="0 * * * * ? ") //
public void processTimeoutOrder(){
log.info("定时处理超时订单:{}", LocalD
LocalDateTime time = LocalDate
// 查找数据库中状态为待支付且下单时间早于当前时间 -15
// select * from orders status
List<Orders> ordersList = orde
if(ordersList != null && order
for(Orders orders : orders
// 取消订单并写入原因、时间
orders.setStatus(Order
orders.setCancelReason
orders.setCancelTime(L
orderMapper.update(ord
}
}
}
// 处理派送超时的订单
@Scheduled(cron="0 0 1 * * ?") //
public void processDeliveryOrder()
log.info("定时处理处于派送中的订单:{}", Lo
LocalDateTime time = LocalDate
// 查找派送超过 60min 的订单
List<Orders> ordersList = orde
if(ordersList != null && order
for(Orders orders : orders
// 更新状态为已完成
orders.setStatus(Order
orderMapper.update(ord
}
}
}
}
【mapper.OrderMapper】
java
@Select("select * from orders where status=#{status} and order_time < #{orderTime}")
List<Orders> getByStatusAndOrderTimeLT(Integer status, LocalDateTime orderTime);
九、WebSocket
9.1基本介绍
HTTP协议是半双工通信,即客户端请求,服务端响应,服务端不能主动推送。而WebSocket可以在客户端与服务端之间建立一个长连接,允许服务端主动推送消息给客户端,也允许客户端实时发送消息给服务端。服务端可通过注解来定义WebSocket端点(类似于控制器)。常用的注解有:
@ServerEndpoint:定义WebSocket服务端地址。@OnOpen:客户端建立连接时调用。@OnMessage:服务端接收消息时调用。@OnClose:连接关闭时调用。@OnError:发生错误时调用。
使用方式:
【1.引入依赖】
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
【2.创建配置类】
java
@Component
@ServerEndpoint("/ws/chat") // WebSocket的访问路径
public class ChatWebSocket {
// 建立连接
@OnOpen
public void onOpen(Session session) {
System.out.println("连接建立成功: " + session.getId());
}
// 接收消息
@OnMessage
public void onMessage(String message, Session session) throws IOException {
System.out.println("收到消息: " + message);
// 可以回一条消息给客户端
session.getBasicRemote().sendText("服务端已收到: " + message);
}
// 关闭连接
@OnClose
public void onClose(Session session) {
System.out.println("连接关闭: " + session.getId());
}
// 出现异常
@OnError
public void onError(Session session, Throwable error) {
System.out.println("发生错误: " + error.getMessage());
}
}
【3.前端调用】
js
const socket = new WebSocket("ws://localhost:8080/ws/chat");
// 连接成功
socket.onopen = () => {
console.log("WebSocket 连接成功");
socket.send("你好,服务端!");
};
// 接收消息
socket.onmessage = (event) => {
console.log("收到服务端消息:", event.data);
};
// 关闭连接
socket.onclose = () => {
console.log("WebSocket 连接关闭");
};
【4.工作流程】
- 1.客户端执行
new WebSocket("ws://localhost:8080/ws/chat");建立连接 → 触发@OnOpen,控制台输出:
bash
连接建立成功: 0 (例如,sessionId=0)
- 2.客户端执行
socket.send("你好,服务端!");发送消息 → 触发@OnMessage,控制台输出:
bash
收到消息: 你好,服务端!
同时,服务端会向客户端发送:
bash
服务端已收到: 你好,服务端!
- 3.服务端执行
console.log("收到服务端消息:", event.data);监听,收到消息后 → 浏览器输出:
bash
收到服务端消息: 服务端已收到: 你好,服务端!
- 客户端关闭连接 → 触发
@OnClose,控制台输出:
bash
连接关闭: 0
- 发生异常 → 触发
@OnError
9.2来单提醒
9.2.1需求分析
用户下单且支付成功后,需第一时间通知外卖商家,通知形式包括语音播报、弹出提示框。代码设计:
- 通过
WebSocket实现管理端页面和服务端保持长连接状态。 - 当客户支付后,调用
WebSocket相关API实现服务端向客户端推送消息。 - 客户端浏览器解析服务端推送的消息,判断是来单提醒还是客户催单,进行相应的消息提示和语音播报。
- 约定服务端发送给客户端浏览器的数据格式为
JSON,字段包括:type:消息类型,1为来单提醒,2为客户催单。orderId:订单Id。content:消息内容。
9.2.2代码开发

将以上代码导入。
【OrderServiceImpl】
java
@Autowired
private WebSocketServer webSocketServer;
/**
* 订单支付
*
* @param ordersPaymentDTO
* @return
*/
public OrderPaymentVO payment(OrdersPaymentDTO ordersPaymentDTO) throws Exception {
// 当前登录用户id
Long userId = BaseContext.getCurrentId();
User user = userMapper.getById(userId);
//调用微信支付接口,生成预支付交易单
JSONObject jsonObject = weChatPayUtil.pay(
ordersPaymentDTO.getOrderNumber(), //商户订单号
new BigDecimal(0.01), //支付金额,单位 元
"苍穹外卖订单", //商品描述
user.getOpenid() //微信用户的openid
);
if (jsonObject.getString("code") != null && jsonObject.getString("code").equals("ORDERPAID")) {
throw new OrderBusinessException("该订单已支付");
}
OrderPaymentVO vo = jsonObject.toJavaObject(OrderPaymentVO.class);
vo.setPackageStr(jsonObject.getString("package"));
//通过websocket向客户端浏览器推送消息 type orderId content
Map map = new HashMap();
map.put("type", 1);
map.put("orderId", this.orders.getId());
map.put("content", "订单号:" + this.orders.getNumber());
String json = JSON.toJSONString(map);
webSocketServer.sendToAllClient(json);
return vo;
}
9.3用户催单
9.3.1需求分析
用户在小程序中点击催单按钮后,需要第一时间通知外卖商家。通知的形式包括语音波高、弹出提示框,条件包括待接单状态+用户已付款。代码设计:
- 通过
WebSocket实现管理端页面和服务端保持长连接状态。 - 当用户点击催单按钮后,调用
WebSocket实现服务端向客户端推送消息。 - 客户端浏览器解析服务端推送的消息,判断是来单提醒还是客户催单,进行相应的消息提示和语音播报。
- 约定服务端发送给客户端浏览器的数据格式为
JSON,字段包括:type:消息类型,1为来单提醒,2为客户催单。orderld:为订单id。content:为消息内容。
请求路径设为GET /user/order/reminder/{id},id为订单id。
9.3.2代码开发
【user.OrderController】
java
@GetMapping("/reminder/{id}")
@ApiOperation("客户催单")
public Result reminder(@PathVariable("id") Long id){
orderService.reminder(id);
return Result.success();
}
【OrderService】
java
// 客户催单
void reminder(Long id);
【OrderServiceImpl】
java
// 客户催单
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());
webSocketServer.sendToAllClient(JSON.toJSONString(map));
}
十、数据统计
10.1营业额统计
10.1.1需求分析

ECharts是一款基于JavaScript的数据可视化图标,提供直观、生动、可交互、可个性化定制的数据可视化图表。业务规则:
- 营业额指订单状态为已完成的订单金额合计。
- 基于可视化报表的折线图展示营业额数据,
x轴为日期,y轴为营业额,可根据时间选择区间来展示每天的营业数据。

10.1.2代码开发
【admin.ReportController】
java
@RestController
@RequestMapping("/admin/report")
@Api(tags="数据统计相关接口")
@Slf4j
public class ReportController {
@Autowired
private ReportService reportService;
//营业额统计
@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);
return Result.success(reportService.getTurnoverStatistics(begin, end));
}
}
【ReportService】
java
public interface ReportService {
// 获取指定时间区间内的营业额数据
TurnoverReportVO getTurnoverStatistics(LocalDate begin, LocalDate end);
}
【ReportServiceImpl】
java
@Service
@Slf4j
public class ReportServiceImpl implements ReportService {
@Autowired
private OrderMapper orderMapper;
//统计指定时间区间内的营业额数据
public TurnoverReportVO getTurnoverStatistics(LocalDate begin,LocalDate end){
//当前集合用于存放从begin到end范围内的每天的日期
List<LocalDate> dateList = new ArrayList<>();
dateList.add(begin);
while(!begin.equals(end)) {
//日期计算,计算指定日期的后一天对应的日期
begin = begin.plusDays(1);
dateList.add(begin);
}
//存放每天的营业额
List<Double> turnoverList = new ArrayList<>();
for(LocalDate date : dateList){
//查询date日期对应的营业额数据,营业额是指:状态为"已完成"的订单金额合计。
//LocalDate只有年月日
LocalDateTime beginTime = LocalDateTime.of(date, LocalTime.MIN); //LocalTime.MIN相当于获得0点0分
LocalDateTime endTime = LocalDateTime.of(date,LocalTime.MAX);//无限接近于下一个日期的0点0分0秒
//select sum(amount) from orders where order_time > ? and order_time < ? and status = 5
//status==5代表订单已完成
Map map = new HashMap();
map.put("begin",beginTime);
map.put("end",endTime);
map.put("status", Orders.COMPLETED);
Double turnover = orderMapper.sumByMap(map); //算出当天的营业额
//考虑当天营业额为0的情况,会返回空
turnover = turnover == null ? 0.0:turnover;
turnoverList.add(turnover);
}
//封装返回结果
return TurnoverReportVO
.builder()
.dateList(StringUtils.join(dateList,","))
.turnoverList(StringUtils.join(turnoverList,","))
.build();
}
}
【OrderMapper】
java
// 根据动态条件统计营业额数据
Double sumByMap(Map map);
【OrderMapper.xml】
xml
<select id="sumByMap" resultType="java.lang.Double">
select sum(amount) from orders
<where>
<if test="begin != null">and order_time > #{begin}</if>
<if test="end != null">and order_Time < #{end}</if>
<if test="status != null"> and status = #{status} </if>
</where>
</select>
10.2用户统计
10.2.1需求分析

根据时间选择区间,展示每天用户总量和新增用户数。
10.2.2代码开发
【admin.ReportController】
java
// 用户统计
@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);
return Result.success(reportService.getUserStatistics(begin,end));
}
【ReportService】
java
// 统计指定时间区间内的营业额数据
UserReportVO getUserStatistics(LocalDate begin, LocalDate end);
【ReportServiceImpl】
java
//统计指定时间区间内的用户数据
public UserReportVO getUserStatistics(LocalDate begin,LocalDate end){
//存放从begin到end之间的每天对应的日期
List<LocalDate> dateList = new ArrayList<>();
dateList.add(begin);
while(!begin.equals(end)){
begin = begin.plusDays(1);
dateList.add(begin);
}
//存放每天的新增用户数量 select count(id) from user where create_time < ? and create_time> ?
List<Integer> newUserList = new ArrayList<>();
//存放每天的总用户数量 select count(id) from user where create_time < ?
List<Integer> totalUserList = new ArrayList<>();
for(LocalDate date : dateList){
LocalDateTime beginTime = LocalDateTime.of(date, LocalTime.MIN);
LocalDateTime endTime = LocalDateTime.of(date, LocalTime.MAX);
Map map = new HashMap();
map.put("end",endTime);//只加一个end(1个参数)自动匹配统计总计的SQL语句
//总用户数量
Integer totalUser = userMapper.countByMap(map);
map.put("begin",beginTime);//再加一个参数匹配统计每日新增的SQL语句
//新增用户数量
Integer newUser = userMapper.countByMap(map);
totalUserList.add(totalUser);
newUserList.add(newUser);
}
return UserReportVO
.builder()
.dateList(StringUtils.join(dateList,","))
.totalUserList(StringUtils.join(totalUserList,","))
.newUserList(StringUtils.join(newUserList,","))
.build();
}
【UserMapper.java】
java
// 根据动态条件统计用户数量
Integer countByMap(Map map);
【UserMapper.xml】
xml
<select id="countByMap" resultType="java.lang.Integer">
select count(id) from user
<where>
<if test="begin != null">
and create_time > #{begin}
</if>
<if test="end != null">
and create_time < #{end}
</if>
</where>
</select>
10.3订单统计
10.3.1需求分析

业务规则:
- 1.有效订单指状态为已完成的订单。
- 2.
x轴为日期,y轴为订单数量 - 3.在时间选择区间内,展示每天的订单总数和有效订单数。
- 4.展示区间内有效订单数、总订单数、订单完成率,订单完成率=有效订单数/总订单数x
100%。
10.3.2代码开发
【admin.ReportController】
java
//订单统计
@GetMapping("/ordersStatistics")
@ApiOperation("订单统计")
public Result<OrderReportVO> ordersStatistics(
@DateTimeFormat(pattern="yyyy-MM-dd") LocalDate begin,
@DateTimeFormat(pattern="yyyy-MM-dd") LocalDate end){
log.info("订单数据统计:{},{}",begin,end);
return Result.success(reportService.getOrderStatistics(begin,end));
}
【ReportService】
java
//统计指定时间区间内的订单数据
OrderReportVO getOrderStatistics(LocalDate begin, LocalDate end);
【ReportServiceImpl】
java
//统计指定时间区间内的订单数据
public OrderReportVO getOrderStatistics(LocalDate begin, LocalDate end) {
List<LocalDate> dateList = new ArrayList<>();
dateList.add(begin);
while(!begin.equals(end)){
begin = begin.plusDays(1);
dateList.add(begin);
}
//存放每天的订单总数
List<Integer> orderCountList = new ArrayList<>();
//存放每天的有效订单数
List<Integer> validOrderCountList = new ArrayList<>();
//便利dateList集合,查询每天的有效订单数和订单总数
for(LocalDate date : dateList){
//查询每天的订单总数 select count(id) from orders where order_time > ? and order_time < ?
LocalDateTime beginTime = LocalDateTime.of(date, LocalTime.MIN);
LocalDateTime endTime = LocalDateTime.of(date, LocalTime.MAX);
Integer orderCount = getOrderCount(beginTime, endTime, null);
//查询每天的有效订单数select count(id) from orders where order_time > ? and order_time < ? and status = 5
Integer validOrderCount = getOrderCount(beginTime, endTime, Orders.COMPLETED);
orderCountList.add(orderCount);
validOrderCountList.add(validOrderCount);
}
//计算时间区间内的订单总数量
Integer totalOrderCount = orderCountList.stream().reduce(Integer::sum).get();
//计算时间区间内的有效订单数量
Integer validOrderCount = validOrderCountList.stream().reduce(Integer::sum).get();
//计算订单完成率
Double orderCompletionRate = 0.0;
if(totalOrderCount != 0){
//计算订单完成率
orderCompletionRate = validOrderCount.doubleValue() / totalOrderCount;
}
return OrderReportVO.builder()
.dateList(StringUtils.join(dateList,","))
.orderCountList(StringUtils.join(orderCountList,","))
.validOrderCountList(StringUtils.join(validOrderCountList,","))
.totalOrderCount(totalOrderCount)
.validOrderCount(validOrderCount)
.orderCompletionRate(orderCompletionRate)
.build();
}
//根据条件统计订单数量
private Integer getOrderCount(LocalDateTime begin,LocalDateTime end,Integer status){
Map map = new HashMap();
map.put("begin",begin);
map.put("end",end);
map.put("status",status);
return orderMapper.countByMap(map);
}
【OrderMapper】
java
//根据动态条件统计订单数量
Integer countByMap(Map map);
【UserMapper.xml】
xml
<select id="countByMap" resultType="java.lang.Integer">
select count(id) from orders
<where>
<if test="begin != null">and order_time > #{begin}</if>
<if test="end != null">and order_time < #{end}</if>
<if test="status != null">and status=#{status}</if>
</where>
</select>
10.3销售排名统计
10.3.1需求分析

业务规则:
- 1.根据时间选择区间,展示销量前
10的商品(包括菜品和套餐)。 - 2.基于柱状图展示商品销量。
10.3.2代码开发
【admin.ReportController】
java
//销量排名top10
@GetMapping("/top10")
@ApiOperation("销量排名top10")
public Result<SalesTop10ReportVO> top10(
@DateTimeFormat(pattern="yyyy-MM-dd") LocalDate begin,
@DateTimeFormat(pattern="yyyy-MM-dd") LocalDate end){
log.info("销量排名top10:{},{}",begin,end);
return Result.success(reportService.getSalesTop10(begin,end));
}
【ReportService】
java
//统计指定时间区间内的销量排名前10
SalesTop10ReportVO getSalesTop10(LocalDate begin, LocalDate end);
【ReportServiceImpl】
java
//统计指定时间区间内的销量排名前10
public SalesTop10ReportVO getSalesTop10(LocalDate begin, LocalDate end) {
LocalDateTime beginTime = LocalDateTime.of(begin, LocalTime.MIN);
LocalDateTime endTime = LocalDateTime.of(end,LocalTime.MAX);
List<GoodsSalesDTO> salesTop10 = orderMapper.getSalesTop10(beginTime, endTime);
List<String> names = salesTop10.stream().map(GoodsSalesDTO::getName).collect(Collectors.toList());
String nameList = StringUtils.join(names, ",");
List<Integer> numbers = salesTop10.stream().map(GoodsSalesDTO::getNumber).collect(Collectors.toList());
String numberList = StringUtils.join(numbers, ",");
return SalesTop10ReportVO.builder().nameList(nameList).numberList(numberList).build();
}
【OrderMapper】
java
//统计指定时间区间内的销量排名前10
List<GoodsSalesDTO> getSalesTop10(LocalDateTime begin,LocalDateTime end);
【OrderMapper.xml】
xml
<select id="getSalesTop10" resultType="com.sky.dto.GoodsSalesDTO">
select od.name,sum(od.number) number
from order_detail od,orders o
where od.order_id = o.id and o.status = 5
<if test="begin != null">
and o.order_time > #{begin}
</if>
<if test="end != null">
and o.order_time < #{end}
</if>
group by od.name
order by number desc
limit 0,10
</select>
十一、面试题
11.1Nginx反向代理与负载均衡策略
见上文。
- 1.反向代理 :统一对外暴露一个入口(
http://域名),根据URL路径把请求转发到不同服务。整个过程中,用户只能访问Nginx而不会直接暴露后端应用服务器,后端只需提供REST API并监听端口即可。 - 2.静态资源服务器 :可直接用
Nginx读取index.html、js、css等前端静态文件,减少后端服务器压力。 - 3.负载均衡:将大量的请求按照指定的方式均衡地分配给集群中的每台服务器,常见策略如轮询分发请求、给不同服务器设置权重等。
11.2完善登录功能
密码在数据库中明文存储,安全性太低,因此引入MD5加密策略:
- 密码用
MD5方式加密后存储,当前端输入的密码时,将其经MD5转换的结果与数据库中存储的密文对比即可。
11.3Swagger

11.4ThreadLocal
java
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();
}
}
若不将ThreadLocal定义为static,则必须先创建BaseContext对象才能调用。此时,此时每个BaseContext对象都有自己的ThreadLocal,拦截器里通过setCurrentId()存入地值在Controller/Service里无法获取,因为它们使用地不是同一个BaseContext对象。
11.5分类管理与公共字段填充

如上图所示,分类按照类型可分为菜品分类和套餐分类。对应数据库表:

公共字段如下:

若在插入或更新数据时都手动去赋值,就会存在大量冗余代码。事实上,create_time、create_user字段仅在执行插入语句时会被更新,而update_time、update_user字段在执行插入/更新语句时会被更新。因此考虑:
- 1.自定义注解
@AutoFill,用于标识需进行公共字段自动填充的方法。
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {
// 指明数据库操作类型: UPDATE INSERT
OperationType value();
}
- 2.自定义切面类
AutoFillAspect,统一拦截加入了@AutoFill的方法,并通过反射为公共字段赋值。
java
@Aspect
@Component
@Slf4j
public class AutoFillAspect {
// 切点要求: Mapper接口方法 + 被@AutoFill标注
@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
public void autoFillPointCut(){}
@Before("autoFillPointCut()")
public void autoFill(JoinPoint joinPoint){
log.info("开始进行公共字段自动填充...");
// 1.通过方法签名获取注解对象、操作类型
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class);
OperationType operationType = autoFill.value();
// 2.获取方法参数
Object[] args = joinPoint.getArgs();
if(args == null || args.length==0 ){
return;
}
// 3.获取方法参数中的实体参数(约定放在第一个位置)
Object entity = args[0];
LocalDateTime now = LocalDateTime.now();
Long currentId = BaseContext.getCurrentId();
if(operationType == OperationType.INSERT){
// 4.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){
e.printStackTrace();
}
} else if(operationType == OperationType.UPDATE){
// 5.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){
e.printStackTrace();
}
}
}
}
11.6阿里云OSS
每个菜品必须对应一张图片,而本地保存图片等静态资源存在以下问题:
- 1.存储有限:服务器磁盘空间有限,随着图片数量增加,可能会很快耗尽磁盘。
- 2.访问性能差 :图片访问需要经过应用服务器(
Tomcat),会占用带宽和性能,降低并发能力。 - 3.不利于负载均衡和扩展 :项目部署在多台服务器时,本地存储的文件不同步,可能出现
A服务器上传,B服务器访问不到的情况。
阿里云OSS(Object Storage Service,对象存储服务)能够存储任何类型的非结构化数据,并提供HTTP/HTTPS访问接口,用户只要有一个URL,就能直接访问存储的文件。进行配置:
yml
sky:
alioss:
endpoint: oss-cn-shenzhen.aliyuncs.com
accessKeyId: LTAI5tHzX4fMjySKcCoCvYci
accessKeySecret: vPKhVa4Kux8jUP6fU4614CQ3FW0wiC
bucketName: cangqiongwaimaipbj
endpoint:指明访问的OSS服务所在的地域名。accessKeyId:访问凭证ID,用于标识身份。accessKeySecret:账号密钥(密码)。bucketName:存储空间名称,用于隔离不同业务的数据,相当于大型文件夹。
java
@RestController
@RequestMapping("/admin/common")
@Api(tags = "通用接口")
@Slf4j
public class CommonController {
@Autowired
private AliOssUtil aliOssUtil;
/**
* @param file 文件对象(MultipartFile)
*/
@PostMapping("/upload")
@ApiOperation("文件上传")
public Result<String> upload(MultipartFile file) {
log.info("文件上传: {}", file.getOriginalFilename());
try {
String originalFilename = file.getOriginalFilename();//原始文件名
// 截取原始文件名的的后缀
String extention = originalFilename.substring(originalFilename.lastIndexOf("."));
// 构造新文件名称
String objectName = UUID.randomUUID().toString() + extention;
// 文件的请求路径
String filePath = aliOssUtil.upload(file.getBytes(), objectName);
return Result.success(filePath);
} catch (IOException e) {
log.error("文件上传失败:{}", e);
}
return null;
}
}
11.7删除菜品


1.可以一次删除一个菜品也可批量删除菜品。
2.起售中的菜品不能被删除。
3.被套餐关联的菜品不能删除。
4.删除菜品后,关联的口味数据也需被删除。
java
@Autowired
private SetmealDishMapper setmealDishMapper;
@Transactional
public void deleteBatch(List<Long> ids){
// 存在起售中的菜品不能删除
for (Long id : ids) {
Dish dish = dishMapper.getById(id);
// StatusConstant.ENABLE表示状态为起售中
if(dish.getStatus()== StatusConstant.ENABLE){ //状态为1起售中
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);
}
}
11.8Redis保存店铺营业状态

在后台管理端,可动态切换营业/打烊状态,底层通过修改数据库中的相关字段实现。事实上,营业状态数据有以下特点:
- 高频访问:每一个用户在进入点餐页面时,都需要先判断店铺是否营业。假设有成千上万的用户同时访问,如果每次都查
MySQL,数据库压力会非常大。 - 低频修改:营业状态一般一天只改几次(比如早上开门、晚上关门),修改频率非常低。
这种读多写少的场景非常适合使用Redis缓存存储。
java
@RestController("userShopController")
@RequestMapping("/user/shop")
@Api(tags="店铺相关接口")
@Slf4j
public class ShopController {
public static final String KEY="SHOP_STATUS";
@Autowired
private RedisTemplate redisTemplate;
@GetMapping("/status")
@ApiOperation("获取店铺的营业状态")
public Result<Integer> getStatus(){
Integer status = (Integer) redisTemplate.opsForValue().get(KEY);
log.info("获取到店铺的营业状态为:{}", status== 1 ? "营业中" : "打烊中");
return Result.success(status);
}
}
11.9HttpClient
小程序在苍穹外卖中作为用户端入口,直接在微信小程序里使用点餐、下单、支付、订单查询等功能,其通过HTTP接口调用SpringBoot后端服务。编写步骤:
- 1.创建
HttpClient对象。 - 2.创建
Http请求对象,如HttpGet。 - 3.调用
HttpClient.execute(...)发送请求。
例如:
java
@SpringBootTest
public class HttpClientTest {
@Test
public void testPOST() throws Exception{
// 创建 HttpClient
CloseableHttpClient httpClient = HttpClients.createDefault();
// 创建 POST 请求对象
HttpPost httpPost = new HttpPost("http://localhost:8080/admin/employee/login");
// 构造 JSON 请求体
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);
// 获取响应信息 ( JSON 格式)
int statusCode = response.getStatusLine().getStatusCode();
System.out.println("响应码为:"+statusCode);
HttpEntity entity1 = response.getEntity();
String body = EntityUtils.toString(entity1);
System.out.println("响应数据为:"+body);
// 关闭资源
response.close();
httpClient.close();
}
}
11.10JWT
见原文
11.11微信登录
见原文
11.12SpringCache
SpringCache屏蔽了不同缓存实现的差异,使得程序员只需要关心业务逻辑,而不用关心底层缓存实现。
@EnableCaching:用于启用缓存功能。@Cacheable:用于缓存读取。先查缓存,如果有就直接返回,不调用方法;如果没有,就调用方法并把结果放入缓存。@CachePut:用于缓存更新。每次调用方法都会执行,并更新缓存。@CacheEvict:用于缓存清除。
套餐缓存:
java
// user.SetmealController.list()
@Cacheable(cacheNames="setmealCache",key="#categoryId")
// admin.SetmealController.save()
@CacheEvict(cacheNames="setmealCache",key="#setmealDTO.categoryId")
// admin.SetmealController 的 update()、delete()、startOrStop()
@CacheEvict(cacheNames = "setmealCache",allEntries = true)