params = new HashMap<>();
+
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/DwsAuthSessionVo.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/DwsAuthSessionVo.java
new file mode 100644
index 000000000..2efbd2bfe
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/DwsAuthSessionVo.java
@@ -0,0 +1,48 @@
+package org.dromara.sync.domain.vo;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 钉钉 Web 设备授权会话视图。
+ *
+ * 该对象只包含一次性授权链接、验证码和脱敏身份信息,绝不包含
+ * access token、refresh token、device code 或应用密钥。
+ *
+ * @param sessionId 授权会话 ID
+ * @param connectionId 同步连接 ID
+ * @param status 会话状态
+ * @param verificationUri 用户输入验证码的授权地址
+ * @param verificationUriComplete 可直接打开或生成二维码的完整授权地址
+ * @param userCode 一次性用户验证码
+ * @param expiresAt 会话过期时间(Unix 毫秒)
+ * @param remainingSeconds 距离过期的秒数
+ * @param pollInterval 前端建议轮询间隔(秒)
+ * @param corpId 授权后的组织 ID
+ * @param corpName 授权后的组织名称
+ * @param userId 授权后的钉钉用户 ID
+ * @param userName 授权后的用户名称
+ * @param profile DWS 稳定 profile(corpId:userId)
+ * @param message 面向用户的脱敏提示
+ */
+public record DwsAuthSessionVo(
+ String sessionId,
+ Long connectionId,
+ String status,
+ String verificationUri,
+ String verificationUriComplete,
+ String userCode,
+ Long expiresAt,
+ long remainingSeconds,
+ int pollInterval,
+ String corpId,
+ String corpName,
+ String userId,
+ String userName,
+ String profile,
+ String message
+) implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/SyncCheckpointVo.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/SyncCheckpointVo.java
new file mode 100644
index 000000000..955444120
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/SyncCheckpointVo.java
@@ -0,0 +1,29 @@
+package org.dromara.sync.domain.vo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.dromara.sync.domain.SyncCheckpoint;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 同步断点视图对象。
+ */
+@Data
+@AutoMapper(target = SyncCheckpoint.class)
+public class SyncCheckpointVo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private Long checkpointId;
+ private Long planId;
+ private String checkpointType;
+ private String checkpointKey;
+ private String checkpointValue;
+ private LocalDateTime watermarkTime;
+ private Long lastJobId;
+ private Long version;
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/SyncConnectionVo.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/SyncConnectionVo.java
new file mode 100644
index 000000000..07790e8f0
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/SyncConnectionVo.java
@@ -0,0 +1,106 @@
+package org.dromara.sync.domain.vo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.dromara.sync.domain.SyncConnection;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 同步连接视图对象 sync_connection。
+ *
+ * 敏感凭证字段不在该视图对象中声明,任何查询接口均不会返回凭证内容。
+ *
+ * @author Codex
+ * @date 2026-09-02
+ */
+@Data
+@AutoMapper(target = SyncConnection.class)
+public class SyncConnectionVo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 连接主键。
+ */
+ private Long connectionId;
+
+ /**
+ * 连接名称。
+ */
+ private String connectionName;
+
+ /**
+ * 连接角色(SOURCE 源端、TARGET 目标端)。
+ */
+ private String connectionRole;
+
+ /**
+ * 连接类型(DINGTALK、S3、ALIYUN_OSS)。
+ */
+ private String connectionType;
+
+ /**
+ * 服务端点。
+ */
+ private String endpoint;
+
+ /**
+ * 服务区域。
+ */
+ private String region;
+
+ /**
+ * 存储桶名称。
+ */
+ private String bucketName;
+
+ /**
+ * 目标基础路径。
+ */
+ private String basePath;
+
+ /**
+ * 非敏感扩展配置 JSON。
+ */
+ private String configJson;
+
+ /**
+ * 状态(0 正常、1 停用)。
+ */
+ private String status;
+
+ /**
+ * 备注。
+ */
+ private String remark;
+
+ /**
+ * 创建部门。
+ */
+ private Long createDept;
+
+ /**
+ * 创建者。
+ */
+ private Long createBy;
+
+ /**
+ * 创建时间。
+ */
+ private LocalDateTime createTime;
+
+ /**
+ * 更新者。
+ */
+ private Long updateBy;
+
+ /**
+ * 更新时间。
+ */
+ private LocalDateTime updateTime;
+
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/SyncJobItemVo.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/SyncJobItemVo.java
new file mode 100644
index 000000000..1059f62fa
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/SyncJobItemVo.java
@@ -0,0 +1,42 @@
+package org.dromara.sync.domain.vo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.dromara.sync.domain.SyncJobItem;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 同步任务明细视图对象。
+ */
+@Data
+@AutoMapper(target = SyncJobItem.class)
+public class SyncJobItemVo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private Long itemId;
+ private Long jobId;
+ private String sourceObjectId;
+ private String parentObjectId;
+ private String sourcePath;
+ private String targetKey;
+ private String objectType;
+ private String actionType;
+ private String status;
+ private Long size;
+ private Long transferredBytes;
+ private String versionToken;
+ private String sourceEtag;
+ private String sourceSha256;
+ private String targetEtag;
+ private String targetVersionId;
+ private Integer retryCount;
+ private LocalDateTime startTime;
+ private LocalDateTime finishTime;
+ private String errorCode;
+ private String errorMessage;
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/SyncJobVo.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/SyncJobVo.java
new file mode 100644
index 000000000..c2e206fc0
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/SyncJobVo.java
@@ -0,0 +1,40 @@
+package org.dromara.sync.domain.vo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.dromara.sync.domain.SyncJob;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 同步任务视图对象。
+ */
+@Data
+@AutoMapper(target = SyncJob.class)
+public class SyncJobVo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private Long jobId;
+ private Long planId;
+ private String triggerType;
+ private String runType;
+ private String status;
+ private String checkpointBefore;
+ private String checkpointAfter;
+ private Long totalCount;
+ private Long processedCount;
+ private Long successCount;
+ private Long failedCount;
+ private Long skippedCount;
+ private Long deletedCount;
+ private Long totalBytes;
+ private Long transferredBytes;
+ private LocalDateTime startTime;
+ private LocalDateTime finishTime;
+ private String errorMessage;
+ private LocalDateTime createTime;
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/SyncObjectVo.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/SyncObjectVo.java
new file mode 100644
index 000000000..00c06724d
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/SyncObjectVo.java
@@ -0,0 +1,47 @@
+package org.dromara.sync.domain.vo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.dromara.sync.domain.SyncObject;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 同步对象清单视图对象。
+ */
+@Data
+@AutoMapper(target = SyncObject.class)
+public class SyncObjectVo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private Long objectId;
+ private Long planId;
+ private String sourceObjectId;
+ private String parentObjectId;
+ private String sourcePath;
+ private String objectName;
+ private String objectType;
+ private Long size;
+ private LocalDateTime modifiedTime;
+ private String versionToken;
+ private String sourceEtag;
+ private String sourceSha256;
+ private String contentType;
+ private String metadataJson;
+ private String targetKey;
+ private String targetVersionId;
+ private String targetEtag;
+ private String syncStatus;
+ private String sourceDeleted;
+ private Long firstSeenJobId;
+ private Long lastSeenJobId;
+ private Long lastSyncJobId;
+ private LocalDateTime lastSyncTime;
+ private String lastErrorMessage;
+ private LocalDateTime createTime;
+ private LocalDateTime updateTime;
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/SyncPlanVo.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/SyncPlanVo.java
new file mode 100644
index 000000000..7d13e85e4
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/SyncPlanVo.java
@@ -0,0 +1,144 @@
+package org.dromara.sync.domain.vo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.dromara.sync.domain.SyncPlan;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 同步计划视图对象 sync_plan。
+ *
+ * @author Codex
+ * @date 2026-09-02
+ */
+@Data
+@AutoMapper(target = SyncPlan.class)
+public class SyncPlanVo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 计划主键。
+ */
+ private Long planId;
+
+ /**
+ * 计划名称。
+ */
+ private String planName;
+
+ /**
+ * 源端连接主键。
+ */
+ private Long sourceConnectionId;
+
+ /**
+ * 目标端连接主键。
+ */
+ private Long targetConnectionId;
+
+ /**
+ * 源端同步根路径或对象标识。
+ */
+ private String sourceRoot;
+
+ /**
+ * 目标端对象键前缀。
+ */
+ private String targetPrefix;
+
+ /**
+ * 同步模式(FULL、INCREMENTAL)。
+ */
+ private String syncMode;
+
+ /**
+ * 调度类型(MANUAL、CRON)。
+ */
+ private String scheduleType;
+
+ /**
+ * CRON 表达式。
+ */
+ private String cronExpression;
+
+ /**
+ * 冲突处理策略(OVERWRITE、SKIP、KEEP_BOTH)。
+ */
+ private String conflictStrategy;
+
+ /**
+ * 源端删除处理策略(KEEP、MARK、DELETE)。
+ */
+ private String deleteStrategy;
+
+ /**
+ * 单次目标删除比例保护阈值。
+ */
+ private Integer deleteGuardPercent;
+
+ /**
+ * 完整性校验方式(SIZE、ETAG、SHA256)。
+ */
+ private String verifyMode;
+
+ /**
+ * 最大并发传输数。
+ */
+ private Integer maxConcurrency;
+
+ /**
+ * 带宽上限,单位 KB/s;0 表示不限速。
+ */
+ private Long bandwidthLimitKbps;
+
+ /**
+ * 状态(0 正常、1 停用)。
+ */
+ private String status;
+
+ /**
+ * 最近一次运行时间。
+ */
+ private LocalDateTime lastRunTime;
+
+ /**
+ * 下一次计划运行时间。
+ */
+ private LocalDateTime nextRunTime;
+
+ /**
+ * 备注。
+ */
+ private String remark;
+
+ /**
+ * 创建部门。
+ */
+ private Long createDept;
+
+ /**
+ * 创建者。
+ */
+ private Long createBy;
+
+ /**
+ * 创建时间。
+ */
+ private LocalDateTime createTime;
+
+ /**
+ * 更新者。
+ */
+ private Long updateBy;
+
+ /**
+ * 更新时间。
+ */
+ private LocalDateTime updateTime;
+
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/SyncTransferPartVo.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/SyncTransferPartVo.java
new file mode 100644
index 000000000..0cddf5380
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/domain/vo/SyncTransferPartVo.java
@@ -0,0 +1,35 @@
+package org.dromara.sync.domain.vo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.dromara.sync.domain.SyncTransferPart;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 传输分片视图对象。
+ */
+@Data
+@AutoMapper(target = SyncTransferPart.class)
+public class SyncTransferPartVo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private Long partId;
+ private Long jobItemId;
+ private String uploadId;
+ private Integer partNumber;
+ private Long partOffset;
+ private Long partSize;
+ private Long transferredBytes;
+ private String partEtag;
+ private String checksumSha256;
+ private String status;
+ private Integer retryCount;
+ private LocalDateTime startTime;
+ private LocalDateTime finishTime;
+ private String errorMessage;
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/job/SyncPlanJobExecutor.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/job/SyncPlanJobExecutor.java
new file mode 100644
index 000000000..2ebb4c217
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/job/SyncPlanJobExecutor.java
@@ -0,0 +1,31 @@
+package org.dromara.sync.job;
+
+import cn.hutool.core.convert.Convert;
+import com.aizuda.snailjob.client.job.core.annotation.JobExecutor;
+import com.aizuda.snailjob.client.job.core.dto.JobArgs;
+import com.aizuda.snailjob.model.dto.ExecuteResult;
+import lombok.RequiredArgsConstructor;
+import org.dromara.sync.service.ISyncJobService;
+import org.springframework.stereotype.Component;
+
+/**
+ * SnailJob 同步计划执行入口。
+ *
+ * 执行器名称为 {@code syncPlanJobExecutor},任务参数填写同步计划 ID。
+ */
+@Component
+@RequiredArgsConstructor
+@JobExecutor(name = "syncPlanJobExecutor")
+public class SyncPlanJobExecutor {
+
+ private final ISyncJobService jobService;
+
+ public ExecuteResult jobExecute(JobArgs jobArgs) {
+ Long planId = Convert.toLong(jobArgs.getJobParams());
+ if (planId == null) {
+ return ExecuteResult.failure("任务参数必须是同步计划 ID");
+ }
+ Long jobId = jobService.startPlan(planId, "SCHEDULE", null);
+ return ExecuteResult.success("同步任务已提交,jobId=" + jobId);
+ }
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/job/SyncPlanScheduler.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/job/SyncPlanScheduler.java
new file mode 100644
index 000000000..ad715cda5
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/job/SyncPlanScheduler.java
@@ -0,0 +1,75 @@
+package org.dromara.sync.job;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.sync.constant.SyncConstants;
+import org.dromara.sync.domain.SyncPlan;
+import org.dromara.sync.mapper.SyncPlanMapper;
+import org.dromara.sync.service.ISyncJobService;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.scheduling.support.CronExpression;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.time.ZonedDateTime;
+import java.util.List;
+
+/**
+ * 从同步计划表领取到期的 Cron 计划。
+ *
+ * 通过 next_run_time 条件更新完成多实例抢占,同一触发时刻只有一个应用实例能够提交任务。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+@ConditionalOnProperty(prefix = "sync.scheduler", name = "enabled", havingValue = "true", matchIfMissing = true)
+public class SyncPlanScheduler {
+
+ private final SyncPlanMapper planMapper;
+ private final ISyncJobService jobService;
+
+ @Scheduled(fixedDelayString = "${sync.scheduler.poll-interval-ms:30000}")
+ public void dispatchDuePlans() {
+ LocalDateTime now = LocalDateTime.now();
+ List plans = planMapper.selectList(Wrappers.lambdaQuery()
+ .eq(SyncPlan::getStatus, SyncConstants.STATUS_NORMAL)
+ .eq(SyncPlan::getScheduleType, "CRON")
+ .isNotNull(SyncPlan::getNextRunTime)
+ .le(SyncPlan::getNextRunTime, now)
+ .orderByAsc(SyncPlan::getNextRunTime)
+ .last("limit 100"));
+ for (SyncPlan plan : plans) {
+ dispatch(plan, now);
+ }
+ }
+
+ private void dispatch(SyncPlan plan, LocalDateTime now) {
+ try {
+ LocalDateTime nextRunTime = nextRunTime(plan.getCronExpression(), now);
+ boolean claimed = planMapper.lambda()
+ .set(SyncPlan::getNextRunTime, nextRunTime)
+ .eq(SyncPlan::getPlanId, plan.getPlanId())
+ .eq(SyncPlan::getNextRunTime, plan.getNextRunTime())
+ .eq(SyncPlan::getCronExpression, plan.getCronExpression())
+ .eq(SyncPlan::getScheduleType, "CRON")
+ .eq(SyncPlan::getStatus, SyncConstants.STATUS_NORMAL)
+ .update();
+ if (!claimed) {
+ return;
+ }
+ jobService.startPlan(plan.getPlanId(), "SCHEDULE", null);
+ } catch (Exception e) {
+ log.warn("到期同步计划提交失败,planId={},原因={}", plan.getPlanId(), e.getMessage());
+ }
+ }
+
+ private LocalDateTime nextRunTime(String cronExpression, LocalDateTime now) {
+ ZonedDateTime next = CronExpression.parse(cronExpression).next(now.atZone(java.time.ZoneId.systemDefault()));
+ if (next == null) {
+ throw new IllegalArgumentException("Cron 表达式没有下一次触发时间");
+ }
+ return next.toLocalDateTime();
+ }
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/mapper/SyncCheckpointMapper.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/mapper/SyncCheckpointMapper.java
new file mode 100644
index 000000000..3f0e36537
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/mapper/SyncCheckpointMapper.java
@@ -0,0 +1,11 @@
+package org.dromara.sync.mapper;
+
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.sync.domain.SyncCheckpoint;
+import org.dromara.sync.domain.vo.SyncCheckpointVo;
+
+/**
+ * 同步断点 Mapper。
+ */
+public interface SyncCheckpointMapper extends BaseMapperPlus {
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/mapper/SyncConnectionMapper.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/mapper/SyncConnectionMapper.java
new file mode 100644
index 000000000..8b1a3365b
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/mapper/SyncConnectionMapper.java
@@ -0,0 +1,15 @@
+package org.dromara.sync.mapper;
+
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.sync.domain.SyncConnection;
+import org.dromara.sync.domain.vo.SyncConnectionVo;
+
+/**
+ * 同步连接 Mapper 接口。
+ *
+ * @author Codex
+ * @date 2026-09-02
+ */
+public interface SyncConnectionMapper extends BaseMapperPlus {
+
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/mapper/SyncJobItemMapper.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/mapper/SyncJobItemMapper.java
new file mode 100644
index 000000000..2184eb3e3
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/mapper/SyncJobItemMapper.java
@@ -0,0 +1,11 @@
+package org.dromara.sync.mapper;
+
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.sync.domain.SyncJobItem;
+import org.dromara.sync.domain.vo.SyncJobItemVo;
+
+/**
+ * 同步任务明细 Mapper。
+ */
+public interface SyncJobItemMapper extends BaseMapperPlus {
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/mapper/SyncJobMapper.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/mapper/SyncJobMapper.java
new file mode 100644
index 000000000..1b5b36ae6
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/mapper/SyncJobMapper.java
@@ -0,0 +1,11 @@
+package org.dromara.sync.mapper;
+
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.sync.domain.SyncJob;
+import org.dromara.sync.domain.vo.SyncJobVo;
+
+/**
+ * 同步任务 Mapper。
+ */
+public interface SyncJobMapper extends BaseMapperPlus {
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/mapper/SyncObjectMapper.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/mapper/SyncObjectMapper.java
new file mode 100644
index 000000000..49e97c171
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/mapper/SyncObjectMapper.java
@@ -0,0 +1,11 @@
+package org.dromara.sync.mapper;
+
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.sync.domain.SyncObject;
+import org.dromara.sync.domain.vo.SyncObjectVo;
+
+/**
+ * 同步对象清单 Mapper。
+ */
+public interface SyncObjectMapper extends BaseMapperPlus {
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/mapper/SyncPlanMapper.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/mapper/SyncPlanMapper.java
new file mode 100644
index 000000000..dc7d9c85c
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/mapper/SyncPlanMapper.java
@@ -0,0 +1,15 @@
+package org.dromara.sync.mapper;
+
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.sync.domain.SyncPlan;
+import org.dromara.sync.domain.vo.SyncPlanVo;
+
+/**
+ * 同步计划 Mapper 接口。
+ *
+ * @author Codex
+ * @date 2026-09-02
+ */
+public interface SyncPlanMapper extends BaseMapperPlus {
+
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/mapper/SyncTransferPartMapper.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/mapper/SyncTransferPartMapper.java
new file mode 100644
index 000000000..e97649076
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/mapper/SyncTransferPartMapper.java
@@ -0,0 +1,11 @@
+package org.dromara.sync.mapper;
+
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.sync.domain.SyncTransferPart;
+import org.dromara.sync.domain.vo.SyncTransferPartVo;
+
+/**
+ * 同步传输分片 Mapper。
+ */
+public interface SyncTransferPartMapper extends BaseMapperPlus {
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/ISyncConnectionService.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/ISyncConnectionService.java
new file mode 100644
index 000000000..c7ab116b9
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/ISyncConnectionService.java
@@ -0,0 +1,109 @@
+package org.dromara.sync.service;
+
+import org.dromara.common.core.domain.PageResult;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.sync.connector.model.ConnectorTestResult;
+import org.dromara.sync.domain.bo.SyncConnectionBo;
+import org.dromara.sync.domain.vo.DwsAuthSessionVo;
+import org.dromara.sync.domain.vo.SyncConnectionVo;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * 同步连接 Service 接口。
+ *
+ * @author Codex
+ * @date 2026-09-02
+ */
+public interface ISyncConnectionService {
+
+ /**
+ * 根据主键查询同步连接。
+ *
+ * @param connectionId 连接主键
+ * @return 同步连接详情
+ */
+ SyncConnectionVo queryById(Long connectionId);
+
+ /**
+ * 分页查询同步连接列表。
+ *
+ * @param bo 查询条件
+ * @param pageQuery 分页参数
+ * @return 同步连接分页列表
+ */
+ PageResult queryPageList(SyncConnectionBo bo, PageQuery pageQuery);
+
+ /**
+ * 查询符合条件的同步连接列表。
+ *
+ * @param bo 查询条件
+ * @return 同步连接列表
+ */
+ List queryList(SyncConnectionBo bo);
+
+ /**
+ * 新增同步连接。
+ *
+ * @param bo 同步连接业务对象
+ * @return 是否新增成功
+ */
+ Boolean insertByBo(SyncConnectionBo bo);
+
+ /**
+ * 修改同步连接。
+ *
+ * @param bo 同步连接业务对象
+ * @return 是否修改成功
+ */
+ Boolean updateByBo(SyncConnectionBo bo);
+
+ /**
+ * 校验并批量删除同步连接。
+ *
+ * @param ids 待删除的连接主键集合
+ * @param isValid 是否执行删除前校验
+ * @return 是否删除成功
+ */
+ Boolean deleteWithValidByIds(Collection ids, Boolean isValid);
+
+ /**
+ * 校验配置并调用对应连接器执行连通性测试。
+ *
+ * @param connectionId 连接主键
+ * @return 连通性测试结果
+ */
+ ConnectorTestResult testConnection(Long connectionId);
+
+ /**
+ * 启动钉钉 Web 设备授权。
+ *
+ * @param connectionId 钉钉连接主键
+ * @param ownerId 发起授权的系统用户
+ * @param expectedCorpId 可选的组织 ID,用于防止误授权到其他组织
+ * @return 授权会话快照
+ */
+ DwsAuthSessionVo startDingTalkAuth(Long connectionId, Long ownerId, String expectedCorpId);
+
+ /**
+ * 查询钉钉 Web 设备授权状态。
+ *
+ * @param connectionId 钉钉连接主键
+ * @param sessionId 授权会话 ID
+ * @param ownerId 发起授权的系统用户
+ * @return 授权会话快照
+ */
+ DwsAuthSessionVo getDingTalkAuth(Long connectionId, String sessionId, Long ownerId);
+
+ /**
+ * 取消钉钉 Web 设备授权。
+ *
+ * @param connectionId 钉钉连接主键
+ * @param sessionId 授权会话 ID
+ * @param ownerId 发起授权的系统用户
+ * @return 取消后的会话快照
+ */
+ DwsAuthSessionVo cancelDingTalkAuth(Long connectionId, String sessionId, Long ownerId);
+
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/ISyncJobService.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/ISyncJobService.java
new file mode 100644
index 000000000..2b62e2123
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/ISyncJobService.java
@@ -0,0 +1,30 @@
+package org.dromara.sync.service;
+
+import org.dromara.common.core.domain.PageResult;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.sync.domain.bo.SyncJobBo;
+import org.dromara.sync.domain.bo.SyncJobItemBo;
+import org.dromara.sync.domain.vo.SyncJobItemVo;
+import org.dromara.sync.domain.vo.SyncJobVo;
+
+import java.util.Collection;
+
+/**
+ * 同步任务服务。
+ */
+public interface ISyncJobService {
+
+ SyncJobVo queryById(Long jobId);
+
+ PageResult queryPageList(SyncJobBo bo, PageQuery pageQuery);
+
+ PageResult queryItemPageList(SyncJobItemBo bo, PageQuery pageQuery);
+
+ Long startPlan(Long planId, String triggerType, String runType);
+
+ Long retry(Long jobId);
+
+ Boolean cancel(Long jobId);
+
+ Boolean deleteWithValidByIds(Collection ids, Boolean isValid);
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/ISyncObjectService.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/ISyncObjectService.java
new file mode 100644
index 000000000..68999022e
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/ISyncObjectService.java
@@ -0,0 +1,16 @@
+package org.dromara.sync.service;
+
+import org.dromara.common.core.domain.PageResult;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.sync.domain.bo.SyncObjectBo;
+import org.dromara.sync.domain.vo.SyncObjectVo;
+
+/**
+ * 同步对象清单服务。
+ */
+public interface ISyncObjectService {
+
+ SyncObjectVo queryById(Long objectId);
+
+ PageResult queryPageList(SyncObjectBo bo, PageQuery pageQuery);
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/ISyncPlanService.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/ISyncPlanService.java
new file mode 100644
index 000000000..f00f0d806
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/ISyncPlanService.java
@@ -0,0 +1,77 @@
+package org.dromara.sync.service;
+
+import org.dromara.common.core.domain.PageResult;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.sync.domain.bo.SyncPlanBo;
+import org.dromara.sync.domain.vo.SyncPlanVo;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * 同步计划 Service 接口。
+ *
+ * @author Codex
+ * @date 2026-09-02
+ */
+public interface ISyncPlanService {
+
+ /**
+ * 根据主键查询同步计划。
+ *
+ * @param planId 计划主键
+ * @return 同步计划详情
+ */
+ SyncPlanVo queryById(Long planId);
+
+ /**
+ * 分页查询同步计划列表。
+ *
+ * @param bo 查询条件
+ * @param pageQuery 分页参数
+ * @return 同步计划分页列表
+ */
+ PageResult queryPageList(SyncPlanBo bo, PageQuery pageQuery);
+
+ /**
+ * 查询符合条件的同步计划列表。
+ *
+ * @param bo 查询条件
+ * @return 同步计划列表
+ */
+ List queryList(SyncPlanBo bo);
+
+ /**
+ * 校验计划名称是否唯一。
+ *
+ * @param bo 同步计划
+ * @return 名称未被占用返回 {@code true}
+ */
+ boolean checkPlanNameUnique(SyncPlanBo bo);
+
+ /**
+ * 新增同步计划。
+ *
+ * @param bo 同步计划
+ * @return 是否新增成功
+ */
+ Boolean insertByBo(SyncPlanBo bo);
+
+ /**
+ * 修改同步计划。
+ *
+ * @param bo 同步计划
+ * @return 是否修改成功
+ */
+ Boolean updateByBo(SyncPlanBo bo);
+
+ /**
+ * 校验并批量删除同步计划。
+ *
+ * @param ids 计划主键集合
+ * @param isValid 是否执行删除前业务校验
+ * @return 是否删除成功
+ */
+ Boolean deleteWithValidByIds(Collection ids, Boolean isValid);
+
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/impl/SyncConnectionServiceImpl.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/impl/SyncConnectionServiceImpl.java
new file mode 100644
index 000000000..c275156a1
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/impl/SyncConnectionServiceImpl.java
@@ -0,0 +1,716 @@
+package org.dromara.sync.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.domain.PageResult;
+import org.dromara.common.core.exception.ServiceException;
+import org.dromara.common.core.utils.MapstructUtils;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.json.utils.JsonUtils;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.query.QueryBuilder;
+import org.dromara.common.satoken.utils.LoginHelper;
+import org.dromara.sync.connector.ConnectorRegistry;
+import org.dromara.sync.connector.dingtalk.DwsAuthIdentity;
+import org.dromara.sync.connector.dingtalk.DwsAuthSessionManager;
+import org.dromara.sync.connector.model.ConnectorTestResult;
+import org.dromara.sync.connector.s3.S3SseSetting;
+import org.dromara.sync.constant.SyncConstants;
+import org.dromara.sync.domain.SyncConnection;
+import org.dromara.sync.domain.SyncJob;
+import org.dromara.sync.domain.SyncPlan;
+import org.dromara.sync.domain.bo.SyncConnectionBo;
+import org.dromara.sync.domain.vo.DwsAuthSessionVo;
+import org.dromara.sync.domain.vo.SyncConnectionVo;
+import org.dromara.sync.mapper.SyncConnectionMapper;
+import org.dromara.sync.mapper.SyncJobMapper;
+import org.dromara.sync.mapper.SyncPlanMapper;
+import org.dromara.sync.service.ISyncConnectionService;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 同步连接 Service 业务层处理。
+ *
+ * @author Codex
+ * @date 2026-09-02
+ */
+@RequiredArgsConstructor
+@Service
+public class SyncConnectionServiceImpl implements ISyncConnectionService {
+
+ private static final Pattern DOWNLOAD_PART_SIZE_PATTERN = Pattern.compile("(?i)^([1-9]\\d*)(KB|MB|GB)$");
+ private static final long ONE_MIB = 1024L * 1024L;
+ private static final long ONE_GIB = 1024L * 1024L * 1024L;
+
+ private final SyncConnectionMapper connectionMapper;
+ private final SyncPlanMapper planMapper;
+ private final SyncJobMapper jobMapper;
+ private final ConnectorRegistry connectorRegistry;
+ private final DwsAuthSessionManager dwsAuthSessionManager;
+
+ /**
+ * Serializes connection mutations with the asynchronous DWS profile
+ * callback. Without this small critical section an edit could pass the
+ * active-session check just before login starts and then overwrite the
+ * profile written by the callback (or vice versa).
+ */
+ private final Object connectionMutationLock = new Object();
+
+ @Value("${mybatis-encryptor.enable:false}")
+ private boolean fieldEncryptionEnabled;
+
+ @Value("${mybatis-encryptor.password:}")
+ private String fieldEncryptionPassword;
+
+ /**
+ * 根据主键查询同步连接详情。
+ *
+ * @param connectionId 连接主键
+ * @return 同步连接详情
+ */
+ @Override
+ public SyncConnectionVo queryById(Long connectionId) {
+ ensureConnectionAccess(connectionId);
+ return connectionMapper.selectVoById(connectionId);
+ }
+
+ /**
+ * 分页查询同步连接列表。
+ *
+ * @param bo 查询条件
+ * @param pageQuery 分页参数
+ * @return 同步连接分页列表
+ */
+ @Override
+ public PageResult queryPageList(SyncConnectionBo bo, PageQuery pageQuery) {
+ LambdaQueryWrapper lqw = buildQueryWrapper(bo);
+ Page result = connectionMapper.selectVoPage(pageQuery.build(), lqw);
+ return PageResult.build(result.getRecords(), result.getTotal());
+ }
+
+ /**
+ * 查询符合条件的同步连接列表。
+ *
+ * @param bo 查询条件
+ * @return 同步连接列表
+ */
+ @Override
+ public List queryList(SyncConnectionBo bo) {
+ return connectionMapper.selectVoList(buildQueryWrapper(bo));
+ }
+
+ /**
+ * 构造同步连接动态查询条件。
+ *
+ * @param bo 查询条件
+ * @return 查询条件包装器
+ */
+ private LambdaQueryWrapper buildQueryWrapper(SyncConnectionBo bo) {
+ LambdaQueryWrapper wrapper = QueryBuilder.lambda(SyncConnection.class)
+ .eqIfPresent(SyncConnection::getConnectionId, bo.getConnectionId())
+ .likeIfText(SyncConnection::getConnectionName, bo.getConnectionName())
+ .eqIfText(SyncConnection::getConnectionRole, bo.getConnectionRole())
+ .eqIfText(SyncConnection::getConnectionType, bo.getConnectionType())
+ .likeIfText(SyncConnection::getEndpoint, bo.getEndpoint())
+ .likeIfText(SyncConnection::getBucketName, bo.getBucketName())
+ .eqIfText(SyncConnection::getStatus, bo.getStatus())
+ .orderByAsc(SyncConnection::getConnectionId)
+ .build();
+ // Connections contain credentials and are creator-owned resources. Do
+ // not rely on the menu permission alone for row-level isolation.
+ if (!LoginHelper.isSuperAdmin()) {
+ wrapper.eq(SyncConnection::getCreateBy, requireCurrentUserId());
+ }
+ return wrapper;
+ }
+
+ /**
+ * 新增同步连接。
+ *
+ * @param bo 同步连接业务对象
+ * @return 是否新增成功
+ */
+ @Override
+ public Boolean insertByBo(SyncConnectionBo bo) {
+ requireCurrentUserId();
+ SyncConnection add = MapstructUtils.convert(bo, SyncConnection.class);
+ validEntityBeforeSave(add);
+ boolean flag;
+ try {
+ flag = connectionMapper.insert(add) > 0;
+ } catch (DuplicateKeyException e) {
+ throw new ServiceException("连接名称【{}】已存在", add.getConnectionName());
+ }
+ if (flag) {
+ bo.setConnectionId(add.getConnectionId());
+ }
+ return flag;
+ }
+
+ /**
+ * 修改同步连接,未填写敏感配置时不更新数据库中的原凭证。
+ *
+ * @param bo 同步连接业务对象
+ * @return 是否修改成功
+ */
+ @Override
+ public Boolean updateByBo(SyncConnectionBo bo) {
+ synchronized (connectionMutationLock) {
+ SyncConnection current = connectionMapper.selectById(bo.getConnectionId());
+ if (current == null) {
+ throw new ServiceException("同步连接不存在");
+ }
+ ensureConnectionAccess(current);
+ if (hasActiveJob(current.getConnectionId())) {
+ throw new ServiceException("连接正在被同步任务使用,任务结束后才能修改");
+ }
+ ensureNoDingTalkAuth(current.getConnectionId());
+ SyncConnection update = MapstructUtils.convert(bo, SyncConnection.class);
+ boolean connectorChanged = !Objects.equals(current.getConnectionRole(), update.getConnectionRole())
+ || !Objects.equals(current.getConnectionType(), update.getConnectionType());
+ if (connectorChanged && isReferenced(update.getConnectionId())) {
+ throw new ServiceException("连接已被同步计划引用,不能修改连接角色或类型");
+ }
+ if (connectorChanged && StringUtils.isBlank(update.getSecretJson())) {
+ throw new ServiceException("修改连接角色或类型时必须重新填写敏感凭证");
+ }
+ preserveDingTalkProfile(current, update);
+ validEntityBeforeSave(update);
+ if (StringUtils.isBlank(update.getSecretJson())) {
+ update.setSecretJson(null);
+ }
+ try {
+ return connectionMapper.updateById(update) > 0;
+ } catch (DuplicateKeyException e) {
+ throw new ServiceException("连接名称【{}】已存在", update.getConnectionName());
+ }
+ }
+ }
+
+ /**
+ * 执行保存前的唯一性、枚举组合与 JSON 格式校验。
+ *
+ * @param entity 待保存的同步连接
+ */
+ private void validEntityBeforeSave(SyncConnection entity) {
+ if (StringUtils.isNotBlank(entity.getSecretJson())
+ && (!fieldEncryptionEnabled || StringUtils.isBlank(fieldEncryptionPassword))) {
+ throw new ServiceException("保存敏感凭证前必须启用 mybatis-encryptor 并配置 MYBATIS_ENCRYPTOR_PASSWORD");
+ }
+ entity.setConnectionName(StringUtils.trim(entity.getConnectionName()));
+ entity.setConnectionRole(StringUtils.trim(entity.getConnectionRole()));
+ entity.setConnectionType(StringUtils.trim(entity.getConnectionType()));
+ entity.setStatus(StringUtils.trim(entity.getStatus()));
+ entity.setEndpoint(StringUtils.trim(entity.getEndpoint()));
+ entity.setRegion(StringUtils.trim(entity.getRegion()));
+ entity.setBucketName(StringUtils.trim(entity.getBucketName()));
+ entity.setBasePath(StringUtils.trim(entity.getBasePath()));
+ requireText(entity.getConnectionName(), "连接名称不能为空");
+ validateLength("连接名称", entity.getConnectionName(), 100);
+ validateLength("服务端点", entity.getEndpoint(), 512);
+ validateLength("区域", entity.getRegion(), 64);
+ validateLength("存储桶名称", entity.getBucketName(), 255);
+ validateLength("基础路径", entity.getBasePath(), 1024);
+ validateLength("备注", entity.getRemark(), 500);
+ validateObjectKeyPrefix(entity.getBasePath(), "基础路径");
+ validateRoleAndType(entity);
+ validateStatus(entity.getStatus());
+ validateJsonObjectIfPresent(entity.getConfigJson(), "扩展配置");
+ validateJsonObjectIfPresent(entity.getSecretJson(), "敏感配置");
+ validateRequiredConfig(entity);
+ if (!isConnectionNameUnique(entity)) {
+ throw new ServiceException("连接名称【{}】已存在", entity.getConnectionName());
+ }
+ }
+
+ /**
+ * 校验连接名称唯一性,编辑时排除当前记录。
+ *
+ * @param entity 同步连接
+ * @return 名称唯一返回 {@code true}
+ */
+ private boolean isConnectionNameUnique(SyncConnection entity) {
+ if (StringUtils.isBlank(entity.getConnectionName())) {
+ return true;
+ }
+ LambdaQueryWrapper lqw = QueryBuilder.lambda(SyncConnection.class)
+ .eq(SyncConnection::getConnectionName, entity.getConnectionName())
+ .neIfPresent(SyncConnection::getConnectionId, entity.getConnectionId())
+ .build();
+ return !connectionMapper.exists(lqw);
+ }
+
+ /**
+ * 校验连接角色与连接类型组合。
+ *
+ * @param connection 同步连接
+ */
+ private void validateRoleAndType(SyncConnection connection) {
+ String role = connection.getConnectionRole();
+ String type = connection.getConnectionType();
+ if (SyncConstants.TYPE_DINGTALK.equals(type)) {
+ if (!SyncConstants.ROLE_SOURCE.equals(role)) {
+ throw new ServiceException("DINGTALK 连接只能配置为 SOURCE 角色");
+ }
+ return;
+ }
+ if (SyncConstants.TYPE_S3.equals(type) || SyncConstants.TYPE_ALIYUN_OSS.equals(type)) {
+ if (!SyncConstants.ROLE_TARGET.equals(role)) {
+ throw new ServiceException("{} 连接只能配置为 TARGET 角色", type);
+ }
+ return;
+ }
+ throw new ServiceException("不支持的连接类型:{}", type);
+ }
+
+ /**
+ * 校验连接状态取值。
+ *
+ * @param status 连接状态
+ */
+ private void validateStatus(String status) {
+ if (!SyncConstants.STATUS_NORMAL.equals(status) && !SyncConstants.STATUS_DISABLED.equals(status)) {
+ throw new ServiceException("连接状态只支持 0 或 1");
+ }
+ }
+
+ private void validateRequiredConfig(SyncConnection connection) {
+ validateRequiredConfig(connection, false);
+ }
+
+ /**
+ * 校验连接运行所需配置。
+ *
+ * @param connection 连接
+ * @param requireProfile 是否要求已经完成 DWS profile 配置
+ */
+ private void validateRequiredConfig(SyncConnection connection, boolean requireProfile) {
+ boolean credentialRequired = connection.getConnectionId() == null
+ || StringUtils.isNotBlank(connection.getSecretJson());
+ Map secrets = credentialRequired
+ ? parseSecretJson(connection.getSecretJson()) : Map.of();
+ if (SyncConstants.TYPE_DINGTALK.equals(connection.getConnectionType())) {
+ if (credentialRequired) {
+ requireSecret(secrets, "clientId", "钉钉连接 clientId 不能为空");
+ requireSecret(secrets, "clientSecret", "钉钉连接 clientSecret 不能为空");
+ }
+ Map config = parseOptionalJson(connection.getConfigJson());
+ String profile = stringValue(config, "profile");
+ if (requireProfile && StringUtils.isBlank(profile)) {
+ throw new ServiceException("钉钉连接尚未完成登录,请先在网页端完成设备授权");
+ }
+ if (StringUtils.isNotBlank(profile) && !profile.matches("[^\\s:]+:[^\\s:]+")) {
+ throw new ServiceException("钉钉 profile 必须使用 corpId:userId 稳定标识");
+ }
+ if (config.containsKey("configDir")) {
+ throw new ServiceException("钉钉 configDir 由服务端按连接隔离分配,不能在连接配置中指定");
+ }
+ Object spaceType = config.get("spaceType");
+ if (spaceType != null && !List.of("orgSpace", "mySpace").contains(String.valueOf(spaceType))) {
+ throw new ServiceException("钉钉 spaceType 只支持 orgSpace 或 mySpace");
+ }
+ validateDownloadOptions(config);
+ return;
+ }
+ requireText(connection.getEndpoint(), "目标连接 endpoint 不能为空");
+ requireText(connection.getBucketName(), "目标连接 bucketName 不能为空");
+ if (credentialRequired) {
+ requireSecret(secrets, "accessKey", "目标连接 accessKey 不能为空");
+ requireSecret(secrets, "secretKey", "目标连接 secretKey 不能为空");
+ }
+ // 加密方式与 KMS 密钥组合的合法性由连接器统一定义,保存时提前拦截非法配置。
+ S3SseSetting.from(parseOptionalJson(connection.getConfigJson()));
+ }
+
+ /**
+ * 校验可选 JSON 字段格式。
+ *
+ * @param value JSON 字符串
+ * @param fieldName 字段名称
+ */
+ private void validateJsonObjectIfPresent(String value, String fieldName) {
+ if (StringUtils.isNotBlank(value) && !JsonUtils.isJsonObject(value)) {
+ throw new ServiceException("{}必须是 JSON 对象", fieldName);
+ }
+ }
+
+ private Map parseOptionalJson(String json) {
+ return StringUtils.isBlank(json) ? Map.of() : JsonUtils.parseMap(json);
+ }
+
+ private void validateLength(String fieldName, String value, int maxLength) {
+ if (value != null && value.length() > maxLength) {
+ throw new ServiceException("{}不能超过{}个字符", fieldName, maxLength);
+ }
+ }
+
+ private void validateObjectKeyPrefix(String value, String fieldName) {
+ if (StringUtils.isBlank(value)) {
+ return;
+ }
+ for (String segment : value.replace('\\', '/').split("/")) {
+ if (".".equals(segment) || "..".equals(segment)) {
+ throw new ServiceException("{}不能包含 . 或 .. 路径段", fieldName);
+ }
+ }
+ if (value.chars().anyMatch(Character::isISOControl)) {
+ throw new ServiceException("{}不能包含控制字符", fieldName);
+ }
+ }
+
+ /**
+ * 校验并批量删除同步连接。
+ *
+ * @param ids 待删除的连接主键集合
+ * @param isValid 是否执行删除前校验
+ * @return 是否删除成功
+ */
+ @Override
+ public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) {
+ synchronized (connectionMutationLock) {
+ if (isValid && (ids == null || ids.isEmpty())) {
+ throw new ServiceException("待删除的连接主键不能为空");
+ }
+ if (ids != null) {
+ for (Long connectionId : ids) {
+ ensureConnectionAccess(connectionId);
+ if (isValid) {
+ ensureNoDingTalkAuth(connectionId);
+ }
+ }
+ }
+ if (isValid && planMapper.exists(com.baomidou.mybatisplus.core.toolkit.Wrappers.lambdaQuery()
+ .and(wrapper -> wrapper.in(SyncPlan::getSourceConnectionId, ids)
+ .or().in(SyncPlan::getTargetConnectionId, ids)))) {
+ throw new ServiceException("连接已被同步计划引用,不能删除");
+ }
+ return connectionMapper.deleteByIds(ids) > 0;
+ }
+ }
+
+ /**
+ * 校验连接配置并调用对应连接器执行连通性测试。
+ *
+ * @param connectionId 连接主键
+ * @return 连通性测试结果
+ */
+ @Override
+ public ConnectorTestResult testConnection(Long connectionId) {
+ SyncConnection connection = connectionMapper.selectById(connectionId);
+ if (connection == null) {
+ throw new ServiceException("同步连接不存在");
+ }
+ ensureConnectionAccess(connection);
+ requireText(connection.getConnectionName(), "连接名称不能为空");
+ validateRoleAndType(connection);
+ validateStatus(connection.getStatus());
+ validateJsonObjectIfPresent(connection.getConfigJson(), "扩展配置");
+ validateJsonObjectIfPresent(connection.getSecretJson(), "敏感配置");
+ validateRequiredConfig(connection, true);
+ return connectorRegistry.resolve(connection).test(connection);
+ }
+
+ /**
+ * 启动钉钉 Web 设备授权。登录完成后由回调把 DWS 返回的稳定 profile
+ * 合并回连接的非敏感配置,不会覆盖其他配置项或敏感凭证。
+ *
+ * @param connectionId 连接主键
+ * @param ownerId 当前系统用户
+ * @param expectedCorpId 可选组织 ID
+ * @return 授权会话快照
+ */
+ @Override
+ public DwsAuthSessionVo startDingTalkAuth(Long connectionId, Long ownerId, String expectedCorpId) {
+ synchronized (connectionMutationLock) {
+ SyncConnection connection = requireDingTalkConnection(connectionId, ownerId);
+ if (hasActiveJob(connectionId)) {
+ throw new ServiceException("连接正在被同步任务使用,任务结束后才能重新登录");
+ }
+ validateJsonObjectIfPresent(connection.getConfigJson(), "扩展配置");
+ validateJsonObjectIfPresent(connection.getSecretJson(), "敏感配置");
+ // 保存时允许 profile 为空;启动 Web 登录前仍必须有应用凭证。
+ validateRequiredConfig(connection, false);
+ return dwsAuthSessionManager.start(connection, ownerId, expectedCorpId,
+ identity -> saveDingTalkProfile(connectionId, ownerId, identity));
+ }
+ }
+
+ /**
+ * 查询钉钉 Web 设备授权状态。
+ *
+ * @param connectionId 连接主键
+ * @param sessionId 会话 ID
+ * @param ownerId 当前系统用户
+ * @return 授权会话快照
+ */
+ @Override
+ public DwsAuthSessionVo getDingTalkAuth(Long connectionId, String sessionId, Long ownerId) {
+ requireDingTalkConnection(connectionId, ownerId);
+ return dwsAuthSessionManager.get(connectionId, sessionId, ownerId, LoginHelper.isSuperAdmin(ownerId));
+ }
+
+ /**
+ * 取消钉钉 Web 设备授权。
+ *
+ * @param connectionId 连接主键
+ * @param sessionId 会话 ID
+ * @param ownerId 当前系统用户
+ * @return 取消后的授权会话快照
+ */
+ @Override
+ public DwsAuthSessionVo cancelDingTalkAuth(Long connectionId, String sessionId, Long ownerId) {
+ requireDingTalkConnection(connectionId, ownerId);
+ return dwsAuthSessionManager.cancel(connectionId, sessionId, ownerId, LoginHelper.isSuperAdmin(ownerId));
+ }
+
+ private void ensureNoDingTalkAuth(Long connectionId) {
+ if (connectionId != null && dwsAuthSessionManager.hasActive(connectionId)) {
+ throw new ServiceException("连接正在进行钉钉 Web 登录,登录结束后才能修改或删除");
+ }
+ }
+
+ private SyncConnection requireDingTalkConnection(Long connectionId) {
+ if (connectionId == null) {
+ throw new ServiceException("连接主键不能为空");
+ }
+ SyncConnection connection = connectionMapper.selectById(connectionId);
+ if (connection == null) {
+ throw new ServiceException("同步连接不存在");
+ }
+ if (!SyncConstants.TYPE_DINGTALK.equals(connection.getConnectionType())
+ || !SyncConstants.ROLE_SOURCE.equals(connection.getConnectionRole())) {
+ throw new ServiceException("只有 SOURCE 角色的 DINGTALK 连接支持 Web 登录");
+ }
+ validateStatus(connection.getStatus());
+ return connection;
+ }
+
+ /**
+ * Ensures that the current user may access a connection row. The
+ * super-admin is the only account allowed to operate across owners.
+ *
+ * @param connectionId connection id
+ */
+ private void ensureConnectionAccess(Long connectionId) {
+ if (connectionId == null) {
+ throw new ServiceException("连接主键不能为空");
+ }
+ SyncConnection connection = connectionMapper.selectById(connectionId);
+ if (connection == null) {
+ throw new ServiceException("同步连接不存在");
+ }
+ ensureConnectionAccess(connection);
+ }
+
+ /**
+ * Ensures that the current user may access a loaded connection row.
+ *
+ * @param connection connection row
+ */
+ private void ensureConnectionAccess(SyncConnection connection) {
+ Long userId = requireCurrentUserId();
+ if (!LoginHelper.isSuperAdmin(userId) && !Objects.equals(connection.getCreateBy(), userId)) {
+ throw new ServiceException("无权访问该同步连接");
+ }
+ }
+
+ /**
+ * Gets the authenticated system user id for service-layer access checks.
+ *
+ * @return current user id
+ */
+ private Long requireCurrentUserId() {
+ Long userId = LoginHelper.getUserId();
+ if (userId == null) {
+ throw new ServiceException("当前登录用户无效,请重新登录系统");
+ }
+ return userId;
+ }
+
+ private SyncConnection requireDingTalkConnection(Long connectionId, Long ownerId) {
+ SyncConnection connection = requireDingTalkConnection(connectionId);
+ if (ownerId == null) {
+ throw new ServiceException("当前登录用户无效,请重新登录系统");
+ }
+ if (!LoginHelper.isSuperAdmin(ownerId) && !Objects.equals(connection.getCreateBy(), ownerId)) {
+ throw new ServiceException("只有连接创建者或超级管理员可以发起/查看钉钉 Web 登录");
+ }
+ return connection;
+ }
+
+ private void saveDingTalkProfile(Long connectionId, Long ownerId, DwsAuthIdentity identity) {
+ synchronized (connectionMutationLock) {
+ SyncConnection current = requireDingTalkConnection(connectionId);
+ if (ownerId == null || (!LoginHelper.isSuperAdmin(ownerId)
+ && !Objects.equals(current.getCreateBy(), ownerId))) {
+ throw new ServiceException("连接归属已变化,不能保存钉钉登录身份");
+ }
+ Map config = parseOptionalJson(current.getConfigJson());
+ if (config.isEmpty()) {
+ config = new java.util.LinkedHashMap<>();
+ } else {
+ config = new java.util.LinkedHashMap<>(config);
+ }
+ config.put("profile", identity.profile());
+ String configJson = JsonUtils.toJsonString(config);
+ com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper wrapper =
+ com.baomidou.mybatisplus.core.toolkit.Wrappers.lambdaUpdate(SyncConnection.class)
+ .eq(SyncConnection::getConnectionId, connectionId)
+ .eq(SyncConnection::getConnectionType, SyncConstants.TYPE_DINGTALK)
+ .eq(SyncConnection::getConnectionRole, SyncConstants.ROLE_SOURCE)
+ .eq(SyncConnection::getStatus, SyncConstants.STATUS_NORMAL)
+ .set(SyncConnection::getConfigJson, configJson)
+ .set(SyncConnection::getUpdateBy, ownerId);
+ if (!LoginHelper.isSuperAdmin(ownerId)) {
+ wrapper.eq(SyncConnection::getCreateBy, ownerId);
+ }
+ if (connectionMapper.update(null, wrapper) <= 0) {
+ throw new ServiceException("保存钉钉登录身份失败");
+ }
+ }
+ }
+
+ /**
+ * The DWS profile is an authentication result, not editable connection
+ * metadata. Keep it server-authoritative so a normal PUT cannot redirect
+ * a connection to another user's/org's profile (or clear a valid profile
+ * by submitting a form that omits the read-only field).
+ *
+ * @param current persisted connection
+ * @param update client update
+ */
+ private void preserveDingTalkProfile(SyncConnection current, SyncConnection update) {
+ if (!SyncConstants.TYPE_DINGTALK.equals(current.getConnectionType())
+ || !SyncConstants.ROLE_SOURCE.equals(current.getConnectionRole())
+ || !SyncConstants.TYPE_DINGTALK.equals(update.getConnectionType())
+ || !SyncConstants.ROLE_SOURCE.equals(update.getConnectionRole())) {
+ return;
+ }
+ Map currentConfig = parseOptionalJson(current.getConfigJson());
+ String currentProfile = stringValue(currentConfig, "profile");
+ Map requestedConfig = StringUtils.isBlank(update.getConfigJson())
+ ? new java.util.LinkedHashMap<>(currentConfig)
+ : new java.util.LinkedHashMap<>(parseOptionalJson(update.getConfigJson()));
+ String requestedProfile = stringValue(requestedConfig, "profile");
+ if (StringUtils.isNotBlank(requestedProfile)
+ && !Objects.equals(currentProfile, requestedProfile)) {
+ throw new ServiceException("钉钉 profile 只能通过 Web 登录授权变更");
+ }
+ if (StringUtils.isNotBlank(currentProfile)) {
+ requestedConfig.put("profile", currentProfile);
+ } else {
+ requestedConfig.remove("profile");
+ }
+ update.setConfigJson(requestedConfig.isEmpty() ? null : JsonUtils.toJsonString(requestedConfig));
+ }
+
+ /**
+ * 解析敏感配置 JSON。
+ *
+ * @param secretJson 敏感配置 JSON
+ * @return 敏感配置键值
+ */
+ private Map parseSecretJson(String secretJson) {
+ if (StringUtils.isBlank(secretJson)) {
+ throw new ServiceException("敏感配置不能为空");
+ }
+ return JsonUtils.parseMap(secretJson);
+ }
+
+ private boolean isReferenced(Long connectionId) {
+ return planMapper.exists(com.baomidou.mybatisplus.core.toolkit.Wrappers.lambdaQuery()
+ .and(wrapper -> wrapper.eq(SyncPlan::getSourceConnectionId, connectionId)
+ .or().eq(SyncPlan::getTargetConnectionId, connectionId)));
+ }
+
+ private boolean hasActiveJob(Long connectionId) {
+ List planIds = planMapper.selectList(com.baomidou.mybatisplus.core.toolkit.Wrappers.lambdaQuery()
+ .select(SyncPlan::getPlanId)
+ .and(wrapper -> wrapper.eq(SyncPlan::getSourceConnectionId, connectionId)
+ .or().eq(SyncPlan::getTargetConnectionId, connectionId)))
+ .stream().map(SyncPlan::getPlanId).toList();
+ return !planIds.isEmpty() && jobMapper.exists(
+ com.baomidou.mybatisplus.core.toolkit.Wrappers.lambdaQuery()
+ .in(SyncJob::getPlanId, planIds)
+ .in(SyncJob::getStatus, SyncConstants.JOB_PENDING,
+ SyncConstants.JOB_RUNNING, SyncConstants.JOB_CANCELING));
+ }
+
+ private void validateDownloadOptions(Map config) {
+ String parallel = stringValue(config, "downloadParallel");
+ if (StringUtils.isNotBlank(parallel)) {
+ try {
+ int value = Integer.parseInt(parallel);
+ if (value < 1 || value > 8) {
+ throw new ServiceException("钉钉 downloadParallel 必须在1到8之间");
+ }
+ } catch (NumberFormatException e) {
+ throw new ServiceException("钉钉 downloadParallel 必须是整数");
+ }
+ }
+ String partSize = stringValue(config, "downloadPartSize");
+ if (StringUtils.isNotBlank(partSize)) {
+ Matcher matcher = DOWNLOAD_PART_SIZE_PATTERN.matcher(partSize);
+ if (!matcher.matches()) {
+ throw new ServiceException("钉钉 downloadPartSize 格式不正确,例如 32MB");
+ }
+ try {
+ long multiplier = switch (matcher.group(2).toUpperCase(Locale.ROOT)) {
+ case "KB" -> 1024L;
+ case "MB" -> ONE_MIB;
+ case "GB" -> ONE_GIB;
+ default -> throw new IllegalStateException("无法识别容量单位");
+ };
+ long bytes = Math.multiplyExact(Long.parseLong(matcher.group(1)), multiplier);
+ if (bytes < ONE_MIB || bytes > ONE_GIB) {
+ throw new ServiceException("钉钉 downloadPartSize 必须在1MB到1GB之间");
+ }
+ } catch (NumberFormatException | ArithmeticException e) {
+ throw new ServiceException("钉钉 downloadPartSize 超出有效范围");
+ }
+ }
+ }
+
+ private String stringValue(Map values, String key) {
+ Object value = values.get(key);
+ return value == null ? null : String.valueOf(value);
+ }
+
+ /**
+ * 校验敏感配置中的必填项。
+ *
+ * @param secrets 敏感配置
+ * @param key 配置键
+ * @param message 校验失败提示
+ */
+ private void requireSecret(Map secrets, String key, String message) {
+ Object value = secrets.get(key);
+ if (value == null || StringUtils.isBlank(String.valueOf(value))) {
+ throw new ServiceException(message);
+ }
+ }
+
+ /**
+ * 校验必填文本。
+ *
+ * @param value 文本值
+ * @param message 校验失败提示
+ */
+ private void requireText(String value, String message) {
+ if (StringUtils.isBlank(value)) {
+ throw new ServiceException(message);
+ }
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/impl/SyncJobServiceImpl.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/impl/SyncJobServiceImpl.java
new file mode 100644
index 000000000..94175d3cf
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/impl/SyncJobServiceImpl.java
@@ -0,0 +1,199 @@
+package org.dromara.sync.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.domain.PageResult;
+import org.dromara.common.core.exception.ServiceException;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.sync.constant.SyncConstants;
+import org.dromara.sync.domain.SyncJob;
+import org.dromara.sync.domain.SyncJobItem;
+import org.dromara.sync.domain.SyncPlan;
+import org.dromara.sync.domain.SyncTransferPart;
+import org.dromara.sync.domain.bo.SyncJobBo;
+import org.dromara.sync.domain.bo.SyncJobItemBo;
+import org.dromara.sync.domain.vo.SyncJobItemVo;
+import org.dromara.sync.domain.vo.SyncJobVo;
+import org.dromara.sync.mapper.SyncJobItemMapper;
+import org.dromara.sync.mapper.SyncJobMapper;
+import org.dromara.sync.mapper.SyncPlanMapper;
+import org.dromara.sync.mapper.SyncTransferPartMapper;
+import org.dromara.sync.service.ISyncJobService;
+import org.dromara.sync.worker.SyncExecutionWorker;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * 同步任务服务实现。
+ */
+@Service
+@RequiredArgsConstructor
+public class SyncJobServiceImpl implements ISyncJobService {
+
+ private final SyncJobMapper jobMapper;
+ private final SyncJobItemMapper itemMapper;
+ private final SyncTransferPartMapper transferPartMapper;
+ private final SyncPlanMapper planMapper;
+ private final SyncExecutionWorker executionWorker;
+
+ @Override
+ public SyncJobVo queryById(Long jobId) {
+ return jobMapper.selectVoById(jobId);
+ }
+
+ @Override
+ public PageResult queryPageList(SyncJobBo bo, PageQuery pageQuery) {
+ LambdaQueryWrapper lqw = Wrappers.lambdaQuery();
+ lqw.eq(bo.getPlanId() != null, SyncJob::getPlanId, bo.getPlanId());
+ lqw.eq(StringUtils.isNotBlank(bo.getTriggerType()), SyncJob::getTriggerType, bo.getTriggerType());
+ lqw.eq(StringUtils.isNotBlank(bo.getRunType()), SyncJob::getRunType, bo.getRunType());
+ lqw.eq(StringUtils.isNotBlank(bo.getStatus()), SyncJob::getStatus, bo.getStatus());
+ Object beginTime = bo.getParams().get("beginTime");
+ Object endTime = bo.getParams().get("endTime");
+ lqw.ge(beginTime != null, SyncJob::getCreateTime, beginTime);
+ lqw.le(endTime != null, SyncJob::getCreateTime, endTime);
+ lqw.orderByDesc(SyncJob::getJobId);
+ Page page = jobMapper.selectVoPage(pageQuery.build(), lqw);
+ return PageResult.build(page.getRecords(), page.getTotal());
+ }
+
+ @Override
+ public PageResult queryItemPageList(SyncJobItemBo bo, PageQuery pageQuery) {
+ LambdaQueryWrapper lqw = Wrappers.lambdaQuery();
+ lqw.eq(bo.getJobId() != null, SyncJobItem::getJobId, bo.getJobId());
+ lqw.like(StringUtils.isNotBlank(bo.getSourcePath()), SyncJobItem::getSourcePath, bo.getSourcePath());
+ lqw.eq(StringUtils.isNotBlank(bo.getObjectType()), SyncJobItem::getObjectType, bo.getObjectType());
+ lqw.eq(StringUtils.isNotBlank(bo.getActionType()), SyncJobItem::getActionType, bo.getActionType());
+ lqw.eq(StringUtils.isNotBlank(bo.getStatus()), SyncJobItem::getStatus, bo.getStatus());
+ lqw.orderByAsc(SyncJobItem::getItemId);
+ Page page = itemMapper.selectVoPage(pageQuery.build(), lqw);
+ return PageResult.build(page.getRecords(), page.getTotal());
+ }
+
+ @Override
+ public Long startPlan(Long planId, String triggerType, String runType) {
+ SyncPlan plan = planMapper.selectById(planId);
+ if (plan == null) {
+ throw new ServiceException("同步计划不存在");
+ }
+ if (!SyncConstants.STATUS_NORMAL.equals(plan.getStatus())) {
+ throw new ServiceException("同步计划已停用");
+ }
+ boolean running = jobMapper.exists(Wrappers.lambdaQuery()
+ .eq(SyncJob::getPlanId, planId)
+ .in(SyncJob::getStatus, SyncConstants.JOB_PENDING,
+ SyncConstants.JOB_RUNNING, SyncConstants.JOB_CANCELING));
+ if (running) {
+ throw new ServiceException("该同步计划已有运行中任务");
+ }
+ String actualTriggerType = StringUtils.isBlank(triggerType) ? "MANUAL" : triggerType;
+ String actualRunType = StringUtils.isBlank(runType) ? plan.getSyncMode() : runType;
+ if (!List.of("MANUAL", "SCHEDULE", "RETRY").contains(actualTriggerType)) {
+ throw new ServiceException("不支持的任务触发类型:{}", actualTriggerType);
+ }
+ if (!List.of("FULL", "INCREMENTAL").contains(actualRunType)) {
+ throw new ServiceException("不支持的任务运行类型:{}", actualRunType);
+ }
+ SyncJob job = new SyncJob();
+ job.setPlanId(planId);
+ job.setTriggerType(actualTriggerType);
+ job.setRunType(actualRunType);
+ job.setStatus(SyncConstants.JOB_PENDING);
+ job.setTotalCount(0L);
+ job.setProcessedCount(0L);
+ job.setSuccessCount(0L);
+ job.setFailedCount(0L);
+ job.setSkippedCount(0L);
+ job.setDeletedCount(0L);
+ job.setTotalBytes(0L);
+ job.setTransferredBytes(0L);
+ try {
+ if (jobMapper.insert(job) <= 0) {
+ throw new ServiceException("创建同步任务失败");
+ }
+ } catch (DuplicateKeyException e) {
+ throw new ServiceException("该同步计划已有等待或运行中的任务");
+ }
+ try {
+ executionWorker.executeAsync(job.getJobId());
+ } catch (RuntimeException e) {
+ jobMapper.lambda()
+ .set(SyncJob::getStatus, SyncConstants.JOB_FAILED)
+ .set(SyncJob::getFinishTime, LocalDateTime.now())
+ .set(SyncJob::getErrorMessage, "同步任务未能提交到后台执行器")
+ .eq(SyncJob::getJobId, job.getJobId())
+ .eq(SyncJob::getStatus, SyncConstants.JOB_PENDING)
+ .update();
+ throw new ServiceException("同步任务未能提交到后台执行器", e);
+ }
+ return job.getJobId();
+ }
+
+ @Override
+ public Long retry(Long jobId) {
+ SyncJob oldJob = jobMapper.selectById(jobId);
+ if (oldJob == null) {
+ throw new ServiceException("原同步任务不存在");
+ }
+ if (List.of(SyncConstants.JOB_PENDING, SyncConstants.JOB_RUNNING, SyncConstants.JOB_CANCELING)
+ .contains(oldJob.getStatus())) {
+ throw new ServiceException("运行中的任务不能重试");
+ }
+ return startPlan(oldJob.getPlanId(), "RETRY", "INCREMENTAL");
+ }
+
+ @Override
+ public Boolean cancel(Long jobId) {
+ SyncJob job = jobMapper.selectById(jobId);
+ if (job == null) {
+ throw new ServiceException("同步任务不存在");
+ }
+ if (!List.of(SyncConstants.JOB_PENDING, SyncConstants.JOB_RUNNING).contains(job.getStatus())) {
+ throw new ServiceException("只有等待或运行中的任务可以取消");
+ }
+ String targetStatus = SyncConstants.JOB_PENDING.equals(job.getStatus())
+ ? SyncConstants.JOB_CANCELED : SyncConstants.JOB_CANCELING;
+ boolean canceled = jobMapper.lambda()
+ .set(SyncJob::getStatus, targetStatus)
+ .set(SyncConstants.JOB_CANCELED.equals(targetStatus), SyncJob::getFinishTime, LocalDateTime.now())
+ .eq(SyncJob::getJobId, jobId)
+ .eq(SyncJob::getStatus, job.getStatus())
+ .update();
+ if (canceled && SyncConstants.JOB_CANCELING.equals(targetStatus)) {
+ executionWorker.cancel(jobId);
+ }
+ return canceled;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) {
+ if (isValid) {
+ boolean active = jobMapper.exists(Wrappers.lambdaQuery()
+ .in(SyncJob::getJobId, ids)
+ .in(SyncJob::getStatus, SyncConstants.JOB_PENDING,
+ SyncConstants.JOB_RUNNING, SyncConstants.JOB_CANCELING));
+ if (active) {
+ throw new ServiceException("等待或运行中的同步任务不能删除");
+ }
+ }
+ List itemIds = itemMapper.selectList(Wrappers.lambdaQuery()
+ .select(SyncJobItem::getItemId)
+ .in(SyncJobItem::getJobId, ids))
+ .stream().map(SyncJobItem::getItemId).toList();
+ if (!itemIds.isEmpty()) {
+ transferPartMapper.delete(Wrappers.lambdaQuery()
+ .in(SyncTransferPart::getJobItemId, itemIds));
+ }
+ itemMapper.delete(Wrappers.lambdaQuery().in(SyncJobItem::getJobId, ids));
+ return jobMapper.deleteByIds(ids) > 0;
+ }
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/impl/SyncObjectServiceImpl.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/impl/SyncObjectServiceImpl.java
new file mode 100644
index 000000000..162ccdb2a
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/impl/SyncObjectServiceImpl.java
@@ -0,0 +1,44 @@
+package org.dromara.sync.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.domain.PageResult;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.sync.domain.SyncObject;
+import org.dromara.sync.domain.bo.SyncObjectBo;
+import org.dromara.sync.domain.vo.SyncObjectVo;
+import org.dromara.sync.mapper.SyncObjectMapper;
+import org.dromara.sync.service.ISyncObjectService;
+import org.springframework.stereotype.Service;
+
+/**
+ * 同步对象清单服务实现。
+ */
+@Service
+@RequiredArgsConstructor
+public class SyncObjectServiceImpl implements ISyncObjectService {
+
+ private final SyncObjectMapper objectMapper;
+
+ @Override
+ public SyncObjectVo queryById(Long objectId) {
+ return objectMapper.selectVoById(objectId);
+ }
+
+ @Override
+ public PageResult queryPageList(SyncObjectBo bo, PageQuery pageQuery) {
+ LambdaQueryWrapper lqw = Wrappers.lambdaQuery();
+ lqw.eq(bo.getPlanId() != null, SyncObject::getPlanId, bo.getPlanId());
+ lqw.eq(StringUtils.isNotBlank(bo.getSourceObjectId()), SyncObject::getSourceObjectId, bo.getSourceObjectId());
+ lqw.like(StringUtils.isNotBlank(bo.getSourcePath()), SyncObject::getSourcePath, bo.getSourcePath());
+ lqw.eq(StringUtils.isNotBlank(bo.getObjectType()), SyncObject::getObjectType, bo.getObjectType());
+ lqw.eq(StringUtils.isNotBlank(bo.getSyncStatus()), SyncObject::getSyncStatus, bo.getSyncStatus());
+ lqw.eq(StringUtils.isNotBlank(bo.getSourceDeleted()), SyncObject::getSourceDeleted, bo.getSourceDeleted());
+ lqw.orderByAsc(SyncObject::getSourcePath);
+ Page page = objectMapper.selectVoPage(pageQuery.build(), lqw);
+ return PageResult.build(page.getRecords(), page.getTotal());
+ }
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/impl/SyncPlanServiceImpl.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/impl/SyncPlanServiceImpl.java
new file mode 100644
index 000000000..eb8c44f84
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/impl/SyncPlanServiceImpl.java
@@ -0,0 +1,380 @@
+package org.dromara.sync.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.domain.PageResult;
+import org.dromara.common.core.exception.ServiceException;
+import org.dromara.common.core.utils.MapstructUtils;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.query.QueryBuilder;
+import org.dromara.sync.constant.SyncConstants;
+import org.dromara.sync.domain.SyncConnection;
+import org.dromara.sync.domain.SyncJob;
+import org.dromara.sync.domain.SyncPlan;
+import org.dromara.sync.domain.bo.SyncPlanBo;
+import org.dromara.sync.domain.vo.SyncPlanVo;
+import org.dromara.sync.mapper.SyncConnectionMapper;
+import org.dromara.sync.mapper.SyncJobMapper;
+import org.dromara.sync.mapper.SyncPlanMapper;
+import org.dromara.sync.service.ISyncPlanService;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.scheduling.support.CronExpression;
+import org.springframework.stereotype.Service;
+
+import java.time.ZonedDateTime;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 同步计划 Service 业务层处理。
+ *
+ * @author Codex
+ * @date 2026-09-02
+ */
+@RequiredArgsConstructor
+@Service
+public class SyncPlanServiceImpl implements ISyncPlanService {
+
+ private static final String SCHEDULE_MANUAL = "MANUAL";
+ private static final String SCHEDULE_CRON = "CRON";
+
+ private static final Set SYNC_MODES = Set.of("FULL", "INCREMENTAL");
+ private static final Set SCHEDULE_TYPES = Set.of(SCHEDULE_MANUAL, SCHEDULE_CRON);
+ private static final Set CONFLICT_STRATEGIES = Set.of("OVERWRITE", "SKIP", "KEEP_BOTH");
+ private static final Set DELETE_STRATEGIES = Set.of("KEEP", "MARK", "DELETE");
+ private static final Set VERIFY_MODES = Set.of("SIZE", "ETAG", "SHA256");
+ private static final Set PLAN_STATUSES = Set.of(SyncConstants.STATUS_NORMAL, SyncConstants.STATUS_DISABLED);
+
+ private final SyncPlanMapper planMapper;
+ private final SyncConnectionMapper connectionMapper;
+ private final SyncJobMapper jobMapper;
+
+ /**
+ * 根据主键查询同步计划。
+ *
+ * @param planId 计划主键
+ * @return 同步计划详情
+ */
+ @Override
+ public SyncPlanVo queryById(Long planId) {
+ return planMapper.selectVoById(planId);
+ }
+
+ /**
+ * 分页查询同步计划列表。
+ *
+ * @param bo 查询条件
+ * @param pageQuery 分页参数
+ * @return 同步计划分页列表
+ */
+ @Override
+ public PageResult queryPageList(SyncPlanBo bo, PageQuery pageQuery) {
+ LambdaQueryWrapper lqw = buildQueryWrapper(bo);
+ Page result = planMapper.selectVoPage(pageQuery.build(), lqw);
+ return PageResult.build(result.getRecords(), result.getTotal());
+ }
+
+ /**
+ * 查询符合条件的同步计划列表。
+ *
+ * @param bo 查询条件
+ * @return 同步计划列表
+ */
+ @Override
+ public List queryList(SyncPlanBo bo) {
+ return planMapper.selectVoList(buildQueryWrapper(bo));
+ }
+
+ /**
+ * 构建同步计划动态查询条件。
+ *
+ * @param bo 查询条件
+ * @return 查询条件包装器
+ */
+ private LambdaQueryWrapper buildQueryWrapper(SyncPlanBo bo) {
+ Map params = bo.getParams();
+ return QueryBuilder.lambda(SyncPlan.class)
+ .eqIfPresent(SyncPlan::getPlanId, bo.getPlanId())
+ .likeIfText(SyncPlan::getPlanName, bo.getPlanName())
+ .eqIfPresent(SyncPlan::getSourceConnectionId, bo.getSourceConnectionId())
+ .eqIfPresent(SyncPlan::getTargetConnectionId, bo.getTargetConnectionId())
+ .likeIfText(SyncPlan::getSourceRoot, bo.getSourceRoot())
+ .likeIfText(SyncPlan::getTargetPrefix, bo.getTargetPrefix())
+ .eqIfText(SyncPlan::getSyncMode, bo.getSyncMode())
+ .eqIfText(SyncPlan::getScheduleType, bo.getScheduleType())
+ .eqIfText(SyncPlan::getConflictStrategy, bo.getConflictStrategy())
+ .eqIfText(SyncPlan::getDeleteStrategy, bo.getDeleteStrategy())
+ .eqIfPresent(SyncPlan::getDeleteGuardPercent, bo.getDeleteGuardPercent())
+ .eqIfText(SyncPlan::getVerifyMode, bo.getVerifyMode())
+ .eqIfPresent(SyncPlan::getMaxConcurrency, bo.getMaxConcurrency())
+ .eqIfPresent(SyncPlan::getBandwidthLimitKbps, bo.getBandwidthLimitKbps())
+ .eqIfText(SyncPlan::getStatus, bo.getStatus())
+ .betweenParams(SyncPlan::getLastRunTime, params, "beginLastRunTime", "endLastRunTime")
+ .betweenParams(SyncPlan::getNextRunTime, params, "beginNextRunTime", "endNextRunTime")
+ .orderByAsc(SyncPlan::getPlanId)
+ .build();
+ }
+
+ /**
+ * 校验计划名称是否唯一。
+ *
+ * @param bo 同步计划
+ * @return 名称未被占用返回 {@code true}
+ */
+ @Override
+ public boolean checkPlanNameUnique(SyncPlanBo bo) {
+ String planName = StringUtils.trim(bo.getPlanName());
+ if (StringUtils.isBlank(planName)) {
+ return true;
+ }
+ return !planMapper.lambda()
+ .eq(SyncPlan::getPlanName, planName)
+ .neIfPresent(SyncPlan::getPlanId, bo.getPlanId())
+ .exists();
+ }
+
+ /**
+ * 新增同步计划。
+ *
+ * @param bo 同步计划
+ * @return 是否新增成功
+ */
+ @Override
+ public Boolean insertByBo(SyncPlanBo bo) {
+ SyncPlan add = MapstructUtils.convert(bo, SyncPlan.class);
+ validEntityBeforeSave(add);
+ boolean flag;
+ try {
+ flag = planMapper.insert(add) > 0;
+ } catch (DuplicateKeyException e) {
+ throw new ServiceException("计划名称'{}'已存在", add.getPlanName());
+ }
+ if (flag) {
+ bo.setPlanId(add.getPlanId());
+ }
+ return flag;
+ }
+
+ /**
+ * 修改同步计划。
+ *
+ * @param bo 同步计划
+ * @return 是否修改成功
+ */
+ @Override
+ public Boolean updateByBo(SyncPlanBo bo) {
+ SyncPlan current = planMapper.selectById(bo.getPlanId());
+ if (current == null) {
+ throw new ServiceException("同步计划不存在");
+ }
+ boolean hasActiveJob = jobMapper.lambda()
+ .eq(SyncJob::getPlanId, bo.getPlanId())
+ .in(SyncJob::getStatus, SyncConstants.JOB_PENDING,
+ SyncConstants.JOB_RUNNING, SyncConstants.JOB_CANCELING)
+ .exists();
+ if (hasActiveJob) {
+ throw new ServiceException("计划存在等待或运行中的任务,任务结束后才能修改");
+ }
+ SyncPlan update = MapstructUtils.convert(bo, SyncPlan.class);
+ validEntityBeforeSave(update);
+ try {
+ return planMapper.updateById(update) > 0;
+ } catch (DuplicateKeyException e) {
+ throw new ServiceException("计划名称'{}'已存在", update.getPlanName());
+ }
+ }
+
+ /**
+ * 执行同步计划保存前的业务校验和数据归一化。
+ *
+ * @param entity 同步计划实体
+ */
+ private void validEntityBeforeSave(SyncPlan entity) {
+ entity.setPlanName(StringUtils.trim(entity.getPlanName()));
+ entity.setSourceRoot(StringUtils.trim(entity.getSourceRoot()));
+ entity.setTargetPrefix(StringUtils.trim(entity.getTargetPrefix()));
+ if (StringUtils.isBlank(entity.getPlanName())) {
+ throw new ServiceException("计划名称不能为空");
+ }
+ validateLength("计划名称", entity.getPlanName(), 100);
+ validateLength("源端同步根路径", entity.getSourceRoot(), 1024);
+ validateLength("目标端对象键前缀", entity.getTargetPrefix(), 1024);
+ validateLength("备注", entity.getRemark(), 500);
+ validateObjectKeyPrefix(entity.getTargetPrefix(), "目标端对象键前缀");
+
+ boolean nameExists = planMapper.lambda()
+ .eq(SyncPlan::getPlanName, entity.getPlanName())
+ .neIfPresent(SyncPlan::getPlanId, entity.getPlanId())
+ .exists();
+ if (nameExists) {
+ throw new ServiceException("计划名称'{}'已存在", entity.getPlanName());
+ }
+
+ validateConnection(entity.getSourceConnectionId(), SyncConstants.ROLE_SOURCE, "源端");
+ validateConnection(entity.getTargetConnectionId(), SyncConstants.ROLE_TARGET, "目标端");
+
+ entity.setSyncMode(normalizeOption(entity.getSyncMode()));
+ entity.setScheduleType(normalizeOption(entity.getScheduleType()));
+ entity.setConflictStrategy(normalizeOption(entity.getConflictStrategy()));
+ entity.setDeleteStrategy(normalizeOption(entity.getDeleteStrategy()));
+ entity.setVerifyMode(normalizeOption(entity.getVerifyMode()));
+ entity.setStatus(normalizeOption(entity.getStatus()));
+
+ validateOption("同步模式", entity.getSyncMode(), SYNC_MODES);
+ validateOption("调度类型", entity.getScheduleType(), SCHEDULE_TYPES);
+ validateOption("冲突处理策略", entity.getConflictStrategy(), CONFLICT_STRATEGIES);
+ validateOption("删除处理策略", entity.getDeleteStrategy(), DELETE_STRATEGIES);
+ validateOption("校验方式", entity.getVerifyMode(), VERIFY_MODES);
+ validateOption("状态", entity.getStatus(), PLAN_STATUSES);
+
+ if (entity.getDeleteGuardPercent() == null
+ || entity.getDeleteGuardPercent() < 0
+ || entity.getDeleteGuardPercent() > 100) {
+ throw new ServiceException("删除保护阈值必须在0到100之间");
+ }
+
+ if (entity.getMaxConcurrency() == null
+ || entity.getMaxConcurrency() < 1
+ || entity.getMaxConcurrency() > 100) {
+ throw new ServiceException("最大并发数必须在1到100之间");
+ }
+ if (entity.getBandwidthLimitKbps() == null || entity.getBandwidthLimitKbps() < 0) {
+ throw new ServiceException("带宽上限不能小于0");
+ }
+ if (entity.getBandwidthLimitKbps() > 0) {
+ throw new ServiceException("当前版本尚未启用带宽限速,请将带宽上限设置为0");
+ }
+
+ if (SCHEDULE_CRON.equals(entity.getScheduleType())) {
+ String expression = StringUtils.trim(entity.getCronExpression());
+ if (StringUtils.isBlank(expression)) {
+ throw new ServiceException("CRON调度的表达式不能为空");
+ }
+ validateLength("CRON表达式", expression, 128);
+ try {
+ CronExpression.parse(expression);
+ } catch (IllegalArgumentException ex) {
+ throw new ServiceException("CRON表达式格式不正确");
+ }
+ entity.setCronExpression(expression);
+ ZonedDateTime next = CronExpression.parse(expression).next(ZonedDateTime.now());
+ entity.setNextRunTime(next == null ? null : next.toLocalDateTime());
+ } else {
+ entity.setCronExpression(null);
+ entity.setNextRunTime(null);
+ }
+ }
+
+ /**
+ * 校验连接存在、启用且角色与计划端点一致。
+ *
+ * @param connectionId 连接主键
+ * @param expectedRole 期望角色
+ * @param endpointName 端点名称
+ */
+ private void validateConnection(Long connectionId, String expectedRole, String endpointName) {
+ if (connectionId == null) {
+ throw new ServiceException("{}连接不能为空", endpointName);
+ }
+ SyncConnection connection = connectionMapper.selectById(connectionId);
+ if (connection == null) {
+ throw new ServiceException("{}连接不存在或已删除", endpointName);
+ }
+ if (!SyncConstants.STATUS_NORMAL.equals(connection.getStatus())) {
+ throw new ServiceException("{}连接'{}'已停用", endpointName, connection.getConnectionName());
+ }
+ if (!expectedRole.equals(connection.getConnectionRole())) {
+ throw new ServiceException("{}连接'{}'的角色必须为{}", endpointName,
+ connection.getConnectionName(), expectedRole);
+ }
+ if (SyncConstants.ROLE_SOURCE.equals(expectedRole)
+ && !SyncConstants.TYPE_DINGTALK.equals(connection.getConnectionType())) {
+ throw new ServiceException("首版源端连接只支持 DINGTALK");
+ }
+ if (SyncConstants.ROLE_TARGET.equals(expectedRole)
+ && !Set.of(SyncConstants.TYPE_S3, SyncConstants.TYPE_ALIYUN_OSS)
+ .contains(connection.getConnectionType())) {
+ throw new ServiceException("首版目标端连接只支持 S3 或 ALIYUN_OSS");
+ }
+ }
+
+ /**
+ * 去除枚举型文本首尾空白。
+ *
+ * @param value 原始值
+ * @return 归一化后的值
+ */
+ private String normalizeOption(String value) {
+ return StringUtils.trim(value);
+ }
+
+ /**
+ * 校验枚举型配置值。
+ *
+ * @param fieldName 字段名称
+ * @param value 字段值
+ * @param options 允许值
+ */
+ private void validateOption(String fieldName, String value, Set options) {
+ if (!options.contains(value)) {
+ throw new ServiceException("{}不正确,可选值为{}", fieldName, String.join(",", options));
+ }
+ }
+
+ /**
+ * 校验文本长度不超过数据库字段上限。
+ *
+ * @param fieldName 字段名称
+ * @param value 字段值
+ * @param maxLength 最大长度
+ */
+ private void validateLength(String fieldName, String value, int maxLength) {
+ if (value != null && value.length() > maxLength) {
+ throw new ServiceException("{}不能超过{}个字符", fieldName, maxLength);
+ }
+ }
+
+ private void validateObjectKeyPrefix(String value, String fieldName) {
+ if (StringUtils.isBlank(value)) {
+ return;
+ }
+ for (String segment : value.replace('\\', '/').split("/")) {
+ if (".".equals(segment) || "..".equals(segment)) {
+ throw new ServiceException("{}不能包含 . 或 .. 路径段", fieldName);
+ }
+ }
+ if (value.chars().anyMatch(Character::isISOControl)) {
+ throw new ServiceException("{}不能包含控制字符", fieldName);
+ }
+ }
+
+ /**
+ * 校验并批量删除同步计划。
+ *
+ * @param ids 计划主键集合
+ * @param isValid 是否执行删除前业务校验
+ * @return 是否删除成功
+ */
+ @Override
+ public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) {
+ if (isValid && (ids == null || ids.isEmpty())) {
+ throw new ServiceException("待删除的计划主键不能为空");
+ }
+ if (isValid) {
+ boolean hasRunningJob = jobMapper.lambda()
+ .in(SyncJob::getPlanId, ids)
+ .in(SyncJob::getStatus, List.of(SyncConstants.JOB_PENDING,
+ SyncConstants.JOB_RUNNING, SyncConstants.JOB_CANCELING))
+ .exists();
+ if (hasRunningJob) {
+ throw new ServiceException("存在等待中或运行中的同步任务,不能删除计划");
+ }
+ }
+ return planMapper.deleteByIds(ids) > 0;
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/worker/SyncExecutionWorker.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/worker/SyncExecutionWorker.java
new file mode 100644
index 000000000..3ddd873e7
--- /dev/null
+++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/worker/SyncExecutionWorker.java
@@ -0,0 +1,966 @@
+package org.dromara.sync.worker;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.exception.ServiceException;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.json.utils.JsonUtils;
+import org.dromara.sync.connector.ConnectorRegistry;
+import org.dromara.sync.connector.SourceConnector;
+import org.dromara.sync.connector.TargetConnector;
+import org.dromara.sync.connector.model.*;
+import org.dromara.sync.constant.SyncConstants;
+import org.dromara.sync.domain.*;
+import org.dromara.sync.mapper.*;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.scheduling.support.CronExpression;
+import org.springframework.stereotype.Component;
+
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.security.MessageDigest;
+import java.time.LocalDateTime;
+import java.time.ZonedDateTime;
+import java.util.*;
+import java.util.concurrent.CompletionService;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorCompletionService;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 同步任务后台执行器。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class SyncExecutionWorker {
+
+ private static final String CHECKPOINT_TYPE = "RUN_WATERMARK";
+ private static final String CHECKPOINT_KEY = "default";
+ private static final int MAX_PAGES_PER_FOLDER = 100_000;
+ private static final long MAX_OBJECTS_PER_RUN = 10_000_000L;
+
+ private final SyncJobMapper jobMapper;
+ private final SyncJobItemMapper itemMapper;
+ private final SyncPlanMapper planMapper;
+ private final SyncConnectionMapper connectionMapper;
+ private final SyncObjectMapper objectMapper;
+ private final SyncCheckpointMapper checkpointMapper;
+ private final ConnectorRegistry connectorRegistry;
+ private final Map activeExecutions = new ConcurrentHashMap<>();
+
+ /**
+ * 异步执行已创建的同步任务。
+ *
+ * @param jobId 任务ID
+ */
+ @Async
+ public void executeAsync(Long jobId) {
+ execute(jobId);
+ }
+
+ /**
+ * 同步执行任务,供测试和调度适配器复用。
+ *
+ * @param jobId 任务ID
+ */
+ public void execute(Long jobId) {
+ if (!markRunning(jobId)) {
+ return;
+ }
+ Thread executionThread = Thread.currentThread();
+ activeExecutions.put(jobId, executionThread);
+ Stats stats = new Stats();
+ try {
+ SyncJob job = requireJob(jobId);
+ SyncPlan plan = requirePlan(job.getPlanId());
+ SyncConnection sourceConnection = requireConnection(plan.getSourceConnectionId(), SyncConstants.ROLE_SOURCE);
+ SyncConnection targetConnection = requireConnection(plan.getTargetConnectionId(), SyncConstants.ROLE_TARGET);
+ SourceConnector sourceConnector = connectorRegistry.source(sourceConnection.getConnectionType());
+ TargetConnector targetConnector = connectorRegistry.target(targetConnection.getConnectionType());
+ String checkpointBefore = loadCheckpoint(plan.getPlanId());
+ jobMapper.lambda().set(SyncJob::getCheckpointBefore, checkpointBefore)
+ .eq(SyncJob::getJobId, jobId).update();
+ scanAndTransfer(job, plan, sourceConnection, targetConnection, sourceConnector, targetConnector, stats);
+ checkCanceled(jobId);
+ if (hasFailures(stats)) {
+ log.warn("本次同步存在文件失败项,跳过源端删除处理,jobId={}", jobId);
+ } else {
+ processDeletedObjects(job, plan, targetConnection, targetConnector, stats);
+ }
+ checkCanceled(jobId);
+ String checkpointAfter = LocalDateTime.now() + ":" + jobId;
+ saveCheckpoint(plan.getPlanId(), jobId, checkpointAfter);
+ if (!finish(jobId, stats, checkpointAfter)) {
+ checkCanceled(jobId);
+ throw new ServiceException("同步任务状态已发生变化,不能提交完成状态");
+ }
+ planMapper.lambda()
+ .set(SyncPlan::getLastRunTime, LocalDateTime.now())
+ .set(SyncPlan::getNextRunTime, calculateNextRunSafely(plan))
+ .eq(SyncPlan::getPlanId, plan.getPlanId()).update();
+ } catch (JobCanceledException ignored) {
+ markCanceled(jobId);
+ log.info("同步任务已取消,jobId={}", jobId);
+ } catch (Exception e) {
+ if (isCancellationRequested(jobId)) {
+ markCanceled(jobId);
+ log.info("同步任务已取消,jobId={}", jobId);
+ } else {
+ log.error("同步任务执行失败,jobId={}", jobId, e);
+ fail(jobId, stats, e.getMessage());
+ }
+ } finally {
+ activeExecutions.remove(jobId, executionThread);
+ }
+ }
+
+ /**
+ * 中断当前节点上正在执行的任务。数据库状态仍是跨节点取消的最终依据。
+ *
+ * @param jobId 任务ID
+ */
+ public void cancel(Long jobId) {
+ Thread executionThread = activeExecutions.get(jobId);
+ if (executionThread != null) {
+ executionThread.interrupt();
+ }
+ }
+
+ private void scanAndTransfer(SyncJob job, SyncPlan plan, SyncConnection sourceConnection,
+ SyncConnection targetConnection, SourceConnector sourceConnector,
+ TargetConnector targetConnector, Stats stats) {
+ int configuredConcurrency = plan.getMaxConcurrency() == null ? 1 : plan.getMaxConcurrency();
+ int concurrency = Math.max(1, Math.min(configuredConcurrency, 100));
+ ExecutorService executor = Executors.newFixedThreadPool(concurrency,
+ Thread.ofVirtual().name("sync-transfer-" + job.getJobId() + "-", 0).factory());
+ CompletionService transfers = new ExecutorCompletionService<>(executor);
+ int outstanding = 0;
+ try {
+ Deque folders = new ArrayDeque<>();
+ FolderNode root = new FolderNode(normalizeSourceRoot(plan.getSourceRoot()), "", null);
+ folders.add(root);
+ Set discoveredFolders = new HashSet<>();
+ discoveredFolders.add(new FolderKey(root.scopeId(), root.objectId()));
+ Set discoveredObjects = new HashSet<>();
+ long objectCount = 0;
+ while (!folders.isEmpty()) {
+ FolderNode folder = folders.removeFirst();
+ String cursor = null;
+ Set visitedCursors = new HashSet<>();
+ int pageCount = 0;
+ do {
+ checkCanceled(job.getJobId());
+ if (++pageCount > MAX_PAGES_PER_FOLDER) {
+ throw new ServiceException("单个源目录分页超过安全上限,已停止同步");
+ }
+ String cursorKey = StringUtils.isBlank(cursor) ? "" : cursor;
+ if (!visitedCursors.add(cursorKey)) {
+ throw new ServiceException("源端返回了重复分页游标,已停止同步");
+ }
+ ScanResult page = sourceConnector.scan(sourceConnection,
+ new ScanRequest(folder.objectId(), cursor, folder.scopeId()));
+ if (page == null || page.objects() == null) {
+ throw new ServiceException("源端返回了不完整的分页结果,已停止同步");
+ }
+ for (SourceObject sourceObject : page.objects()) {
+ checkCanceled(job.getJobId());
+ if (sourceObject == null || StringUtils.isBlank(sourceObject.objectId())) {
+ throw new ServiceException("源端返回了缺少对象ID的条目,已停止同步");
+ }
+ if (!discoveredObjects.add(sourceObject.objectId())) {
+ throw new ServiceException("源端返回了重复对象ID:{}", sourceObject.objectId());
+ }
+ if (++objectCount > MAX_OBJECTS_PER_RUN) {
+ throw new ServiceException("本次扫描对象数超过安全上限,已停止同步");
+ }
+ String sourcePath = joinPath(folder.path(), sourceObject.name());
+ if (sourcePath.length() > 2048) {
+ throw new ServiceException("源端路径超过数据库长度上限:{}", sourcePath.substring(0, 256));
+ }
+ SyncObject existing = findObject(plan.getPlanId(), sourceObject.objectId());
+ if (existing != null && !Objects.equals(existing.getObjectType(), sourceObject.objectType())) {
+ throw new ServiceException("源对象类型发生变化,需人工确认后再同步:{}", sourcePath);
+ }
+ boolean existed = existing != null;
+ boolean changed = isChanged(existing, sourcePath, sourceObject);
+ String previousTargetKey = existing == null ? null : existing.getTargetKey();
+ SyncObject current = saveSeenObject(existing, plan.getPlanId(), job.getJobId(),
+ sourcePath, sourceObject);
+ if (sourceObject.isFolder()) {
+ FolderNode child = new FolderNode(sourceObject.objectId(), sourcePath,
+ metadataValue(sourceObject, "spaceId"));
+ if (!discoveredFolders.add(new FolderKey(child.scopeId(), child.objectId()))) {
+ throw new ServiceException("源端目录结构存在重复或循环节点:{}", sourcePath);
+ }
+ folders.addLast(child);
+ continue;
+ }
+ while (outstanding >= concurrency * 2) {
+ awaitTransfer(transfers);
+ outstanding--;
+ }
+ transfers.submit(() -> {
+ checkCanceled(job.getJobId());
+ transferObject(job, plan, targetConnection, sourceConnector, targetConnector,
+ sourceConnection, sourceObject, existed, changed, previousTargetKey,
+ current, sourcePath, stats);
+ return null;
+ });
+ outstanding++;
+ }
+ cursor = page.hasMore() ? page.nextCursor() : null;
+ if (page.hasMore() && StringUtils.isBlank(cursor)) {
+ throw new ServiceException("源端返回 hasMore=true 但没有分页游标,已停止同步");
+ }
+ } while (StringUtils.isNotBlank(cursor));
+ }
+ while (outstanding > 0) {
+ awaitTransfer(transfers);
+ outstanding--;
+ }
+ } finally {
+ executor.shutdownNow();
+ boolean interrupted = Thread.interrupted();
+ long nextWarningAt = System.nanoTime() + TimeUnit.MINUTES.toNanos(1);
+ try {
+ while (!executor.isTerminated()) {
+ try {
+ if (executor.awaitTermination(10, TimeUnit.SECONDS)) {
+ break;
+ }
+ } catch (InterruptedException e) {
+ interrupted = true;
+ executor.shutdownNow();
+ }
+ if (System.nanoTime() >= nextWarningAt) {
+ log.warn("仍在等待同步传输线程安全退出,jobId={}", job.getJobId());
+ nextWarningAt = System.nanoTime() + TimeUnit.MINUTES.toNanos(1);
+ }
+ }
+ } finally {
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+ }
+
+ private void awaitTransfer(CompletionService transfers) {
+ try {
+ transfers.take().get();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new ServiceException("等待文件传输时线程被中断", e);
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause();
+ if (cause instanceof JobCanceledException canceledException) {
+ throw canceledException;
+ }
+ if (cause instanceof RuntimeException runtimeException) {
+ throw runtimeException;
+ }
+ throw new ServiceException("文件传输线程执行失败", cause);
+ }
+ }
+
+ private void transferObject(SyncJob job, SyncPlan plan, SyncConnection targetConnection,
+ SourceConnector sourceConnector, TargetConnector targetConnector,
+ SyncConnection sourceConnection, SourceObject sourceObject,
+ boolean existed, boolean changed, String previousTargetKey, SyncObject current,
+ String sourcePath, Stats stats) {
+ boolean full = "FULL".equalsIgnoreCase(job.getRunType());
+ boolean shouldTransfer = full || !existed || changed;
+ String action = !existed ? "CREATE" : changed || full ? "UPDATE" : "SKIP";
+ if (!shouldTransfer) {
+ action = "SKIP";
+ }
+ String targetKey = buildTargetKey(targetConnection, plan, sourcePath, sourceObject);
+ SyncJobItem item = createItem(job.getJobId(), sourceObject, sourcePath, targetKey, action);
+ synchronized (stats) {
+ stats.totalCount++;
+ }
+ String unsupportedMessage = unsupportedSourceMessage(sourceObject);
+ if (unsupportedMessage != null) {
+ completeUnsupported(item, current, unsupportedMessage);
+ synchronized (stats) {
+ stats.processedCount++;
+ stats.skippedCount++;
+ }
+ updateProgress(job.getJobId(), stats);
+ return;
+ }
+ if (!shouldTransfer) {
+ completeSkipped(item, current, false);
+ synchronized (stats) {
+ stats.processedCount++;
+ stats.skippedCount++;
+ }
+ updateProgress(job.getJobId(), stats);
+ return;
+ }
+ try {
+ if ("SKIP".equals(plan.getConflictStrategy()) || "KEEP_BOTH".equals(plan.getConflictStrategy())) {
+ checkCanceled(job.getJobId());
+ boolean targetExists = targetConnector.exists(targetConnection, targetKey);
+ checkCanceled(job.getJobId());
+ if ("SKIP".equals(plan.getConflictStrategy()) && targetExists) {
+ completeSkipped(item, current, true);
+ synchronized (stats) {
+ stats.skippedCount++;
+ }
+ return;
+ }
+ if ("KEEP_BOTH".equals(plan.getConflictStrategy()) && targetExists) {
+ targetKey = addVersionSuffix(targetKey, sourceVersionIdentity(sourceObject));
+ item.setTargetKey(targetKey);
+ itemMapper.updateById(item);
+ }
+ }
+ synchronized (stats) {
+ stats.totalBytes += Math.max(sourceObject.size(), 0L);
+ }
+ item.setStatus(SyncConstants.ITEM_RUNNING);
+ item.setStartTime(LocalDateTime.now());
+ itemMapper.updateById(item);
+ try (SourceContent content = sourceConnector.download(sourceConnection, sourceObject)) {
+ checkCanceled(job.getJobId());
+ if (content.size() != sourceObject.size()) {
+ synchronized (stats) {
+ stats.totalBytes += content.size() - Math.max(sourceObject.size(), 0L);
+ }
+ item.setSize(content.size());
+ }
+ String sha256 = "SHA256".equals(plan.getVerifyMode())
+ ? sha256(content.path(), job.getJobId()) : sourceSha256(sourceObject.hash());
+ checkCanceled(job.getJobId());
+ Map metadata = new HashMap<>();
+ metadata.put("sync-source-id", sourceObject.objectId());
+ if (StringUtils.isNotBlank(sha256)) {
+ metadata.put("sync-sha256", sha256);
+ }
+ TargetWriteResult result = targetConnector.upload(targetConnection,
+ new TargetWriteRequest(targetKey, content.path(), content.size(), content.contentType(), metadata));
+ checkCanceled(job.getJobId());
+ if (result.size() != content.size()) {
+ throw new ServiceException("上传后大小校验失败,源大小 {},目标大小 {}", content.size(), result.size());
+ }
+ if ("SHA256".equals(plan.getVerifyMode())
+ && !Objects.equals(sha256, metadataValue(result.metadata(), "sync-sha256"))) {
+ throw new ServiceException("上传后 SHA-256 元数据回读校验失败");
+ }
+ if ("ETAG".equals(plan.getVerifyMode()) && StringUtils.isBlank(result.eTag())) {
+ throw new ServiceException("上传后未能读取目标 ETag");
+ }
+ item.setTransferredBytes(result.size());
+ if (StringUtils.isNotBlank(previousTargetKey)
+ && !previousTargetKey.equals(result.objectKey())
+ && "DELETE".equals(plan.getDeleteStrategy())
+ && !"KEEP_BOTH".equals(plan.getConflictStrategy())) {
+ checkCanceled(job.getJobId());
+ targetConnector.delete(targetConnection, previousTargetKey);
+ }
+ item.setStatus(SyncConstants.ITEM_SUCCESS);
+ item.setSourceSha256(sha256);
+ item.setTargetEtag(result.eTag());
+ item.setTargetVersionId(result.versionId());
+ item.setFinishTime(LocalDateTime.now());
+ itemMapper.updateById(item);
+ current.setTargetKey(result.objectKey());
+ current.setTargetVersionId(result.versionId());
+ current.setTargetEtag(result.eTag());
+ current.setSourceSha256(sha256);
+ current.setSyncStatus("SYNCED");
+ current.setLastSyncJobId(job.getJobId());
+ current.setLastSyncTime(LocalDateTime.now());
+ current.setLastErrorMessage(null);
+ objectMapper.updateById(current);
+ synchronized (stats) {
+ stats.successCount++;
+ stats.transferredBytes += result.size();
+ }
+ }
+ } catch (JobCanceledException e) {
+ item.setStatus(SyncConstants.ITEM_FAILED);
+ item.setFinishTime(LocalDateTime.now());
+ item.setErrorCode("CANCELED");
+ item.setErrorMessage("任务已取消");
+ itemMapper.updateById(item);
+ throw e;
+ } catch (Exception e) {
+ if (isAITableLink(sourceObject)) {
+ String message = "钉钉多维表链接(.dlink)导出失败:" + abbreviate(e.getMessage());
+ item.setStatus(SyncConstants.ITEM_SKIPPED);
+ item.setFinishTime(LocalDateTime.now());
+ item.setErrorCode("UNSUPPORTED");
+ item.setErrorMessage(message);
+ itemMapper.updateById(item);
+ current.setSyncStatus("SKIPPED");
+ current.setLastErrorMessage(message);
+ objectMapper.updateById(current);
+ synchronized (stats) {
+ stats.skippedCount++;
+ }
+ } else {
+ item.setStatus(SyncConstants.ITEM_FAILED);
+ item.setFinishTime(LocalDateTime.now());
+ item.setErrorCode(e.getClass().getSimpleName());
+ item.setErrorMessage(abbreviate(e.getMessage()));
+ itemMapper.updateById(item);
+ current.setSyncStatus("FAILED");
+ current.setLastErrorMessage(abbreviate(e.getMessage()));
+ objectMapper.updateById(current);
+ synchronized (stats) {
+ stats.failedCount++;
+ }
+ }
+ } finally {
+ synchronized (stats) {
+ stats.processedCount++;
+ }
+ updateProgress(job.getJobId(), stats);
+ }
+ }
+
+ private void processDeletedObjects(SyncJob job, SyncPlan plan, SyncConnection targetConnection,
+ TargetConnector targetConnector, Stats stats) {
+ List deleted = objectMapper.selectList(Wrappers.lambdaQuery()
+ .eq(SyncObject::getPlanId, plan.getPlanId())
+ .eq(SyncObject::getSourceDeleted, "0")
+ .and(wrapper -> wrapper.isNull(SyncObject::getLastSeenJobId)
+ .or().ne(SyncObject::getLastSeenJobId, job.getJobId())));
+ validateDeleteGuard(plan, deleted);
+ for (SyncObject object : deleted) {
+ checkCanceled(job.getJobId());
+ if (SyncConstants.OBJECT_FOLDER.equals(object.getObjectType())) {
+ markSourceDeleted(object, job.getJobId());
+ synchronized (stats) {
+ stats.deletedCount++;
+ }
+ continue;
+ }
+ SyncJobItem item = createDeleteItem(job.getJobId(), object);
+ synchronized (stats) {
+ stats.totalCount++;
+ }
+ try {
+ checkCanceled(job.getJobId());
+ if ("DELETE".equals(plan.getDeleteStrategy()) && StringUtils.isNotBlank(object.getTargetKey())) {
+ targetConnector.delete(targetConnection, object.getTargetKey());
+ }
+ checkCanceled(job.getJobId());
+ item.setStatus(SyncConstants.ITEM_SUCCESS);
+ item.setFinishTime(LocalDateTime.now());
+ itemMapper.updateById(item);
+ markSourceDeleted(object, job.getJobId());
+ synchronized (stats) {
+ stats.successCount++;
+ stats.deletedCount++;
+ }
+ } catch (JobCanceledException e) {
+ item.setStatus(SyncConstants.ITEM_FAILED);
+ item.setFinishTime(LocalDateTime.now());
+ item.setErrorCode("CANCELED");
+ item.setErrorMessage("任务已取消");
+ itemMapper.updateById(item);
+ throw e;
+ } catch (Exception e) {
+ item.setStatus(SyncConstants.ITEM_FAILED);
+ item.setFinishTime(LocalDateTime.now());
+ item.setErrorCode(e.getClass().getSimpleName());
+ item.setErrorMessage(abbreviate(e.getMessage()));
+ itemMapper.updateById(item);
+ object.setSyncStatus("FAILED");
+ object.setLastErrorMessage(abbreviate(e.getMessage()));
+ objectMapper.updateById(object);
+ synchronized (stats) {
+ stats.failedCount++;
+ }
+ }
+ synchronized (stats) {
+ stats.processedCount++;
+ }
+ updateProgress(job.getJobId(), stats);
+ }
+ }
+
+ private void validateDeleteGuard(SyncPlan plan, List deleted) {
+ if (!"DELETE".equals(plan.getDeleteStrategy())) {
+ return;
+ }
+ long destructiveCount = deleted.stream()
+ .filter(object -> !SyncConstants.OBJECT_FOLDER.equals(object.getObjectType()))
+ .filter(object -> StringUtils.isNotBlank(object.getTargetKey()))
+ .count();
+ if (destructiveCount == 0) {
+ return;
+ }
+ long activeFileCount = objectMapper.selectCount(Wrappers.lambdaQuery()
+ .eq(SyncObject::getPlanId, plan.getPlanId())
+ .eq(SyncObject::getSourceDeleted, "0")
+ .ne(SyncObject::getObjectType, SyncConstants.OBJECT_FOLDER));
+ int allowedPercent = plan.getDeleteGuardPercent() == null ? 50 : plan.getDeleteGuardPercent();
+ if (activeFileCount > 0 && destructiveCount * 100L > activeFileCount * allowedPercent) {
+ throw new ServiceException(
+ "本次拟删除目标对象 {} 个,占现有文件 {} 个的比例超过保护阈值 {}%,已拒绝删除",
+ destructiveCount, activeFileCount, allowedPercent);
+ }
+ }
+
+ private void markSourceDeleted(SyncObject object, Long jobId) {
+ object.setSourceDeleted("1");
+ object.setSyncStatus("DELETED");
+ object.setLastSyncJobId(jobId);
+ object.setLastSyncTime(LocalDateTime.now());
+ object.setLastErrorMessage(null);
+ objectMapper.updateById(object);
+ }
+
+ private SyncObject saveSeenObject(SyncObject existing, Long planId, Long jobId,
+ String sourcePath, SourceObject sourceObject) {
+ SyncObject entity = existing == null ? new SyncObject() : existing;
+ entity.setPlanId(planId);
+ entity.setSourceObjectId(sourceObject.objectId());
+ entity.setParentObjectId(sourceObject.parentObjectId());
+ entity.setSourcePath(sourcePath);
+ entity.setObjectName(sourceObject.name());
+ entity.setObjectType(sourceObject.objectType());
+ entity.setSize(sourceObject.size());
+ entity.setModifiedTime(sourceObject.modifiedTime());
+ entity.setVersionToken(sourceObject.versionToken());
+ entity.setSourceEtag(sourceObject.hash());
+ entity.setContentType(sourceObject.contentType());
+ entity.setMetadataJson(JsonUtils.toJsonString(sourceObject.metadata()));
+ entity.setSourceDeleted("0");
+ entity.setLastSeenJobId(jobId);
+ if (sourceObject.isFolder()) {
+ entity.setSyncStatus("SYNCED");
+ } else if (existing == null) {
+ entity.setSyncStatus("PENDING");
+ }
+ if (existing == null) {
+ entity.setFirstSeenJobId(jobId);
+ objectMapper.insert(entity);
+ } else {
+ objectMapper.updateById(entity);
+ }
+ return entity;
+ }
+
+ private SyncJobItem createItem(Long jobId, SourceObject object, String sourcePath,
+ String targetKey, String action) {
+ SyncJobItem item = new SyncJobItem();
+ item.setJobId(jobId);
+ item.setSourceObjectId(object.objectId());
+ item.setParentObjectId(object.parentObjectId());
+ item.setSourcePath(sourcePath);
+ item.setTargetKey(targetKey);
+ item.setObjectType(object.objectType());
+ item.setActionType(action);
+ item.setStatus(SyncConstants.ITEM_PENDING);
+ item.setSize(object.size());
+ item.setTransferredBytes(0L);
+ item.setVersionToken(object.versionToken());
+ item.setSourceEtag(object.hash());
+ item.setRetryCount(0);
+ itemMapper.insert(item);
+ return item;
+ }
+
+ private SyncJobItem createDeleteItem(Long jobId, SyncObject object) {
+ SourceObject sourceObject = new SourceObject(object.getSourceObjectId(), object.getParentObjectId(),
+ object.getObjectName(), object.getObjectType(), null, null, object.getSize(),
+ object.getModifiedTime(), object.getVersionToken(), object.getSourceEtag(), Map.of());
+ SyncJobItem item = createItem(jobId, sourceObject, object.getSourcePath(), object.getTargetKey(), "DELETE");
+ item.setStartTime(LocalDateTime.now());
+ itemMapper.updateById(item);
+ return item;
+ }
+
+ private void completeSkipped(SyncJobItem item, SyncObject object, boolean conflictSkipped) {
+ item.setActionType("SKIP");
+ item.setStatus(SyncConstants.ITEM_SKIPPED);
+ item.setFinishTime(LocalDateTime.now());
+ itemMapper.updateById(item);
+ object.setSyncStatus(conflictSkipped ? "SKIPPED" : "SYNCED");
+ object.setLastErrorMessage(null);
+ objectMapper.updateById(object);
+ }
+
+ /**
+ * 标记源端当前版本暂不支持的对象,避免把预期能力缺失计为任务失败。
+ */
+ private void completeUnsupported(SyncJobItem item, SyncObject object, String message) {
+ item.setActionType("SKIP");
+ item.setStatus(SyncConstants.ITEM_SKIPPED);
+ item.setFinishTime(LocalDateTime.now());
+ item.setErrorCode("UNSUPPORTED");
+ item.setErrorMessage(message);
+ itemMapper.updateById(item);
+ object.setSyncStatus("SKIPPED");
+ object.setLastErrorMessage(message);
+ objectMapper.updateById(object);
+ }
+
+ private String unsupportedSourceMessage(SourceObject sourceObject) {
+ String extension = sourceObject.extension();
+ if (extension == null) {
+ return null;
+ }
+ extension = extension.startsWith(".") ? extension.substring(1) : extension;
+ extension = extension.toLowerCase(Locale.ROOT);
+ if ("amind".equals(extension) || "adraw".equals(extension)) {
+ return "钉钉官方 DWS 暂不支持自动导出:" + extension;
+ }
+ return null;
+ }
+
+ private boolean isAITableLink(SourceObject sourceObject) {
+ String extension = sourceObject.extension();
+ if (extension == null) return false;
+ return "dlink".equalsIgnoreCase(extension.startsWith(".") ? extension.substring(1) : extension);
+ }
+
+ private boolean isChanged(SyncObject existing, String sourcePath, SourceObject sourceObject) {
+ if (existing == null) {
+ return true;
+ }
+ if (!"SYNCED".equals(existing.getSyncStatus())
+ || "1".equals(existing.getSourceDeleted())
+ || StringUtils.isBlank(existing.getTargetKey())
+ || !Objects.equals(existing.getSourcePath(), sourcePath)) {
+ return true;
+ }
+ if (StringUtils.isNotBlank(sourceObject.versionToken()) || StringUtils.isNotBlank(existing.getVersionToken())) {
+ return !Objects.equals(existing.getVersionToken(), sourceObject.versionToken());
+ }
+ if (StringUtils.isNotBlank(sourceObject.hash()) || StringUtils.isNotBlank(existing.getSourceEtag())) {
+ return !Objects.equals(existing.getSourceEtag(), sourceObject.hash())
+ || !Objects.equals(existing.getSize(), sourceObject.size());
+ }
+ if (sourceObject.modifiedTime() == null || existing.getModifiedTime() == null) {
+ return true;
+ }
+ return !Objects.equals(existing.getSize(), sourceObject.size())
+ || !Objects.equals(existing.getModifiedTime(), sourceObject.modifiedTime());
+ }
+
+ private String buildTargetKey(SyncConnection targetConnection, SyncPlan plan, String sourcePath,
+ SourceObject sourceObject) {
+ String relativePath = sourcePath;
+ if ("dlink".equalsIgnoreCase(normalizeExtension(sourceObject.extension()))) {
+ relativePath = replaceExtension(relativePath, "xlsx");
+ } else if (SyncConstants.OBJECT_ONLINE_DOCUMENT.equals(sourceObject.objectType())) {
+ if ("adoc".equalsIgnoreCase(sourceObject.extension())) {
+ relativePath = replaceExtension(relativePath, "docx");
+ } else if ("axls".equalsIgnoreCase(sourceObject.extension())) {
+ relativePath = replaceExtension(relativePath, "xlsx");
+ } else if ("amind".equalsIgnoreCase(sourceObject.extension())
+ || "adraw".equalsIgnoreCase(sourceObject.extension())) {
+ relativePath = replaceExtension(relativePath, "pdf");
+ }
+ }
+ return joinObjectKey(targetConnection.getBasePath(), plan.getTargetPrefix(), relativePath);
+ }
+
+ private String joinObjectKey(String... values) {
+ List segments = new ArrayList<>();
+ for (String value : values) {
+ if (StringUtils.isBlank(value)) {
+ continue;
+ }
+ for (String segment : value.replace('\\', '/').split("/")) {
+ if (StringUtils.isBlank(segment)) {
+ continue;
+ }
+ if (".".equals(segment) || "..".equals(segment)) {
+ throw new ServiceException("对象键路径不能包含 . 或 .. 段");
+ }
+ segments.add(segment);
+ }
+ }
+ if (segments.isEmpty()) {
+ throw new ServiceException("目标对象键不能为空");
+ }
+ String key = String.join("/", segments);
+ if (key.length() > 2048) {
+ throw new ServiceException("目标对象键超过数据库长度上限");
+ }
+ return key;
+ }
+
+ private String joinPath(String parent, String name) {
+ return StringUtils.isBlank(parent) ? name : parent + '/' + name;
+ }
+
+ private String normalizeSourceRoot(String sourceRoot) {
+ String value = StringUtils.trim(sourceRoot);
+ return StringUtils.isBlank(value) || "/".equals(value) ? null : value;
+ }
+
+ private LocalDateTime calculateNextRunSafely(SyncPlan plan) {
+ if (!"CRON".equals(plan.getScheduleType()) || StringUtils.isBlank(plan.getCronExpression())) {
+ return null;
+ }
+ try {
+ ZonedDateTime next = CronExpression.parse(plan.getCronExpression()).next(ZonedDateTime.now());
+ return next == null ? null : next.toLocalDateTime();
+ } catch (Exception e) {
+ log.warn("同步计划 Cron 表达式已失效,planId={},原因={}", plan.getPlanId(), e.getMessage());
+ return null;
+ }
+ }
+
+ private String metadataValue(SourceObject sourceObject, String key) {
+ return sourceObject.metadata() == null ? null : sourceObject.metadata().get(key);
+ }
+
+ private String metadataValue(Map metadata, String key) {
+ return metadata == null ? null : metadata.get(key);
+ }
+
+ private String sourceSha256(String hash) {
+ if (StringUtils.isBlank(hash) || !hash.matches("(?i)[0-9a-f]{64}")) {
+ return null;
+ }
+ return hash.toLowerCase(Locale.ROOT);
+ }
+
+ private String normalizeExtension(String extension) {
+ if (StringUtils.isBlank(extension)) return "";
+ String value = extension.startsWith(".") ? extension.substring(1) : extension;
+ return value.toLowerCase(Locale.ROOT);
+ }
+
+ private String replaceExtension(String path, String extension) {
+ int slash = path.lastIndexOf('/');
+ int dot = path.lastIndexOf('.');
+ return dot > slash ? path.substring(0, dot + 1) + extension : path + '.' + extension;
+ }
+
+ private String addVersionSuffix(String key, String versionToken) {
+ String suffix = StringUtils.isBlank(versionToken)
+ ? String.valueOf(System.currentTimeMillis()) : versionToken.replaceAll("[^a-zA-Z0-9_-]", "_");
+ if (suffix.length() > 48) {
+ suffix = suffix.substring(0, 48);
+ }
+ int slash = key.lastIndexOf('/');
+ int dot = key.lastIndexOf('.');
+ String versionedKey = dot > slash
+ ? key.substring(0, dot) + "__" + suffix + key.substring(dot) : key + "__" + suffix;
+ if (versionedKey.length() > 2048) {
+ throw new ServiceException("保留多版本后的目标对象键超过数据库长度上限");
+ }
+ return versionedKey;
+ }
+
+ private String sourceVersionIdentity(SourceObject sourceObject) {
+ if (StringUtils.isNotBlank(sourceObject.versionToken())) {
+ return sourceObject.versionToken();
+ }
+ if (StringUtils.isNotBlank(sourceObject.hash())) {
+ return sourceObject.hash();
+ }
+ if (sourceObject.modifiedTime() != null) {
+ return sourceObject.modifiedTime() + "_" + sourceObject.size();
+ }
+ return "size_" + sourceObject.size();
+ }
+
+ private String sha256(java.nio.file.Path path, Long jobId) throws Exception {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ try (InputStream input = Files.newInputStream(path)) {
+ byte[] buffer = new byte[1024 * 1024];
+ int length;
+ while ((length = input.read(buffer)) >= 0) {
+ if (Thread.currentThread().isInterrupted()) {
+ checkCanceled(jobId);
+ throw new ServiceException("计算文件摘要时线程被中断");
+ }
+ if (length > 0) {
+ digest.update(buffer, 0, length);
+ }
+ }
+ }
+ checkCanceled(jobId);
+ return HexFormat.of().formatHex(digest.digest());
+ }
+
+ private SyncObject findObject(Long planId, String sourceObjectId) {
+ return objectMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(SyncObject::getPlanId, planId)
+ .eq(SyncObject::getSourceObjectId, sourceObjectId));
+ }
+
+ private String loadCheckpoint(Long planId) {
+ SyncCheckpoint checkpoint = checkpointMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(SyncCheckpoint::getPlanId, planId)
+ .eq(SyncCheckpoint::getCheckpointType, CHECKPOINT_TYPE)
+ .eq(SyncCheckpoint::getCheckpointKey, CHECKPOINT_KEY));
+ return checkpoint == null ? null : checkpoint.getCheckpointValue();
+ }
+
+ private void saveCheckpoint(Long planId, Long jobId, String value) {
+ SyncCheckpoint checkpoint = checkpointMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(SyncCheckpoint::getPlanId, planId)
+ .eq(SyncCheckpoint::getCheckpointType, CHECKPOINT_TYPE)
+ .eq(SyncCheckpoint::getCheckpointKey, CHECKPOINT_KEY));
+ if (checkpoint == null) {
+ checkpoint = new SyncCheckpoint();
+ checkpoint.setPlanId(planId);
+ checkpoint.setCheckpointType(CHECKPOINT_TYPE);
+ checkpoint.setCheckpointKey(CHECKPOINT_KEY);
+ checkpoint.setCheckpointValue(value);
+ checkpoint.setWatermarkTime(LocalDateTime.now());
+ checkpoint.setLastJobId(jobId);
+ checkpoint.setVersion(0L);
+ checkpointMapper.insert(checkpoint);
+ } else {
+ checkpoint.setCheckpointValue(value);
+ checkpoint.setWatermarkTime(LocalDateTime.now());
+ checkpoint.setLastJobId(jobId);
+ checkpointMapper.updateById(checkpoint);
+ }
+ }
+
+ private boolean markRunning(Long jobId) {
+ return jobMapper.lambda()
+ .set(SyncJob::getStatus, SyncConstants.JOB_RUNNING)
+ .set(SyncJob::getStartTime, LocalDateTime.now())
+ .eq(SyncJob::getJobId, jobId)
+ .eq(SyncJob::getStatus, SyncConstants.JOB_PENDING)
+ .update();
+ }
+
+ private void updateProgress(Long jobId, Stats stats) {
+ synchronized (stats) {
+ jobMapper.lambda()
+ .set(SyncJob::getTotalCount, stats.totalCount)
+ .set(SyncJob::getProcessedCount, stats.processedCount)
+ .set(SyncJob::getSuccessCount, stats.successCount)
+ .set(SyncJob::getFailedCount, stats.failedCount)
+ .set(SyncJob::getSkippedCount, stats.skippedCount)
+ .set(SyncJob::getDeletedCount, stats.deletedCount)
+ .set(SyncJob::getTotalBytes, stats.totalBytes)
+ .set(SyncJob::getTransferredBytes, stats.transferredBytes)
+ .eq(SyncJob::getJobId, jobId)
+ .update();
+ }
+ }
+
+ private boolean hasFailures(Stats stats) {
+ synchronized (stats) {
+ return stats.failedCount > 0;
+ }
+ }
+
+ private boolean finish(Long jobId, Stats stats, String checkpointAfter) {
+ String status;
+ synchronized (stats) {
+ status = stats.failedCount == 0 ? SyncConstants.JOB_SUCCESS
+ : stats.successCount > 0 || stats.skippedCount > 0
+ ? SyncConstants.JOB_PARTIAL_FAILED : SyncConstants.JOB_FAILED;
+ }
+ updateProgress(jobId, stats);
+ return jobMapper.lambda()
+ .set(SyncJob::getStatus, status)
+ .set(SyncJob::getCheckpointAfter, checkpointAfter)
+ .set(SyncJob::getFinishTime, LocalDateTime.now())
+ .eq(SyncJob::getJobId, jobId)
+ .eq(SyncJob::getStatus, SyncConstants.JOB_RUNNING)
+ .update();
+ }
+
+ private void fail(Long jobId, Stats stats, String message) {
+ updateProgress(jobId, stats);
+ jobMapper.lambda()
+ .set(SyncJob::getStatus, SyncConstants.JOB_FAILED)
+ .set(SyncJob::getFinishTime, LocalDateTime.now())
+ .set(SyncJob::getErrorMessage, abbreviate(message))
+ .eq(SyncJob::getJobId, jobId)
+ .eq(SyncJob::getStatus, SyncConstants.JOB_RUNNING)
+ .update();
+ }
+
+ private boolean isCancellationRequested(Long jobId) {
+ SyncJob job = jobMapper.selectById(jobId);
+ return job != null && (SyncConstants.JOB_CANCELING.equals(job.getStatus())
+ || SyncConstants.JOB_CANCELED.equals(job.getStatus()));
+ }
+
+ private void markCanceled(Long jobId) {
+ jobMapper.lambda()
+ .set(SyncJob::getStatus, SyncConstants.JOB_CANCELED)
+ .set(SyncJob::getFinishTime, LocalDateTime.now())
+ .eq(SyncJob::getJobId, jobId)
+ .eq(SyncJob::getStatus, SyncConstants.JOB_CANCELING)
+ .update();
+ }
+
+ private void checkCanceled(Long jobId) {
+ SyncJob job = requireJob(jobId);
+ if (SyncConstants.JOB_CANCELING.equals(job.getStatus())
+ || SyncConstants.JOB_CANCELED.equals(job.getStatus())) {
+ throw new JobCanceledException();
+ }
+ }
+
+ private SyncJob requireJob(Long jobId) {
+ SyncJob job = jobMapper.selectById(jobId);
+ if (job == null) {
+ throw new ServiceException("同步任务不存在:{}", jobId);
+ }
+ return job;
+ }
+
+ private SyncPlan requirePlan(Long planId) {
+ SyncPlan plan = planMapper.selectById(planId);
+ if (plan == null) {
+ throw new ServiceException("同步计划不存在:{}", planId);
+ }
+ return plan;
+ }
+
+ private SyncConnection requireConnection(Long connectionId, String expectedRole) {
+ SyncConnection connection = connectionMapper.selectById(connectionId);
+ if (connection == null || !expectedRole.equals(connection.getConnectionRole())) {
+ throw new ServiceException("同步连接不存在或角色不正确:{}", connectionId);
+ }
+ if (!SyncConstants.STATUS_NORMAL.equals(connection.getStatus())) {
+ throw new ServiceException("同步连接已停用:{}", connection.getConnectionName());
+ }
+ return connection;
+ }
+
+ private String abbreviate(String message) {
+ if (message == null) {
+ return "未知错误";
+ }
+ return message.length() <= 2000 ? message : message.substring(0, 2000);
+ }
+
+ private record FolderNode(String objectId, String path, String scopeId) {
+ }
+
+ private record FolderKey(String scopeId, String objectId) {
+ }
+
+ private static final class Stats {
+ private long totalCount;
+ private long processedCount;
+ private long successCount;
+ private long failedCount;
+ private long skippedCount;
+ private long deletedCount;
+ private long totalBytes;
+ private long transferredBytes;
+ }
+
+ private static final class JobCanceledException extends RuntimeException {
+ private static final long serialVersionUID = 1L;
+ }
+}
diff --git a/script/sql/ry_sync.sql b/script/sql/ry_sync.sql
new file mode 100644
index 000000000..2048b3571
--- /dev/null
+++ b/script/sql/ry_sync.sql
@@ -0,0 +1,359 @@
+-- ----------------------------
+-- 数据同步模块(MySQL)
+-- 依赖 ry_vue.sql 中的 sys_menu、sys_dict_type、sys_dict_data 表
+-- ----------------------------
+
+-- ----------------------------
+-- 1、连接配置表
+-- ----------------------------
+create table sync_connection
+(
+ connection_id bigint(20) not null comment '连接ID',
+ connection_name varchar(100) not null comment '连接名称',
+ connection_role varchar(16) not null comment '连接角色(SOURCE源端 TARGET目标端)',
+ connection_type varchar(32) not null comment '连接类型(DINGTALK钉钉 S3标准S3 ALIYUN_OSS阿里云OSS)',
+ endpoint varchar(512) default null comment '服务端点',
+ region varchar(64) default null comment '区域',
+ bucket_name varchar(255) default null comment '存储桶名称',
+ base_path varchar(1024) default '' comment '连接默认根路径',
+ config_json text comment '非敏感扩展配置JSON',
+ secret_json text comment '加密后的敏感配置JSON(禁止明文存储)',
+ status char(1) default '0' comment '状态(0正常 1停用)',
+ del_flag char(1) default '0' comment '删除标志(0代表存在 1代表删除)',
+ active_name varchar(100) generated always as
+ (case when del_flag = '0' then connection_name else null end) stored
+ comment '有效连接名称(用于唯一约束)',
+ create_dept bigint(20) default null comment '创建部门',
+ create_by bigint(20) default null comment '创建者',
+ create_time datetime default null comment '创建时间',
+ update_by bigint(20) default null comment '更新者',
+ update_time datetime default null comment '更新时间',
+ remark varchar(500) default null comment '备注',
+ primary key (connection_id),
+ unique key uk_sync_connection_active_name (active_name),
+ key idx_sync_connection_name_del (connection_name, del_flag),
+ key idx_sync_connection_role_type (connection_role, connection_type),
+ key idx_sync_connection_status_del (status, del_flag)
+) engine = innodb comment = '数据同步连接配置表';
+
+-- ----------------------------
+-- 2、同步计划表
+-- ----------------------------
+create table sync_plan
+(
+ plan_id bigint(20) not null comment '同步计划ID',
+ plan_name varchar(100) not null comment '计划名称',
+ source_connection_id bigint(20) not null comment '源端连接ID',
+ target_connection_id bigint(20) not null comment '目标端连接ID',
+ source_root varchar(1024) default '/' comment '源端同步根路径或空间标识',
+ target_prefix varchar(1024) default '' comment '目标端对象键前缀',
+ sync_mode varchar(16) default 'INCREMENTAL' comment '同步模式(FULL全量 INCREMENTAL增量)',
+ schedule_type varchar(16) default 'MANUAL' comment '调度类型(MANUAL手动 CRON定时)',
+ cron_expression varchar(128) default null comment 'Cron表达式',
+ conflict_strategy varchar(16) default 'OVERWRITE' comment '冲突策略(OVERWRITE覆盖 SKIP跳过 KEEP_BOTH两者保留)',
+ delete_strategy varchar(16) default 'KEEP' comment '删除策略(KEEP保留 MARK标记 DELETE删除目标)',
+ delete_guard_percent int(11) default 50 comment '单次目标删除比例保护阈值(0-100,100表示允许全删)',
+ verify_mode varchar(16) default 'SIZE' comment '校验方式(SIZE大小 ETAG标识 SHA256摘要)',
+ max_concurrency int(11) default 4 comment '最大并发传输数',
+ bandwidth_limit_kbps bigint(20) default 0 comment '带宽上限KB/s(0表示不限速)',
+ status char(1) default '0' comment '状态(0正常 1停用)',
+ last_run_time datetime default null comment '最近运行时间',
+ next_run_time datetime default null comment '下次运行时间',
+ del_flag char(1) default '0' comment '删除标志(0代表存在 1代表删除)',
+ active_name varchar(100) generated always as
+ (case when del_flag = '0' then plan_name else null end) stored
+ comment '有效计划名称(用于唯一约束)',
+ create_dept bigint(20) default null comment '创建部门',
+ create_by bigint(20) default null comment '创建者',
+ create_time datetime default null comment '创建时间',
+ update_by bigint(20) default null comment '更新者',
+ update_time datetime default null comment '更新时间',
+ remark varchar(500) default null comment '备注',
+ primary key (plan_id),
+ unique key uk_sync_plan_active_name (active_name),
+ key idx_sync_plan_name_del (plan_name, del_flag),
+ key idx_sync_plan_source_connection (source_connection_id),
+ key idx_sync_plan_target_connection (target_connection_id),
+ key idx_sync_plan_schedule (status, del_flag, schedule_type, next_run_time)
+) engine = innodb comment = '数据同步计划表';
+
+-- ----------------------------
+-- 3、同步任务表
+-- ----------------------------
+create table sync_job
+(
+ job_id bigint(20) not null comment '同步任务ID',
+ plan_id bigint(20) not null comment '同步计划ID',
+ trigger_type varchar(16) not null comment '触发类型(MANUAL手动 SCHEDULE调度 RETRY重试)',
+ run_type varchar(16) not null comment '运行类型(FULL全量 INCREMENTAL增量)',
+ status varchar(20) default 'PENDING' comment '任务状态(PENDING待执行 RUNNING执行中 CANCELING取消中 SUCCESS成功 PARTIAL_FAILED部分失败 FAILED失败 CANCELED已取消)',
+ active_plan_id bigint(20) generated always as
+ (case when status in ('PENDING', 'RUNNING', 'CANCELING') then plan_id else null end) stored
+ comment '活动任务计划ID(用于保证单计划仅一个运行任务)',
+ checkpoint_before text comment '运行前检查点快照',
+ checkpoint_after text comment '运行后检查点快照',
+ total_count bigint(20) default 0 comment '待处理对象总数',
+ processed_count bigint(20) default 0 comment '已处理对象数',
+ success_count bigint(20) default 0 comment '成功对象数',
+ failed_count bigint(20) default 0 comment '失败对象数',
+ skipped_count bigint(20) default 0 comment '跳过对象数',
+ deleted_count bigint(20) default 0 comment '删除或标记对象数',
+ total_bytes bigint(20) default 0 comment '待传输总字节数',
+ transferred_bytes bigint(20) default 0 comment '已传输字节数',
+ start_time datetime default null comment '开始时间',
+ finish_time datetime default null comment '结束时间',
+ error_message text comment '任务错误信息',
+ create_dept bigint(20) default null comment '创建部门',
+ create_by bigint(20) default null comment '创建者',
+ create_time datetime default null comment '创建时间',
+ update_by bigint(20) default null comment '更新者',
+ update_time datetime default null comment '更新时间',
+ primary key (job_id),
+ unique key uk_sync_job_active_plan (active_plan_id),
+ key idx_sync_job_plan_time (plan_id, create_time),
+ key idx_sync_job_status_time (status, create_time)
+) engine = innodb comment = '数据同步任务表';
+
+-- ----------------------------
+-- 4、同步任务明细表
+-- ----------------------------
+create table sync_job_item
+(
+ item_id bigint(20) not null comment '任务明细ID',
+ job_id bigint(20) not null comment '同步任务ID',
+ source_object_id varchar(255) not null comment '源端对象唯一标识',
+ parent_object_id varchar(255) default null comment '源端父对象标识',
+ source_path varchar(2048) not null comment '源端相对路径',
+ target_key varchar(2048) default null comment '目标端对象键',
+ object_type varchar(16) not null comment '对象类型(FILE文件 FOLDER目录 ONLINE_DOCUMENT在线文档)',
+ action_type varchar(16) not null comment '动作类型(CREATE新增上传 UPDATE更新上传 SKIP跳过 DELETE删除)',
+ status varchar(20) default 'PENDING' comment '明细状态(PENDING待执行 RUNNING执行中 SUCCESS成功 SKIPPED已跳过 FAILED失败)',
+ size bigint(20) default 0 comment '源对象字节数',
+ transferred_bytes bigint(20) default 0 comment '已传输字节数',
+ version_token varchar(512) default null comment '源端版本标识',
+ source_etag varchar(255) default null comment '源端ETag',
+ source_sha256 char(64) default null comment '源端SHA-256摘要',
+ target_etag varchar(255) default null comment '目标端ETag',
+ target_version_id varchar(512) default null comment '目标端版本ID',
+ retry_count int(11) default 0 comment '已重试次数',
+ start_time datetime default null comment '开始时间',
+ finish_time datetime default null comment '结束时间',
+ error_code varchar(64) default null comment '错误码',
+ error_message text comment '错误信息',
+ create_dept bigint(20) default null comment '创建部门',
+ create_by bigint(20) default null comment '创建者',
+ create_time datetime default null comment '创建时间',
+ update_by bigint(20) default null comment '更新者',
+ update_time datetime default null comment '更新时间',
+ primary key (item_id),
+ unique key uk_sync_job_item_job_object (job_id, source_object_id),
+ key idx_sync_job_item_job_status (job_id, status),
+ key idx_sync_job_item_retry (status, retry_count),
+ key idx_sync_job_item_parent (job_id, parent_object_id)
+) engine = innodb comment = '数据同步任务明细表';
+
+-- ----------------------------
+-- 5、源对象状态表
+-- ----------------------------
+create table sync_object
+(
+ object_id bigint(20) not null comment '对象记录ID',
+ plan_id bigint(20) not null comment '同步计划ID',
+ source_object_id varchar(255) not null comment '源端对象唯一标识',
+ parent_object_id varchar(255) default null comment '源端父对象标识',
+ object_name varchar(1024) not null comment '对象名称',
+ source_path varchar(2048) not null comment '相对同步根目录的源端路径',
+ object_type varchar(16) not null comment '对象类型(FILE文件 FOLDER目录 ONLINE_DOCUMENT在线文档)',
+ size bigint(20) default 0 comment '对象字节数',
+ modified_time datetime default null comment '源端最后修改时间',
+ version_token varchar(512) default null comment '源端版本标识',
+ source_etag varchar(255) default null comment '源端ETag',
+ source_sha256 char(64) default null comment '源端SHA-256摘要',
+ content_type varchar(255) default null comment '内容类型',
+ metadata_json text comment '源端扩展元数据JSON',
+ target_key varchar(2048) default null comment '最近成功同步的目标对象键',
+ target_version_id varchar(512) default null comment '最近成功同步的目标版本ID',
+ target_etag varchar(255) default null comment '最近成功同步的目标端ETag',
+ sync_status varchar(20) default 'DISCOVERED' comment '同步状态(DISCOVERED已发现 PENDING待同步 SYNCED已同步 SKIPPED已跳过 FAILED失败 DELETED源端已删除)',
+ source_deleted char(1) default '0' comment '源端删除标志(0未删除 1已删除)',
+ first_seen_job_id bigint(20) default null comment '首次发现任务ID',
+ last_seen_job_id bigint(20) default null comment '最近扫描发现任务ID',
+ last_sync_job_id bigint(20) default null comment '最近成功同步任务ID',
+ last_sync_time datetime default null comment '最近成功同步时间',
+ last_error_message text comment '最近同步错误信息',
+ create_dept bigint(20) default null comment '创建部门',
+ create_by bigint(20) default null comment '创建者',
+ create_time datetime default null comment '创建时间',
+ update_by bigint(20) default null comment '更新者',
+ update_time datetime default null comment '更新时间',
+ primary key (object_id),
+ unique key uk_sync_object_plan_source (plan_id, source_object_id),
+ key idx_sync_object_plan_status (plan_id, sync_status, source_deleted),
+ key idx_sync_object_last_seen (plan_id, last_seen_job_id),
+ key idx_sync_object_target_key (plan_id, target_key(191))
+) engine = innodb comment = '数据同步源对象状态表';
+
+-- ----------------------------
+-- 6、增量检查点表
+-- ----------------------------
+create table sync_checkpoint
+(
+ checkpoint_id bigint(20) not null comment '检查点ID',
+ plan_id bigint(20) not null comment '同步计划ID',
+ checkpoint_type varchar(32) not null comment '检查点类型(PAGE_CURSOR分页游标 INCREMENTAL_WATERMARK增量水位 RUN_WATERMARK运行水位)',
+ checkpoint_key varchar(255) not null comment '检查点作用域键(空间、目录或分区标识)',
+ checkpoint_value longtext comment '检查点值或JSON快照',
+ watermark_time datetime default null comment '增量水位时间',
+ last_job_id bigint(20) default null comment '最近提交检查点的任务ID',
+ version bigint(20) default 0 comment '乐观锁版本号',
+ create_dept bigint(20) default null comment '创建部门',
+ create_by bigint(20) default null comment '创建者',
+ create_time datetime default null comment '创建时间',
+ update_by bigint(20) default null comment '更新者',
+ update_time datetime default null comment '更新时间',
+ primary key (checkpoint_id),
+ unique key uk_sync_checkpoint_plan_type_key (plan_id, checkpoint_type, checkpoint_key),
+ key idx_sync_checkpoint_last_job (last_job_id)
+) engine = innodb comment = '数据同步增量检查点表';
+
+-- ----------------------------
+-- 7、分片传输记录表
+-- ----------------------------
+create table sync_transfer_part
+(
+ part_id bigint(20) not null comment '分片记录ID',
+ job_item_id bigint(20) not null comment '任务明细ID',
+ upload_id varchar(512) not null comment '目标端分片上传会话ID',
+ part_number int(11) not null comment '分片序号(从1开始)',
+ part_offset bigint(20) default 0 comment '分片起始字节偏移',
+ part_size bigint(20) default 0 comment '分片字节数',
+ transferred_bytes bigint(20) default 0 comment '分片已传输字节数',
+ part_etag varchar(255) default null comment '目标端分片ETag',
+ checksum_sha256 char(64) default null comment '分片SHA-256摘要',
+ status varchar(20) default 'PENDING' comment '分片状态(PENDING待上传 UPLOADING上传中 SUCCESS成功 FAILED失败)',
+ retry_count int(11) default 0 comment '已重试次数',
+ start_time datetime default null comment '开始时间',
+ finish_time datetime default null comment '结束时间',
+ error_message text comment '错误信息',
+ create_dept bigint(20) default null comment '创建部门',
+ create_by bigint(20) default null comment '创建者',
+ create_time datetime default null comment '创建时间',
+ update_by bigint(20) default null comment '更新者',
+ update_time datetime default null comment '更新时间',
+ primary key (part_id),
+ unique key uk_sync_transfer_part_item_number (job_item_id, part_number),
+ key idx_sync_transfer_part_upload_status (upload_id(191), status),
+ key idx_sync_transfer_part_status_retry (status, retry_count)
+) engine = innodb comment = '数据同步分片传输记录表';
+
+-- ----------------------------
+-- 8、数据同步菜单
+-- ----------------------------
+insert into sys_menu
+ (menu_id, menu_name, parent_id, order_num, path, component, query_param, is_frame, is_cache, menu_type,
+ visible, status, perms, icon, active_menu, ext, create_dept, create_by, create_time, update_by, update_time, remark)
+values
+ (1901400000000000001, '数据同步', 0, 2, 'sync', null, null, 'N', 'Y', 'M', '0', '0', null, 'sync', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '数据同步目录'),
+ (1901400000000000100, '连接管理', 1901400000000000001, 1, 'connection', 'sync/connection/index', null, 'N', 'Y', 'C', '0', '0', 'sync:connection:list', 'link', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '数据同步连接管理菜单'),
+ (1901400000000000101, '同步计划', 1901400000000000001, 2, 'plan', 'sync/plan/index', null, 'N', 'Y', 'C', '0', '0', 'sync:plan:list', 'calendar', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '数据同步计划菜单'),
+ (1901400000000000102, '同步任务', 1901400000000000001, 3, 'job', 'sync/job/index', null, 'N', 'Y', 'C', '0', '0', 'sync:job:list', 'job', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '数据同步任务菜单'),
+ (1901400000000000103, '对象清单', 1901400000000000001, 4, 'object', 'sync/object/index', null, 'N', 'Y', 'C', '0', '0', 'sync:object:list', 'list', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '数据同步对象清单菜单'),
+ (1901400000000001001, '连接查询', 1901400000000000100, 1, '#', '', null, 'N', 'Y', 'F', '0', '0', 'sync:connection:query', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''),
+ (1901400000000001002, '连接新增', 1901400000000000100, 2, '#', '', null, 'N', 'Y', 'F', '0', '0', 'sync:connection:add', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''),
+ (1901400000000001003, '连接修改', 1901400000000000100, 3, '#', '', null, 'N', 'Y', 'F', '0', '0', 'sync:connection:edit', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''),
+ (1901400000000001004, '连接删除', 1901400000000000100, 4, '#', '', null, 'N', 'Y', 'F', '0', '0', 'sync:connection:remove', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''),
+ (1901400000000001006, '连接测试', 1901400000000000100, 5, '#', '', null, 'N', 'Y', 'F', '0', '0', 'sync:connection:test', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''),
+ (1901400000000001007, '钉钉 Web 登录', 1901400000000000100, 6, '#', '', null, 'N', 'Y', 'F', '0', '0', 'sync:connection:auth', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''),
+ (1901400000000001011, '计划查询', 1901400000000000101, 1, '#', '', null, 'N', 'Y', 'F', '0', '0', 'sync:plan:query', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''),
+ (1901400000000001012, '计划新增', 1901400000000000101, 2, '#', '', null, 'N', 'Y', 'F', '0', '0', 'sync:plan:add', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''),
+ (1901400000000001013, '计划修改', 1901400000000000101, 3, '#', '', null, 'N', 'Y', 'F', '0', '0', 'sync:plan:edit', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''),
+ (1901400000000001014, '计划删除', 1901400000000000101, 4, '#', '', null, 'N', 'Y', 'F', '0', '0', 'sync:plan:remove', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''),
+ (1901400000000001016, '立即运行', 1901400000000000101, 5, '#', '', null, 'N', 'Y', 'F', '0', '0', 'sync:job:run', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''),
+ (1901400000000001021, '任务查询', 1901400000000000102, 1, '#', '', null, 'N', 'Y', 'F', '0', '0', 'sync:job:query', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''),
+ (1901400000000001022, '任务删除', 1901400000000000102, 2, '#', '', null, 'N', 'Y', 'F', '0', '0', 'sync:job:remove', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''),
+ (1901400000000001024, '取消任务', 1901400000000000102, 3, '#', '', null, 'N', 'Y', 'F', '0', '0', 'sync:job:cancel', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''),
+ (1901400000000001025, '重试任务', 1901400000000000102, 4, '#', '', null, 'N', 'Y', 'F', '0', '0', 'sync:job:retry', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''),
+ (1901400000000001031, '对象查询', 1901400000000000103, 1, '#', '', null, 'N', 'Y', 'F', '0', '0', 'sync:object:query', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '');
+
+-- ----------------------------
+-- 9、数据同步字典类型
+-- ----------------------------
+insert into sys_dict_type
+ (dict_id, dict_name, dict_type, create_dept, create_by, create_time, update_by, update_time, remark)
+values
+ (1901500000000000001, '同步连接角色', 'sync_connection_role', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '数据同步连接角色'),
+ (1901500000000000002, '同步连接类型', 'sync_connection_type', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '数据同步连接类型'),
+ (1901500000000000003, '同步模式', 'sync_mode', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '全量或增量同步模式'),
+ (1901500000000000004, '同步调度类型', 'sync_schedule_type', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '手动或Cron调度'),
+ (1901500000000000005, '同步冲突策略', 'sync_conflict_strategy', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '目标对象冲突处理策略'),
+ (1901500000000000006, '同步删除策略', 'sync_delete_strategy', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '源端删除处理策略'),
+ (1901500000000000007, '同步校验方式', 'sync_verify_mode', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '传输完整性校验方式'),
+ (1901500000000000008, '同步触发类型', 'sync_trigger_type', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '同步任务触发类型'),
+ (1901500000000000009, '同步任务状态', 'sync_job_status', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '同步任务运行状态'),
+ (1901500000000000010, '同步明细状态', 'sync_job_item_status', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '同步任务明细状态'),
+ (1901500000000000011, '同步对象类型', 'sync_object_type', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '同步对象类型'),
+ (1901500000000000012, '同步动作类型', 'sync_action_type', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '同步任务明细动作类型'),
+ (1901500000000000013, '同步对象状态', 'sync_object_status', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '源对象同步状态'),
+ (1901500000000000014, '同步分片状态', 'sync_transfer_part_status', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '分片上传状态'),
+ (1901500000000000015, '同步检查点类型', 'sync_checkpoint_type', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '增量检查点类型');
+
+-- ----------------------------
+-- 10、数据同步字典数据
+-- ----------------------------
+insert into sys_dict_data
+ (dict_code, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default,
+ create_dept, create_by, create_time, update_by, update_time, remark)
+values
+ (1901600000000000001, 1, '源端', 'SOURCE', 'sync_connection_role', '', 'primary', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '数据来源连接'),
+ (1901600000000000002, 2, '目标端', 'TARGET', 'sync_connection_role', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '数据目标连接'),
+ (1901600000000000003, 1, '钉钉', 'DINGTALK', 'sync_connection_type', '', 'primary', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '钉钉企业网盘'),
+ (1901600000000000004, 2, '标准S3', 'S3', 'sync_connection_type', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, 'S3标准兼容存储'),
+ (1901600000000000005, 3, '阿里云OSS', 'ALIYUN_OSS', 'sync_connection_type', '', 'warning', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '阿里云对象存储OSS'),
+ (1901600000000000006, 1, '全量同步', 'FULL', 'sync_mode', '', 'primary', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '每次扫描全部对象'),
+ (1901600000000000007, 2, '增量同步', 'INCREMENTAL', 'sync_mode', '', 'success', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '完整枚举后按持久清单差异传输'),
+ (1901600000000000008, 1, '手动执行', 'MANUAL', 'sync_schedule_type', '', 'primary', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '仅手动触发'),
+ (1901600000000000009, 2, 'Cron调度', 'CRON', 'sync_schedule_type', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '按Cron表达式触发'),
+ (1901600000000000010, 1, '覆盖', 'OVERWRITE', 'sync_conflict_strategy', '', 'warning', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '覆盖目标对象'),
+ (1901600000000000011, 2, '跳过', 'SKIP', 'sync_conflict_strategy', '', 'info', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '保留目标对象并跳过'),
+ (1901600000000000012, 3, '两者保留', 'KEEP_BOTH', 'sync_conflict_strategy', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '重命名后保留两个对象'),
+ (1901600000000000013, 1, '保留目标', 'KEEP', 'sync_delete_strategy', '', 'primary', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '源端删除时保留目标对象'),
+ (1901600000000000014, 2, '仅标记', 'MARK', 'sync_delete_strategy', '', 'warning', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '仅记录源端删除状态'),
+ (1901600000000000015, 3, '删除目标', 'DELETE', 'sync_delete_strategy', '', 'danger', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '同步删除目标对象'),
+ (1901600000000000016, 1, '文件大小', 'SIZE', 'sync_verify_mode', '', 'primary', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '按对象大小校验'),
+ (1901600000000000017, 2, 'ETag', 'ETAG', 'sync_verify_mode', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '按ETag校验'),
+ (1901600000000000018, 3, 'SHA-256', 'SHA256', 'sync_verify_mode', '', 'warning', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '按SHA-256摘要校验'),
+ (1901600000000000019, 1, '手动触发', 'MANUAL', 'sync_trigger_type', '', 'primary', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '用户手动触发任务'),
+ (1901600000000000020, 2, '调度触发', 'SCHEDULE', 'sync_trigger_type', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '计划调度触发任务'),
+ (1901600000000000021, 3, '重试触发', 'RETRY', 'sync_trigger_type', '', 'warning', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '失败任务重试触发'),
+ (1901600000000000022, 1, '待执行', 'PENDING', 'sync_job_status', '', 'info', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '等待执行'),
+ (1901600000000000023, 2, '执行中', 'RUNNING', 'sync_job_status', '', 'primary', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '正在执行'),
+ (1901600000000000024, 3, '成功', 'SUCCESS', 'sync_job_status', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '全部成功'),
+ (1901600000000000025, 4, '部分失败', 'PARTIAL_FAILED', 'sync_job_status', '', 'warning', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '部分对象失败'),
+ (1901600000000000026, 5, '失败', 'FAILED', 'sync_job_status', '', 'danger', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '任务失败'),
+ (1901600000000000027, 6, '已取消', 'CANCELED', 'sync_job_status', '', 'info', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '任务已取消'),
+ (1901600000000000053, 7, '取消中', 'CANCELING', 'sync_job_status', '', 'warning', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '正在终止本节点传输'),
+ (1901600000000000028, 1, '待执行', 'PENDING', 'sync_job_item_status', '', 'info', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '等待执行'),
+ (1901600000000000029, 2, '执行中', 'RUNNING', 'sync_job_item_status', '', 'primary', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '正在执行'),
+ (1901600000000000030, 3, '成功', 'SUCCESS', 'sync_job_item_status', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '对象同步成功'),
+ (1901600000000000031, 4, '已跳过', 'SKIPPED', 'sync_job_item_status', '', 'info', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '对象已跳过'),
+ (1901600000000000032, 5, '失败', 'FAILED', 'sync_job_item_status', '', 'danger', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '对象同步失败'),
+ (1901600000000000033, 1, '文件', 'FILE', 'sync_object_type', '', 'primary', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '普通文件'),
+ (1901600000000000034, 2, '目录', 'FOLDER', 'sync_object_type', '', 'info', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '目录对象'),
+ (1901600000000000035, 3, '在线文档', 'ONLINE_DOCUMENT', 'sync_object_type', '', 'warning', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '需导出的在线文档'),
+ (1901600000000000036, 1, '新增上传', 'CREATE', 'sync_action_type', '', 'success', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '首次上传目标对象'),
+ (1901600000000000037, 2, '更新上传', 'UPDATE', 'sync_action_type', '', 'warning', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '更新或覆盖目标对象'),
+ (1901600000000000038, 3, '跳过', 'SKIP', 'sync_action_type', '', 'info', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '跳过目标操作'),
+ (1901600000000000051, 4, '删除', 'DELETE', 'sync_action_type', '', 'danger', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '删除目标对象'),
+ (1901600000000000039, 1, '已发现', 'DISCOVERED', 'sync_object_status', '', 'info', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '扫描已发现'),
+ (1901600000000000040, 2, '待同步', 'PENDING', 'sync_object_status', '', 'warning', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '等待同步'),
+ (1901600000000000041, 3, '已同步', 'SYNCED', 'sync_object_status', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '最近同步成功'),
+ (1901600000000000042, 4, '已跳过', 'SKIPPED', 'sync_object_status', '', 'info', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '按策略跳过'),
+ (1901600000000000043, 5, '失败', 'FAILED', 'sync_object_status', '', 'danger', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '最近同步失败'),
+ (1901600000000000044, 6, '源端已删除', 'DELETED', 'sync_object_status', '', 'danger', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '源端对象已删除'),
+ (1901600000000000045, 1, '待上传', 'PENDING', 'sync_transfer_part_status', '', 'info', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '分片等待上传'),
+ (1901600000000000046, 2, '上传中', 'UPLOADING', 'sync_transfer_part_status', '', 'primary', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '分片正在上传'),
+ (1901600000000000047, 3, '成功', 'SUCCESS', 'sync_transfer_part_status', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '分片上传成功'),
+ (1901600000000000048, 4, '失败', 'FAILED', 'sync_transfer_part_status', '', 'danger', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '分片上传失败'),
+ (1901600000000000049, 1, '分页游标', 'PAGE_CURSOR', 'sync_checkpoint_type', '', 'primary', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '源端分页扫描游标'),
+ (1901600000000000050, 2, '增量水位', 'INCREMENTAL_WATERMARK', 'sync_checkpoint_type', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '源端增量同步水位'),
+ (1901600000000000052, 3, '运行水位', 'RUN_WATERMARK', 'sync_checkpoint_type', '', 'warning', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '当前版本任务运行水位');
diff --git a/script/sql/ry_vue.sql b/script/sql/ry_vue.sql
index 9841276a6..c87fd8e14 100644
--- a/script/sql/ry_vue.sql
+++ b/script/sql/ry_vue.sql
@@ -82,7 +82,7 @@ create table sys_user (
user_id bigint(20) not null comment '用户ID',
dept_id bigint(20) default null comment '部门ID',
user_name varchar(30) not null comment '用户账号',
- nick_name varchar(30) not null comment '用户昵称',
+ nick_name varchar(64) not null comment '用户昵称',
user_type varchar(10) default 'sys_user' comment '用户类型(sys_user系统用户)',
email varchar(50) default '' comment '用户邮箱',
phone_number varchar(11) default '' comment '手机号码',
@@ -931,4 +931,3 @@ INSERT INTO test_tree VALUES (1762200000000000010, 1762200000000000007, 17610000
INSERT INTO test_tree VALUES (1762200000000000011, 1762200000000000007, 1761000000000000108, 1761100000000000003, '子节点77', 0, 1761000000000000103, sysdate(), 1761100000000000001, NULL, NULL, 0);
INSERT INTO test_tree VALUES (1762200000000000012, 1762200000000000010, 1761000000000000108, 1761100000000000003, '子节点88', 0, 1761000000000000103, sysdate(), 1761100000000000001, NULL, NULL, 0);
INSERT INTO test_tree VALUES (1762200000000000013, 1762200000000000010, 1761000000000000108, 1761100000000000003, '子节点99', 0, 1761000000000000103, sysdate(), 1761100000000000001, NULL, NULL, 0);
-
diff --git a/ui/data-sync-s3-vue b/ui/data-sync-s3-vue
new file mode 160000
index 000000000..cfab6cebb
--- /dev/null
+++ b/ui/data-sync-s3-vue
@@ -0,0 +1 @@
+Subproject commit cfab6cebb66815bdf0e27ee8c1aa8b36593597e5