项目:苍穹外卖

文章目录


一、准备工作

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打开:

常见的对象职责说明:

  • EntityJavaBean实体,通常与数据库表相对应。
  • 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,那么其子类对象都可以传入,从而实现统一处理所有业务异常

同理,当密码错误、账号被锁定时会分别抛出异常PasswordErrorExceptionAccountLockedException

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;

}

@BuilderLombok提供的注解,用于在编译时为类自动生成建造者模式的代码,用于将对象构建步骤拆开,可通过更灵活的方式来创建对象。例如,有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.htmljscss等前端静态文件,减少后端服务器压力。

由上文配置文件内容可知,当客户端访问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%808010%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-servercom.sky.service.impl.EmployeeServiceImpllogin()

java 复制代码
// 将前端明文密码MD5加密后与数据库对比
password = DigestUtils.md5DigestAsHex(password.getBytes());

1.4导入接口文档

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

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

查看管理端接口文档:

1.5Swagger

Swagger是一个API文档生成和测试工具,主要用来自动生成后端接口文档,其提供一个可交互的在线API测试界面,让前端、测试、后端对接口对齐更方便。

1.5.1配置方式

Spring Boot中常用Springfox-Swagger2Knife4jSwagger增强版),本项目中使用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请求都会由一个独立的线程来处理,这一点可在controllerservice和拦截器代码中输出当前线程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修饰后,整个JVMthreadLocal就是唯一的实例,无论在拦截器、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
  • 请求参数
    • pageint类型,表示当前页码。
    • pageSizeint类型,表示每页显示条数。
    • nameString类型,表示员工姓名。

返回数据为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接收pagepageSizename参数,调用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可作为Resultdata属性直接返回。

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.方法一】

EmployeecreateTime字段上加上注解:

java 复制代码
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;

@JsonFormatJackson提供的注解,主要用于控制对象字段在JSON序列化与反序列化时的格式,通常用于格式化DateLocalDateTime类型的时间字段。常用注解参数:

参数 含义
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 MVCHttpMessageConverter列表添加或修改转换器,以实现请求参数和响应结果在Java对象与JSON间自动转换。

  • 创建JSON消息转换器MappingJackson2HttpMessageConverterSpring提供的Jackson JSON转换器,专门用来处理application/json类型的请求和响应。
  • 绑定自定义的ObjectMapperObjectMapper负责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);
    }
}

ObjectMapperJackson的核心类,SpringMVC默认使用它做JSONJava对象之间的转换。上述代码中定义了日期/时间类型的转换逻辑,对于普通字段,JacksonObjectMapper内部已经内置了大量序列化/反序列化器,直接走内置规则即可。

过程举例:

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},而请求参数idquery方法直接拼接到地址栏。

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 :通过@PathVariableURL路径获取。
  • 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_timecreate_user字段仅在执行INSERT语句时会被更新,而update_timeupdate_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服务器访问不到的情况。

阿里云OSSObject 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 RedisSpring提供的Redis操作框架,核心组件:

  • RedisTemplate<Object, Object>:核心操作类,使用前需指定序列化器,否则存入二进制字节数组。
  • StringRedisTemplateRedisTemplate的特殊实现,keyvalue都是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序列化,存储到Rediskey会是二进制字节数组。此处将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登录后返回给前端)
  • 接口访问权限控制(如OAuth2access_token

常见的生成方式:

  • JWT
  • UUID

5.3.1JWT

JWTJSON 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

UUIDUniversally Unique Identifier,全局唯一标识符),是一个128位的数字,通常表现为3216进制字符,分成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
    }
}

也可去掉-得到32Token

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及前文获取的AppIDAppSecret)换取openidunionidsession_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生成自己的tokenJWTUUID)返回给前端,并将其作为用户会话状态存入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):代表点餐小程序的普通顾客,只能下单、查订单等。

因此使用两套配置、两套拦截器,且在配置上:

  • secretKeyadminuser用不同的密钥,避免一处泄露全线失守。
  • TTLadmin token更短,user token稍长。
  • tokenNameadminuser的请求头不同,方便区分。

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至少会有10key,每个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);
}

因此在所有updatedelete方法调用完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.SetmealControllerupdate()、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:应用的id
    • notify_url:回调地址
    • amount:总金额
    • payer.openid:当前付款用户的openid

微信支付平台验证请求后,返回一个prepay_id(预交易支付标识)。

  • 后端返回支付参数 :商户后端拿到prepay_id后,需要再生成一组签名参数,如timeStampnonceStrpackage等,返回给小程序端。
  • 小程序端调起支付 :用户确认支付后,小程序端调用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 &gt; #{begin}</if>
            <if test="end != null">and order_Time &lt; #{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 &gt; #{begin}
        </if>
        <if test="end != null">
            and create_time &lt; #{end}
        </if>
    </where>
</select>

10.3订单统计

10.3.1需求分析

业务规则:

  • 1.有效订单指状态为已完成的订单。
  • 2.x轴为日期,y轴为订单数量
  • 3.在时间选择区间内,展示每天的订单总数和有效订单数。
  • 4.展示区间内有效订单数、总订单数、订单完成率,订单完成率=有效订单数/总订单数x100%

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 &gt; #{begin}</if>
        <if test="end != null">and order_time &lt; #{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 &gt; #{begin}
    </if>
    <if test="end != null">
        and o.order_time &lt; #{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_timecreate_user字段仅在执行插入语句时会被更新,而update_timeupdate_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服务器访问不到的情况。

阿里云OSSObject 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)
相关推荐
顾安r11 天前
11.29 脚本游戏 单页面格斗游戏模板
前端·javascript·css·游戏·virtualenv
wsj__WSJ11 天前
Python 项目管理工具 uv 详解
python·conda·virtualenv
Warren9819 天前
软件测试常见面试题
linux·python·django·flask·virtualenv·pygame·tornado
Learn-Share_HY22 天前
[Python]如何用uv套件建置python專案與虛擬環境?
python·ai·virtualenv·uv·server·mcp·cline
龙腾AI白云1 个月前
大模型-模型压缩:量化、剪枝、蒸馏、二值化 (4)
virtualenv·scikit-learn
伊玛目的门徒1 个月前
Jupyter Notebook 配置使用虚拟环境中(virtualenv) 内核
python·jupyter·virtualenv
百锦再2 个月前
低代码开发的约束性及ABP框架的实践解析
android·开发语言·python·低代码·django·virtualenv·rxjava
猫头虎2 个月前
如何解决 pip install -r requirements.txt extras 语法 ‘package[extra’ 缺少 ‘]’ 解析失败问题
开发语言·python·开源·beautifulsoup·virtualenv·pandas·pip
雨夜的星光2 个月前
Python环境管理工具全景对比:Virtualenv, Pipenv, Poetry 与 Conda
python·pycharm·conda·virtualenv