目标连接此前不发送任何服务端加密请求头,实际加密方式完全由存储桶默认策略 决定,换到默认策略不同或不支持 KMS 的服务端时行为不可控。 新增连接扩展配置 sseMode(NONE/SSE_S3/SSE_KMS)与 sseKmsKeyId,显式指定加密 方式。默认 NONE 保持不发送加密请求头的原有行为,对不支持 SSE 的服务端无影响。 - S3SseSetting 统一负责加密配置的解析、校验与请求头应用 - SyncS3OssClient 增加带 SSE 的上传,走底层 doCustomUpload 自行构建 PutObject 请求,不修改公共 oss 模块 API - 上传后比对 headObject 返回的实际加密方式,不一致仅告警不中断同步, 便于发现服务端静默忽略加密请求头的情况 - 连接保存与连通性测试阶段前置校验非法的加密配置组合 |
||
|---|---|---|
| .. | ||
| src/main/java/org/dromara/sync | ||
| pom.xml | ||
| README.md | ||
数据同步后端(V1)
本模块把原来的“钉钉盘备份到 OSS”收敛为可扩展的数据同步内核。V1 支持:
- 源端:钉钉企业网盘 / 我的文件(DingTalk Workspace CLI,简称 DWS)
- 目标端:标准 S3 兼容存储、阿里云 OSS(复用项目原生 S3 客户端)
- 执行:手动运行、内置 Cron 调度、可选 SnailJob 入口
- 能力:全量或清单差异同步、在线文档导出、冲突策略、源端删除策略、连接测试、任务明细、对象清单和检查点
1. 架构与扩展点
DingTalkSourceConnector
│ ScanResult / SourceContent
▼
SyncExecutionWorker ── sync_job / sync_job_item / sync_object / sync_checkpoint
│ TargetWriteRequest
├──────────────► S3TargetConnector
└──────────────► AliyunOssTargetConnector
源、目标均通过接口隔离:
SourceConnector:分页扫描、下载或导出源对象TargetConnector:检查对象、上传、删除目标对象ConnectorRegistry:按连接角色和类型选择实现
后续增加其他源或目标时,不需要修改计划、任务和对象清单的数据模型。
2. 数据库
先导入项目基础 SQL,再导入 script/sql/ry_sync.sql。该脚本面向 MySQL,包含菜单、权限和字典数据。
| 表 | 用途 |
|---|---|
sync_connection |
源/目标连接及加密凭证 |
sync_plan |
路径、策略、并发、Cron 和删除保护配置 |
sync_job |
一次运行及汇总;数据库唯一键保证同一计划只有一个活动任务 |
sync_job_item |
每个文件的上传、跳过或删除结果 |
sync_object |
源对象与目标对象的持久清单,用于差异判断和删除发现 |
sync_checkpoint |
已完成任务水位;V1 不把未完成分页游标当作可提交检查点 |
sync_transfer_part |
应用级分片续传的预留表,V1 尚未写入 |
delete_guard_percent 默认是 50。若一次运行拟删除的目标文件比例超过阈值,任务会失败且不会执行任何删除;只有显式设为 100 才允许一次删除全部目标文件。
3. 必需的服务配置
生产环境必须启用字段加密,否则服务会拒绝保存 secretJson:
MYBATIS_ENCRYPTOR_ENABLE=true
MYBATIS_ENCRYPTOR_PASSWORD=<由密钥管理系统注入的 AES 密钥>
DWS_EXECUTABLE=/opt/dws/bin/dws
DWS_PROFILE_ROOT=/var/lib/ruoyi-sync/dws
DWS_KEYCHAIN_ROOT=/var/lib/ruoyi-sync/dws-keychain
请不要把真实密钥写进 YAML、启动脚本或 Git。SyncConnectionVo 不含 secretJson,新增/修改接口的操作日志也排除了该字段。
后端运行时需要 JDK 21(项目使用虚拟线程)。DWS 建议固定到已验收版本;仓库提供的生产镜像默认使用 v1.0.60,升级 DWS 后应重新做一次设备登录和真实空间访问验收。
内置调度器默认开启。如改由 SnailJob 触发,应设置:
SYNC_SCHEDULER_ENABLED=false
SnailJob 执行器名为 syncPlanJobExecutor,任务参数是同步计划 ID。内置调度和 SnailJob 二选一。
4. 钉钉连接
4.1 DWS 运行契约
运行节点需要安装与验收 DWS,并至少确认以下命令存在:
dws drive list --help
dws drive download --help
dws doc export --help
dws wiki space list --help
本模块使用原子 drive list 保留版本、修改时间、扩展名等增量证据;普通文件使用原子 drive download 的分片、并发和断点能力;adoc 在线文档使用 doc export(自动提交、轮询并下载)导出为 docx。
每个同步连接都有独立的 DWS 配置目录:
${DWS_PROFILE_ROOT}/${connectionId}
登录 token 的加密 keychain 也按连接隔离,目录为:
${DWS_KEYCHAIN_ROOT}/${connectionId}/dws-cli
所有业务命令还会显式传入该连接配置中的 profile=corpId:userId,不会依赖服务进程当前选中的账号。
4.2 登录态初始化
应用密钥不能代替用户 OAuth 登录态。现在首次登录可以完全从管理后台完成,不需要 SSH 到服务器:
- 创建一个
DINGTALK/SOURCE连接。secretJson填写 AppKey/AppSecret;configJson可以暂时不填profile。 - 网页调用
POST /sync/connection/{connectionId}/auth/login/start。 - 页面展示返回的
verificationUriComplete(可生成二维码),同时展示verificationUri和userCode作为备用方式。 - 用户在自己的浏览器中打开链接并在钉钉完成确认;页面每 2 秒左右调用状态接口,直到
SUCCESS、FAILED、EXPIRED或CANCELLED。 - 授权成功后,服务器自动把 DWS 返回的稳定
corpId:userId写入该连接的configJson.profile,之后即可执行连接测试和同步。
启动接口返回的对象只含一次性授权链接、验证码和脱敏身份信息,绝不返回 access token、refresh token、device code 或 AppSecret。DWS 进程和加密登录态始终留在后端节点;浏览器只负责完成钉钉授权。
后端只接受 DWS 最终 JSON 中明确返回的 corp_id/corpId 与 user_id/userId(或明确的 profile 字段),缺少任一 ID 会安全失败,不会根据普通日志文本猜测登录人。连接凭证属于创建者,普通用户只能查看和操作自己创建的连接,超级管理员可跨创建者管理;登录操作也仅允许连接创建人或超级管理员发起、查询和取消。登录进行中禁止修改或删除该连接,Profile 只能由 Web 授权回调变更,编辑旧表单不会清掉已完成的登录态。
接口契约:
| 方法 | 地址 | 说明 |
|---|---|---|
POST |
/sync/connection/{id}/auth/login/start |
启动设备流;可选查询参数 expectedCorpId 用于组织校验 |
GET |
/sync/connection/{id}/auth/login/{sessionId} |
查询会话状态和展示信息 |
POST |
/sync/connection/{id}/auth/login/{sessionId}/cancel |
取消会话并终止服务器上的 DWS 进程 |
DWS 设备流有服务端有效期(通常约 10 分钟),过期后重新点击“登录”即可。DWS 会自动刷新已保存的登录态,日常同步不需要重复登录。组织必须允许 DWS/CLI 数据访问,且授权用户必须拥有目标空间的访问权限;否则会话会返回失败提示。
会话状态接口返回 STARTING、WAITING_USER、FINALIZING、SUCCESS、FAILED、EXPIRED 或 CANCELLED;进入 FINALIZING 后正在写回连接身份,页面应继续轮询并禁止重复发起登录。
生产部署必须持久化下面两个目录,否则重启后会丢失 DWS 登录态:
${DWS_PROFILE_ROOT}
${DWS_KEYCHAIN_ROOT}
当前会话管理器保存在单节点内存中,因此 V1 要求登录页面的三次请求落到同一个后端实例(单实例部署天然满足;多实例请配置会话亲和或单独的认证节点)。后续需要无亲和的集群部署时,再把会话状态和进程协调迁移到 Redis/专用认证服务。
同一节点内,Web 授权会独占该连接的 DWS 配置/keychain 锁;正在执行的扫描或下载会让登录请求稍后重试,登录期间新发起的 DWS 业务命令也会被拒绝。若同步 worker 和 Web/API 分布在多个后端节点,除会话亲和外还需要把这把连接锁迁移到 Redis 或数据库分布式锁。
如需运维兜底,仍可在服务器上执行等价的设备流命令;这不是正常使用路径:
DWS_CONFIG_DIR=/var/lib/ruoyi-sync/dws/<connectionId> \
DWS_KEYCHAIN_DIR=/var/lib/ruoyi-sync/dws-keychain/<connectionId> \
DWS_CLIENT_ID=<与该连接 secretJson 一致的 AppKey> \
DWS_CLIENT_SECRET=<与该连接 secretJson 一致的 AppSecret> \
dws auth login --device --no-browser
4.3 连接示例
请求中的 JSON 字符串为展示方便做了格式化;实际字段仍是字符串。
{
"connectionName": "钉钉企业盘",
"connectionRole": "SOURCE",
"connectionType": "DINGTALK",
"configJson": "{\"profile\":\"corpId:userId\",\"spaceType\":\"orgSpace\",\"spaceId\":\"可选\",\"downloadPartSize\":\"32MB\",\"downloadParallel\":\"4\"}",
"secretJson": "{\"clientId\":\"AppKey\",\"clientSecret\":\"AppSecret\"}",
"status": "0"
}
spaceId可选:填写时只同步该空间;不填时按spaceType发现空间。spaceType:orgSpace或mySpace,默认orgSpace。sourceRoot:/表示空间根;指定子目录时必须填写该目录的dentryUuid,且连接必须指定spaceId。downloadParallel:1 至 8,默认 4。downloadPartSize:DWS 支持的容量字符串,例如32MB。configDir不允许由连接指定,避免一个连接读取另一个连接的登录态。
V1 只导出 adoc -> docx。axls、able、appt、amind、adraw 会被识别为在线对象并明确失败,不会错误地按普通文件下载;相应导出适配器留到后续版本。
5. S3 / 阿里云 OSS 连接
标准 S3 示例:
{
"connectionName": "备份 S3",
"connectionRole": "TARGET",
"connectionType": "S3",
"endpoint": "s3.example.com",
"region": "us-east-1",
"bucketName": "company-backup",
"basePath": "dingtalk",
"configJson": "{\"useHttps\":true,\"pathStyleAccess\":true}",
"secretJson": "{\"accessKey\":\"...\",\"secretKey\":\"...\"}",
"status": "0"
}
阿里云 OSS 示例:
{
"connectionName": "阿里云 OSS",
"connectionRole": "TARGET",
"connectionType": "ALIYUN_OSS",
"endpoint": "oss-cn-hangzhou.aliyuncs.com",
"region": "cn-hangzhou",
"bucketName": "company-backup",
"basePath": "dingtalk",
"configJson": "{\"useHttps\":true,\"pathStyleAccess\":false}",
"secretJson": "{\"accessKey\":\"...\",\"secretKey\":\"...\"}",
"status": "0"
}
region 为可选配置。留空时与 ruoyi-vue-plus 原生 OSS 配置保持一致,客户端使用 us-east-1 作为默认 Region;如已知存储桶所在地域,仍建议填写实际值(例如 cn-hangzhou)。
连接测试会真实执行 HeadBucket。上传完成后再执行 HeadObject,以远端实际大小、ETag、版本 ID 和元数据作为任务结果。
6. 同步语义
计划请求示例:
{
"planName": "钉钉企业盘每日备份",
"sourceConnectionId": 1,
"targetConnectionId": 2,
"sourceRoot": "/",
"targetPrefix": "daily",
"syncMode": "INCREMENTAL",
"scheduleType": "CRON",
"cronExpression": "0 0 2 * * *",
"conflictStrategy": "OVERWRITE",
"deleteStrategy": "KEEP",
"deleteGuardPercent": 50,
"verifyMode": "SHA256",
"maxConcurrency": 4,
"bandwidthLimitKbps": 0,
"status": "0"
}
FULL:扫描并重新处理全部文件。INCREMENTAL:仍完整枚举权威目录树,然后用versionToken -> hash -> modifiedTime + size依次判断变化;证据不足时保守地重新传输,不会只凭相同大小跳过。OVERWRITE:写入目标键。SKIP:目标键已存在时跳过。KEEP_BOTH:目标键已存在时附加稳定版本后缀。KEEP/MARK:源端消失时保留目标,仅更新清单和任务记录。DELETE:通过删除比例保护后删除目标;删除失败会保留待重试状态,不会误记为已完成。SIZE:以HeadObject.contentLength校验。ETAG:要求目标端回读到 ETag,不能把不同厂商/不同分片策略的 ETag 当作源端内容哈希。SHA256:本地计算摘要、写入对象元数据并通过HeadObject回读校验。
目录页缺少集合、条目缺少关键字段、部分错误、重复游标、重复对象 ID 或目录环路都会使任务失败。失败任务不会进入源删除发现阶段。
7. 后端接口
| 方法 | 地址 | 权限 | 用途 |
|---|---|---|---|
GET |
/sync/connection/list |
sync:connection:list |
连接分页 |
GET |
/sync/connection/{id} |
sync:connection:query |
连接详情(不返回凭证) |
POST |
/sync/connection |
sync:connection:add |
新增连接 |
PUT |
/sync/connection |
sync:connection:edit |
修改连接;空 secretJson 保留原凭证 |
DELETE |
/sync/connection/{ids} |
sync:connection:remove |
删除未被计划引用的连接 |
POST |
/sync/connection/test/{id} |
sync:connection:test |
真实连通性测试 |
POST |
/sync/connection/{id}/auth/login/start |
sync:connection:auth |
Web 端启动钉钉设备授权 |
GET |
/sync/connection/{id}/auth/login/{sessionId} |
sync:connection:auth |
查询 Web 授权状态 |
POST |
/sync/connection/{id}/auth/login/{sessionId}/cancel |
sync:connection:auth |
取消 Web 授权 |
GET |
/sync/plan/list |
sync:plan:list |
计划分页 |
GET |
/sync/plan/{id} |
sync:plan:query |
计划详情 |
POST / PUT / DELETE |
/sync/plan |
对应 add/edit/remove | 计划维护 |
POST |
/sync/job/run/{planId} |
sync:job:run |
立即运行 |
POST |
/sync/job/retry/{jobId} |
sync:job:retry |
重新枚举并重试失败对象 |
POST |
/sync/job/cancel/{jobId} |
sync:job:cancel |
取消并中断本节点任务 |
GET |
/sync/job/list |
sync:job:list |
任务分页 |
GET |
/sync/job/{id} |
sync:job:query |
任务汇总 |
GET |
/sync/job/{id}/items |
sync:job:query |
文件明细 |
DELETE |
/sync/job/{ids} |
sync:job:remove |
删除非活动任务及明细 |
GET |
/sync/object/list |
sync:object:list |
对象清单分页 |
GET |
/sync/object/{id} |
sync:object:query |
对象详情 |
8. 当前版本边界
- Web 登录会话是单节点内存态;多实例部署需要网关会话亲和或专用认证节点。不会把 token 或 device code 写入数据库或返回给浏览器。
sync_transfer_part为应用级、跨任务分片续传预留。当前 DWS 下载和项目原生 S3 客户端各自使用其内部大文件机制,但应用数据库尚不接管 uploadId/part 状态。- 带宽字段已预留,但 V1 会拒绝非 0 值,避免界面显示“已限速”而运行时未生效。
- 应用进程异常退出可能遗留
PENDING/RUNNING/CANCELING任务。为避免多实例误接管仍在其他节点运行的任务,V1 不做激进的启动自动恢复;运维确认原节点已停止后,需要由 DBA 将遗留任务核销为CANCELED,再点重试。 - 取消会中断本节点 DWS 命令并在上传、校验、删除前复查数据库状态;对象存储 SDK 已经提交到远端的请求只能尽力取消,取消后重试仍按幂等对象键收敛。
- 未接真实钉钉账号、S3 或 OSS 凭证时只能完成编译和静态契约验证;上线前必须做真实空间枚举、空文件、大文件、在线文档、权限变化、分页和删除保护验收。