2 Ревизии 8d372b5641 ... f0f17cda34

Автор SHA1 Съобщение Дата
  ht f0f17cda34 Merge remote-tracking branch 'origin/master' преди 1 ден
  ht cc5279db5f 接口开发11.4 преди 1 ден

+ 3 - 0
README.md

@@ -190,6 +190,9 @@ Content-Type: application/json
 
 精确查询优先级为 `dept_id`、`user_id`、`dept_name`。传入 `user_id` 时,服务端会先查询人员所属部门,再按部门 ID 查询主数据。
 
+审批矩阵查询仅读取本地表 `yida_approval_matrix_cache`,不再在接口请求期间调用宜搭。部署前需执行
+`sql/yida_approval_matrix_cache.sql`;应用会在每天 `02:00`(`Asia/Shanghai`)从宜搭审批矩阵表全量同步数据。同步写入采用事务替换:拉取、校验或写入失败时保留上一批缓存。首次成功同步前,审批矩阵查询不会返回负责人配置。
+
 ### 4.3 部门成员查询
 
 ```http

+ 29 - 0
src/main/java/com/malk/minglei/dto/ApprovalMatrixSyncResult.java

@@ -0,0 +1,29 @@
+package com.malk.minglei.dto;
+
+/**
+ * 审批矩阵缓存同步结果。
+ */
+public class ApprovalMatrixSyncResult {
+
+    private final int recordCount;
+    private final int syncedCount;
+
+    /**
+     * 创建审批矩阵缓存同步结果。
+     *
+     * @param recordCount 从宜搭读取的有效记录数
+     * @param syncedCount 写入本地缓存的记录数
+     */
+    public ApprovalMatrixSyncResult(int recordCount, int syncedCount) {
+        this.recordCount = recordCount;
+        this.syncedCount = syncedCount;
+    }
+
+    public int getRecordCount() {
+        return recordCount;
+    }
+
+    public int getSyncedCount() {
+        return syncedCount;
+    }
+}

+ 47 - 0
src/main/java/com/malk/minglei/service/impl/yida/ApprovalMatrixCacheRecord.java

@@ -0,0 +1,47 @@
+package com.malk.minglei.service.impl.yida;
+
+import java.time.LocalDateTime;
+
+/**
+ * 宜搭审批矩阵本地缓存记录。
+ */
+public class ApprovalMatrixCacheRecord {
+
+    private Long departmentId;
+    private String departmentName;
+    private String applicationCompany;
+    private String firstLevelManagerUserIds;
+    private String secondLevelManagerUserIds;
+    private String thirdLevelManagerUserIds;
+    private String fourthLevelManagerUserIds;
+    private String departmentLeaderUserIds;
+    private String yidaFormInstanceId;
+    private String sourceFormData;
+    private String syncBatchId;
+    private LocalDateTime syncedAt;
+
+    public Long getDepartmentId() { return departmentId; }
+    public void setDepartmentId(Long departmentId) { this.departmentId = departmentId; }
+    public String getDepartmentName() { return departmentName; }
+    public void setDepartmentName(String departmentName) { this.departmentName = departmentName; }
+    public String getApplicationCompany() { return applicationCompany; }
+    public void setApplicationCompany(String applicationCompany) { this.applicationCompany = applicationCompany; }
+    public String getFirstLevelManagerUserIds() { return firstLevelManagerUserIds; }
+    public void setFirstLevelManagerUserIds(String firstLevelManagerUserIds) { this.firstLevelManagerUserIds = firstLevelManagerUserIds; }
+    public String getSecondLevelManagerUserIds() { return secondLevelManagerUserIds; }
+    public void setSecondLevelManagerUserIds(String secondLevelManagerUserIds) { this.secondLevelManagerUserIds = secondLevelManagerUserIds; }
+    public String getThirdLevelManagerUserIds() { return thirdLevelManagerUserIds; }
+    public void setThirdLevelManagerUserIds(String thirdLevelManagerUserIds) { this.thirdLevelManagerUserIds = thirdLevelManagerUserIds; }
+    public String getFourthLevelManagerUserIds() { return fourthLevelManagerUserIds; }
+    public void setFourthLevelManagerUserIds(String fourthLevelManagerUserIds) { this.fourthLevelManagerUserIds = fourthLevelManagerUserIds; }
+    public String getDepartmentLeaderUserIds() { return departmentLeaderUserIds; }
+    public void setDepartmentLeaderUserIds(String departmentLeaderUserIds) { this.departmentLeaderUserIds = departmentLeaderUserIds; }
+    public String getYidaFormInstanceId() { return yidaFormInstanceId; }
+    public void setYidaFormInstanceId(String yidaFormInstanceId) { this.yidaFormInstanceId = yidaFormInstanceId; }
+    public String getSourceFormData() { return sourceFormData; }
+    public void setSourceFormData(String sourceFormData) { this.sourceFormData = sourceFormData; }
+    public String getSyncBatchId() { return syncBatchId; }
+    public void setSyncBatchId(String syncBatchId) { this.syncBatchId = syncBatchId; }
+    public LocalDateTime getSyncedAt() { return syncedAt; }
+    public void setSyncedAt(LocalDateTime syncedAt) { this.syncedAt = syncedAt; }
+}

+ 156 - 0
src/main/java/com/malk/minglei/service/impl/yida/ApprovalMatrixCacheRepository.java

@@ -0,0 +1,156 @@
+package com.malk.minglei.service.impl.yida;
+
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.jdbc.core.ConnectionCallback;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.util.List;
+
+/**
+ * 宜搭审批矩阵本地缓存的数据访问组件。
+ */
+@Repository
+public class ApprovalMatrixCacheRepository {
+
+    private static final String TABLE_NAME = "yida_approval_matrix_cache";
+    private final ObjectProvider<JdbcTemplate> jdbcTemplateProvider;
+
+    public ApprovalMatrixCacheRepository(@Qualifier("employeeRosterJdbcTemplate") ObjectProvider<JdbcTemplate> jdbcTemplateProvider) {
+        this.jdbcTemplateProvider = jdbcTemplateProvider;
+    }
+
+    public ApprovalMatrixCacheRecord findByDepartmentId(String departmentId) {
+        return findOne("department_id = ?", departmentId);
+    }
+
+    public ApprovalMatrixCacheRecord findByDepartmentName(String departmentName) {
+        return findOne("department_name = ?", departmentName);
+    }
+
+    public List<ApprovalMatrixCacheRecord> findPage(int page, int size) {
+        int offset = (page - 1) * size;
+        return jdbcTemplate().query("SELECT " + columns() + " FROM " + TABLE_NAME
+                        + " ORDER BY department_id LIMIT ? OFFSET ?",
+                statement -> {
+                    statement.setInt(1, size);
+                    statement.setInt(2, offset);
+                }, (resultSet, rowNum) -> mapRow(resultSet));
+    }
+
+    public int count() {
+        Integer count = jdbcTemplate().queryForObject("SELECT COUNT(*) FROM " + TABLE_NAME, Integer.class);
+        return count == null ? 0 : count.intValue();
+    }
+
+    /**
+     * 以一次成功拉取的全量数据原子替换本地缓存。写入异常时保留上一次完整缓存。
+     */
+    public int replaceAll(List<ApprovalMatrixCacheRecord> records, String syncBatchId) {
+        if (records == null || records.isEmpty()) {
+            throw new IllegalArgumentException("Approval matrix records must not be empty");
+        }
+        return jdbcTemplate().execute((ConnectionCallback<Integer>) connection -> replaceAll(connection, records, syncBatchId));
+    }
+
+    private int replaceAll(Connection connection, List<ApprovalMatrixCacheRecord> records, String syncBatchId) throws SQLException {
+        boolean originalAutoCommit = connection.getAutoCommit();
+        try {
+            connection.setAutoCommit(false);
+            try (PreparedStatement upsert = connection.prepareStatement(createUpsertSql())) {
+                for (ApprovalMatrixCacheRecord record : records) {
+                    bind(upsert, record, syncBatchId);
+                    upsert.addBatch();
+                }
+                upsert.executeBatch();
+            }
+            try (PreparedStatement delete = connection.prepareStatement("DELETE FROM " + TABLE_NAME + " WHERE sync_batch_id <> ?")) {
+                delete.setString(1, syncBatchId);
+                delete.executeUpdate();
+            }
+            connection.commit();
+            return records.size();
+        } catch (SQLException exception) {
+            connection.rollback();
+            throw exception;
+        } finally {
+            connection.setAutoCommit(originalAutoCommit);
+        }
+    }
+
+    private ApprovalMatrixCacheRecord findOne(String whereClause, String value) {
+        List<ApprovalMatrixCacheRecord> records = jdbcTemplate().query("SELECT " + columns() + " FROM " + TABLE_NAME
+                        + " WHERE " + whereClause + " ORDER BY id LIMIT 1",
+                statement -> statement.setString(1, value), (resultSet, rowNum) -> mapRow(resultSet));
+        return records.isEmpty() ? null : records.get(0);
+    }
+
+    private JdbcTemplate jdbcTemplate() {
+        JdbcTemplate jdbcTemplate = jdbcTemplateProvider.getIfAvailable();
+        if (jdbcTemplate == null) {
+            throw new IllegalStateException("MINGLEI_EMPLOYEE_ROSTER_DATASOURCE_URL is not configured");
+        }
+        return jdbcTemplate;
+    }
+
+    private String columns() {
+        return "department_id, department_name, application_company, first_level_manager_user_ids, "
+                + "second_level_manager_user_ids, third_level_manager_user_ids, fourth_level_manager_user_ids, "
+                + "department_leader_user_ids, yida_form_instance_id, source_form_data, sync_batch_id, synced_at";
+    }
+
+    private String createUpsertSql() {
+        return "INSERT INTO " + TABLE_NAME + " (department_id, department_name, application_company, "
+                + "first_level_manager_user_ids, second_level_manager_user_ids, third_level_manager_user_ids, "
+                + "fourth_level_manager_user_ids, department_leader_user_ids, yida_form_instance_id, source_form_data, "
+                + "sync_batch_id, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
+                + "ON DUPLICATE KEY UPDATE department_name = VALUES(department_name), "
+                + "application_company = VALUES(application_company), first_level_manager_user_ids = VALUES(first_level_manager_user_ids), "
+                + "second_level_manager_user_ids = VALUES(second_level_manager_user_ids), "
+                + "third_level_manager_user_ids = VALUES(third_level_manager_user_ids), "
+                + "fourth_level_manager_user_ids = VALUES(fourth_level_manager_user_ids), "
+                + "department_leader_user_ids = VALUES(department_leader_user_ids), "
+                + "yida_form_instance_id = VALUES(yida_form_instance_id), source_form_data = VALUES(source_form_data), "
+                + "sync_batch_id = VALUES(sync_batch_id), synced_at = VALUES(synced_at)";
+    }
+
+    private void bind(PreparedStatement statement, ApprovalMatrixCacheRecord record, String syncBatchId) throws SQLException {
+        int index = 1;
+        statement.setLong(index++, record.getDepartmentId().longValue());
+        statement.setString(index++, record.getDepartmentName());
+        statement.setString(index++, record.getApplicationCompany());
+        statement.setString(index++, record.getFirstLevelManagerUserIds());
+        statement.setString(index++, record.getSecondLevelManagerUserIds());
+        statement.setString(index++, record.getThirdLevelManagerUserIds());
+        statement.setString(index++, record.getFourthLevelManagerUserIds());
+        statement.setString(index++, record.getDepartmentLeaderUserIds());
+        statement.setString(index++, record.getYidaFormInstanceId());
+        statement.setString(index++, record.getSourceFormData());
+        statement.setString(index++, syncBatchId);
+        statement.setTimestamp(index, Timestamp.valueOf(record.getSyncedAt()));
+    }
+
+    private ApprovalMatrixCacheRecord mapRow(ResultSet resultSet) throws SQLException {
+        ApprovalMatrixCacheRecord record = new ApprovalMatrixCacheRecord();
+        record.setDepartmentId(Long.valueOf(resultSet.getLong("department_id")));
+        record.setDepartmentName(resultSet.getString("department_name"));
+        record.setApplicationCompany(resultSet.getString("application_company"));
+        record.setFirstLevelManagerUserIds(resultSet.getString("first_level_manager_user_ids"));
+        record.setSecondLevelManagerUserIds(resultSet.getString("second_level_manager_user_ids"));
+        record.setThirdLevelManagerUserIds(resultSet.getString("third_level_manager_user_ids"));
+        record.setFourthLevelManagerUserIds(resultSet.getString("fourth_level_manager_user_ids"));
+        record.setDepartmentLeaderUserIds(resultSet.getString("department_leader_user_ids"));
+        record.setYidaFormInstanceId(resultSet.getString("yida_form_instance_id"));
+        record.setSourceFormData(resultSet.getString("source_form_data"));
+        record.setSyncBatchId(resultSet.getString("sync_batch_id"));
+        Timestamp syncedAt = resultSet.getTimestamp("synced_at");
+        record.setSyncedAt(syncedAt == null ? null : syncedAt.toLocalDateTime());
+        return record;
+    }
+}

+ 36 - 0
src/main/java/com/malk/minglei/service/impl/yida/ApprovalMatrixScheduler.java

@@ -0,0 +1,36 @@
+package com.malk.minglei.service.impl.yida;
+
+import com.malk.minglei.dto.ApprovalMatrixSyncResult;
+import com.malk.minglei.service.yida.MingleiDocumentService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * 每日将宜搭审批矩阵同步到本地缓存,避免业务查询受宜搭接口时延影响。
+ */
+@Component
+public class ApprovalMatrixScheduler {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(ApprovalMatrixScheduler.class);
+    private final MingleiDocumentService mingleiDocumentService;
+
+    public ApprovalMatrixScheduler(MingleiDocumentService mingleiDocumentService) {
+        this.mingleiDocumentService = mingleiDocumentService;
+    }
+
+    /**
+     * 每日凌晨两点同步审批矩阵。
+     */
+    @Scheduled(cron = "0 0 2 * * ?", zone = "Asia/Shanghai")
+    public void syncApprovalMatrix() {
+        try {
+            ApprovalMatrixSyncResult result = mingleiDocumentService.syncApprovalMatrix();
+            LOGGER.info("Approval matrix cache sync finished, recordCount={}, syncedCount={}",
+                    result.getRecordCount(), result.getSyncedCount());
+        } catch (RuntimeException exception) {
+            LOGGER.error("Approval matrix cache sync failed; previous cache was retained", exception);
+        }
+    }
+}

+ 194 - 20
src/main/java/com/malk/minglei/service/impl/yida/MingleiDocumentServiceImpl.java

@@ -1,8 +1,10 @@
 package com.malk.minglei.service.impl.yida;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.ApprovalMatrixSyncResult;
 import com.malk.minglei.dto.MingleiDocumentQueryRequest;
 import com.malk.minglei.service.employee.EmployeeRosterService;
 import com.malk.minglei.service.yida.MingleiDocumentService;
@@ -25,6 +27,8 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.time.LocalDateTime;
+import java.util.UUID;
 
 /**
  * 明磊主数据宜搭表单查询服务实现。
@@ -49,8 +53,7 @@ public class MingleiDocumentServiceImpl implements MingleiDocumentService {
             FOURTH_LEVEL_DEPARTMENT_MANAGER_FIELD_ID,
             DEPARTMENT_LEADER_FIELD_ID
     };
-    private static final int APPROVAL_MATRIX_QUERY_PAGE_SIZE = 1;
-    private static final int APPLICANT_CONFIGURATION_QUERY_PAGE_SIZE = 100;
+    private static final int APPROVAL_MATRIX_SYNC_PAGE_SIZE = 100;
 
     private final YDClient yidaClient;
     private final DDClient dingTalkClient;
@@ -59,6 +62,7 @@ public class MingleiDocumentServiceImpl implements MingleiDocumentService {
     private final YidaApplicationsProperties.ApplicationCredentials masterDataConfiguration;
     private final ObjectMapper objectMapper;
     private final EmployeeRosterService employeeRosterService;
+    private final ApprovalMatrixCacheRepository approvalMatrixCacheRepository;
 
     /**
      * 创建明磊主数据表单查询服务。
@@ -70,6 +74,7 @@ public class MingleiDocumentServiceImpl implements MingleiDocumentService {
      * @param applications 宜搭应用配置
      * @param objectMapper JSON 序列化组件
      * @param employeeRosterService 员工花名册服务
+     * @param approvalMatrixCacheRepository 审批矩阵本地缓存仓储
      */
     public MingleiDocumentServiceImpl(@Qualifier("masterDataYidaClient") YDClient yidaClient,
                                       DDClient dingTalkClient,
@@ -77,7 +82,8 @@ public class MingleiDocumentServiceImpl implements MingleiDocumentService {
                                       DDConf dingTalkConfiguration,
                                       YidaApplicationsProperties applications,
                                        ObjectMapper objectMapper,
-                                       EmployeeRosterService employeeRosterService) {
+                                       EmployeeRosterService employeeRosterService,
+                                       ApprovalMatrixCacheRepository approvalMatrixCacheRepository) {
         this.yidaClient = yidaClient;
         this.dingTalkClient = dingTalkClient;
         this.dingTalkContactsClient = dingTalkContactsClient;
@@ -85,6 +91,7 @@ public class MingleiDocumentServiceImpl implements MingleiDocumentService {
         this.masterDataConfiguration = applications.getMasterData();
         this.objectMapper = objectMapper;
         this.employeeRosterService = employeeRosterService;
+        this.approvalMatrixCacheRepository = approvalMatrixCacheRepository;
     }
 
     /**
@@ -96,7 +103,16 @@ public class MingleiDocumentServiceImpl implements MingleiDocumentService {
      */
     @Override
     public Map<String, Object> queryDocuments(int page, int size) {
-        return executeQuery(null, null, page, size);
+        List<ApprovalMatrixCacheRecord> records = approvalMatrixCacheRepository.findPage(page, size);
+        List<Map<String, Object>> resultRecords = new ArrayList<Map<String, Object>>();
+        for (ApprovalMatrixCacheRecord record : records) {
+            resultRecords.add(Collections.<String, Object>singletonMap("formData", toFormData(record)));
+        }
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        result.put("records", resultRecords);
+        result.put("totalCount", Integer.valueOf(approvalMatrixCacheRepository.count()));
+        result.put("pageNumber", Integer.valueOf(page));
+        return result;
     }
 
     /**
@@ -158,8 +174,7 @@ public class MingleiDocumentServiceImpl implements MingleiDocumentService {
         if (requestedDepartmentId == null) {
             throw new IllegalArgumentException("departmentId must not be blank");
         }
-        return extractDepartmentManagers(executeQuery(DEPARTMENT_ID_FIELD_ID, requestedDepartmentId,
-                1, APPROVAL_MATRIX_QUERY_PAGE_SIZE));
+        return extractDepartmentManagers(approvalMatrixCacheRepository.findByDepartmentId(requestedDepartmentId));
     }
 
     /**
@@ -173,20 +188,34 @@ public class MingleiDocumentServiceImpl implements MingleiDocumentService {
         String requestedUserId = trimToNull(userId);
         require(requestedUserId, "userId must not be blank");
         String departmentId = resolveDepartmentId(requestedUserId);
-        Map<String, Object> queryResult = executeQuery(DEPARTMENT_ID_FIELD_ID, departmentId, 1,
-                APPLICANT_CONFIGURATION_QUERY_PAGE_SIZE);
-        Map formData = firstFormData(queryResult.get("records"));
+        ApprovalMatrixCacheRecord record = approvalMatrixCacheRepository.findByDepartmentId(departmentId);
         Map<String, Object> result = new LinkedHashMap<String, Object>();
         result.put("departmentId", departmentId);
-        if (formData != null) {
-            result.put("company", toStringValue(formData.get(APPLICATION_COMPANY_FIELD_ID)));
-            result.put("firstLevelManagerUserIds", employeeUserIds(formData.get(FIRST_LEVEL_DEPARTMENT_MANAGER_FIELD_ID + "_id")));
-            result.put("secondLevelManagerUserIds", employeeUserIds(formData.get(SECOND_LEVEL_DEPARTMENT_MANAGER_FIELD_ID + "_id")));
-            result.put("thirdLevelManagerUserIds", employeeUserIds(formData.get(THIRD_LEVEL_DEPARTMENT_MANAGER_FIELD_ID + "_id")));
+        if (record != null) {
+            result.put("company", record.getApplicationCompany());
+            result.put("firstLevelManagerUserIds", readUserIds(record.getFirstLevelManagerUserIds()));
+            result.put("secondLevelManagerUserIds", readUserIds(record.getSecondLevelManagerUserIds()));
+            result.put("thirdLevelManagerUserIds", readUserIds(record.getThirdLevelManagerUserIds()));
         }
         return result;
     }
 
+    @Override
+    public ApprovalMatrixSyncResult syncApprovalMatrix() {
+        List<ApprovalMatrixCacheRecord> records = fetchApprovalMatrixRecords();
+        if (records.isEmpty()) {
+            throw new IllegalStateException("Approval matrix sync returned no records; existing cache was retained");
+        }
+        Set<Long> departmentIds = new LinkedHashSet<Long>();
+        for (ApprovalMatrixCacheRecord record : records) {
+            if (!departmentIds.add(record.getDepartmentId())) {
+                throw new IllegalStateException("Duplicate department_id was found in approval matrix: " + record.getDepartmentId());
+            }
+        }
+        int syncedCount = approvalMatrixCacheRepository.replaceAll(records, UUID.randomUUID().toString());
+        return new ApprovalMatrixSyncResult(records.size(), syncedCount);
+    }
+
     /**
      * 按钉钉用户读取通讯录主部门。未设置主部门时取首个所属部门。
      *
@@ -298,14 +327,14 @@ public class MingleiDocumentServiceImpl implements MingleiDocumentService {
      */
     private Map<String, Object> queryApprovalMatrix(String queryType, String fieldId, String fieldValue) {
         long startedAt = System.nanoTime();
-        Object totalCount = null;
         try {
-            Map<String, Object> queryResult = executeQuery(fieldId, fieldValue, 1, APPROVAL_MATRIX_QUERY_PAGE_SIZE);
-            totalCount = queryResult.get("totalCount");
-            return extractDepartmentManagers(queryResult);
+            ApprovalMatrixCacheRecord record = DEPARTMENT_ID_FIELD_ID.equals(fieldId)
+                    ? approvalMatrixCacheRepository.findByDepartmentId(fieldValue)
+                    : approvalMatrixCacheRepository.findByDepartmentName(fieldValue);
+            return extractDepartmentManagers(record);
         } finally {
-            LOGGER.info("Approval matrix query stage=YIDA_MATRIX_QUERY queryType={} durationMs={} totalCount={}",
-                    queryType, elapsedMillis(startedAt), totalCount);
+            LOGGER.info("Approval matrix query stage=DATABASE_MATRIX_QUERY queryType={} durationMs={}",
+                    queryType, elapsedMillis(startedAt));
         }
     }
 
@@ -332,6 +361,137 @@ public class MingleiDocumentServiceImpl implements MingleiDocumentService {
         return result;
     }
 
+    private Map<String, Object> extractDepartmentManagers(ApprovalMatrixCacheRecord record) {
+        if (record == null) {
+            return Collections.emptyMap();
+        }
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        result.put(FIRST_LEVEL_DEPARTMENT_MANAGER_FIELD_ID, firstUserId(record.getFirstLevelManagerUserIds()));
+        result.put(SECOND_LEVEL_DEPARTMENT_MANAGER_FIELD_ID, firstUserId(record.getSecondLevelManagerUserIds()));
+        result.put(THIRD_LEVEL_DEPARTMENT_MANAGER_FIELD_ID, firstUserId(record.getThirdLevelManagerUserIds()));
+        result.put(FOURTH_LEVEL_DEPARTMENT_MANAGER_FIELD_ID, firstUserId(record.getFourthLevelManagerUserIds()));
+        result.put(DEPARTMENT_LEADER_FIELD_ID, firstUserId(record.getDepartmentLeaderUserIds()));
+        return result;
+    }
+
+    private List<ApprovalMatrixCacheRecord> fetchApprovalMatrixRecords() {
+        validateConfiguration();
+        List<ApprovalMatrixCacheRecord> records = new ArrayList<ApprovalMatrixCacheRecord>();
+        int page = 1;
+        while (true) {
+            Map<String, Object> response = executeQuery(null, null, page, APPROVAL_MATRIX_SYNC_PAGE_SIZE);
+            Object rawRecords = response.get("records");
+            if (rawRecords == null) {
+                break;
+            }
+            if (!(rawRecords instanceof Iterable)) {
+                throw new IllegalStateException("Approval matrix sync returned an invalid records payload");
+            }
+
+            int rawPageRecordCount = 0;
+            for (Object rawRecord : (Iterable<?>) rawRecords) {
+                rawPageRecordCount++;
+                if (!(rawRecord instanceof Map)) {
+                    throw new IllegalStateException("Approval matrix sync returned an invalid record");
+                }
+                Object formData = ((Map) rawRecord).get("formData");
+                if (!(formData instanceof Map)) {
+                    throw new IllegalStateException("Approval matrix record has no formData");
+                }
+                records.add(toCacheRecord((Map) rawRecord, (Map) formData));
+            }
+            // 以宜搭原始页记录数判断是否还有下一页,避免单条坏数据导致提前结束并误删旧缓存。
+            if (rawPageRecordCount < APPROVAL_MATRIX_SYNC_PAGE_SIZE) {
+                break;
+            }
+            page++;
+        }
+        return records;
+    }
+
+    private ApprovalMatrixCacheRecord toCacheRecord(Map rawRecord, Map formData) {
+        String departmentId = toStringValue(formData.get(DEPARTMENT_ID_FIELD_ID));
+        if (departmentId == null) {
+            throw new IllegalStateException("Approval matrix record has no department_id");
+        }
+        ApprovalMatrixCacheRecord record = new ApprovalMatrixCacheRecord();
+        try {
+            record.setDepartmentId(Long.valueOf(departmentId));
+        } catch (NumberFormatException exception) {
+            throw new IllegalStateException("Approval matrix department_id is invalid: " + departmentId, exception);
+        }
+        record.setDepartmentName(toStringValue(formData.get(DEPARTMENT_NAME_FIELD_ID)));
+        record.setApplicationCompany(toStringValue(formData.get(APPLICATION_COMPANY_FIELD_ID)));
+        record.setFirstLevelManagerUserIds(writeUserIds(formData.get(FIRST_LEVEL_DEPARTMENT_MANAGER_FIELD_ID + "_id")));
+        record.setSecondLevelManagerUserIds(writeUserIds(formData.get(SECOND_LEVEL_DEPARTMENT_MANAGER_FIELD_ID + "_id")));
+        record.setThirdLevelManagerUserIds(writeUserIds(formData.get(THIRD_LEVEL_DEPARTMENT_MANAGER_FIELD_ID + "_id")));
+        record.setFourthLevelManagerUserIds(writeUserIds(formData.get(FOURTH_LEVEL_DEPARTMENT_MANAGER_FIELD_ID + "_id")));
+        record.setDepartmentLeaderUserIds(writeUserIds(formData.get(DEPARTMENT_LEADER_FIELD_ID + "_id")));
+        record.setYidaFormInstanceId(firstText(rawRecord, "formInstanceId", "form_instance_id", "instanceId", "id"));
+        try {
+            record.setSourceFormData(objectMapper.writeValueAsString(formData));
+        } catch (JsonProcessingException exception) {
+            throw new IllegalStateException("Unable to serialize approval matrix formData", exception);
+        }
+        record.setSyncedAt(LocalDateTime.now());
+        return record;
+    }
+
+    private String writeUserIds(Object employeeIds) {
+        try {
+            return objectMapper.writeValueAsString(employeeUserIds(employeeIds));
+        } catch (JsonProcessingException exception) {
+            throw new IllegalStateException("Unable to serialize approval matrix manager user IDs", exception);
+        }
+    }
+
+    private List<String> readUserIds(String serializedUserIds) {
+        if (serializedUserIds == null || serializedUserIds.trim().isEmpty()) {
+            return Collections.emptyList();
+        }
+        try {
+            return objectMapper.readValue(serializedUserIds, new TypeReference<List<String>>() { });
+        } catch (JsonProcessingException exception) {
+            throw new IllegalStateException("Approval matrix cached manager user IDs are invalid", exception);
+        }
+    }
+
+    private String firstUserId(String serializedUserIds) {
+        List<String> userIds = readUserIds(serializedUserIds);
+        return userIds.isEmpty() ? null : userIds.get(0);
+    }
+
+    private Map<String, Object> toFormData(ApprovalMatrixCacheRecord record) {
+        Map<String, Object> formData = readSourceFormData(record);
+        formData.put(DEPARTMENT_ID_FIELD_ID, String.valueOf(record.getDepartmentId()));
+        formData.put(DEPARTMENT_NAME_FIELD_ID, record.getDepartmentName());
+        formData.put(APPLICATION_COMPANY_FIELD_ID, record.getApplicationCompany());
+        formData.put(FIRST_LEVEL_DEPARTMENT_MANAGER_FIELD_ID + "_id", readUserIds(record.getFirstLevelManagerUserIds()));
+        formData.put(SECOND_LEVEL_DEPARTMENT_MANAGER_FIELD_ID + "_id", readUserIds(record.getSecondLevelManagerUserIds()));
+        formData.put(THIRD_LEVEL_DEPARTMENT_MANAGER_FIELD_ID + "_id", readUserIds(record.getThirdLevelManagerUserIds()));
+        formData.put(FOURTH_LEVEL_DEPARTMENT_MANAGER_FIELD_ID + "_id", readUserIds(record.getFourthLevelManagerUserIds()));
+        formData.put(DEPARTMENT_LEADER_FIELD_ID + "_id", readUserIds(record.getDepartmentLeaderUserIds()));
+        return formData;
+    }
+
+    /**
+     * 分页接口需要继续返回宜搭表单的完整字段,因此优先使用同步时留存的原始 formData。
+     *
+     * @param record 审批矩阵本地缓存记录
+     * @return 可供分页接口返回的表单字段
+     */
+    private Map<String, Object> readSourceFormData(ApprovalMatrixCacheRecord record) {
+        String sourceFormData = trimToNull(record.getSourceFormData());
+        if (sourceFormData == null) {
+            return new LinkedHashMap<String, Object>();
+        }
+        try {
+            return objectMapper.readValue(sourceFormData, new TypeReference<LinkedHashMap<String, Object>>() { });
+        } catch (JsonProcessingException exception) {
+            throw new IllegalStateException("Approval matrix cached formData is invalid", exception);
+        }
+    }
+
     private Map firstFormData(Object records) {
         if (!(records instanceof Iterable)) {
             return null;
@@ -434,6 +594,20 @@ public class MingleiDocumentServiceImpl implements MingleiDocumentService {
         return value == null ? null : trimToNull(String.valueOf(value));
     }
 
+    private String firstText(Map values, String firstKey, String... otherKeys) {
+        String value = toStringValue(values.get(firstKey));
+        if (value != null) {
+            return value;
+        }
+        for (String key : otherKeys) {
+            value = toStringValue(values.get(key));
+            if (value != null) {
+                return value;
+            }
+        }
+        return null;
+    }
+
     private String trimToNull(String value) {
         if (value == null) {
             return null;

+ 8 - 0
src/main/java/com/malk/minglei/service/yida/MingleiDocumentService.java

@@ -1,6 +1,7 @@
 package com.malk.minglei.service.yida;
 
 import com.malk.minglei.dto.MingleiDocumentQueryRequest;
+import com.malk.minglei.dto.ApprovalMatrixSyncResult;
 
 import java.util.List;
 import java.util.Map;
@@ -43,6 +44,13 @@ public interface MingleiDocumentService {
      */
     Map<String, Object> queryApplicantDepartmentConfiguration(String userId);
 
+    /**
+     * 从宜搭全量同步审批矩阵到本地缓存。
+     *
+     * @return 本次同步统计结果
+     */
+    ApprovalMatrixSyncResult syncApprovalMatrix();
+
     /**
      * 按钉钉用户读取通讯录主部门。未设置主部门时取首个所属部门。
      *

+ 57 - 54
src/test/java/com/malk/minglei/service/impl/yida/CustomerReleaseDepartmentConfigurationTest.java

@@ -1,20 +1,14 @@
 package com.malk.minglei.service.impl.yida;
 
 import com.alibaba.fastjson.JSONObject;
-import com.fasterxml.jackson.databind.ObjectMapper;
 import com.malk.minglei.config.YidaApplicationsProperties;
 import com.malk.minglei.dto.ErpYidaPushMainData;
 import com.malk.minglei.dto.ErpYidaPushRequest;
 import com.malk.minglei.service.employee.EmployeeRosterService;
 import com.malk.minglei.service.yida.MingleiDocumentService;
 import com.malk.minglei.service.yida.YidaProcessOpenApiClient;
-import com.malk.server.aliwork.YDConf;
-import com.malk.server.aliwork.YDParam;
 import com.malk.server.dingtalk.DDConf;
 import com.malk.server.dingtalk.DDR_New;
-import com.malk.service.aliwork.YDClient;
-import com.malk.service.dingtalk.DDClient;
-import com.malk.service.dingtalk.DDClient_Contacts;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
@@ -24,6 +18,7 @@ import org.springframework.context.annotation.AnnotationConfigApplicationContext
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -31,8 +26,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyMap;
-import static org.mockito.ArgumentMatchers.argThat;
-import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
@@ -107,10 +100,10 @@ class CustomerReleaseDepartmentConfigurationTest {
     }
 
     @Test
-    void doesNotStartWhenDepartmentConfigurationQueryFails() {
+    void doesNotStartWhenDepartmentConfigurationCacheReadFails() {
         try (Fixture fixture = new Fixture(Collections.<String, Object>singletonMap("main_dept_id", 20L), null)) {
-            when(fixture.masterDataClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
-                    .thenThrow(new IllegalStateException("Master data unavailable"));
+            when(fixture.documentService.queryApplicantDepartmentConfiguration("applicant-1"))
+                    .thenThrow(new IllegalStateException("Approval matrix cache unavailable"));
             assertThrows(IllegalStateException.class, () -> fixture.handler.push(fixture.request));
             verify(fixture.processClient, never()).startProcess(anyMap());
         }
@@ -163,20 +156,18 @@ class CustomerReleaseDepartmentConfigurationTest {
     }
 
     @Test
-    void doesNotStartWhenFinanceConfigurationQueryFails() {
+    void doesNotStartWhenFinanceConfigurationCacheReadFails() {
         try (Fixture fixture = new Fixture(Collections.<String, Object>singletonMap("main_dept_id", 20L), null)) {
-            when(fixture.masterDataClient.queryData(argThat((YDParam parameter) -> parameter.getSearchFieldJson().contains("962219827")),
-                    eq(YDConf.FORM_QUERY.retrieve_search_form))).thenThrow(new IllegalStateException("Finance configuration unavailable"));
+            when(fixture.documentService.queryDepartmentManagersByDepartmentId("962219827"))
+                    .thenThrow(new IllegalStateException("Approval matrix cache unavailable"));
             assertThrows(IllegalStateException.class, () -> fixture.handler.push(fixture.request));
             verify(fixture.processClient, never()).startProcess(anyMap());
         }
     }
 
     private static class Fixture implements AutoCloseable {
-        private final YDClient masterDataClient = mock(YDClient.class);
         private final YidaProcessOpenApiClient processClient = mock(YidaProcessOpenApiClient.class);
-        private final DDClient_Contacts contactsClient = mock(DDClient_Contacts.class);
-        private final EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        private final MingleiDocumentService documentService = mock(MingleiDocumentService.class);
         private final AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
         private final ErpYidaPushRequest request = new ErpYidaPushRequest();
         private final CustomerReleaseFormPushHandler handler;
@@ -184,32 +175,19 @@ class CustomerReleaseDepartmentConfigurationTest {
         private Map<String, Object> financeConfiguration;
 
         private Fixture(Map<String, Object> user, Map<String, Object> configuration) {
-            DDClient dingTalkClient = mock(DDClient.class);
-            when(employeeRosterService.findDepartmentIdByUserId("applicant-1"))
-                    .thenReturn(firstDepartmentId(user));
             DDConf ddConf = new DDConf();
             ddConf.setAppKey("app-key");
             ddConf.setAppSecret("app-secret");
             YidaApplicationsProperties applications = new YidaApplicationsProperties();
             applications.getCoordinationOffice().setAppType("APP_RJJL69QUIZVQSV7YC8TL");
             applications.getCoordinationOffice().setSystemToken("office-token");
-            applications.getMasterData().setAppType("APP_KI0AOTH7OLFS2UAUHZXI");
-            applications.getMasterData().setSystemToken("master-data-token");
-            when(masterDataClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
-                    .thenAnswer(invocation -> {
-                        YDParam parameter = invocation.getArgument(0);
-                        String departmentId = JSONObject.parseArray(parameter.getSearchFieldJson()).getJSONObject(0).getString("value");
-                        Map<String, Object> selected = financeDepartmentId.equals(departmentId) ? financeConfiguration : configuration;
-                        DDR_New queryResponse = mock(DDR_New.class);
-                        when(queryResponse.getRecords()).thenReturn(selected == null ? Collections.emptyList()
-                                : Collections.singletonList(Collections.singletonMap("formData", selected)));
-                        return queryResponse;
-                    });
+            when(documentService.queryApplicantDepartmentConfiguration("applicant-1"))
+                    .thenAnswer(invocation -> applicantConfiguration(firstDepartmentId(user), configuration));
+            when(documentService.queryDepartmentManagersByDepartmentId(any(String.class)))
+                    .thenAnswer(invocation -> departmentManagers(financeConfiguration));
             DDR_New startResponse = new DDR_New();
             startResponse.setResult("process-1");
             when(processClient.startProcess(anyMap())).thenReturn(startResponse);
-            MingleiDocumentService documentService = new MingleiDocumentServiceImpl(masterDataClient,
-                    dingTalkClient, contactsClient, ddConf, applications, new ObjectMapper(), employeeRosterService);
             context.registerBean(YidaProcessOpenApiClient.class, () -> processClient);
             context.registerBean(DDConf.class, () -> ddConf);
             context.registerBean(YidaApplicationsProperties.class, () -> applications);
@@ -241,17 +219,8 @@ class CustomerReleaseDepartmentConfigurationTest {
         }
 
         private void verifyDepartmentQuery(String departmentId) {
-            verify(employeeRosterService).findDepartmentIdByUserId("applicant-1");
-            verify(contactsClient, never()).getUserInfoById(any(String.class), any(String.class));
-            ArgumentCaptor<YDParam> parameter = ArgumentCaptor.forClass(YDParam.class);
-            verify(masterDataClient, times(2)).queryData(parameter.capture(), eq(YDConf.FORM_QUERY.retrieve_search_form));
-            YDParam applicantQuery = parameter.getAllValues().get(0);
-            assertEquals("APP_KI0AOTH7OLFS2UAUHZXI", applicantQuery.getAppType());
-            assertEquals("FORM-8F527F696C5B4AF7AB7A53979527FBA23USO", applicantQuery.getFormUuid());
-            JSONObject filter = JSONObject.parseArray(applicantQuery.getSearchFieldJson()).getJSONObject(0);
-            assertEquals("textField_msztwhs2", filter.getString("key"));
-            assertEquals(departmentId, filter.getString("value"));
-            assertEquals("eq", filter.getString("operator"));
+            verify(documentService).queryApplicantDepartmentConfiguration("applicant-1");
+            verify(documentService).queryDepartmentManagersByDepartmentId(financeDepartmentId);
         }
 
         private String firstDepartmentId(Map<String, Object> user) {
@@ -279,15 +248,49 @@ class CustomerReleaseDepartmentConfigurationTest {
         }
 
         private void verifyFinanceQuery(String departmentId) {
-            ArgumentCaptor<YDParam> parameter = ArgumentCaptor.forClass(YDParam.class);
-            verify(masterDataClient, times(2)).queryData(parameter.capture(), eq(YDConf.FORM_QUERY.retrieve_search_form));
-            YDParam financeQuery = parameter.getAllValues().get(1);
-            assertEquals("APP_KI0AOTH7OLFS2UAUHZXI", financeQuery.getAppType());
-            assertEquals("FORM-8F527F696C5B4AF7AB7A53979527FBA23USO", financeQuery.getFormUuid());
-            JSONObject filter = JSONObject.parseArray(financeQuery.getSearchFieldJson()).getJSONObject(0);
-            assertEquals("textField_msztwhs2", filter.getString("key"));
-            assertEquals(departmentId, filter.getString("value"));
-            assertEquals("eq", filter.getString("operator"));
+            verify(documentService).queryDepartmentManagersByDepartmentId(departmentId);
+        }
+
+        private Map<String, Object> applicantConfiguration(String departmentId, Map<String, Object> configuration) {
+            Map<String, Object> result = new LinkedHashMap<String, Object>();
+            result.put("departmentId", departmentId);
+            if (configuration != null) {
+                result.put("company", configuration.get("textField_mtgmtiwb"));
+                result.put("firstLevelManagerUserIds", userIds(configuration.get("employeeField_mszdqrl8_id")));
+                result.put("secondLevelManagerUserIds", userIds(configuration.get("employeeField_mszdqrl9_id")));
+                result.put("thirdLevelManagerUserIds", userIds(configuration.get("employeeField_mszdqrla_id")));
+            }
+            return result;
+        }
+
+        private Map<String, Object> departmentManagers(Map<String, Object> configuration) {
+            if (configuration == null) {
+                return Collections.emptyMap();
+            }
+            List<String> managerIds = userIds(configuration.get("employeeField_mszdqrl8_id"));
+            if (managerIds.isEmpty()) {
+                return Collections.emptyMap();
+            }
+            return Collections.<String, Object>singletonMap("employeeField_mszdqrl8", managerIds.get(0));
+        }
+
+        @SuppressWarnings("rawtypes")
+        private List<String> userIds(Object values) {
+            if (!(values instanceof Iterable)) {
+                return Collections.emptyList();
+            }
+            java.util.ArrayList<String> userIds = new java.util.ArrayList<String>();
+            for (Object value : (Iterable<?>) values) {
+                if (value instanceof Map) {
+                    Object userId = ((Map) value).get("userId");
+                    if (userId != null) {
+                        userIds.add(String.valueOf(userId));
+                    }
+                } else if (value != null) {
+                    userIds.add(String.valueOf(value));
+                }
+            }
+            return userIds;
         }
 
         @Override

+ 89 - 17
src/test/java/com/malk/minglei/service/impl/yida/MingleiDocumentServiceImplTest.java

@@ -2,6 +2,7 @@ package com.malk.minglei.service.impl.yida;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.ApprovalMatrixSyncResult;
 import com.malk.minglei.dto.MingleiDocumentQueryRequest;
 import com.malk.minglei.service.employee.EmployeeRosterService;
 import com.malk.server.aliwork.YDConf;
@@ -15,7 +16,9 @@ import org.junit.jupiter.api.Test;
 
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -30,28 +33,28 @@ import static org.mockito.Mockito.verify;
 class MingleiDocumentServiceImplTest {
 
     @Test
-    @SuppressWarnings("rawtypes")
-    void queriesOnlyOneApprovalMatrixRecordForUserLookup() {
+    void queriesApprovalMatrixCacheForUserLookup() {
         YDClient yidaClient = mock(YDClient.class);
         DDClient dingTalkClient = mock(DDClient.class);
         DDClient_Contacts contactsClient = mock(DDClient_Contacts.class);
         EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
-        DDR_New yidaResponse = mock(DDR_New.class);
+        ApprovalMatrixCacheRepository cacheRepository = mock(ApprovalMatrixCacheRepository.class);
         given(employeeRosterService.findDepartmentIdByUserId("user-1")).willReturn("10");
-        given(yidaClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
-                .willReturn(yidaResponse);
-        given(yidaResponse.getRecords()).willReturn(Collections.emptyList());
+        given(cacheRepository.findByDepartmentId("10")).willReturn(cacheRecord(10L, "Configured Company",
+                "[\"manager-1\"]", "[\"manager-2\"]", "[]", "[]", "[]"));
 
         MingleiDocumentQueryRequest request = new MingleiDocumentQueryRequest();
         request.setUserId("user-1");
-        MingleiDocumentServiceImpl service = configuredService(yidaClient, dingTalkClient, contactsClient, employeeRosterService);
+        MingleiDocumentServiceImpl service = configuredService(yidaClient, dingTalkClient, contactsClient,
+                employeeRosterService, cacheRepository);
 
-        service.queryDocuments(request);
+        Map<String, Object> result = service.queryDocuments(request);
 
-        org.mockito.ArgumentCaptor<YDParam> parameterCaptor = org.mockito.ArgumentCaptor.forClass(YDParam.class);
-        verify(yidaClient).queryData(parameterCaptor.capture(), eq(YDConf.FORM_QUERY.retrieve_search_form));
-        assertEquals(1, parameterCaptor.getValue().getPageSize());
+        assertEquals("manager-1", result.get("employeeField_mszdqrl8"));
+        assertEquals("manager-2", result.get("employeeField_mszdqrl9"));
         verify(employeeRosterService).findDepartmentIdByUserId("user-1");
+        verify(cacheRepository).findByDepartmentId("10");
+        verify(yidaClient, never()).queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form));
         verify(dingTalkClient, never()).getAccessToken();
         verify(contactsClient, never()).getUserInfoById(any(String.class), any(String.class));
     }
@@ -64,7 +67,8 @@ class MingleiDocumentServiceImplTest {
         EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
         MingleiDocumentQueryRequest request = new MingleiDocumentQueryRequest();
         request.setUserId("user-1");
-        MingleiDocumentServiceImpl service = configuredService(yidaClient, dingTalkClient, contactsClient, employeeRosterService);
+        MingleiDocumentServiceImpl service = configuredService(yidaClient, dingTalkClient, contactsClient,
+                employeeRosterService, mock(ApprovalMatrixCacheRepository.class));
 
         IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                 () -> service.queryDocuments(request));
@@ -76,6 +80,27 @@ class MingleiDocumentServiceImplTest {
         verify(yidaClient, never()).queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form));
     }
 
+    @Test
+    @SuppressWarnings("unchecked")
+    void pagedLookupPreservesCachedYidaFormData() {
+        ApprovalMatrixCacheRepository cacheRepository = mock(ApprovalMatrixCacheRepository.class);
+        ApprovalMatrixCacheRecord record = cacheRecord(10L, "ML", "[\"manager-1\"]", "[]", "[]", "[]", "[]");
+        record.setSourceFormData("{\"customField\":\"preserved\",\"textField_msztwhs2\":\"outdated\"}");
+        given(cacheRepository.findPage(2, 20)).willReturn(Collections.singletonList(record));
+        given(cacheRepository.count()).willReturn(1);
+        MingleiDocumentServiceImpl service = configuredService(mock(YDClient.class), mock(DDClient.class),
+                mock(DDClient_Contacts.class), mock(EmployeeRosterService.class), cacheRepository);
+
+        Map<String, Object> result = service.queryDocuments(2, 20);
+
+        List<Map<String, Object>> records = (List<Map<String, Object>>) result.get("records");
+        Map<String, Object> formData = (Map<String, Object>) records.get(0).get("formData");
+        assertEquals("preserved", formData.get("customField"));
+        assertEquals("10", formData.get("textField_msztwhs2"));
+        assertEquals(Collections.singletonList("manager-1"), formData.get("employeeField_mszdqrl8_id"));
+        assertEquals(Integer.valueOf(1), result.get("totalCount"));
+    }
+
     /**
      * 验证 type 为 0 时会合并当前部门与子部门成员,并保持去重顺序。
      */
@@ -96,7 +121,8 @@ class MingleiDocumentServiceImplTest {
                 mock(DDConf.class),
                 new YidaApplicationsProperties(),
                 new ObjectMapper(),
-                mock(EmployeeRosterService.class));
+                mock(EmployeeRosterService.class),
+                mock(ApprovalMatrixCacheRepository.class));
 
         List<String> userIds = service.listDepartmentUserIds(10L, 0);
 
@@ -121,7 +147,8 @@ class MingleiDocumentServiceImplTest {
                 mock(DDConf.class),
                 new YidaApplicationsProperties(),
                 new ObjectMapper(),
-                mock(EmployeeRosterService.class));
+                mock(EmployeeRosterService.class),
+                mock(ApprovalMatrixCacheRepository.class));
 
         List<String> userIds = service.listDepartmentUserIds(10L, 1);
 
@@ -148,7 +175,8 @@ class MingleiDocumentServiceImplTest {
                 mock(DDConf.class),
                 new YidaApplicationsProperties(),
                 new ObjectMapper(),
-                mock(EmployeeRosterService.class));
+                mock(EmployeeRosterService.class),
+                mock(ApprovalMatrixCacheRepository.class));
 
         List<String> userIds = service.listDepartmentUserIds(Arrays.asList(10L, 20L), 0);
 
@@ -157,9 +185,39 @@ class MingleiDocumentServiceImplTest {
         verify(contactsClient).getDepartmentId_all("access-token", false, 20L);
     }
 
+    @Test
+    @SuppressWarnings("rawtypes")
+    void syncApprovalMatrixWritesAllYidaRecordsToCache() {
+        YDClient yidaClient = mock(YDClient.class);
+        DDR_New yidaResponse = mock(DDR_New.class);
+        ApprovalMatrixCacheRepository cacheRepository = mock(ApprovalMatrixCacheRepository.class);
+        Map<String, Object> formData = new LinkedHashMap<String, Object>();
+        formData.put("textField_msztwhs2", "10");
+        formData.put("textField_mszr6tuz", "研发部");
+        formData.put("textField_mtgmtiwb", "ML");
+        formData.put("employeeField_mszdqrl8_id", Collections.singletonList(Collections.singletonMap("userId", "manager-1")));
+        formData.put("employeeField_mszdqrl9_id", Collections.singletonList("manager-2"));
+        given(yidaClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))).willReturn(yidaResponse);
+        given(yidaResponse.getRecords()).willReturn(Collections.singletonList(Collections.singletonMap("formData", formData)));
+        given(cacheRepository.replaceAll(any(List.class), any(String.class))).willReturn(1);
+
+        ApprovalMatrixSyncResult result = configuredService(yidaClient, mock(DDClient.class),
+                mock(DDClient_Contacts.class), mock(EmployeeRosterService.class), cacheRepository).syncApprovalMatrix();
+
+        assertEquals(1, result.getRecordCount());
+        assertEquals(1, result.getSyncedCount());
+        org.mockito.ArgumentCaptor<List> records = org.mockito.ArgumentCaptor.forClass(List.class);
+        verify(cacheRepository).replaceAll(records.capture(), any(String.class));
+        ApprovalMatrixCacheRecord record = (ApprovalMatrixCacheRecord) records.getValue().get(0);
+        assertEquals(Long.valueOf(10L), record.getDepartmentId());
+        assertEquals("[\"manager-1\"]", record.getFirstLevelManagerUserIds());
+        assertEquals("[\"manager-2\"]", record.getSecondLevelManagerUserIds());
+    }
+
     private MingleiDocumentServiceImpl configuredService(YDClient yidaClient, DDClient dingTalkClient,
                                                           DDClient_Contacts contactsClient,
-                                                          EmployeeRosterService employeeRosterService) {
+                                                          EmployeeRosterService employeeRosterService,
+                                                          ApprovalMatrixCacheRepository cacheRepository) {
         DDConf dingTalkConfiguration = new DDConf();
         dingTalkConfiguration.setAppKey("app-key");
         dingTalkConfiguration.setAppSecret("app-secret");
@@ -167,6 +225,20 @@ class MingleiDocumentServiceImplTest {
         applications.getMasterData().setAppType("APP-TEST");
         applications.getMasterData().setSystemToken("system-token");
         return new MingleiDocumentServiceImpl(yidaClient, dingTalkClient, contactsClient, dingTalkConfiguration,
-                applications, new ObjectMapper(), employeeRosterService);
+                applications, new ObjectMapper(), employeeRosterService, cacheRepository);
+    }
+
+    private ApprovalMatrixCacheRecord cacheRecord(Long departmentId, String company, String firstLevelManagers,
+                                                  String secondLevelManagers, String thirdLevelManagers,
+                                                  String fourthLevelManagers, String departmentLeaders) {
+        ApprovalMatrixCacheRecord record = new ApprovalMatrixCacheRecord();
+        record.setDepartmentId(departmentId);
+        record.setApplicationCompany(company);
+        record.setFirstLevelManagerUserIds(firstLevelManagers);
+        record.setSecondLevelManagerUserIds(secondLevelManagers);
+        record.setThirdLevelManagerUserIds(thirdLevelManagers);
+        record.setFourthLevelManagerUserIds(fourthLevelManagers);
+        record.setDepartmentLeaderUserIds(departmentLeaders);
+        return record;
     }
 }