介绍
参照链接:https://github.com/xkcoding/spring-boot-demo
知识储备
用户权限管理数据库设计(RBAC)
参考链接:
SpringDataJpa技术
把SQL全部封装到注解的方式,或者直接使用方法拼接查询。
SpringBoot整合SpringDataJPA入门案例
JpaRepository<T, ID>
该方法封装了常用的增删改查。
JpaSpecificationExecutor< T>
封装了查询一条数据、条件查询、分页查询、排序查询、计数查询。
@EntityGraph、@ManyToMany
@EntityGraph:解决懒加载的查询N+1问题,提升查询效率。
@ManyToMany:一对多
注意:防止无限递归,可以使用toString来解决。
@Query
实现自定义的sql。
Security技术
SpringBoot整合Security无数据库版本入门SpringBoot整合Security数据库版本入门 后续会更新微服务版本、整合OAuth2+JWT版本。
Redis技术
Redis基本命令入门 常用的获取key,删除key,以及批量删除key。
@Component
@Slf4j
public class RedisUtil {
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 分页获取指定格式key,使用 scan 命令代替 keys 命令,在大数据量的情况下可以提高查询效率
*
* @param patternKey key格式
* @param currentPage 当前页码
* @param pageSize 每页条数
* @return 分页获取指定格式key
*/
public PageResult<String> findKeysForPage(String patternKey, int currentPage, int pageSize) {
ScanOptions options = ScanOptions.scanOptions()
.match(patternKey)
.build();
RedisConnectionFactory factory = stringRedisTemplate.getConnectionFactory();
RedisConnection rc = factory.getConnection();
Cursor<byte[]> cursor = rc.scan(options);
List<String> result = Lists.newArrayList();
long tmpIndex = 0;
int startIndex = (currentPage - 1) * pageSize;
int end = currentPage * pageSize;
while (cursor.hasNext()) {
String key = new String(cursor.next());
if (tmpIndex >= startIndex && tmpIndex < end) {
result.add(key);
}
tmpIndex++;
}
try {
cursor.close();
RedisConnectionUtils.releaseConnection(rc, factory);
} catch (Exception e) {
log.warn("Redis连接关闭异常,", e);
}
return new PageResult<>(result, tmpIndex);
}
/**
* 删除 Redis 中的某个key
*
* @param key 键
*/
public void delete(String key) {
stringRedisTemplate.delete(key);
}
/**
* 批量删除 Redis 中的某些key
*
* @param keys 键列表
*/
public void delete(Collection<String> keys) {
stringRedisTemplate.delete(keys);
}
}
/**
* 通用分页参数返回
* @param <T>
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageResult<T> implements Serializable {
private static final long serialVersionUID = 3420391142991247367L;
/**
* 当前页数据
*/
private List<T> rows;
/**
* 总条数
*/
private Long total;
public static <T> PageResult of(List<T> rows, Long total) {
return new PageResult<>(rows, total);
}
}
Lombok技术
@Data
实现bean 的get/set。
@NoArgsConstructor
实现bean空构造函数。
@AllArgsConstructor
实现可能会用到构造函数。
@Builder
建造者模式下的bean。
@ToString
toString()方法。
@Slf4j
log日志。
JWT技术
token鉴权,token就是各种协议和加密字符串集成。权限验证的方式。
@Configuration
@Slf4j
public class JwtUtil {
/**
* redis工具类
*/
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 创建JWT
*
* @param authentication 用户认证信息
* @param rememberMe 记住我
* @return JWT
*/
public String createJWT(Authentication authentication, Boolean rememberMe) {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
return createJWT(rememberMe, userPrincipal.getId(), userPrincipal.getUsername(), userPrincipal.getRoles(), userPrincipal.getAuthorities());
}
/**
* 创建JWT
*
* @param rememberMe 记住我
* @param id 用户id
* @param subject 用户名
* @param roles 用户角色
* @param authorities 用户权限
* @return JWT
*/
public String createJWT(Boolean rememberMe, Long id, String subject, List<String> roles, Collection<? extends GrantedAuthority> authorities) {
Date now = new Date();
//生成JWT的时间
long nowMillis = System.currentTimeMillis();
// 生成加密key
SecretKey key = generalKey();
// 为payload添加各种标准声明和私有声明了
JwtBuilder builder = Jwts.builder()
// 设置jti(JWT ID):是JWT的唯一标识,从而回避重放攻击。
.setId(id.toString())
// sub代表这个JWT的主体,即它的所有人。
.setSubject(subject)
// jwt签收者
.setIssuedAt(now)
// 设置签名使用的签名算法和签名使用的秘钥
.signWith(SignatureAlgorithm.HS256, key)
.claim("roles", roles) // 创建Payload
.claim("authorities", authorities);
// 设置过期时间
Long ttlMillis = rememberMe ? CommonConstant.JWT_REMEMBER : CommonConstant.JWT_TTL;
if (ttlMillis > 0) {
long expMillis = nowMillis + ttlMillis;
Date exp = new Date(expMillis);
builder.setExpiration(exp);
}
String jwt = builder.compact();
// 将生成的JWT保存至Redis
stringRedisTemplate.opsForValue()
.set(CommonConstant.REDIS_JWT_KEY_PREFIX + subject, jwt, ttlMillis, TimeUnit.MILLISECONDS);
return jwt;
}
/**
* 从 request 的 header 中获取 JWT
*
* @param request 请求
* @return JWT
*/
public String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader(CommonConstant.JWT_HEADER);
if (!StringUtils.isEmpty(bearerToken) && bearerToken.startsWith(CommonConstant.JWT_BEARER)) {
return bearerToken.substring(7);
}
return null;
}
/**
* 从令牌获取用户名
*
* @param token 令牌
* @return 用户名
*/
public String getUsernameFromToken(String token) {
// 从令牌中获取用户名
final Claims claims = getClaimsFromToken(token);
// 从要求中获取主题(用户名)
return claims.getSubject();
}
/**
* 解析JWT
*
* @param token JWT
* @return {@link Claims}
*/
private Claims getClaimsFromToken(String token) {
try {
// 签名秘钥,和生成的签名的秘钥一模一样
SecretKey key = generalKey();
Claims claims = Jwts.parser() // 得到DefaultJwtParser
.setSigningKey(key) // 设置签名的秘钥
.parseClaimsJws(token).getBody(); // 设置需要解析的jwt
// 获取用户名
String username = claims.getSubject();
// 申明redis存储key
String redisKey = CommonConstant.REDIS_JWT_KEY_PREFIX + username;
// 校验redis中的JWT是否存在
Long expire = stringRedisTemplate.getExpire(redisKey, TimeUnit.MILLISECONDS);
if (Objects.isNull(expire) || expire <= 0) {
throw new SecurityException(HttpStatusCodeEnum.TOKEN_EXPIRED);
}
// 校验redis中的JWT是否与当前的一致,不一致则代表用户已注销/用户在不同设备登录,均代表JWT已过期
String redisToken = stringRedisTemplate.opsForValue()
.get(redisKey);
if (!token.equals(redisToken)) {
throw new SecurityException(HttpStatusCodeEnum.TOKEN_OUT_OF_CTRL);
}
return claims;
} catch (ExpiredJwtException e) {
log.error("Token 已过期");
throw new SecurityException(HttpStatusCodeEnum.TOKEN_EXPIRED);
} catch (UnsupportedJwtException e) {
log.error("不支持的 Token");
throw new SecurityException(HttpStatusCodeEnum.TOKEN_PARSE_ERROR);
} catch (MalformedJwtException e) {
log.error("Token 无效");
throw new SecurityException(HttpStatusCodeEnum.TOKEN_PARSE_ERROR);
} catch (SignatureException e) {
log.error("无效的 Token 签名");
throw new SecurityException(HttpStatusCodeEnum.TOKEN_PARSE_ERROR);
} catch (IllegalArgumentException e) {
log.error("Token 参数不存在");
throw new SecurityException(HttpStatusCodeEnum.TOKEN_PARSE_ERROR);
}
}
/**
* 由字符串生成加密key
* @return
*/
public SecretKey generalKey(){
// signature签证信息生成, header (base64后的)payload (base64后的) secret
byte[] encodedKey = Base64.decodeBase64(CommonConstant.JWT_KEY);//本地的密码解码[B@152f6e2
// 根据给定的字节数组使用AES加密算法构造一个密钥
SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
return key;
}
/**
* 设置JWT过期
*
* @param request 请求
*/
public void invalidateJWT(HttpServletRequest request) {
String jwt = getJwtFromRequest(request);
String username = getUsernameFromToken(jwt);
// 从redis中清除JWT
stringRedisTemplate.delete(CommonConstant.REDIS_JWT_KEY_PREFIX + username);
}
}
public class JwtUtil3 {
/**
* 创建一个 header 为JWT ,算法为 HS256
* payload
*/
@Test
public void createJwtToken() {
Map<String,Object> headerMap = new HashMap<>();
headerMap.put("typ","JWT");
headerMap.put("alg","HS256");
JwtBuilder builder = Jwts.builder()
.setHeader(headerMap) // 设置请求头
.setIssuer("hikktn")
.setAudience("xiaohong")
.setId("999") //设置唯一编号
.setSubject("小白")//设置主题 可以是JSON数据
.setIssuedAt(new Date())//设置签发日期
.signWith(SignatureAlgorithm.HS256, "hahaha");//设置签名 使用HS256算法,并设置SecretKey(字符串)
//构建 并返回一个字符串
System.out.println(builder.compact());
}
@Test
public void parseJWT(){
String compactJwt ="eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJoaWtrdG4iLCJhdWQiOiJ4aWFvaG9uZyIsImp0aSI6Ijk5OSIsInN1YiI6IuWwj-eZvSIsImlhdCI6MTYyNTk5MTc4N30.wcYmhw7-OyZJYPWIMoc9AQtVhQZHdBiQaDfPsFFo50E";
JwtParser parser = Jwts.parser();
JwtParser jwtParser = parser.setSigningKey("hahaha");
Claims claims = jwtParser.parseClaimsJws(compactJwt).getBody();
System.out.println(claims);
JwsHeader header = jwtParser.parseClaimsJws(compactJwt).getHeader();
System.out.println(header);
String signature = jwtParser.parseClaimsJws(compactJwt).getSignature();
System.out.println(signature);
}
/**
* Header(请求头)
* {“typ”:“JWT”,“alg”:“HS256”}
* 类型:JWT,算法:HS256
*/
/**
* Payload(负载) 参数自定义
* 例子:{“sub”:“123”,“name”:“Tom”,“admin”:true}
* 可以选择使用的参数:
* iss(issuer): jwt签发者
* sub(subject): jwt所面向的用户,主题
* aud(audience): 接收jwt的一方
* exp(expiration time): jwt的过期时间,这个过期时间必须要大于签发时间
* nbf(Not Before): 生效时间
* iat(Issued At): jwt的签发时间
* jti(JWT ID): jwt的唯一身份标识,主要用来作为一次性token。
* 这组数据被定为claim(要求)使用
*/
/**
* signature(签名)
* header和payload以JSON格式转换为base64,逗号拼接后根据header里的算法加密后,得到的签名
* 组成部分:
* header (base64后的)
* payload (base64后的)
* secret(盐) 自定义
* 盐就是你的私钥,可以进行自定义
*/
}
具体实现
思路和问题
我的学习旅程,拿到一个项目,里面没有任何文档说明,只能硬着头皮理解代码,而想要理解代码,就必须掌握很多作者的技术,关键是我没有使用或学习过的经验,这之前为了消化这个项目,我的选择是先收集一些专门争对这个项目的技术入门,完成一到二个demo。
然后按照自己的思路结合前面的demo,进行改进项目,一边思考一边学习和理解这个项目,过程中却是痛苦的。
各种因为我的代码和作者的代码不同,出现各种错误异常抛出,先后出现了不下五种异常,加之这个有bug代码移植到新的机子上,又出现了各种版本引起的环境问题。
说说我遇到卡人的问题把!
先是用WinMerge比较两个文件差异,可是并没有解决问题。
重新安装软件redis和mysql。
问题一:Authentication 获取null
首先debug到源码中,一直抛出这个异常,后面一步步检查代码,发现我是用的方法获取到空值。
AbstractUserDetailsAuthenticationProvider
就是这里我之前的代码判断是没有非空,该死!这个方法替换的时候,写错了。
问题二:DisabledException: User is disabled
获取的用户一直为null,经过盘查后,发现UserPrincipal类,一旦继承了User类,那么你就得自己重新创建属性,并且类型和父类一样,而传值也是一个一个传,千万不要写一个User类,构造函数后传入UserPrincipal类,那样你就会和我一样愚蠢,一些数据就是传不进去。
没遇到这个异常前,我根本不理解为什么要这么写,遇到这个问题后,才理解了。
还有一个问题,在isEnabled()方法,我一开始写的return false,导致验证用户直接被禁用。
先放开验证。
@Override
public boolean isEnabled() {
return true;
}
开始
数据表和数据准备
/*
Navicat Premium Data Transfer
Source Server : localhost
Source Server Type : MySQL
Source Server Version : 80026
Source Host : localhost:3306
Source Schema : sso_demo
Target Server Type : MySQL
Target Server Version : 80026
File Encoding : 65001
Date: 23/08/2021 01:05:33
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for sec_permission
-- ----------------------------
DROP TABLE IF EXISTS `sec_permission`;
CREATE TABLE `sec_permission` (
`id` bigint NOT NULL COMMENT '主键',
`name` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '权限名',
`url` varchar(1000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '类型为页面时,代表前端路由地址,类型为按钮时,代表后端接口地址',
`type` int NOT NULL COMMENT '权限类型,页面-1,按钮-2',
`permission` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '权限表达式',
`method` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '后端接口访问方式',
`sort` int NOT NULL COMMENT '排序',
`parent_id` bigint NOT NULL COMMENT '父级id',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '权限表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sec_permission
-- ----------------------------
INSERT INTO `sec_permission` VALUES (1072806379288399872, '测试页面', '/test', 1, 'page:test', NULL, 1, 0);
INSERT INTO `sec_permission` VALUES (1072806379313565696, '测试页面-查询', '/**/test', 2, 'btn:test:query', 'GET', 1, 1072806379288399872);
INSERT INTO `sec_permission` VALUES (1072806379330342912, '测试页面-添加', '/**/test', 2, 'btn:test:insert', 'POST', 2, 1072806379288399872);
INSERT INTO `sec_permission` VALUES (1072806379342925824, '监控在线用户页面', '/monitor', 1, 'page:monitor:online', NULL, 2, 0);
INSERT INTO `sec_permission` VALUES (1072806379363897344, '在线用户页面-查询', '/**/api/monitor/online/user', 2, 'btn:monitor:online:query', 'GET', 1, 1072806379342925824);
INSERT INTO `sec_permission` VALUES (1072806379384868864, '在线用户页面-踢出', '/**/api/monitor/online/user/kickout', 2, 'btn:monitor:online:kickout', 'DELETE', 2, 1072806379342925824);
-- ----------------------------
-- Table structure for sec_role
-- ----------------------------
DROP TABLE IF EXISTS `sec_role`;
CREATE TABLE `sec_role` (
`id` bigint NOT NULL COMMENT '主键',
`name` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '角色名',
`description` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '描述',
`create_time` date NOT NULL COMMENT '创建时间',
`update_time` date NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `name`(`name`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '角色表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sec_role
-- ----------------------------
INSERT INTO `sec_role` VALUES (1072806379208708096, '管理员', '超级管理员', '2021-08-22', '2021-08-22');
INSERT INTO `sec_role` VALUES (1072806379238068224, '普通用户', '普通用户', '2021-08-22', '2021-08-22');
-- ----------------------------
-- Table structure for sec_role_permission
-- ----------------------------
DROP TABLE IF EXISTS `sec_role_permission`;
CREATE TABLE `sec_role_permission` (
`role_id` bigint NOT NULL COMMENT '角色主键',
`permission_id` bigint NOT NULL COMMENT '权限主键',
PRIMARY KEY (`role_id`, `permission_id`) USING BTREE,
INDEX `FKpin61ltb1uniw17shihq1cove`(`permission_id`) USING BTREE,
CONSTRAINT `FK2btquksd4tgtj9a8pn6qyeubl` FOREIGN KEY (`role_id`) REFERENCES `sec_role` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT,
CONSTRAINT `FKpin61ltb1uniw17shihq1cove` FOREIGN KEY (`permission_id`) REFERENCES `sec_permission` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '角色权限关系表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sec_role_permission
-- ----------------------------
INSERT INTO `sec_role_permission` VALUES (1072806379208708096, 1072806379288399872);
INSERT INTO `sec_role_permission` VALUES (1072806379238068224, 1072806379288399872);
INSERT INTO `sec_role_permission` VALUES (1072806379208708096, 1072806379313565696);
INSERT INTO `sec_role_permission` VALUES (1072806379238068224, 1072806379313565696);
INSERT INTO `sec_role_permission` VALUES (1072806379208708096, 1072806379330342912);
INSERT INTO `sec_role_permission` VALUES (1072806379208708096, 1072806379342925824);
INSERT INTO `sec_role_permission` VALUES (1072806379208708096, 1072806379363897344);
INSERT INTO `sec_role_permission` VALUES (1072806379208708096, 1072806379384868864);
-- ----------------------------
-- Table structure for sec_user
-- ----------------------------
DROP TABLE IF EXISTS `sec_user`;
CREATE TABLE `sec_user` (
`id` bigint NOT NULL COMMENT '主键',
`username` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '用户名',
`password` varchar(60) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '密码',
`nickname` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '昵称',
`phone` varchar(11) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '手机',
`email` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '邮箱',
`birthday` bigint NULL DEFAULT NULL COMMENT '生日',
`sex` int NULL DEFAULT NULL COMMENT '性别,男-1,女-2',
`status` int NOT NULL DEFAULT 1 COMMENT '状态,启用-1,禁用-0',
`create_time` date NOT NULL COMMENT '创建时间',
`update_time` date NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `username`(`username`) USING BTREE,
UNIQUE INDEX `phone`(`phone`) USING BTREE,
UNIQUE INDEX `email`(`email`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '用户表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sec_user
-- ----------------------------
INSERT INTO `sec_user` VALUES (1072806377661009920, 'admin', '$2a$10$2xC9NWUJxgCWwjJtADT3mOY3uekARVMbRiXWntKZSFsSf5ugP9K2G', '管理员', '17300000000', 'admin@xkcoding.com', 785433600000, 1, 1, '2021-08-22', '2021-08-22');
INSERT INTO `sec_user` VALUES (1072806378780889088, 'user', '$2a$10$OUDl4thpcHfs7WZ1kMUOb.ZO5eD4QANW5E.cexBLiKDIzDNt87QbO', '普通用户', '17300001111', 'user@xkcoding.com', 785433600000, 1, 1, '2021-08-22', '2021-08-22');
-- ----------------------------
-- Table structure for sec_user_role
-- ----------------------------
DROP TABLE IF EXISTS `sec_user_role`;
CREATE TABLE `sec_user_role` (
`user_id` bigint NOT NULL COMMENT '用户主键',
`role_id` bigint NOT NULL COMMENT '角色主键',
PRIMARY KEY (`user_id`, `role_id`) USING BTREE,
INDEX `FKfowkd8vw5qarh8b8y9noaf4et`(`role_id`) USING BTREE,
CONSTRAINT `FK835bbyiy6majrolcov7bp0yo0` FOREIGN KEY (`user_id`) REFERENCES `sec_user` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT,
CONSTRAINT `FKfowkd8vw5qarh8b8y9noaf4et` FOREIGN KEY (`role_id`) REFERENCES `sec_role` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '用户角色关系表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sec_user_role
-- ----------------------------
INSERT INTO `sec_user_role` VALUES (1072806377661009920, 1072806379208708096);
INSERT INTO `sec_user_role` VALUES (1072806378780889088, 1072806379238068224);
SET FOREIGN_KEY_CHECKS = 1;
思路
接下来,只讲解代码的核心思路,不会把全部代码贴出来,当然代码还是会分享出来,并且这么测试也会放出来。
首先我想玩玩JPA多对多,所以尝试了@ManyToMany。
上面就是我改动的代码,下面就是原本的代码。
这里是上面代码的主要逻辑。
«interface» UserDao +findByUsernameOrEmailOrPhone(username,email,phone) SecUser +List<SecRole> roleInfo; SecRole +List<SecPermission> permissionInfo; SecPermission 调用roleInfo
下面写Security主要的业务逻辑。
AuthController +login(username,password,rememberme) «abstract» WebSecurityConfigurerAdapter SpringSecurityConfig +configure(http) RbacAuthorityService +hasPermission(request,authentication) SecurityException +SecurityException(code,msg) GlobalExceptionHandler +handlerException(e) JwtAuthenticationFilter +doFilterInternal(request,response,filterChain) CustomAccessDeniedHandler +handle() CustomUserDetailsService +loadUserByUsername(username) UserPrincipal +create(user,roleInfo,permissionInfo) 依赖 继承 调用hasPermission()检验请求和权限 调用checkRequest(request)校验请求是否存在 全局异常拦截返回 url校验通过 调用doFilterInternal() 调用handle()无权限响应内容 调用loadUserByUsername()查询用户和密码 调用create(user,roleInfo,permissionInfo) 回调,返回结果
以及重要的url校验过程
SecurityConfig +CustomConfig customConfig +configure(web) CustomConfig -IgnoreConfig ignores IgnoreConfig - pattern -GET -POST ... 调用getIgnores()读取application.yml配置的放行URL和校验的URL 调用每个请求方式,设置请求方式,例如:get/post请求
源码分享
链接:https://pan.baidu.com/s/1w6Ui4zogDZagfYM4mSemlA 提取码:21y4
测试
使用Postman测试