苍穹外卖笔记 @Trenchance 2025/07/15
note后端启动方法:启动sky-server的SkyApplication,访问苍穹外卖
swagger接口文档:苍穹外卖项目接口文档
每一章后面的括号代表这一章的知识点
day0 Spring补充 Bean Spring 的 Bean 就是被 Spring 容器管理的对象 。也就是说,普通的 Java 对象一旦被交给 Spring 管理,就成了 “Spring Bean”。
在传统开发中,你自己创建对象,比如:
1 UserService userService = new UserService ();
但在 Spring 中,对象由框架帮你创建、管理、销毁,比如:
1 2 @Autowired UserService userService;
这就是 IoC(控制反转) 的体现。
只要你在类上加了如下注解之一,它就会变成 Bean,交由 Spring 容器管理:
注解
说明
@Component
最基础的通用组件注解
@Service
业务逻辑层组件
@Repository
数据访问组件
@Controller
控制器
@RestController
@Controller + @ResponseBody
这些注解其实都隐含了 @Component 的功能。
举个例子,首先定义一个Bean:
1 2 3 4 5 6 @Service public class UserService { public void register () { System.out.println("注册用户" ); } }
然后在控制器中使用它:
1 2 3 4 5 6 7 8 9 10 11 12 @RestController public class UserController { @Autowired private UserService userService; @GetMapping("/register") public String register () { userService.register(); return "注册成功" ; } }
注意,什么时候需要写@Autowired:
当你希望 Spring 自动将某个 Bean 注入到当前类中使用 ,并且注入方式不是通过 @Bean 方法参数或构造器参数时,你需要写 @Autowired。
硬编码和软编码 硬编码(Hard Coding)是指在程序中直接将具体的值(如字符串、数字、配置项等)写死在代码中 ,而不是通过配置文件、参数、数据库等外部方式来获取这些值。
场景
错误做法(硬编码)
正确做法(软编码)
文件路径
"C:/myapp/logs/file.log"
使用配置文件定义
密码/密钥
"admin123"
使用环境变量或配置中心
页面跳转路径
"/home/index"
使用常量或路由映射
魔法数字
if (status == 3)
使用枚举或常量:if (status == Status.APPROVED)
反射 反射之中包含了一个「反」字,所以想要解释反射就必须先从「正」开始解释。
一般情况下,我们使用某个类时必定知道它是什么类,是用来做什么的。于是我们直接对这个类进行实例化,之后使用这个类对象进行操作。
1 2 Apple apple = new Apple (); apple.setPrice(4 );
上面这样子进行类对象的初始化,我们可以理解为「正」。
而反射则是一开始并不知道我要初始化的类对象是什么,自然也无法使用 new 关键字来创建对象了。
这时候,我们使用 JDK 提供的反射 API 进行反射调用:
1 2 3 4 5 Class clz = Class.forName("com.chenshuyi.reflect.Apple" );Method method = clz.getMethod("setPrice" , int .class);Constructor constructor = clz.getConstructor();Object object = constructor.newInstance();method.invoke(object, 4 );
上面两段代码的执行结果,其实是完全一样的。但是其思路完全不一样,第一段代码在未运行时就已经确定了要运行的类(Apple),而第二段代码则是在运行时通过字符串值才得知要运行的类(com.chenshuyi.reflect.Apple)。
所以说什么是反射?
反射就是在运行时才知道要操作的类是什么,并且可以在运行时获取类的完整构造,并调用对应的方法。
动态 SQL 你可以把 MyBatis 动态 SQL 理解为“在 XML 里写代码,让 SQL 语句按需自动拼接 ”
常规使用JDBC拼接sql:
1 2 3 4 String sql = "select * from t_user where 1=1"; if (name != null ) sql + = " and name like concat('%', ?, '%')"; if (age != null ) sql + = " and age = ?"; if (email!= null ) sql + = " and email like concat('%', ?, '%')";
使用动态sql:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <select id ="listUsers" resultType ="com.example.User" > SELECT * FROM t_user <where > <if test ="name != null and name != ''" > AND name LIKE CONCAT('%', #{name}, '%') </if > <if test ="age != null" > AND age = #{age} </if > <if test ="email != null and email != ''" > AND email LIKE CONCAT('%', #{email}, '%') </if > </where > </select >
这是另一个动态sql的例子:
1 2 3 4 5 6 7 <delete id ="deleteByIds" > DELETE FROM t_user WHERE id IN <foreach collection ="idList" item ="id" open ="(" separator ="," close =")" > #{id} </foreach > </delete >
他等效于这样的sql语句:
1 2 DELETE FROM t_userWHERE id IN (101 , 102 , 103 );
实际开发中,90% 的动态 SQL 都是 <if> + <where> + <foreach> 组合,掌握这三个就能解决大部分场景。
然后我们讲一下另一个使用动态SQL的场景,查询。有时候我们会有多种查询方法,通过名称,通过属性,通过ID等等,我们不能为每一个查询条件都写一个方法,而是选择写一个可重用的动态SQL,具体怎么实现呢?举个例子:
假如我们现在要查询菜品,方法一,根据每个条件写一个接口:
1 2 3 4 5 6 7 @Select("select * from dish where category_id = #{categoryId}") List<Dish> getByCategoryId (Long categoryId) ; @Select("select * from dish where name like concat('%',#{name},'%')") List<Dish> getByName (String name) ; List<Dish> getByCategoryIdAndName (Long categoryId, String name) ;
可以是可以,但是重用性不高。
方法二,使用一个统一接口:
1 List<Dish> list (Dish dish) ;
怎么实现统一接口呢?答案是构造一个带有查询条件的Entity对象,把该Entity对象传入接口 !
比较抽象,举个具体例子,假如我们要通过categoryId查询,那我就把构造一个Dish对象,这个对象categoryId字段是我要查询的条件,其他字段留空;如果我要查询categoryId且status是enable,那我就构造一个对象,这个对象categoryId是查询条件,且status是enable:
1 2 3 4 Setmeal setmeal = new Setmeal ();setmeal.setCategoryId(categoryId); setmeal.setStatus(StatusConstant.ENABLE); List<Setmeal> list = setmealService.list(setmeal);
然后调用mapper查询的时候,动态sql判断传进来的对象每个字段是否为空,如果不为空就代表这个是查询字段,以此为条件进行查询:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <select id ="list" resultType ="com.sky.entity.Dish" parameterType ="com.sky.entity.Dish" > select * from dish <where > <if test ="name != null" > and name like concat('%',#{name},'%') </if > <if test ="categoryId != null" > and category_id = #{categoryId} </if > <if test ="status != null" > and status = #{status} </if > </where > order by create_time desc </select >
再举一个完整的例子吧,假如查询当前登录用户的所有地址信息:
controller:
1 2 3 4 5 6 7 8 @GetMapping("/list") @ApiOperation("查询当前登录用户的所有地址信息") public Result<List<AddressBook>> list () { AddressBook addressBook = new AddressBook (); addressBook.setUserId(BaseContext.getCurrentId()); List<AddressBook> list = addressBookService.list(addressBook); return Result.success(list); }
service:
1 2 3 public List<AddressBook> list (AddressBook addressBook) { return addressBookMapper.list(addressBook); }
mapper.java:
1 List<AddressBook> list (AddressBook addressBook) ;
mapper.xml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <select id ="list" parameterType ="com.sky.entity.AddressBook" resultType ="com.sky.entity.AddressBook" > select * from address_book <where > <if test ="userId != null" > and user_id = #{userId} </if > <if test ="phone != null" > and phone = #{phone} </if > <if test ="isDefault != null" > and is_default = #{isDefault} </if > </where > </select >
这里就构造一个AddressBook对象,对象除了userId,其他字段都留空。把这个对象传给mapper。动态sql判断这个对象哪些字段不为空就以哪些字段为查询条件,这里userId不为空,那么where user_id = #{userId}。
day1-2 整体介绍
需求分析:需求规格说明书,产品原型(一个静态html页面展示)
day1-3 项目介绍 需求分析
产品原型
技术选型
day1-4 前端环境搭建 前端基于nginx服务器。
运行方式:双击nginx.exe,然后浏览器打开苍穹外卖 。
day1-5 后端环境搭建 (DTO, VO)
DTO(Data Transfer Object):前端传给后端的请求体封装为DTO
VO(Value Object):后端发回给前端的相应封装为VO
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 classDiagram class 前端 { + 发送请求(JSON body) + 接收响应(JSON) } class 后端 { + @PostMapping("/login") + login(employeeLoginDTO) } class DTO { <<数据传输对象>> + username + password __接收前端请求体__ } class VO { <<视图对象>> + id + userName + token __返回前端响应__ } class Entity { <<持久化实体>> + id + username + password + name } class 数据库 { + employee表 } 前端 --> 后端 : HTTP请求(包含JSON body) 后端 --> 前端 : HTTP响应(包含VO JSON) 后端 --> DTO : @RequestBody接收 后端 --> VO : 构造响应数据 DTO --> Entity : 转换(用于业务处理) Entity --> VO : 转换(构建响应) Entity --> 数据库 : ORM操作 数据库 --> Entity : 查询结果
day1-6 Git Git分为本地和远程仓库,先配置本地仓库。
打开浏览器访问官网:https://git-scm.com/下载Git并安装。
在IDEA配置Git路径:
IDEA顶部菜单栏,VCS(版本控制系统)。点进去创建Git仓库:
创建完成后左上角可以进行Git操作:
在Github创建仓库,并复制下面的地址:
IDEA中选择推送,再点击定义远程(Define Remote):
如果添加了错误的远程仓库,可以在左侧项目栏中右键项目目录,然后:Git,管理远程。
粘贴上面复制的ssh地址:
如果没有配置公钥,需要先配置公钥:
然后在Github中点击设置,在ssh一栏中添加公钥。
现在再进入IDEA推送页面,页面如下所示:
点击推送即可。
推送成功。
day1-7 数据库环境搭建MySQL 首先安装MySQL,MySQL :: Download MySQL Installer ,选择下面的下载:
端口设置:
密码设置:123456。 Windows服务名称:MySQL80。
安装完毕后从命令行进入 mysqlsh:
连接数据库:
接下来就可以在这里写 sql 建表语句了。之后我们在IDEA配置数据源,点击右侧工具栏的数据库图标:
配置drivers:
配置数据源:
或者也可以不在 mysqlsh 写sql,直接在IDEA运行:
day1-8 前后端联调 在右侧maven工具栏中,对root进行编译,这样对四个模块都会进行编译。
启动sky-server的SkyApplication。启动之前记得修改yaml中的sql账号密码端口号:
启动后,即可在工作台 进行登录。admin,123456。即可登录。登录截图略。
day1-9 Nginx 什么是代理:proxy(代理) 指的是一种中间服务器 ,它位于客户端和目标服务器之间,代替客户端向目标服务器发送请求 ,然后再把服务器的响应返回给客户端。
类型
特点
正向代理(Forward Proxy)
客户端使用代理访问外部资源,目标服务器看不到真实客户端。
反向代理(Reverse Proxy)
客户端不知道后端服务器的存在,代理接收请求并转发到后端服务器(如 Nginx)。
什么是 Nginx 反向代理:前端不直接向后端发出请求而是找Nginx。你不直接访问真实的服务器,而是先找中介Nginx,由它转发请求、拿到结果后再转交给你。
1 2 3 4 5 6 7 8 server { listen 80 ; server_name localhost; location /api/ { proxy_pass http://localhost:8080/admin/; } }
上面这段代码的含义:将前端80端口以api开头的请求,转发给后端8080的admin路径上。
Nginx还能实现负载均衡,可以把请求分给不同服务器处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 http { upstream my_backend { server 127.0.0.1:8081 ; server 127.0.0.1:8082 ; } server { listen 80 ; server_name localhost; location / { proxy_pass http://my_backend; proxy_set_header Host $host ; proxy_set_header X-Real-IP $remote_addr ; } } }
day1-11 完善登录密码加密 密码不能够明文存储。使用MD5进行加密。MD5加密不可逆,密码比对只能对密文进行比对。
Java代码修改如下:
1 2 3 4 5 6 password = DigestUtils.md5DigestAsHex(password.getBytes()); if (!password.equals(employee.getPassword())) { throw new PasswordErrorException (MessageConstant.PASSWORD_ERROR); }
然后把数据库中密码改成md5密文。
day1-12 导入接口文档 YApi
YApi 是一个可视化接口管理平台 ,它是由腾讯开源的,主要用于 API 文档管理、接口调试、团队协作等。
首先登录网址 YApi
创建项目:
进入项目,点击数据管理,导入接口文档(支持json,swagger等等)
day1-13 Swagger Knife4j 是一个用于 生成和增强 API 文档的工具 ,它基于 Swagger ,是 Swagger 的一个增强 UI 实现。
使用方法:
第一步,maven配置:略
第二步,配置类中配置knife4j:最重要的就是RequestHandlerSelectors.basePackage(“com.sky.controller”),制定了扫描的包:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Bean public Docket docket () { ApiInfo apiInfo = new ApiInfoBuilder () .title("苍穹外卖项目接口文档" ) .version("2.0" ) .description("苍穹外卖项目接口文档" ) .build(); Docket docket = new Docket (DocumentationType.SWAGGER_2) .apiInfo(apiInfo) .select() .apis(RequestHandlerSelectors.basePackage("com.sky.controller" )) .paths(PathSelectors.any()) .build(); return docket; }
第三步,设置静态资源映射:
1 2 3 4 5 6 7 8 protected void addResourceHandlers (ResourceHandlerRegistry registry) { registry.addResourceHandler("/doc.html" ).addResourceLocations("classpath:/META-INF/resources/" ); registry.addResourceHandler("/webjars/**" ).addResourceLocations("classpath:/META-INF/resources/webjars/" ); }
访问:苍穹外卖项目接口文档
阶段
Swagger 用于做什么
YApi 用于做什么
接口设计阶段(前)
❌(不适合)
✅ 可手动创建接口、模拟返回,前端可提前开发
后端开发中
✅ 自动生成接口文档,方便开发者查看
❌ 一般此时不是主要工具(除非需要共享接口)
前后端联调阶段
✅ 配合 Knife4j 可调试接口、查看字段
✅ Mock 接口 + 文档共享 + 接口测试,前端/测试可独立使用
day1-14 Swagger常用注解
注解
作用
位置
示例用法
@Api
定义一个 Controller,给接口类加注释
类上
@Api(tags="用户管理")
@ApiOperation
说明一个接口的用途
Controller 方法上
@ApiOperation("查询用户列表")
@ApiParam
描述方法参数的意义
方法参数上
@ApiParam(value="用户ID", required=true)
@ApiModel
用于实体类,说明实体含义
实体类上
@ApiModel("用户实体")
@ApiModelProperty
描述实体类字段
实体类字段上
@ApiModelProperty("用户名")
@ApiResponses
描述多个响应信息
Controller 方法上
包含多个 @ApiResponse
@ApiResponse
描述单个响应信息
@ApiResponses 内部
code=200, message="请求成功"
@ApiIgnore
忽略该接口或参数
类、方法、参数上
@ApiIgnore
例如:
1 2 3 4 5 6 7 8 9 @Data @ApiModel(description = "员工登录时传递的数据模型") public class EmployeeLoginDTO implements Serializable { @ApiModelProperty("用户名") private String username; @ApiModelProperty("密码") private String password; }
再如:
1 2 3 4 5 @PostMapping("/logout") @ApiOperation("员工退出") public Result<String> logout () { return Result.success(); }
加完注解,接口文档中tag会变成注解的文字:
day2-1 新增员工-需求分析与设计 需求分析往往对着产品原型 进行分析:
接下来进行接口设计 :添加员工使用POST比较合适,使用json传递数据。
注:前端返回的msg是用于创建失败时候的提示弹窗。管理端url用admin前缀,用户端user前缀。
接口设计完毕进行数据库设计 :
day2-2 新增员工-代码开发 (Lombok, implements Serializable) 现在我们需要设计DTO。当然也可以直接用Employee类进行接收,但是由于Employee有很多前端用不到的属性,因此封装为DTO。DTO的字段是根据前面接口设计 中Body的字段确定的。
DTO代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 package com.sky.entity;import lombok.AllArgsConstructor;import lombok.Builder;import lombok.Data;import lombok.NoArgsConstructor;import java.io.Serializable;import java.time.LocalDateTime;@Data @Builder @NoArgsConstructor @AllArgsConstructor public class Employee implements Serializable { private static final long serialVersionUID = 1L ; private Long id; private String username; private String name; private String password; private String phone; private String sex; private String idNumber; private Integer status; private LocalDateTime createTime; private LocalDateTime updateTime; private Long createUser; private Long updateUser; }
让我们来解释一些东西:
首先是Lombok。Lombok是一个 Java 库,它通过使用注解来简化 Java 代码的编写。它提供了一系列的注解,用于自动生成常见的代码,如getter和setter方法、构造函数、equals和hashCode方法等,以减少开发者的重复劳动。Lomlok可以在maven中引入依赖使用。
注解
作用
@Data
自动生成 getter/setter、toString、equals、hashCode
@ToString
自动生成 toString() 方法
@NoArgsConstructor
自动生成无参构造方法
@AllArgsConstructor
自动生成全参构造方法
@Builder
生成建造者模式代码(Builder Pattern)
@Slf4j
自动生成 private static final Logger log 日志对象
@Value
不可变对象类(final + private + 只读)
例如:
1 2 3 4 5 6 7 @Data @NoArgsConstructor @AllArgsConstructor public class User { private String name; private int age; }
@Builder 用法:
1 2 3 4 5 6 7 @Builder @Data public class Employee { private Long id; private String name; private String email; }
1 2 3 4 5 Employee emp = Employee.builder() .id(1L ) .name("张三" ) .email("zhangsan@example.com" ) .build();
然后是序列化,Java中的实体类为什么要 implements Serializable?
序列化:把对象转换为字节序列的过程称为对象的序列化。反序列化:把字节序列恢复为对象的过程称为对象的反序列化。
序列化操作用于存储时,一般是对于 NoSql 数据库,而在使用 Nosql 数据库进行存储时,如 redis ,它就没有 varchar,int 之类的数据结构。 而在没有的情况下,我们又确实需要进行存储,那么我们就需要将对象进行序列化。
为什么要显示声明 serialVersionUID?
serialVersionUID 的作用是验证序列化和反序列化的过程中,对象是否保持一致。所以在一般情况下我们需要显示的声明serialVersionUID。如果接受者加载的该对象的类的 serialVersionUID 和发送者的类版本号不同的话,反序列化会爆出 InvalidClassException 错误。
解释完DTO,还有一个东西需要解释。看上面的接口文档,看返回数据,这个对应着我们sky-common模块下的Result类(sky-common/src/main/java/com/sky/result/Result.java),这个类也就是后端统一返回结果。代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public class Result <T> implements Serializable { private Integer code; private String msg; private T data; public static <T> Result<T> success () { Result<T> result = new Result <T>(); result.code = 1 ; return result; } public static <T> Result<T> success (T object) { Result<T> result = new Result <T>(); result.data = object; result.code = 1 ; return result; } public static <T> Result<T> error (String msg) { Result result = new Result (); result.msg = msg; result.code = 0 ; return result; } }
也就是,请求参数Body对应着DTO,返回数据对应着Result。
现在开始写代码,首先写控制器 ,控制器的参数是DTO,控制器的返回值是Result对象。这里我们没有返回Data,所以直接返回Result.success()也就是只有code和msg。(观察Result类代码可以知道,这里success()方法返回的是一个Result的对象)
1 2 3 4 5 6 7 @PostMapping() @ApiOperation("新增员工") public Result save (@RequestBody EmployeeDTO employeeDTO) { log.info("新增员工:{}" ,employeeDTO); employeeService.save(employeeDTO); return Result.success(); }
控制器调用service接口,因此先在service 中加上我们需要的接口,然后在implement中进行实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public void save (EmployeeDTO employeeDTO) { Employee employee = new Employee (); BeanUtils.copyProperties(employeeDTO,employee); employee.setStatus(StatusConstant.ENABLE); employee.setPassword(DigestUtils.md5DigestAsHex(PasswordConstant.DEFAULT_PASSWORD.getBytes())); employee.setCreateTime(LocalDateTime.now()); employee.setUpdateTime(LocalDateTime.now()); employee.setCreateUser(10L ); employee.setUpdateUser(10L ); employeeMapper.insert(employee); }
service又需要通过mapper进行插入,因此要在mapper 中加入insert方法。
1 2 3 @Insert("INSERT INTO employee(name, username, password, phone, sex, id_number, create_time, update_time, create_user, update_user) " + "VALUES(#{name}, #{username}, #{password}, #{phone}, #{sex}, #{idNumber}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser})") void insert (Employee employee) ;
day2-3 新增员工-测试 两种后端测试方式:Swagger,前后端联调。开发阶段以 Swagger 测试为主。
注意,我们现在直接进行测试是会返回401的,因为在com/sky/interceptor/JwtTokenAdminInterceptor.java中定义了JWT令牌的拦截器,我们当前令牌为空,因此返回401异常。解决方案:在“员工登录”这里调试,并获取返回的token:
然后把复制下来的token放在全局参数中即可。
现在直接调试就可以成功了。
当然我们现在也可以进行前后端联调,测试也通过。
注意!!!JWT令牌是有有效期的,可能隔一段时间我们再用这个令牌操作就失效了!这个时候按照前面操作再生成即可
day2-4 新增员工-增加相同用户异常处理 如果我们新增一个相同的员工,状态码500:
IDEA终端出现异常:java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'zhang' for key 'employee.idx_username'
com/sky/handler/GlobalExceptionHandler.java是我们处理异常的类,在这里添加一个方法用来处理SQL异常:
注意传入的参数就是上面的SQL异常类,通过Duplicate entry关键字识别进行用户友好提示。注意有些异常提示已经封装为常量,尽量避免直接写字符串。
1 2 3 4 5 6 7 8 9 10 11 12 public Result exceptionHandler (SQLIntegrityConstraintViolationException ex) { String msg=ex.getMessage(); log.error("异常信息:{}" , ex.getMessage()); if (msg.contains("Duplicate entry" )){ String[] tmp=msg.split(" " ); String username=tmp[2 ]; String errMsg=username+" 已存在" ; return Result.error(errMsg); } else { return Result.error(MessageConstant.UNKNOWN_ERROR); } }
现在如果再新增一个相同的员工,状态码就会是200,且返回用户友好的信息。
1 2 3 4 5 { "code" : 0 , "msg" : "'zhang' 已存在" , "data" : null }
day2-5 新增员工-创建人修改人ID替换 (ThreadLocal) 注意一个概念,Tomcat中,客户端每发起一次请求就是一个新的线程 。
当客户端发起 HTTP 请求时,服务器为了并发处理,会为这个请求分配一个线程来处理它(通常是线程池中的一个线程)。这个线程会处理业务逻辑、数据库、响应输出等任务。服务端会为每个请求分配一个线程(或复用线程池中的线程) 来处理它,彼此互不干扰,支持并发。
ThreadLocal 是 Java 提供的一种线程局部变量 ,就像是每个线程都有一个自己的空间,放进去的变量只有当前线程能看见、能拿出来用 ,其他线程完全无法访问。
ThreadLocal 通常有下面的作用:
场景
说明
用户登录上下文
每个请求绑定当前用户 ID、Token,存在线程中
数据库事务管理
保存每个线程独立的数据库连接对象
日志跟踪
将 traceId 绑定到线程中,贯穿调用链
请求上下文传递
Spring 的 RequestContextHolder 就是用的 ThreadLocal
登录后想保存当前用户的 ID,让业务层可以随时访问 。这就是一个 ThreadLocal 常见的应用场景,他可以做到存储一个线程的上下文的作用。
在我们项目的com/sky/context/BaseContext.java中,封装了 ThreadLocal 的方法。
1 2 3 4 5 6 7 8 9 10 11 12 public class BaseContext { public static ThreadLocal<Long> threadLocal = new ThreadLocal <>(); public static void setCurrentId (Long id) { threadLocal.set(id); } public static Long getCurrentId () { return threadLocal.get(); } public static void removeCurrentId () { threadLocal.remove(); } }
为了在当前线程共享当前登录用户的ID,我们可以从JWT中拿到用户ID,存在ThreadLocal变量中。
1 2 3 4 5 log.info("jwt校验:{}" , token); Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());log.info("当前员工id:" , empId); BaseContext.setCurrentId(empId);
最后,在service中把该变量取出,赋值即可:
1 2 employee.setCreateUser(BaseContext.getCurrentId()); employee.setUpdateUser(BaseContext.getCurrentId());
测试发现可以正常新增,且修改人ID正确。新增员工开发完毕,别忘了Git Push。
day2-6 员工分页查询-需求分析与设计
day2-7 员工分页查询-代码开发 (PageHelper, MyBatis动态SQL与XML配置) 老规矩,DTO对应着接口的请求参数Query(前面是POST请求,是Body,这里是GET请求,是Query),Result对应着接口的返回值。
DTO代码,和上面的Query字段完全一致:
1 2 3 4 5 public class EmployeePageQueryDTO implements Serializable { private String name; private int page; private int pageSize; }
至于Result,由于这里由于是分页查询,我们在sky-common/src/main/java/com/sky/result包中还封装了PageResult类。
1 2 3 4 public class PageResult implements Serializable { private long total; private List records; }
1 2 3 4 5 public class Result <T> implements Serializable { private Integer code; private String msg; private T data; }
之前的Result类正好对应着我们接口返回值,Result中的data正好对应着PageResult类。这样的话,Result的泛型就是PageResult也就是:Result<PageResult>。
确定了参数(EmployeePageQueryDTO)和返回值(Result<PageResult>),我们就可以开始写controller了(注意,这里是Query,不是json请求,不需要写@RequestBody注解):
1 2 3 4 5 6 @GetMapping("/page") @ApiOperation("员工分页查询") public Result<PageResult> page (EmployeePageQueryDTO employeePageQueryDTO) { PageResult pageResult=employeeService.pageQuary(employeePageQueryDTO); return Result.success(pageResult); }
至此我们controller就写好了,不过service的pageQuary方法还没写。
其实你会发现,controller是很好写的,只是明确参数和返回值,调用一下service,构造一下结果就可以了。至于具体实现,那是service干的事情。
(我在写到这的时候有一个疑问,我们是怎么直接拿到employeeService对象的?答案是employeeService对象是EmployeeController的private字段,并加上了@Autowired注解,同时service的实现类需要加上@Service注解)
1 2 3 4 5 6 public class EmployeeController { @Autowired private EmployeeService employeeService; @Autowired private JwtProperties jwtProperties; }
(其实controller和service的关系,和service与mapper的关系一样,mapper要加上@Mapper注解,同时在service类中自动注入mapper)
OK,下面开始实现service:
在实现service之前先介绍一个用于实现分页的插件:PageHelper ,它可以自动为你的 SQL 查询添加分页语句(LIMIT 语句),让你只写一条查询语句就能实现分页。该插件需要通过maven进行引入。
假设你有个用户表,想分页查询用户列表(每页 10 条),传统写法要手动拼接 LIMIT 语句,很麻烦,PageHelper 就无需这么麻烦。
service代码如下:
1 2 3 4 5 6 7 public PageResult pageQuery (EmployeePageQueryDTO employeePageQueryDTO) { PageHelper.startPage(employeePageQueryDTO.getPage(),employeePageQueryDTO.getPageSize()); Page<Employee> page=employeeMapper.pageQuery(employeePageQueryDTO); long total=page.getTotal(); List<Employee> records=page.getResult(); return new PageResult (total,records); }
接下来让我们实现mapper的pageQuery方法:
实现mapper之前,让我们介绍一下动态SQL。动态 SQL 就是写一段“会变”的 SQL,具体内容根据参数、逻辑在运行时自动拼出来的。MyBatis 提供了一套标签实现动态SQL,我们可以尽量把 SQL 逻辑放在 XML 中。
在一个 标准的 Java Web 项目(MyBatis)中,XML文件(Mapper 映射文件) 的放置位置如下:
1 2 3 4 5 6 7 8 9 src ├── main │ ├── java │ │ └── com.example.project │ │ └── mapper │ │ └── EmployeeMapper.java <-- 接口 │ └── resources │ └── mapper │ └── EmployeeMapper.xml <-- XML 映射文件
当然,Spring Boot 项目通常使用 application.yml 配置 MyBatis 路径。
在我们现在要实现的分页查询中,我们使用动态SQL,而不再使用注解的方式,因此需要使用xml映射文件:
该项目xml在sky-server/src/main/resources/mapper/EmployeeMapper.xml中。
首先我们回到mapper,把pageQuery方法创建,并右键点击生成:
最终xml如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" > <mapper namespace ="com.sky.mapper.EmployeeMapper" > <select id ="pageQuery" resultType ="com.sky.entity.Employee" parameterType ="com.sky.dto.EmployeePageQueryDTO" > select * from employee <where > <if test ="name!=null and name!=''" > and name like concat('%',#{name},'%') </if > </where > order by create_time desc </select > </mapper >
那么上面的代码是怎么实现分页的呢?
PageHelper 是 MyBatis 的分页插件,通过拦截器机制拦截你的查询语句,并自动在 SQL 后面加上分页的 LIMIT 子句 。
PageHelper.startPage(...) 会启用分页上下文;MyBatis 执行 SQL 时,PageHelper 的拦截器就会捕捉到这个 SQL;然后插件会在末尾加上 limit ? offset ?,最后结果封装为 Page<T> 返回。(PageHelper也是基于ThreadLocal实现的)
OK,总结一下,一个功能需求实现的步骤就是:接口文档,DTO和Result,控制器,service,mapper,测试,异常处理与其他。
day2-9 员工分页查询-完善日期显示 (序列化, 反序列化, 消息转换) 时间显示格式有问题,需要修改。
sky-common/src/main/java/com/sky/json/JacksonObjectMapper.java,一个自定义的 Jackson 配置类,用于控制 Java 对象和 JSON 之间的转换格式 ,尤其是 时间类(LocalDate、LocalTime、LocalDateTime)的序列化与反序列化格式。
序列化(Serialization) :把 Java 对象 ➜ 转换为字节流(例如保存到文件、传输给网络)
反序列化(Deserialization) :把字节流 ➜ 转换回 Java 对象。
在com/sky/config/WebMvcConfiguration.java中添加如下语句:
1 2 3 4 5 protected void extendMessageConverters (List<HttpMessageConverter<?>> converters) { MappingJackson2HttpMessageConverter converter=new MappingJackson2HttpMessageConverter (); converter.setObjectMapper(new JacksonObjectMapper ()); converters.add(0 ,converter); }
这段代码是 Spring MVC 中的一种配置方式,用于扩展 Spring 的消息转换器 (MessageConverter),实现 自定义对象序列化和反序列化的行为 。
在JacksonObjectMapper中(也就是上面提到的自定义Jackson配置类)就可以写日期转换逻辑,具体逻辑略。
day2-10 启用禁用-需求分析与设计 原型与需求:
接口:
注意,{status}:这是一个路径参数,表示在实际请求时,需要将这个占位符替换为具体的员工状态值。例如,如果员工的状态是1,那么完整的url将是 /admin/employee/status/1。
day2-11 启用禁用-代码开发与测试 对于路径参数,我们需要使用@PathVariable注解来获取路径参数:
1 2 3 4 5 6 @PostMapping("/status/{status}") @ApiOperation("启用禁用员工账号") public Result startOrStop (@PathVariable Integer status, Long id) { employeeService.startOrStop(status, id); return Result.success(); }
实现service方法:
1 2 3 4 5 6 7 public void startOrStop (Integer status, Long id) { Employee employee=Employee.builder() .status(status) .id(id) .build(); employeeMapper.update(employee); }
注意这里的service。因为我们想让employeeMapper.update方法更具有通用性,也就是以后想要修改别的属性也可以通过employeeMapper.update方法使用,我们就不能只传入status和id了,而是传入一个employee对象(为什么这么做有点不好理解,可以结合后面动态sql语句理解),这个employee对象中id是我们要修改的id,status是目标stauts,其余都是null。
接下来是mapper,这里传入了我们创建的employee对象:
1 void update (Employee employee) ;
一样的右键生成代码,然后在xml配置动态sql:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <update id ="update" parameterType ="com.sky.entity.Employee" > update employee <set > <if test ="name != null" > name = #{name},</if > <if test ="username != null" > username = #{username},</if > <if test ="password != null" > password = #{password},</if > <if test ="phone != null" > phone = #{phone},</if > <if test ="sex != null" > sex = #{sex},</if > <if test ="idNumber != null" > id_Number = #{idNumber},</if > <if test ="updateTime != null" > update_Time = #{updateTime},</if > <if test ="updateUser != null" > update_User = #{updateUser},</if > <if test ="status != null" > status = #{status},</if > </set > where id=#{id} </update >
解释一下这个sql:以第一个if语句为例 <if test="name != null">name = #{name},</if>
位置
含义
test="name != null" 中的 name
是传入的参数 Java 对象 employee 的属性:employee.getName()
name = #{name} 中的左边 name
是 数据库字段名 (表字段),对应 employee 表中的列名
#{name} 中的 name
是传入的参数 Java 对象 employee 的属性,用于参数占位符绑定
假设调用如下代码:
1 2 3 4 Employee emp = new Employee ();emp.setId(1L ); emp.setName("张三" ); employeeMapper.update(emp);
那么:name != null → 会判断 emp.getName() != null,返回 true,name = #{name} → 会生成 SQL 片段:name = '张三'。而其他字段都是空,因此不会做更新。
最终生成的 SQL 是:
1 2 3 update employeeset name = '张三' where id = 1 ;
至此,我们的代码开发完毕。
测试略,没有新东西。
day2-12 编辑员工-需求分析与设计
需要两个接口,一个是修改前用于页面回显(查询),一个是更新。
day2-13 编辑员工-代码开发 首先是查询,这个功能非常简单,直接上代码:
控制器:
1 2 3 4 5 6 @GetMapping("/{id}") @ApiOperation("根据id查询员工信息") public Result<Employee> getById (@PathVariable Long id) { Employee employee=employeeService.getById(id); return Result.success(employee); }
service(为了安全起见,传给前端的时候把密码抹掉):
1 2 3 4 5 public Employee getById (Long id) { Employee employee=employeeMapper.getById(id); employee.setPassword("****" ); return employee; }
mapper,比较简单,直接写sql:
1 2 @Select("select * from employee where id = #{id}") Employee getById (Long id) ;
下面是编辑员工信息:
控制器,注意观察接口文档中的Body字段,容易看出来我们这时候应该用DTO而不是Employee本身:
1 2 3 4 5 6 @PutMapping @ApiOperation("编辑员工信息") public Result update (@RequestBody EmployeeDTO employeeDTO) { employeeService.update(employeeDTO); return Result.success(); }
service,注意把DTO转成Employee,符合update接口:
1 2 3 4 5 6 7 8 public void update (EmployeeDTO employeeDTO) { Employee employee=new Employee (); BeanUtils.copyProperties(employeeDTO,employee); employee.setUpdateTime(LocalDateTime.now()); employee.setCreateUser(BaseContext.getCurrentId()); employee.setUpdateUser(BaseContext.getCurrentId()); employeeMapper.update(employee); }
mapper,和前面2-11一样,xml代码不再重复:
1 void update (Employee employee) ;
day2-15 分类管理代码导入 由于代码和之前很类似,因此直接导入。
原型:
分类分为两种大的类型,菜品分类和套餐分类。
六个接口:新增分类,分类分页查询,根据id删除分类,修改分类,启用禁用分类,根据类型查询分类。
数据库设计:
代码导入:略。
测试:略。
day3-1 公共字段自动填充 (AOP, 面向切面编程)
由于表中含有公共字段,冗余代码过多,不便于后期维护。
创建时间会在insert赋值,修改时间会在insert和update赋值。
实现思路:
技术点:枚举,注解,AOP,反射。
AOP(面向切面编程)是一种编程模式,让开发者在不修改核心代码的前提下,给程序动态添加通用功能 (比如日志、权限检查、事务管理)。
假设你有一本小说(核心业务逻辑),现在想给每一章的开头加一句“本章由AI生成”(通用功能)。传统做法:手动修改每一章的开头 → 代码重复、侵入性强 。AOP做法:直接给整本书套一个“自动盖章机” → 非侵入式、集中管理 。
AOP通过 切面(Aspect) 将 横切关注点 从业务代码中分离,包含以下核心组件:
Join Point(连接点) :程序执行中的特定点(如方法调用、异常抛出)。
Pointcut(切点) :匹配一组连接点的表达式(定义在哪里插入代码)。
Advice(通知) :在连接点执行的具体逻辑(如前置、后置、环绕通知)。
Aspect(切面) :包含切点和通知的模块化单元。
AOP通过在程序执行的不同阶段织入切面代码来实现其功能。织入(weaving)是指将切面代码与目标代码合并的过程。
有两种主要的织入方式:编译时织入和运行时织入。
day3-2 公共字段自动填充 代码开发 (AOP, 反射) 步骤:
自定义注解 AutoFill :创建一个名为 AutoFill 的注解,用于标记那些需要自动填充公共字段的方法。
自定义切面类 AutoFillAspect :创建一个名为 AutoFillAspect 的切面类。在这个切面类中,拦截所有使用了 AutoFill 注解的方法,并通过反射机制为这些方法中的公共字段自动赋值。
在 Mapper 的方法上加入 AutoFill 注解 :这样这些方法在执行时就会自动触发切面逻辑,完成公共字段的自动填充。
自定义注解 首先创建包 sky-server/src/main/java/com/sky/annotation,并在包下创建注解 AutoFill:
自定义注解如下(注解本质上是一个特殊的接口,编译器会帮你生成实现类):
1 2 3 4 5 6 @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface AutoFill { OperationType value () ; }
解释一下:@Target(ElementType.METHOD)代表表示这个注解只能用在“方法”上,如果你用在字段、类上,就会报错,例如下面就是正确使用:
1 2 3 4 @AutoFill(OperationType.INSERT) public void insertUser (User user) { }
@Retention(RetentionPolicy.RUNTIME)代表这个注解在运行时 依然存在,JVM 会保留它,程序可以通过 反射 获取到它。这条在AOP中是必须写的,因为只有运行时保留,切面才能拦截到这个注解。
OperationType value();是什么意思呢?这是注解的参数,表示你要传入一个值,类型是 OperationType(一个自定义的枚举)。
举个例子,如果注解参数这样写:
1 2 3 4 public @interface LogInfo { String action () ; String module () ; }
那么使用应该:
1 @LogInfo(action = "添加用户", module = "用户管理")
如果注解参数这样写:
1 2 3 public @interface MyTag { String value () ; }
那么使用应该:
注意,这个等同于 :
因此,如果我们注解参数是 OperationType value(); 的话,可以直接写:
1 @AutoFill(OperationType.INSERT)
这里 value() 就会等于 OperationType.INSERT。
自定义切面类 创建包sky-server/src/main/java/com/sky/aspect,创建切面类AutoFillAspect:
切面=通知+切入点 ,即要“切”的地方(即:拦截哪些方法)+要“切”进去干的事(具体要做什么)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 @Aspect @Component @Slf4j public class AutoFillAspect { @Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)") public void autoFillPointCut () {} @Before("autoFillPointCut()") public void autoFill (JoinPoint joinPoint) { MethodSignature signature = (MethodSignature) joinPoint.getSignature(); AutoFill autoFill=signature.getMethod().getAnnotation(AutoFill.class); OperationType operationType=autoFill.value(); Object[] args=joinPoint.getArgs(); if (args==null || args.length==0 ){ return ; } Object entity=args[0 ]; LocalDateTime now=LocalDateTime.now(); Long currentId= BaseContext.getCurrentId(); if (operationType==OperationType.INSERT){ try { Method setCreateTime=entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME,LocalDateTime.class); Method setCreateUser=entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER,Long.class); Method setUpdateTime=entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME,LocalDateTime.class); Method setUpdateUser=entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER,Long.class); setCreateTime.invoke(entity, now); setCreateUser.invoke(entity, currentId); setUpdateTime.invoke(entity, now); setUpdateUser.invoke(entity, currentId); } catch (Exception e) { throw new RuntimeException (e); } } else if (operationType==OperationType.UPDATE) { try { Method setUpdateTime=entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME,LocalDateTime.class); Method setUpdateUser=entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER,Long.class); setUpdateTime.invoke(entity, now); setUpdateUser.invoke(entity, currentId); } catch (Exception e) { throw new RuntimeException (e); } } } }
让我们解释一下上面的代码:
注解 1 2 3 4 @Aspect @Component @Slf4j public class AutoFillAspect
@Aspect声明这是一个切面类,@Component将该类注册为Bean,由 Spring 管理这个类。@Slf4j之前解释过(Lombok),略。
切入点 1 2 @Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)") public void autoFillPointCut () {}
@Pointcut 注解定义了要拦截的方法范围 :execution(* com.sky.mapper.*.*(..)),匹配 com.sky.mapper 包下的 任意类 的 任意方法 ,方法参数不限。
&& @annotation(com.sky.annotation.AutoFill),只匹配那些被 @AutoFill 注解标注的方法。
注意:切入点函数可以随意命名,@Before 的 value 必须和切入点函数名一样,切入点函数里面不可以写代码。
通知 1 2 3 4 @Before("autoFillPointCut()") public void autoFill (JoinPoint joinPoint) { ... }
@Before 是一个通知(Advice),表示:在目标方法执行前,先执行 autoFill() 方法。
当你调用 employeeMapper.insert(...),如果这个方法有 @AutoFill 注解,就会先自动执行 autoFill() 方法。
这里的参数 JoinPoint 是 Spring AOP 提供的一个对象,表示当前连接点(方法) 的信息。你可以通过它拿到:方法参数,注解内容等等。
举个例子,如果我们mapper有:
1 2 @AutoFill(OperationType.INSERT) void insert (Employee employee) ;
在service层有如下代码:
1 employeeMapper.insert(emp);
那么,insert() 就是一个连接点 。
业务实现与反射 1 2 3 MethodSignature signature = (MethodSignature) joinPoint.getSignature();AutoFill autoFill=signature.getMethod().getAnnotation(AutoFill.class); OperationType operationType=autoFill.value();
第一步获取方法的签名对象 :获取方法签名对象的作用,是为了在 AOP 通知中拿到目标方法的详细信息 ,尤其是方法上的注解、方法名、参数类型等。这里我们用来获得注解的value。
1 Object[] args = joinPoint.getArgs();
这句话获取目标方法在运行时接收到的实参数组,得到一个 Object[],里面就是实际调用 insert() 时传进来的那些参数对象。(这里约定第一个参数为需要修改的对象)
1 2 3 4 5 6 7 8 9 Object[] args=joinPoint.getArgs(); Object entity=args[0 ]; Method setUpdateTime=entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class); Method setUpdateUser=entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class); setUpdateTime.invoke(entity, now); setUpdateUser.invoke(entity, currentId);
上面的代码是典型的Java反射机制 ,动态调用对象的方法。
首先通过反射获取方法对象:
entity.getClass():获取传入实体对象的运行时类,比如 Employee.class
.getDeclaredMethod(...):通过方法名和参数类型来获取这个类中的指定方法对象(Method)
然后调用方法(反射执行):
setUpdateTime.invoke(entity, now); 第一个参数:调用方法的对象;后面的参数:是方法调用时传入的实际参数。
1 setUpdateUser.invoke(entity, currentId);
等价于:
1 entity.setUpdateUser(currentId);
为什么需要反射:在 AOP 的 @Before 通知中,我们拿到的是 Object entity,它是一个未知类型的对象 ,为了让这个 AOP 能适配多个实体类(如 Employee、Dish、Category),就不能写死调用。反射在运行时 getClass() 才得知要运行的类。
通过反射调用方法的一般步骤:
获取类的 Class 对象实例:Class clz = entity.getClass();
获取方法的 Method 对象:Method setPriceMethod = clz.getMethod("setPrice", int.class);
使用 invoke 调用方法:setPriceMethod.invoke(appleObj, 14);
注意,凡是涉及硬编码(指在程序中直接将具体的值,如字符串,数字,写死在代码中)的地方,都通过配置文件,常量类,枚举类,实现软编码。
如这里:
1 Method setCreateUser=entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER,Long.class);
这里的AutoFillConstant.SET_CREATE_USER就用常量类代表这个方法的字符串:
1 2 3 4 5 6 7 8 9 public class AutoFillConstant { public static final String SET_CREATE_TIME = "setCreateTime" ; public static final String SET_UPDATE_TIME = "setUpdateTime" ; public static final String SET_CREATE_USER = "setCreateUser" ; public static final String SET_UPDATE_USER = "setUpdateUser" ; }
加上自定义注解 1 2 3 4 @Insert("INSERT INTO employee(name, username, password, phone, sex, id_number, create_time, update_time, create_user, update_user) " + "VALUES(#{name}, #{username}, #{password}, #{phone}, #{sex}, #{idNumber}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser})") @AutoFill(OperationType.INSERT) void insert (Employee employee) ;
1 2 @AutoFill(value = OperationType.UPDATE) void update (Employee employee) ;
这里我们带和不带value=都可以。
把原先service类中手动赋值代码删除 略。
day3-5 新增菜品-需求分析
要求:菜名唯一,菜品必须属于某个分类,口味不必须,图片必须。
菜品分类栏需要实现菜品查询,该接口已经实现过了,不需要再实现。
现在只需要实现文件上传和菜品新增两个接口。
注意,这里返回的data是必须的,需要把图片的服务器端路径返回。
数据库设计如下:
逻辑外键:数据库没有创建外键,我们在java代码中自行处理。
菜品和口味是一对多的关系 ,因此在口味中设置菜品的主键。一个菜品有多种口味,应该在一个菜品中选择不同的口味,比如你在点菜时候想点一条鱼,我可以选择辣或者不辣;但是先选择辣或不辣再选菜品是没有意义的。
day3-6 新增菜品-代码开发 (对象存储服务OSS, 读取yml配置文件到java代码) 阿里云的对象存储服务 OSS(Object Storage Service ),是一种云端文件存储服务 ,可以让你把任意格式的数据(如图片、视频、文档、日志、备份文件等)存储在云端,并随时随地访问、下载或共享 。
Bucket(存储空间) 类似于文件夹,用来组织和隔离你的文件资源。每个 Bucket 的名称是唯一的。
Object(对象) 就是你存储的文件,比如图片、视频、压缩包。每个 Object 有一个唯一的 Key(路径名)。
Endpoint(访问域名) :用来访问你存储资源的地址。
AccessKey ID / Secret :访问 OSS 所需的身份凭证,用于身份认证。
在使用阿里云OSS之前,需要在application.yml进行配置:
1 2 3 4 5 6 sky: alioss: endpoint: ${sky.alioss.endpoint} access-key-id: ${sky.alioss.access-key-id} access-key-secret: ${sky.alioss.access-key-secret} bucket-name: ${sky.alioss.bucket-name}
为什么要使用引用方式配置?
我们的项目会经历开发阶段、测试阶段和生产阶段,不同阶段对应的服务器(如数据库、OSS、Redis 等)配置往往不同。
因此,我们会将通用配置放在主配置文件 application.yml 中,而将环境相关的配置分别放在 application-dev.yml、application-test.yml 和 application-prod.yml 文件中。
Spring Boot 会通过设置 spring.profiles.active 属性来决定当前使用哪一个环境配置文件。
例如:
1 2 3 4 5 6 7 8 9 10 11 spring: profiles: active: dev main: allow-circular-references: true datasource: druid: driver-class-name: ${sky.datasource.driver-class-name} url: jdbc:mysql://${sky.datasource.host}:${sky.datasource.port}/${sky.datasource.database}?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true username: ${sky.datasource.username} password: ${sky.datasource.password}
在这里,active的就是dev,也就是application-dev.yml是我们当前有效的配置文件。下面的sql配置也是使用引用方式,具体值写在dev配置文件中:
1 2 3 4 5 6 7 8 sky: datasource: driver-class-name: com.mysql.cj.jdbc.Driver host: localhost port: 3030 database: sky_take_out username: root password: ****
因此我们主配置文件使用引用方式,而不是写死,具体配置的值在dev,test,prod等配置文件中写,根据开发阶段,测试阶段,生产阶段的不同,主配置文件上面会写当前有效的是哪个配置文件。
让我们回到阿里云OSS配置。(这里需要我们在阿里云申请一个OSS,申请过程略)完善application-dev.yml:
1 2 3 4 5 6 7 8 9 10 11 12 13 sky: datasource: driver-class-name: com.mysql.cj.jdbc.Driver host: localhost port: 5005 database: sky_take_out username: root password: 123456 alioss: endpoint: oss-cn-beijing.aliyuncs.com bucket-name: *** access-key-secret: *** access-key-id: ***
该项目已经开发好了一个用于OSS上传文件的工具类:sky-common/src/main/java/com/sky/utils/AliOssUtil.java
1 2 3 4 5 6 7 8 9 10 11 12 13 @Data @AllArgsConstructor @Slf4j public class AliOssUtil { private String endpoint; private String accessKeyId; private String accessKeySecret; private String bucketName; public String upload (byte [] bytes, String objectName) { } }
这个类的字段没有被初始化,我们拿不到这些字段值无法进行上传。我们现在的任务就是从配置文件中读取配置,然后把配置赋值给这里的字段。怎么做呢?
在 Spring Boot 项目中,我们可以非常优雅地将 application.yml 中的配置读取到 Java 代码中使用 。
方法就是通过创建一个配置属性类 ,我们使用 @ConfigurationProperties 注解来绑定配置,@Component 或 @Configuration 让它成为 Spring Bean。@ConfigurationProperties(prefix = "aliyun.oss") 指的是你要读取的配置路径。使用 @Data(Lombok)自动生成 getter/setter。
在我们的项目中,我们创建了一个配置属性类sky-common/src/main/java/com/sky/properties/AliOssProperties.java
1 2 3 4 5 6 7 8 9 @Component @ConfigurationProperties(prefix = "sky.alioss") @Data public class AliOssProperties { private String endpoint; private String accessKeyId; private String accessKeySecret; private String bucketName; }
接下来再创建一个配置类sky-server/src/main/java/com/sky/config/OssConfiguration.java。
这个类基于AliOssProperties创建一个 OSS 工具类 AliOssUtil,并将其注册为 Spring 的 Bean,在项目中全局可用:
1 2 3 4 5 6 7 8 9 10 11 12 @Configuration @Slf4j public class OssConfiguration { @Bean @ConditionalOnMissingBean public AliOssUtil aliOssUtil (AliOssProperties aliOssProperties) { return new AliOssUtil (aliOssProperties.getEndpoint(), aliOssProperties.getAccessKeyId(), aliOssProperties.getAccessKeySecret(), aliOssProperties.getBucketName()); } }
这里 @Bean 将 aliOssUtil(...) 方法返回的对象注入到 Spring 容器中,这样这个方法就自动被执行,把这个AliOssProperties对象创建出来并交给 Spring 管理。@ConditionalOnMissingBean保证只创建一次。
综上,理一遍过程。
先在阿里云OSS进行申请;
然后在application.yml配置阿里云OSS,注意使用引用方式;然后在application-dev.yml配置具体的值;
实现用于OSS上传文件的工具类AliOssUtil,注意使用全部参数构造方法注解@AllArgsConstructor,便于后面对象创建;
接下来创建AliOssProperties类,使用 @ConfigurationProperties 注解来绑定配置,自动将 application.yml 中的配置项绑定到 Java 对象里,方便后续直接用对象访问配置值;
然后创建OssConfiguration类,基于AliOssProperties创建一个 OSS 工具类 AliOssUtil,并将其注册为 Spring 的 Bean,在项目中全局可用。
day3-7 新增菜品-图片上传控制器 (UUID) 编写控制器代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @RestController @RequestMapping("/admin/common") @Api(tags = "通用接口") @Slf4j public class CommonController { @Autowired private AliOssUtil aliOssUtil; @ApiOperation("文件上传") @PostMapping("/upload") public Result<String> upload (MultipartFile file) { try { String originalFilename=file.getOriginalFilename(); String extension=originalFilename.substring(originalFilename.lastIndexOf("." )); String objectName=UUID.randomUUID().toString() + extension; String filePath=aliOssUtil.upload(file.getBytes(), objectName); return Result.success(filePath); } catch (Exception e) { log.error(String.valueOf(e)); } return Result.error(MessageConstant.UPLOAD_FAILED); } }
自动注入我们前面的工具类AliOssUtil并调用方法。注意这里使用UUID生成唯一的文件名,避免被覆盖。
day3-8 新增菜品-代码开发 (多表插入, 事务注解, 主键回填) 回顾一下现在需要开发的接口的文档:
我们可以把这个Body封装为DTO:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Data public class DishDTO implements Serializable { private Long id; private String name; private Long categoryId; private BigDecimal price; private String image; private String description; private Integer status; private List<DishFlavor> flavors = new ArrayList <>(); }
其中DishFlavor是口味实体类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Data @Builder @NoArgsConstructor @AllArgsConstructor public class DishFlavor implements Serializable { private static final long serialVersionUID = 1L ; private Long id; private Long dishId; private String name; private String value; }
接下来写控制器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @RestController @RequestMapping("/admin/dish") @Api(tags = "菜品接口") @Slf4j public class DishController { @Autowired private DishService dishService; @PostMapping @ApiOperation("新增菜品") public Result save (@RequestBody DishDTO dishDTO) { dishService.saveWithFlavor(dishDTO); return Result.success(); } }
service:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @Service @Slf4j public class DishServiceImpl implements DishService { @Autowired private DishMapper dishMapper; @Autowired private DishFlavorMapper dishFlavorMapper; @Transactional public void saveWithFlavor (DishDTO dishDTO) { Dish dish=new Dish (); BeanUtils.copyProperties(dishDTO,dish); dishMapper.insert(dish); Long dishId=dish.getId(); List<DishFlavor> flavors=dishDTO.getFlavors(); if (flavors!=null && flavors.size()>0 ){ flavors.forEach(dishFlavor -> { dishFlavor.setDishId(dishId); }); dishFlavorMapper.insertBatch(flavors); } } }
注意这里@Transactional 是 Spring 框架中用于 声明式事务管理 的注解,确保一组数据库操作要么全部成功,要么全部失败(回滚)。
@Transactional要用在 Service 层而不要用在其他层。
@Transactional要使用需要在启动类开启注解方式的事务管理。
启动类:
1 2 3 4 5 6 7 8 9 @SpringBootApplication @EnableTransactionManagement @Slf4j public class SkyApplication { public static void main (String[] args) { SpringApplication.run(SkyApplication.class, args); log.info("server started" ); } }
回到service代码,解释一下这里的业务逻辑:由于我们添加菜品的时候需要对两个表进行操作,一个是菜品表(Dish)一个是口味表(DishFlavor),因此需要调用两个不同的mapper。
在添加菜品(Dish)的时候,由于数据库中存的是菜品,因此要把DTO转成实体,用BeanUtils.copyProperties属性拷贝即可,创建一个新的Dish对象,再把它插入数据库,很简单。
问题在于口味表(DishFlavor),口味表中有一个字段叫做dish_id,也就是菜品的ID,但是前端传来的DTO中是不包含dish_id的(dish_id是dish插入数据库后,数据库自己生成的),这样我们需要把这个主键值回传回java代码中。
要实现这个需求,我们先看看DishMapper.xml是怎么写的:
DishMapper:
1 2 @AutoFill(OperationType.INSERT) void insert (Dish dish) ;
DishMapper.xml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 <?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" > <mapper namespace ="com.sky.mapper.DishMapper" > <insert id ="insert" parameterType ="com.sky.entity.Dish" useGeneratedKeys ="true" keyProperty ="id" > insert into dish(name, category_id, price, image, description, create_time, update_time, create_user, update_user, status) values ( #{name}, #{categoryId}, #{price}, #{image}, #{description}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser}, #{status} ) </insert > </mapper >
重点在于useGeneratedKeys=”true”以及keyProperty=”id”。前者表示:获取数据库生成的主键(如自增主键),然后将这个主键值回填到 Java 对象中 ,后者表示:MyBatis 会把数据库生成的主键值,赋值给 Java 对象中的哪个属性 。这里是 id,表示主键值将回填到 Dish 对象的 id 字段。
这样让我们再看service代码:
1 2 dishMapper.insert(dish); Long dishId=dish.getId();
前面调用insert,mapper通过回填把dish的id回填到java对象dish中,后面就可以直接调用getter把id取出来。
取出来之后,通过setter赋值,给口味赋值dishId。
最后调用dishFlavorMapper.insertBatch()方法把口味存到数据库。
DishFlavorMapper.xml如下(mapper接口就一句话,这里不单列出了):
1 2 3 4 5 6 7 8 9 10 11 12 <?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" > <mapper namespace ="com.sky.mapper.DishFlavorMapper" > <insert id ="insertBatch" > insert into dish_flavor (dish_id, name, value) VALUES <foreach collection ="flavors" item ="df" separator ="," > (#{df.dishId}, #{df.name}, #{df.value}) </foreach > </insert > </mapper >
这里使用了foreach,解释一下:
collection="flavors":表示要遍历的集合(参数中传入的 List 或数组);
item="df":每次遍历出来的单个元素,变量名为 df;
separator=",":每个项之间用英文逗号 , 连接 ,生成合法的 VALUES 语句;
#{df.xxx}:就是取出这个对象的字段值。
其他都好理解,因为我们传入了一个List,但separator=”,”是什么意思?
设置 separator="," 是为了构造合法的 SQL 语句,确保多个 VALUES 子句之间用逗号分隔:
如果你希望执行的是如下格式的 SQL:
1 2 3 4 insert into dish_flavor (dish_id, name, value ) VALUES (1 , '辣' , '微辣' ), (1 , '甜' , '中甜' ), (1 , '麻' , '中麻' );
可以看到多个 (值, 值, 值) 之间是用英文逗号 , 分隔的,separator=”,”正是让每次循环之间用逗号隔开,符合 SQL 写法。
现在新增菜品的业务代码已经全部实现了。
day3-10 菜品分页查询-需求分析
注意!!!返回数据中有一个叫做categoryName,但是我们菜品表中只有分类的ID,并没有存名称!
day3-11 菜品分页查询-代码开发 (多表查询, 外连接) 首先根据query字段封装DTO:
1 2 3 4 5 6 7 8 @Data public class DishPageQueryDTO implements Serializable { private int page; private int pageSize; private String name; private Integer categoryId; private Integer status; }
然后根据返回数据设计VO(因为要返回给前端的数据和目前的类都不匹配,也即categoryName属性在菜品表中并不存在,因此创建一个VO,VO字段和返回数据字段匹配):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Data @Builder @NoArgsConstructor @AllArgsConstructor public class DishVO implements Serializable { private Long id; private String name; private Long categoryId; private BigDecimal price; private String image; private String description; private Integer status; private LocalDateTime updateTime; private String categoryName; private List<DishFlavor> flavors = new ArrayList <>(); }
控制器:
1 2 3 4 5 6 @GetMapping("/page") @ApiOperation("菜品分页查询") public Result<PageResult> page (DishPageQueryDTO dishPageQueryDTO) { PageResult pageResult=dishService.pageQuery(dishPageQueryDTO); return Result.success(pageResult); }
复习一下PageResult:
1 2 3 4 5 6 7 @Data @AllArgsConstructor @NoArgsConstructor public class PageResult implements Serializable { private long total; private List records; }
我们可以看到有total和records,完美符合接口文档返回数据的设计,同时records中存的VO和接口文档返回数据也一致。
service,其实就是分页查询的模板了:
1 2 3 4 5 public PageResult pageQuery (DishPageQueryDTO dishPageQueryDTO) { PageHelper.startPage(dishPageQueryDTO.getPage(),dishPageQueryDTO.getPageSize()); Page<DishVO> page=dishMapper.pageQuery(dishPageQueryDTO); return new PageResult (page.getTotal(),page.getResult()); }
接下来要实现mapper,写mapper之前,我们可以先在查询终端写sql看看能不能查询出来:
1 select d.* ,c.name as categoryName from dish d left outer join category c on d.category_id = c.id
上面的sql语句可以正常执行。注意这里必须改别名为categoryName,不然mybatis无法进行封装。
下面我们来写mapper.xml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <select id ="pageQuery" resultType ="com.sky.vo.DishVO" parameterType ="com.sky.dto.DishPageQueryDTO" > select d.*,c.name as categoryName from dish d left outer join category c on d.category_id = c.id <where > <if test ="name!=null" > and d.name like concat('%',#{name},'%') </if > <if test ="categoryId!=null" > and d.category_id = #{categoryId} </if > <if test ="status!=null" > and d.status = #{status} </if > </where > order by d.create_time desc </select >
解释一下,Page<DishVO> page=dishMapper.pageQuery(dishPageQueryDTO);这句话意味着,mapper传入参数是DTO,返回值是VO。
这里传入的DTO其实就是查询的条件。如果DTO是空的就没查询条件;如果DTO只有status意味着只需要进行状态筛选。这个逻辑对应着mapper.xml中的3个if语句,例如<if test="categoryId!=null">and d.category_id = #{categoryId}</if>这句话,categoryId就是DTO的字段,category_id是数据库表字段。
至此代码编写完毕。
day3-12 删除菜品-需求分析
可以一次删除一个,也可以批量删除。起售中的菜品不能删除。被套餐关联的不能删除。被套餐关联的菜品不能删除。删除菜品后把口味也删除。
day3-13 删除菜品-代码开发 (@RequestParam, @PathVariable, @RestControllerAdvice) 控制器:
1 2 3 4 5 6 @DeleteMapping @ApiOperation("菜品批量删除") public Result delete (@RequestParam List<Long> ids) { dishService.deleteBatch(ids); return Result.success(); }
当 Spring 看到 @RequestParam List<Long> ids 时,会自动将 ids 里的字符串 1,2,3 转换为 List<Long>,即每个逗号分隔的数字会变成 Long 类型并放入 List 中。
注意区别@RequestParam和之前的@PathVariable:
1 2 3 4 5 6 @PostMapping("/status/{status}") @ApiOperation("启用禁用员工账号") public Result startOrStop (@PathVariable Integer status, Long id) { employeeService.startOrStop(status, id); return Result.success(); }
对比项
@PathVariable
@RequestParam
来源
路径参数 /user/{id}
查询参数 ?id=123
示例路径
/user/123
/user?id=123
service:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Transactional public void deleteBatch (List<Long> ids) { for (Long id : ids) { Dish dish=dishMapper.getByID(id); if (dish.getStatus().equals(StatusConstant.ENABLE)){ throw new DeletionNotAllowedException (MessageConstant.DISH_ON_SALE); } } List<Long> setmealIds=setmealDishMapper.getSetmealIdsByDishIds(ids); if (setmealIds!=null && setmealIds.size()>0 ){ throw new DeletionNotAllowedException (MessageConstant.DISH_BE_RELATED_BY_SETMEAL); } for (Long id : ids) { dishMapper.deleteById(id); dishFlavorMapper.deleteByDishId(id); } }
我们在写这个代码的时候有一个问题,为什么在service中throw了异常,且controller中没有try-catch却没有报错?
答案是我们用了全局异常捕获类sky-server/src/main/java/com/sky/handler/GlobalExceptionHandler.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @RestControllerAdvice @Slf4j public class GlobalExceptionHandler { @ExceptionHandler public Result exceptionHandler (BaseException ex) { log.error("异常信息:{}" , ex.getMessage()); return Result.error(ex.getMessage()); } @ExceptionHandler public Result exceptionHandler (SQLIntegrityConstraintViolationException ex) { String msg=ex.getMessage(); log.error("异常信息:{}" , ex.getMessage()); if (msg.contains("Duplicate entry" )){ String[] tmp=msg.split(" " ); String username=tmp[2 ]; String errMsg=username+" 已存在" ; return Result.error(errMsg); } else { return Result.error(MessageConstant.UNKNOWN_ERROR); } } }
其中这里BaseException extends RuntimeException,而我们的自定义异常类都继承BaseException。
BaseException也是我们自定义的,sky-common/src/main/java/com/sky/exception/BaseException.java。这样写就可以把所有自定义的运行时异常全部全局捕获。
如果你没有在 Controller 中显式地捕获异常,Spring 可以通过 @RestControllerAdvice 来集中处理所有控制器中的异常。@RestControllerAdvice 可以作为全局异常处理器,捕获控制器中抛出的异常,并返回统一的错误响应。
继续写mapper(套餐):
1 2 3 4 5 6 <select id ="getSetmealIdsByDishIds" resultType ="java.lang.Long" > select setmeal_id from setmeal_dish where dish_id in <foreach collection ="dishIds" item ="dishId" separator ="," open ="(" close =")" > #{dishId} </foreach > </select >
解释一下这个动态sql:
这段sql最终等效的sql应该是:select setmeal_id from setmeal_dish where dish_id in (1,2,3)
注意后面的(1,2,3),这里我们需要加括号(使用open和close),同时每个dishId用逗号分隔(separator)。
其余的deleteById等mapper接口,太简单,直接注解sql语句即可,这里略。
修改菜品day3-15 修改菜品-需求分析
复杂点主要在回显,修改很简单。
day3-16 修改菜品-查询接口开发 查询采用路径参数/{id},别忘了@PathVariable。
返回值由于和实体类差别较大,因此封装为VO对象,VO对象和返回值字段一一对应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Data @Builder @NoArgsConstructor @AllArgsConstructor public class DishVO implements Serializable { private Long id; private String name; private Long categoryId; private BigDecimal price; private String image; private String description; private Integer status; private LocalDateTime updateTime; private String categoryName; private List<DishFlavor> flavors = new ArrayList <>(); }
很简单,建议不看。
controller:
1 2 3 4 5 6 @ApiOperation("根据id查询菜品") @GetMapping("/{id}") public Result<DishVO> getById (@PathVariable Long id) { DishVO dishVO=dishService.getByIdWithFlavor(id); return Result.success(dishVO); }
service:
1 2 3 4 5 6 7 8 public DishVO getByIdWithFlavor (Long id) { Dish dish=dishMapper.getByID(id); List<DishFlavor> dishFlavors=dishFlavorMapper.getByDishId(id); DishVO dishVO=new DishVO (); BeanUtils.copyProperties(dish, dishVO); dishVO.setFlavors(dishFlavors); return dishVO; }
mapper:
1 2 3 4 @Select("select * from dish where id = #{id}") Dish getByID (Long id) ; @Select("select * from dish_flavor where dish_id = #{dishId}") List<DishFlavor> getByDishId (Long dishId) ;
开发完毕。
day3-17 修改菜品-修改接口开发 很简单,建议不看。
controller:
1 2 3 4 5 6 @PutMapping @ApiOperation("修改菜品") public Result update (@RequestBody DishDTO dishDTO) { dishService.updateWithFlavor(dishDTO); return Result.success(); }
service:
1 2 3 4 5 6 7 8 9 10 11 12 13 public void updateWithFlavor (DishDTO dishDTO) { Dish dish=new Dish (); BeanUtils.copyProperties(dishDTO, dish); dishMapper.update(dish); dishFlavorMapper.deleteByDishId(dishDTO.getId()); List<DishFlavor> flavors=dishDTO.getFlavors(); if (flavors!=null && flavors.size()>0 ){ flavors.forEach(dishFlavor -> { dishFlavor.setDishId(dishDTO.getId()); }); dishFlavorMapper.insertBatch(flavors); } }
逻辑:先把之前该菜品的口味全删了,然后重新批量插入。批量插入之前,设置dishId。
mapper(这里只写更新的):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <update id ="update" > update dish <set > <if test ="name != null" > name = #{name},</if > <if test ="categoryId != null" > category_id = #{categoryId},</if > <if test ="price != null" > price = #{price},</if > <if test ="image != null" > image = #{image},</if > <if test ="description != null" > description = #{description},</if > <if test ="status != null" > status = #{status},</if > <if test ="updateTime != null" > update_time = #{updateTime},</if > <if test ="updateUser != null" > update_user = #{updateUser},</if > </set > where id = #{id} </update >
都是老套路了,记住就行。
day4 套餐管理模块 略,没有新知识。
day5-2 Redis入门 Redis和Mysql都是数据库,但是Redis是内存存储(读写性能高),Mysql在磁盘存储。
Redis基于key-value存储数据。
Redis适合存储热点数据(相当短的时间内大量的访问)。
下载安装启动 redis直接解压即可使用。
启动方法:在项目目录下打开终端,输入 .\redis-server.exe .\redis.windows.conf
上面成功打开了服务端,现在打开客户端,新开终端:.\redis-cli.exe
当然客户端可以指定IP和Port:.\redis-cli.exe -h localhost -p 6379
现在通过修改配置文件 redis.windows.conf 修改密码,添加这一行:
现在再在客户端输入命令:
1 2 127 .0 .0 .1 :6379 > keys *(error) NOAUTH Authentication required.
发现无权限。我们通过-a参数输入密码打开客户端:
1 2 3 .\redis-cli.exe -a 123456 127.0.0.1:6379> keys * (empty list or set)
Redis也可以通过图形界面操作。下载Another-Redis-Desktop-Manager.1.5.5并安装,安装和连接都很简单,略。
点击左上角的终端符号进入终端:
在最底下有命令输入栏。
在这里可以选择数据库(总共有16个),注意不同数据库之间的数据完全隔离:
day5-3 Redis数据类型 Redis存储k-v数据类型,其中key是字符串,value对应5种常用数据类型:string,hash,list,set,zset (有序set)。
hash适合存对象(属性+值);list适用于有顺序的,如朋友圈,可以左插可以右插;set集合,可以计算并集交集等,如计算两人共同朋友;zset,适用于排行榜。
day5-4 Redis命令
字符串类型
1 2 3 SET key value GET key DEL key
1 2 3 4 > set name jack OK > get name jack
哈希类型
1 2 3 4 5 HSET key field value HGET key field HDEL key field HKEYS key HVALS key
1 2 3 4 5 6 7 8 9 10 11 12 13 14 > hset 100 name xiaoming 1 > hset 100 age 22 1 > hget 100 name xiaoming > hget 100 age 22 > hkeys 100 name age > hvals 100 xiaoming 22
列表类型
左侧为头部,右侧为尾部,下图帮助理解:
1 2 3 4 5 LPUSH key val1 [val2] LRANGE key start stop LRANGE key 0 -1 RPOP key LLEN key
1 2 3 4 5 6 7 8 9 10 11 > lpush mylist a b c 3 > lpush mylist d 4 > lrange mylist 0 -1 d c b a > rpop mylist a
集合类型
1 2 3 4 5 6 7 SADD key e1 [e2] SREM key e1 [e2] SMEMBERS key SCARD key SISMEMBER key e1 SINTER key1 key2 [key3] SUNION key1 key2 [key3]
1 2 3 4 5 6 7 8 9 > sadd set1 a b c d 4 > scard set1 4 > sadd set2 a b x y 4 > sinter set1 set2 b a
有序集合类型
1 2 3 4 ZADD key score1 member1 [score2 member2] ZRANGE key start stop [WITHSCORES] ZINCRBY key increment member ZREM key member1 [menber2]
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 > zadd zset1 10.0 a 1 > zadd zset1 10.5 b 1 > zadd zset1 10.2 c 1 > ZINCRBY zset1 5.0 a 15 > zrange zset1 0 -1 withscores c 10.199999999999999 b 10.5 a 15
通用命令
1 2 3 4 KEYS pattern EXISTS key TYPE key DEL key
1 2 3 4 5 6 7 8 9 10 > keys * zset1 set1 name set2 100 mylist > keys set * set1 set2
day5-10 在 Java 中操作 Redis 使用官方提供的 Spring Data Redis,它提供了便捷的封装和自动配置,让我们可以像操作普通对象一样使用 Redis。
步骤:引入maven,配置Redis数据源(application.yml),编写配置类,创建RedisTemplate对象,使用RedisTemplate对象操作Redis。
首先是maven:
1 2 3 4 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-data-redis</artifactId > </dependency >
配置数据源,application.yml:
1 2 3 4 5 6 7 8 9 10 11 server: port: 8080 spring: profiles: active: dev redis: host: ${sky.redis.host} port: ${sky.redis.port} password: ${sky.redis.password} database: ${sky.redis.database}
application-dev.yml:
1 2 3 4 5 6 sky: redis: host: localhost port: 6379 password: 123456 database: 10
编写配置类,sky-server/src/main/java/com/sky/config/RedisConfiguration.java,对key和value做序列化,RedisTemplate在这里被创建:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 @Configuration @Slf4j public class RedisConfiguration { @Bean public RedisTemplate redisTemplate (RedisConnectionFactory redisConnectionFactory) { RedisTemplate redisTemplate=new RedisTemplate <>(); redisTemplate.setConnectionFactory(redisConnectionFactory); redisTemplate.setKeySerializer(new StringRedisSerializer ()); Jackson2JsonRedisSerializer<Object> jacksonSerializer = new Jackson2JsonRedisSerializer <>(Object.class); redisTemplate.setValueSerializer(jacksonSerializer); redisTemplate.setKeySerializer(new StringRedisSerializer ()); redisTemplate.setHashKeySerializer(new StringRedisSerializer ()); redisTemplate.setHashValueSerializer(jacksonSerializer); redisTemplate.afterPropertiesSet(); return redisTemplate; } }
创建一个测试类sky-server/src/test/java/com/sky/test/SpringDataRedisTest.java,采用单元测试 JUnit 5(Jupiter)测试框架,自动注入RedisTemplate对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 package com.sky.test;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.data.redis.core.*;@SpringBootTest public class SpringDataRedisTest { @Autowired private RedisTemplate redisTemplate; @Test public void testRedisTemplate () { System.out.println(redisTemplate); ValueOperations valueOperations=redisTemplate.opsForValue(); HashOperations hashOperations=redisTemplate.opsForHash(); ListOperations listOperations=redisTemplate.opsForList(); SetOperations setOperations=redisTemplate.opsForSet(); ZSetOperations zSetOperations=redisTemplate.opsForZSet(); } @Test public void testString () { ValueOperations valueOperations=redisTemplate.opsForValue(); valueOperations.set("city" ,"北京beijing" ); System.out.println((String)valueOperations.get("city" )); } }
执行后成功在redis查找到存入的数据:
我们还可以继续测试 Hash 数据类型:
1 2 3 4 5 6 7 8 9 10 11 12 13 @Test public void testHash () { HashOperations hashOperations=redisTemplate.opsForHash(); hashOperations.put("100" ,"名字" ,"张哥" ); hashOperations.put("100" ,"年龄" ,"19" ); String name=(String)hashOperations.get("100" ,"名字" ); System.out.println(name); Set keys=hashOperations.keys("100" ); System.out.println(keys); List values=hashOperations.values("100" ); System.out.println(values); }
输出:
其他类型都差不多,略过了。
day5-15 店铺营业状态设置-需求分析
那么我们怎么存储这个营业状态呢?可以基于Redis的字符串存储!
key:SHOP_STATUS
value:0或1
day5-16 店铺营业状态设置-代码开发 先把测试类注释掉。
然后直接写控制器(因为逻辑简单,直接写在控制器里了):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @RestController @RequestMapping("/admin/shop") @Slf4j public class ShopController { @Autowired private RedisTemplate redisTemplate; @PutMapping("/{status}") public Result setStatus (@PathVariable Integer status) { redisTemplate.opsForValue().set("SHOP_STATUS" ,status); return Result.success(); } @GetMapping("/status") public Result<Integer> getStatus () { return Result.success((Integer)redisTemplate.opsForValue().get("SHOP_STATUS" )); } }
然后在用户端也创建一个控制器,这是我们的第一个用户端控制器:
1 2 3 4 5 6 7 8 9 10 11 12 13 @RestController("userShopController") @RequestMapping("/user/shop") @Slf4j public class ShopController { @Autowired private RedisTemplate redisTemplate; @GetMapping("/status") public Result<Integer> getStatus () { return Result.success((Integer)redisTemplate.opsForValue().get("SHOP_STATUS" )); } }
由于两个都叫ShopController,Spring管理Bean时候发现这两个Bean重名,因此冲突。我们解决方案就是加上@RestController(“userShopController”)。
现在我们修改一下swagger配置,因为现在用户和管理员的接口混在一起,不好区分,因此我们分成两页面:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 @Bean public Docket docket () { ApiInfo apiInfo = new ApiInfoBuilder () .title("苍穹外卖项目接口文档" ) .version("2.0" ) .description("苍穹外卖项目接口文档" ) .build(); Docket docket = new Docket (DocumentationType.SWAGGER_2) .groupName("管理端接口" ) .apiInfo(apiInfo) .select() .apis(RequestHandlerSelectors.basePackage("com.sky.controller.admin" )) .paths(PathSelectors.any()) .build(); return docket; } @Bean public Docket docket2 () { ApiInfo apiInfo = new ApiInfoBuilder () .title("苍穹外卖项目接口文档" ) .version("2.0" ) .description("苍穹外卖项目接口文档" ) .build(); Docket docket = new Docket (DocumentationType.SWAGGER_2) .groupName("用户端接口" ) .apiInfo(apiInfo) .select() .apis(RequestHandlerSelectors.basePackage("com.sky.controller.user" )) .paths(PathSelectors.any()) .build(); return docket; }
day6-2 HttpClient HttpClient 是一个用于在Java程序通过编码的方式发送 HTTP 请求的工具。
可以模拟发 GET、POST、PUT、DELETE 等请求,并接收请求的响应。
发送GET请求 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public void testGET () throws Exception{ CloseableHttpClient httpClient= HttpClients.createDefault(); HttpGet httpGet=new HttpGet ("http://localhost:8080/user/shop/status" ); CloseableHttpResponse response=httpClient.execute(httpGet); System.out.println("status code:" +response.getStatusLine().getStatusCode()); HttpEntity entity=response.getEntity(); String body=EntityUtils.toString(entity); System.out.println("data:" +body); response.close(); httpClient.close(); }
发送POST请求 需要用JSONObject构建一个json的请求。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public void testPOST () throws Exception{ CloseableHttpClient httpClient= HttpClients.createDefault(); HttpPost httpPost=new HttpPost ("http://localhost:8080/admin/employee/login" ); JSONObject jsonObject=new JSONObject (); jsonObject.put("username" ,"admin" ); jsonObject.put("password" ,"123456" ); StringEntity entity=new StringEntity (jsonObject.toString()); entity.setContentEncoding("utf-8" ); entity.setContentType("application/json" ); httpPost.setEntity(entity); CloseableHttpResponse response=httpClient.execute(httpPost); HttpEntity httpEntity=response.getEntity(); String body=EntityUtils.toString(httpEntity); System.out.println("data:" +body); response.close(); httpClient.close(); }
为了便于后续使用,封装为工具类:sky-common/src/main/java/com/sky/utils/HttpClientUtil.java。
day6-6 微信小程序开发-准备工作 首先注册。
已注册就右侧点击立即登录,完善信息。
保存小程序ID和密钥。
下载微信开发者工具。
点击加号新建小程序,选择如下:
在详情-本地设置中把以下选项打勾:
调试基础库调整为2.xx.xx:
小程序目录结构:
day6-8 微信小程序开发入门 wxml中的<view>标签,类似于html中的<div>标签。
Vue Template
WXML 语法
说明
<div>{{ msg }}</div>
<view>{{ msg }}</view>
只能使用小程序内置组件
v-if="ok"
wx:if="{{ok}}"
条件渲染
v-for="item in list"
wx:for="{{list}}"
列表渲染
@click="clickFn"
bindtap="clickFn"
事件绑定
js类似于vue:
1 2 3 4 5 6 7 8 9 10 11 12 13 Page ({ data : { msg : "Hello World" , items : [{id : 1 , name : "A" }, {id : 2 , name : "B" }] }, onLoad ( ) { console .log ("页面加载" ) }, clickFn ( ) { this .setData ({ msg : "Clicked!" }); } })
现在让我们写一个简单的小程序:
1 2 3 4 5 6 7 8 9 10 11 12 <view class ="container" > <text class ="title" > 欢迎来到小程序</text > <text class ="message" > {{message}}</text > <button bindtap ="changeList" > 更新列表</button > <view class ="list" > <view wx:for ="{{items}}" wx:key ="id" class ="item" > {{index + 1}}. {{item.name}} </view > </view > </view >
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 Page ({ data : { message : '初始列表如下:' , items : [ { id : 1 , name : '苹果' }, { id : 2 , name : '香蕉' }, { id : 3 , name : '橘子' } ] }, changeList ( ) { this .setData ({ message : '列表已更新:' , items : [ { id : 4 , name : '葡萄' }, { id : 5 , name : '西瓜' }, { id : 6 , name : '芒果' } ] }); } });
效果:
day6-15 微信登录 (JWT, HttpClient) 这里的开发者服务器就是指我们的后端。
后端服务器请求微信接口服务,需要使用之前的HttpClient。
自定义登录态:我们这里使用Token。
现在进入后端代码开发,首先配置微信小程序id和密钥:
1 2 3 4 sky: wechat: appid: ${sky.wechat.appid} secret: ${sky.wechat.secret}
配置用户端的jwt:
1 2 3 4 5 6 7 8 sky: jwt: admin-secret-key: itcast admin-ttl: 7200000 admin-token-name: token user-secret-key: itheima user-ttl: 7200000 user-token-name: authentication
接下来开始写控制器,在写控制器之前先确定参数和返回值,虽然这里参数只有一个code,但还是给封装到DTO中:
1 2 3 4 @Data public class UserLoginDTO implements Serializable { private String code; }
同时封装VO:
1 2 3 4 5 6 7 8 9 @Data @Builder @NoArgsConstructor @AllArgsConstructor public class UserLoginVO implements Serializable { private Long id; private String openid; private String token; }
创建controller:
这里User类的字段,和上面的数据库一致。
先调用service,获取User对象(获取openid),然后给用户创建jwt令牌token,随后把openid,token,用户主键封装为VO返回。
1 2 3 4 5 6 7 8 9 10 11 12 13 @PostMapping("/login") public Result<UserLoginVO> login (@RequestBody UserLoginDTO userLoginDTO) { User user = userService.wxLogin(userLoginDTO); Map<String, Object> claims=new HashMap <>(); claims.put(JwtClaimsConstant.USER_ID,user.getId()); String token=JwtUtil.createJWT(jwtProperties.getUserSecretKey(), jwtProperties.getUserTtl(), claims); UserLoginVO userLoginVO=UserLoginVO.builder() .id(user.getId()) .openid(user.getOpenid()) .token(token) .build(); return Result.success(userLoginVO); }
现在写service:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 @Service public class UserServiceImpl implements UserService { public static final String WX_LOGIN="https://api.weixin.qq.com/sns/jscode2session" ; @Autowired private WeChatProperties weChatProperties; @Autowired private UserMapper userMapper; public User wxLogin (UserLoginDTO userLoginDTO) { Map<String,String> map=new HashMap <>(); map.put("appid" ,weChatProperties.getAppid()); map.put("secret" ,weChatProperties.getSecret()); map.put("js_code" , userLoginDTO.getCode()); map.put("grant_type" , "authorization_code" ); String json= HttpClientUtil.doGet(WX_LOGIN,map); JSONObject jsonObject= JSON.parseObject(json); String openid=jsonObject.getString("openid" ); if (openid==null ){ throw new LoginFailedException (MessageConstant.LOGIN_FAILED); } User user=userMapper.getByOpenid(openid); if (user==null ){ user=User.builder() .openid(openid) .createTime(LocalDateTime.now()) .build(); userMapper.insert(user); } return user; } }
首先构造请求向微信的api通过HttpClient发送,返回并提取openid,然后判断该用户是否在数据库存在,如果不存在就插入用户数据,如果存在直接返回user。
mapper代码略。
现在通过小程序来测试一下登录:
最后,我们需要创建JWT拦截器,用于身份的校验。首先在interceptor包下面创建一个JwtTokenUserInterceptor:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 package com.sky.interceptor;import com.sky.constant.JwtClaimsConstant;import com.sky.context.BaseContext;import com.sky.properties.JwtProperties;import com.sky.utils.JwtUtil;import io.jsonwebtoken.Claims;import lombok.extern.slf4j.Slf4j;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Component;import org.springframework.web.method.HandlerMethod;import org.springframework.web.servlet.HandlerInterceptor;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;@Component @Slf4j public class JwtTokenUserInterceptor implements HandlerInterceptor { @Autowired private JwtProperties jwtProperties; public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true ; } String token = request.getHeader(jwtProperties.getUserTokenName()); try { log.info("jwt校验:{}" , token); Claims claims = JwtUtil.parseJWT(jwtProperties.getUserSecretKey(), token); Long userId = Long.valueOf(claims.get(JwtClaimsConstant.USER_ID).toString()); log.info("当前用户id:" , userId); BaseContext.setCurrentId(userId); return true ; } catch (Exception ex) { response.setStatus(401 ); return false ; } } }
然后在配置类WebMvcConfiguration中进行注册:
1 2 3 4 5 6 7 8 9 10 protected void addInterceptors (InterceptorRegistry registry) { log.info("开始注册自定义拦截器..." ); registry.addInterceptor(jwtTokenAdminInterceptor) .addPathPatterns("/admin/**" ) .excludePathPatterns("/admin/employee/login" ); registry.addInterceptor(jwtTokenUserInterceptor) .addPathPatterns("/user/**" ) .excludePathPatterns("/user/user/login" ) .excludePathPatterns("/user/shop/status" ); }
day6-19 商品浏览-代码导入 产品原型:
导入代码(略)
day7-2 缓存菜品-代码开发 (Redis)
key:分类ID,dish_categoryId
value:字符串,每个分类下的菜品List
菜品有变更的时候需要及时清理缓存数据。
现在让我们开始实现代码:
首先是user下的DishController:
先注入RedisTemplate:
1 2 @Autowired private RedisTemplate redisTemplate;
改造控制器逻辑!!!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @GetMapping("/list") @ApiOperation("根据分类id查询菜品") public Result<List<DishVO>> list (Long categoryId) { String key="dish_" +categoryId; 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); list = dishService.listWithFlavor(dish); redisTemplate.opsForValue().set(key,list); return Result.success(list); }
然后测试。测试过程中出现报错:Jackson 默认 不支持 Java 8 时间类(如 LocalDateTime)的序列化与反序列化,Redis 序列化时使用的是默认的 ObjectMapper,从而无法识别 LocalDateTime。
1 com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date /time type java.time.LocalDateTime not supported by default : add Module "com.fasterxml.jackson.datatype:jackson -datatype -jsr310 " to enable handling (through reference chain : java.util.ArrayList [0]->com.sky.vo.DishVO ["updateTime "])
解决方案:我们之前写过一个自定义的 ObjectMapper :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 public class JacksonObjectMapper extends ObjectMapper { public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd" ; public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm" ; public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss" ; public JacksonObjectMapper () { super (); this .configure(FAIL_ON_UNKNOWN_PROPERTIES, false ); this .getDeserializationConfig().withoutFeatures(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); SimpleModule simpleModule = new SimpleModule () .addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer (DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT))) .addDeserializer(LocalDate.class, new LocalDateDeserializer (DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT))) .addDeserializer(LocalTime.class, new LocalTimeDeserializer (DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT))) .addSerializer(LocalDateTime.class, new LocalDateTimeSerializer (DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT))) .addSerializer(LocalDate.class, new LocalDateSerializer (DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT))) .addSerializer(LocalTime.class, new LocalTimeSerializer (DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT))); this .registerModule(simpleModule); } }
在Redis配置中,手动传入自定义的 ObjectMapper 给 Jackson2JsonRedisSerializer即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Bean public RedisTemplate redisTemplate (RedisConnectionFactory redisConnectionFactory) { RedisTemplate redisTemplate=new RedisTemplate <>(); redisTemplate.setConnectionFactory(redisConnectionFactory); redisTemplate.setKeySerializer(new StringRedisSerializer ()); ObjectMapper objectMapper = new JacksonObjectMapper (); Jackson2JsonRedisSerializer<Object> jacksonSerializer = new Jackson2JsonRedisSerializer <>(Object.class); jacksonSerializer.setObjectMapper(objectMapper); redisTemplate.setValueSerializer(jacksonSerializer); redisTemplate.setHashKeySerializer(new StringRedisSerializer ()); redisTemplate.setHashValueSerializer(jacksonSerializer); redisTemplate.afterPropertiesSet(); return redisTemplate; }
现在,有一个问题,缓存和数据库的数据可能出现不一致的问题 ,当管理员修改/停售/菜品/删除菜品的时候,redis缓存的数据需要被清除!!
因此还需要修改管理端的controller:
以新增菜品举例:
1 2 3 4 5 6 7 8 @PostMapping @ApiOperation("新增菜品") public Result save (@RequestBody DishDTO dishDTO) { dishService.saveWithFlavor(dishDTO); String key="dish_" +dishDTO.getCategoryId(); redisTemplate.delete(key); return Result.success(); }
其实这里可以抽取这个公共方法,然后采用面向切片编程,时间紧张,先略过。
day7-6 Spring Cache 框架使用 基于注解 的缓存功能,只需要加一个注解就可以实现缓存。
要使用需要导入maven。
注解
功能
@EnableCaching
开启 Spring 缓存功能,通常在启动类
@Cacheable
方法执行前查询缓存是否有数据,如果有就返回缓存数据,如果没有就调用方法并把返回值放到缓存中
@CachePut
执行方法并把返回值放入缓存(更新缓存)
@CacheEvict
清除一条或多条缓存
@Caching
组合多个缓存注解使用
让我们结合一个示例工程了解一下用法。第一步在启动类上加@EnableCaching,代码就不贴了。
第二步就是在controller上面加注解:
1 2 3 4 5 6 7 8 @PostMapping @CachePut(cacheNames = "userCache", key = "#user.id") public User save (@RequestBody User user) { userMapper.insert(user); return user; }
测试结果:redis和mysql都被插入这条数据。
接下来使用@Cacheable,在查询的controller上面加上该注解:
1 2 3 4 5 6 @GetMapping @Cacheable(cacheNames = "userCache", key = "#id") public User getById (Long id) { User user = userMapper.getById(id); return user; }
测试结果:成功从缓存查询。如果数据库有数据,缓存没有数据,会调用方法把数据放到缓存里。如果在缓存能查到,他压根不会执行控制器方法。
删除也加上:
1 2 3 4 5 6 7 8 9 10 11 @DeleteMapping @CacheEvict(cacheNames = "userCache", key = "#id") public void deleteById (Long id) { userMapper.deleteById(id); } @DeleteMapping("/delAll") @CacheEvict(cacheNames = "userCache", allEntries = true) public void deleteAll () { userMapper.deleteAll(); }
day7-11 缓存套餐-代码开发 (Spring Cache) com/sky/controller/user /SetmealController.java:
1 2 3 4 5 6 7 8 9 10 11 @GetMapping("/list") @ApiOperation("根据分类id查询套餐") @Cacheable(cacheNames = "setmealCache",key = "#categoryId") public Result<List<Setmeal>> list (Long categoryId) { Setmeal setmeal = new Setmeal (); setmeal.setCategoryId(categoryId); setmeal.setStatus(StatusConstant.ENABLE); List<Setmeal> list = setmealService.list(setmeal); return Result.success(list); }
com/sky/controller/admin /SetmealController.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 @PostMapping @CacheEvict(cacheNames = "setmealCache", key = "#setmealDTO.categoryId") public Result save (@RequestBody SetmealDTO setmealDTO) { setmealService.saveWithDish(setmealDTO); return Result.success(); } @DeleteMapping @CacheEvict(cacheNames = "setmealCache", allEntries = true) public Result delete (@RequestParam List<Long> ids) { setmealService.delete(ids); return Result.success(); }
其余代码略。
day7-13 添加购物车
可能传入菜品(无口味),菜品(有口味),套餐,因此接口设计:
设计一下购物车的数据库表,每个用户的购物车需要区分开来(注意购物车也需要持久化,因为我可能查询历史购物信息,还有就是如果我还没点完退出了,下一次登录,我应该还能保留上次没点完的继续点):
现在开始代码开发,DTO已经封装完毕,字段和Body一致,略。
控制器很简单,直接调用service就结束了,略。
服务层,注意userId是通过BaseContext拿到的,也就是jwt解析token并放到ThreadLocal中,我们可以直接取。
代码就不写了。看一下结果:
注意同一个菜品不同口味是分开不同项的。
day7-18 查看购物车-清空购物车 查看购物车和清空购物车完全不需要传入参数,因为用户ID通过ThreadLocal即可获取。
1 2 3 4 5 6 7 public List<ShoppingCart> showShoppingCart () { Long userId=BaseContext.getCurrentId(); ShoppingCart shoppingCart=ShoppingCart.builder() .userId(userId) .build(); return shoppingCartMapper.list(shoppingCart); }
其他代码都很简单,略。
day8-2 地址簿代码导入
导入略。
day8-5 用户下单
只给service代码,其他略:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 @Transactional public OrderSubmitVO submitOrder (OrdersSubmitDTO ordersSubmitDTO) { AddressBook addressBook=addressBookMapper.getById(ordersSubmitDTO.getAddressBookId()); if (addressBook==null ){ throw new AddressBookBusinessException (MessageConstant.ADDRESS_BOOK_IS_NULL); } Long userId= BaseContext.getCurrentId(); ShoppingCart shoppingCart=new ShoppingCart (); shoppingCart.setUserId(userId); List<ShoppingCart> shoppingCartList=shoppingCartMapper.list(shoppingCart); if (shoppingCartList==null ||shoppingCartList.size()==0 ){ throw new ShoppingCartBusinessException (MessageConstant.SHOPPING_CART_IS_NULL); } Orders orders=new Orders (); BeanUtils.copyProperties(ordersSubmitDTO,orders); orders.setOrderTime(LocalDateTime.now()); orders.setPayStatus(Orders.UN_PAID); orders.setStatus(Orders.PENDING_PAYMENT); orders.setNumber(String.valueOf(System.currentTimeMillis())); orders.setPhone(addressBook.getPhone()); orders.setConsignee(addressBook.getConsignee()); orders.setUserId(userId); orderMapper.insert(orders); List<OrderDetail> orderDetailList=new ArrayList <>(); for (ShoppingCart cart:shoppingCartList){ OrderDetail orderDetail=new OrderDetail (); BeanUtils.copyProperties(cart,orderDetail); orderDetail.setOrderId(orders.getId()); orderDetailList.add(orderDetail); } orderDetailMapper.insertBatch(orderDetailList); shoppingCartMapper.deleteByUserId(userId); OrderSubmitVO orderSubmitVO=OrderSubmitVO.builder() .id(orders.getId()) .orderTime(orders.getOrderTime()) .orderNumber(orders.getNumber()) .orderAmount(orders.getAmount()) .build(); return orderSubmitVO; }
day8-12 微信支付 (cpolar 内网穿透) 这个我们实现不了因为拿不到证书。
5.后端调用微信下单接口:
9.用户确认支付,前端调用 wx.requestPayment,此时小程序界面就弹出支付界面。
13.微信后台向后端发出回调接口,14.后端更新订单已完成状态。
接下来介绍cpolar:将本地服务映射到公网,使外部可以访问本地服务器。
常用于本地写了个微信/支付宝支付接口,提供给第三方回调这种场景。
概念
说明
隧道(Tunnel)
映射规则:本地端口 ➝ 公网地址
公网地址
系统自动生成的 xxxxx.cpolar.cn 地址
临时/固定地址
免费版每天地址变化,专业版可自定义固定域名
Web UI 控制台
访问 localhost:9200 可查看隧道状态
使用方法:下载安装,在官网找到隧道Authtoken复制,到安装目录打开cmd:
1 2 ./cpolar.exe authtoken ******** Authtoken saved to configuration file: C:\Users\30516/.cpolar/cpolar.yml
接下来输入(8080取决于我们后端的端口号):
出现这样的界面:
现在我们就可以通过上面的域名访问了,比如我们访问接口文档:苍穹外卖项目接口文档 ,是能够正常访问的
在配置微信支付的时候,回调的网址就可以以这个为前缀。
我们拿不到证书,直接改前端代码,按支付按钮直接跳转到支付成功界面即可。
day10-1 订单定时处理 (SpringTask)
CRON表达式手写较困难,一般采用AI生成。
接下来让我们进入定时处理的开发。
用户下单后会出现两种情况,用户下单后一直未支付 ,完成后管理端一直不点完成 。前者我们可以每分钟检查一次,后者一天检查一次(凌晨检查)。
这里不需要接口设计,和前端无关。
注意要加@Component注解,代码实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 @Component @Slf4j public class OrderTask { @Autowired private OrderMapper orderMapper; @Scheduled(cron = "0 * * * * ?") public void processTimeoutOrder () { LocalDateTime time = LocalDateTime.now().plusMinutes(-15 ); List<Orders> ordersList = orderMapper.getByStatusAndOrderTimeLT(Orders.PENDING_PAYMENT, time); if (ordersList != null && !ordersList.isEmpty()) { for (Orders order : ordersList) { order.setStatus(Orders.CANCELLED); order.setCancelReason("订单超时未支付,已自动取消" ); order.setCancelTime(LocalDateTime.now()); orderMapper.update(order); } } } @Scheduled(cron = "0 0 1 * * ?") public void processDeliveryOrder () { LocalDateTime time = LocalDateTime.now().plusMinutes(-60 ); List<Orders> ordersList = orderMapper.getByStatusAndOrderTimeLT(Orders.DELIVERY_IN_PROGRESS, time); if (ordersList != null && !ordersList.isEmpty()) { for (Orders order : ordersList) { order.setStatus(Orders.COMPLETED); orderMapper.update(order); } } } }
day10-7 来单提醒-客户催单 (WebSocket) WebSocket是基于TCP的网络协议,实现浏览器和服务器的全双工通信:一次握手,持久连接,进行双向数据传输。适合实现聊天软件等。
使用websocket需要导入maven。
配置:
1 2 3 4 5 6 7 @Configuration public class WebSocketConfiguration { @Bean public ServerEndpointExporter serverEndpointExporter () { return new ServerEndpointExporter (); } }
简单的示例websocket服务类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @Component @ServerEndpoint("/ws/{sid}") public class WebSocketServer { private static Map<String, Session> sessionMap = new HashMap (); @OnOpen public void onOpen (Session session, @PathParam("sid") String sid) { sessionMap.put(sid, session); } @OnMessage public void onMessage (String message, @PathParam("sid") String sid) { } @OnClose public void onClose (@PathParam("sid") String sid) { sessionMap.remove(sid); } public void sendToAllClient (String message) { } }
下面我们正式开始开发来单提醒+客户催单代码:
websocket服务类不变。
修改service:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public void paySuccess (String outTradeNo) { Orders ordersDB = orderMapper.getByNumber(outTradeNo); Orders orders = Orders.builder() .id(ordersDB.getId()) .status(Orders.TO_BE_CONFIRMED) .payStatus(Orders.PAID) .checkoutTime(LocalDateTime.now()) .build(); orderMapper.update(orders); Map map=new HashMap <>(); map.put("type" ,1 ); map.put("orderId" ,ordersDB.getId()); map.put("content" ,"订单号: " +outTradeNo); String json=JSON.toJSONString(map); websocketService.sendToAllClient(json); }
上面解决了来单提醒,下面开发客户催单。用户可以按一个按钮来进行催单,该按钮的api如下:
1 2 3 4 5 @GetMapping("/reminder/{id}") public Result reminder (@PathVariable Long id) { orderService.reminder(id); return Result.success(); }
service中调用websocket,对后台管理客户端发出消息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public void reminder (Long id) { Orders ordersDB = orderMapper.getById(id); if (ordersDB == null ) { throw new OrderBusinessException (MessageConstant.ORDER_STATUS_ERROR); } Map map=new HashMap <>(); map.put("type" ,2 ); map.put("orderId" ,id); map.put("content" ,"订单号: " +ordersDB.getNumber()); String json=JSON.toJSONString(map); websocketService.sendToAllClient(json); }
代码实现完毕。
day11-1 后台数据统计 (Apache ECharts) Apache ECharts是前端技术,是一个由 Apache 软件基金会孵化和维护的 开源、纯 JavaScript 实现的可视化图表库 ,用于在网页中绘制丰富、交互性强的数据可视化图表。链接:快速上手 - 使用手册 - Apache ECharts
这块没啥新东西,纯无聊,只要大三数据库好好写这些sql都会写。
也就这个sql能讲讲:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <select id ="sumByMap" resultType ="java.lang.Double" > select sum(amount) from orders <where > <if test ="status != null" > and status = #{status} </if > <if test ="begin != null" > and order_time > = #{begin} </if > <if test ="end != null" > and order_time < = #{end} </if > </where > </select >
这里的符号是转义字符,是大于小于的意思。意思就是把这段时间内,status是status的营业额相加。
day12-5 导出运营数据Excel报表 (Apache POI) POI可以让我们在java程序中对Office文件进行读写,一般都是操作excel文件。
我们一般不会从0开始构建一个excel,而是做一个模板放在template文件夹下,然后往这个模板里面填数字。
模板:
控制器:
1 2 3 4 5 @GetMapping("/export") @ApiOperation("导出运营数据报表") public void export (HttpServletResponse response) { reportService.exportBusinessData(response); }
这里参数要获取HttpServletResponse response,因为:
1 2 ServletOutputStream out = response.getOutputStream();excel.write(out);
response.getOutputStream():获取一个 ServletOutputStream,这是一个专门用于 HTTP 响应的输出流。
excel.write(out):将生成的 Excel 文件内容写入到输出流中,从而将文件发送给客户端。
service实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 public void exportBusinessData (HttpServletResponse response) { LocalDate begin = LocalDate.now().minusDays(30 ); LocalDate end = LocalDate.now().minusDays(1 ); BusinessDataVO businessData = workspaceService.getBusinessData(LocalDateTime.of(begin,LocalTime.MIN), LocalDateTime.of(end, LocalTime.MAX)); InputStream inputStream = this .getClass().getClassLoader().getResourceAsStream("template/运营数据报表模板.xlsx" ); try { XSSFWorkbook excel = new XSSFWorkbook (inputStream); XSSFSheet sheet = excel.getSheet("Sheet1" ); sheet.getRow(1 ).getCell(1 ).setCellValue(begin + "至" + end); XSSFRow row = sheet.getRow(3 ); row.getCell(2 ).setCellValue(businessData.getTurnover()); row.getCell(4 ).setCellValue(businessData.getOrderCompletionRate()); row.getCell(6 ).setCellValue(businessData.getNewUsers()); row = sheet.getRow(4 ); row.getCell(2 ).setCellValue(businessData.getValidOrderCount()); row.getCell(4 ).setCellValue(businessData.getUnitPrice()); for (int i = 0 ; i < 30 ; i++) { LocalDate date = begin.plusDays(i); businessData = workspaceService.getBusinessData(LocalDateTime.of(date,LocalTime.MIN), LocalDateTime.of(date, LocalTime.MAX)); row = sheet.getRow(7 + i); row.getCell(1 ).setCellValue(date.toString()); row.getCell(2 ).setCellValue(businessData.getTurnover()); row.getCell(3 ).setCellValue(businessData.getValidOrderCount()); row.getCell(4 ).setCellValue(businessData.getOrderCompletionRate()); row.getCell(5 ).setCellValue(businessData.getUnitPrice()); row.getCell(6 ).setCellValue(businessData.getNewUsers()); } ServletOutputStream out = response.getOutputStream(); excel.write(out); out.flush(); out.close(); excel.close(); }catch (IOException e){ e.printStackTrace(); } }
苍穹外卖结束。