# 数据同步后端(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/ \ DWS_KEYCHAIN_DIR=/var/lib/ruoyi-sync/dws-keychain/ \ 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 凭证时只能完成编译和静态契约验证;上线前必须做真实空间枚举、空文件、大文件、在线文档、权限变化、分页和删除保护验收。