Browse Source

refactor: 重构同步服务并引入批量同步追踪机制

- 移除 `SyncDataController` 控制器
- 新增 `BatchSyncResult`, `RemoteBatchSyncErrorDto`, `RemoteSectionSyncBatchDto` 等DTO类
- 创建 `t_sys_record` 和 `t_sync_error` 表用于记录同步批次和错误信息
- 添加 `SyncBatchTrackerService` 服务,实现同步批次的启动、错误记录和完成处理
- 更新 `SectionBatchSyncService` 以支持新的同步逻辑和错误处理
- 引入相关依赖和配置文件更新
yubo 1 month ago
parent
commit
b79994a38f
30 changed files with 899 additions and 171 deletions
  1. 7 5
      ruoyi-api/ruoyi-api-ecs/src/main/java/org/dromara/ecs/api/RemoteSectionSyncService.java
  2. 19 0
      ruoyi-api/ruoyi-api-ecs/src/main/java/org/dromara/ecs/api/domain/dto/RemoteBatchSyncErrorDto.java
  3. 2 0
      ruoyi-api/ruoyi-api-ecs/src/main/java/org/dromara/ecs/api/domain/dto/RemoteBatchSyncResultDto.java
  4. 19 0
      ruoyi-api/ruoyi-api-ecs/src/main/java/org/dromara/ecs/api/domain/dto/RemoteSectionSyncBatchDto.java
  5. 3 3
      ruoyi-modules/ruoyi-backstage/src/main/java/org/dromara/backstage/business/self/TraineeBusiness.java
  6. 0 54
      ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/controller/sync/SyncDataController.java
  7. 19 0
      ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/domain/dto/sync/SyncSectionBatchDTO.java
  8. 19 0
      ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/domain/dto/sync/SyncSectionBatchErrorDTO.java
  9. 23 0
      ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/domain/dto/sync/SyncSectionBatchResultDTO.java
  10. 2 0
      ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/domain/dto/sync/SyncSectionConverter.java
  11. 50 13
      ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/dubbo/RemoteSectionSyncServiceImpl.java
  12. 7 0
      ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/mapper/EcsSectionMapper.java
  13. 10 0
      ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/service/IEcsSectionService.java
  14. 9 4
      ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/service/impl/EcsSectionServiceImpl.java
  15. 5 11
      ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/service/sync/ISyncSectionService.java
  16. 81 59
      ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/service/sync/SyncSectionServiceImpl.java
  17. 141 19
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/SectionBatchSyncService.java
  18. 111 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/SyncBatchTrackerService.java
  19. 9 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/mapper/SectionRecordMapper.java
  20. 3 3
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/controller/SyncReceiveController.java
  21. 37 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/domain/SyncBatchErrorRecord.java
  22. 50 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/domain/SyncBatchRecord.java
  23. 23 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/domain/dto/result/BatchSyncResult.java
  24. 32 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/domain/vo/SyncBatchErrorRecordVo.java
  25. 38 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/domain/vo/SyncBatchRecordVo.java
  26. 8 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/mapper/SyncBatchErrorRecordMapper.java
  27. 11 0
      ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/mapper/SyncBatchRecordMapper.java
  28. 62 0
      sql/update/kingbase/sync_batch_record.sql
  29. 64 0
      sql/update/postgres/sync_batch_record.sql
  30. 35 0
      sql/update/sync_batch_record.sql

+ 7 - 5
ruoyi-api/ruoyi-api-ecs/src/main/java/org/dromara/ecs/api/RemoteSectionSyncService.java

@@ -2,13 +2,15 @@ package org.dromara.ecs.api;
 
 import org.dromara.common.core.domain.R;
 import org.dromara.ecs.api.domain.dto.RemoteBatchSyncResultDto;
-import org.dromara.ecs.api.domain.dto.RemoteSectionSyncDto;
+import org.dromara.ecs.api.domain.dto.RemoteSectionSyncBatchDto;
 
 import java.util.List;
 
 public interface RemoteSectionSyncService {
-
-    R<RemoteBatchSyncResultDto> syncSections(List<RemoteSectionSyncDto> records);
-
-    R<Void> syncSection(RemoteSectionSyncDto record);
+    /**
+     * 批量同步课表数据
+     * @param records 课表数据列表
+     * @return 同步结果
+     */
+    R<RemoteBatchSyncResultDto> syncSectionBatch(List<RemoteSectionSyncBatchDto> records);
 }

+ 19 - 0
ruoyi-api/ruoyi-api-ecs/src/main/java/org/dromara/ecs/api/domain/dto/RemoteBatchSyncErrorDto.java

@@ -0,0 +1,19 @@
+package org.dromara.ecs.api.domain.dto;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+@Data
+public class RemoteBatchSyncErrorDto implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    private Integer recordIndex;
+
+    private String bizKey;
+
+    private String errorMessage;
+}

+ 2 - 0
ruoyi-api/ruoyi-api-ecs/src/main/java/org/dromara/ecs/api/domain/dto/RemoteBatchSyncResultDto.java

@@ -4,6 +4,7 @@ import lombok.Data;
 
 import java.io.Serial;
 import java.io.Serializable;
+import java.util.List;
 
 @Data
 public class RemoteBatchSyncResultDto implements Serializable {
@@ -14,4 +15,5 @@ public class RemoteBatchSyncResultDto implements Serializable {
     private Integer total;
     private Integer success;
     private Integer failed;
+    private List<RemoteBatchSyncErrorDto> errors;
 }

+ 19 - 0
ruoyi-api/ruoyi-api-ecs/src/main/java/org/dromara/ecs/api/domain/dto/RemoteSectionSyncBatchDto.java

@@ -0,0 +1,19 @@
+package org.dromara.ecs.api.domain.dto;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+@Data
+public class RemoteSectionSyncBatchDto implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    private String batchId;
+
+    private Integer startIndex;
+
+    private RemoteSectionSyncDto record;
+}

+ 3 - 3
ruoyi-modules/ruoyi-backstage/src/main/java/org/dromara/backstage/business/self/TraineeBusiness.java

