From 3967e9078a0819626549e6fc9a4a6aad81c3b4c6 Mon Sep 17 00:00:00 2001 From: JiaoTianBo Date: Sat, 28 Mar 2026 11:33:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(feishu):=20=E5=A2=9E=E5=8A=A0=E9=A3=9E?= =?UTF-8?q?=E4=B9=A6=E7=94=A8=E6=88=B7=E5=90=8C=E6=AD=A5=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=8F=8A=E7=9B=B8=E5=85=B3API=E5=92=8C=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增飞书用户同步控制器,提供手动全量及按部门同步接口 - 新增飞书员工信息相关DTO,支持飞书API响应数据映射 - 新增飞书员工列表查询请求和响应DTO,支持分页查询功能 - 实现飞书SDK客户端服务,封装调用飞书官方API逻辑 - 实现飞书用户同步服务,支持全量及按部门同步,处理分页与数据持久化 - 增加飞书用户同步定时任务,每天0点自动同步飞书员工信息 - 在主应用类启用计划任务支持(@EnableScheduling) - 优化全局异常处理中Token无效提示信息 - 在BaseResponse增加success和error静态方法便捷创建响应对象 - 支持BusinessException新增仅消息构造方法,简化异常创建 - pom.xml中更新sa-token-redis注释,强调分布式会话持久化用途 --- pom.xml | 2 +- .../YlhpAiProjectManagerApplication.java | 5 + .../yinlihupo/common/core/BaseResponse.java | 23 ++ .../common/exception/BusinessException.java | 5 + .../exception/GlobalExceptionHandler.java | 2 +- .../system/FeishuSyncController.java | 68 ++++ .../domain/dto/feishu/FeishuEmployeeDTO.java | 325 ++++++++++++++++++ .../dto/feishu/FeishuEmployeeListRequest.java | 136 ++++++++ .../feishu/FeishuEmployeeListResponse.java | 111 ++++++ .../dto/feishu/FeishuUserSyncResult.java | 183 ++++++++++ .../cn/yinlihupo/job/FeishuUserSyncJob.java | 60 ++++ .../service/system/FeishuClientService.java | 39 +++ .../service/system/FeishuUserSyncService.java | 34 ++ .../system/impl/FeishuClientServiceImpl.java | 231 +++++++++++++ .../impl/FeishuUserSyncServiceImpl.java | 206 +++++++++++ 15 files changed, 1428 insertions(+), 2 deletions(-) create mode 100644 src/main/java/cn/yinlihupo/controller/system/FeishuSyncController.java create mode 100644 src/main/java/cn/yinlihupo/domain/dto/feishu/FeishuEmployeeDTO.java create mode 100644 src/main/java/cn/yinlihupo/domain/dto/feishu/FeishuEmployeeListRequest.java create mode 100644 src/main/java/cn/yinlihupo/domain/dto/feishu/FeishuEmployeeListResponse.java create mode 100644 src/main/java/cn/yinlihupo/domain/dto/feishu/FeishuUserSyncResult.java create mode 100644 src/main/java/cn/yinlihupo/job/FeishuUserSyncJob.java create mode 100644 src/main/java/cn/yinlihupo/service/system/FeishuClientService.java create mode 100644 src/main/java/cn/yinlihupo/service/system/FeishuUserSyncService.java create mode 100644 src/main/java/cn/yinlihupo/service/system/impl/FeishuClientServiceImpl.java create mode 100644 src/main/java/cn/yinlihupo/service/system/impl/FeishuUserSyncServiceImpl.java diff --git a/pom.xml b/pom.xml index 44d70cc..a59c201 100644 --- a/pom.xml +++ b/pom.xml @@ -138,7 +138,7 @@ 1.39.0 - + cn.dev33 sa-token-redis-jackson diff --git a/src/main/java/cn/yinlihupo/YlhpAiProjectManagerApplication.java b/src/main/java/cn/yinlihupo/YlhpAiProjectManagerApplication.java index fb2f928..0fcaf41 100644 --- a/src/main/java/cn/yinlihupo/YlhpAiProjectManagerApplication.java +++ b/src/main/java/cn/yinlihupo/YlhpAiProjectManagerApplication.java @@ -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 { diff --git a/src/main/java/cn/yinlihupo/common/core/BaseResponse.java b/src/main/java/cn/yinlihupo/common/core/BaseResponse.java index 1e4b22a..9506a5f 100644 --- a/src/main/java/cn/yinlihupo/common/core/BaseResponse.java +++ b/src/main/java/cn/yinlihupo/common/core/BaseResponse.java @@ -32,4 +32,27 @@ public class BaseResponse implements Serializable { public BaseResponse(ErrorCode errorCode) { this(errorCode.getCode(), null, errorCode.getMessage()); } + + /** + * 成功响应 + * + * @param data 数据 + * @param 数据类型 + * @return 响应对象 + */ + public static BaseResponse success(T data) { + return new BaseResponse<>(0, data, "success"); + } + + /** + * 错误响应 + * + * @param message 错误消息 + * @param data 数据 + * @param 数据类型 + * @return 响应对象 + */ + public static BaseResponse error(String message, T data) { + return new BaseResponse<>(500, data, message); + } } diff --git a/src/main/java/cn/yinlihupo/common/exception/BusinessException.java b/src/main/java/cn/yinlihupo/common/exception/BusinessException.java index d892a67..5d0750e 100644 --- a/src/main/java/cn/yinlihupo/common/exception/BusinessException.java +++ b/src/main/java/cn/yinlihupo/common/exception/BusinessException.java @@ -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; } diff --git a/src/main/java/cn/yinlihupo/common/exception/GlobalExceptionHandler.java b/src/main/java/cn/yinlihupo/common/exception/GlobalExceptionHandler.java index e7839da..3eeed2f 100644 --- a/src/main/java/cn/yinlihupo/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/cn/yinlihupo/common/exception/GlobalExceptionHandler.java @@ -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或登录已失效"); } } diff --git a/src/main/java/cn/yinlihupo/controller/system/FeishuSyncController.java b/src/main/java/cn/yinlihupo/controller/system/FeishuSyncController.java new file mode 100644 index 0000000..a84e3a8 --- /dev/null +++ b/src/main/java/cn/yinlihupo/controller/system/FeishuSyncController.java @@ -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 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 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); + } + } +} diff --git a/src/main/java/cn/yinlihupo/domain/dto/feishu/FeishuEmployeeDTO.java b/src/main/java/cn/yinlihupo/domain/dto/feishu/FeishuEmployeeDTO.java new file mode 100644 index 0000000..33cb492 --- /dev/null +++ b/src/main/java/cn/yinlihupo/domain/dto/feishu/FeishuEmployeeDTO.java @@ -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 departments; + + /** + * 部门路径信息 + */ + @JsonProperty("department_path_infos") + private List> 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 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 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> pathInfos = baseInfo.getDepartmentPathInfos(); + // 取第一个部门路径的最后一个节点作为主部门 + for (List 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; + } +} diff --git a/src/main/java/cn/yinlihupo/domain/dto/feishu/FeishuEmployeeListRequest.java b/src/main/java/cn/yinlihupo/domain/dto/feishu/FeishuEmployeeListRequest.java new file mode 100644 index 0000000..a501942 --- /dev/null +++ b/src/main/java/cn/yinlihupo/domain/dto/feishu/FeishuEmployeeListRequest.java @@ -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 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 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; + } +} diff --git a/src/main/java/cn/yinlihupo/domain/dto/feishu/FeishuEmployeeListResponse.java b/src/main/java/cn/yinlihupo/domain/dto/feishu/FeishuEmployeeListResponse.java new file mode 100644 index 0000000..3753d27 --- /dev/null +++ b/src/main/java/cn/yinlihupo/domain/dto/feishu/FeishuEmployeeListResponse.java @@ -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 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 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; + } +} diff --git a/src/main/java/cn/yinlihupo/domain/dto/feishu/FeishuUserSyncResult.java b/src/main/java/cn/yinlihupo/domain/dto/feishu/FeishuUserSyncResult.java new file mode 100644 index 0000000..265f80f --- /dev/null +++ b/src/main/java/cn/yinlihupo/domain/dto/feishu/FeishuUserSyncResult.java @@ -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 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; + } +} diff --git a/src/main/java/cn/yinlihupo/job/FeishuUserSyncJob.java b/src/main/java/cn/yinlihupo/job/FeishuUserSyncJob.java new file mode 100644 index 0000000..3222d10 --- /dev/null +++ b/src/main/java/cn/yinlihupo/job/FeishuUserSyncJob.java @@ -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); + } + } +} diff --git a/src/main/java/cn/yinlihupo/service/system/FeishuClientService.java b/src/main/java/cn/yinlihupo/service/system/FeishuClientService.java new file mode 100644 index 0000000..a126c47 --- /dev/null +++ b/src/main/java/cn/yinlihupo/service/system/FeishuClientService.java @@ -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 listAllActiveEmployees(); + + /** + * 根据部门ID查询员工列表 + * + * @param departmentId 部门ID + * @return 员工列表 + */ + List listEmployeesByDepartment(String departmentId); +} diff --git a/src/main/java/cn/yinlihupo/service/system/FeishuUserSyncService.java b/src/main/java/cn/yinlihupo/service/system/FeishuUserSyncService.java new file mode 100644 index 0000000..d224e57 --- /dev/null +++ b/src/main/java/cn/yinlihupo/service/system/FeishuUserSyncService.java @@ -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); +} diff --git a/src/main/java/cn/yinlihupo/service/system/impl/FeishuClientServiceImpl.java b/src/main/java/cn/yinlihupo/service/system/impl/FeishuClientServiceImpl.java new file mode 100644 index 0000000..08ef95e --- /dev/null +++ b/src/main/java/cn/yinlihupo/service/system/impl/FeishuClientServiceImpl.java @@ -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 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 convertRequestToMap(FeishuEmployeeListRequest request) { + Map body = new HashMap<>(); + + // 构建filter + if (request.getFilter() != null && request.getFilter().getConditions() != null) { + Map filter = new HashMap<>(); + List> conditions = new ArrayList<>(); + + for (FeishuEmployeeListRequest.Condition condition : request.getFilter().getConditions()) { + Map 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 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 listAllActiveEmployees() { + List 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 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 listEmployeesByDepartment(String departmentId) { + List 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 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; + } +} diff --git a/src/main/java/cn/yinlihupo/service/system/impl/FeishuUserSyncServiceImpl.java b/src/main/java/cn/yinlihupo/service/system/impl/FeishuUserSyncServiceImpl.java new file mode 100644 index 0000000..410bdf5 --- /dev/null +++ b/src/main/java/cn/yinlihupo/service/system/impl/FeishuUserSyncServiceImpl.java @@ -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 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 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 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; + } + } +}