匹配系统开发要点
匹配系统开发要点
Jin封装请求响应结果和异常处理
响应数据封装
接口响应给前端的数据统一风格
{
"code": 200,
"data": true,
"msg": "ok",
"description": null
}
代码实现:使用泛型传入返回 data 里的数据类型
@Data
public class BaseResponse<T> implements Serializable {
private static final long serialVersionUID = -1053725934018053785L;
private int code;
private T data;
private String msg;
private String description;
public BaseResponse(int code, T data, String msg) {
this.code = code;
this.data = data;
this.msg = msg;
}
public BaseResponse(int code, T data) {
this(code,data,"");
}
public BaseResponse(int code, T data, String msg, String description) {
this.code = code;
this.data = data;
this.msg = msg;
this.description = description;
}
public BaseResponse() {
}
public BaseResponse(ErrorCode errorCode){
this(errorCode.getCode(),null,errorCode.getMsg(),errorCode.getDescription());
}
}
封装自己的状态码枚举类型
package com.jin.partner.common;
/**
* @author fantasy
*/
public enum ErrorCode {
SUCCESS(200,"ok",""),
PARAMS_ERROR(40000,"params error","参数错误"),
NULL_ERROR(40001,"null error","请求数据为空"),
NO_LOGIN(40100,"no login","没有登陆"),
NO_AUTH(40101,"no auth","无权限"),
SYSTEM_ERROR(5000,"SYSTEM ERROR","系统内部错误")
;
private final int code;
private final String msg;
private final String description;
ErrorCode(int code, String msg, String description) {
this.code = code;
this.msg = msg;
this.description = description;
}
public int getCode() {
return code;
}
public String getMsg() {
return msg;
}
public String getDescription() {
return description;
}
}
封装返回工具类
public class ResultUtil {
/**
* 成功 200 ok
* @param data
* @param <T>
* @return
*/
public static <T> BaseResponse<T> success(T data) {
return new BaseResponse<>(200, data, "ok");
}
/**
* 失败 data:null
* @param errorCode
* @param <T>
* @return
*/
public static <T> BaseResponse<T> error(ErrorCode errorCode) {
return new BaseResponse<>(errorCode);
}
public static BaseResponse error(int code, String msg, String dsc) {
return new BaseResponse<>(code, null, msg, dsc);
}
public static BaseResponse error(ErrorCode errorCode, String msg, String dsc) {
return new BaseResponse<>(errorCode.getCode(), null, msg, dsc);
}
public static BaseResponse error(ErrorCode errorCode, String dsc) {
return new BaseResponse<>(errorCode.getCode(), null, errorCode.getMsg(), dsc);
}
}
使用例子
// 返回类型:BaseResponse<UserVo>
// 使用工具类返回 ResultUtil.success(safetyUser);
@GetMapping("/current")
public BaseResponse<UserVo> getCurrentUser(HttpServletRequest request){
User user = userService.getLoginUser(request);
if(user==null){
throw new BusinessException(ErrorCode.NULL_ERROR);
}
Long userId = user.getId();
User byIdUser = userService.getById(userId);
UserVo safetyUser = userService.getSafetyUser(byIdUser);
return ResultUtil.success(safetyUser);
}
全局异常响应封装
如果我们代码出问题了,抛出异常,不能直接把系统的异常信息都响应给前端(不安全)
所以封装自定义异常,明确什么是异常,再捕获代码的异常 集中处理,处理后再响应给前端请求
封装异常
public class BusinessException extends RuntimeException {
private static final long serialVersionUID = -8228470992635963684L;
private final int code;
private final String description;
public BusinessException(String message, int code, String description) {
super(message);
this.code = code;
this.description = description;
}
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMsg());
this.code = errorCode.getCode();
this.description = errorCode.getDescription();
}
public BusinessException(ErrorCode errorCode ,String description) {
super(errorCode.getMsg());
this.code = errorCode.getCode();
this.description = description;
}
public int getCode() {
return code;
}
public String getDescription() {
return description;
}
}
异常处理
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public BaseResponse businessExceptionHandler(BusinessException e){
log.error("BusinessException: "+ e.getMessage(),e);
// 发生异常;利用上面封装的响应工具类响应给前端
return ResultUtil.error(e.getCode(),e.getMessage(), e.getDescription());
}
@ExceptionHandler(RuntimeException.class)
public BaseResponse runtimeExceptionHandler(BusinessException e){
log.error("runtimeException: "+ e);
return ResultUtil.error(ErrorCode.SYSTEM_ERROR,e.getMessage(), "");
}
}
查询数据库数据
查询用户标签
sql 处理
实现简单,sql 拆分查询处理
如果参数可以分析,根据用户的参数去选择查询方式,比如标签数
/**
* sql 模糊查询标签
* @param tagNameList 用户标签
* @return 用户列表
*/
private List<User> searchTagsSql(List<String> tagNameList) {
// 查询
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
//拼接tag
// like '%Java%' and like '%Python%'
for (String tagList : tagNameList) {
queryWrapper = queryWrapper.like("tag", tagList);
}
List<User> users = userMapper.selectList(queryWrapper);
List<User> userList = users.stream().collect(Collectors.toList());
return userList;
}
内存查询
灵活通过并发优化
如果参数不可分析,并且数据库连接足够、内存空间足够,可以并发同时查询,谁先返回用谁
还可以 SQL 查询与内存计算相结合,比如先用 SQL 过滤掉部分 tag
/**
* 搜索标签内存处理
*
* @param tagNameList 标记名称列表
* @return {@link List}<{@link User}>
*/
private Page<User> searchTagsMemory(List<String> tagNameList){
// 查询所有用户
QueryWrapper<User> wrapper = new QueryWrapper<User>().ne("tag", "[]");
List<User> userList = userMapper.selectList(wrapper);
// 内存判断
Gson gson = new Gson();
Page<User> page = new Page<>(pageListRequest.getPageNum(), pageListRequest.getPageSize());
Page<User> userPage = page.setRecords(userList.stream().filter(user -> {
String tag = user.getTag();
// json 反系列化
Set<String> tempTagNameSet = gson.fromJson(tag, new TypeToken<Set<String>>() {
}.getType());
// 判空
tempTagNameSet = Optional.ofNullable(tempTagNameSet).orElse(new HashSet<>());
for (String tagName : tagNameList) {
// 判断查询出来的标签用户是否拥有
// noneMath 遍历流中的元素,一旦发现有任何一个元素满足指定的条件,就会立即返回 false
if (tempTagNameSet
.stream()
.noneMatch(tempTagName -> tempTagName.equalsIgnoreCase(tagName))) {
return false;
}
}
return true;
}).collect(Collectors.toList()));
return userPage;
}
分布式 session
如果我们在服务器 A 登陆后,后面请求发送到服务器 B,会请求不到之前存储的 session 域的用户信息
原因:我们把用户信息存储到服务器 A 内存上
解决方法:不要把用户信息存在单个服务器内存上,而是存在公共存储上
使用 redis 公共存储用户信息
Redis(基于内存的 K / V 数据库)
springboot 使用 redis
- 引用 redis 依赖
<!-- session-data-redis -->
<!-- https://mvnrepository.com/artifact/org.springframework.session/spring-session-data-redis -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<version>2.6.3</version>
</dependency>
<!-- session-data-redis:spring-session 和 redis 的整合-->
<!-- https://mvnrepository.com/artifact/org.springframework.session/spring-session-data-redis -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<version>2.6.3</version>
</dependency>
修改 session 存储位置
默认是 none,表示存储在单台服务器
store-type: redis,表示从 redis 读写 session
spring: session: store-type: redis redis: port: 6379 host: localhost database: 1 password: 123456 #默认没密码
domain注意点:
比如两个域名:
aaa.yupi.com
bbb.yupi.com
如果要共享 cookie,可以种一个更高层的公共域名,比如 yupi.com
server:
session:
cookie:
domain: yupi.com
查看是否配置成功:开两个服务,8080,8081 在8080登陆,8081是否可以获取用户信息,查看redis是否多了redis 的数据
数据缓存 Redis
缓存可使用:
- Redis(分布式缓存)
- memcached(分布式)
- Etcd(云原生架构的一个分布式存储,存储配置,扩容能力)
- ehcache(单机)
- 本地缓存(Java 内存 Map)
- Caffeine(Java 内存缓存,高性能)
- Google Guava
实现方法
Spring-Data-Redis
Spring Data:
通用的数据访问框架,定义了一组 增删改查 的接口mysql、redis、jpa
Jedis:
(独立于 Spring 操作 Redis 的 Java 客户端,要配合 Jedis Pool 使用)
Lettuce
高阶的操作 Redis 的 Java 客户端
异步、连接池
Redisson
- 分布式操作 Redis 的 Java 客户端,让你像在使用本地的集合一样操作 Redis(分布式 Redis 数据网格)
使用 spring-data-redis
依赖
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.6.4</version>
</dependency>
配置文件
spring:
redis:
port: 6379
host: localhost
database: 1
password: 123456 #默认没密码
自定义序列化
@Configuration
public class RedisTemplateConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
//创建RedisTemplate对象
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
//设置连接工厂
redisTemplate.setConnectionFactory(connectionFactory);
// 自定义序列化 key
redisTemplate.setKeySerializer(RedisSerializer.string());
return redisTemplate;
}
}
缓存数据
缓存数据 key :例如 Jin.user.recommend;不能与其他冲突
redis 内存不能无限增加,一定要设置过期时间
@Resource
private RedisTemplate redisTemplate;
@GetMapping("/recommend")
public BaseResponse<IPage<UserVo>> recommendUserList(PageListRequest pageListRequest, HttpServletRequest request){
User loginUser = userService.getLoginUser(request);
// 缓存 key
String redisKey = String.format("jin:user:recommend:%s",loginUser.getId());
ValueOperations valueOperations = redisTemplate.opsForValue();
//如果有缓存,直接读取
Page<UserVo> userPage = (Page<UserVo>) valueOperations.get(redisKey);
if (userPage != null){
return ResultUtil.success(userPage);
}
// 无缓存查数据库
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
Page<User> pageInfo = new Page<>(pageListRequest.getPageNum(), pageListRequest.getPageSize());
Page<User> userList = userService.page(pageInfo, queryWrapper);
// 数据脱敏
IPage<UserVo> userVoIPage = userList.convert(e -> {
UserVo userVo = new UserVo();
BeanUtils.copyProperties(e, userVo);
return userVo;
});
//写缓存,10分钟过期
try {
valueOperations.set(redisKey,userVoIPage,600000, TimeUnit.MILLISECONDS);
} catch (Exception e) {
e.printStackTrace();
}
return ResultUtil.success(userVoIPage);
}
定时任务缓存数据
用定时任务,每天刷新所有用户的推荐列表
注意点:
- 缓存预热的意义(新增少、总用户多)
- 缓存的空间不能太大,要预留给其他缓存空间
- 缓存数据的周期(此处每天一次)
使用 Spring Scheduler(spring boot 默认整合了)
使用方式:
- 主类开启
@EnableScheduling
- 给要定时执行的方法添加
@Scheduling
注解,指定 cron 表达式或者执行频率
@Component
@Slf4j
public class PreCacheJob {
@Resource
private UserService userService;
@Resource
private RedisTemplate<String, Object> redisTemplate;
// 重点用户
private List<Long> mainUserList = Arrays.asList(1L);
// 每天执行,预热推荐用户
@Scheduled(cron = "0 12 1 * * *") //自己设置时间测试
public void doCacheRecommendUser() {
//查数据库
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
Page<User> userPage = userService.page(new Page<>(1,10),queryWrapper);
String redisKey = String.format("jin:user:recommend:%s",mainUserList);
ValueOperations valueOperations = redisTemplate.opsForValue();
// 数据脱敏
IPage<UserVo> userVoIPage = userPage.convert(e -> {
UserVo userVo = new UserVo();
BeanUtils.copyProperties(e, userVo);
return userVo;
});
//写缓存,10分钟过期
try {
valueOperations.set(redisKey,userVoIPage,600000, TimeUnit.MILLISECONDS);
} catch (Exception e){
log.error("redis set key error",e);
}
}
}
cron 表达式
redis 实现分布式锁
使用 Redisson 是一个 java 操作 Redis 的客户端
直接引用
添加依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.19.1</version>
</dependency>
配置文件
/**
* Redisson 配置
*/
@Configuration
@ConfigurationProperties(prefix = "spring.redis")
@Data
public class RedissonConfig {
private String host;
private String port;
@Bean
public RedissonClient redissonClient() {
// 1. 创建配置
Config config = new Config();
String redisAddress = String.format("redis://%s:%s", host, port);
// 使用单个Redis,没有开集群 useClusterServers() 设置地址和使用库
config.useSingleServer().setAddress(redisAddress).setDatabase(1);
// 2. 创建实例
RedissonClient redisson = Redisson.create(config);
return redisson;
}
}
简单使用
@SpringBootTest
public class RedissonTest {
@Resource
private RedissonClient redissonClient;
@Test
void test() {
// list,数据存在本地 JVM 内存中
List<String> list = new ArrayList<>();
list.add("yupi");
System.out.println("list:" + list.get(0));
list.remove(0);
// 数据存在 redis 的内存中
RList<String> rList = redissonClient.getList("test-list");
rList.add("yupi");
System.out.println("rlist:" + rList.get(0));
rList.remove(0);
// map
Map<String, Integer> map = new HashMap<>();
map.put("yupi", 10);
map.get("yupi");
RMap<Object, Object> map1 = redissonClient.getMap("test-map");
// set
// stack
}
}
分布式锁实现
实现方法:锁+定时任务
waitTime 设置为 0,只抢一次,抢不到就放弃
@Component
@Slf4j
public class PreCacheJob {
@Resource
private UserService userService;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private RedissonClient redissonClient;
// 重点用户
private List<Long> mainUserList = Arrays.asList(1L);
// 每天执行,预热推荐用户
@Scheduled(cron = "0 12 1 * * *") //自己设置时间测试
public void doCacheRecommendUser() {
RLock lock = redissonClient.getLock("jin:precachejob:docache:lock");
try {
// 只有一个线程能获取到锁
if (lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) {
// System.out.println("getLock: " + Thread.currentThread().getId());
for (Long userId : mainUserList) {
//查数据库
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
Page<User> userPage = userService.page(new Page<>(1, 10), queryWrapper);
String redisKey = String.format("shayu:user:recommend:%s", mainUserList);
ValueOperations valueOperations = redisTemplate.opsForValue();
// 数据脱敏
IPage<UserVo> userVoIPage = userPage.convert(e -> {
UserVo userVo = new UserVo();
BeanUtils.copyProperties(e, userVo);
return userVo;
});
//写缓存,30s过期
try {
valueOperations.set(redisKey, userVoIPage, 30000, TimeUnit.MILLISECONDS);
} catch (Exception e) {
log.error("redis set key error", e);
}
}
}
} catch (InterruptedException e) {
log.error("doCacheRecommendUser error", e);
} finally {
// 只能释放自己的锁
if (lock.isHeldByCurrentThread()) {
System.out.println("unLock: " + Thread.currentThread().getId());
lock.unlock();
}
}
}
}
看门狗机制(逾期问题)
开一个监听线程,如果方法还没执行完,就帮你重置 redis 锁的过期时间。
原理:
- 监听当前线程,默认过期时间是 30 秒,每 10 秒续期一次(补到 30 秒)
- 如果线程挂掉(注意 debug 模式也会被它当成服务器宕机),则不会续期
参考连接:https://blog.csdn.net/qq_26222859/article/details/79645203
匹配算法
匹配方法:根据用户标签去匹配相似度,共同标签
- 找到有共同标签最多的用户(TopN)
- 共同标签越多,分数越高,越排在前面
- 如果没有匹配的用户,随机推荐几个(降级方案)
编辑距离算法:https://blog.csdn.net/DBC_121/article/details/104198838
最小编辑距离:字符串 1 通过最少多少次增删改字符的操作可以变成字符串 2
余弦相似度算法:https://blog.csdn.net/m0_55613022/article/details/125683937(如果需要带权重计算,比如学什么方向最重要,性别相对次要)
最短距离算法
编辑距离算法(用于计算最相似的两组标签)
原理:https://blog.csdn.net/DBC_121/article/details/104198838
package com.yupi.usercenter.utlis;
import java.util.List;
import java.util.Objects;
/**
* 算法工具类
*
* @author yupi
*/
public class AlgorithmUtils {
/**
* 编辑距离算法(用于计算最相似的两组标签)
* 原理:https://blog.csdn.net/DBC_121/article/details/104198838
*
* @param tagList1
* @param tagList2
* @return
*/
public static int minDistance(List<String> tagList1, List<String> tagList2) {
int n = tagList1.size();
int m = tagList2.size();
if (n * m == 0) {
return n + m;
}
int[][] d = new int[n + 1][m + 1];
for (int i = 0; i < n + 1; i++) {
d[i][0] = i;
}
for (int j = 0; j < m + 1; j++) {
d[0][j] = j;
}
for (int i = 1; i < n + 1; i++) {
for (int j = 1; j < m + 1; j++) {
int left = d[i - 1][j] + 1;
int down = d[i][j - 1] + 1;
int left_down = d[i - 1][j - 1];
if (!Objects.equals(tagList1.get(i - 1), tagList2.get(j - 1))) {
left_down += 1;
}
d[i][j] = Math.min(left, Math.min(down, left_down));
}
}
return d[n][m];
}
/**
* 编辑距离算法(用于计算最相似的两个字符串)
* 原理:https://blog.csdn.net/DBC_121/article/details/104198838
*
* @param word1
* @param word2
* @return
*/
public static int minDistance(String word1, String word2) {
int n = word1.length();
int m = word2.length();
if (n * m == 0) {
return n + m;
}
int[][] d = new int[n + 1][m + 1];
for (int i = 0; i < n + 1; i++) {
d[i][0] = i;
}
for (int j = 0; j < m + 1; j++) {
d[0][j] = j;
}
for (int i = 1; i < n + 1; i++) {
for (int j = 1; j < m + 1; j++) {
int left = d[i - 1][j] + 1;
int down = d[i][j - 1] + 1;
int left_down = d[i - 1][j - 1];
if (word1.charAt(i - 1) != word2.charAt(j - 1)) {
left_down += 1;
}
d[i][j] = Math.min(left, Math.min(down, left_down));
}
}
return d[n][m];
}
}
实现代码
public IPage<UserVo> getMatchUser(PageListRequest pageInfo, User loginUser) {
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.isNotNull("tag");
queryWrapper.ne("tag", "[]");
queryWrapper.select("id", "tag");
// 获取当前标签
String loginUserTag = loginUser.getTag();
Gson gson = new Gson();
List<String> tagList = gson.fromJson(loginUserTag, new TypeToken<List<String>>() {}.getType());
// 计算所有用户的相似度,并将用户及其相似度保存在一个 Map 中
// 获取用户处理
HashMap<User, Integer> userScoreMap = new HashMap<>();
this.list(queryWrapper).stream()
.filter(user -> !user.getId().equals(loginUser.getId())) // 排除当前登录用户
.forEach(user -> userScoreMap.put(user, AlgorithmUtils.minDistance(gson.fromJson(user.getTag(), new TypeToken<List<String>>() {
}.getType()), tagList)));
// 对 Map 中的用户进行排序
List<User> sortedUserList = sortMap(userScoreMap);
List<Long> validUserIdData = sortedUserList.stream().map(User::getId).collect(Collectors.toList());
// 分页查询用户
Page<User> page = new Page<>(pageInfo.getPageNum(), pageInfo.getPageSize());
QueryWrapper<User> userQueryWrapper = new QueryWrapper<User>();
// 根据上面 validUserIdData 数组查询相关 id 的用户,
// 在数组 validUserIdData 中的顺序进行升序排序
// `FIELD()` 函数,该根据 `id` 值在 `validUserIdData` 数组中的顺序进行升序排列
userQueryWrapper
.in("id", validUserIdData)
.orderByAsc("FIELD(id, " + StringUtils.join(validUserIdData, ",") + ")");
Page<User> userPageList = this.page(page, userQueryWrapper);
if (userPageList == null) {
throw new BusinessException(ErrorCode.NULL_ERROR, "查询失败");
}
return userPageList.convert(this::getSafetyUser);
}
/**
* map 排序
*
* @param map 需要排序的map 集合
* @return 排好序的list
*/
public static List<User> sortMap(Map<User, Integer> map) {
//利用Map的entrySet方法,转化为list进行排序
List<Map.Entry<User, Integer>> entryList = new ArrayList<>(map.entrySet());
//利用Collections的sort方法对list排序
entryList.sort(Comparator.comparingInt(Map.Entry::getValue));
List<User> userList = new ArrayList<>();
for (Map.Entry<User, Integer> e : entryList) {
userList.add(e.getKey());
}
return userList;
}
实用工具
json 解析
序列化:java对象转成 json
反序列化:把 json 转为 java 对象
- gson(google )
- fastjson alibaba(阿里 出品,快,但是漏洞太多)
- jackson
- kryo
gson 使用方法
参考连接:https://juejin.cn/post/7106058260163067934
接口文档
Swagger + Knife4j :https://doc.xiaominfo.com/knife4j/documentation/get_start.html
导入依赖
<!-- knife4j 接口文档 -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<version>2.0.7</version>
</dependency>
创建配置文件 swaggerConfig
@Configuration
@EnableSwagger2WebMvc
@Profile({"dev", "test"}) //版本控制访问
public class SwaggerConfig {
@Bean(value = "defaultApi2")
public Docket defaultApi2() {
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo())
.select()
// 这里一定要标注你控制器的位置
.apis(RequestHandlerSelectors.basePackage("com.jin.usercenter.controller"))
.paths(PathSelectors.any())
.build();
}
/**
* api 信息
* @return
*/
// 基本信息设置
private ApiInfo apiInfo() {
Contact contact = new Contact(
"jin", // 作者姓名
"blog.luckjin.top", // 作者网址
"1768606916@qq.com"); // 作者邮箱
return new ApiInfoBuilder()
.title("匹配系统接口文档") // 标题
.description("匹配系统接口文档") // 描述
.termsOfServiceUrl("") // 跳转连接
.version("1.0") // 版本
.contact(contact)
.build();
}
}
springboot配置文件
spring:
mvc:
pathmatch:
matching-strategy: ant_path_matcher
接口的描述配置
在 controller 方法上添加:
- @Api(value = “/team”, tags = {“队伍模块”})
- @ApiImplicitParam(name = “name”,value = “姓名”,required = true)
- @ApiOperation(value = “向客人问好”) 等注解来自定义生成的接口描述信息
推荐使用idea 插件 swagger 自动生成接口描述的注解
https://plugins.jetbrains.com/plugin/14130-swagger-tools
例如下面的代码
/**
* 队伍列表分页查询
* @param teamQuery 查询条件
* @return 返回列表
*/
@ApiOperation(value = "队伍列表分页查询", notes = "队伍列表分页查询", httpMethod = "GET")
@GetMapping("/list/page")
public BaseResponse<Page<TeamUserVO>> getTeamListPage(TeamQuery teamQuery,HttpServletRequest request){
if(ObjectUtils.isEmpty(teamQuery)){
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
User loginUser = userService.getLoginUser(request);
Page<TeamUserVO> teamUserVOS = teamService.teamList(teamQuery,loginUser);
return ResultUtil.success(teamUserVOS);
}
esay excel
java 处理 excel 表格的数据
官方文档:https://easyexcel.opensource.alibaba.com/index.html