项目:苍穹外卖

文章目录


一、准备工作

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)
相关推荐
数字融合18 天前
透明化数字孪生:未来医疗的核心平台
大数据·人工智能·virtualenv
数字融合23 天前
透明化地铁线视频孪生综合监控项目技术
大数据·人工智能·virtualenv
数字融合1 个月前
透明化视频孪生大数据全域时空对齐应用技术
大数据·人工智能·virtualenv
言乐61 个月前
Python实现随机抽人回答问题
开发语言·python·django·virtualenv·pygame
开开心心就好1 个月前
视频播放器完美解码集成三款播放器切换使用
前端·人工智能·智能手机·电脑·音视频·virtualenv·pygame
数字融合1 个月前
黎阳之光一数字孪生多能源可视化系统管理技术
人工智能·自动化·virtualenv
数字融合1 个月前
透明化视场时空配准视频孪生多视域空间融合技术
人工智能·音视频·virtualenv
数字融合1 个月前
透明化时空联合无缝追踪,构筑无断点全域态势感知网络专项技术
网络·人工智能·virtualenv
龙腾AI白云2 个月前
世界模型被视为破局关键
人工智能·virtualenv·知识图谱·pygame
敬往事一杯酒哈2 个月前
Codesys入门:新建工程(一)
virtualenv·pygame