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:
2026-03-28 11:33:19 +08:00
parent 44e6db0adc
commit 3967e9078a
15 changed files with 1428 additions and 2 deletions

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}