@@ -201,11 +201,11 @@ public class TraineeBusiness {
             ycTraineeVo.setRoomCardData(cardData);
             // 发送报到短信(有房间)
             // TODO [2026/4/28][luoyibo][P0-当日内完成]: 因调试需要被注释,需要在正式环境中打开
-            selfBusiness.sendSmsHasRoom(ycTraineeVo.getMobilePhone(), ycTraineeVo.getUserXm(), remoteDeptVo.getDeptName(), roomCode);
+            // selfBusiness.sendSmsHasRoom(ycTraineeVo.getMobilePhone(), ycTraineeVo.getUserXm(), remoteDeptVo.getDeptName(), roomCode);
         } else {
             // 发送短信,暂无房间
             // TODO [2026/4/28][luoyibo][P0-当日内完成]: 因调试需要被注释,需要在正式环境中打开
-            selfBusiness.sendSmsNoRoom(ycTraineeVo.getMobilePhone(), ycTraineeVo.getUserXm(), remoteDeptVo.getDeptName());
+            // selfBusiness.sendSmsNoRoom(ycTraineeVo.getMobilePhone(), ycTraineeVo.getUserXm(), remoteDeptVo.getDeptName());
         }
         setCheckInfoStatus(bo, ycTraineeVo);
         // 培训班级信息
@@ -217,7 +217,7 @@ public class TraineeBusiness {
 
         // 发送报到的kafka消息
         // TODO [2026/4/28][luoyibo][P0-当日内完成]: 因调试需要被注释,需要在正式环境中打开
-        sendCheckInMessageToKafka(bo);
+        // sendCheckInMessageToKafka(bo);
         //写发卡记录表
         //insertCardData(ycTraineeVo);
         return R.ok();

+ 0 - 54
ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/controller/sync/SyncDataController.java

@@ -1,54 +0,0 @@
-package org.dromara.ecs.controller.sync;
-
-import cn.dev33.satoken.annotation.SaIgnore;
-import lombok.RequiredArgsConstructor;
-import org.dromara.common.core.domain.R;
-import org.dromara.ecs.domain.dto.sync.SyncSectionDTO;
-import org.dromara.ecs.domain.dto.wrapper.SyncDataWrapper;
-import org.dromara.ecs.service.sync.ISyncSectionService;
-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;
-
-/**
- * 电子班牌数据同步接口
- * 提供给外部系统调用,同步电子班牌数据到本平台
- * 统一使用 {"records": [...]} 格式
- */
-@Validated
-@RequiredArgsConstructor
-@RestController
-@RequestMapping("/sync")
-@SaIgnore
-public class SyncDataController {
-
-    private final ISyncSectionService syncSectionService;
-
-    /**
-     * 接收外部系统同步的课表数据
-     * 接口地址:POST /ecs/sync/section/push
-     * 请求格式:{"records": [{...}, {...}]}
-     */
-    @PostMapping("/section/push")
-    public R<String> pushSections(@RequestBody SyncDataWrapper<SyncSectionDTO> wrapper) {
-        if (wrapper == null || wrapper.getRecords() == null || wrapper.getRecords().isEmpty()) {
-            return R.fail("同步数据不能为空");
-        }
-
-        Integer successCount = syncSectionService.receiveSections(wrapper.getRecords());
-
-        return R.ok("成功处理 " + successCount + " 条课表数据");
-    }
-
-    /**
-     * 接收外部系统同步的单条课表数据
-     * 接口地址:POST /ecs/sync/section/push-one
-     */
-    @PostMapping("/section/push-one")
-    public R<String> pushSection(@RequestBody SyncSectionDTO dto) {
-        Boolean success = syncSectionService.receiveSection(dto);
-        return Boolean.TRUE.equals(success) ? R.ok("课表数据同步成功") : R.fail("课表数据同步失败");
-    }
-}

+ 19 - 0
ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/domain/dto/sync/SyncSectionBatchDTO.java

@@ -0,0 +1,19 @@
+package org.dromara.ecs.domain.dto.sync;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+@Data
+public class SyncSectionBatchDTO implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    private String batchId;
+
+    private Integer recordIndex;
+
+    private SyncSectionDTO record;
+}

+ 19 - 0
ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/domain/dto/sync/SyncSectionBatchErrorDTO.java

@@ -0,0 +1,19 @@
+package org.dromara.ecs.domain.dto.sync;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+@Data
+public class SyncSectionBatchErrorDTO implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    private Integer recordIndex;
+
+    private String sectionId;
+
+    private String errorMessage;
+}

+ 23 - 0
ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/domain/dto/sync/SyncSectionBatchResultDTO.java

@@ -0,0 +1,23 @@
+package org.dromara.ecs.domain.dto.sync;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+@Data
+public class SyncSectionBatchResultDTO implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    private Integer total;
+
+    private Integer success;
+
+    private Integer failed;
+
+    private List<SyncSectionBatchErrorDTO> errors = new ArrayList<>();
+}

+ 2 - 0
ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/domain/dto/sync/SyncSectionConverter.java

@@ -63,6 +63,8 @@ public class SyncSectionConverter {
         // 数据来源:1-外部同步
         bo.setDataSource(1);
 
+        bo.setTenantId(dto.getTenantId());
+
         return bo;
     }
 

+ 50 - 13
ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/dubbo/RemoteSectionSyncServiceImpl.java

@@ -4,8 +4,13 @@ import lombok.RequiredArgsConstructor;
 import org.apache.dubbo.config.annotation.DubboService;
 import org.dromara.common.core.domain.R;
 import org.dromara.ecs.api.RemoteSectionSyncService;
+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.ecs.domain.dto.sync.SyncSectionBatchDTO;
+import org.dromara.ecs.domain.dto.sync.SyncSectionBatchErrorDTO;
+import org.dromara.ecs.domain.dto.sync.SyncSectionBatchResultDTO;
 import org.dromara.ecs.domain.dto.sync.SyncSectionDTO;
 import org.dromara.ecs.service.sync.ISyncSectionService;
 import org.springframework.stereotype.Service;
