1.JWT令牌认证
JSON Web Token (JWT)是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的。
JWT是目前最常用的一种令牌规范,它最常用于保存用户的登录信息。
JWT与Session的差异 相同点是,它们都是存储用户信息;然而,Session是在服务器端的,而JWT是在客户端的。
Session方式存储用户信息的最大问题在于要占用大量服务器内存,增加服务器的开销。
而JWT方式将用户状态分散到了客户端中,可以明显减轻服务端的内存压力。
Session的状态是存储在服务器端,客户端只有session id;而Token的状态是存储在客户端。
那么我们是如何实现的呢,我们只看他的后端代码:
public class TestUse {public static void main(String[] args) {Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);String jws = Jwts.builder().setSubject("NB").signWith(key).compact();System.out.println(jws);}
}
- 根据指定的签名算法,安全随机生成一个SecretKey
- 建立一个JWT,它将 sub(主题)设置为NB,NB是密钥,不能泄露。
- 使用适用于HMAC-SHA-256算法的密钥对JWT进行签名。
- 最后compact将其压缩成最终String形式。签名的JWT称为“ JWS”。
我们一般都会对其进行封装,下面是调用代码:
Map<String, Object> claims = new HashMap<>();claims.put(JwtClaimsConstant.EMP_ID, employee.getId());String token = JwtUtil.createJWT(jwtProperties.getAdminSecretKey(),jwtProperties.getAdminTtl(),claims);
封装的创建JWT令牌方法:
public static String createJWT(String secretKey, long ttlMillis, Map<String, Object> claims) {// 指定签名的时候使用的签名算法,也就是header那部分SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;// 生成JWT的时间long expMillis = System.currentTimeMillis() + ttlMillis;Date exp = new Date(expMillis);// 设置jwt的bodyJwtBuilder builder = Jwts.builder()// 如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的.setClaims(claims)// 设置签名使用的签名算法和签名使用的秘钥.signWith(signatureAlgorithm, secretKey.getBytes(StandardCharsets.UTF_8))// 设置过期时间.setExpiration(exp);return builder.compact();}
2.Ngnix服务器
如何实现负载均衡?
下面是我们在nginx中的配置:我们可以看到,负载均衡用的也是proxy_pass
,即也是基于反向代理实现的
3.VSCode编译器
这个软件是用于开发前端代码的,但其也支持Python、Java的扩展,功能十分强大。
4.MD5明文加密
我们看到,数据库中的密码是以明文形式存储的,这是十分危险的,因此我们需要对其进行加密。
我们使用的加密算法便是MD5加密方式。
MD5(Message Digest Algorithmn)是一种广泛使用的密码散列函数,用于生成一个 128
位的散列值,以确保信息传输的完整性和一致性1。虽然它理论上是一种不可逆的加密算法,但我们可以使用在线工具来进行 MD5 加密和解密。
它可以将一段字符串转换为32位的加密字符,注意,这个过程理论上是不可逆的
,即只能将字符串转换为加密字符而不能将加密字符转换回来,那么,我们的登录逻辑事实上就是将我们输入的密码也进行MD5加密生成密文,在与数据库中的密文进行比较,如果相同,则认为登录成功,否则登录失败。
那么,在我们的代码中该如何实现呢,具体需要修改两个地方:
第一个就是存储密码时(注册时)对密码进行加密并插入数据库;
另一个则是登录时对密码进行MD5加密,将加密后的字符与数据库中的密文进行比较。
首先是新增用户流程:
Controller层,这里使用了RESTful风格,同时传入转换为JSON格式的员工对象employeeDTO
@PostMapping@ApiOperation("新增员工")public Result save(@RequestBody EmployeeDTO employeeDTO) {log.info("新增员工:{}",employeeDTO);employeeService.save(employeeDTO);return Result.success();}
随后在Service(业务层)的代码如下:
public void save(EmployeeDTO employeeDTO) {Employee employee = new Employee();//对象属性拷贝BeanUtils.copyProperties(employeeDTO, employee);//设置账号的状态,默认正常状态 1表示正常 0表示锁定employee.setStatus(StatusConstant.ENABLE);//设置密码,默认密码123456employee.setPassword(DigestUtils.md5DigestAsHex(PasswordConstant.DEFAULT_PASSWORD.getBytes()));//设置当前记录的创建时间和修改时间employee.setCreateTime(LocalDateTime.now());employee.setUpdateTime(LocalDateTime.now());// 通过ThreadLocal获取用户信息Long currentId = BaseContext.getCurrentId();//设置当前记录创建人id和修改人idemployee.setCreateUser(currentId);//目前写个假数据,后期修改employee.setUpdateUser(currentId);employeeMapper.insert(employee);//后续步骤定义}
由于添加用户的界面中没有设置密码框,因此使用默认密码employee.setPassword(DigestUtils.md5DigestAsHex(PasswordConstant.DEFAULT_PASSWORD.getBytes()));
随后便是数据持久化层将数据添加到数据库中了。
<insert id="insert">insert into employee (name, username, password, phone, sex, id_number, create_time, update_time, create_user, update_user,status) values (#{name},#{username},#{password},#{phone},#{sex},#{idNumber},#{createTime},#{updateTime},#{createUser},#{updateUser},#{status})
</insert>
以上是添加员工的步骤,那么登录该如何做呢?Controller做了三个工作,分别是查询员工信息,生成JWT
令牌以及返还员工信息。我们只关注查询用户信息即可,调用的是service
层的login
方法
@PostMapping("/login")public Result<EmployeeLoginVO> login(@RequestBody EmployeeLoginDTO employeeLoginDTO) {log.info("员工登录:{}", employeeLoginDTO);Employee employee = employeeService.login(employeeLoginDTO);//登录成功后,生成jwt令牌Map<String, Object> claims = new HashMap<>();claims.put(JwtClaimsConstant.EMP_ID, employee.getId());String token = JwtUtil.createJWT(jwtProperties.getAdminSecretKey(),jwtProperties.getAdminTtl(),claims);EmployeeLoginVO employeeLoginVO = EmployeeLoginVO.builder().id(employee.getId()).userName(employee.getUsername()).name(employee.getName()).token(token).build();return Result.success(employeeLoginVO);}
login方法的实现:这里将一些情况做了异常处理,即直接抛出异常即可。同时,Spring中为我们提供了MD5的加密类,我们调用其内的加密方法即可。
public Employee login(EmployeeLoginDTO employeeLoginDTO) {String username = employeeLoginDTO.getUsername();String password = employeeLoginDTO.getPassword();//1、根据用户名查询数据库中的数据Employee employee = employeeMapper.getByUsername(username);//2、处理各种异常情况(用户名不存在、密码不对、账号被锁定)if (employee == null) {//账号不存在throw new AccountNotFoundException(MessageConstant.ACCOUNT_NOT_FOUND);}//密码比对// TODO 后期需要进行md5加密,然后再进行比对password = DigestUtils.md5DigestAsHex(password.getBytes());if (!password.equals(employee.getPassword())) {//密码错误throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR);}if (employee.getStatus() == StatusConstant.DISABLE) {//账号被锁定throw new AccountLockedException(MessageConstant.ACCOUNT_LOCKED);}//3、返回实体对象return employee;}
5.接口文档管理工具
这个接口文档是前后端人员经过多次讨论与研究确定下来的,同时这个接口在开发过程中可能也会发现一些问题,因此在开发过程中也会进行修改。
这里我们可以看到资料中有两个接口文档,都是JSON格式的,那么我们该如何查看呢,我们使用的是YAPI
这个网站工具。
5.1 YApi接口管理工具
YApi是通过项目的方式来进行接口管理的,因此 我们需要先创建一个项目,随后我们点击数据管理,选择导入的数据格式。
随后我们点击接口,就可以看到我们的接口了。这是方便我们查看的工具。
5.2 Apifox接口管理工具
同时,我们在这里推荐一个功能更为强大的接口管理工具Apifox。
(它提供了网站与软件两种形式,我们使用网站版即可)
可以看到,这里面集成了Postman、Swagger等工具的功能
这里我们选择YApi的形式。其支持多种格式。
我们开发完后端接口后,先前都是通过Postman
进行测试的,但是当接口过多时,该如何处理呢,这时候便可以使用Swagger
工具了。
6.Swagger接口管理工具
通过Swagger可以生成后端接口文档,同时可以实现在线接口调试。
1.导入knife4j
的maven
坐标
2.在配置类中加入 knife4j
相关配置
@Beanpublic 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;}
3.设置静态资源映射,否则接口文档页面无法访问
protected void addResourceHandlers(ResourceHandlerRegistry registry) {log.info("开始设置静态资源映射...");registry.addResourceHandler("/doc.html").addResourceLocations("classpath:/META-INF/resources/");//代表发送/doc.html请求时将其映射到classpath:/META-INF/resources/下registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");}
将上面的内容配置好后,我们将项目重新启动,可以看到,在WebMVCConfiguration
下面的这些方法全部被执行了。
这一切都是因为这个文件被我们加了@Configuration注解的原因,将其当作配置文件加载了。
随后我们访问我们的文档接口地址:localhost:8080/doc.html
,我们就可以访问到Swagger为我们动态创建的接口管理页面了。
通过 Swagger 就可以生成接口文档,那么我们就不需要 Yapi 了?
- Yapi是设计阶段使用的工具,管理和维护接口
- swagger 在开发阶段使用的框架,帮助后端开发人员做后端的接口测试
通过Swagger
注解可以控制生成的接口文档,使接口文档拥有更好的可读性,常用注解如下:
那么,这个Swagger我们该怎么用呢,只需要打开要测试的接口,然后点击调试即可:
这时,我们发现并没有期望的响应值,反而出现了状态码401
,这是什么原因呢,这是因为我们的拦截器设置需要检验身份令牌所导致的。这个拦截器接口是Spring
写好的,我们只需要实现这个即可即可,里面的preHandle
方法代表在每次操作前执行。
@Component
@Slf4j
public class JwtTokenAdminInterceptor implements HandlerInterceptor {@Autowiredprivate JwtProperties jwtProperties;/*** 校验jwt** @param request* @param response* @param handler* @return* @throws Exception*/public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//判断当前拦截到的是Controller的方法还是其他资源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);
// 将用户id存储到ThreadLocalBaseContext.setCurrentId(empId);//3、通过,放行return true;} catch (Exception ex) {//4、不通过,响应401状态码response.setStatus(401);return false;}}
}
下面给出其实现的三个方法。
@Component
public class ProjectInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {System.out.print("preHandle\n");return true;}@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {System.out.print("postHandle\n");}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {System.out.print("afterCompletion\n");}
}
了解了原因后,我们该如何解决呢,只需要设置一个全局参数,里面的内容是JWT令牌即可。
我们首先使用员工登录接口创建一个合法的身份令牌:
随后将这个令牌设置为全局参数:
随后再次执行刚刚的功能:
7.实体类POJO的划分
我们在之前的开发过程中,都会使用到实体类,如用户类,订单类等,我们称这些实体类为POJO,但是随着开发的不断规范,POJO也有了 新的划分 。
VO(Value Object)值对象
VO就是展示用的数据,不管展示方式是网页,还是客户端,还是APP,只要是这个东西是让人看到的,这就叫VO。VO主要的存在形式就是js里面的对象(也可以简单理解成json)
PO(Persistant Object)持久对象
PO比较好理解,简单说PO就是数据库中的记录,一个PO的数据结构对应着库中表的结构,表中的一条记录就是一个PO对象,通常PO里面除了get,set之外没有别的方法。
BO(Business Object)业务对象
BO就是PO的组合,即多个PO的集合即为BO
更为详尽的解释如下:
VO(View Object):
视图对象,用于展示层,它的作用是把某个指定页面(或组件)的所有数据封装起来。DTO(Data Transfer Object):
数据传输对象,这个概念来源于J2EE的设计模式,原来的目的是为了EJB的分布式应用提供粗粒度的数据实体,以减少分布式调用的次数,从而提高分布式调用的性能和降低网络负载,但在这里,我泛指用于展示层与服务层之间的数据传输对象。DO(Domain Object):
领域对象,就是从现实世界中抽象出来的有形或无形的业务实体。PO(Persistent Object):
持久化对象,它跟持久层(通常是关系型数据库)的数据结构形成一一对应的映射关系,如果持久层是关系型数据库,那么,数据表中的每个字段(或若干个)就对应PO的一个(或若干个)属性。
8.新增员工功能开发
首先,需要设计要接受的员工对象所对应的DTO(Data Transfer Object,数据传输对象)
,事实上我们也可以使用原始的员工实体类来接收,但这样会有很多数据没有赋值,因此,当前端提交的数据和实体类中的数据差距较大时,建议使用DTO来封装数据。
Controller层实现添加用户
@PostMapping//RESTful风格,代表添加@ApiOperation("新增员工")public Result save(@RequestBody EmployeeDTO employeeDTO) {log.info("新增员工:{}",employeeDTO);//这是log,会在下面控制台输出,{}是占位符,会将employeeDTO填充到{}中。employeeService.save(employeeDTO);return Result.success();}
在业务层中,主要完成的任务是将原本Employee类型的对象数据通过对象属性拷贝方法拷贝到Employee对象中,并将Employee没有的值进行设置,最终将数据插入到数据库中。
public void save(EmployeeDTO employeeDTO) {Employee employee = new Employee();//对象属性拷贝,这是Spring提供的一个方法,由于employeeDTO是我们接收的数据对象,与我们插入到数据库中的实体类还是有差别的,因此 我们使用对象属性拷贝方法将employeeDTO内的属性值拷贝到Employee类型的对象中。BeanUtils.copyProperties(employeeDTO, employee);//设置账号的状态,默认正常状态 1表示正常 0表示锁定employee.setStatus(StatusConstant.ENABLE);//设置密码,默认密码123456employee.setPassword(DigestUtils.md5DigestAsHex(PasswordConstant.DEFAULT_PASSWORD.getBytes()));//设置当前记录的创建时间和修改时间employee.setCreateTime(LocalDateTime.now());employee.setUpdateTime(LocalDateTime.now());// 通过ThreadLocal获取用户信息Long currentId = BaseContext.getCurrentId();//设置当前记录创建人id和修改人idemployee.setCreateUser(currentId);//目前写个假数据,后期修改employee.setUpdateUser(currentId);//TODOemployeeMapper.insert(employee);//后续步骤定义}
持久层(Mapper操作)
<insert id="insert">
insert into employee (name, username, password, phone, sex, id_number, create_time, update_time, create_user, update_user,status) values (#{name},#{username},#{password},#{phone},#{sex},#{idNumber},#{createTime},#{updateTime},#{createUser},#{updateUser},#{status})
</insert>
9.面向切面编程
从上面的内容中我们看到在员工添加功能的业务层中,需要对其余的参数设置属性,如设置添加时间,修改时间这些内容,事实上,在数据库中很多表都有这个字段,那么我们是否可以将这部分功能给封装一下呢,对于这些功能很类似的模块我们就可以使用到Spring
的面向切面编程了,同时,也涉及到自定义注解。
10.注解
注解是提供一种为程序元素设置元数据的方法
,程序元素就是指接口、类、属性、方法,这些都是属于程序的元素,那啥叫元数据呢?就是描述数据的数据(data about data
),举个简单的例子,系统上有一个sm.png
文件,这个文件才是我们真正需要的数据本身,而这个文件的属性则可以称之为sm.png
的元数据,是用来描述png
文件的创建时间、修改时间、分辨率等信息的,这些信息无论是有还是没有都不影响它作为图片的性质,都可以使用图片软件打开。
注解的分类
通常来说注解分为以下三类
- 元注解 –
java
内置的注解,标明该注解的使用范围、生命周期等。 - 标准注解 –
Java
提供的基础注解,标明过期的元素/标明是复写父类方法的方法/标明抑制警告。标准注解有一下三个:@Override
标记一个方法是覆写父类方法,@Deprecated
标记一个元素为已过期,避免使用 - 自定义注解 – 第三方定义的注解,含义和功能由第三方来定义和实现。
元注解
用于定义注解的注解,通常用于注解的定义上,标明该注解的使用范围、生效范围等。元XX
都代表最基本最原始的东西,因此,元注解就是最基本不可分解的注解,我们不能去改变它只能使用它来定义自定义的注解。元注解包含以下五种: @Retention、@Target、@Documented、@Inherited、@Repeatable
,其中最常用的是@Retention
和@Target
下面分别介绍一下这五种元注解。
首先是@Retention
注解,用于设置生命周期。我们一般都设置为RUNTIME
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.ANNOTATION_TYPE)
public @interface Retention {RetentionPolicy value();
}
从编写Java代码到运行主要周期为源文件→ Class文件 → 运行时数据
,@Retention
则标注了自定义注解的信息要保留到哪个阶段,分别对应的value取值为SOURCE →CLASS→RUNTIME
。
1. SOURCE
源代码java
文件,生成的class
文件中就没有该信息了
2. CLASS
class
文件中会保留注解,但是jvm
加载运行时就没有了
3. RUNTIME
运行时,如果想使用反射获取注解信息,则需要使RUNTIME
,反射是在运行阶段进行反射的
@Target注解,中文翻译为目标,描述自定义注解的使用范围,允许自定义注解标注在哪些Java元素上(类、方法、属性、局部属性、参数…)
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.ANNOTATION_TYPE)
public @interface Target {ElementType[] value();
}
value
是一个数组,可以有多个取值,说明同一个注解可以同时用于标注在不同的元素上。value的取值如下:
示例:自定义一个注解@MyAnnotation1
想要用在类或方法上,就可以如下定义:
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface MyAnnotation {String description() default "";
}
@MyAnnotation
public class AnnotationTest {// @MyAnnotation 用在属性上则会报错public String name;@MyAnnotationpublic void test(){}}
至于其他的元注解,则很少用到。
自定义注解
自定义注解的格式如下:
public @interface 注解名 {修饰符 返回值 属性名() 默认值;修饰符 返回值 属性名() 默认值;
}
其支持的返回值类型有:
1. 基本类型 int float boolean byte double char logn short
2. String
3. Class
4. Enum
5. Annotation(注解)
6. 以上所有类型的数组类型
// 保留至运行时
@Retention(RetentionPolicy.RUNTIME)
// 可以加在方法或者类上
@Target(value = {ElementType.TYPE,ElementType.METHOD})
public @interface RequestMapping {public String method() default "GET";public String path();public boolean required();
}
其实,总结起来,注解就是给方法、类设置一些属性。
11.反射
JAVA反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为java语言的反射机制。
Class
和clas
s是不同的两个点,Class
本身也是一个类型,和String
、List
本身没有什么差异, 而class
只是一个关键字。Class可以理解为某个类型的元信息,包含其对应的构造函数(Constructor)、方法(Method)、属性(Field)以及其他相关信息(比如注解等信息),通过反射
,也就是操作Class
具体的对象,我们可以在运行期获取一个类型中各种访问权限的构造器、方法、属性,灵活的去创建某个类型的实例,调用其方法,设置其属性。可以认为这是Java提供的一个外挂,让我们可以做一些常规操作不能做到的操作。
那么具体该如何获取呢?
只要调用Class的相应方法即可获取,而这些方法的命名是具有共同的特征的。
- 获取所有构造器
获取所有公开的构造器使用getConstructors()
。获取所有(包含public/protected/default/private
的构造器使用getDeclaredConstructors()
- 获取所有的方法
获取所有公开的方法使用getMethods()
,同时会返回父类的所有公开方法。获取所有访问权限的方法使用getDeclaredMethods()
。 - 获取所有的属性
获取所有公开权限的属性getFields()
,同时会返回父类的公开属性。获取所有访问权限的属性.getDeclaredFields()
我们给出一个示例:
package Reflect;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class FelectTest {private int id;private String name;public void show(String name){System.out.println(name);}public int getId() {return id;}public void setId(int id) {this.id = id;}public String getName() {return name;}public void setName(String name) {this.name = name;}public FelectTest(int id, String name) {this.id = id;this.name = name;}public FelectTest(int id) {this.id = id;}public FelectTest() {}@Overridepublic String toString() {return "FelectTest{" +"id=" + id +", name='" + name + '\'' +'}';}public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, NoSuchFieldException, InvocationTargetException, IllegalAccessException, InstantiationException {Class<FelectTest> felect= (Class<FelectTest>) Class.forName("Reflect.FelectTest");System.out.println("类对象:"+felect);//类对象Constructor[] constructors=felect.getConstructors();System.out.println("无参构造方法:"+constructors);//无参构造函数Constructor constructorint=felect.getConstructor(int.class);System.out.println(constructorint);//形参为int的构造函数Object obj=constructorint.newInstance(1);//使用构造函数创建对象Method method=felect.getMethod("setName",String.class);//获取名字为setName,参数为String类型的方法method.invoke(obj,"李白");//执行方法,需要传入对象和参数Method methodget=felect.getMethod("getName");//获取名字为getName的get方法methodget.invoke(obj);//执行getName方法Field[] fields=felect.getDeclaredFields();//获取所有属性for (Field field : fields) {System.out.println("属性:"+field);}Field id=felect.getDeclaredField("id");System.out.println(id);//获取属性名称为id的属性Method methods=felect.getDeclaredMethod("show",String.class);Object object=felect.getConstructor().newInstance();methods.invoke(object,"李白");}
}
还记得员工添加功能中设置添加人,修改人,添加时间等属性吗,这在其他表中,如菜品表,类别表中也存在,那么对于这些都具备的功能,我们就可以利用面向切面编程来封装起来,同时我们还要知道当前的方法执行的是什么操作,因为我们在执行修改功能时是不需要设置添加人和添加时间的。另外,由于涉及多个对象,如员工,用户,菜品,因此我们需要获取对应对象的方法来实现。
12.公共字段填充
那么我们该如何实现呢,首先是定义我们要执行的方法类型:
随后定义注解,这个注解用于标识我们执行的是哪种方法:
package com.sky.annotation;
import com.sky.enumeration.OperationType;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/*** 自定义注解,用于标识某个方法需要进行字段自动填充*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {
// 数据库操作类型OperationType value();
}
接着我们将这个注解添加到我们要执行的方法上,一般情况下我们为了解耦合,都是将其放到接口上而非具体的实现方法上。
将注解添加到Mapper
接口的方法上:
//修改员工,设置注解操作方式为INSERT
@AutoFill(OperationType.INSERT)
void insert(Employee employee);
//修改员工,设置注解操作方式为UPDATE
@AutoFill(OperationType.UPDATE)
void update(Employee employee);
完成这个注解设置后,便是面向切面编程设置了。
首先定义切入点,这里是匹配Mapper
里面的所有方法,同时还要该方法使用AutoFill
注解的。
@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
public void autoFillPointCut() {System.out.println("切入点");
}
随后便是设置切面的具体操作了,设置前置通知,即对公共字段进行填充。
具体,则是使用反射
实现的。
/*** 前置通知,在通知中进行公共字段的赋值*/@Before("autoFillPointCut()")public void autoFill(JoinPoint joinPoint) {log.info("开始进行公共字段自动填充");// 获取到当前拦截的方法上的数据库操作类型
// 获取方法签名对象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) {
// 为4个公共字段赋值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) {
// 为2个公共字段赋值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);}}}
通过反射可以读取该方法所使用的注解,用于确定我们的操作类型:
// 获取方法上的注解对象
AutoFill autoFill =signature.getMethod().getAnnotation(AutoFill.class);
// 获取数据库操作类型
OperationType operationType = autoFill.value();
根据我们刚刚DeBug的结果,我们可以分析出实体对象保存在args[0]
中。
//获取到当前被拦截的方法的参数---实体对象
Object[] args = joinPoint.getArgs();
if (args == null || args.length == 0) {return;}
Object entity = args[0];
随后便是根据操作类型判断是进行哪种操作了:就是获取对象的方法然后通过invoke
来执行这些方法。
if (operationType == OperationType.INSERT) {
// 为4个公共字段赋值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) {
// 为2个公共字段赋值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);}}
至此,面向切面编程实现公共字段属性填充便实现了。
13.异常处理
在SpringBoot中,对异常的处理方式主要可分为三种:
- 自定义全局异常
- 手动抛出异常
- 测试打印异常
自定义全局异常
SpringBoot
中,@ControllerAdvice
即可开启全局异常处理,使用该注解表示开启了全局异常的捕获,我们只需在自定义一个方法使用@ExceptionHandler
注解然后定义捕获异常的类型即可对这些捕获的异常进行统一的处理。
@ControllerAdvice
public class MyExceptionHandler {@ExceptionHandler(value =Exception.class)@ResponseBodypublic String exceptionHandler(Exception e){System.out.println("全局异常捕获>>>:"+e);return "全局异常捕获,错误原因>>>"+e.getMessage();}
}
至于其他的方式,则并不优雅。在这里,既然我们使用了SpringBoot框架,那么自然便使用这种注解方式处理异常最佳,在先前的员工添加功能中,我们还要对这个功能进行完善,即不允许添加员工的账号相同:
如果发生了这种情况,在不进行异常处理时,便会提示:SQLIntegrityConstraintViolationException
那么,我们只需要自定义全局异常时将其捕获即可:
package com.sky.handler;import com.sky.constant.MessageConstant;
import com.sky.exception.BaseException;
import com.sky.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.sql.SQLIntegrityConstraintViolationException;
/*** 全局异常处理器,处理项目中抛出的业务异常*/
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {@ExceptionHandlerpublic Result exceptionHandler(SQLIntegrityConstraintViolationException ex){//捕获的异常类型//Duplicate entry 'zhangsan' for key 'employee.idx_username'String message = ex.getMessage();//提取异常信息,就是上面的这句话if(message.contains("Duplicate entry")){//我们想将这个异常信息中的zhangsan提取出来,提示已经存在,因此便将上面的字符串进行分割,并拼接上定义好的常量提示信息:MessageConstant.ALREADY_EXISTSString[] split = message.split(" ");String username = split[2];String msg = username + MessageConstant.ALREADY_EXISTS;return Result.error(msg);}else{return Result.error(MessageConstant.UNKNOWN_ERROR);}}
}
14.ThreadLocal
ThreadLocal
并不是一个Thread
,而是Thread
的同部变量。ThreadLocal
为每个线程提供单独一份存储空间,具有线程隔离的效果,只有在线程内才能获取到对应的值,线程外则不能访问。
学习这个有什么用呢?还记得我们在员工添加时需要将添加入的信息保存到数据表中吗,原本我们该如何做呢,我们可以使用Session
,但如今我们使用JWT
认证来实现用户信息校验,JWT
令牌中存储着用户信息,那么我们就可以将这个信息解析出来,但JWT
的信息解析是放在我们的拦截器中的,我们而用户信息则是在我们的业务层中使用 ,问题是如何将这个信息传递到业务层中呢?这就用到了我们的ThreadLocal
存储空间了,由于整个业务是同一个线程,因此我们就可以将数据保存在里面。
那么,ThreadLocal怎么使用呢?
ThreadLocal
常用方法:
public void set(T value)
设置当前线程的线程局部变量的值
public T get()
返回当前线程所对应的线程局部变量的值
public void remove()
移除当前线程的线程局部变量
下面是JWT令牌认证的过程:
那么该如何如何实现呢?首先我们定义一个BaseContext 类,里面生成了一个ThreadLocal对象,同时实现了上面的那几个方法。
package com.sky.context;
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();}
}
随后我们只需要把JWT
令牌解析出来的用户信息添加到ThreadLocal
中即可。
JWT定义的常量信息
package com.sky.constant;public class JwtClaimsConstant {public static final String EMP_ID = "empId";public static final String USER_ID = "userId";public static final String PHONE = "phone";public static final String USERNAME = "username";public static final String NAME = "name";}
在登录时将用户信息保留到JWT
令牌中。
将JWT
令牌中的信息解析出来并添加到ThreadLocal
对象中。
随后通过下面的方法就可以获取出我们保存的数据了。
Long currentId = BaseContext.getCurrentId();
15.分页查询 PageHelper
我们首先看一下接口文档的要求,请求的数据类型是query,即是以字符串拼接到浏览器地址的形式实现的,所需要的参数有三个。
以前博主开发时,会都是用一个实体类来封装这些数据,这确实不规范,我们此时定义一个专门用于封装接收数据的实体类:
package com.sky.dto;
import lombok.Data;
import java.io.Serializable;
@Data
public class EmployeePageQueryDTO implements Serializable {//员工姓名private String name;//页码private int page;//每页显示记录数private int pageSize;
}
随后我们定义Controller层方法:
@GetMapping("/page")@ApiOperation("员工分页查询")public Result<PageResult> page(EmployeePageQueryDTO employeePageQueryDTO) {//这里就不要@RequestBody注解了,因为是jquery。log.info("员工分页查询,参数为:{}", employeePageQueryDTO);PageResult pageResult = employeeService.pageQuery(employeePageQueryDTO);return Result.success(pageResult);}
随后是业务层实现,为了让代码更简洁,使用PageHelper插件。
@Overridepublic PageResult pageQuery(EmployeePageQueryDTO employeePageQueryDTO) {
// 开始分页查询,这是基于select * form table limit 0,10来实现的,但我们可以通过PageHelper插件来实现分页sql拼接功能。PageHelper.startPage(employeePageQueryDTO.getPage(), employeePageQueryDTO.getPageSize());
// 要想使用PageHelper插件,就要符合其规则,查询的返回结果为Page类型,泛型为EmployeePage<Employee> page = employeeMapper.pageQuery(employeePageQueryDTO);
// 获取记录数与结果long total = page.getTotal();List<Employee> records = page.getResult();
// 封装到PageResult中return new PageResult(total, records);}
书写对应的SQL语句,由于name属性是动态的,因此要用动态SQL,而注解写的SQL很不方便,因此用mapper.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></select>
这里我们可能会有疑问,这个PageHelper.startPage
方法似乎和后面的查询没关系吧,事实上,PageHelper.startPage
是基于ThreadLocal实现的,它会将我们传递的limit
的起始和页数保存起来,然后拼接到我们的xml中的SQL语句中。
16.拦截器 Interceptor
概念:是一种动态拦截方法调用的机制,类似于过滤器。Spring框架中提供的,用来动态拦截控制器方法的执行。
作用:拦截请求,在指定的方法调用前后,根据业务需要执行预先设定的代码。
Filter
与Interceptor
区别
接口规范不同:过滤器需要实现Filter接口,而拦截器需要实现HandlerInterceptor接口。
拦截范围不同:过滤器Filter会拦截所有的资源,而Interceptor只会拦截Spring环境中的资源。
那么,在SpringBoot中该如何使用拦截器呢?需要以下两个步骤:
1.配置拦截器
配置拦截器只需要实现HandlerInterceptor接口,并重写其所有方法即可。
@Component
public class LoginInterceptor implements HandlerInterceptor {@Override //目标方法执行前的执行,返回true放行,返回false不放行public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {return HandlerInterceptor.super.preHandle(request, response, handler);}@Override //目标方法执行后执行public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);}@Override //视图渲染执行后执行,最后执行public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {HandlerInterceptor.super.afterCompletion(request, response, handler, ex);}
}
JWT令牌拦截器配置如下:
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 JwtTokenAdminInterceptor implements HandlerInterceptor {@Autowiredprivate JwtProperties jwtProperties;public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//判断当前拦截到的是Controller的方法还是其他资源if (!(handler instanceof HandlerMethod)) {//当前拦截到的不是动态方法,直接放行return true;}//1、从请求头中获取令牌String token = request.getHeader(jwtProperties.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);// 将用户id存储到ThreadLocalBaseContext.setCurrentId(empId);//3、通过,放行return true;} catch (Exception ex) {//4、不通过,响应401状态码response.setStatus(401);return false;}}
}
2.注册拦截器
在配置完拦截器后,我们需要在配置类中注册改拦截器,只需要实现WebMvcConfigurer
接口中的方法即可
@Configuration
public class WebConfig implements WebMvcConfigurer {@AutowiredLoginInterceptor loginInterceptor;@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(loginInterceptor).addPathPatterns("/**");//拦截所有请求}
}
这个注册拦截器的作用是告诉配置文件我们有拦截器,并且负责告诉系统哪些要拦截哪些不拦截,当然上面是通过实现WebMvcConfigurer
接口的方式完成的,也可以通过继承的方式重写WebMvcConfigurationSupport
里面的方法,比如我们书写的代码中包含JWT
令牌验证,资源过滤等功能。
/*** 配置类,注册web层相关组件*/
@Configuration
@Slf4j
public class WebMvcConfiguration extends WebMvcConfigurationSupport {@Autowiredprivate JwtTokenAdminInterceptor jwtTokenAdminInterceptor;@Autowiredprivate JwtTokenUserInterceptor jwtTokenUserInterceptor;/*** 注册自定义拦截器* @param registry*/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");}
}
17.拓展消息转换器
我们将员工数据查询出来后,发现时间格式不是我们所期望的。如下:
有两种解决方式,第一种是使用@JsonFormat
注解
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
但这种方式需要对每个需要转换的数据都添加一个注解,十分麻烦,因此选择第二种解决方法,使用拓展消息转换器extendMessageConverters
。
这个配置也是在WebMvcConfiguration
中,WebMvcConfiguration
是我们自己定义的配置类,它继承了WebMvcConfigurationSupport
,我们重写extendMessageConverters
方法即可(该方法是固定的)。
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {log.info("扩展消息转换器...");//创建一个消息转换器对象(Spring提供的)MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();//需要为消息转换器设置一个对象转换器,对象转换器可以将Java对象序列化为json数据(这个对象转换器是自己定义的,是一种工具类,会用即可)converter.setObjectMapper(new JacksonObjectMapper());//将自己的消息转化器加入容器中,converter是一个容器,是所有消息转换器的集合。同时在converter有许多java自定义的消息转换器,我们使用add方法默认是加在最后面的,这会导致难以被使用到,因此加一个索引0converters.add(0,converter);}
对象转换器的定义如下,其作用如下:
- 对象映射器:基于jackson将Java对象转为json,或者将json转为Java对象
- 将JSON解析为Java对象的过程称为 [从JSON反序列化Java对象]
- 从Java对象生成JSON的过程称为 [序列化Java对象到JSON]
package com.sky.json;import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES;/*** 对象映射器:基于jackson将Java对象转为json,或者将json转为Java对象* 将JSON解析为Java对象的过程称为 [从JSON反序列化Java对象]* 从Java对象生成JSON的过程称为 [序列化Java对象到JSON]*/
public class JacksonObjectMapper extends ObjectMapper {public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";//public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm";public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss";public JacksonObjectMapper() {super();//收到未知属性时不报异常this.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);//反序列化时,属性不存在的兼容处理this.getDeserializationConfig().withoutFeatures(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);SimpleModule simpleModule = new SimpleModule().addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT))).addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT))).addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT))).addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT))).addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT))).addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)));//注册功能模块 例如,可以添加自定义序列化器和反序列化器this.registerModule(simpleModule);}
}
18.@RequestParam与@PathVariable
这两种都是将前端发送的数据传递到Controller,那么有什么不同呢?
@RequestParam
传递的是Request
参数,当然我们可以不写这个注解,默认就是这种。即?参数名=参数值
形式
@PathVariable
传递的是URL
变量:在@RequestMapping
注解中用{ }来表明它的变量部分,例如:
@RequestMapping(value="/user/{username}")
单个URL变量:
@RequestMapping(value="/user/{username}")public String userProfile(@PathVariable(value="username") String username) {return "user"+username;}
当有多个URL变量时:
@RequestMapping(value = "/user/{username}/blog/{blogId}")public String getUserBlog(@PathVariable String username, @PathVariable int blogId) {return "user:" + username + "blog->" + blogId;}
当两者共用时:@PathVariable
中参数名相同时可以不写参数名,@RequestParam
可以省略
@PostMapping("/status/{status}")@ApiOperation("启用禁用员工账户")public Result startOrStop(@PathVariable Integer status, Long id) {log.info("启用禁用员工账户:{},{}", status, id);employeeService.startOrStop(status, id);return Result.success();}
19.实体类中的注解
在实体类上加入@Builder注解后,我们就可以使用builder(构造器)来创建对象。这个效果与new
对象后设置属性效果相同。
Employee employee = Employee.builder().status(status).id(id).build();
- @Data//省去代码中大量的get()、 set()、 toString()等方法
- @Builder//允许Builder构造器创建对象注解
- @NoArgsConstructor//生成无参构造方法注解
- @AllArgsConstructor//生成所有有参构造方法注解
package com.sky.entity;import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;import java.io.Serializable;
import java.time.LocalDateTime;@Data//省去代码中大量的get()、 set()、 toString()等方法
@Builder//允许Builder构造器创建对象注解
@NoArgsConstructor//生成无参构造方法注解
@AllArgsConstructor//生成所有有参构造方法注解
public class Employee implements Serializable {private static final long serialVersionUID = 1L;private Long id;private String username;private String name;private String password;private String phone;private String sex;private String idNumber;private Integer status;@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")private LocalDateTime createTime;//@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")private LocalDateTime updateTime;private Long createUser;private Long updateUser;}
20.动态SQL语句
先前在查询时使用过一次了,那么在修改时我们可能会对多个字段进行修改,因此可以使用动态SQL语句来实现。
动态修改
<update id="update" parameterType="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>
动态查询
<select id="list" resultType="com.sky.entity.ShoppingCart">select *from shopping_cart<where><if test="userId!=null">and user_id=#{userId}</if><if test="dishId!=null">and dish_id=#{dishId}</if><if test="setmealId!=null">and setmeal_id=#{setmealId}</if><if test="dishFlavor!=null">and dish_flavor=#{dishFlavor}</if></where>order by create_time desc</select>