feat(feishu): 增加飞书用户同步功能及相关API和定时任务
- 新增飞书用户同步控制器,提供手动全量及按部门同步接口 - 新增飞书员工信息相关DTO,支持飞书API响应数据映射 - 新增飞书员工列表查询请求和响应DTO,支持分页查询功能 - 实现飞书SDK客户端服务,封装调用飞书官方API逻辑 - 实现飞书用户同步服务,支持全量及按部门同步,处理分页与数据持久化 - 增加飞书用户同步定时任务,每天0点自动同步飞书员工信息 - 在主应用类启用计划任务支持(@EnableScheduling) - 优化全局异常处理中Token无效提示信息 - 在BaseResponse增加success和error静态方法便捷创建响应对象 - 支持BusinessException新增仅消息构造方法,简化异常创建 - pom.xml中更新sa-token-redis注释,强调分布式会话持久化用途
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
package cn.yinlihupo.service.system;
|
||||
|
||||
import cn.yinlihupo.domain.dto.feishu.FeishuEmployeeDTO;
|
||||
import cn.yinlihupo.domain.dto.feishu.FeishuEmployeeListRequest;
|
||||
import cn.yinlihupo.domain.dto.feishu.FeishuEmployeeListResponse;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 飞书SDK客户端服务接口
|
||||
* 封装飞书开放平台API调用
|
||||
*/
|
||||
public interface FeishuClientService {
|
||||
|
||||
/**
|
||||
* 查询企业员工列表
|
||||
* 调用飞书 directory/v1/employees/filter 接口
|
||||
*
|
||||
* @param request 查询请求参数
|
||||
* @return 员工列表响应
|
||||
*/
|
||||
FeishuEmployeeListResponse listEmployees(FeishuEmployeeListRequest request);
|
||||
|
||||
/**
|
||||
* 查询所有在职员工
|
||||
* 自动处理分页,返回全部员工列表
|
||||
*
|
||||
* @return 所有在职员工列表
|
||||
*/
|
||||
List<FeishuEmployeeDTO> listAllActiveEmployees();
|
||||
|
||||
/**
|
||||
* 根据部门ID查询员工列表
|
||||
*
|
||||
* @param departmentId 部门ID
|
||||
* @return 员工列表
|
||||
*/
|
||||
List<FeishuEmployeeDTO> listEmployeesByDepartment(String departmentId);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package cn.yinlihupo.service.system;
|
||||
|
||||
import cn.yinlihupo.domain.dto.feishu.FeishuUserSyncResult;
|
||||
|
||||
/**
|
||||
* 飞书用户同步服务接口
|
||||
* 用于将飞书企业员工信息同步到系统用户表
|
||||
*/
|
||||
public interface FeishuUserSyncService {
|
||||
|
||||
/**
|
||||
* 同步所有飞书在职员工到系统
|
||||
* 自动处理分页,获取全部员工数据
|
||||
*
|
||||
* @return 同步结果
|
||||
*/
|
||||
FeishuUserSyncResult syncAllEmployees();
|
||||
|
||||
/**
|
||||
* 根据部门ID同步员工
|
||||
*
|
||||
* @param departmentId 飞书部门ID
|
||||
* @return 同步结果
|
||||
*/
|
||||
FeishuUserSyncResult syncEmployeesByDepartment(String departmentId);
|
||||
|
||||
/**
|
||||
* 同步指定员工
|
||||
*
|
||||
* @param employeeId 飞书员工ID
|
||||
* @return 同步结果
|
||||
*/
|
||||
FeishuUserSyncResult syncEmployeeById(String employeeId);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package cn.yinlihupo.service.system.impl;
|
||||
|
||||
import cn.hutool.http.HttpRequest;
|
||||
import cn.hutool.http.HttpResponse;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import cn.yinlihupo.common.config.FeishuConfig;
|
||||
import cn.yinlihupo.common.exception.BusinessException;
|
||||
import cn.yinlihupo.domain.dto.feishu.FeishuEmployeeDTO;
|
||||
import cn.yinlihupo.domain.dto.feishu.FeishuEmployeeListRequest;
|
||||
import cn.yinlihupo.domain.dto.feishu.FeishuEmployeeListResponse;
|
||||
import cn.yinlihupo.service.system.FeishuClientService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 飞书SDK客户端服务实现类
|
||||
* 使用 Hutool HTTP 直接调用飞书 API,避免 Java 17 模块化反射问题
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FeishuClientServiceImpl implements FeishuClientService {
|
||||
|
||||
private final FeishuConfig feishuConfig;
|
||||
|
||||
// 每页最大查询数量
|
||||
private static final int MAX_PAGE_SIZE = 100;
|
||||
// 员工列表查询接口路径
|
||||
private static final String EMPLOYEES_FILTER_API = "https://open.feishu.cn/open-apis/directory/v1/employees/filter";
|
||||
// 应用访问令牌获取接口
|
||||
private static final String APP_ACCESS_TOKEN_API = "https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal";
|
||||
|
||||
/**
|
||||
* 获取应用访问令牌 (app_access_token)
|
||||
*
|
||||
* @return app_access_token
|
||||
*/
|
||||
private String getAppAccessToken() {
|
||||
try {
|
||||
JSONObject requestBody = new JSONObject();
|
||||
requestBody.set("app_id", feishuConfig.getAppId());
|
||||
requestBody.set("app_secret", feishuConfig.getAppSecret());
|
||||
|
||||
try (HttpResponse response = HttpRequest.post(APP_ACCESS_TOKEN_API)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(requestBody.toString())
|
||||
.execute()) {
|
||||
|
||||
String body = response.body();
|
||||
log.debug("获取应用访问令牌响应: {}", body);
|
||||
|
||||
JSONObject jsonObject = JSONUtil.parseObj(body);
|
||||
if (jsonObject.getInt("code") != 0) {
|
||||
throw new BusinessException("获取应用访问令牌失败: " + jsonObject.getStr("msg"));
|
||||
}
|
||||
|
||||
return jsonObject.getStr("app_access_token");
|
||||
}
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("获取应用访问令牌异常", e);
|
||||
throw new BusinessException("获取应用访问令牌异常: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public FeishuEmployeeListResponse listEmployees(FeishuEmployeeListRequest request) {
|
||||
try {
|
||||
// 1. 获取应用访问令牌
|
||||
String appAccessToken = getAppAccessToken();
|
||||
|
||||
// 2. 将请求对象转换为Map
|
||||
Map<String, Object> body = convertRequestToMap(request);
|
||||
|
||||
// 3. 构建查询参数
|
||||
String url = EMPLOYEES_FILTER_API + "?department_id_type=open_department_id&employee_id_type=open_id";
|
||||
|
||||
// 4. 发起请求
|
||||
try (HttpResponse response = HttpRequest.post(url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer " + appAccessToken)
|
||||
.body(JSONUtil.toJsonStr(body))
|
||||
.execute()) {
|
||||
|
||||
String responseBody = response.body();
|
||||
log.debug("飞书员工列表查询响应: {}", responseBody);
|
||||
|
||||
// 5. 使用Hutool解析JSON
|
||||
FeishuEmployeeListResponse resp = JSONUtil.toBean(responseBody, FeishuEmployeeListResponse.class);
|
||||
|
||||
if (!resp.isSuccess()) {
|
||||
log.error("飞书员工列表查询失败, code: {}, msg: {}", resp.getCode(), resp.getMsg());
|
||||
throw new BusinessException("飞书员工列表查询失败: " + resp.getMsg());
|
||||
}
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("飞书员工列表查询异常", e);
|
||||
throw new BusinessException("飞书员工列表查询异常: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将请求对象转换为Map
|
||||
*/
|
||||
private Map<String, Object> convertRequestToMap(FeishuEmployeeListRequest request) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
|
||||
// 构建filter
|
||||
if (request.getFilter() != null && request.getFilter().getConditions() != null) {
|
||||
Map<String, Object> filter = new HashMap<>();
|
||||
List<Map<String, Object>> conditions = new ArrayList<>();
|
||||
|
||||
for (FeishuEmployeeListRequest.Condition condition : request.getFilter().getConditions()) {
|
||||
Map<String, Object> cond = new HashMap<>();
|
||||
cond.put("field", condition.getField());
|
||||
cond.put("operator", condition.getOperator());
|
||||
cond.put("value", condition.getValue());
|
||||
conditions.add(cond);
|
||||
}
|
||||
|
||||
filter.put("conditions", conditions);
|
||||
body.put("filter", filter);
|
||||
}
|
||||
|
||||
// 构建required_fields
|
||||
if (request.getRequiredFields() != null) {
|
||||
body.put("required_fields", request.getRequiredFields());
|
||||
}
|
||||
|
||||
// 构建page_request
|
||||
if (request.getPageRequest() != null) {
|
||||
Map<String, Object> pageRequest = new HashMap<>();
|
||||
pageRequest.put("page_size", request.getPageRequest().getPageSize());
|
||||
if (request.getPageRequest().getPageToken() != null && !request.getPageRequest().getPageToken().isEmpty()) {
|
||||
pageRequest.put("page_token", request.getPageRequest().getPageToken());
|
||||
}
|
||||
body.put("page_request", pageRequest);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FeishuEmployeeDTO> listAllActiveEmployees() {
|
||||
List<FeishuEmployeeDTO> allEmployees = new ArrayList<>();
|
||||
String pageToken = "";
|
||||
int pageCount = 0;
|
||||
final int maxPages = 100; // 最大分页数,防止死循环
|
||||
boolean hasMore = true;
|
||||
|
||||
while (hasMore && pageCount < maxPages) {
|
||||
FeishuEmployeeListRequest request = FeishuEmployeeListRequest.buildActiveEmployeeRequest(
|
||||
MAX_PAGE_SIZE,
|
||||
pageToken
|
||||
);
|
||||
|
||||
FeishuEmployeeListResponse response = listEmployees(request);
|
||||
|
||||
if (response.isSuccess() && response.getData() != null) {
|
||||
List<FeishuEmployeeDTO> employees = response.getEmployees();
|
||||
if (employees != null && !employees.isEmpty()) {
|
||||
allEmployees.addAll(employees);
|
||||
log.debug("获取到 {} 条员工数据", employees.size());
|
||||
}
|
||||
|
||||
// 更新分页标记和hasMore标志
|
||||
hasMore = response.hasMore();
|
||||
pageToken = response.getNextPageToken();
|
||||
pageCount++;
|
||||
|
||||
log.debug("第 {} 页查询完成, hasMore: {}, nextPageToken: {}",
|
||||
pageCount, hasMore, pageToken);
|
||||
} else {
|
||||
log.error("查询员工列表失败: {}", response.getMsg());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
log.info("共查询到 {} 条在职员工数据", allEmployees.size());
|
||||
return allEmployees;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FeishuEmployeeDTO> listEmployeesByDepartment(String departmentId) {
|
||||
List<FeishuEmployeeDTO> allEmployees = new ArrayList<>();
|
||||
String pageToken = "";
|
||||
int pageCount = 0;
|
||||
final int maxPages = 100;
|
||||
boolean hasMore = true;
|
||||
|
||||
while (hasMore && pageCount < maxPages) {
|
||||
FeishuEmployeeListRequest request = FeishuEmployeeListRequest.buildDepartmentEmployeeRequest(
|
||||
departmentId,
|
||||
MAX_PAGE_SIZE,
|
||||
pageToken
|
||||
);
|
||||
|
||||
FeishuEmployeeListResponse response = listEmployees(request);
|
||||
|
||||
if (response.isSuccess() && response.getData() != null) {
|
||||
List<FeishuEmployeeDTO> employees = response.getEmployees();
|
||||
if (employees != null && !employees.isEmpty()) {
|
||||
allEmployees.addAll(employees);
|
||||
}
|
||||
|
||||
hasMore = response.hasMore();
|
||||
pageToken = response.getNextPageToken();
|
||||
pageCount++;
|
||||
} else {
|
||||
log.error("查询部门员工列表失败: {}", response.getMsg());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
log.info("部门 {} 共查询到 {} 条员工数据", departmentId, allEmployees.size());
|
||||
return allEmployees;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package cn.yinlihupo.service.system.impl;
|
||||
|
||||
import cn.yinlihupo.common.exception.BusinessException;
|
||||
import cn.yinlihupo.domain.dto.feishu.FeishuEmployeeDTO;
|
||||
import cn.yinlihupo.domain.dto.feishu.FeishuUserSyncResult;
|
||||
import cn.yinlihupo.domain.entity.SysUser;
|
||||
import cn.yinlihupo.mapper.SysUserMapper;
|
||||
import cn.yinlihupo.service.system.FeishuClientService;
|
||||
import cn.yinlihupo.service.system.FeishuUserSyncService;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 飞书用户同步服务实现类
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FeishuUserSyncServiceImpl implements FeishuUserSyncService {
|
||||
|
||||
private final FeishuClientService feishuClientService;
|
||||
private final SysUserMapper sysUserMapper;
|
||||
|
||||
// 飞书用户默认密码
|
||||
private static final String DEFAULT_PASSWORD = "FEISHU_SYNC_USER";
|
||||
// 默认状态:正常
|
||||
private static final Integer DEFAULT_STATUS = 1;
|
||||
|
||||
@Override
|
||||
public FeishuUserSyncResult syncAllEmployees() {
|
||||
log.info("开始同步所有飞书在职员工...");
|
||||
FeishuUserSyncResult result = FeishuUserSyncResult.success("同步完成");
|
||||
|
||||
try {
|
||||
// 1. 获取所有在职员工
|
||||
List<FeishuEmployeeDTO> employees = feishuClientService.listAllActiveEmployees();
|
||||
result.setTotal(employees.size());
|
||||
log.info("从飞书获取到 {} 条在职员工数据", employees.size());
|
||||
|
||||
// 2. 逐个同步员工(每个员工独立事务)
|
||||
for (FeishuEmployeeDTO employee : employees) {
|
||||
syncSingleEmployee(employee, result);
|
||||
}
|
||||
|
||||
result.complete();
|
||||
log.info("员工同步完成: 总计={}, 新增={}, 更新={}, 跳过={}, 失败={}",
|
||||
result.getTotalCount(),
|
||||
result.getCreatedCount(),
|
||||
result.getUpdatedCount(),
|
||||
result.getSkippedCount(),
|
||||
result.getFailedCount());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("同步员工列表异常", e);
|
||||
result.setSuccess(false);
|
||||
result.setMessage("同步异常: " + e.getMessage());
|
||||
result.complete();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FeishuUserSyncResult syncEmployeesByDepartment(String departmentId) {
|
||||
log.info("开始同步部门 {} 的飞书员工...", departmentId);
|
||||
FeishuUserSyncResult result = FeishuUserSyncResult.success("同步完成");
|
||||
|
||||
try {
|
||||
// 1. 获取部门员工
|
||||
List<FeishuEmployeeDTO> employees = feishuClientService.listEmployeesByDepartment(departmentId);
|
||||
result.setTotal(employees.size());
|
||||
log.info("从飞书获取到部门 {} 的 {} 条员工数据", departmentId, employees.size());
|
||||
|
||||
// 2. 逐个同步员工(每个员工独立事务)
|
||||
for (FeishuEmployeeDTO employee : employees) {
|
||||
syncSingleEmployee(employee, result);
|
||||
}
|
||||
|
||||
result.complete();
|
||||
log.info("部门 {} 员工同步完成: 总计={}, 新增={}, 更新={}, 跳过={}, 失败={}",
|
||||
departmentId,
|
||||
result.getTotalCount(),
|
||||
result.getCreatedCount(),
|
||||
result.getUpdatedCount(),
|
||||
result.getSkippedCount(),
|
||||
result.getFailedCount());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("同步部门员工列表异常, departmentId: {}", departmentId, e);
|
||||
result.setSuccess(false);
|
||||
result.setMessage("同步异常: " + e.getMessage());
|
||||
result.complete();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FeishuUserSyncResult syncEmployeeById(String employeeId) {
|
||||
log.info("同步指定员工: {}", employeeId);
|
||||
// 由于飞书API不支持直接查询单个员工,这里先获取全部员工再筛选
|
||||
// 实际使用时可以根据需求调整
|
||||
throw new BusinessException("暂不支持单个员工同步,请使用全部同步或部门同步");
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步单个员工
|
||||
* 使用独立事务,失败不影响其他员工
|
||||
*
|
||||
* @param employee 飞书员工数据
|
||||
* @param result 同步结果统计
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
|
||||
public void syncSingleEmployee(FeishuEmployeeDTO employee, FeishuUserSyncResult result) {
|
||||
try {
|
||||
// 1. 获取员工基本信息
|
||||
String openId = employee.getOpenId();
|
||||
String phone = employee.getPhoneNumber();
|
||||
String realName = employee.getRealName();
|
||||
String email = employee.getEmail();
|
||||
String avatar = employee.getAvatarUrl();
|
||||
Integer gender = employee.getGender();
|
||||
|
||||
// 2. 数据校验
|
||||
if (!StringUtils.hasText(openId)) {
|
||||
log.warn("员工OpenID为空,跳过同步");
|
||||
result.incrementSkipped();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!StringUtils.hasText(phone)) {
|
||||
log.warn("员工 {} 手机号为空,跳过同步", realName);
|
||||
result.addFailed(openId, realName, "手机号为空");
|
||||
result.incrementSkipped();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!StringUtils.hasText(realName)) {
|
||||
log.warn("员工 {} 姓名为空,使用默认值", openId);
|
||||
realName = "未知用户";
|
||||
}
|
||||
|
||||
// 3. 根据手机号查询用户
|
||||
LambdaQueryWrapper<SysUser> queryWrapper = new LambdaQueryWrapper<>();
|
||||
queryWrapper.eq(SysUser::getPhone, phone);
|
||||
SysUser existingUser = sysUserMapper.selectOne(queryWrapper);
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
if (existingUser != null) {
|
||||
// 4. 用户已存在,更新信息
|
||||
log.debug("用户已存在,更新用户信息, phone: {}, name: {}", phone, realName);
|
||||
existingUser.setRealName(realName);
|
||||
existingUser.setNickname(realName);
|
||||
if (StringUtils.hasText(avatar)) {
|
||||
existingUser.setAvatar(avatar);
|
||||
}
|
||||
if (StringUtils.hasText(email)) {
|
||||
existingUser.setEmail(email);
|
||||
}
|
||||
if (gender != null && gender > 0) {
|
||||
existingUser.setGender(gender);
|
||||
}
|
||||
existingUser.setUpdateTime(now);
|
||||
sysUserMapper.updateById(existingUser);
|
||||
result.incrementUpdated();
|
||||
} else {
|
||||
// 5. 用户不存在,创建新用户
|
||||
log.debug("创建新用户, phone: {}, name: {}", phone, realName);
|
||||
SysUser newUser = new SysUser();
|
||||
newUser.setUsername(openId);
|
||||
newUser.setRealName(realName);
|
||||
newUser.setNickname(realName);
|
||||
newUser.setPhone(phone);
|
||||
newUser.setAvatar(avatar);
|
||||
newUser.setEmail(email);
|
||||
newUser.setGender(gender != null ? gender : 0);
|
||||
newUser.setPassword(DEFAULT_PASSWORD);
|
||||
newUser.setStatus(DEFAULT_STATUS);
|
||||
newUser.setCreateTime(now);
|
||||
newUser.setUpdateTime(now);
|
||||
newUser.setDeleted(0);
|
||||
sysUserMapper.insert(newUser);
|
||||
result.incrementCreated();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("同步员工失败: {}", employee.getOpenId(), e);
|
||||
result.addFailed(
|
||||
employee.getOpenId(),
|
||||
employee.getRealName(),
|
||||
"同步异常: " + e.getMessage()
|
||||
);
|
||||
// 抛出异常触发事务回滚
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user