RuoYi-Vue-Plus/ruoyi-modules/ruoyi-sync/README.md
2026-09-03 14:14:40 +08:00

281 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 数据同步后端V1
本模块把原来的“钉钉盘备份到 OSS”收敛为可扩展的数据同步内核。V1 支持:
- 源端:钉钉企业网盘 / 我的文件DingTalk Workspace CLI简称 DWS
- 目标端:标准 S3 兼容存储、阿里云 OSS复用项目原生 S3 客户端)
- 执行:手动运行、内置 Cron 调度、可选 SnailJob 入口
- 能力:全量或清单差异同步、在线文档导出、冲突策略、源端删除策略、连接测试、任务明细、对象清单和检查点
## 1. 架构与扩展点
```text
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`](../../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`
```bash
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 触发,应设置:
```bash
SYNC_SCHEDULER_ENABLED=false
```
SnailJob 执行器名为 `syncPlanJobExecutor`,任务参数是同步计划 ID。内置调度和 SnailJob 二选一。
## 4. 钉钉连接
### 4.1 DWS 运行契约
运行节点需要安装与验收 DWS并至少确认以下命令存在
```bash
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 配置目录:
```text
${DWS_PROFILE_ROOT}/${connectionId}
```
登录 token 的加密 keychain 也按连接隔离,目录为:
```text
${DWS_KEYCHAIN_ROOT}/${connectionId}/dws-cli
```
所有业务命令还会显式传入该连接配置中的 `profile=corpId:userId`,不会依赖服务进程当前选中的账号。
### 4.2 登录态初始化
应用密钥不能代替用户 OAuth 登录态。现在首次登录可以完全从管理后台完成,不需要 SSH 到服务器:
1. 创建一个 `DINGTALK/SOURCE` 连接。`secretJson` 填写 AppKey/AppSecret`configJson` 可以暂时不填 `profile`
2. 网页调用 `POST /sync/connection/{connectionId}/auth/login/start`
3. 页面展示返回的 `verificationUriComplete`(可生成二维码),同时展示 `verificationUri``userCode` 作为备用方式。
4. 用户在自己的浏览器中打开链接并在钉钉完成确认;页面每 2 秒左右调用状态接口,直到 `SUCCESS`、`FAILED`、`EXPIRED` 或 `CANCELLED`
5. 授权成功后,服务器自动把 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 登录态:
```text
${DWS_PROFILE_ROOT}
${DWS_KEYCHAIN_ROOT}
```
当前会话管理器保存在单节点内存中,因此 V1 要求登录页面的三次请求落到同一个后端实例(单实例部署天然满足;多实例请配置会话亲和或单独的认证节点)。后续需要无亲和的集群部署时,再把会话状态和进程协调迁移到 Redis/专用认证服务。
同一节点内Web 授权会独占该连接的 DWS 配置/keychain 锁;正在执行的扫描或下载会让登录请求稍后重试,登录期间新发起的 DWS 业务命令也会被拒绝。若同步 worker 和 Web/API 分布在多个后端节点,除会话亲和外还需要把这把连接锁迁移到 Redis 或数据库分布式锁。
如需运维兜底,仍可在服务器上执行等价的设备流命令;这不是正常使用路径:
```bash
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 字符串为展示方便做了格式化;实际字段仍是字符串。
```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 示例:
```json
{
"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 示例:
```json
{
"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. 同步语义
计划请求示例:
```json
{
"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 凭证时只能完成编译和静态契约验证;上线前必须做真实空间枚举、空文件、大文件、在线文档、权限变化、分页和删除保护验收。