Эх сурвалжийг харах

refactor: 重构同步服务并引入批量同步编排机制

- 移除 `TrainBatchSyncService`
- 新增 `BatchChunkSyncResult` 和 `BatchSyncBusinessHandler` 接口
- 添加 `ClassroomBatchSyncService` 实现 `BatchSyncBusinessHandler`
- 引入 `CommonBatchSyncService` 统一处理批量同步逻辑
- 增加 `CachedBodyFilter` 和 `CachedBodyHttpServletRequest` 以支持请求体缓存
- 添加 `HmacSignatureAuth` 注解和 `HmacSignatureAuthInterceptor` 拦截器进行签名验证
- 更新配置文件 `ruoyi-server-sync.yml` 添加相关配置
yubo 1 сар өмнө
parent
commit
007d872894
31 өөрчлөгдсөн 2067 нэмэгдсэн , 250 устгасан
  1. 113 0
      .workbuddy/plans/创建ruoyi-test模块_含Dubbo接口__9ae3da26.md
  2. 29 0
      config/nacos/ruoyi-server-sync.yml
  3. 18 0
      ruoyi-api/ruoyi-api-backstage/src/main/java/org/dromara/backstage/api/domain/bo/RemotePtRoomBo.java
  4. 6 0
      ruoyi-api/ruoyi-api-backstage/src/main/java/org/dromara/backstage/api/domain/dto/RemoteClassroomQueryDto.java
  5. 12 0
      ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/annotation/HmacSignatureAuth.java
  6. 76 0
      ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/utils/EncryptUtils.java
  7. 2 0
      ruoyi-modules/ruoyi-backstage/src/main/java/org/dromara/backstage/basics/dubbo/RemotePtRoomServiceImpl.java
  8. 2 0
      ruoyi-modules/ruoyi-backstage/src/main/java/org/dromara/backstage/basics/service/impl/PtRoomServiceImpl.java
  9. 10 0
      ruoyi-server/ruoyi-server-common/src/main/java/org/dromara/server/common/constant/SyncResourceConstants.java
  10. 272 0
      ruoyi-server/ruoyi-server-sync/section-sync-orchestration.md
  11. 55 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/BatchSyncBusinessHandler.java
  12. 221 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/ClassroomBatchSyncService.java
  13. 185 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/CommonBatchSyncService.java
  14. 134 113
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/SectionBatchSyncService.java
  15. 0 83
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/TrainBatchSyncService.java
  16. 130 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/TrainClassBatchSyncService.java
  17. 95 38
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/TraineeBatchSyncService.java
  18. 9 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/mapper/TrainClassRecordMapper.java
  19. 21 1
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/mapper/TraineeRecordMapper.java
  20. 42 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/config/SyncHmacSignatureConfig.java
  21. 55 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/config/SyncHmacSignatureProperties.java
  22. 128 15
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/controller/SyncReceiveController.java
  23. 8 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/domain/dto/request/ClassroomRecordRequest.java
  24. 26 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/domain/dto/result/BatchChunkSyncResult.java
  25. 2 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/domain/dto/wrapper/BatchSyncRequest.java
  26. 32 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/filter/CachedBodyFilter.java
  27. 76 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/filter/CachedBodyHttpServletRequest.java
  28. 130 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/interceptor/HmacSignatureAuthInterceptor.java
  29. 33 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/strategy/dept/impl/TrainClassStrategyImpl.java
  30. 33 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/strategy/user/impl/SyncTraineeStrategyImpl.java
  31. 112 0
      ruoyi-server/ruoyi-server-sync/src/test/java/org/dromara/server/sync/demo/SyncTraineeSignedRequestDemo.java

+ 113 - 0
.workbuddy/plans/创建ruoyi-test模块_含Dubbo接口__9ae3da26.md

@@ -0,0 +1,113 @@
+---
+name: 创建ruoyi-test模块(含Dubbo接口)
+overview: 在ykt_server项目中创建ruoyi-test业务模块及其对应的ruoyi-api-test接口模块,包含完整的Maven配置、目录结构和Dubbo服务暴露能力。
+todos:
+  - id: update-modules-pom
+    content: 修改 ruoyi-modules/pom.xml 添加 ruoyi-test 模块声明
+    status: completed
+  - id: update-api-pom
+    content: 修改 ruoyi-api/pom.xml 添加 ruoyi-api-test 模块声明
+    status: completed
+  - id: create-test-module
+    content: 创建 ruoyi-test 模块目录结构和 pom.xml
+    status: completed
+    dependencies:
+      - update-modules-pom
+  - id: create-test-application
+    content: 创建 RuoYiTestApplication 启动类和 application.yml
+    status: completed
+    dependencies:
+      - create-test-module
+  - id: create-test-directories
+    content: 创建 controller/domain/mapper/service/dubbo 目录结构
+    status: completed
+    dependencies:
+      - create-test-module
+  - id: create-api-test-module
+    content: 创建 ruoyi-api-test 模块目录结构和 pom.xml
+    status: completed
+    dependencies:
+      - update-api-pom
+---
+
+## 产品概述
+
+在 RuoYi-Cloud-Plus 项目中创建 ruoyi-test 模块,并配置 Dubbo 接口暴露能力。
+
+## 核心功能
+
+1. 创建 ruoyi-test 业务模块(ruoyi-modules/ruoyi-test/)
+2. 创建 ruoyi-api-test 接口模块(ruoyi-api/ruoyi-api-test/)
+3. 配置 Dubbo 服务暴露和引用能力
+4. 建立标准的模块目录结构(controller/domain/mapper/service/dubbo)
+
+## 技术栈
+
+- 基础框架:Spring Boot 3.1+
+- 微服务框架:Spring Cloud Alibaba
+- RPC框架:Apache Dubbo 3.X
+- ORM框架:MyBatis-Plus
+- 注册中心:Nacos
+
+## 实现方案
+
+参考现有 ruoyi-ecs 模块结构,创建完整的 test 模块:
+
+### 1. 模块结构
+
+```
+ruoyi-modules/ruoyi-test/
+├── pom.xml
+└── src/
+    ├── main/
+    │   ├── java/org/dromara/test/
+    │   │   ├── RuoYiTestApplication.java
+    │   │   ├── controller/
+    │   │   ├── domain/
+    │   │   │   ├── bo/
+    │   │   │   └── vo/
+    │   │   ├── mapper/
+    │   │   ├── service/
+    │   │   │   └── impl/
+    │   │   └── dubbo/
+    │   └── resources/
+    │       └── application.yml
+
+ruoyi-api/ruoyi-api-test/
+├── pom.xml
+└── src/main/java/org/dromara/test/api/
+```
+
+### 2. 关键配置
+
+- 模块POM:引用 ruoyi-modules 父POM,添加 dubbo、mybatis、security 等依赖
+- API模块POM:引用 ruoyi-api 父POM,添加 core、mybatis 依赖
+- application.yml:配置服务端口、Nacos、数据源、Dubbo
+
+### 3. Dubbo配置
+
+- 实现类使用 `@DubboService` 注解暴露服务
+- 消费端使用 `@DubboReference` 引用服务
+
+## 目录结构
+
+### 修改文件
+
+1. `ruoyi-modules/pom.xml` [MODIFY] - 添加 ruoyi-test 模块声明
+2. `ruoyi-api/pom.xml` [MODIFY] - 添加 ruoyi-api-test 模块声明
+
+### 新建文件
+
+1. `ruoyi-modules/ruoyi-test/pom.xml` [NEW] - 模块Maven配置,包含Dubbo、MyBatis等依赖
+2. `ruoyi-modules/ruoyi-test/src/main/java/org/dromara/test/RuoYiTestApplication.java` [NEW] - SpringBoot启动类
+3. `ruoyi-modules/ruoyi-test/src/main/resources/application.yml` [NEW] - 应用配置文件(端口9209)
+4. `ruoyi-modules/ruoyi-test/src/main/java/org/dromara/test/controller/` [NEW] - 控制器目录
+5. `ruoyi-modules/ruoyi-test/src/main/java/org/dromara/test/domain/` [NEW] - 实体类目录
+6. `ruoyi-modules/ruoyi-test/src/main/java/org/dromara/test/domain/bo/` [NEW] - BO对象目录
+7. `ruoyi-modules/ruoyi-test/src/main/java/org/dromara/test/domain/vo/` [NEW] - VO对象目录
+8. `ruoyi-modules/ruoyi-test/src/main/java/org/dromara/test/mapper/` [NEW] - Mapper目录
+9. `ruoyi-modules/ruoyi-test/src/main/java/org/dromara/test/service/` [NEW] - Service接口目录
+10. `ruoyi-modules/ruoyi-test/src/main/java/org/dromara/test/service/impl/` [NEW] - Service实现目录
+11. `ruoyi-modules/ruoyi-test/src/main/java/org/dromara/test/dubbo/` [NEW] - Dubbo服务实现目录
+12. `ruoyi-api/ruoyi-api-test/pom.xml` [NEW] - API模块Maven配置
+13. `ruoyi-api/ruoyi-api-test/src/main/java/org/dromara/test/api/` [NEW] - Dubbo接口定义目录

+ 29 - 0
config/nacos/ruoyi-server-sync.yml

@@ -0,0 +1,29 @@
+thread-pool:
+  enabled: true
+  queue-capacity: 1000
+  keep-alive-seconds: 60
+
+sync:
+  hmac-signature:
+    enabled: true
+    timestamp-tolerance-seconds: 300
+    api-key-header: X-Api-Key
+    timestamp-header: X-Timestamp
+    nonce-header: X-Nonce
+    signature-header: X-Signature
+    algorithm-header: X-Signature-Algorithm
+    clients:
+      sync-jw: jwapi-t3E9qrMRn74ClaahRo6F3WjJOgCBu1F3i39diSvoYT0uJX5ZPh1Pl6SWPNDcjYxe
+  batch:
+    chunk-size: 100
+    max-parallel-chunks: 5
+    timeout: 300
+
+logging:
+  level:
+    org:
+      dromara:
+        server:
+          sync:
+            business:
+              batch: INFO

+ 18 - 0
ruoyi-api/ruoyi-api-backstage/src/main/java/org/dromara/backstage/api/domain/bo/RemotePtRoomBo.java