@@ -19,31 +24,63 @@ public class RemoteSectionSyncServiceImpl implements RemoteSectionSyncService {
 
     private final ISyncSectionService syncSectionService;
 
+    /**
+     * 接收批量课表同步请求,转换后交给同步服务处理并返回批量结果。
+     *
+     * @param records 远端批量课表记录
+     * @return 批量同步结果
+     */
     @Override
-    public R<RemoteBatchSyncResultDto> syncSections(List<RemoteSectionSyncDto> records) {
+    public R<RemoteBatchSyncResultDto> syncSectionBatch(List<RemoteSectionSyncBatchDto> records) {
         if (records == null || records.isEmpty()) {
             return R.fail("同步数据不能为空");
         }
 
-        List<SyncSectionDTO> syncDtos = records.stream().map(this::toSyncSectionDto).toList();
-        Integer successCount = syncSectionService.receiveSections(syncDtos);
+        List<SyncSectionBatchDTO> syncDtos = records.stream().map(this::toSyncSectionBatchDto).toList();
+        SyncSectionBatchResultDTO syncResult = syncSectionService.receiveSectionBatch(syncDtos);
 
         RemoteBatchSyncResultDto result = new RemoteBatchSyncResultDto();
-        result.setTotal(records.size());
-        result.setSuccess(successCount);
-        result.setFailed(records.size() - successCount);
+        result.setTotal(syncResult.getTotal());
+        result.setSuccess(syncResult.getSuccess());
+        result.setFailed(syncResult.getFailed());
+        result.setErrors(syncResult.getErrors().stream().map(this::toRemoteErrorDto).toList());
         return R.ok(result);
     }
 
-    @Override
-    public R<Void> syncSection(RemoteSectionSyncDto record) {
-        if (record == null) {
-            return R.fail("同步数据不能为空");
-        }
-        Boolean success = syncSectionService.receiveSection(toSyncSectionDto(record));
-        return Boolean.TRUE.equals(success) ? R.ok() : R.fail();
+    /**
+     * 将远端批量课表记录转换为内部批量处理对象。
+     *
+     * @param dto 远端批量课表记录
+     * @return 内部批量处理对象
+     */
+    private SyncSectionBatchDTO toSyncSectionBatchDto(RemoteSectionSyncBatchDto dto) {
+        SyncSectionBatchDTO target = new SyncSectionBatchDTO();
+        target.setBatchId(dto.getBatchId());
+        target.setRecordIndex(dto.getStartIndex());
+        target.setRecord(toSyncSectionDto(dto.getRecord()));
+        return target;
+    }
+
+    /**
+     * 将内部失败明细转换为远端批量同步失败明细。
+     *
+     * @param dto 内部失败明细
+     * @return 远端失败明细
+     */
+    private RemoteBatchSyncErrorDto toRemoteErrorDto(SyncSectionBatchErrorDTO dto) {
+        RemoteBatchSyncErrorDto target = new RemoteBatchSyncErrorDto();
+        target.setRecordIndex(dto.getRecordIndex());
+        target.setBizKey(dto.getSectionId());
+        target.setErrorMessage(dto.getErrorMessage());
+        return target;
     }
 
+    /**
+     * 将远端单条课表记录转换为内部课表同步对象。
+     *
+     * @param dto 远端单条课表记录
+     * @return 内部课表同步对象
+     */
     private SyncSectionDTO toSyncSectionDto(RemoteSectionSyncDto dto) {
         SyncSectionDTO target = new SyncSectionDTO();
         target.setSectionId(dto.getSectionId());

+ 7 - 0
ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/mapper/EcsSectionMapper.java

@@ -1,9 +1,13 @@
 package org.dromara.ecs.mapper;
 
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Update;
 import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
 import org.dromara.ecs.domain.EcsSection;
 import org.dromara.ecs.domain.vo.EcsSectionVo;
 
+import java.util.Date;
+
 /**
  * 课表管理Mapper接口
  *
@@ -12,4 +16,7 @@ import org.dromara.ecs.domain.vo.EcsSectionVo;
  */
 public interface EcsSectionMapper extends BaseMapperPlus<EcsSection, EcsSectionVo> {
 
+    @Update("update t_ecs_section set del_flag = #{delFlag}, update_time = #{updateTime} where other_id = #{otherId} and tenant_id = #{tenantId}")
+    int updateDelFlagByOtherId(@Param("otherId") String otherId, @Param("tenantId") String tenantId,
+                               @Param("delFlag") String delFlag, @Param("updateTime") Date updateTime);
 }

+ 10 - 0
ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/service/IEcsSectionService.java

@@ -54,4 +54,14 @@ public interface IEcsSectionService {
      */
     EcsSectionVo queryByOtherId(String otherId);
 
+    /**
+     * 根据第三方标识更新删除标记,包含已删除数据
+     *
+     * @param otherId 第三方课表ID
+     * @param tenantId 租户编号
+     * @param delFlag 删除标记
+     * @return 是否更新成功
+     */
+    Boolean updateDelFlagByOtherId(String otherId, String tenantId, String delFlag);
+
 }

+ 9 - 4
ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/service/impl/EcsSectionServiceImpl.java

@@ -16,10 +16,7 @@ import org.dromara.ecs.mapper.EcsSectionMapper;
 import org.dromara.ecs.service.IEcsSectionService;
 import org.springframework.stereotype.Service;
 
-import java.util.Collection;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
+import java.util.*;
 
 /**
  * 课表管理Service业务层处理
@@ -111,6 +108,14 @@ public class EcsSectionServiceImpl implements IEcsSectionService {
         return baseMapper.selectVoOne(lqw);
     }
 
+    @Override
+    public Boolean updateDelFlagByOtherId(String otherId, String tenantId, String delFlag) {
+        if (StringUtils.isBlank(otherId) || StringUtils.isBlank(tenantId) || StringUtils.isBlank(delFlag)) {
+            return false;
+        }
+        return baseMapper.updateDelFlagByOtherId(otherId, tenantId, delFlag, new Date()) > 0;
+    }
+
     /**
      * 保存前的数据校验
      */

+ 5 - 11
ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/service/sync/ISyncSectionService.java

@@ -1,6 +1,7 @@
 package org.dromara.ecs.service.sync;
 
-import org.dromara.ecs.domain.dto.sync.SyncSectionDTO;
+import org.dromara.ecs.domain.dto.sync.SyncSectionBatchDTO;
+import org.dromara.ecs.domain.dto.sync.SyncSectionBatchResultDTO;
 
 import java.util.List;
 
@@ -11,18 +12,11 @@ import java.util.List;
 public interface ISyncSectionService {
 
     /**
-     * 批量接收外部同步的课表数据
+     * 批量接收外部同步的课表数据,并返回失败明细
      *
      * @param records 课表记录列表
-     * @return 成功处理的数量
+     * @return 批量处理结果
      */
-    Integer receiveSections(List<SyncSectionDTO> records);
+    SyncSectionBatchResultDTO receiveSectionBatch(List<SyncSectionBatchDTO> records);
 
-    /**
-     * 单条接收外部同步的课表数据
-     *
-     * @param dto 课表记录
-     * @return 是否成功
-     */
-    Boolean receiveSection(SyncSectionDTO dto);
 }

+ 81 - 59
ruoyi-modules/ruoyi-ecs/src/main/java/org/dromara/ecs/service/sync/SyncSectionServiceImpl.java

@@ -3,8 +3,7 @@ package org.dromara.ecs.service.sync;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.dromara.ecs.domain.bo.EcsSectionBo;
-import org.dromara.ecs.domain.dto.sync.SyncSectionConverter;
-import org.dromara.ecs.domain.dto.sync.SyncSectionDTO;
+import org.dromara.ecs.domain.dto.sync.*;
 import org.dromara.ecs.domain.vo.EcsSectionVo;
 import org.dromara.ecs.service.IEcsSectionService;
 import org.springframework.stereotype.Service;
@@ -26,90 +25,113 @@ public class SyncSectionServiceImpl implements ISyncSectionService {
     private final IEcsSectionService ecsSectionService;
 
     /**
-     * 批量接收外部同步的课表数据
+     * 批量接收课表数据,先处理 delFlag=1 的删除组,再处理 delFlag=0 的新增修改组,并返回失败明细。
+     *
+     * @param records 带批次序号的课表记录列表
+     * @return 批量处理结果
      */
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public Integer receiveSections(List<SyncSectionDTO> records) {
+    public SyncSectionBatchResultDTO receiveSectionBatch(List<SyncSectionBatchDTO> records) {
+        SyncSectionBatchResultDTO result = new SyncSectionBatchResultDTO();
         if (records == null || records.isEmpty()) {
-            log.warn("同步课表数据为空");
-            return 0;
+            result.setTotal(0);
+            result.setSuccess(0);
+            result.setFailed(0);
+            return result;
         }
 
-        log.info("开始接收同步课表数据,共 {} 条", records.size());
+        List<SyncSectionBatchErrorDTO> errors = new ArrayList<>();
+        int successCount = processSectionBatch(records, errors, true) + processSectionBatch(records, errors, false);
 
-        int successCount = 0;
-        List<String> errors = new ArrayList<>();
+        result.setTotal(records.size());
+        result.setSuccess(successCount);
+        result.setFailed(errors.size());
+        result.setErrors(errors);
+        return result;
+    }
+
+
+    /**
+     * 处理单条课表数据:删除组按 otherId 更新删除标记,非删除组按 otherId 决定新增或修改。
+     *
+     * @param bo 课表业务对象
+     * @return 是否处理成功
+     */
+    private Boolean processSection(EcsSectionBo bo) {
+        if ("1".equals(bo.getDelFlag())) {
+            log.info("按otherId更新课表删除标记,otherId: {}, delFlag: {}", bo.getOtherId(), bo.getDelFlag());
+            ecsSectionService.updateDelFlagByOtherId(bo.getOtherId(), bo.getTenantId(), bo.getDelFlag());
+            return true;
+        }
 
+        EcsSectionVo existing = ecsSectionService.queryByOtherId(bo.getOtherId());
+        if (existing != null) {
+            bo.setSectionId(existing.getSectionId());
+            log.info("更新课表数据,otherId: {}, sectionId: {}", bo.getOtherId(), existing.getSectionId());
+            return ecsSectionService.updateByBo(bo);
+        } else {
+            log.info("新增课表数据,otherId: {}", bo.getOtherId());
+            return ecsSectionService.insertByBo(bo);
+        }
+    }
+
+    /**
+     * 按删除组或新增修改组批量处理带序号的课表记录,并收集失败明细。
+     *
+     * @param records 带批次序号的课表记录列表
+     * @param errors 失败明细集合
+     * @param deleteRecords true 表示处理删除组,false 表示处理新增修改组
+     * @return 本轮成功处理条数
+     */
+    private int processSectionBatch(List<SyncSectionBatchDTO> records, List<SyncSectionBatchErrorDTO> errors, boolean deleteRecords) {
+        int successCount = 0;
         for (int i = 0; i < records.size(); i++) {
-            SyncSectionDTO dto = records.get(i);
+            SyncSectionBatchDTO item = records.get(i);
+            SyncSectionDTO dto = item == null ? null : item.getRecord();
+            if (isDeleteRecord(dto) != deleteRecords) {
+                continue;
+            }
+            Integer recordIndex = item == null || item.getRecordIndex() == null ? i + 1 : item.getRecordIndex();
             try {
-                // 1. DTO转BO
                 EcsSectionBo bo = syncSectionConverter.toBo(dto);
-
-                // 2. 判断是新增还是更新(根据otherId判断)
                 Boolean success = processSection(bo);
-
                 if (success) {
                     successCount++;
                 } else {
-                    errors.add("第" + (i + 1) + "条数据处理失败");
+                    errors.add(error(recordIndex, dto, "课表数据处理失败"));
                 }
             } catch (Exception e) {
-                log.error("处理第{}条课表数据失败: {}", i + 1, e.getMessage(), e);
-                errors.add("第" + (i + 1) + "条数据: " + e.getMessage());
+                log.error("处理第{}条课表数据失败: {}", recordIndex, e.getMessage(), e);
+                errors.add(error(recordIndex, dto, e.getMessage()));
             }
         }
-
-        if (!errors.isEmpty()) {
-            log.warn("同步课表数据处理完成,成功{}条,失败{}条", successCount, errors.size());
-        } else {
-            log.info("同步课表数据全部处理成功,共{}条", successCount);
-        }
-
         return successCount;
     }
 
     /**
-     * 单条接收
+     * 判断记录是否属于删除组。
+     *
+     * @param dto 课表记录
+     * @return 是否为删除记录
      */
-    @Override
-    @Transactional(rollbackFor = Exception.class)
-    public Boolean receiveSection(SyncSectionDTO dto) {
-        EcsSectionBo bo = syncSectionConverter.toBo(dto);
-        return processSection(bo);
+    private boolean isDeleteRecord(SyncSectionDTO dto) {
+        return dto != null && Integer.valueOf(1).equals(dto.getDelFlag());
     }
 
     /**
-     * 处理单条课表数据(删除、更新或新增)
+     * 构造批量处理失败明细对象。
+     *
+     * @param recordIndex 原始记录序号
+     * @param dto 原始课表记录
+     * @param errorMessage 错误信息
+     * @return 失败明细对象
      */
-    private Boolean processSection(EcsSectionBo bo) {
-        // 如果delFlag=1,直接设置删除标志
-        if ("1".equals(bo.getDelFlag())) {
-            // 根据otherId查询是否存在
-            EcsSectionVo existing = ecsSectionService.queryByOtherId(bo.getOtherId());
-            if (existing != null) {
-                // 存在则执行逻辑删除
-                log.info("删除课表数据,otherId: {}", bo.getOtherId());
-                return ecsSectionService.deleteWithValidByIds(List.of(existing.getSectionId()), false);
-            } else {
-                // 不存在则无需处理
-                log.warn("尝试删除不存在的课表数据,otherId: {}", bo.getOtherId());
-                return true;
-            }
-        }
-        
-        // delFlag=0,根据otherId查询是否已存在
-        EcsSectionVo existing = ecsSectionService.queryByOtherId(bo.getOtherId());
-        if (existing != null) {
-            // 存在则更新
-            bo.setSectionId(existing.getSectionId());
-            log.info("更新课表数据,otherId: {}, sectionId: {}", bo.getOtherId(), existing.getSectionId());
-            return ecsSectionService.updateByBo(bo);
-        } else {
-            // 不存在则新增
-            log.info("新增课表数据,otherId: {}", bo.getOtherId());
-            return ecsSectionService.insertByBo(bo);
-        }
+    private SyncSectionBatchErrorDTO error(Integer recordIndex, SyncSectionDTO dto, String errorMessage) {
+        SyncSectionBatchErrorDTO error = new SyncSectionBatchErrorDTO();
+        error.setRecordIndex(recordIndex);
+        error.setSectionId(dto == null ? null : dto.getSectionId());
+        error.setErrorMessage(errorMessage);
+        return error;
     }
 }

+ 141 - 19
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/SectionBatchSyncService.java

@@ -8,15 +8,19 @@ import org.dromara.common.core.domain.R;
 import org.dromara.common.core.exception.ServiceException;
 import org.dromara.common.core.utils.StringUtils;
 import org.dromara.ecs.api.RemoteSectionSyncService;
+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.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.BatchSyncResult;
 import org.dromara.server.sync.domain.dto.wrapper.BatchSyncRequest;
 import org.springframework.stereotype.Service;
 
+import java.util.ArrayList;
 import java.util.List;
-import java.util.UUID;
 
 /**
  * @ClassName SectionBatchSyncService
@@ -32,41 +36,149 @@ import java.util.UUID;
 @RequiredArgsConstructor
 public class SectionBatchSyncService {
 
+    private static final String API_NAME = "sections";
+    private static final String RESOURCE_TYPE = "SECTION";
+
     private final SectionRecordMapper sectionRecordMapper;
+    private final SyncBatchTrackerService syncBatchTrackerService;
 
     @DubboReference
     private RemoteSectionSyncService remoteSectionSyncService;
 
-    public void syncSections(BatchSyncRequest<SectionRecordRequest> request) {
+    /**
+     * 批量同步课表节次,完成参数校验、远端调用、错误归集和批次结果汇总。
+     *
+     * @param request 批量课表节次请求
+     * @return 批次同步结果
+     */
+    public BatchSyncResult syncSections(BatchSyncRequest<SectionRecordRequest> request) {
         if (request == null || CollUtil.isEmpty(request.getRecords())) {
             throw new ServiceException("records 不能为空");
         }
 
-        request.getRecords().forEach(this::validateRecord);
-        String batchId = UUID.randomUUID().toString().replace("-", "");
-        List<RemoteSectionSyncDto> records = request.getRecords().stream().map(sectionRecordMapper::toRemoteDto).toList();
+        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()));
+                }
+            }
+        }
 
-        String tenantId = records.stream().map(RemoteSectionSyncDto::getTenantId).filter(StringUtils::isNotBlank).findFirst().orElse("default");
-        String sectionIds = records.stream().map(RemoteSectionSyncDto::getSectionId).filter(StringUtils::isNotBlank).limit(10).reduce((a, b) -> a + "," + b).orElse("");
+        // 持久化错误明细并结束批次,最终返回汇总结果。
+        syncBatchTrackerService.saveErrors(errors);
+        int errorCount = errors.size();
+        syncBatchTrackerService.finish(batchId, requestRecords.size(), successCount, errorCount);
 
-        log.info("[batch-sync-sections-start]-[batchId:{}]-[tenant:{}]-[total:{}]-[sampleSectionIds:{}]",
-            batchId, tenantId, records.size(), sectionIds);
+        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;
+    }
 
-        R<RemoteBatchSyncResultDto> result = remoteSectionSyncService.syncSections(records);
-        if (R.isError(result)) {
-            log.error("[batch-sync-sections-fail]-[batchId:{}]-[msg:{}]", batchId, result.getMsg());
-            throw new ServiceException(result.getMsg());
+    /**
+     * 将远端返回的失败明细归集到本地批次错误列表。
+     *
+     * @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;
         }
 
-        RemoteBatchSyncResultDto data = result.getData();
-        if (data != null) {
-            log.info("[batch-sync-sections-done]-[batchId:{}]-[total:{}]-[成功:{}]-[失败:{}]",
-                batchId, data.getTotal(), data.getSuccess(), data.getFailed());
-        } else {
-            log.info("[batch-sync-sections-done]-[batchId:{}]-[total:{}]-[远端未返回统计]", batchId, request.getRecords().size());
+        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()));
         }
     }
 
+    /**
+     * 根据总数、成功数和失败数计算批次状态。
+     *
+     * @param total 请求总数
+     * @param success 成功数
+     * @param error 失败数
+     * @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;
+    }
+
+    /**
+     * 校验单条课表节次记录的必填字段。
+     *
+     * @param record 单条课表节次记录
+     */
     private void validateRecord(SectionRecordRequest record) {
         if (record == null) {
             throw new ServiceException("record 不能为空");
@@ -75,4 +187,14 @@ public class SectionBatchSyncService {
             throw new ServiceException("sectionId 不能为空");
         }
     }
+
+    /**
+     * 提取记录的业务主键用于错误定位。
+     *
+     * @param record 单条课表节次记录
+     * @return 业务主键
+     */
+    private String getBizKey(SectionRecordRequest record) {
+        return record == null ? null : record.getSectionId();
+    }
 }

+ 111 - 0
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/business/batch/SyncBatchTrackerService.java

@@ -0,0 +1,111 @@
+package org.dromara.server.sync.business.batch;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.IdUtil;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.json.utils.JsonUtils;
+import org.dromara.server.sync.domain.SyncBatchErrorRecord;
+import org.dromara.server.sync.domain.SyncBatchRecord;
+import org.dromara.server.sync.mapper.SyncBatchErrorRecordMapper;
+import org.dromara.server.sync.mapper.SyncBatchRecordMapper;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class SyncBatchTrackerService {
+
+    public static final String STATUS_PROCESSING = "PROCESSING";
+    public static final String STATUS_SUCCESS = "SUCCESS";
+    public static final String STATUS_PARTIAL_FAIL = "PARTIAL_FAIL";
+    public static final String STATUS_FAIL = "FAIL";
+    public static final String STATUS_COUNT_MISMATCH = "COUNT_MISMATCH";
+
+    public static final String ERROR_CODE_VALIDATION = "VALIDATION_ERROR";
+    public static final String ERROR_CODE_BIZ = "BIZ_ERROR";
+
+    private static final int FAIL_RECORD_BATCH_SIZE = 200;
+
+    private final SyncBatchRecordMapper syncBatchRecordMapper;
+    private final SyncBatchErrorRecordMapper syncBatchErrorRecordMapper;
+
+    public String start(String apiName, String resourceType, String tenantId, Object request, int requestTotal) {
+        String batchId = IdUtil.fastSimpleUUID();
+        Date now = new Date();
+
+        SyncBatchRecord record = new SyncBatchRecord();
+        record.setBatchId(batchId);
+        record.setApiName(apiName);
+        record.setResourceType(resourceType);
+        record.setTenantId(tenantId);
+        record.setRequestPayload(JsonUtils.toJsonString(request));
+        record.setRequestTotal(requestTotal);
+        record.setSuccessCount(0);
+        record.setErrorCount(0);
+        record.setStatus(STATUS_PROCESSING);
+        record.setStartTime(now);
+        syncBatchRecordMapper.insert(record);
+        return batchId;
+    }
+
+    public SyncBatchErrorRecord error(String batchId, String resourceType, int recordIndex, String bizKey, String errorCode,
+                                      String errorMessage, Object rawRecord) {
+        SyncBatchErrorRecord record = new SyncBatchErrorRecord();
+        record.setBatchId(batchId);
+        record.setResourceType(resourceType);
+        record.setRecordIndex(recordIndex);
+        record.setBizKey(bizKey);
+        record.setErrorCode(errorCode);
+        record.setErrorMessage(errorMessage);
+        record.setRawRecordJson(JsonUtils.toJsonString(rawRecord));
+        return record;
+    }
+
+    public void saveErrors(List<SyncBatchErrorRecord> errors) {
+        if (CollUtil.isEmpty(errors)) {
+            return;
+        }
+        partition(errors, FAIL_RECORD_BATCH_SIZE).forEach(syncBatchErrorRecordMapper::insertBatch);
+    }
+
+    public void finish(String batchId, int requestTotal, int successCount, int errorCount) {
+        Date endTime = new Date();
+        SyncBatchRecord existing = syncBatchRecordMapper.selectByBatchId(batchId);
+        String status;
+        if (requestTotal != successCount + errorCount) {
+            status = STATUS_COUNT_MISMATCH;
+            log.warn("[sync-batch-count-mismatch]-[batchId:{}]-[requestTotal:{}]-[success:{}]-[error:{}]",
+                batchId, requestTotal, successCount, errorCount);
+        } else if (errorCount == 0) {
+            status = STATUS_SUCCESS;
+        } else if (successCount == 0) {
+            status = STATUS_FAIL;
+        } else {
+            status = STATUS_PARTIAL_FAIL;
+        }
+
+        SyncBatchRecord record = new SyncBatchRecord();
+        record.setBatchId(batchId);
+        record.setSuccessCount(successCount);
+        record.setErrorCount(errorCount);
+        record.setStatus(status);
+        record.setEndTime(endTime);
+        if (existing != null && existing.getStartTime() != null) {
+            record.setCostMs(endTime.getTime() - existing.getStartTime().getTime());
+        }
+        syncBatchRecordMapper.updateById(record);
+    }
+
+    private static <T> List<List<T>> partition(List<T> source, int size) {
+        List<List<T>> result = new ArrayList<>();
+        for (int i = 0; i < source.size(); i += size) {
+            result.add(source.subList(i, Math.min(i + size, source.size())));
+        }
+        return result;
+    }
+}

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

@@ -1,5 +1,6 @@
 package org.dromara.server.sync.business.batch.mapper;
 
+import org.dromara.ecs.api.domain.dto.RemoteSectionSyncBatchDto;
 import org.dromara.ecs.api.domain.dto.RemoteSectionSyncDto;
 import org.dromara.server.sync.domain.dto.request.SectionRecordRequest;
 import org.springframework.stereotype.Component;
@@ -32,4 +33,12 @@ public class SectionRecordMapper {
         dto.setTenantId(record.getTenantId());
         return dto;
     }
+
+    public RemoteSectionSyncBatchDto toBatchDto(String batchId, int recordIndex, SectionRecordRequest record) {
+        RemoteSectionSyncBatchDto dto = new RemoteSectionSyncBatchDto();
+        dto.setBatchId(batchId);
+        dto.setStartIndex(recordIndex);
+        dto.setRecord(toRemoteDto(record));
+        return dto;
+    }
 }

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

@@ -7,6 +7,7 @@ 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.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.validation.annotation.Validated;
 import org.springframework.web.bind.annotation.PostMapping;
@@ -51,9 +52,8 @@ public class SyncReceiveController {
      * @return 同步结果
      */
     @PostMapping("/sections")
-    public R<Void> syncSections(@RequestBody BatchSyncRequest<SectionRecordRequest> request) {
-        sectionBatchSyncService.syncSections(request);
-        return R.ok();
+    public R<BatchSyncResult> syncSections(@RequestBody BatchSyncRequest<SectionRecordRequest> request) {
+        return R.ok(sectionBatchSyncService.syncSections(request));
     }
 
     @PostMapping("/train/course")

+ 37 - 0
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/domain/SyncBatchErrorRecord.java

@@ -0,0 +1,37 @@
+package org.dromara.server.sync.domain;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.tenant.core.TenantEntity;
+
+@Data
+@TableName("t_sync_error")
+@EqualsAndHashCode(callSuper = true)
+public class SyncBatchErrorRecord extends TenantEntity {
+
+    @TableId(value = "id")
+    private Long id;
+
+    private String batchId;
+
+    private String resourceType;
+
+    private Integer recordIndex;
+
+    private String bizKey;
+
+    private String errorCode;
+
+    private String errorMessage;
+
+    private String rawRecordJson;
+
+    /**
+     * 删除标志(0-未删除 2-已删除)
+     */
+    @TableLogic
+    private String delFlag;
+}

+ 50 - 0
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/domain/SyncBatchRecord.java

@@ -0,0 +1,50 @@
+package org.dromara.server.sync.domain;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.tenant.core.TenantEntity;
+
+import java.util.Date;
+
+@Data
+@TableName("t_sync_record")
+@EqualsAndHashCode(callSuper = true)
+public class SyncBatchRecord extends TenantEntity {
+    /**
+     * 主键
+     */
+    @TableId(value = "id")
+    private Long id;
+
+    private String batchId;
+
+    private String apiName;
+
+    private String resourceType;
+
+    private String tenantId;
+
+    private String requestPayload;
+
+    private Integer requestTotal;
+
+    private Integer successCount;
+
+    private Integer errorCount;
+
+    private String status;
+
+    private Date startTime;
+
+    private Date endTime;
+
+    private Long costMs;
+    /**
+     * 删除标志(0-未删除 2-已删除)
+     */
+    @TableLogic
+    private String delFlag;
+}

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

@@ -0,0 +1,23 @@
+package org.dromara.server.sync.domain.dto.result;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+@Data
+public class BatchSyncResult implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    private String batchId;
+
+    private Integer total;
+
+    private Integer success;
+
+    private Integer error;
+
+    private String status;
+}

+ 32 - 0
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/domain/vo/SyncBatchErrorRecordVo.java

@@ -0,0 +1,32 @@
+package org.dromara.server.sync.domain.vo;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+public class SyncBatchErrorRecordVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    private String id;
+
+    private String batchId;
+
+    private String resourceType;
+
+    private Integer recordIndex;
+
+    private String bizKey;
+
+    private String errorCode;
+
+    private String errorMessage;
+
+    private String rawRecordJson;
+
+    private Date createdTime;
+}

+ 38 - 0
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/domain/vo/SyncBatchRecordVo.java

@@ -0,0 +1,38 @@
+package org.dromara.server.sync.domain.vo;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+public class SyncBatchRecordVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    private String batchId;
+
+    private String apiName;
+
+    private String resourceType;
+
+    private String tenantId;
+
+    private String requestPayload;
+
+    private Integer requestTotal;
+
+    private Integer successCount;
+
+    private Integer errorCount;
+
+    private String status;
+
+    private Date startTime;
+
+    private Date endTime;
+
+    private Long costMs;
+}

+ 8 - 0
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/mapper/SyncBatchErrorRecordMapper.java

@@ -0,0 +1,8 @@
+package org.dromara.server.sync.mapper;
+
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.server.sync.domain.SyncBatchErrorRecord;
+import org.dromara.server.sync.domain.vo.SyncBatchErrorRecordVo;
+
+public interface SyncBatchErrorRecordMapper extends BaseMapperPlus<SyncBatchErrorRecord, SyncBatchErrorRecordVo> {
+}

+ 11 - 0
ruoyi-server/ruoyi-server-sync/src/main/java/org/dromara/server/sync/mapper/SyncBatchRecordMapper.java

@@ -0,0 +1,11 @@
+package org.dromara.server.sync.mapper;
+
+import org.apache.ibatis.annotations.Select;
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.server.sync.domain.SyncBatchRecord;
+import org.dromara.server.sync.domain.vo.SyncBatchRecordVo;
+
+public interface SyncBatchRecordMapper extends BaseMapperPlus<SyncBatchRecord, SyncBatchRecordVo> {
+    @Select("select * from t_sync_record where batch_id = #{batchId}")
+    SyncBatchRecord selectByBatchId(String batchId);
+}

+ 62 - 0
sql/update/kingbase/sync_batch_record.sql

@@ -0,0 +1,62 @@
+-- syncSections 批次追踪表
+CREATE TABLE "dbo"."sync_batch_record" (
+    "batch_id" character varying(64 char) NOT NULL,
+    "api_name" character varying(64 char) NULL,
+    "resource_type" character varying(32 char) NULL,
+    "tenant_id" character varying(20 char) NULL,
+    "request_payload" text NULL,
+    "request_total" integer NULL DEFAULT 0,
+    "success_count" integer NULL DEFAULT 0,
+    "error_count" integer NULL DEFAULT 0,
+    "status" character varying(32 char) NULL,
+    "start_time" timestamp NULL,
+    "end_time" timestamp NULL,
+    "cost_ms" bigint NULL DEFAULT 0,
+    CONSTRAINT "sync_batch_record_pkey" PRIMARY KEY (batch_id)
+);
+
+CREATE INDEX "idx_sync_batch_record_resource" ON "dbo"."sync_batch_record" ("resource_type");
+CREATE INDEX "idx_sync_batch_record_status" ON "dbo"."sync_batch_record" ("status");
+CREATE INDEX "idx_sync_batch_record_start_time" ON "dbo"."sync_batch_record" ("start_time");
+
+ALTER TABLE "dbo"."sync_batch_record" COMMENT '同步批次记录';
+ALTER TABLE "dbo"."sync_batch_record" MODIFY "batch_id" COMMENT '批次ID';
+ALTER TABLE "dbo"."sync_batch_record" MODIFY "api_name" COMMENT '同步接口名称';
+ALTER TABLE "dbo"."sync_batch_record" MODIFY "resource_type" COMMENT '同步资源类型';
+ALTER TABLE "dbo"."sync_batch_record" MODIFY "tenant_id" COMMENT '租户编号';
+ALTER TABLE "dbo"."sync_batch_record" MODIFY "request_payload" COMMENT '完整原始请求JSON';
+ALTER TABLE "dbo"."sync_batch_record" MODIFY "request_total" COMMENT '请求记录数';
+ALTER TABLE "dbo"."sync_batch_record" MODIFY "success_count" COMMENT '成功记录数';
+ALTER TABLE "dbo"."sync_batch_record" MODIFY "error_count" COMMENT '异常记录数';
+ALTER TABLE "dbo"."sync_batch_record" MODIFY "status" COMMENT '批次状态';
+ALTER TABLE "dbo"."sync_batch_record" MODIFY "start_time" COMMENT '开始时间';
+ALTER TABLE "dbo"."sync_batch_record" MODIFY "end_time" COMMENT '结束时间';
+ALTER TABLE "dbo"."sync_batch_record" MODIFY "cost_ms" COMMENT '处理耗时毫秒';
+
+CREATE TABLE "dbo"."sync_batch_error_record" (
+    "id" character varying(64 char) NOT NULL,
+    "batch_id" character varying(64 char) NOT NULL,
+    "resource_type" character varying(32 char) NULL,
+    "record_index" integer NULL,
+    "biz_key" character varying(128 char) NULL,
+    "error_code" character varying(64 char) NULL,
+    "error_message" character varying(2000 char) NULL,
+    "raw_record_json" text NULL,
+    "created_time" timestamp NULL,
+    CONSTRAINT "sync_batch_error_record_pkey" PRIMARY KEY (id)
+);
+
+CREATE INDEX "idx_sync_batch_error_record_batch" ON "dbo"."sync_batch_error_record" ("batch_id");
+CREATE INDEX "idx_sync_batch_error_record_biz" ON "dbo"."sync_batch_error_record" ("biz_key");
+CREATE INDEX "idx_sync_batch_error_record_resource" ON "dbo"."sync_batch_error_record" ("resource_type");
+
+ALTER TABLE "dbo"."sync_batch_error_record" COMMENT '同步批次失败明细';
+ALTER TABLE "dbo"."sync_batch_error_record" MODIFY "id" COMMENT '主键';
+ALTER TABLE "dbo"."sync_batch_error_record" MODIFY "batch_id" COMMENT '批次ID';
+ALTER TABLE "dbo"."sync_batch_error_record" MODIFY "resource_type" COMMENT '同步资源类型';
+ALTER TABLE "dbo"."sync_batch_error_record" MODIFY "record_index" COMMENT '原始请求记录序号';
+ALTER TABLE "dbo"."sync_batch_error_record" MODIFY "biz_key" COMMENT '业务主键';
+ALTER TABLE "dbo"."sync_batch_error_record" MODIFY "error_code" COMMENT '错误编码';
+ALTER TABLE "dbo"."sync_batch_error_record" MODIFY "error_message" COMMENT '错误信息';
+ALTER TABLE "dbo"."sync_batch_error_record" MODIFY "raw_record_json" COMMENT '失败记录原始JSON';
+ALTER TABLE "dbo"."sync_batch_error_record" MODIFY "created_time" COMMENT '创建时间';

+ 64 - 0
sql/update/postgres/sync_batch_record.sql

@@ -0,0 +1,64 @@
+-- syncSections 批次追踪表
+create table if not exists sync_batch_record
+(
+    batch_id        varchar(64) not null,
+    api_name        varchar(64),
+    resource_type   varchar(32),
+    tenant_id       varchar(20),
+    request_payload text,
+    request_total   int4 default 0,
+    success_count   int4 default 0,
+    error_count     int4 default 0,
+    status          varchar(32),
+    start_time      timestamp,
+    end_time        timestamp,
+    cost_ms         int8 default 0,
+    constraint sync_batch_record_pk primary key (batch_id)
+);
+
+create index idx_sync_batch_record_resource on sync_batch_record (resource_type);
+create index idx_sync_batch_record_status on sync_batch_record (status);
+create index idx_sync_batch_record_start_time on sync_batch_record (start_time);
+
+comment on table sync_batch_record is '同步批次记录';
+comment on column sync_batch_record.batch_id is '批次ID';
+comment on column sync_batch_record.api_name is '同步接口名称';
+comment on column sync_batch_record.resource_type is '同步资源类型';
+comment on column sync_batch_record.tenant_id is '租户编号';
+comment on column sync_batch_record.request_payload is '完整原始请求JSON';
+comment on column sync_batch_record.request_total is '请求记录数';
+comment on column sync_batch_record.success_count is '成功记录数';
+comment on column sync_batch_record.error_count is '异常记录数';
+comment on column sync_batch_record.status is '批次状态';
+comment on column sync_batch_record.start_time is '开始时间';
+comment on column sync_batch_record.end_time is '结束时间';
+comment on column sync_batch_record.cost_ms is '处理耗时毫秒';
+
+create table if not exists sync_batch_error_record
+(
+    id              varchar(64) not null,
+    batch_id        varchar(64) not null,
+    resource_type   varchar(32),
+    record_index    int4,
+    biz_key         varchar(128),
+    error_code      varchar(64),
+    error_message   varchar(2000),
+    raw_record_json text,
+    created_time    timestamp,
+    constraint sync_batch_error_record_pk primary key (id)
+);
+
+create index idx_sync_batch_error_record_batch on sync_batch_error_record (batch_id);
+create index idx_sync_batch_error_record_biz on sync_batch_error_record (biz_key);
+create index idx_sync_batch_error_record_resource on sync_batch_error_record (resource_type);
+
+comment on table sync_batch_error_record is '同步批次失败明细';
+comment on column sync_batch_error_record.id is '主键';
+comment on column sync_batch_error_record.batch_id is '批次ID';
+comment on column sync_batch_error_record.resource_type is '同步资源类型';
+comment on column sync_batch_error_record.record_index is '原始请求记录序号';
+comment on column sync_batch_error_record.biz_key is '业务主键';
+comment on column sync_batch_error_record.error_code is '错误编码';
+comment on column sync_batch_error_record.error_message is '错误信息';
+comment on column sync_batch_error_record.raw_record_json is '失败记录原始JSON';
+comment on column sync_batch_error_record.created_time is '创建时间';

+ 35 - 0
sql/update/sync_batch_record.sql

@@ -0,0 +1,35 @@
+-- syncSections 批次追踪表
+CREATE TABLE `sync_batch_record` (
+    `batch_id` varchar(64) NOT NULL COMMENT '批次ID',
+    `api_name` varchar(64) NULL COMMENT '同步接口名称',
+    `resource_type` varchar(32) NULL COMMENT '同步资源类型',
+    `tenant_id` varchar(20) NULL COMMENT '租户编号',
+    `request_payload` longtext NULL COMMENT '完整原始请求JSON',
+    `request_total` int NULL DEFAULT 0 COMMENT '请求记录数',
+    `success_count` int NULL DEFAULT 0 COMMENT '成功记录数',
+    `error_count` int NULL DEFAULT 0 COMMENT '异常记录数',
+    `status` varchar(32) NULL COMMENT '批次状态',
+    `start_time` datetime NULL COMMENT '开始时间',
+    `end_time` datetime NULL COMMENT '结束时间',
+    `cost_ms` bigint NULL DEFAULT 0 COMMENT '处理耗时毫秒',
+    PRIMARY KEY (`batch_id`),
+    KEY `idx_sync_batch_record_resource` (`resource_type`),
+    KEY `idx_sync_batch_record_status` (`status`),
+    KEY `idx_sync_batch_record_start_time` (`start_time`)
+) COMMENT='同步批次记录';
+
+CREATE TABLE `sync_batch_error_record` (
+    `id` varchar(64) NOT NULL COMMENT '主键',
+    `batch_id` varchar(64) NOT NULL COMMENT '批次ID',
+    `resource_type` varchar(32) NULL COMMENT '同步资源类型',
+    `record_index` int NULL COMMENT '原始请求记录序号',
+    `biz_key` varchar(128) NULL COMMENT '业务主键',
+    `error_code` varchar(64) NULL COMMENT '错误编码',
+    `error_message` varchar(2000) NULL COMMENT '错误信息',
+    `raw_record_json` longtext NULL COMMENT '失败记录原始JSON',
+    `created_time` datetime NULL COMMENT '创建时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_sync_batch_error_record_batch` (`batch_id`),
+    KEY `idx_sync_batch_error_record_biz` (`biz_key`),
+    KEY `idx_sync_batch_error_record_resource` (`resource_type`)
+) COMMENT='同步批次失败明细';