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