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

@@ -138,7 +138,7 @@
<version>1.39.0</version>
</dependency>
<!-- Sa-Token Redis 集成(可选,用于分布式环境) -->
<!-- Sa-Token Redis 集成(用于分布式环境会话持久化 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>

View File

@@ -2,7 +2,12 @@ package cn.yinlihupo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* AI项目进度与风险管控平台启动类
*/
@EnableScheduling
@SpringBootApplication
public class YlhpAiProjectManagerApplication {

View File

@@ -32,4 +32,27 @@ public class BaseResponse<T> implements Serializable {
public BaseResponse(ErrorCode errorCode) {
this(errorCode.getCode(), null, errorCode.getMessage());
}
/**
* 成功响应
*
* @param data 数据
* @param <T> 数据类型
* @return 响应对象
*/
public static <T> BaseResponse<T> success(T data) {
return new BaseResponse<>(0, data, "success");
}
/**
* 错误响应
*
* @param message 错误消息
* @param data 数据
* @param <T> 数据类型
* @return 响应对象
*/
public static <T> BaseResponse<T> error(String message, T data) {
return new BaseResponse<>(500, data, message);
}
}

View File

@@ -23,6 +23,11 @@ public class BusinessException extends RuntimeException {
this.code = errorCode.getCode();
}
public BusinessException(String message) {
super(message);
this.code = ErrorCode.SYSTEM_ERROR.getCode();
}
public int getCode() {
return code;
}

View File

@@ -64,6 +64,6 @@ public class GlobalExceptionHandler {
@ExceptionHandler(NotLoginException.class)
public BaseResponse<?> notLoginExceptionHandler(NotLoginException e) {
log.error("NotLoginException", e);
return ResultUtils.error(ErrorCode.TOKEN_INVALID, "没有传递token");
return ResultUtils.error(ErrorCode.TOKEN_INVALID, "没有传递token或登录已失效");
}
}

View File

@@ -0,0 +1,68 @@
package cn.yinlihupo.controller.system;
import cn.yinlihupo.common.core.BaseResponse;
import cn.yinlihupo.domain.dto.feishu.FeishuUserSyncResult;
import cn.yinlihupo.service.system.FeishuUserSyncService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
/**
* 飞书用户同步控制器
* 提供手动触发用户同步的接口
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/feishu/sync")
@RequiredArgsConstructor
public class FeishuSyncController {
private final FeishuUserSyncService feishuUserSyncService;
/**
* 手动同步所有飞书员工
* 同步所有在职员工信息到系统用户表
*
* @return 同步结果
*/
@PostMapping("/users")
public BaseResponse<FeishuUserSyncResult> syncAllUsers() {
log.info("手动触发飞书用户全量同步");
long startTime = System.currentTimeMillis();
FeishuUserSyncResult result = feishuUserSyncService.syncAllEmployees();
long costTime = System.currentTimeMillis() - startTime;
log.info("手动同步完成,耗时: {}ms, 结果: {}", costTime, result);
if (result.getSuccess()) {
return BaseResponse.success(result);
} else {
return BaseResponse.error(result.getMessage(), result);
}
}
/**
* 根据部门ID同步员工
*
* @param departmentId 飞书部门ID
* @return 同步结果
*/
@PostMapping("/users/department/{departmentId}")
public BaseResponse<FeishuUserSyncResult> syncUsersByDepartment(
@PathVariable("departmentId") String departmentId) {
log.info("手动触发部门用户同步, departmentId: {}", departmentId);
long startTime = System.currentTimeMillis();
FeishuUserSyncResult result = feishuUserSyncService.syncEmployeesByDepartment(departmentId);
long costTime = System.currentTimeMillis() - startTime;
log.info("部门同步完成,耗时: {}ms, 结果: {}", costTime, result);
if (result.getSuccess()) {
return BaseResponse.success(result);
} else {
return BaseResponse.error(result.getMessage(), result);
}
}
}

View File

@@ -0,0 +1,325 @@
package cn.yinlihupo.domain.dto.feishu;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 飞书员工信息DTO
* 对应飞书 directory/v1/employees/filter 接口返回的员工数据
*/
@Data
public class FeishuEmployeeDTO {
/**
* 基础信息
*/
@JsonProperty("base_info")
private BaseInfo baseInfo;
@Data
public static class BaseInfo {
/**
* 飞书用户唯一标识 (employee_id/open_id)
*/
@JsonProperty("employee_id")
private String employeeId;
/**
* 用户姓名
*/
private I18nField name;
/**
* 手机号
*/
private String mobile;
/**
* 邮箱
*/
private String email;
/**
* 企业邮箱
*/
@JsonProperty("enterprise_email")
private String enterpriseEmail;
/**
* 性别: 1-男, 2-女
*/
private Integer gender;
/**
* 头像信息
*/
private AvatarInfo avatar;
/**
* 部门列表
*/
private List<DepartmentInfo> departments;
/**
* 部门路径信息
*/
@JsonProperty("department_path_infos")
private List<List<DepartmentPathInfo>> departmentPathInfos;
/**
* 职位信息
*/
private String description;
/**
* 是否离职
*/
@JsonProperty("is_resigned")
private Boolean isResigned;
/**
* 激活状态
*/
@JsonProperty("active_status")
private Integer activeStatus;
/**
* 是否管理员
*/
@JsonProperty("is_admin")
private Boolean isAdmin;
/**
* 是否超级管理员
*/
@JsonProperty("is_primary_admin")
private Boolean isPrimaryAdmin;
/**
* 入职时间 (时间戳,毫秒)
*/
@JsonProperty("entry_time")
private String entryTime;
/**
* 离职时间 (时间戳,毫秒)
*/
@JsonProperty("resign_time")
private String resignTime;
}
@Data
public static class I18nField {
private FieldValue name;
}
@Data
public static class FieldValue {
/**
* 默认值
*/
@JsonProperty("default_value")
private String defaultValue;
/**
* 多语言值
*/
@JsonProperty("i18n_value")
private Map<String, String> i18nValue;
}
@Data
public static class AvatarInfo {
/**
* 72x72 头像
*/
@JsonProperty("avatar_72")
private String avatar72;
/**
* 240x240 头像
*/
@JsonProperty("avatar_240")
private String avatar240;
/**
* 640x640 头像
*/
@JsonProperty("avatar_640")
private String avatar640;
/**
* 原图头像
*/
@JsonProperty("avatar_origin")
private String avatarOrigin;
}
@Data
public static class DepartmentInfo {
/**
* 部门ID
*/
@JsonProperty("department_id")
private String departmentId;
/**
* 部门名称
*/
private Map<String, String> name;
}
@Data
public static class DepartmentPathInfo {
/**
* 部门ID
*/
@JsonProperty("department_id")
private String departmentId;
/**
* 部门名称
*/
@JsonProperty("department_name")
private I18nField departmentName;
}
/**
* 获取用户真实姓名
*
* @return 真实姓名
*/
public String getRealName() {
if (baseInfo != null && baseInfo.getName() != null && baseInfo.getName().getName() != null) {
return baseInfo.getName().getName().getDefaultValue();
}
return null;
}
/**
* 获取手机号(去除+86前缀
*
* @return 手机号
*/
public String getPhoneNumber() {
if (baseInfo != null && baseInfo.getMobile() != null) {
String mobile = baseInfo.getMobile();
// 去除 +86 前缀
if (mobile.startsWith("+86")) {
return mobile.substring(3);
}
return mobile;
}
return null;
}
/**
* 获取企业邮箱
*
* @return 企业邮箱
*/
public String getEmail() {
if (baseInfo != null) {
// 优先返回企业邮箱
if (baseInfo.getEnterpriseEmail() != null && !baseInfo.getEnterpriseEmail().isEmpty()) {
return baseInfo.getEnterpriseEmail();
}
return baseInfo.getEmail();
}
return null;
}
/**
* 获取头像URL优先使用240x240尺寸
*
* @return 头像URL
*/
public String getAvatarUrl() {
if (baseInfo != null && baseInfo.getAvatar() != null) {
AvatarInfo avatar = baseInfo.getAvatar();
if (avatar.getAvatar240() != null) {
return avatar.getAvatar240();
}
if (avatar.getAvatar72() != null) {
return avatar.getAvatar72();
}
return avatar.getAvatarOrigin();
}
return null;
}
/**
* 获取飞书OpenID
*
* @return OpenID
*/
public String getOpenId() {
if (baseInfo != null) {
return baseInfo.getEmployeeId();
}
return null;
}
/**
* 获取性别
*
* @return 性别: 0-未知, 1-男, 2-女
*/
public Integer getGender() {
if (baseInfo != null && baseInfo.getGender() != null) {
return baseInfo.getGender();
}
return 0;
}
/**
* 获取主部门名称
*
* @return 部门名称
*/
public String getPrimaryDepartmentName() {
if (baseInfo != null && baseInfo.getDepartmentPathInfos() != null
&& !baseInfo.getDepartmentPathInfos().isEmpty()) {
List<List<DepartmentPathInfo>> pathInfos = baseInfo.getDepartmentPathInfos();
// 取第一个部门路径的最后一个节点作为主部门
for (List<DepartmentPathInfo> path : pathInfos) {
if (path != null && !path.isEmpty()) {
DepartmentPathInfo lastDept = path.get(path.size() - 1);
if (lastDept.getDepartmentName() != null && lastDept.getDepartmentName().getName() != null) {
return lastDept.getDepartmentName().getName().getDefaultValue();
}
}
}
}
return null;
}
/**
* 获取主部门ID
*
* @return 部门ID
*/
public String getPrimaryDepartmentId() {
if (baseInfo != null && baseInfo.getDepartments() != null && !baseInfo.getDepartments().isEmpty()) {
// 取第一个部门作为主部门
return baseInfo.getDepartments().get(0).getDepartmentId();
}
return null;
}
/**
* 是否在职
*
* @return true-在职, false-离职
*/
public Boolean isActive() {
if (baseInfo != null && baseInfo.getIsResigned() != null) {
return !baseInfo.getIsResigned();
}
return true;
}
}

View File

@@ -0,0 +1,136 @@
package cn.yinlihupo.domain.dto.feishu;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 飞书员工列表查询请求DTO
* 对应飞书 directory/v1/employees/filter 接口请求体
*/
@Data
@Builder
public class FeishuEmployeeListRequest {
/**
* 查询筛选条件
*/
private Filter filter;
/**
* 需要返回的字段列表
*/
@JsonProperty("required_fields")
private List<String> requiredFields;
/**
* 分页请求参数
*/
@JsonProperty("page_request")
private PageRequest pageRequest;
/**
* 构建查询在职员工的默认请求
*
* @param pageSize 每页大小
* @param pageToken 分页标记
* @return 请求对象
*/
public static FeishuEmployeeListRequest buildActiveEmployeeRequest(int pageSize, String pageToken) {
return FeishuEmployeeListRequest.builder()
.filter(Filter.builder()
.conditions(List.of(
Condition.builder()
.field("work_info.staff_status")
.operator("eq")
.value("1") // 1 表示在职
.build()
))
.build())
.requiredFields(List.of("base_info.*"))
.pageRequest(PageRequest.builder()
.pageSize(pageSize)
.pageToken(pageToken)
.build())
.build();
}
/**
* 构建查询指定部门员工的请求
*
* @param departmentId 部门ID
* @param pageSize 每页大小
* @param pageToken 分页标记
* @return 请求对象
*/
public static FeishuEmployeeListRequest buildDepartmentEmployeeRequest(String departmentId, int pageSize, String pageToken) {
return FeishuEmployeeListRequest.builder()
.filter(Filter.builder()
.conditions(List.of(
Condition.builder()
.field("work_info.staff_status")
.operator("eq")
.value("1")
.build(),
Condition.builder()
.field("department_id")
.operator("eq")
.value(departmentId)
.build()
))
.build())
.requiredFields(List.of("base_info.*"))
.pageRequest(PageRequest.builder()
.pageSize(pageSize)
.pageToken(pageToken)
.build())
.build();
}
@Data
@Builder
public static class Filter {
/**
* 筛选条件列表
*/
private List<Condition> conditions;
}
@Data
@Builder
public static class Condition {
/**
* 字段名
*/
private String field;
/**
* 操作符: eq(等于), ne(不等于), gt(大于), lt(小于), contains(包含)等
*/
private String operator;
/**
* 字段值
*/
private String value;
}
@Data
@Builder
public static class PageRequest {
/**
* 每页大小最大100
*/
@JsonProperty("page_size")
private Integer pageSize;
/**
* 分页标记,首次请求为空
*/
@JsonProperty("page_token")
private String pageToken;
}
}

View File

@@ -0,0 +1,111 @@
package cn.yinlihupo.domain.dto.feishu;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;
/**
* 飞书员工列表查询响应DTO
* 对应飞书 directory/v1/employees/filter 接口响应体
*/
@Data
public class FeishuEmployeeListResponse {
/**
* 响应码0表示成功
*/
private Integer code;
/**
* 响应消息
*/
private String msg;
/**
* 响应数据
*/
private Data data;
@lombok.Data
public static class Data {
/**
* 员工列表
*/
private List<FeishuEmployeeDTO> employees;
/**
* 分页信息
*/
@JsonProperty("page_response")
private PageResponse pageResponse;
}
@lombok.Data
public static class PageResponse {
/**
* 是否有更多数据
*/
@JsonProperty("has_more")
private Boolean hasMore;
/**
* 下一页的分页标记
*/
@JsonProperty("page_token")
private String pageToken;
/**
* 总数量(可能为空)
*/
@JsonProperty("total_count")
private Integer totalCount;
}
/**
* 判断是否请求成功
*
* @return true-成功, false-失败
*/
public boolean isSuccess() {
return code != null && code == 0;
}
/**
* 获取员工列表
*
* @return 员工列表,失败返回空列表
*/
public List<FeishuEmployeeDTO> getEmployees() {
if (data != null && data.getEmployees() != null) {
return data.getEmployees();
}
return List.of();
}
/**
* 是否有更多数据
*
* @return true-有下一页, false-无下一页
*/
public boolean hasMore() {
if (data != null && data.getPageResponse() != null && data.getPageResponse().getHasMore() != null) {
return data.getPageResponse().getHasMore();
}
return false;
}
/**
* 获取下一页的分页标记
*
* @return 分页标记无下一页返回null
*/
public String getNextPageToken() {
if (data != null && data.getPageResponse() != null) {
return data.getPageResponse().getPageToken();
}
return null;
}
}

View File

@@ -0,0 +1,183 @@
package cn.yinlihupo.domain.dto.feishu;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 飞书用户同步结果DTO
*/
@Data
@Builder
public class FeishuUserSyncResult {
/**
* 同步是否成功
*/
private Boolean success;
/**
* 同步消息
*/
private String message;
/**
* 从飞书获取的员工总数
*/
private Integer totalCount;
/**
* 新增用户数
*/
private Integer createdCount;
/**
* 更新用户数
*/
private Integer updatedCount;
/**
* 失败数量
*/
private Integer failedCount;
/**
* 跳过的用户数量(如手机号为空等无效数据)
*/
private Integer skippedCount;
/**
* 同步开始时间
*/
private LocalDateTime startTime;
/**
* 同步结束时间
*/
private LocalDateTime endTime;
/**
* 失败的员工列表
*/
@Builder.Default
private List<FailedEmployee> failedList = new ArrayList<>();
/**
* 失败员工信息
*/
@Data
@Builder
public static class FailedEmployee {
/**
* 飞书员工ID
*/
private String employeeId;
/**
* 员工姓名
*/
private String name;
/**
* 失败原因
*/
private String reason;
}
/**
* 创建成功结果
*
* @param message 消息
* @return 结果对象
*/
public static FeishuUserSyncResult success(String message) {
return FeishuUserSyncResult.builder()
.success(true)
.message(message)
.startTime(LocalDateTime.now())
.build();
}
/**
* 创建失败结果
*
* @param message 错误消息
* @return 结果对象
*/
public static FeishuUserSyncResult fail(String message) {
return FeishuUserSyncResult.builder()
.success(false)
.message(message)
.startTime(LocalDateTime.now())
.endTime(LocalDateTime.now())
.build();
}
/**
* 完成同步,设置结束时间
*/
public void complete() {
this.endTime = LocalDateTime.now();
}
/**
* 添加失败记录
*
* @param employeeId 员工ID
* @param name 姓名
* @param reason 失败原因
*/
public void addFailed(String employeeId, String name, String reason) {
if (this.failedList == null) {
this.failedList = new ArrayList<>();
}
this.failedList.add(FailedEmployee.builder()
.employeeId(employeeId)
.name(name)
.reason(reason)
.build());
this.failedCount = this.failedList.size();
}
/**
* 增加创建计数
*/
public void incrementCreated() {
if (this.createdCount == null) {
this.createdCount = 0;
}
this.createdCount++;
}
/**
* 增加更新计数
*/
public void incrementUpdated() {
if (this.updatedCount == null) {
this.updatedCount = 0;
}
this.updatedCount++;
}
/**
* 增加跳过计数
*/
public void incrementSkipped() {
if (this.skippedCount == null) {
this.skippedCount = 0;
}
this.skippedCount++;
}
/**
* 设置总数
*
* @param count 总数
*/
public void setTotal(Integer count) {
this.totalCount = count;
}
}

View File

@@ -0,0 +1,60 @@
package cn.yinlihupo.job;
import cn.yinlihupo.domain.dto.feishu.FeishuUserSyncResult;
import cn.yinlihupo.service.system.FeishuUserSyncService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 飞书用户同步定时任务
* 每天0点自动同步飞书员工信息到系统
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class FeishuUserSyncJob {
private final FeishuUserSyncService feishuUserSyncService;
/**
* 每天0点执行用户同步
* cron表达式: 秒 分 时 日 月 周
*/
@Scheduled(cron = "0 0 0 * * ?")
public void syncUsersDaily() {
log.info("========== 开始执行每日飞书用户同步任务 ==========");
long startTime = System.currentTimeMillis();
try {
FeishuUserSyncResult result = feishuUserSyncService.syncAllEmployees();
long costTime = System.currentTimeMillis() - startTime;
if (result.getSuccess()) {
log.info("========== 每日飞书用户同步任务完成 ==========");
log.info("同步结果: 总计={}, 新增={}, 更新={}, 跳过={}, 失败={}",
result.getTotalCount(),
result.getCreatedCount(),
result.getUpdatedCount(),
result.getSkippedCount(),
result.getFailedCount());
log.info("耗时: {}ms", costTime);
// 如果有失败记录,输出失败详情
if (result.getFailedCount() != null && result.getFailedCount() > 0) {
log.warn("存在同步失败的员工,共 {} 条", result.getFailedCount());
result.getFailedList().forEach(failed ->
log.warn(" - {}({}): {}", failed.getName(), failed.getEmployeeId(), failed.getReason())
);
}
} else {
log.error("========== 每日飞书用户同步任务失败 ==========");
log.error("失败原因: {}", result.getMessage());
log.error("耗时: {}ms", costTime);
}
} catch (Exception e) {
log.error("========== 每日飞书用户同步任务异常 ==========", e);
}
}
}

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