springboot+shiro+jwt实现token认证登录
准备:
springboot 2.5.5
jdk 1.8
没有操作刷新token功能,也没有放redis做缓存
1.先贴代码
2.后讲一下验证逻辑
1.导入依赖
<!--shiro--><dependency><groupId>org.apache.shiro</groupId><artifactId>shiro-spring</artifactId><version>1.7.1</version></dependency><!--集成jwt实现token认证--><dependency><groupId>com.auth0</groupId><artifactId>java-jwt</artifactId><version>3.2.0</version></dependency>
2.创建JWTUtil工具类
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.ronsafe.wlw.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;import javax.servlet.http.HttpServletRequest;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;public class JWTUtil {// 过期时间 2 小时private static final long EXPIRE_TIME = 2 * 60 * 60 * 1000;// 密钥private static final String SECRET = "jwt+shiro";@Autowiredprivate UserMapper userMapper;/*** 生成 token*/public static String createToken(String username) {try {Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);Algorithm algorithm = Algorithm.HMAC256(SECRET);//jwt的header部分Map<String ,Object> map=new HashMap<>();map.put("alg","HS256");map.put("typ","JWT");// 附带username信息return JWT.create().withHeader(map)//jwt的header部分.withClaim("username", username)//私有声明.withExpiresAt(date)//过期时间.withIssuedAt(new Date())//签发时间.sign(algorithm);//签名} catch (Exception e) {return null;}}/*** 校验 token 是否正确*///校验token的有效性,1、token的header和payload是否没改过;2、没有过期public static boolean verify(String token) {try {//解密JWTVerifier verifier=JWT.require(Algorithm.HMAC256(SECRET)).build();
// System.out.println("5555555->error1111111111");verifier.verify(token);return true;}catch (Exception e){return false;}}/*** 获得token中的信息,无需secret解密也能获得*/public static String getUsername(String token) {try {DecodedJWT jwt = JWT.decode(token);return jwt.getClaim("username").asString();} catch (JWTDecodeException e) {return null;}}public static String getCurrentUsername(HttpServletRequest request){String accessToken = request.getHeader("Jmt-token");return getUsername(accessToken);}
}
3.创建类JwtToken
import org.apache.shiro.authc.AuthenticationToken;public class JwtToken implements AuthenticationToken {private String token;public JwtToken(String token) {this.token = token;}@Overridepublic Object getPrincipal() {return token;}@Overridepublic Object getCredentials() {return token;}}
4.创建ShiroRealm类
import com.ronsafe.wlw.util.JWTUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.stereotype.Component;@Component
@Slf4j
public class ShiroRealm extends AuthorizingRealm {/*** 根据token判断此Authenticator是否使用该realm* 必须重写此方法,不然会报错*/@Overridepublic boolean supports(AuthenticationToken token) {return token instanceof JwtToken;}/*** 默认使用此方法进行用户名正确与否验证,错误抛出异常即可。*/@Overrideprotected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
// System.out.println("7777777777777777");String token = (String) authenticationToken.getCredentials();// 解密获得username,用于和数据库进行对比String username = null;try {username= JWTUtil.getUsername(token);}catch (Exception e){throw new AuthenticationException("token非法,不是规范的token,可能被篡改了,或者过期了");}if (username == null || !JWTUtil.verify(token)) {
// System.out.println("5555555->error2222222222");throw new AuthenticationException("token认证失效,token错误或者过期,重新登陆");}
// System.out.println("8888888888888888888888");return new SimpleAuthenticationInfo(token,token,"ShiroRealm");}/*** 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的*/@Overrideprotected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {return null;}
}
5.创建类JwtFilter
import com.alibaba.fastjson.JSON;
import com.ronsafe.wlw.util.Result;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authz.UnauthorizedException;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.apache.shiro.web.util.WebUtils;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMethod;import javax.servlet.ServletOutputStream;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;@Slf4j
public class JwtFilter extends BasicHttpAuthenticationFilter {private boolean allowOrigin = true;public JwtFilter(){}public JwtFilter(boolean allowOrigin){this.allowOrigin = allowOrigin;}/*** 如果带有 token,则对 token 进行检查,否则直接通过*/@Overrideprotected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws UnauthorizedException {
// System.out.println("555555555555555555");try {executeLogin(request, response);} catch (Exception e) {
// System.out.println("5555555->error333333333333");//token 错误responseError(response);}
// System.out.println("1010101010");return true;}/*** 判断用户是否想要登入。* 检测 header 里面是否包含 token 字段*/@Overrideprotected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {HttpServletRequest req = (HttpServletRequest) request;String token = req.getHeader("Jmt-token");return token != null;}/*** 执行登陆操作*/@Overrideprotected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
// System.out.println("6666666666666");HttpServletRequest httpServletRequest = (HttpServletRequest) request;String token = httpServletRequest.getHeader("Jmt-token");JwtToken jwtToken = new JwtToken(token);// 提交给realm进行登入,如果错误它会抛出异常并被捕获getSubject(request, response).login(jwtToken);// 如果没有抛出异常则代表登入成功,返回true
// System.out.println("9999999999999999999");return true;}/*** 对跨域提供支持*/@Overrideprotected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {HttpServletRequest httpServletRequest = (HttpServletRequest) request;HttpServletResponse httpServletResponse = (HttpServletResponse) response;httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));//前后端分离,shiro过滤器配置引起的跨域问题// 是否允许发送Cookie,默认Cookie不包括在CORS请求之中。设为true时,表示服务器允许Cookie包含在请求中。httpServletResponse.setHeader("Access-Control-Allow-Credentials", "true");//前后端分离,shiro过滤器配置引起的跨域问题// 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {httpServletResponse.setStatus(HttpStatus.OK.value());return false;}return super.preHandle(request, response);}/*** 非法请求返回401,前端拦截到登录页*/private void responseError(ServletResponse response) {HttpServletResponse httpServletResponse = WebUtils.toHttp(response);httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());httpServletResponse.setCharacterEncoding("UTF-8");httpServletResponse.setContentType("application/json; charset=utf-8");try (ServletOutputStream out = httpServletResponse.getOutputStream()) {
// System.out.println("5555555->error444444444444444");out.write(JSON.toJSONString(Result.fail(401,"身份验证失败,请重新登陆!")).getBytes("utf-8"));} catch (IOException e) {throw new AuthenticationException("直接返回Response信息出现IOException异常:" + e.getMessage());}}
}
6.创建类ShiroConfig
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;import javax.servlet.Filter;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;@Slf4j
@Configuration
public class ShiroConfig {/*** 先经过token过滤器,如果检测到请求头存在 token,则用 token 去 login,接着走 Realm 去验证*/@Beanpublic ShiroFilterFactoryBean factory(@Qualifier("securityManager")DefaultWebSecurityManager securityManager) {
// System.out.println("1111111111111");ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();factoryBean.setSecurityManager(securityManager);Map<String, Filter> filterMap = new LinkedHashMap<>();// 添加自己的过滤器并且取名为jwtfilterMap.put("jwt", new JwtFilter());factoryBean.setFilters(filterMap);// 设置无权限时跳转的 url;factoryBean.setUnauthorizedUrl("/unauthorized/relogin");Map<String, String> filterRuleMap = new HashMap<>();//添加不需要拦截的urlfilterRuleMap.put("/unauthorized/**","anon");
// //登录不需要拦截filterRuleMap.put("/login","anon");
// //处理swagger不能访问问题filterRuleMap.put("/swagger-ui.html", "anon");filterRuleMap.put("/swagger**/**", "anon");filterRuleMap.put("/webjars/**", "anon");filterRuleMap.put("/v2/**", "anon");//这个需要放到最下面// 所有请求通过我们自己的JWT FilterfilterRuleMap.put("/**", "jwt");factoryBean.setFilterChainDefinitionMap(filterRuleMap);
// System.out.println("2222222222222222222222");return factoryBean;}/*** 注入 securityManager*/@Bean(name = "securityManager")public DefaultWebSecurityManager securityManager(ShiroRealm customRealm) {
// System.out.println("333333333333333");DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();//设置自定义realm.securityManager.setRealm(customRealm);//关闭shiro自带的sessionDefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();defaultSessionStorageEvaluator.setSessionStorageEnabled(false);subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);securityManager.setSubjectDAO(subjectDAO);
// System.out.println("444444444444444");return securityManager;}/*** 添加注解支持*/@Beanpublic DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();// 强制使用cglib,防止重复代理和可能引起代理出错的问题defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);return defaultAdvisorAutoProxyCreator;}@Beanpublic AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(@Qualifier("securityManager") DefaultWebSecurityManager securityManager) {AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();advisor.setSecurityManager(securityManager);return advisor;}@Beanpublic LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {return new LifecycleBeanPostProcessor();}
}
7.创建类LoginController
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.ronsafe.wlw.entity.SysUser;
import com.ronsafe.wlw.service.UserService;
import com.ronsafe.wlw.util.JWTUtil;
import com.ronsafe.wlw.util.PasswordUtil;
import com.ronsafe.wlw.util.Result;
import com.ronsafe.wlw.util.StatusCode;
import com.ronsafe.wlw.vo.UserVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;import java.util.HashMap;/*** @Author R0137 csy* @Date 2021/11/9 14:46*/
@RestController
@Api(tags = "系统管理")
public class LoginController {@Autowiredprivate UserService userService;@CrossOrigin@PostMapping("/login")@ApiOperation("登录")public Result login(String username,String password){QueryWrapper<SysUser> wrapper=new QueryWrapper<>();wrapper.eq("username",username);SysUser user = userService.getOne(wrapper);if (user==null) return Result.fail(StatusCode.LOGINERROR,"用户不存在!");password = PasswordUtil.encrypt(username,password,user.getSalt());if(!user.getPassword().equals(password)) return Result.fail(StatusCode.LOGINERROR,"密码错误!");String token = JWTUtil.createToken(username);HashMap<String, Object> result = new HashMap<>();UserVO userVO = new UserVO();BeanUtils.copyProperties(user,userVO);result.put("token",token);result.put("user",userVO);return Result.success(result);}
}
至此所有集成代码都粘贴完毕,下面粘一下工具类
public class Result {//是否成功private boolean flag;//返回的状态码private Integer code;//返回信息private String message;//返回数据private Object data;//全参构造方法public Result(boolean flag, Integer code, String message, Object data) {//super();this.flag = flag;this.code = code;this.message = message;this.data = data;}//无参构造方法public Result() {}//没有返回数据的方法public Result(boolean flag, Integer code, String message) {super();this.flag = flag;this.code = code;this.message = message;}// 通用的成功 无返回结果public static Result success() {return new Result(true, StatusCode.OK, "OK", null);}// 通用的成功 有返回结果public static Result success(Object data) {return new Result(true, StatusCode.OK, "OK", data);}// 通用的失败创建接口 没有返回结果public static Result fail(int statusCode, String message) {return new Result(false, statusCode, message, null);}// 通用的失败创建接口 有返回结果public static Result fail(int statusCode, String message, Object data) {return new Result(false, statusCode, message, data);}public boolean isFlag() {return flag;}public void setFlag(boolean flag) {this.flag = flag;}public Integer getCode() {return code;}public void setCode(Integer code) {this.code = code;}public String getMessage() {return message;}public void setMessage(String message) {this.message = message;}public Object getData() {return data;}public void setData(Object data) {this.data = data;}
}
package com.ronsafe.wlw.util;import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.PBEParameterSpec;
import java.security.Key;
import java.security.SecureRandom;
import java.util.Random;public class PasswordUtil {/*** 随机数* @param place 定义随机数的位数*/public static String randomGen(int place) {String base = "qwertyuioplkjhgfdsazxcvbnmQAZWSXEDCRFVTGBYHNUJMIKLOP0123456789";StringBuffer sb = new StringBuffer();Random rd = new Random();for(int i=0;i<place;i++) {sb.append(base.charAt(rd.nextInt(base.length())));}return sb.toString();}/*** JAVA6支持以下任意一种算法 PBEWITHMD5ANDDES PBEWITHMD5ANDTRIPLEDES* PBEWITHSHAANDDESEDE PBEWITHSHA1ANDRC2_40 PBKDF2WITHHMACSHA1* *//*** 定义使用的算法为:PBEWITHMD5andDES算法*/public static final String ALGORITHM = "PBEWithMD5AndDES";//加密算法public static final String Salt = "63293188";//密钥/*** 定义迭代次数为1000次*/private static final int ITERATIONCOUNT = 1000;/*** 获取加密算法中使用的盐值,解密中使用的盐值必须与加密中使用的相同才能完成操作. 盐长度必须为8字节* * @return byte[] 盐值* */public static byte[] getSalt() throws Exception {// 实例化安全随机数SecureRandom random = new SecureRandom();// 产出盐return random.generateSeed(8);}public static byte[] getStaticSalt() {// 产出盐return Salt.getBytes();}/*** 根据PBE密码生成一把密钥* * @param password* 生成密钥时所使用的密码* @return Key PBE算法密钥* */private static Key getPBEKey(String password) {// 实例化使用的算法SecretKeyFactory keyFactory;SecretKey secretKey = null;try {keyFactory = SecretKeyFactory.getInstance(ALGORITHM);// 设置PBE密钥参数PBEKeySpec keySpec = new PBEKeySpec(password.toCharArray());// 生成密钥secretKey = keyFactory.generateSecret(keySpec);} catch (Exception e) {// TODO Auto-generated catch blocke.printStackTrace();}return secretKey;}/*** 加密明文字符串* * @param plaintext* 待加密的明文字符串* @param password* 生成密钥时所使用的密码* @param salt* 盐值* @return 加密后的密文字符串* @throws Exception*/public static String encrypt(String plaintext, String password, String salt) {Key key = getPBEKey(password);byte[] encipheredData = null;PBEParameterSpec parameterSpec = new PBEParameterSpec(salt.getBytes(), ITERATIONCOUNT);try {Cipher cipher = Cipher.getInstance(ALGORITHM);cipher.init(Cipher.ENCRYPT_MODE, key, parameterSpec);//update-begin-author:sccott date:20180815 for:中文作为用户名时,加密的密码windows和linux会得到不同的结果 gitee/issues/IZUD7encipheredData = cipher.doFinal(plaintext.getBytes("utf-8"));//update-end-author:sccott date:20180815 for:中文作为用户名时,加密的密码windows和linux会得到不同的结果 gitee/issues/IZUD7} catch (Exception e) {}return bytesToHexString(encipheredData);}/*** 解密密文字符串* * @param ciphertext* 待解密的密文字符串* @param password* 生成密钥时所使用的密码(如需解密,该参数需要与加密时使用的一致)* @param salt* 盐值(如需解密,该参数需要与加密时使用的一致)* @return 解密后的明文字符串* @throws Exception*/public static String decrypt(String ciphertext, String password, String salt) {Key key = getPBEKey(password);byte[] passDec = null;PBEParameterSpec parameterSpec = new PBEParameterSpec(salt.getBytes(), ITERATIONCOUNT);try {Cipher cipher = Cipher.getInstance(ALGORITHM);cipher.init(Cipher.DECRYPT_MODE, key, parameterSpec);passDec = cipher.doFinal(hexStringToBytes(ciphertext));}catch (Exception e) {// TODO: handle exception}return new String(passDec);}/*** 将字节数组转换为十六进制字符串* * @param src* 字节数组* @return*/public static String bytesToHexString(byte[] src) {StringBuilder stringBuilder = new StringBuilder("");if (src == null || src.length <= 0) {return null;}for (int i = 0; i < src.length; i++) {int v = src[i] & 0xFF;String hv = Integer.toHexString(v);if (hv.length() < 2) {stringBuilder.append(0);}stringBuilder.append(hv);}return stringBuilder.toString();}/*** 将十六进制字符串转换为字节数组* * @param hexString* 十六进制字符串* @return*/public static byte[] hexStringToBytes(String hexString) {if (hexString == null || hexString.equals("")) {return null;}hexString = hexString.toUpperCase();int length = hexString.length() / 2;char[] hexChars = hexString.toCharArray();byte[] d = new byte[length];for (int i = 0; i < length; i++) {int pos = i * 2;d[i] = (byte) (charToByte(hexChars[pos]) << 4 | charToByte(hexChars[pos + 1]));}return d;}private static byte charToByte(char c) {return (byte) "0123456789ABCDEF".indexOf(c);}}
代码粘完了,讲一下流程
一些问题
1.没有登出?
目前没有结合redis存token,也不存在token在线操作刷新的问题,所以后端不需要做什么,如果用户主动登出,前端删除用户信息,回到登录界面即可,如果是token过期的话,用户带着过期的token过来会给前端返回401,前端拦截,再执行退出操作即可
2.获取当前登录用户
通过jwtUtil工具类中的getCurrentUsername方法拿到用户名,即可以拿到用户
springboot+shiro+jwt实现token认证登录相关推荐
- springboot+vue jwt校验token 单点登录
SSO(Single Sign On)模式 CAS单点登录.OAuth2 分布式,SSO(single sign on)模式:单点登录英文全称Single Sign On,简称就是SSO.它的解释是: ...
- SpringBoot集成JWT实现token验证
Jwt全称是:json web token,以JSON对象的形式安全的传递信息.它将用户信息加密到token里,服务器不保存任何用户信息.服务器通过使用保存的密钥验证token的正确性,只要正确即通过 ...
- jwt重放攻击_【干货分享】基于JWT的Token认证机制及安全问题
一步一步教你基于JWT的Token认证机制实现,以及如何防范XSS攻击.Replay攻击和中间人攻击. 文章目录 一.几种常用的认证机制 1.1 HTTP Basic Auth HTTP Basic ...
- SpringSecurity - 整合JWT使用 Token 认证授权
一.SpringSecurity 前面讲解了SpringSecurity的动态认证和动态权限角色,我们都知道在现在大多都是微服务前后端分离的模式开发,前面讲的还是基于Session的,本篇我们整合JW ...
- 基于JWT的Token认证机制实现
一.基于JWT的Token认证机制实现 1.什么是JWT JSON Web Token(JWT)是一个非常轻巧的规范.这个规范允许我们使用JWT在用户和服务器之间传递安全可靠的信息. 2.JWT组成 ...
- SpringBoot 整合 JWT 实现 Token 验证
前言 在Spring Security整合oauth2实现认证token也不满足实际生产需求的时候,可以整合Jwt实现token认证,完全手写获取token,认证token的方法. Maven依赖包 ...
- 前后端分离 springboot shiro+jwt token认证 权限校验
项目源码 国涛/springboot-shiro-jwthttps://gitee.com/dugt/springboot-shiro-jwt GitHub - dugt-1998/springboo ...
- springboot+shiro+jwt实现登录+权限验证
目录 一.简介: JWT优点: JWT缺点: shiro: JWT: 1.JWT头 2.有效载荷 3.签名哈希 4.Base64URL算法 二.实现 1.引入maven依赖 2.编写shiro配置类 ...
- SpringBoot集成JWT实现Token登录验证
目录 1.1 JWT是什么? 1.2 JWT主要使用场景 1.3 JWT请求流程 1.4 JWT结构 二,SpringBoot集成JWT具体实现过程 2.1添加相关依赖 2.2自定义跳出拦截器的注解 ...
最新文章
- RecyclerView滑动到指定位置,并置顶
- WP7之题样式与数据绑定
- oracle迁移postsql的,osdba's blog : Oracle迁移PostgreSQL系列文章之二:merge语句
- 【OpenCV3】图像翻转——cv::flip()详解
- mysql分库分表风险_数据库分库分表存在的问题及解决方案
- 一份完整的机房建设方案
- centos7.9更改root账号密码
- ubuntu16.04卡在了’SMBus Host Controller not enabled‘
- docker 简版教程
- java做图形界面计算n_n皇后问题回溯法---java图形界面实现回溯过程
- Java实战之管家婆记账系统(24)——项目总结
- 网速魔法师 v1.8 官方安装版
- webpack 的plugin简单实现 customize-cra
- shap 解释理赔时效模型特征
- 苹果要求部分员工佩戴警用级随身摄像头
- windows搭建redis java简易访问客户端
- html标签图片填充背景色快捷键,ps中填充颜色的快捷键是什么?
- CSS3的:nth选择器
- 2023年1月伊凡梳理如何快速安装git并且配置本地gitee账号权限方便拉代码
- mysql temporary_MySQL中临时表(TEMPORARY)