@@ -36,6 +36,11 @@ public class RemotePtRoomBo implements Serializable {
      */
     private String roomName;
 
+    /**
+     * 租户Id
+     */
+    private String tenantId;
+
     /**
      * 房间类型,见FJLX字典类型
      */
@@ -71,5 +76,18 @@ public class RemotePtRoomBo implements Serializable {
      */
     private String remark;
 
+    /**
+     * 数据来源:0-本地,1-第三方同步
+     */
+    private String dataSource;
+
+    /**
+     * 第三方系统原始ID
+     */
+    private String otherId;
 
+    /**
+     * 容纳人数
+     */
+    private Integer capacity;
 }

+ 6 - 0
ruoyi-api/ruoyi-api-backstage/src/main/java/org/dromara/backstage/api/domain/dto/RemoteClassroomQueryDto.java

@@ -20,6 +20,12 @@ public class RemoteClassroomQueryDto implements Serializable {
     /** 房间名称(模糊查询) */
     private String roomName;
 
+    /** 第三方Id(精确查询) */
+    private String otherId;
+
+    /** 租户Id(精确查询) */
+    private String tenantId;
+
     /** 页码,默认1 */
     private Integer pageNum;
 

+ 12 - 0
ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/annotation/HmacSignatureAuth.java

@@ -0,0 +1,12 @@
+package org.dromara.common.encrypt.annotation;
+
+import java.lang.annotation.*;
+
+/**
+ * Requires API Key + HMAC-SHA256 signature authentication.
+ */
+@Documented
+@Target({ElementType.TYPE, ElementType.METHOD})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface HmacSignatureAuth {
+}

+ 76 - 0
ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/utils/EncryptUtils.java

@@ -9,8 +9,12 @@ import cn.hutool.crypto.asymmetric.KeyType;
 import cn.hutool.crypto.asymmetric.RSA;
 import cn.hutool.crypto.asymmetric.SM2;
 
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
 import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
 import java.util.HashMap;
+import java.util.HexFormat;
 import java.util.Map;
 
 /**
@@ -327,6 +331,78 @@ public class EncryptUtils {
      * @param data 待加密数据
      * @return 加密后字符串, 采用Hex编码
      */
+    /**
+     * HMAC-SHA256签名
+     *
+     * @param data   待签名数据
+     * @param secret 签名密钥
+     * @return 签名后字符串, 采用Hex编码
+     */
+    public static String hmacSha256(String data, String secret) {
+        return hmacSha256Hex(data, secret);
+    }
+
+    /**
+     * HMAC-SHA256签名
+     *
+     * @param data   待签名数据
+     * @param secret 签名密钥
+     * @return 签名后字符串, 采用Hex编码
+     */
+    public static String hmacSha256Hex(String data, String secret) {
+        return HexFormat.of().formatHex(hmacSha256Bytes(data, secret));
+    }
+
+    /**
+     * HMAC-SHA256签名
+     *
+     * @param data   待签名数据
+     * @param secret 签名密钥
+     * @return 签名后字符串, 采用Base64编码
+     */
+    public static String hmacSha256Base64(String data, String secret) {
+        return Base64.encode(hmacSha256Bytes(data, secret));
+    }
+
+    /**
+     * HMAC-SHA256签名
+     *
+     * @param data   待签名数据
+     * @param secret 签名密钥
+     * @return 签名后字节数组
+     */
+    public static byte[] hmacSha256Bytes(String data, String secret) {
+        if (StrUtil.isBlank(secret)) {
+            throw new IllegalArgumentException("HMAC-SHA256需要传入签名密钥");
+        }
+        try {
+            Mac mac = Mac.getInstance("HmacSHA256");
+            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+            return mac.doFinal(StrUtil.nullToEmpty(data).getBytes(StandardCharsets.UTF_8));
+        } catch (Exception e) {
+            throw new IllegalStateException("HMAC-SHA256签名失败", e);
+        }
+    }
+
+    /**
+     * 校验HMAC-SHA256 Hex签名,使用常量时间比较避免时序攻击。
+     *
+     * @param data        待签名数据
+     * @param secret      签名密钥
+     * @param expectedHex 预期Hex签名
+     * @return 签名是否匹配
+     */
+    public static boolean verifyHmacSha256Hex(String data, String secret, String expectedHex) {
+        if (StrUtil.isBlank(expectedHex)) {
+            return false;
+        }
+        String actualHex = hmacSha256Hex(data, secret);
+        return MessageDigest.isEqual(
+            actualHex.getBytes(StandardCharsets.UTF_8),
+            expectedHex.getBytes(StandardCharsets.UTF_8)
+        );
+    }
+
     public static String encryptByMd5(String data) {
         return SecureUtil.md5(data);
     }

+ 2 - 0
ruoyi-modules/ruoyi-backstage/src/main/java/org/dromara/backstage/basics/dubbo/RemotePtRoomServiceImpl.java

@@ -138,6 +138,8 @@ public class RemotePtRoomServiceImpl implements RemotePtRoomService {
         PtRoomBo bo = new PtRoomBo();
         bo.setRoomCode(queryDto.getRoomCode());
         bo.setRoomName(queryDto.getRoomName());
+        bo.setOtherId(queryDto.getOtherId());
+        bo.setTenantId(queryDto.getTenantId());
         return bo;
     }
 

+ 2 - 0
ruoyi-modules/ruoyi-backstage/src/main/java/org/dromara/backstage/basics/service/impl/PtRoomServiceImpl.java

@@ -259,6 +259,8 @@ public class PtRoomServiceImpl implements IPtRoomService {
         LambdaQueryWrapper<PtRoom> lqw = Wrappers.lambdaQuery();
         lqw.eq(StringUtils.isNotBlank(bo.getRoomCode()), PtRoom::getRoomCode, bo.getRoomCode());
         lqw.like(StringUtils.isNotBlank(bo.getRoomName()), PtRoom::getRoomName, bo.getRoomName());
+        lqw.eq(StringUtils.isNotBlank(bo.getOtherId()), PtRoom::getOtherId, bo.getOtherId());
+        lqw.eq(StringUtils.isNotBlank(bo.getTenantId()), PtRoom::getTenantId, bo.getTenantId());
         lqw.eq(StringUtils.isNotBlank(bo.getRoomType()), PtRoom::getRoomType, bo.getRoomType());
         lqw.eq(StringUtils.isNotBlank(bo.getCodeOne()), PtRoom::getCodeOne, bo.getCodeOne());
         lqw.eq(StringUtils.isNotBlank(bo.getStatus()), PtRoom::getStatus, bo.getStatus());

+ 10 - 0
ruoyi-server/ruoyi-server-common/src/main/java/org/dromara/server/common/constant/SyncResourceConstants.java

@@ -24,6 +24,11 @@ public interface SyncResourceConstants {
      */
     String TRAIN_CLASS = "TRAIN_CLASS";
 
+    /**
+     * 课表节次
+     */
+    String SECTION = "SECTION";
+
     /**
      * 教职工
      */
@@ -36,4 +41,9 @@ public interface SyncResourceConstants {
      * 培训学员
      */
     String TRAINEE = "TRAINEE";
+
+    /**
+     * 教室
+     */
+    String CLASSROOM = "CLASSROOM";
 }

+ 272 - 0
ruoyi-server/ruoyi-server-sync/section-sync-orchestration.md

@@ -0,0 +1,272 @@
+# Section 同步改造后的调用链路
+
+## 一、目标说明
+
+本次改造将 section 同步从“controller 直接调用专用批处理 service”的方式,收敛为“controller + 公共编排 service + 业务处理器”的统一批量同步架构。
+
+目标收益:
+
+- 统一批次校验、分片、并发、错误归集和状态汇总逻辑
+- 业务类只保留 section 专属的校验、映射和单分片下发逻辑
+- 为后续 course/classroom 等同步类型复用同一套编排链路
+
+---
+
+## 二、改造后的主调用链
+
+### 1. HTTP 入口
+
+文件:`ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/controller/SyncReceiveController.java`
+
+入口方法:
+
+- `syncSections(BatchSyncRequest<SectionRecordRequest> request)`
+
+职责:
+
+1. 接收 `/api/sync/receive/sections` 请求
+2. 设置 `request.businessType = SyncResourceConstants.SECTION`
+3. 调用 `CommonBatchSyncService.sync(request)`
+4. 返回 `BatchSyncResult`
+
+---
+
+### 2. 公共批量编排层
+
+文件:`ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/CommonBatchSyncService.java`
+
+主方法:
+
+- `sync(BatchSyncRequest<T> request)`
+
+职责:
+
+1. 校验 `request` 和 `records`
+2. 根据 `businessType` 获取业务处理器
+3. 创建批次记录 `SyncBatchTrackerService.start(...)`
+4. 逐条校验并转换记录
+5. 收集校验失败错误
+6. 将有效记录按 100 条分片
+7. 单片串行执行,多片按每轮最多 5 片并发执行
+8. 聚合所有分片的成功数与错误明细
+9. 保存错误明细并结束批次
+10. 返回统一 `BatchSyncResult`
+
+关键内部方法:
+
+- `getHandler(...)`:根据 `businessType` 获取处理器
+- `partition(...)`:按固定大小切分分片
+- `executeChunks(...)`:执行分片,同步或并发
+- `buildResult(...)`:构造最终返回结果
+- `resolveStatus(...)`:计算批次状态
+
+---
+
+### 3. 业务处理器抽象
+
+文件:`ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/BatchSyncBusinessHandler.java`
+
+作用:
+定义公共编排层与具体业务处理器之间的统一契约。
+
+核心方法:
+
+- `getBusinessType()`
+- `getApiName()`
+- `getResourceType()`
+- `resolveTenantId(...)`
+- `validateRecord(...)`
+- `getBizKey(...)`
+- `toDispatchDto(...)`
+- `dispatchChunk(...)`
+
+---
+
+### 4. section 业务处理器
+
+文件:`ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/SectionBatchSyncService.java`
+
+注册方式:
+
+- `@Service(SyncResourceConstants.SECTION)`
+
+实现接口:
+
+- `BatchSyncBusinessHandler<SectionRecordRequest, RemoteSectionSyncBatchDto>`
+
+职责拆分:
+
+1. `validateRecord(...)`
+   - 校验 section 单条记录的必填字段
+2. `getBizKey(...)`
+   - 提取 `sectionId` 作为错误定位主键
+3. `toDispatchDto(...)`
+   - 将请求对象转换为 `RemoteSectionSyncBatchDto`
+4. `dispatchChunk(...)`
+   - 执行单个分片的远端 section 同步
+   - 解析远端返回结果
+   - 转换远端错误为本地 `SyncBatchErrorRecord`
+5. `syncSections(...)`
+   - 兼容旧入口,内部委托给 `CommonBatchSyncService`
+
+---
+
+### 5. 远端 Dubbo 接口
+
+接口文件:`ruoyi-api/ruoyi-api-ecs/src/main/java/org/dromara/ecs/api/RemoteSectionSyncService.java`
+
+实现文件:`ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/dubbo/RemoteSectionSyncServiceImpl.java`
+
+调用方法:
+
+- `syncSectionBatch(List<RemoteSectionSyncBatchDto> records)`
+
+职责:
+
+1. 接收 server-sync 下发的 section 分片数据
+2. 转换为 ECS 内部 DTO
+3. 调用 `ISyncSectionService.receiveSectionBatch(...)`
+4. 返回远端批量处理结果 `RemoteBatchSyncResultDto`
+
+---
+
+### 6. ECS 内部同步服务
+
+文件:`ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/service/sync/SyncSectionServiceImpl.java`
+
+核心方法:
+
+- `receiveSectionBatch(...)`
+- `processSection(...)`
+
+职责:
+
+1. 先处理 `delFlag=1` 的删除组
+2. 再处理 `delFlag=0` 的新增/修改组
+3. 单条处理时:
+   - 删除组:按 `otherId` 更新删除标记
+   - 非删除组:按 `otherId` 判断新增或修改
+
+---
+
+### 7. 持久化层
+
+文件:
+
+- `ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/service/impl/EcsSectionServiceImpl.java`
+- `ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/mapper/EcsSectionMapper.java`
+
+职责:
+
+- `queryByOtherId(...)`:查询已存在记录
+- `insertByBo(...)`:新增课表
+- `updateByBo(...)`:更新课表
+- `updateDelFlagByOtherId(...)`:按 `otherId + tenantId` 更新删除标记
+
+---
+
+## 三、改造后的完整执行时序
+
+1. 外部系统调用 `/api/sync/receive/sections`
+2. `SyncReceiveController.syncSections(...)`
+3. 设置 `businessType = SECTION`
+4. `CommonBatchSyncService.sync(...)`
+5. `SpringUtils.getBean("SECTION", BatchSyncBusinessHandler.class)` 获取 `SectionBatchSyncService`
+6. `SyncBatchTrackerService.start(...)` 创建批次
+7. 公共层逐条校验 section 数据并转换为 `RemoteSectionSyncBatchDto`
+8. 公共层按 100 条切分分片
+9. 单分片同步执行;多分片每轮最多 5 片并发执行
+10. 每个分片调用 `SectionBatchSyncService.dispatchChunk(...)`
+11. `dispatchChunk(...)` 调用 `RemoteSectionSyncService.syncSectionBatch(...)`
+12. `RemoteSectionSyncServiceImpl.syncSectionBatch(...)` 转内部 DTO 后调用 `SyncSectionServiceImpl.receiveSectionBatch(...)`
+13. ECS 内部完成删除/新增/修改处理
+14. 分片结果返回到公共层
+15. 公共层聚合 success/error
+16. `SyncBatchTrackerService.saveErrors(...)` 保存错误明细
+17. `SyncBatchTrackerService.finish(...)` 更新批次状态
+18. controller 返回 `BatchSyncResult`
+
+---
+
+## 四、当前分片与并发规则
+
+在 `CommonBatchSyncService` 中约定:
+
+- `CHUNK_SIZE = 100`
+- `MAX_PARALLEL_CHUNKS = 5`
+
+执行规则:
+
+- 只有 1 个分片:当前线程同步执行
+- 多于 1 个分片:按窗口执行,每轮最多并发 5 个分片
+- 并发线程池复用 `threadPoolTaskExecutor`
+
+---
+
+## 五、错误归集规则
+
+错误来源分两类:
+
+### 1. 校验失败
+
+发生位置:公共编排层
+
+处理方式:
+
+- 不进入远端调用
+- 直接通过 `SyncBatchTrackerService.error(...)` 构造 `VALIDATION_ERROR`
+
+### 2. 业务失败 / 远端失败
+
+发生位置:section 分片处理器或远端服务
+
+处理方式:
+
+- 转换为 `BIZ_ERROR`
+- 统一归集为 `SyncBatchErrorRecord`
+- 最后由 `SyncBatchTrackerService.saveErrors(...)` 批量保存
+
+---
+
+## 六、改造后 section 相关关键类
+
+### server-sync 侧
+
+- `SyncReceiveController`
+- `CommonBatchSyncService`
+- `BatchSyncBusinessHandler`
+- `SectionBatchSyncService`
+- `SyncBatchTrackerService`
+- `BatchSyncRequest`
+- `BatchSyncResult`
+- `BatchChunkSyncResult`
+
+### api / ecs 侧
+
+- `RemoteSectionSyncService`
+- `RemoteSectionSyncServiceImpl`
+- `SyncSectionServiceImpl`
+- `EcsSectionServiceImpl`
+- `EcsSectionMapper`
+
+---
+
+## 七、当前实现的边界说明
+
+1. 这次只迁移了 section,同步类如 train/trainee 仍保持原有方式
+2. 并发后的事务边界是“单分片”,不是“整批”
+3. section 下游的删除语义没有改变,仍由 ECS 内部服务控制
+4. 公共编排层只负责批次通用逻辑,不介入 section 的具体业务规则
+
+---
+
+## 八、后续可复用扩展方式
+
+如果后续要接入新的同步类型,如 `course`、`classroom`:
+
+1. 在 `SyncResourceConstants` 中增加业务类型常量
+2. 新增对应的业务处理器,实现 `BatchSyncBusinessHandler`
+3. 在 controller 中设置 `businessType`
+4. 直接复用 `CommonBatchSyncService.sync(...)`
+
+这样就不需要重复实现批次校验、分片、并发、错误归集和批次跟踪逻辑。

+ 55 - 0
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/BatchSyncBusinessHandler.java

@@ -0,0 +1,55 @@
+package org.dromara.server.sync.business.batch;
+
+import org.dromara.server.sync.domain.dto.result.BatchChunkSyncResult;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 批量同步业务处理器,负责单业务类型的校验、转换和单分片下发。
+ *
+ * @param <T> 原始请求记录类型
+ * @param <D> 下发处理对象类型
+ */
+public interface BatchSyncBusinessHandler<T extends Serializable, D> {
+
+    /**
+     * 返回业务类型编码。
+     */
+    String getBusinessType();
+
+    /**
+     * 返回批次跟踪使用的接口名称。
+     */
+    String getApiName();
+
+    /**
+     * 返回批次跟踪使用的资源类型。
+     */
+    String getResourceType();
+
+    /**
+     * 从原始记录中解析租户编号。
+     */
+    String resolveTenantId(List<T> records);
+
+    /**
+     * 校验单条原始记录。
+     */
+    void validateRecord(T record);
+
+    /**
+     * 返回单条记录的业务主键。
+     */
+    String getBizKey(T record);
+
+    /**
+     * 将原始记录转换为待下发对象。
+     */
+    D toDispatchDto(String batchId, int recordIndex, T record);
+
+    /**
+     * 执行单个分片的业务同步。
+     */
+    BatchChunkSyncResult dispatchChunk(String batchId, List<D> chunkRecords);
+}

+ 221 - 0
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/ClassroomBatchSyncService.java

@@ -0,0 +1,221 @@
+package org.dromara.server.sync.business.batch;
+
+import lombok.RequiredArgsConstructor;
+import org.apache.dubbo.config.annotation.DubboReference;
+import org.dromara.backstage.api.RemotePtRoomService;
+import org.dromara.backstage.api.domain.bo.RemotePtRoomBo;
+import org.dromara.backstage.api.domain.dto.RemoteClassroomDto;
+import org.dromara.backstage.api.domain.dto.RemoteClassroomQueryDto;
+import org.dromara.common.core.exception.ServiceException;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.server.common.constant.SyncResourceConstants;
+import org.dromara.server.sync.domain.SyncBatchErrorRecord;
+import org.dromara.server.sync.domain.dto.request.ClassroomRecordRequest;
+import org.dromara.server.sync.domain.dto.result.BatchChunkSyncResult;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Service(SyncResourceConstants.CLASSROOM)
+@RequiredArgsConstructor
+public class ClassroomBatchSyncService implements BatchSyncBusinessHandler<ClassroomRecordRequest, ClassroomBatchSyncService.DispatchRecord> {
+
+    private static final String API_NAME = "train/classroom";
+    private static final String RESOURCE_TYPE = "CLASSROOM";
+    private static final Long DEFAULT_AREA_ID = 2043662728532537345L;
+    private static final String DEFAULT_ROOM_TYPE = "3";
+    private static final String THIRD_PARTY_DATA_SOURCE = "1";
+    private static final Integer DEFAULT_CAPACITY = 50;
+
+    private final SyncBatchTrackerService syncBatchTrackerService;
+
+    @DubboReference
+    private RemotePtRoomService remotePtRoomService;
+
+    /**
+     * 返回教室同步对应的业务类型标识。
+     */
+    @Override
+    public String getBusinessType() {
+        return SyncResourceConstants.CLASSROOM;
+    }
+
+    /**
+     * 返回批次追踪使用的接口名称。
+     */
+    @Override
+    public String getApiName() {
+        return API_NAME;
+    }
+
+    /**
+     * 返回批次错误记录中的资源类型。
+     */
+    @Override
+    public String getResourceType() {
+        return RESOURCE_TYPE;
+    }
+
+    /**
+     * 从请求记录中解析本批次的租户标识。
+     */
+    @Override
+    public String resolveTenantId(List<ClassroomRecordRequest> records) {
+        return records.stream().map(ClassroomRecordRequest::getTenantId).filter(StringUtils::isNotBlank).findFirst().orElse("default");
+    }
+
+    /**
+     * 校验单条教室同步记录的必填字段。
+     */
+    @Override
+    public void validateRecord(ClassroomRecordRequest record) {
+        if (record == null) {
+            throw new ServiceException("record 不能为空");
+        }
+        if (StringUtils.isBlank(record.getRoomId())) {
+            throw new ServiceException("roomId 不能为空");
+        }
+        if (StringUtils.isBlank(record.getTenantId())) {
+            throw new ServiceException("tenantId 不能为空");
+        }
+        if (isDeleted(record)) {
+            return;
+        }
+        if (StringUtils.isBlank(record.getRoomCode()) && StringUtils.isBlank(record.getRoomName())) {
+            throw new ServiceException("roomCode 和 roomName 必须有一项不为空");
+        }
+    }
+
+    /**
+     * 返回单条记录的业务唯一键。
+     */
+    @Override
+    public String getBizKey(ClassroomRecordRequest record) {
+        return record == null ? null : record.getRoomId();
+    }
+
+    /**
+     * 将原始记录包装为分片下发对象。
+     */
+    @Override
+    public DispatchRecord toDispatchDto(String batchId, int recordIndex, ClassroomRecordRequest record) {
+        return new DispatchRecord(recordIndex, record);
+    }
+
+    /**
+     * 逐条执行当前分片中的教室同步并汇总结果。
+     */
+    @Override
+    public BatchChunkSyncResult dispatchChunk(String batchId, List<DispatchRecord> chunkRecords) {
+        List<SyncBatchErrorRecord> errors = new ArrayList<>();
+        int successCount = 0;
+        for (DispatchRecord chunkRecord : chunkRecords) {
+            try {
+                if (isDeleted(chunkRecord.record())) {
+                    deleteRoom(chunkRecord.record());
+                } else {
+                    upsertRoom(chunkRecord.record());
+                }
+                successCount++;
+            } catch (Exception e) {
+                errors.add(syncBatchTrackerService.error(
+                    batchId,
+                    RESOURCE_TYPE,
+                    chunkRecord.startIndex(),
+                    chunkRecord.record().getRoomId(),
+                    SyncBatchTrackerService.ERROR_CODE_BIZ,
+                    e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(),
+                    chunkRecord.record()
+                ));
+            }
+        }
+
+        BatchChunkSyncResult result = new BatchChunkSyncResult();
+        result.setTotal(chunkRecords.size());
+        result.setSuccess(successCount);
+        result.setFailed(errors.size());
+        result.setErrors(errors);
+        return result;
+    }
+
+    /**
+     * 按是否已存在执行教室新增或更新。
+     */
+    private void upsertRoom(ClassroomRecordRequest record) throws Exception {
+        RemoteClassroomDto existing = loadExistingRoom(record);
+        RemotePtRoomBo roomBo = buildRoomBo(record);
+        boolean success;
+        if (existing == null) {
+            success = Boolean.TRUE.equals(remotePtRoomService.insertByBo(roomBo));
+        } else {
+            roomBo.setRoomId(existing.getRoomId());
+            success = Boolean.TRUE.equals(remotePtRoomService.updateByBo(roomBo));
+        }
+        if (!success) {
+            throw new ServiceException("房间同步失败");
+        }
+    }
+
+    /**
+     * 按外部房间标识删除已存在的教室记录。
+     */
+    private void deleteRoom(ClassroomRecordRequest record) throws Exception {
+        RemoteClassroomDto existing = loadExistingRoom(record);
+        if (existing == null) {
+            return;
+        }
+        boolean success = Boolean.TRUE.equals(remotePtRoomService.deleteWithValidByIds(List.of(existing.getRoomId()), true));
+        if (!success) {
+            throw new ServiceException("房间删除失败");
+        }
+    }
+
+    /**
+     * 按租户和外部房间标识查询唯一教室记录。
+     */
+    private RemoteClassroomDto loadExistingRoom(ClassroomRecordRequest record) {
+        RemoteClassroomQueryDto queryDto = new RemoteClassroomQueryDto();
+        queryDto.setOtherId(record.getRoomId());
+        queryDto.setTenantId(record.getTenantId());
+        List<RemoteClassroomDto> rooms = remotePtRoomService.selectClassroomList(queryDto);
+        if (rooms == null || rooms.isEmpty()) {
+            return null;
+        }
+        if (rooms.size() > 1) {
+            throw new ServiceException("roomId 匹配到多条教室记录");
+        }
+        return rooms.get(0);
+    }
+
+    /**
+     * 构造远程房间写入对象并填充默认字段。
+     */
+
+    private RemotePtRoomBo buildRoomBo(ClassroomRecordRequest record) {
+        RemotePtRoomBo roomBo = new RemotePtRoomBo();
+        roomBo.setTenantId(record.getTenantId());
+        roomBo.setAreaId(DEFAULT_AREA_ID);
+        roomBo.setRoomType(DEFAULT_ROOM_TYPE);
+        roomBo.setRoomCode(record.getRoomCode());
+        roomBo.setRoomName(record.getRoomName());
+        roomBo.setCodeOne(StringUtils.isBlank(record.getCodeOne()) ? record.getRoomCode() : record.getCodeOne());
+        roomBo.setCapacity(record.getCapacity() == null ? DEFAULT_CAPACITY : record.getCapacity());
+        roomBo.setOtherId(record.getRoomId());
+        roomBo.setDataSource(THIRD_PARTY_DATA_SOURCE);
+        return roomBo;
+    }
+
+    /**
+     * 判断当前记录是否为删除操作。
+     */
+    private boolean isDeleted(ClassroomRecordRequest record) {
+        return record.getDelFlag() != null && record.getDelFlag() != 0;
+    }
+
+    /**
+     * 保存分片内记录序号和原始请求数据。
+     */
+    record DispatchRecord(int startIndex, ClassroomRecordRequest record) {
+    }
+}

+ 185 - 0
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/CommonBatchSyncService.java

@@ -0,0 +1,185 @@
+package org.dromara.server.sync.business.batch;
+
+import cn.hutool.core.collection.CollUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.exception.ServiceException;
+import org.dromara.common.core.utils.SpringUtils;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.server.sync.domain.SyncBatchErrorRecord;
+import org.dromara.server.sync.domain.dto.result.BatchChunkSyncResult;
+import org.dromara.server.sync.domain.dto.result.BatchSyncResult;
+import org.dromara.server.sync.domain.dto.wrapper.BatchSyncRequest;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.stereotype.Service;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+
+@Service
+@Slf4j
+public class CommonBatchSyncService {
+
+    private static final int CHUNK_SIZE = 100;
+    private static final int MAX_PARALLEL_CHUNKS = 5;
+
+    private final SyncBatchTrackerService syncBatchTrackerService;
+    private final ObjectProvider<ThreadPoolTaskExecutor> threadPoolTaskExecutorProvider;
+
+    public CommonBatchSyncService(SyncBatchTrackerService syncBatchTrackerService,
+                                  @Qualifier("threadPoolTaskExecutor") ObjectProvider<ThreadPoolTaskExecutor> threadPoolTaskExecutorProvider) {
+        this.syncBatchTrackerService = syncBatchTrackerService;
+        this.threadPoolTaskExecutorProvider = threadPoolTaskExecutorProvider;
+    }
+
+    /**
+     * 按业务类型统一编排批量同步,完成校验、分片、下发、错误归集和批次收尾。
+     *
+     * @param request 批量同步请求
+     * @return 批量同步结果
+     */
+    public <T extends Serializable, D> BatchSyncResult sync(BatchSyncRequest<T> request) {
+        if (request == null || CollUtil.isEmpty(request.getRecords())) {
+            throw new ServiceException("records 不能为空");
+        }
+        if (StringUtils.isBlank(request.getBusinessType())) {
+            throw new ServiceException("businessType 不能为空");
+        }
+
+        BatchSyncBusinessHandler<T, D> handler = getHandler(request.getBusinessType());
+        List<T> requestRecords = request.getRecords();
+        String tenantId = handler.resolveTenantId(requestRecords);
+        String batchId = syncBatchTrackerService.start(handler.getApiName(), handler.getResourceType(), tenantId, request, requestRecords.size());
+
+        List<D> validRecords = new ArrayList<>();
+        List<SyncBatchErrorRecord> errors = new ArrayList<>();
+        for (int i = 0; i < requestRecords.size(); i++) {
+            T record = requestRecords.get(i);
+            try {
+                handler.validateRecord(record);
+                validRecords.add(handler.toDispatchDto(batchId, i + 1, record));
+            } catch (Exception e) {
+                errors.add(syncBatchTrackerService.error(batchId, handler.getResourceType(), i + 1, handler.getBizKey(record),
+                    SyncBatchTrackerService.ERROR_CODE_VALIDATION, e.getMessage(), record));
+            }
+        }
+
+        log.info("[common-batch-sync-start]-[batchId:{}]-[businessType:{}]-[total:{}]-[valid:{}]-[invalid:{}]",
+            batchId, request.getBusinessType(), requestRecords.size(), validRecords.size(), errors.size());
+
+        int successCount = 0;
+        if (CollUtil.isNotEmpty(validRecords)) {
+            List<List<D>> chunks = partition(validRecords, CHUNK_SIZE);
+            log.info("[common-batch-sync-chunks]-[batchId:{}]-[chunkSize:{}]-[chunkCount:{}]-[mode:{}]",
+                batchId, CHUNK_SIZE, chunks.size(), chunks.size() == 1 ? "sync" : "parallel");
+            List<BatchChunkSyncResult> chunkResults = executeChunks(batchId, chunks, handler);
+            for (BatchChunkSyncResult chunkResult : chunkResults) {
+                successCount += chunkResult.getSuccess() == null ? 0 : chunkResult.getSuccess();
+                if (CollUtil.isNotEmpty(chunkResult.getErrors())) {
+                    errors.addAll(chunkResult.getErrors());
+                }
+            }
+        }
+
+        syncBatchTrackerService.saveErrors(errors);
+        int errorCount = errors.size();
+        syncBatchTrackerService.finish(batchId, requestRecords.size(), successCount, errorCount);
+        log.info("[common-batch-sync-done]-[batchId:{}]-[businessType:{}]-[success:{}]-[error:{}]-[status:{}]",
+            batchId, request.getBusinessType(), successCount, errorCount, resolveStatus(requestRecords.size(), successCount, errorCount));
+        return buildResult(batchId, requestRecords.size(), successCount, errorCount);
+    }
+
+    /**
+     * 根据业务类型获取对应的同步处理器。
+     */
+    @SuppressWarnings("unchecked")
+    private <T extends Serializable, D> BatchSyncBusinessHandler<T, D> getHandler(String businessType) {
+        Map<String, BatchSyncBusinessHandler> handlers = SpringUtils.context().getBeansOfType(BatchSyncBusinessHandler.class);
+        for (BatchSyncBusinessHandler handler : handlers.values()) {
+            if (businessType.equals(handler.getBusinessType())) {
+                return (BatchSyncBusinessHandler<T, D>) handler;
+            }
+        }
+        throw new ServiceException("未找到业务处理器: " + businessType);
+    }
+
+    /**
+     * 按固定大小对有效记录分片。
+     */
+    private <D> List<List<D>> partition(List<D> records, int chunkSize) {
+        List<List<D>> result = new ArrayList<>();
+        for (int i = 0; i < records.size(); i += chunkSize) {
+            result.add(records.subList(i, Math.min(i + chunkSize, records.size())));
+        }
+        return result;
+    }
+
+    /**
+     * 执行所有分片:单分片串行执行,多分片按每轮最多 5 片并发执行。
+     */
+    private <T extends Serializable, D> List<BatchChunkSyncResult> executeChunks(String batchId, List<List<D>> chunks,
+                                                                                   BatchSyncBusinessHandler<T, D> handler) {
+        if (CollUtil.isEmpty(chunks)) {
+            return new ArrayList<>();
+        }
+
+        ThreadPoolTaskExecutor executor = threadPoolTaskExecutorProvider.getIfAvailable();
+        if (chunks.size() == 1 || executor == null) {
+            List<BatchChunkSyncResult> results = new ArrayList<>();
+            for (List<D> chunk : chunks) {
+                results.add(handler.dispatchChunk(batchId, chunk));
+            }
+            return results;
+        }
+
+        List<BatchChunkSyncResult> results = new ArrayList<>();
+        for (int i = 0; i < chunks.size(); i += MAX_PARALLEL_CHUNKS) {
+            int end = Math.min(i + MAX_PARALLEL_CHUNKS, chunks.size());
+            List<List<D>> currentChunks = chunks.subList(i, end);
+            log.info("[common-batch-sync-window]-[batchId:{}]-[from:{}]-[to:{}]-[parallel:{}]",
+                batchId, i, end - 1, currentChunks.size());
+            List<CompletableFuture<BatchChunkSyncResult>> futures = new ArrayList<>();
+            for (List<D> chunk : currentChunks) {
+                futures.add(CompletableFuture.supplyAsync(() -> handler.dispatchChunk(batchId, chunk), executor));
+            }
+            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+            for (CompletableFuture<BatchChunkSyncResult> future : futures) {
+                results.add(future.join());
+            }
+        }
+        return results;
+    }
+
+    /**
+     * 构造统一的批次返回结果。
+     */
+    private BatchSyncResult buildResult(String batchId, int total, int success, int error) {
+        BatchSyncResult result = new BatchSyncResult();
+        result.setBatchId(batchId);
+        result.setTotal(total);
+        result.setSuccess(success);
+        result.setError(error);
+        result.setStatus(resolveStatus(total, success, error));
+        return result;
+    }
+
+    /**
+     * 根据成功数和失败数计算批次状态。
+     */
+    private String resolveStatus(int total, int success, int error) {
+        if (total != success + error) {
+            return SyncBatchTrackerService.STATUS_COUNT_MISMATCH;
+        }
+        if (error == 0) {
+            return SyncBatchTrackerService.STATUS_SUCCESS;
+        }
+        if (success == 0) {
+            return SyncBatchTrackerService.STATUS_FAIL;
+        }
+        return SyncBatchTrackerService.STATUS_PARTIAL_FAIL;
+    }
+}

+ 134 - 113
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/SectionBatchSyncService.java

@@ -12,9 +12,11 @@ import org.dromara.ecs.api.domain.dto.RemoteBatchSyncErrorDto;
 import org.dromara.ecs.api.domain.dto.RemoteBatchSyncResultDto;
 import org.dromara.ecs.api.domain.dto.RemoteSectionSyncBatchDto;
 import org.dromara.ecs.api.domain.dto.RemoteSectionSyncDto;
+import org.dromara.server.common.constant.SyncResourceConstants;
 import org.dromara.server.sync.business.batch.mapper.SectionRecordMapper;
 import org.dromara.server.sync.domain.SyncBatchErrorRecord;
 import org.dromara.server.sync.domain.dto.request.SectionRecordRequest;
+import org.dromara.server.sync.domain.dto.result.BatchChunkSyncResult;
 import org.dromara.server.sync.domain.dto.result.BatchSyncResult;
 import org.dromara.server.sync.domain.dto.wrapper.BatchSyncRequest;
 import org.springframework.stereotype.Service;
@@ -30,148 +32,73 @@ import java.util.List;
  * @Version 1.0
  * @since jdk17
  */
-
-@Service
+@Service(SyncResourceConstants.SECTION)
 @Slf4j
 @RequiredArgsConstructor
-public class SectionBatchSyncService {
+public class SectionBatchSyncService implements BatchSyncBusinessHandler<SectionRecordRequest, RemoteSectionSyncBatchDto> {
 
     private static final String API_NAME = "sections";
     private static final String RESOURCE_TYPE = "SECTION";
 
     private final SectionRecordMapper sectionRecordMapper;
     private final SyncBatchTrackerService syncBatchTrackerService;
+    private final CommonBatchSyncService commonBatchSyncService;
 
     @DubboReference
     private RemoteSectionSyncService remoteSectionSyncService;
 
     /**
-     * 批量同步课表节次,完成参数校验、远端调用、错误归集和批次结果汇总
+     * 兼容原有入口,委托公共批量编排服务执行 section 同步。
      *
      * @param request 批量课表节次请求
      * @return 批次同步结果
      */
     public BatchSyncResult syncSections(BatchSyncRequest<SectionRecordRequest> request) {
-        if (request == null || CollUtil.isEmpty(request.getRecords())) {
-            throw new ServiceException("records 不能为空");
-        }
-
-        List<SectionRecordRequest> requestRecords = request.getRecords();
-        String tenantId = requestRecords.stream().map(SectionRecordRequest::getTenantId).filter(StringUtils::isNotBlank).findFirst().orElse("default");
-        String batchId = syncBatchTrackerService.start(API_NAME, RESOURCE_TYPE, tenantId, request, requestRecords.size());
-
-        List<RemoteSectionSyncBatchDto> records = new ArrayList<>();
-        List<SyncBatchErrorRecord> errors = new ArrayList<>();
-
-        // 逐条校验并转换请求数据,校验失败的记录直接记入错误明细。
-        for (int i = 0; i < requestRecords.size(); i++) {
-            SectionRecordRequest record = requestRecords.get(i);
-            try {
-                validateRecord(record);
-                records.add(sectionRecordMapper.toBatchDto(batchId, i + 1, record));
-            } catch (Exception e) {
-                errors.add(syncBatchTrackerService.error(batchId, RESOURCE_TYPE, i + 1, getBizKey(record),
-                    SyncBatchTrackerService.ERROR_CODE_VALIDATION, e.getMessage(), record));
-            }
-        }
-
-        String sectionIds = records.stream().map(RemoteSectionSyncBatchDto::getRecord).map(RemoteSectionSyncDto::getSectionId).filter(StringUtils::isNotBlank).limit(10).reduce((a, b) -> a + "," + b).orElse("");
-        log.info("[batch-sync-sections-start]-[batchId:{}]-[tenant:{}]-[total:{}]-[valid:{}]-[invalid:{}]-[sampleSectionIds:{}]",
-            batchId, tenantId, requestRecords.size(), records.size(), errors.size(), sectionIds);
-
-        int successCount = 0;
-        if (CollUtil.isNotEmpty(records)) {
-            try {
-                // 调用远端批量同步服务,并根据返回结果汇总成功数与业务失败明细。
-                R<RemoteBatchSyncResultDto> result = remoteSectionSyncService.syncSectionBatch(records);
-                if (R.isError(result)) {
-                    String msg = result.getMsg();
-                    log.error("[batch-sync-sections-fail]-[batchId:{}]-[msg:{}]", batchId, msg);
-                    for (RemoteSectionSyncBatchDto record : records) {
-                        errors.add(syncBatchTrackerService.error(batchId, RESOURCE_TYPE, record.getStartIndex(), record.getRecord().getSectionId(),
-                            SyncBatchTrackerService.ERROR_CODE_BIZ, msg, record.getRecord()));
-                    }
-                } else {
-                    RemoteBatchSyncResultDto data = result.getData();
-                    if (data != null) {
-                        successCount = data.getSuccess() == null ? 0 : data.getSuccess();
-                        addRemoteErrors(batchId, records, errors, data);
-                        log.info("[batch-sync-sections-done]-[batchId:{}]-[total:{}]-[成功:{}]-[失败:{}]",
-                            batchId, data.getTotal(), data.getSuccess(), data.getFailed());
-                    } else {
-                        successCount = records.size();
-                        log.info("[batch-sync-sections-done]-[batchId:{}]-[total:{}]-[远端未返回统计]", batchId, requestRecords.size());
-                    }
-                }
-            } catch (Exception e) {
-                // 远端调用异常时,将本次有效记录全部记为业务失败。
-                log.error("[batch-sync-sections-exception]-[batchId:{}]-[msg:{}]", batchId, e.getMessage(), e);
-                for (RemoteSectionSyncBatchDto record : records) {
-                    errors.add(syncBatchTrackerService.error(batchId, RESOURCE_TYPE, record.getStartIndex(), record.getRecord().getSectionId(),
-                        SyncBatchTrackerService.ERROR_CODE_BIZ, e.getMessage(), record.getRecord()));
-                }
-            }
+        if (request != null) {
+            request.setBusinessType(getBusinessType());
         }
+        return commonBatchSyncService.sync(request);
+    }
 
-        // 持久化错误明细并结束批次,最终返回汇总结果。
-        syncBatchTrackerService.saveErrors(errors);
-        int errorCount = errors.size();
-        syncBatchTrackerService.finish(batchId, requestRecords.size(), successCount, errorCount);
-
-        BatchSyncResult batchSyncResult = new BatchSyncResult();
-        batchSyncResult.setBatchId(batchId);
-        batchSyncResult.setTotal(requestRecords.size());
-        batchSyncResult.setSuccess(successCount);
-        batchSyncResult.setError(errorCount);
-        batchSyncResult.setStatus(resolveStatus(requestRecords.size(), successCount, errorCount));
-        return batchSyncResult;
+    /**
+     * 返回当前处理器对应的业务类型。
+     *
+     * @return 业务类型
+     */
+    @Override
+    public String getBusinessType() {
+        return SyncResourceConstants.SECTION;
     }
 
     /**
-     * 将远端返回的失败明细归集到本地批次错误列表
+     * 返回批次跟踪中使用的接口名称。
      *
-     * @param batchId 批次ID
-     * @param records 有效请求记录
-     * @param errors 错误明细集合
-     * @param result 远端批量同步结果
+     * @return 接口名称
      */
-    private void addRemoteErrors(String batchId, List<RemoteSectionSyncBatchDto> records, List<SyncBatchErrorRecord> errors, RemoteBatchSyncResultDto result) {
-        if (CollUtil.isNotEmpty(result.getErrors())) {
-            for (RemoteBatchSyncErrorDto error : result.getErrors()) {
-                RemoteSectionSyncBatchDto record = records.stream().filter(item -> item.getStartIndex().equals(error.getRecordIndex())).findFirst().orElse(null);
-                errors.add(syncBatchTrackerService.error(batchId, RESOURCE_TYPE, error.getRecordIndex(), error.getBizKey(),
-                    SyncBatchTrackerService.ERROR_CODE_BIZ, error.getErrorMessage(), record == null ? null : record.getRecord()));
-            }
-            return;
-        }
+    @Override
+    public String getApiName() {
+        return API_NAME;
+    }
 
-        int remoteFailed = result.getFailed() == null ? 0 : result.getFailed();
-        for (int i = 0; i < remoteFailed && i < records.size(); i++) {
-            RemoteSectionSyncBatchDto record = records.get(i);
-            errors.add(syncBatchTrackerService.error(batchId, RESOURCE_TYPE, record.getStartIndex(), record.getRecord().getSectionId(),
-                SyncBatchTrackerService.ERROR_CODE_BIZ, "远端处理失败,未返回具体失败明细", record.getRecord()));
-        }
+    /**
+     * 返回批次跟踪中使用的资源类型。
+     *
+     * @return 资源类型
+     */
+    @Override
+    public String getResourceType() {
+        return RESOURCE_TYPE;
     }
 
     /**
-     * 根据总数、成功数和失败数计算批次状态
+     * 从请求记录中解析租户编号。
      *
-     * @param total 请求总数
-     * @param success 成功数
-     * @param error 失败数
-     * @return 批次状态
+     * @param records 课表节次记录列表
+     * @return 租户编号
      */
-    private String resolveStatus(int total, int success, int error) {
-        if (total != success + error) {
-            return SyncBatchTrackerService.STATUS_COUNT_MISMATCH;
-        }
-        if (error == 0) {
-            return SyncBatchTrackerService.STATUS_SUCCESS;
-        }
-        if (success == 0) {
-            return SyncBatchTrackerService.STATUS_FAIL;
-        }
-        return SyncBatchTrackerService.STATUS_PARTIAL_FAIL;
+    @Override
+    public String resolveTenantId(List<SectionRecordRequest> records) {
+        return records.stream().map(SectionRecordRequest::getTenantId).filter(StringUtils::isNotBlank).findFirst().orElse("default");
     }
 
     /**
@@ -179,7 +106,8 @@ public class SectionBatchSyncService {
      *
      * @param record 单条课表节次记录
      */
-    private void validateRecord(SectionRecordRequest record) {
+    @Override
+    public void validateRecord(SectionRecordRequest record) {
         if (record == null) {
             throw new ServiceException("record 不能为空");
         }
@@ -194,7 +122,100 @@ public class SectionBatchSyncService {
      * @param record 单条课表节次记录
      * @return 业务主键
      */
-    private String getBizKey(SectionRecordRequest record) {
+    @Override
+    public String getBizKey(SectionRecordRequest record) {
         return record == null ? null : record.getSectionId();
     }
+
+    /**
+     * 将单条课表节次记录转换为远端批量同步 DTO。
+     *
+     * @param batchId 批次ID
+     * @param recordIndex 原始记录序号
+     * @param record 单条课表节次记录
+     * @return 远端批量同步 DTO
+     */
+    @Override
+    public RemoteSectionSyncBatchDto toDispatchDto(String batchId, int recordIndex, SectionRecordRequest record) {
+        return sectionRecordMapper.toBatchDto(batchId, recordIndex, record);
+    }
+
+    /**
+     * 执行单个分片的 section 同步,并返回分片处理结果。
+     *
+     * @param batchId 批次ID
+     * @param chunkRecords 分片记录列表
+     * @return 分片处理结果
+     */
+    @Override
+    public BatchChunkSyncResult dispatchChunk(String batchId, List<RemoteSectionSyncBatchDto> chunkRecords) {
+        BatchChunkSyncResult chunkResult = new BatchChunkSyncResult();
+        chunkResult.setTotal(chunkRecords.size());
+        chunkResult.setSuccess(0);
+        chunkResult.setFailed(0);
+        chunkResult.setErrors(new ArrayList<>());
+
+        String sectionIds = chunkRecords.stream().map(RemoteSectionSyncBatchDto::getRecord).map(RemoteSectionSyncDto::getSectionId)
+            .filter(StringUtils::isNotBlank).limit(10).reduce((a, b) -> a + "," + b).orElse("");
+        log.info("[batch-sync-sections-chunk-start]-[batchId:{}]-[size:{}]-[sampleSectionIds:{}]", batchId, chunkRecords.size(), sectionIds);
+
+        try {
+            R<RemoteBatchSyncResultDto> result = remoteSectionSyncService.syncSectionBatch(chunkRecords);
+            if (R.isError(result)) {
+                String msg = result.getMsg();
+                log.error("[batch-sync-sections-chunk-fail]-[batchId:{}]-[msg:{}]", batchId, msg);
+                for (RemoteSectionSyncBatchDto record : chunkRecords) {
+                    chunkResult.getErrors().add(syncBatchTrackerService.error(batchId, RESOURCE_TYPE, record.getStartIndex(), record.getRecord().getSectionId(),
+                        SyncBatchTrackerService.ERROR_CODE_BIZ, msg, record.getRecord()));
+                }
+            } else {
+                RemoteBatchSyncResultDto data = result.getData();
+                if (data != null) {
+                    chunkResult.setTotal(data.getTotal() == null ? chunkRecords.size() : data.getTotal());
+                    chunkResult.setSuccess(data.getSuccess() == null ? 0 : data.getSuccess());
+                    addRemoteErrors(batchId, chunkRecords, chunkResult.getErrors(), data);
+                    log.info("[batch-sync-sections-chunk-done]-[batchId:{}]-[total:{}]-[成功:{}]-[失败:{}]",
+                        batchId, data.getTotal(), data.getSuccess(), data.getFailed());
+                } else {
+                    chunkResult.setSuccess(chunkRecords.size());
+                    log.info("[batch-sync-sections-chunk-done]-[batchId:{}]-[total:{}]-[远端未返回统计]", batchId, chunkRecords.size());
+                }
+            }
+        } catch (Exception e) {
+            log.error("[batch-sync-sections-chunk-exception]-[batchId:{}]-[msg:{}]", batchId, e.getMessage(), e);
+            for (RemoteSectionSyncBatchDto record : chunkRecords) {
+                chunkResult.getErrors().add(syncBatchTrackerService.error(batchId, RESOURCE_TYPE, record.getStartIndex(), record.getRecord().getSectionId(),
+                    SyncBatchTrackerService.ERROR_CODE_BIZ, e.getMessage(), record.getRecord()));
+            }
+        }
+
+        chunkResult.setFailed(chunkResult.getErrors().size());
+        return chunkResult;
+    }
+
+    /**
+     * 将远端返回的失败明细归集到本地错误列表。
+     *
+     * @param batchId 批次ID
+     * @param records 分片有效记录
+     * @param errors 错误明细集合
+     * @param result 远端批量同步结果
+     */
+    private void addRemoteErrors(String batchId, List<RemoteSectionSyncBatchDto> records, List<SyncBatchErrorRecord> errors, RemoteBatchSyncResultDto result) {
+        if (CollUtil.isNotEmpty(result.getErrors())) {
+            for (RemoteBatchSyncErrorDto error : result.getErrors()) {
+                RemoteSectionSyncBatchDto record = records.stream().filter(item -> item.getStartIndex().equals(error.getRecordIndex())).findFirst().orElse(null);
+                errors.add(syncBatchTrackerService.error(batchId, RESOURCE_TYPE, error.getRecordIndex(), error.getBizKey(),
+                    SyncBatchTrackerService.ERROR_CODE_BIZ, error.getErrorMessage(), record == null ? null : record.getRecord()));
+            }
+            return;
+        }
+
+        int remoteFailed = result.getFailed() == null ? 0 : result.getFailed();
+        for (int i = 0; i < remoteFailed && i < records.size(); i++) {
+            RemoteSectionSyncBatchDto record = records.get(i);
+            errors.add(syncBatchTrackerService.error(batchId, RESOURCE_TYPE, record.getStartIndex(), record.getRecord().getSectionId(),
+                SyncBatchTrackerService.ERROR_CODE_BIZ, "远端处理失败,未返回具体失败明细", record.getRecord()));
+        }
+    }
 }

+ 0 - 83
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/TrainBatchSyncService.java

@@ -1,83 +0,0 @@
-package org.dromara.server.sync.business.batch;
-
-import cn.hutool.core.collection.CollUtil;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.dromara.common.core.exception.ServiceException;
-import org.dromara.common.core.utils.SpringUtils;
-import org.dromara.common.core.utils.StringUtils;
-import org.dromara.server.common.constant.SyncResourceConstants;
-import org.dromara.server.common.domain.bo.ResourceDept;
-import org.dromara.server.sync.business.batch.mapper.TrainClassRecordMapper;
-import org.dromara.server.sync.domain.dto.request.TrainClassRecordRequest;
-import org.dromara.server.sync.domain.dto.wrapper.BatchSyncRequest;
-import org.dromara.server.sync.strategy.dept.ISyncDeptStrategy;
-import org.springframework.stereotype.Service;
-
-import java.util.ArrayList;
-import java.util.List;
-
-@Service
-@Slf4j
-@RequiredArgsConstructor
-public class TrainBatchSyncService {
-
-    private final TrainClassRecordMapper trainClassRecordMapper;
-
-    public void syncTrainClass(BatchSyncRequest<TrainClassRecordRequest> request) {
-        if (request == null || CollUtil.isEmpty(request.getRecords())) {
-            throw new ServiceException("records 不能为空");
-        }
-
-        List<ResourceDept> addOrUpdateList = new ArrayList<>();
-        List<ResourceDept> deleteList = new ArrayList<>();
-
-        for (TrainClassRecordRequest record : request.getRecords()) {
-            validateRecord(record);
-            ResourceDept dept = trainClassRecordMapper.toResourceDept(record);
-            if ("1".equals(record.getDelFlag())) {
-                deleteList.add(dept);
-            } else {
-                addOrUpdateList.add(dept);
-            }
-        }
-
-        ISyncDeptStrategy syncDeptStrategy = SpringUtils.getBean(SyncResourceConstants.TRAIN_CLASS, ISyncDeptStrategy.class);
-        if (CollUtil.isNotEmpty(deleteList)) {
-            syncDeptStrategy.syncDelDept(deleteList);
-        }
-        if (CollUtil.isNotEmpty(addOrUpdateList)) {
-            syncDeptStrategy.syncDept(addOrUpdateList);
-        }
-
-        log.info("[batch-sync-train-class]-[总数:{}]-[新增更新:{}]-[删除:{}]",
-            request.getRecords().size(), addOrUpdateList.size(), deleteList.size());
-    }
-
-    private void validateRecord(TrainClassRecordRequest record) {
-        if (record == null) {
-            throw new ServiceException("record 不能为空");
-        }
-        if (StringUtils.isBlank(record.getClassId())) {
-            throw new ServiceException("classId 不能为空");
-        }
-        if (StringUtils.isBlank(record.getClassName())) {
-            throw new ServiceException("className 不能为空");
-        }
-        if (StringUtils.isBlank(record.getYear())) {
-            throw new ServiceException("year 不能为空");
-        }
-        if (StringUtils.isBlank(record.getSemester())) {
-            throw new ServiceException("semester 不能为空");
-        }
-        if (StringUtils.isBlank(record.getCheckinTime())) {
-            throw new ServiceException("checkinTime 不能为空");
-        }
-        if (StringUtils.isBlank(record.getStartTime())) {
-            throw new ServiceException("startTime 不能为空");
-        }
-        if (StringUtils.isBlank(record.getEndTime())) {
-            throw new ServiceException("endTime 不能为空");
-        }
-    }
-}

+ 130 - 0
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/TrainClassBatchSyncService.java

@@ -0,0 +1,130 @@
+package org.dromara.server.sync.business.batch;
+
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.exception.ServiceException;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.server.common.constant.SyncResourceConstants;
+import org.dromara.server.common.domain.bo.ResourceDept;
+import org.dromara.server.sync.business.batch.mapper.TrainClassRecordMapper;
+import org.dromara.server.sync.domain.SyncBatchErrorRecord;
+import org.dromara.server.sync.domain.dto.request.TrainClassRecordRequest;
+import org.dromara.server.sync.domain.dto.result.BatchChunkSyncResult;
+import org.dromara.server.sync.strategy.dept.impl.TrainClassStrategyImpl;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class TrainClassBatchSyncService implements BatchSyncBusinessHandler<TrainClassRecordRequest, TrainClassBatchSyncService.DispatchRecord> {
+
+    private static final String API_NAME = "classes";
+    private static final String RESOURCE_TYPE = "TRAIN_CLASS";
+
+    private final TrainClassRecordMapper trainClassRecordMapper;
+    private final TrainClassStrategyImpl trainClassStrategy;
+    private final SyncBatchTrackerService syncBatchTrackerService;
+
+    @Override
+    public String getBusinessType() {
+        return SyncResourceConstants.TRAIN_CLASS;
+    }
+
+    @Override
+    public String getApiName() {
+        return API_NAME;
+    }
+
+    @Override
+    public String getResourceType() {
+        return RESOURCE_TYPE;
+    }
+
+    @Override
+    public String resolveTenantId(List<TrainClassRecordRequest> records) {
+        return records.stream().map(TrainClassRecordRequest::getTenantId).filter(StringUtils::isNotBlank).findFirst().orElse("default");
+    }
+
+    @Override
+    public void validateRecord(TrainClassRecordRequest record) {
+        if (record == null) {
+            throw new ServiceException("record 不能为空");
+        }
+        if (StringUtils.isBlank(record.getClassId())) {
+            throw new ServiceException("classId 不能为空");
+        }
+        if (isDeleted(record)) {
+            return;
+        }
+        if (StringUtils.isBlank(record.getClassName())) {
+            throw new ServiceException("className 不能为空");
+        }
+        if (StringUtils.isBlank(record.getYear())) {
+            throw new ServiceException("year 不能为空");
+        }
+        if (StringUtils.isBlank(record.getSemester())) {
+            throw new ServiceException("semester 不能为空");
+        }
+        if (StringUtils.isBlank(record.getCheckinTime())) {
+            throw new ServiceException("checkinTime 不能为空");
+        }
+        if (StringUtils.isBlank(record.getStartTime())) {
+            throw new ServiceException("startTime 不能为空");
+        }
+        if (StringUtils.isBlank(record.getEndTime())) {
+            throw new ServiceException("endTime 不能为空");
+        }
+    }
+
+    @Override
+    public String getBizKey(TrainClassRecordRequest record) {
+        return record == null ? null : record.getClassId();
+    }
+
+    @Override
+    public DispatchRecord toDispatchDto(String batchId, int recordIndex, TrainClassRecordRequest record) {
+        ResourceDept dept = isDeleted(record) ? trainClassRecordMapper.toDeleteResourceDept(record) : trainClassRecordMapper.toResourceDept(record);
+        return new DispatchRecord(recordIndex, record, dept);
+    }
+
+    @Override
+    public BatchChunkSyncResult dispatchChunk(String batchId, List<DispatchRecord> chunkRecords) {
+        List<SyncBatchErrorRecord> errors = new ArrayList<>();
+        int successCount = 0;
+        for (DispatchRecord chunkRecord : chunkRecords) {
+            try {
+                if (isDeleted(chunkRecord.record())) {
+                    trainClassStrategy.deleteTrainClass(chunkRecord.dept());
+                } else {
+                    trainClassStrategy.syncTrainClass(chunkRecord.dept());
+                }
+                successCount++;
+            } catch (Exception e) {
+                errors.add(syncBatchTrackerService.error(
+                    batchId,
+                    RESOURCE_TYPE,
+                    chunkRecord.startIndex(),
+                    chunkRecord.record().getClassId(),
+                    SyncBatchTrackerService.ERROR_CODE_BIZ,
+                    e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(),
+                    chunkRecord.record()
+                ));
+            }
+        }
+
+        BatchChunkSyncResult result = new BatchChunkSyncResult();
+        result.setTotal(chunkRecords.size());
+        result.setSuccess(successCount);
+        result.setFailed(errors.size());
+        result.setErrors(errors);
+        return result;
+    }
+
+    private boolean isDeleted(TrainClassRecordRequest record) {
+        return "1".equals(record.getDelFlag());
+    }
+
+    record DispatchRecord(int startIndex, TrainClassRecordRequest record, ResourceDept dept) {
+    }
+}

+ 95 - 38
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/TraineeBatchSyncService.java

@@ -1,77 +1,134 @@
 package org.dromara.server.sync.business.batch;
 
-import cn.hutool.core.collection.CollUtil;
 import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
 import org.dromara.common.core.exception.ServiceException;
-import org.dromara.common.core.utils.SpringUtils;
 import org.dromara.common.core.utils.StringUtils;
 import org.dromara.server.common.constant.SyncResourceConstants;
 import org.dromara.server.common.domain.bo.ResourcePerson;
 import org.dromara.server.sync.business.batch.mapper.TraineeRecordMapper;
+import org.dromara.server.sync.domain.SyncBatchErrorRecord;
 import org.dromara.server.sync.domain.dto.request.TraineeRecordRequest;
-import org.dromara.server.sync.domain.dto.wrapper.BatchSyncRequest;
-import org.dromara.server.sync.strategy.user.ISyncUserStrategy;
+import org.dromara.server.sync.domain.dto.result.BatchChunkSyncResult;
+import org.dromara.server.sync.strategy.user.impl.SyncTraineeStrategyImpl;
 import org.springframework.stereotype.Service;
 
 import java.util.ArrayList;
 import java.util.List;
 
 @Service
-@Slf4j
 @RequiredArgsConstructor
-public class TraineeBatchSyncService {
+public class TraineeBatchSyncService implements BatchSyncBusinessHandler<TraineeRecordRequest, TraineeBatchSyncService.DispatchRecord> {
 
-    private final TraineeRecordMapper traineeRecordMapper;
+    private static final String API_NAME = "trainee";
+    private static final String RESOURCE_TYPE = "TRAINEE";
 
-    public void syncTrainee(BatchSyncRequest<TraineeRecordRequest> request) {
-        if (request == null || CollUtil.isEmpty(request.getRecords())) {
-            throw new ServiceException("records 不能为空");
-        }
+    private final TraineeRecordMapper traineeRecordMapper;
+    private final SyncTraineeStrategyImpl syncTraineeStrategy;
+    private final SyncBatchTrackerService syncBatchTrackerService;
 
-        List<ResourcePerson> addOrUpdateList = new ArrayList<>();
-        List<ResourcePerson> deleteList = new ArrayList<>();
+    @Override
+    public String getBusinessType() {
+        return SyncResourceConstants.TRAINEE;
+    }
 
-        for (TraineeRecordRequest record : request.getRecords()) {
-            validateRecord(record);
-            ResourcePerson person = traineeRecordMapper.toResourcePerson(record);
-            if ("1".equals(record.getStudent().getDelFlag())) {
-                deleteList.add(person);
-            } else {
-                addOrUpdateList.add(person);
-            }
-        }
+    @Override
+    public String getApiName() {
+        return API_NAME;
+    }
 
-        ISyncUserStrategy syncUserStrategy = SpringUtils.getBean(SyncResourceConstants.TRAINEE, ISyncUserStrategy.class);
-        if (CollUtil.isNotEmpty(deleteList)) {
-            syncUserStrategy.syncDelUser(deleteList);
-        }
-        if (CollUtil.isNotEmpty(addOrUpdateList)) {
-            syncUserStrategy.syncUser(addOrUpdateList);
-        }
+    @Override
+    public String getResourceType() {
+        return RESOURCE_TYPE;
+    }
 
-        log.info("[batch-sync-trainee]-[总数:{}]-[新增更新:{}]-[删除:{}]",
-            request.getRecords().size(), addOrUpdateList.size(), deleteList.size());
+    @Override
+    public String resolveTenantId(List<TraineeRecordRequest> records) {
+        return records.stream().map(TraineeRecordRequest::getTenantId).filter(StringUtils::isNotBlank).findFirst().orElse("default");
     }
 
-    private void validateRecord(TraineeRecordRequest record) {
+    @Override
+    public void validateRecord(TraineeRecordRequest record) {
         if (record == null) {
             throw new ServiceException("record 不能为空");
         }
         if (record.getStudent() == null) {
             throw new ServiceException("student 不能为空");
         }
-        if (record.getClassStudent() == null) {
-            throw new ServiceException("classStudent 不能为空");
-        }
         if (StringUtils.isBlank(record.getStudent().getStudentId())) {
             throw new ServiceException("student.studentId 不能为空");
         }
+        if (StringUtils.isBlank(resolveClassId(record))) {
+            throw new ServiceException("classId 不能为空");
+        }
+        if (isDeleted(record)) {
+            return;
+        }
+        if (record.getClassStudent() == null) {
+            throw new ServiceException("classStudent 不能为空");
+        }
         if (StringUtils.isBlank(record.getStudent().getStudentName())) {
             throw new ServiceException("student.studentName 不能为空");
         }
-        if (StringUtils.isBlank(record.getStudent().getClassId()) && StringUtils.isBlank(record.getClassStudent().getClassId())) {
-            throw new ServiceException("classId 不能为空");
+    }
+
+    @Override
+    public String getBizKey(TraineeRecordRequest record) {
+        return record == null || record.getStudent() == null ? null : record.getStudent().getStudentId();
+    }
+
+    @Override
+    public DispatchRecord toDispatchDto(String batchId, int recordIndex, TraineeRecordRequest record) {
+        ResourcePerson person = isDeleted(record) ? traineeRecordMapper.toDeleteResourcePerson(record) : traineeRecordMapper.toResourcePerson(record);
+        return new DispatchRecord(recordIndex, record, person);
+    }
+
+    @Override
+    public BatchChunkSyncResult dispatchChunk(String batchId, List<DispatchRecord> chunkRecords) {
+        List<SyncBatchErrorRecord> errors = new ArrayList<>();
+        int successCount = 0;
+        for (DispatchRecord chunkRecord : chunkRecords) {
+            try {
+                if (isDeleted(chunkRecord.record())) {
+                    syncTraineeStrategy.deleteTrainee(chunkRecord.person());
+                } else {
+                    syncTraineeStrategy.syncTrainee(chunkRecord.person());
+                }
+                successCount++;
+            } catch (Exception e) {
+                errors.add(syncBatchTrackerService.error(
+                    batchId,
+                    RESOURCE_TYPE,
+                    chunkRecord.startIndex(),
+                    getBizKey(chunkRecord.record()),
+                    SyncBatchTrackerService.ERROR_CODE_BIZ,
+                    e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(),
+                    chunkRecord.record()
+                ));
+            }
         }
+
+        BatchChunkSyncResult result = new BatchChunkSyncResult();
+        result.setTotal(chunkRecords.size());
+        result.setSuccess(successCount);
+        result.setFailed(errors.size());
+        result.setErrors(errors);
+        return result;
+    }
+
+    private boolean isDeleted(TraineeRecordRequest record) {
+        return record.getStudent() != null && "1".equals(record.getStudent().getDelFlag());
+    }
+
+    private String resolveClassId(TraineeRecordRequest record) {
+        if (record.getStudent() != null && StringUtils.isNotBlank(record.getStudent().getClassId())) {
+            return record.getStudent().getClassId();
+        }
+        if (record.getClassStudent() != null) {
+            return record.getClassStudent().getClassId();
+        }
+        return null;
+    }
+
+    record DispatchRecord(int startIndex, TraineeRecordRequest record, ResourcePerson person) {
     }
 }

+ 9 - 0
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/mapper/TrainClassRecordMapper.java

@@ -36,6 +36,15 @@ public class TrainClassRecordMapper {
         return dept;
     }
 
+    public ResourceDept toDeleteResourceDept(TrainClassRecordRequest record) {
+        ResourceDept dept = new ResourceDept();
+        dept.setDept_id(record.getClassId());
+        dept.setTenantId(ObjectUtil.defaultIfEmpty(record.getTenantId(), defaultConfig.getTenantId()));
+        dept.setOperatorId(DefaultConstants.FULL_SYNC_ADMIN);
+        dept.setDelFlag(record.getDelFlag());
+        return dept;
+    }
+
     private String resolveSemester(String semester) {
         if ("1".equals(semester)) {
             return "上学期";

+ 21 - 1
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/mapper/TraineeRecordMapper.java

@@ -27,7 +27,7 @@ public class TraineeRecordMapper {
         person.setSex(resolveSex(student.getSex()));
         person.setPhone(student.getMobile());
         person.setIdNumber(student.getIdNumber());
-        person.setDeptId(student.getClassId());
+        person.setDeptId(resolveClassId(student, classStudent));
         person.setDelFlag(student.getDelFlag());
         person.setCategory(DefaultConstants.CATEGORY_TRAINEE);
         person.setPostCode(DefaultConstants.TRAINEE_CODE);
@@ -45,6 +45,19 @@ public class TraineeRecordMapper {
         return person;
     }
 
+    public ResourcePerson toDeleteResourcePerson(TraineeRecordRequest record) {
+        ResourcePerson person = new ResourcePerson();
+        TraineeRecordRequest.Student student = record.getStudent();
+        TraineeRecordRequest.ClassStudent classStudent = record.getClassStudent();
+
+        person.setUserId(student.getStudentId());
+        person.setDeptId(resolveClassId(student, classStudent));
+        person.setOperatorId(DefaultConstants.FULL_SYNC_ADMIN);
+        person.setTenantId(ObjectUtil.defaultIfEmpty(record.getTenantId(), defaultConfig.getTenantId()));
+        person.setDelFlag(student.getDelFlag());
+        return person;
+    }
+
     private String resolveSex(String sex) {
         if ("1".equals(sex)) {
             return "0";
@@ -58,4 +71,11 @@ public class TraineeRecordMapper {
     private String resolveDeptDelFlag(String delFlag) {
         return "1".equals(delFlag) ? "2" : "0";
     }
+
+    private String resolveClassId(TraineeRecordRequest.Student student, TraineeRecordRequest.ClassStudent classStudent) {
+        if (ObjectUtil.isNotEmpty(student.getClassId())) {
+            return student.getClassId();
+        }
+        return classStudent == null ? null : classStudent.getClassId();
+    }
 }

+ 42 - 0
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/config/SyncHmacSignatureConfig.java

@@ -0,0 +1,42 @@
+package org.dromara.server.sync.config;
+
+import jakarta.servlet.DispatcherType;
+import org.dromara.server.sync.filter.CachedBodyFilter;
+import org.dromara.server.sync.interceptor.HmacSignatureAuthInterceptor;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * HMAC-SHA256 signature authentication configuration for sync receive APIs.
+ */
+@Configuration
+@EnableConfigurationProperties(SyncHmacSignatureProperties.class)
+public class SyncHmacSignatureConfig implements WebMvcConfigurer {
+
+    private final SyncHmacSignatureProperties properties;
+
+    public SyncHmacSignatureConfig(SyncHmacSignatureProperties properties) {
+        this.properties = properties;
+    }
+
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(new HmacSignatureAuthInterceptor(properties))
+            .addPathPatterns("/api/sync/receive/**");
+    }
+
+    @Bean
+    public FilterRegistrationBean<CachedBodyFilter> syncCachedBodyFilter() {
+        FilterRegistrationBean<CachedBodyFilter> registration = new FilterRegistrationBean<>();
+        registration.setDispatcherTypes(DispatcherType.REQUEST);
+        registration.setFilter(new CachedBodyFilter());
+        registration.addUrlPatterns("/api/sync/receive/*");
+        registration.setName("syncCachedBodyFilter");
+        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE + 1);
+        return registration;
+    }
+}

+ 55 - 0
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/config/SyncHmacSignatureProperties.java

@@ -0,0 +1,55 @@
+package org.dromara.server.sync.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * HMAC-SHA256 signature authentication settings for sync receive APIs.
+ */
+@Data
+@ConfigurationProperties(prefix = "sync.hmac-signature")
+public class SyncHmacSignatureProperties {
+
+    /**
+     * Whether HMAC signature authentication is enabled.
+     */
+    private boolean enabled = true;
+
+    /**
+     * Allowed timestamp clock skew in seconds.
+     */
+    private long timestampToleranceSeconds = 300;
+
+    /**
+     * API key header name.
+     */
+    private String apiKeyHeader = "X-Api-Key";
+
+    /**
+     * Timestamp header name.
+     */
+    private String timestampHeader = "X-Timestamp";
+
+    /**
+     * Nonce header name.
+     */
+    private String nonceHeader = "X-Nonce";
+
+    /**
+     * Signature header name.
+     */
+    private String signatureHeader = "X-Signature";
+
+    /**
+     * Signature algorithm header name.
+     */
+    private String algorithmHeader = "X-Signature-Algorithm";
+
+    /**
+     * Supported clients. key = apiKey, value = apiSecret.
+     */
+    private Map<String, String> clients = new HashMap<>();
+}

+ 128 - 15
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/controller/SyncReceiveController.java

@@ -1,20 +1,35 @@
 package org.dromara.server.sync.controller;
 
 import cn.dev33.satoken.annotation.SaIgnore;
+import cn.hutool.core.util.StrUtil;
+import jakarta.servlet.http.HttpServletRequest;
 import lombok.RequiredArgsConstructor;
 import org.dromara.common.core.domain.R;
-import org.dromara.server.sync.business.batch.SectionBatchSyncService;
-import org.dromara.server.sync.business.batch.TrainBatchSyncService;
-import org.dromara.server.sync.business.batch.TraineeBatchSyncService;
+import org.dromara.common.encrypt.annotation.HmacSignatureAuth;
+import org.dromara.common.encrypt.utils.EncryptUtils;
+import org.dromara.server.common.constant.SyncResourceConstants;
+import org.dromara.server.sync.business.batch.CommonBatchSyncService;
+import org.dromara.server.sync.config.SyncHmacSignatureProperties;
 import org.dromara.server.sync.domain.dto.request.*;
 import org.dromara.server.sync.domain.dto.result.BatchSyncResult;
 import org.dromara.server.sync.domain.dto.wrapper.BatchSyncRequest;
+import org.springframework.http.MediaType;
 import org.springframework.validation.annotation.Validated;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.UUID;
+
 /**
  * @ClassName SyncReceiveController
  * @Description 同步接收推送数据控制器
@@ -30,20 +45,76 @@ import org.springframework.web.bind.annotation.RestController;
 @SaIgnore
 public class SyncReceiveController {
 
-    private final TrainBatchSyncService trainBatchSyncService;
-    private final TraineeBatchSyncService traineeBatchSyncService;
-    private final SectionBatchSyncService sectionBatchSyncService;
+    private static final String SYNC_TRAINEE_API_KEY = "sync-jw";
+    private static final String SYNC_TRAINEE_PATH = "/api/sync/receive/trainee";
+    private static final String HMAC_ALGORITHM = "HMAC-SHA256";
+
+    private final CommonBatchSyncService commonBatchSyncService;
+    private final SyncHmacSignatureProperties hmacSignatureProperties;
 
+    /**
+     * 同步培训班班级数据
+     * @param request 批量同步请求参数
+     * @return 同步结果
+    */
     @PostMapping("/classes")
-    public R<Void> syncTrainClass(@RequestBody BatchSyncRequest<TrainClassRecordRequest> request) {
-        trainBatchSyncService.syncTrainClass(request);
-        return R.ok();
+    @HmacSignatureAuth
+    public R<BatchSyncResult> syncTrainClass(@RequestBody BatchSyncRequest<TrainClassRecordRequest> request) {
+        if (request != null) {
+            request.setBusinessType(SyncResourceConstants.TRAIN_CLASS);
+        }
+        return R.ok(commonBatchSyncService.sync(request));
     }
 
+    /**
+     * 同步学员数据
+     * @param request 批量同步请求参数
+     * @return 同步结果
+     */
     @PostMapping("/trainee")
-    public R<Void> syncTrainee(@RequestBody BatchSyncRequest<TraineeRecordRequest> request) {
-        traineeBatchSyncService.syncTrainee(request);
-        return R.ok();
+    @HmacSignatureAuth
+    public R<BatchSyncResult> syncTrainee(@RequestBody BatchSyncRequest<TraineeRecordRequest> request) {
+        if (request != null) {
+            request.setBusinessType(SyncResourceConstants.TRAINEE);
+        }
+        return R.ok(commonBatchSyncService.sync(request));
+    }
+
+    /**
+     * 模拟请求端签名调用同步学员接口。
+     *
+     * @param servletRequest 当前请求
+     * @return 同步学员接口响应
+     */
+    @PostMapping("/trainee/mock-call")
+    public R<String> mockSyncTraineeSignedCall(HttpServletRequest servletRequest) throws Exception {
+        String apiSecret = hmacSignatureProperties.getClients().get(SYNC_TRAINEE_API_KEY);
+        if (StrUtil.isBlank(apiSecret)) {
+            return R.fail("请先在 sync.hmac-signature.clients 中为测试 API Key 配置真实 apiSecret");
+        }
+
+        String body = buildMockTraineeBody();
+        String timestamp = ZonedDateTime.now(ZoneId.of("Asia/Shanghai")).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);
+        String nonce = UUID.randomUUID().toString().replace("-", "");
+        String canonical = "POST" + "\n" + SYNC_TRAINEE_PATH + "\n" + timestamp + "\n" + nonce + "\n" + body;
+        String signature = EncryptUtils.hmacSha256Hex(canonical, apiSecret);
+
+        String baseUrl = servletRequest.getScheme() + "://" + servletRequest.getServerName() + ":" + servletRequest.getServerPort()
+            + servletRequest.getContextPath();
+        HttpRequest request = HttpRequest.newBuilder()
+            .uri(URI.create(baseUrl + SYNC_TRAINEE_PATH))
+            .header("Content-Type", MediaType.APPLICATION_JSON_VALUE + "; charset=UTF-8")
+            .header("Accept", MediaType.APPLICATION_JSON_VALUE)
+            .header(hmacSignatureProperties.getApiKeyHeader(), SYNC_TRAINEE_API_KEY)
+            .header(hmacSignatureProperties.getTimestampHeader(), timestamp)
+            .header(hmacSignatureProperties.getNonceHeader(), nonce)
+            .header(hmacSignatureProperties.getAlgorithmHeader(), HMAC_ALGORITHM)
+            .header(hmacSignatureProperties.getSignatureHeader(), signature)
+            .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
+            .build();
+
+        HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+        return R.ok("status=" + response.statusCode() + ", body=" + response.body());
     }
 
     /**
@@ -52,17 +123,59 @@ public class SyncReceiveController {
      * @return 同步结果
      */
     @PostMapping("/sections")
+    @HmacSignatureAuth
     public R<BatchSyncResult> syncSections(@RequestBody BatchSyncRequest<SectionRecordRequest> request) {
-        return R.ok(sectionBatchSyncService.syncSections(request));
+        if (request != null) {
+            request.setBusinessType(SyncResourceConstants.SECTION);
+        }
+        return R.ok(commonBatchSyncService.sync(request));
     }
 
+    /**
+     * 同步课程数据
+     * @param request 批量同步请求参数
+     * @return 同步结果
+     */
     @PostMapping("/train/course")
     public R<Void> syncCourse(@RequestBody BatchSyncRequest<CourseRecordRequest> request) {
         return R.ok();
     }
 
+    /**
+     * 同步教室数据
+      * @param request 批量同步请求参数
+      * @return 同步结果
+     */
     @PostMapping("/train/classroom")
-    public R<Void> syncClassroom(@RequestBody BatchSyncRequest<ClassroomRecordRequest> request) {
-        return R.ok();
+    @HmacSignatureAuth
+    public R<BatchSyncResult> syncClassroom(@RequestBody BatchSyncRequest<ClassroomRecordRequest> request) {
+        if (request != null) {
+            request.setBusinessType(SyncResourceConstants.CLASSROOM);
+        }
+        return R.ok(commonBatchSyncService.sync(request));
+    }
+
+    private String buildMockTraineeBody() {
+        return """
+            {
+              "records": [
+                {
+                  "student": {
+                    "studentId": "TRAINEE_TEST_OK_011",
+                    "studentName": "测试学员正常011",
+                    "sex": "1",
+                    "mobile": "13800000101",
+                    "idNumber": "",
+                    "classId": "TRAIN_CLASS_TEST_005",
+                    "delFlag": "0"
+                  },
+                  "classStudent": {
+                    "studentId": "TRAINEE_TEST_OK_011",
+                    "classId": "TRAIN_CLASS_TEST_005",
+                    "delFlag": "0"
+                  },
+                  "tenantId": "25"
+                }]}
+            """;
     }
 }

+ 8 - 0
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/domain/dto/request/ClassroomRecordRequest.java

@@ -10,4 +10,12 @@ public class ClassroomRecordRequest implements Serializable {
 
     @Serial
     private static final long serialVersionUID = 1L;
+
+    private String roomId;
+    private String roomCode;
+    private String roomName;
+    private Integer capacity;
+    private String codeOne;
+    private Integer delFlag;
+    private String tenantId;
 }

+ 26 - 0
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/domain/dto/result/BatchChunkSyncResult.java

@@ -0,0 +1,26 @@
+package org.dromara.server.sync.domain.dto.result;
+
+import lombok.Data;
+import org.dromara.server.sync.domain.SyncBatchErrorRecord;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 单个分片执行后的汇总结果。
+ */
+@Data
+public class BatchChunkSyncResult implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    private Integer total;
+
+    private Integer success;
+
+    private Integer failed;
+
+    private List<SyncBatchErrorRecord> errors;
+}

+ 2 - 0
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/domain/dto/wrapper/BatchSyncRequest.java

@@ -12,5 +12,7 @@ public class BatchSyncRequest<T extends Serializable> implements Serializable {
     @Serial
     private static final long serialVersionUID = 1L;
 
+    private String businessType;
+
     private List<T> records;
 }

+ 32 - 0
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/filter/CachedBodyFilter.java

@@ -0,0 +1,32 @@
+package org.dromara.server.sync.filter;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.http.HttpMethod;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+/**
+ * Wraps sync receive requests so the signature interceptor and controller can both read the body.
+ */
+public class CachedBodyFilter extends OncePerRequestFilter {
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+        throws ServletException, IOException {
+        if (requiresCachedBody(request)) {
+            filterChain.doFilter(new CachedBodyHttpServletRequest(request), response);
+            return;
+        }
+        filterChain.doFilter(request, response);
+    }
+
+    private boolean requiresCachedBody(HttpServletRequest request) {
+        return HttpMethod.POST.matches(request.getMethod())
+            || HttpMethod.PUT.matches(request.getMethod())
+            || HttpMethod.PATCH.matches(request.getMethod());
+    }
+}

+ 76 - 0
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/filter/CachedBodyHttpServletRequest.java

@@ -0,0 +1,76 @@
+package org.dromara.server.sync.filter;
+
+import cn.hutool.core.io.IoUtil;
+import jakarta.servlet.ReadListener;
+import jakarta.servlet.ServletInputStream;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletRequestWrapper;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Request wrapper that allows the request body to be read more than once.
+ */
+public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
+
+    private final byte[] body;
+
+    public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException {
+        super(request);
+        this.body = IoUtil.readBytes(request.getInputStream(), false);
+    }
+
+    public String getBodyAsString() {
+        return new String(body, resolveCharset());
+    }
+
+    @Override
+    public ServletInputStream getInputStream() {
+        ByteArrayInputStream inputStream = new ByteArrayInputStream(body);
+        return new ServletInputStream() {
+            @Override
+            public boolean isFinished() {
+                return inputStream.available() == 0;
+            }
+
+            @Override
+            public boolean isReady() {
+                return true;
+            }
+
+            @Override
+            public void setReadListener(ReadListener listener) {
+            }
+
+            @Override
+            public int read() {
+                return inputStream.read();
+            }
+        };
+    }
+
+    @Override
+    public BufferedReader getReader() {
+        return new BufferedReader(new InputStreamReader(getInputStream(), resolveCharset()));
+    }
+
+    @Override
+    public int getContentLength() {
+        return body.length;
+    }
+
+    @Override
+    public long getContentLengthLong() {
+        return body.length;
+    }
+
+    private Charset resolveCharset() {
+        String encoding = getCharacterEncoding();
+        return encoding == null ? StandardCharsets.UTF_8 : Charset.forName(encoding);
+    }
+}

+ 130 - 0
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/interceptor/HmacSignatureAuthInterceptor.java

@@ -0,0 +1,130 @@
+package org.dromara.server.sync.interceptor;
+
+import cn.hutool.core.util.StrUtil;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.encrypt.annotation.HmacSignatureAuth;
+import org.dromara.common.encrypt.utils.EncryptUtils;
+import org.dromara.server.sync.config.SyncHmacSignatureProperties;
+import org.dromara.server.sync.filter.CachedBodyHttpServletRequest;
+import org.springframework.http.MediaType;
+import org.springframework.web.method.HandlerMethod;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeParseException;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Verifies API Key + HMAC-SHA256 signatures for annotated sync APIs.
+ */
+@RequiredArgsConstructor
+public class HmacSignatureAuthInterceptor implements HandlerInterceptor {
+
+    private static final String ALGORITHM = "HMAC-SHA256";
+
+    private final SyncHmacSignatureProperties properties;
+    private final Map<String, Long> nonceCache = new ConcurrentHashMap<>();
+
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
+        if (!(handler instanceof HandlerMethod handlerMethod) || !requiresAuth(handlerMethod)) {
+            return true;
+        }
+        if (!properties.isEnabled()) {
+            return true;
+        }
+
+        String apiKey = request.getHeader(properties.getApiKeyHeader());
+        String timestamp = request.getHeader(properties.getTimestampHeader());
+        String nonce = request.getHeader(properties.getNonceHeader());
+        String signature = request.getHeader(properties.getSignatureHeader());
+        String algorithm = request.getHeader(properties.getAlgorithmHeader());
+
+        if (StrUtil.hasBlank(apiKey, timestamp, nonce, signature)) {
+            writeUnauthorized(response, "Missing HMAC signature headers");
+            return false;
+        }
+        if (StrUtil.isNotBlank(algorithm) && !ALGORITHM.equalsIgnoreCase(algorithm)) {
+            writeUnauthorized(response, "Unsupported signature algorithm");
+            return false;
+        }
+
+        String secret = properties.getClients().get(apiKey);
+        if (StrUtil.isBlank(secret)) {
+            writeUnauthorized(response, "Invalid API key");
+            return false;
+        }
+        if (!isTimestampValid(timestamp)) {
+            writeUnauthorized(response, "Invalid or expired timestamp");
+            return false;
+        }
+        if (!acceptNonce(apiKey, nonce)) {
+            writeUnauthorized(response, "Duplicate nonce");
+            return false;
+        }
+
+        String body = resolveBody(request);
+        String canonical = request.getMethod().toUpperCase()
+            + "\n" + resolvePath(request)
+            + "\n" + timestamp
+            + "\n" + nonce
+            + "\n" + body;
+        if (!EncryptUtils.verifyHmacSha256Hex(canonical, secret, signature.toLowerCase())) {
+            writeUnauthorized(response, "Invalid signature");
+            return false;
+        }
+        return true;
+    }
+
+    private boolean requiresAuth(HandlerMethod handlerMethod) {
+        return handlerMethod.hasMethodAnnotation(HmacSignatureAuth.class)
+            || handlerMethod.getBeanType().isAnnotationPresent(HmacSignatureAuth.class);
+    }
+
+    private boolean isTimestampValid(String timestamp) {
+        try {
+            Instant requestTime = OffsetDateTime.parse(timestamp).toInstant();
+            long diffSeconds = Math.abs(Duration.between(requestTime, Instant.now()).getSeconds());
+            return diffSeconds <= properties.getTimestampToleranceSeconds();
+        } catch (DateTimeParseException e) {
+            return false;
+        }
+    }
+
+    private boolean acceptNonce(String apiKey, String nonce) {
+        long now = Instant.now().getEpochSecond();
+        long expireBefore = now - properties.getTimestampToleranceSeconds();
+        nonceCache.entrySet().removeIf(entry -> entry.getValue() < expireBefore);
+        return nonceCache.putIfAbsent(apiKey + ":" + nonce, now) == null;
+    }
+
+    private String resolveBody(HttpServletRequest request) {
+        if (request instanceof CachedBodyHttpServletRequest cachedRequest) {
+            return cachedRequest.getBodyAsString();
+        }
+        return "";
+    }
+
+    private String resolvePath(HttpServletRequest request) {
+        String uri = request.getRequestURI();
+        String contextPath = request.getContextPath();
+        if (StrUtil.isNotBlank(contextPath) && uri.startsWith(contextPath)) {
+            return uri.substring(contextPath.length());
+        }
+        return uri;
+    }
+
+    private void writeUnauthorized(HttpServletResponse response, String message) throws IOException {
+        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+        response.getWriter().write("{\"code\":401,\"msg\":\"" + message + "\"}");
+    }
+}

+ 33 - 0
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/strategy/dept/impl/TrainClassStrategyImpl.java

@@ -8,6 +8,7 @@ import org.apache.dubbo.config.annotation.DubboReference;
 import org.dromara.common.core.constant.DefaultConstants;
 import org.dromara.common.core.domain.R;
 import org.dromara.common.core.domain.model.ErrorInfo;
+import org.dromara.common.core.exception.ServiceException;
 import org.dromara.common.core.utils.StringUtils;
 import org.dromara.common.json.utils.JsonUtils;
 import org.dromara.hotel.api.domain.bo.RemoteTeamBo;
@@ -44,6 +45,38 @@ public class TrainClassStrategyImpl implements ISyncDeptStrategy {
     @DubboReference
     private final RemoteTeamService remoteTeamService;
 
+    /**
+     * 同步单个培训班信息,先同步一卡通部门,再同步酒店团客。
+     *
+     * @param resourceDept 培训班部门同步数据
+     */
+    public void syncTrainClass(ResourceDept resourceDept) {
+        R<Void> deptResult = syncDept(resourceDept);
+        if (R.isError(deptResult)) {
+            throw new ServiceException(deptResult.getMsg());
+        }
+        R<ErrorInfo> teamResult = syncTeam(resourceDept);
+        if (R.isError(teamResult)) {
+            throw new ServiceException(teamResult.getMsg());
+        }
+    }
+
+    /**
+     * 删除单个培训班信息,先删除一卡通部门,再删除酒店团客。
+     *
+     * @param resourceDept 培训班部门同步数据
+     */
+    public void deleteTrainClass(ResourceDept resourceDept) {
+        Boolean deptResult = deptService.deleteByOtherId(resourceDept.getDept_id(), resourceDept.getTenantId());
+        if (!Boolean.TRUE.equals(deptResult)) {
+            throw new ServiceException("同步删除培训班失败");
+        }
+        Boolean teamResult = remoteTeamService.deleteTeamByOtherId(resourceDept.getDept_id(), resourceDept.getOperatorId(), resourceDept.getTenantId());
+        if (!Boolean.TRUE.equals(teamResult)) {
+            throw new ServiceException("同步删除团客失败");
+        }
+    }
+
     /**
      * 同步同步培训班信息(增加、修改)
      *

+ 33 - 0
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/strategy/user/impl/SyncTraineeStrategyImpl.java

@@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.dromara.common.core.domain.R;
 import org.dromara.common.core.domain.model.ErrorInfo;
+import org.dromara.common.core.exception.ServiceException;
 import org.dromara.common.json.utils.JsonUtils;
 import org.dromara.server.common.constant.SyncResourceConstants;
 import org.dromara.server.common.domain.bo.ResourcePerson;
@@ -32,6 +33,38 @@ import java.util.List;
 public class SyncTraineeStrategyImpl implements ISyncUserStrategy {
     private final SyncUserBusiness syncUserBusiness;
 
+    /**
+     * 同步单个培训学员信息,先同步一卡通人员,再同步酒店客人。
+     *
+     * @param person 学员同步数据
+     */
+    public void syncTrainee(ResourcePerson person) {
+        R<SysUserVo> userResult = syncUserBusiness.syncPeron(person);
+        if (R.isError(userResult)) {
+            throw new ServiceException(userResult.getMsg());
+        }
+        R<ErrorInfo> guestResult = syncUserBusiness.syncGuest(person);
+        if (R.isError(guestResult)) {
+            throw new ServiceException(guestResult.getMsg());
+        }
+    }
+
+    /**
+     * 删除单个培训学员信息,先删除一卡通人员,再删除酒店客人。
+     *
+     * @param person 学员同步数据
+     */
+    public void deleteTrainee(ResourcePerson person) {
+        R<ErrorInfo> userResult = syncUserBusiness.syncDelPeron(person);
+        if (R.isError(userResult)) {
+            throw new ServiceException(userResult.getMsg());
+        }
+        R<ErrorInfo> guestResult = syncUserBusiness.syncDeleteGuest(person);
+        if (R.isError(guestResult)) {
+            throw new ServiceException(guestResult.getMsg());
+        }
+    }
+
     /**
      * 同步人员(增加、修改)
      * @param persons 源人员列表

+ 112 - 0
ruoyi-server/ruoyi-server-sync/src/test/java/org/dromara/server/sync/demo/SyncTraineeSignedRequestDemo.java

@@ -0,0 +1,112 @@
+package org.dromara.server.sync.demo;
+
+import org.dromara.common.encrypt.utils.EncryptUtils;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.UUID;
+
+/**
+ * syncTrainee API Key + HMAC-SHA256 request demo.
+ *
+ * <p>Signature rule:
+ * <pre>
+ * signature = HMAC-SHA256(apiSecret, method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + body)
+ * </pre>
+ *
+ * <p>Headers:
+ * <pre>
+ * X-Api-Key: API Key
+ * X-Timestamp: ISO-8601 time in Asia/Shanghai
+ * X-Nonce: random string
+ * X-Signature-Algorithm: HMAC-SHA256
+ * X-Signature: lowercase hex signature
+ * </pre>
+ */
+public class SyncTraineeSignedRequestDemo {
+
+    private static final String API_KEY = "sync-jw";
+    private static final String API_SECRET = "jwapi-t3E9qrMRn74ClaahRo6F3WjJOgCBu1F3i39diSvoYT0uJX5ZPh1Pl6SWPNDcjYxe";
+    private static final String BASE_URL = "https://example.com";
+    private static final String PATH = "/api/sync/receive/trainee";
+    private static final String METHOD = "POST";
+
+    public static void main(String[] args) throws Exception {
+        String body = buildTraineeBody();
+        String timestamp = ZonedDateTime.now(java.time.ZoneId.of("Asia/Shanghai"))
+            .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);
+        String nonce = UUID.randomUUID().toString().replace("-", "");
+        String signature = sign(METHOD, PATH, timestamp, nonce, body, API_SECRET);
+
+        HttpRequest request = HttpRequest.newBuilder()
+            .uri(URI.create(BASE_URL + PATH))
+            .header("Content-Type", "application/json; charset=UTF-8")
+            .header("Accept", "application/json")
+            .header("X-Api-Key", API_KEY)
+            .header("X-Timestamp", timestamp)
+            .header("X-Nonce", nonce)
+            .header("X-Signature-Algorithm", "HMAC-SHA256")
+            .header("X-Signature", signature)
+            .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
+            .build();
+
+        HttpResponse<String> response = HttpClient.newHttpClient()
+            .send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+
+        System.out.println("status=" + response.statusCode());
+        System.out.println("body=" + response.body());
+    }
+
+    private static String buildTraineeBody() {
+        return """
+            {
+              "records": [
+                {
+                  "student": {
+                    "studentId": "TRAINEE_TEST_OK_001",
+                    "studentName": "测试学员正常001",
+                    "sex": "1",
+                    "mobile": "13800000101",
+                    "idNumber": "",
+                    "classId": "TRAIN_CLASS_TEST_005",
+                    "delFlag": "0"
+                  },
+                  "classStudent": {
+                    "studentId": "TRAINEE_TEST_OK_001",
+                    "classId": "TRAIN_CLASS_TEST_005",
+                    "delFlag": "0"
+                  },
+                  "tenantId": "25"
+                },
+                {
+                  "student": {
+                    "studentId": "TRAINEE_TEST_BAD_CLASS_001",
+                    "studentName": "测试学员班级错误001",
+                    "sex": "2",
+                    "mobile": "13800000102",
+                    "idNumber": "",
+                    "classId": "TRAIN_CLASS_NOT_EXISTS_001",
+                    "delFlag": "0"
+                  },
+                  "classStudent": {
+                    "studentId": "TRAINEE_TEST_BAD_CLASS_001",
+                    "classId": "TRAIN_CLASS_NOT_EXISTS_001",
+                    "delFlag": "0"
+                  },
+                  "tenantId": "25"
+                }
+              ]
+            }
+            """;
+    }
+
+    private static String sign(String method, String path, String timestamp, String nonce, String body, String apiSecret) throws Exception {
+        String canonical = method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + body;
+        return EncryptUtils.hmacSha256Hex(canonical, apiSecret);
+    }
+}