2 Revīzijas b42ef4a363 ... 47ec268368

Autors SHA1 Ziņojums Datums
  lfx 47ec268368 1 1 nedēļu atpakaļ
  lfx f4831a8723 feat(benteler): sync personnel update changes 2 nedēļas atpakaļ
21 mainītis faili ar 1936 papildinājumiem un 30 dzēšanām
  1. 25 0
      mjava-benteler/src/main/java/com/malk/benteler/config/BentelerYidaConf.java
  2. 36 0
      mjava-benteler/src/main/java/com/malk/benteler/dto/DingTalkSyncTask.java
  3. 35 0
      mjava-benteler/src/main/java/com/malk/benteler/schedule/BentelerDingTalkSyncSchedule.java
  4. 545 0
      mjava-benteler/src/main/java/com/malk/benteler/service/BentelerDingTalkSyncTaskService.java
  5. 6 2
      mjava-benteler/src/main/java/com/malk/benteler/service/BentelerYidaFormMapper.java
  6. 85 16
      mjava-benteler/src/main/java/com/malk/benteler/service/BentelerYidaSyncService.java
  7. 27 8
      mjava-benteler/src/main/java/com/malk/benteler/service/EiamOrganizationalUnitLocalService.java
  8. 24 0
      mjava-benteler/src/main/resources/application.yml
  9. 60 0
      mjava-benteler/src/test/java/com/malk/benteler/schedule/BentelerDingTalkSyncScheduleTest.java
  10. 518 0
      mjava-benteler/src/test/java/com/malk/benteler/service/BentelerDingTalkSyncTaskServiceTest.java
  11. 16 0
      mjava-benteler/src/test/java/com/malk/benteler/service/BentelerYidaFormMapperTest.java
  12. 140 1
      mjava-benteler/src/test/java/com/malk/benteler/service/BentelerYidaSyncServiceTest.java
  13. 52 0
      mjava-benteler/src/test/java/com/malk/benteler/service/EiamOrganizationalUnitLocalServiceTest.java
  14. 25 0
      mjava-benteler/src/test/java/com/malk/benteler/service/TestBentelerYidaConf.java
  15. 2 2
      mjava/src/main/java/com/malk/service/aliwork/impl/YDClient_FormImpl.java
  16. 20 0
      mjava/src/main/java/com/malk/service/dingtalk/DDClient_Personnel.java
  17. 7 0
      mjava/src/main/java/com/malk/service/dingtalk/DDService.java
  18. 99 0
      mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Personnel.java
  19. 10 0
      mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplService.java
  20. 30 1
      mjava/src/main/java/com/malk/utils/UtilHttp.java
  21. 174 0
      mjava/src/test/java/com/malk/service/dingtalk/impl/DDImplClient_PersonnelTest.java

+ 25 - 0
mjava-benteler/src/main/java/com/malk/benteler/config/BentelerYidaConf.java

@@ -26,6 +26,7 @@ public class BentelerYidaConf {
     private String onboardingCompanyFieldId;
     private String onboardingCompanyCodeFieldId;
     private String onboardingDepartmentFieldId;
+    private String onboardingOutsourcingCompanyFieldId;
 
     private String updateFormUuid;
     private String updateTableFieldId;
@@ -36,6 +37,11 @@ public class BentelerYidaConf {
     private String updatePhoneFieldId;
     private String updateEmployeeNumberFieldId;
     private String updateJobTitleFieldId;
+    private String updateOutsourcingCompanyFieldId;
+    private String updateNameFieldId;
+    private String updateFactoryFieldId;
+    private String updateProbationConfirmationFieldId;
+    private String updateDingTalkDepartmentFieldId;
 
     private String offboardingFormUuid;
     private String offboardingTableFieldId;
@@ -50,4 +56,23 @@ public class BentelerYidaConf {
     private String totalFieldId;
     private String successFieldId;
     private String failedFieldId;
+
+    private String dingTalkSyncTaskFormUuid;
+    private String dingTalkSyncTaskKeyFieldId;
+    private String dingTalkSyncTaskTypeFieldId;
+    private String dingTalkSyncSourceFormInstanceIdFieldId;
+    private String dingTalkSyncSourceRowIndexFieldId;
+    private String dingTalkSyncUserIdFieldId;
+    private String dingTalkSyncTargetFieldCodeFieldId;
+    private String dingTalkSyncTargetFieldValueFieldId;
+    private String dingTalkSyncRoleGroupIdFieldId;
+    private String dingTalkSyncRoleIdFieldId;
+    private String dingTalkSyncStatusFieldId;
+    private String dingTalkSyncRetryCountFieldId;
+    private String dingTalkSyncNextExecuteAtFieldId;
+    private String dingTalkSyncLastErrorFieldId;
+    private String dingTalkSyncCompletedAtFieldId;
+    private Long dingTalkRosterAgentId;
+    private String dingTalkRosterGroupId;
+    private String dingTalkRosterOutsourcingFieldCode;
 }

+ 36 - 0
mjava-benteler/src/main/java/com/malk/benteler/dto/DingTalkSyncTask.java

@@ -0,0 +1,36 @@
+package com.malk.benteler.dto;
+
+import lombok.Data;
+
+import java.time.Instant;
+
+/**
+ * 宜搭持久化的钉钉同步任务。
+ */
+@Data
+public class DingTalkSyncTask {
+
+    public static final String ROSTER_FIELD_UPDATE = "ROSTER_FIELD_UPDATE";
+    public static final String CONTACT_PRIMARY_DEPARTMENT_UPDATE = "CONTACT_PRIMARY_DEPARTMENT_UPDATE";
+    public static final String ROLE_ASSIGN = "ROLE_ASSIGN";
+    public static final String PENDING = "PENDING";
+    public static final String RETRYING = "RETRYING";
+    public static final String SUCCESS = "SUCCESS";
+    public static final String FAILED = "FAILED";
+
+    private String formInstanceId;
+    private String taskKey;
+    private String taskType;
+    private String sourceFormInstanceId;
+    private Integer sourceRowIndex;
+    private String userId;
+    private String targetFieldCode;
+    private String targetFieldValue;
+    private String roleGroupId;
+    private String roleId;
+    private String status;
+    private Integer retryCount;
+    private Instant nextExecuteAt;
+    private String lastError;
+    private Instant completedAt;
+}

+ 35 - 0
mjava-benteler/src/main/java/com/malk/benteler/schedule/BentelerDingTalkSyncSchedule.java

@@ -0,0 +1,35 @@
+package com.malk.benteler.schedule;
+
+import com.malk.benteler.service.BentelerDingTalkSyncTaskService;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * Polls the durable DingTalk synchronization queue every five minutes.
+ */
+@Component
+@EnableScheduling
+@ConditionalOnProperty(name = "spel.scheduling", havingValue = "true")
+public class BentelerDingTalkSyncSchedule {
+
+    private final BentelerDingTalkSyncTaskService taskService;
+
+    /**
+     * Creates the synchronization scheduler.
+     *
+     * @param taskService durable task service
+     */
+    public BentelerDingTalkSyncSchedule(BentelerDingTalkSyncTaskService taskService) {
+        this.taskService = taskService;
+    }
+
+    /**
+     * Executes tasks whose scheduled time has arrived.
+     */
+    @Scheduled(cron = "0 */5 * * * ?")
+    public void executeDueTasks() {
+        taskService.executeDueTasks();
+    }
+}

+ 545 - 0
mjava-benteler/src/main/java/com/malk/benteler/service/BentelerDingTalkSyncTaskService.java

@@ -0,0 +1,545 @@
+package com.malk.benteler.service;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.benteler.config.BentelerYidaConf;
+import com.malk.benteler.dto.DingTalkSyncTask;
+import com.malk.server.aliwork.YDAuth;
+import com.malk.server.aliwork.YDConf;
+import com.malk.service.aliwork.YDClient_Form;
+import com.malk.service.dingtalk.DDClient_Personnel;
+import com.malk.service.dingtalk.DDClient_Contacts;
+import com.malk.service.dingtalk.DDService;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.regex.Pattern;
+
+/**
+ * Uses a YiDa form as the durable queue for delayed DingTalk synchronization.
+ */
+@Slf4j
+@Service
+public class BentelerDingTalkSyncTaskService {
+
+    private static final int PAGE_SIZE = 100;
+    private static final int MAX_ATTEMPTS = 5;
+    private static final int MAX_ERROR_LENGTH = 500;
+    private static final long CLAIM_MINUTES = 10;
+    private static final String CLAIM_PREFIX = "CLAIM:";
+    private static final Pattern SENSITIVE_VALUE = Pattern.compile(
+            "(?i)(access[_-]?token|appsecret|password|aeskey|privatekey)\\s*[=:]\\s*[^\\s,;]+"
+    );
+
+    private final YDClient_Form ydClientForm;
+    private final YDConf ydConf;
+    private final DDService ddService;
+    private final DDClient_Personnel personnelClient;
+    private final DDClient_Contacts contactsClient;
+    private final BentelerYidaConf conf;
+    private final Clock clock;
+    private final AtomicBoolean executing = new AtomicBoolean();
+
+    /**
+     * Creates the task service with the system clock.
+     *
+     * @param ydClientForm YiDa atomic form client
+     * @param ydConf YiDa application configuration
+     * @param ddService DingTalk service providing a cached token
+     * @param personnelClient DingTalk personnel atomic client
+     * @param conf Benteler task-form and roster configuration
+     */
+    @Autowired
+    public BentelerDingTalkSyncTaskService(YDClient_Form ydClientForm, YDConf ydConf,
+                                            DDService ddService,
+                                            DDClient_Personnel personnelClient,
+                                            DDClient_Contacts contactsClient,
+                                            BentelerYidaConf conf) {
+        this(ydClientForm, ydConf, ddService, personnelClient, contactsClient, conf, Clock.systemUTC());
+    }
+
+    BentelerDingTalkSyncTaskService(YDClient_Form ydClientForm, YDConf ydConf,
+                                     DDService ddService,
+                                     DDClient_Personnel personnelClient,
+                                     DDClient_Contacts contactsClient,
+                                     BentelerYidaConf conf, Clock clock) {
+        this.ydClientForm = ydClientForm;
+        this.ydConf = ydConf;
+        this.ddService = ddService;
+        this.personnelClient = personnelClient;
+        this.contactsClient = contactsClient;
+        this.conf = conf;
+        this.clock = clock;
+    }
+
+    /**
+     * Creates or resets a delayed roster outsourcing-company update.
+     * Blank values are deliberately ignored.
+     *
+     * @param formInstanceId source YiDa form instance ID
+     * @param rowIndex source detail-row index
+     * @param userId DingTalk user ID
+     * @param fieldValue outsourcing-company value
+     */
+    public void enqueueRosterUpdate(String formInstanceId, int rowIndex,
+                                    String userId, String fieldValue) {
+        if (StringUtils.isBlank(fieldValue)) {
+            return;
+        }
+        enqueueRosterFieldUpdate(formInstanceId, rowIndex, userId, fieldValue.trim());
+    }
+
+    /**
+     * Creates or resets a delayed roster outsourcing-company clear.
+     *
+     * @param formInstanceId source YiDa form instance ID
+     * @param rowIndex source detail-row index
+     * @param userId DingTalk user ID
+     */
+    public void enqueueRosterClear(String formInstanceId, int rowIndex, String userId) {
+        if (StringUtils.isBlank(userId)) {
+            return;
+        }
+        enqueueRosterFieldUpdate(formInstanceId, rowIndex, userId, "");
+    }
+
+    private void enqueueRosterFieldUpdate(String formInstanceId, int rowIndex,
+                                          String userId, String fieldValue) {
+        Instant executeAt = clock.instant().plus(10, ChronoUnit.MINUTES);
+        String taskKey = formInstanceId + ":" + rowIndex + ":"
+                + DingTalkSyncTask.ROSTER_FIELD_UPDATE + ":"
+                + conf.getDingTalkRosterOutsourcingFieldCode();
+        Map<String, Object> formData = new HashMap<>();
+        formData.put(conf.getDingTalkSyncTaskKeyFieldId(), taskKey);
+        formData.put(conf.getDingTalkSyncTaskTypeFieldId(), DingTalkSyncTask.ROSTER_FIELD_UPDATE);
+        formData.put(conf.getDingTalkSyncSourceFormInstanceIdFieldId(), formInstanceId);
+        formData.put(conf.getDingTalkSyncSourceRowIndexFieldId(), rowIndex);
+        formData.put(conf.getDingTalkSyncUserIdFieldId(), userId);
+        formData.put(conf.getDingTalkSyncTargetFieldCodeFieldId(),
+                conf.getDingTalkRosterOutsourcingFieldCode());
+        formData.put(conf.getDingTalkSyncTargetFieldValueFieldId(), fieldValue);
+        formData.put(conf.getDingTalkSyncStatusFieldId(), DingTalkSyncTask.PENDING);
+        formData.put(conf.getDingTalkSyncRetryCountFieldId(), 0);
+        formData.put(conf.getDingTalkSyncNextExecuteAtFieldId(), executeAt.toEpochMilli());
+        formData.put(conf.getDingTalkSyncLastErrorFieldId(), "");
+        formData.put(conf.getDingTalkSyncCompletedAtFieldId(), "");
+        Map<String, Object> bodyExt = updateBodyExt(true);
+        ydClientForm.upsertForm(auth(), conf.getDingTalkSyncTaskFormUuid(),
+                taskSearchCondition(taskKey),
+                JSON.toJSONString(formData), bodyExt);
+        reconcileDuplicates(taskKey);
+    }
+
+    /**
+     * Creates or resets a delayed DingTalk primary-department update.
+     *
+     * @param formInstanceId source YiDa form instance ID
+     * @param rowIndex source detail-row index
+     * @param userId DingTalk user ID
+     * @param departmentId target DingTalk department ID
+     */
+    public void enqueueContactPrimaryDepartmentUpdate(String formInstanceId, int rowIndex,
+                                                      String userId, Long departmentId) {
+        if (StringUtils.isBlank(userId) || departmentId == null || departmentId <= 0) {
+            return;
+        }
+        Instant executeAt = clock.instant().plus(10, ChronoUnit.MINUTES);
+        String taskKey = formInstanceId + ":" + rowIndex + ":"
+                + DingTalkSyncTask.CONTACT_PRIMARY_DEPARTMENT_UPDATE + ":" + departmentId;
+        Map<String, Object> formData = new HashMap<>();
+        formData.put(conf.getDingTalkSyncTaskKeyFieldId(), taskKey);
+        formData.put(conf.getDingTalkSyncTaskTypeFieldId(),
+                DingTalkSyncTask.CONTACT_PRIMARY_DEPARTMENT_UPDATE);
+        formData.put(conf.getDingTalkSyncSourceFormInstanceIdFieldId(), formInstanceId);
+        formData.put(conf.getDingTalkSyncSourceRowIndexFieldId(), rowIndex);
+        formData.put(conf.getDingTalkSyncUserIdFieldId(), userId);
+        formData.put(conf.getDingTalkSyncTargetFieldCodeFieldId(), "dept_id_list");
+        formData.put(conf.getDingTalkSyncTargetFieldValueFieldId(), String.valueOf(departmentId));
+        formData.put(conf.getDingTalkSyncStatusFieldId(), DingTalkSyncTask.PENDING);
+        formData.put(conf.getDingTalkSyncRetryCountFieldId(), 0);
+        formData.put(conf.getDingTalkSyncNextExecuteAtFieldId(), executeAt.toEpochMilli());
+        formData.put(conf.getDingTalkSyncLastErrorFieldId(), "");
+        formData.put(conf.getDingTalkSyncCompletedAtFieldId(), "");
+        ydClientForm.upsertForm(auth(), conf.getDingTalkSyncTaskFormUuid(),
+                taskSearchCondition(taskKey),
+                JSON.toJSONString(formData), updateBodyExt(true));
+        reconcileDuplicates(taskKey);
+    }
+
+    private String taskSearchCondition(String taskKey) {
+        Map<String, Object> condition = new HashMap<>();
+        condition.put("key", conf.getDingTalkSyncTaskKeyFieldId());
+        condition.put("value", taskKey);
+        condition.put("type", "TEXT");
+        condition.put("operator", "eq");
+        condition.put("componentName", "TextField");
+        return JSON.toJSONString(Collections.singletonList(condition));
+    }
+
+    /**
+     * Executes every currently due roster task. Failure is isolated per record.
+     */
+    public void executeDueTasks() {
+        if (!executing.compareAndSet(false, true)) {
+            log.info("钉钉同步任务扫描仍在执行,跳过本次本地重叠调度");
+            return;
+        }
+        try {
+            executeDueTasksOnce();
+        } finally {
+            executing.set(false);
+        }
+    }
+
+    private void executeDueTasksOnce() {
+        Instant now = clock.instant();
+        List<DingTalkSyncTask> tasks = queryAllTasks();
+        markDuplicateTasks(tasks, now);
+        for (DingTalkSyncTask task : tasks) {
+            if (!isDueRosterTask(task, now)) {
+                continue;
+            }
+            String claimOwner = CLAIM_PREFIX + UUID.randomUUID().toString();
+            if (!claim(task, claimOwner, now)) {
+                continue;
+            }
+            try {
+                executeTask(task);
+            } catch (Exception ex) {
+                log.warn("钉钉同步任务执行失败, taskId={}, taskKey={}, error={}",
+                        safeText(task.getFormInstanceId()), safeText(task.getTaskKey()),
+                        sanitizedError(ex));
+                try {
+                    saveFailure(task, now, ex);
+                } catch (Exception saveEx) {
+                    log.error("钉钉同步任务失败状态保存失败, taskId={}, taskKey={}, error={}",
+                            safeText(task.getFormInstanceId()), safeText(task.getTaskKey()),
+                            sanitizedError(saveEx));
+                }
+                continue;
+            }
+            Map<String, Object> success = new HashMap<>();
+            success.put(conf.getDingTalkSyncStatusFieldId(), DingTalkSyncTask.SUCCESS);
+            success.put(conf.getDingTalkSyncLastErrorFieldId(), "");
+            success.put(conf.getDingTalkSyncCompletedAtFieldId(), now.toEpochMilli());
+            try {
+                updateTask(task.getFormInstanceId(), success);
+            } catch (Exception ex) {
+                log.error("钉钉同步任务成功状态保存失败, taskId={}, taskKey={}, error={}",
+                        safeText(task.getFormInstanceId()), safeText(task.getTaskKey()),
+                        sanitizedError(ex));
+            }
+        }
+    }
+
+    private void reconcileDuplicates(String taskKey) {
+        Map<String, Object> condition = Collections.singletonMap(
+                conf.getDingTalkSyncTaskKeyFieldId(), taskKey);
+        Map<String, Object> result = ydClientForm.searchForm(auth(),
+                conf.getDingTalkSyncTaskFormUuid(), JSON.toJSONString(condition),
+                1, PAGE_SIZE, null);
+        List<DingTalkSyncTask> matches = new ArrayList<>();
+        for (Object record : records(result)) {
+            try {
+                DingTalkSyncTask task = decode(record);
+                if (isUnfinished(task)) {
+                    matches.add(task);
+                }
+            } catch (RuntimeException ex) {
+                log.warn("钉钉同步任务去重时跳过异常记录, recordId={}, error={}",
+                        safeRecordId(record), sanitizedError(ex));
+            }
+        }
+        markDuplicateTasks(matches, clock.instant());
+    }
+
+    private List<DingTalkSyncTask> queryAllTasks() {
+        List<DingTalkSyncTask> tasks = new ArrayList<>();
+        int page = 1;
+        while (true) {
+            Map<String, Object> result = ydClientForm.searchForm(auth(),
+                    conf.getDingTalkSyncTaskFormUuid(), "{}", page, PAGE_SIZE, null);
+            List<?> records = records(result);
+            for (Object record : records) {
+                try {
+                    tasks.add(decode(record));
+                } catch (RuntimeException ex) {
+                    log.warn("钉钉同步任务扫描跳过异常记录, recordId={}, error={}",
+                            safeRecordId(record), sanitizedError(ex));
+                }
+            }
+            if (records.size() < PAGE_SIZE) {
+                break;
+            }
+            page++;
+        }
+        return tasks;
+    }
+
+    private boolean isDueRosterTask(DingTalkSyncTask task, Instant now) {
+        return (DingTalkSyncTask.ROSTER_FIELD_UPDATE.equals(task.getTaskType())
+                || DingTalkSyncTask.CONTACT_PRIMARY_DEPARTMENT_UPDATE.equals(task.getTaskType()))
+                && (DingTalkSyncTask.PENDING.equals(task.getStatus())
+                || DingTalkSyncTask.RETRYING.equals(task.getStatus()))
+                && task.getNextExecuteAt() != null
+                && !task.getNextExecuteAt().isAfter(now);
+    }
+
+    @SuppressWarnings("unchecked")
+    private void executeTask(DingTalkSyncTask task) {
+        String accessToken = ddService.getAccessToken();
+        if (DingTalkSyncTask.ROSTER_FIELD_UPDATE.equals(task.getTaskType())) {
+            personnelClient.updateEmployeeRosterField(accessToken, conf.getDingTalkRosterAgentId(),
+                    task.getUserId(), conf.getDingTalkRosterGroupId(), task.getTargetFieldCode(),
+                    task.getTargetFieldValue(), null);
+            return;
+        }
+        Map user = contactsClient.getUserInfoById(accessToken, task.getUserId());
+        List<Long> departments = new ArrayList<>();
+        Long target = Long.valueOf(task.getTargetFieldValue());
+        departments.add(target);
+        Object existing = user.get("dept_id_list");
+        if (existing instanceof List) {
+            for (Object value : (List<?>) existing) {
+                Long department = Long.valueOf(String.valueOf(value));
+                if (!target.equals(department)) {
+                    departments.add(department);
+                }
+            }
+        }
+        Map<String, Object> bodyExt = new HashMap<>();
+        bodyExt.put("dept_id_list", departments);
+        contactsClient.updateUser(accessToken, task.getUserId(), bodyExt);
+    }
+
+    private boolean claim(DingTalkSyncTask task, String owner, Instant now) {
+        Map<String, Object> claim = new HashMap<>();
+        claim.put(conf.getDingTalkSyncStatusFieldId(), DingTalkSyncTask.RETRYING);
+        claim.put(conf.getDingTalkSyncNextExecuteAtFieldId(), now.plus(
+                CLAIM_MINUTES, ChronoUnit.MINUTES).toEpochMilli());
+        claim.put(conf.getDingTalkSyncLastErrorFieldId(), owner);
+        try {
+            updateTask(task.getFormInstanceId(), claim, false);
+            DingTalkSyncTask persisted = decode(ydClientForm.getForm(
+                    auth(), task.getFormInstanceId(), null));
+            return owner.equals(persisted.getLastError())
+                    && persisted.getNextExecuteAt() != null
+                    && persisted.getNextExecuteAt().isAfter(now);
+        } catch (RuntimeException ex) {
+            log.info("钉钉同步任务抢占失败, taskId={}, error={}",
+                    safeText(task.getFormInstanceId()), sanitizedError(ex));
+            return false;
+        }
+    }
+
+    private void markDuplicateTasks(List<DingTalkSyncTask> tasks, Instant now) {
+        Map<String, DingTalkSyncTask> canonical = new HashMap<>();
+        for (DingTalkSyncTask task : tasks) {
+            if (!isUnfinished(task) || StringUtils.isBlank(task.getTaskKey())) {
+                continue;
+            }
+            DingTalkSyncTask current = canonical.get(task.getTaskKey());
+            if (current == null || compareId(task, current) < 0) {
+                canonical.put(task.getTaskKey(), task);
+            }
+        }
+        for (DingTalkSyncTask task : tasks) {
+            DingTalkSyncTask keeper = canonical.get(task.getTaskKey());
+            if (keeper == null || keeper == task || !isUnfinished(task)) {
+                continue;
+            }
+            Map<String, Object> duplicate = new HashMap<>();
+            duplicate.put(conf.getDingTalkSyncStatusFieldId(), DingTalkSyncTask.FAILED);
+            duplicate.put(conf.getDingTalkSyncLastErrorFieldId(),
+                    "重复任务,保留实例 " + keeper.getFormInstanceId());
+            duplicate.put(conf.getDingTalkSyncCompletedAtFieldId(), now.toEpochMilli());
+            try {
+                updateTask(task.getFormInstanceId(), duplicate, false);
+                task.setStatus(DingTalkSyncTask.FAILED);
+            } catch (RuntimeException ex) {
+                // Do not execute a duplicate unless its terminal update was durably accepted.
+                task.setNextExecuteAt(now.plus(CLAIM_MINUTES, ChronoUnit.MINUTES));
+                log.warn("钉钉重复同步任务隔离失败, taskId={}, error={}",
+                        safeText(task.getFormInstanceId()), sanitizedError(ex));
+            }
+        }
+    }
+
+    private int compareId(DingTalkSyncTask left, DingTalkSyncTask right) {
+        return StringUtils.defaultString(left.getFormInstanceId()).compareTo(
+                StringUtils.defaultString(right.getFormInstanceId()));
+    }
+
+    private boolean isUnfinished(DingTalkSyncTask task) {
+        return DingTalkSyncTask.PENDING.equals(task.getStatus())
+                || DingTalkSyncTask.RETRYING.equals(task.getStatus());
+    }
+
+    private void saveFailure(DingTalkSyncTask task, Instant now, Exception ex) {
+        int attempts = task.getRetryCount() + 1;
+        Map<String, Object> update = new HashMap<>();
+        update.put(conf.getDingTalkSyncRetryCountFieldId(), attempts);
+        update.put(conf.getDingTalkSyncLastErrorFieldId(), sanitizedError(ex));
+        if (attempts >= MAX_ATTEMPTS) {
+            update.put(conf.getDingTalkSyncStatusFieldId(), DingTalkSyncTask.FAILED);
+            update.put(conf.getDingTalkSyncCompletedAtFieldId(), now.toEpochMilli());
+        } else {
+            update.put(conf.getDingTalkSyncStatusFieldId(), DingTalkSyncTask.RETRYING);
+            update.put(conf.getDingTalkSyncNextExecuteAtFieldId(), now.plus(
+                    retryDelay(attempts), ChronoUnit.MINUTES).toEpochMilli());
+        }
+        updateTask(task.getFormInstanceId(), update);
+    }
+
+    private long retryDelay(int attempts) {
+        if (attempts == 1) {
+            return 15;
+        }
+        if (attempts == 2) {
+            return 30;
+        }
+        return 60;
+    }
+
+    private String sanitizedError(Exception ex) {
+        String message = StringUtils.defaultIfBlank(ex.getMessage(), "未知错误")
+                .replaceAll("[\\r\\n\\t]+", " ");
+        message = SENSITIVE_VALUE.matcher(message).replaceAll("$1=***");
+        String concise = ex.getClass().getSimpleName() + ": " + message;
+        return StringUtils.abbreviate(concise, MAX_ERROR_LENGTH);
+    }
+
+    private void updateTask(String formInstanceId, Map<String, Object> formData) {
+        updateTask(formInstanceId, formData, true);
+    }
+
+    private void updateTask(String formInstanceId, Map<String, Object> formData,
+                            boolean useLatestVersion) {
+        ydClientForm.updateForm(auth(), formInstanceId,
+                JSON.toJSONString(formData), updateBodyExt(useLatestVersion));
+    }
+
+    private Map<String, Object> updateBodyExt(boolean useLatestVersion) {
+        Map<String, Object> bodyExt = new HashMap<>();
+        bodyExt.put("useLatestVersion", useLatestVersion);
+        bodyExt.put("ignoreEmpty", false);
+        return bodyExt;
+    }
+
+    @SuppressWarnings("unchecked")
+    private DingTalkSyncTask decode(Object value) {
+        if (!(value instanceof Map)) {
+            throw new IllegalArgumentException("宜搭任务记录不是对象");
+        }
+        Map<String, Object> record = (Map<String, Object>) value;
+        Map<String, Object> data = formData(record.get("formData"));
+        DingTalkSyncTask task = new DingTalkSyncTask();
+        task.setFormInstanceId(text(record.get("formInstanceId")));
+        if (task.getFormInstanceId() == null) {
+            task.setFormInstanceId(text(record.get("formInstId")));
+        }
+        task.setTaskKey(text(data.get(conf.getDingTalkSyncTaskKeyFieldId())));
+        task.setTaskType(text(data.get(conf.getDingTalkSyncTaskTypeFieldId())));
+        task.setSourceFormInstanceId(text(data.get(
+                conf.getDingTalkSyncSourceFormInstanceIdFieldId())));
+        task.setSourceRowIndex(integer(data.get(conf.getDingTalkSyncSourceRowIndexFieldId()), 0));
+        task.setUserId(text(data.get(conf.getDingTalkSyncUserIdFieldId())));
+        task.setTargetFieldCode(text(data.get(conf.getDingTalkSyncTargetFieldCodeFieldId())));
+        task.setTargetFieldValue(text(data.get(conf.getDingTalkSyncTargetFieldValueFieldId())));
+        task.setRoleGroupId(text(data.get(conf.getDingTalkSyncRoleGroupIdFieldId())));
+        task.setRoleId(text(data.get(conf.getDingTalkSyncRoleIdFieldId())));
+        task.setStatus(text(data.get(conf.getDingTalkSyncStatusFieldId())));
+        task.setRetryCount(integer(data.get(conf.getDingTalkSyncRetryCountFieldId()), 0));
+        task.setNextExecuteAt(instant(data.get(conf.getDingTalkSyncNextExecuteAtFieldId())));
+        task.setLastError(text(data.get(conf.getDingTalkSyncLastErrorFieldId())));
+        task.setCompletedAt(instant(data.get(conf.getDingTalkSyncCompletedAtFieldId())));
+        return task;
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> formData(Object value) {
+        if (value instanceof Map) {
+            return (Map<String, Object>) value;
+        }
+        if (value == null) {
+            return Collections.emptyMap();
+        }
+        Map<String, Object> parsed = JSON.parseObject(String.valueOf(value), Map.class);
+        return parsed == null ? Collections.emptyMap() : parsed;
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<?> records(Map<String, Object> result) {
+        if (result == null || !(result.get("data") instanceof List)) {
+            return Collections.emptyList();
+        }
+        return (List<?>) result.get("data");
+    }
+
+    private String safeRecordId(Object record) {
+        if (!(record instanceof Map)) {
+            return "unknown";
+        }
+        Object id = ((Map<?, ?>) record).get("formInstanceId");
+        if (id == null) {
+            id = ((Map<?, ?>) record).get("formInstId");
+        }
+        return safeText(id);
+    }
+
+    private String safeText(Object value) {
+        String text = value == null ? "unknown" : String.valueOf(value);
+        String sanitized = SENSITIVE_VALUE.matcher(text.replaceAll("[\\r\\n\\t]+", " "))
+                .replaceAll("$1=***");
+        return StringUtils.abbreviate(sanitized, MAX_ERROR_LENGTH);
+    }
+
+    private Integer integer(Object value, int defaultValue) {
+        if (value instanceof Number) {
+            return ((Number) value).intValue();
+        }
+        try {
+            return value == null ? defaultValue : Integer.valueOf(String.valueOf(value));
+        } catch (NumberFormatException ex) {
+            return defaultValue;
+        }
+    }
+
+    private Instant instant(Object value) {
+        if (value instanceof Number) {
+            return Instant.ofEpochMilli(((Number) value).longValue());
+        }
+        if (value == null || StringUtils.isBlank(String.valueOf(value))) {
+            return null;
+        }
+        String text = String.valueOf(value);
+        try {
+            return Instant.ofEpochMilli(Long.parseLong(text));
+        } catch (NumberFormatException ex) {
+            try {
+                return Instant.parse(text);
+            } catch (RuntimeException ignored) {
+                return null;
+            }
+        }
+    }
+
+    private String text(Object value) {
+        return value == null ? null : StringUtils.trimToNull(String.valueOf(value));
+    }
+
+    private YDAuth auth() {
+        return YDAuth.ofGlobal(ydConf);
+    }
+}

+ 6 - 2
mjava-benteler/src/main/java/com/malk/benteler/service/BentelerYidaFormMapper.java

@@ -30,6 +30,7 @@ public class BentelerYidaFormMapper {
     static final String UPDATE_PHONE = "手机号";
     static final String UPDATE_EMPLOYEE_NUMBER = "工号";
     static final String UPDATE_JOB_TITLE = "职位";
+    static final String UPDATE_NAME = "更新姓名";
 
     private final BentelerYidaConf conf;
 
@@ -140,7 +141,7 @@ public class BentelerYidaFormMapper {
             } else if (StringUtils.equals(tableFieldId, conf.getUpdateTableFieldId())) {
                 copyFields(row, target, Arrays.asList(conf.getUpdatePhoneFieldId(),
                         conf.getUpdateEmployeeNumberFieldId(), conf.getUpdateJobTitleFieldId(),
-                        conf.getUpdateDescriptionFieldId()));
+                        conf.getUpdateDescriptionFieldId(), conf.getUpdateNameFieldId()));
                 copyEmployee(row, target, conf.getUpdateEmployeeFieldId());
             } else if (StringUtils.equals(tableFieldId, conf.getOffboardingTableFieldId())) {
                 copyFields(row, target, Arrays.asList(conf.getOffboardingRemarkFieldId()));
@@ -195,7 +196,10 @@ public class BentelerYidaFormMapper {
     }
 
     private void mapUpdatePatchFields(EiamUpdateUserItem item, Map<String, Object> row,
-                                      List<String> updateContents) {
+                                       List<String> updateContents) {
+        if (updateContents.contains(UPDATE_NAME)) {
+            item.setDisplayName(stringValue(row.get(conf.getUpdateNameFieldId())));
+        }
         if (updateContents.contains(UPDATE_PHONE)) {
             String phoneNumber = stringValue(row.get(conf.getUpdatePhoneFieldId()));
             item.setUsername(phoneNumber);

+ 85 - 16
mjava-benteler/src/main/java/com/malk/benteler/service/BentelerYidaSyncService.java

@@ -3,6 +3,7 @@ package com.malk.benteler.service;
 import com.alibaba.fastjson.JSON;
 import com.malk.benteler.config.BentelerYidaConf;
 import com.malk.benteler.dto.EiamBatchOperation;
+import com.malk.benteler.dto.EiamBatchItemResult;
 import com.malk.benteler.dto.EiamBatchResult;
 import com.malk.benteler.dto.EiamFormSyncResult;
 import com.malk.server.aliwork.YDAuth;
@@ -35,17 +36,20 @@ public class BentelerYidaSyncService {
     private final BentelerYidaConf conf;
     private final BentelerYidaFormMapper mapper;
     private final EiamLocalService eiamLocalService;
+    private final BentelerDingTalkSyncTaskService dingTalkSyncTaskService;
 
     public BentelerYidaSyncService(YDClient_Form ydClientForm, YDService ydService,
                                    YDConf ydConf, BentelerYidaConf conf,
                                    BentelerYidaFormMapper mapper,
-                                   EiamLocalService eiamLocalService) {
+                                   EiamLocalService eiamLocalService,
+                                   BentelerDingTalkSyncTaskService dingTalkSyncTaskService) {
         this.ydClientForm = ydClientForm;
         this.ydService = ydService;
         this.ydConf = ydConf;
         this.conf = conf;
         this.mapper = mapper;
         this.eiamLocalService = eiamLocalService;
+        this.dingTalkSyncTaskService = dingTalkSyncTaskService;
     }
 
     /**
@@ -87,13 +91,76 @@ public class BentelerYidaSyncService {
         Map<String, Object> instance = ydClientForm.getForm(auth(), formInstanceId, null);
         validateForm(instance, expectedFormUuid);
         Map<String, Object> formData = formData(instance);
-        List<Map<String, Object>> rows = resolveDetailRows(formInstanceId, tableFieldId,
-                formData.get(tableFieldId));
+        List<Map<String, Object>> rows = resolveDetailRows(formInstanceId, expectedFormUuid,
+                tableFieldId, formData.get(tableFieldId));
         if (rows.isEmpty()) {
             throw new McException("YIDA_FORM_EMPTY", "宜搭人员子表不能为空");
         }
         EiamBatchResult batchResult = execute(operation, formInstanceId, formData, rows);
-        return writeback(formInstanceId, tableFieldId, rows, batchResult);
+        EiamFormSyncResult result = writeback(formInstanceId, expectedFormUuid, tableFieldId,
+                rows, batchResult);
+        if (result.isWritebackSuccess()) {
+            enqueueDingTalkTasks(operation, formInstanceId, formData, rows, batchResult);
+        }
+        return result;
+    }
+
+    private void enqueueDingTalkTasks(EiamBatchOperation operation, String formInstanceId,
+                                    Map<String, Object> formData,
+                                    List<Map<String, Object>> rows,
+                                    EiamBatchResult batchResult) {
+        String fieldValue = operation == EiamBatchOperation.CREATE
+                ? text(formData.get(conf.getOnboardingOutsourcingCompanyFieldId()))
+                : operation == EiamBatchOperation.UPDATE
+                ? text(formData.get(conf.getUpdateOutsourcingCompanyFieldId())) : null;
+        boolean clearOutsourcing = operation == EiamBatchOperation.UPDATE
+                && "BATJ II".equals(text(formData.get(conf.getUpdateFactoryFieldId())))
+                && "是".equals(text(formData.get(conf.getUpdateProbationConfirmationFieldId())));
+        List<String> updateContents = operation == EiamBatchOperation.UPDATE
+                ? texts(formData.get(conf.getUpdateContentFieldId())) : new ArrayList<>();
+        Long dingTalkDepartmentId = updateContents.contains(BentelerYidaFormMapper.ADD_ORG)
+                ? longValue(formData.get(conf.getUpdateDingTalkDepartmentFieldId())) : null;
+        if (StringUtils.isBlank(fieldValue) && !clearOutsourcing && dingTalkDepartmentId == null) {
+            return;
+        }
+        for (EiamBatchItemResult item : batchResult.getItems()) {
+            if (!item.isSuccess() || item.getIndex() < 0 || item.getIndex() >= rows.size()) {
+                continue;
+            }
+            String userId = operation == EiamBatchOperation.CREATE ? item.getUserId()
+                    : firstText(rows.get(item.getIndex()).get(conf.getUpdateEmployeeFieldId() + "_id"));
+            if (StringUtils.isBlank(userId)) {
+                continue;
+            }
+            try {
+                if (clearOutsourcing) {
+                    dingTalkSyncTaskService.enqueueRosterClear(formInstanceId, item.getIndex(), userId);
+                } else if (StringUtils.isNotBlank(fieldValue)) {
+                    dingTalkSyncTaskService.enqueueRosterUpdate(formInstanceId, item.getIndex(),
+                            userId, fieldValue);
+                }
+                if (dingTalkDepartmentId != null) {
+                    dingTalkSyncTaskService.enqueueContactPrimaryDepartmentUpdate(formInstanceId,
+                            item.getIndex(), userId, dingTalkDepartmentId);
+                }
+            } catch (RuntimeException ex) {
+                log.error("钉钉花名册同步任务创建失败, formInstanceId={}, rowIndex={}",
+                        formInstanceId, item.getIndex(), ex);
+            }
+        }
+    }
+
+    private Long longValue(Object value) {
+        Object selected = value instanceof List && !((List<?>) value).isEmpty()
+                ? ((List<?>) value).get(0) : value;
+        if (selected instanceof Map) {
+            selected = ((Map<?, ?>) selected).get("value");
+        }
+        try {
+            return selected == null ? null : Long.valueOf(String.valueOf(selected));
+        } catch (NumberFormatException ex) {
+            return null;
+        }
     }
 
     private EiamBatchResult execute(EiamBatchOperation operation, String formInstanceId,
@@ -128,16 +195,16 @@ public class BentelerYidaSyncService {
         return eiamLocalService.batchDelete(mapper.mapDeleteItems(formInstanceId, rows));
     }
 
-    private EiamFormSyncResult writeback(String formInstanceId, String tableFieldId,
-                                         List<Map<String, Object>> rows,
-                                         EiamBatchResult batchResult) {
+    private EiamFormSyncResult writeback(String formInstanceId, String formUuid, String tableFieldId,
+                                          List<Map<String, Object>> rows,
+                                          EiamBatchResult batchResult) {
         Map<String, Object> update = buildWritebackUpdate(tableFieldId, rows, batchResult);
         try {
             updateForm(formInstanceId, update);
             return EiamFormSyncResult.of(formInstanceId, batchResult, true, "回写成功");
         } catch (RuntimeException ex) {
             if (isVersionConflict(ex)) {
-                return retryWriteback(formInstanceId, tableFieldId, rows, batchResult);
+                return retryWriteback(formInstanceId, formUuid, tableFieldId, rows, batchResult);
             }
             log.error("宜搭实例执行结果回写失败, formInstanceId={}", formInstanceId, ex);
             return writebackFailure(formInstanceId, batchResult, ex);
@@ -165,15 +232,16 @@ public class BentelerYidaSyncService {
         ydClientForm.updateForm(auth(), formInstanceId, JSON.toJSONString(update), bodyExt);
     }
 
-    private EiamFormSyncResult retryWriteback(String formInstanceId, String tableFieldId,
-                                              List<Map<String, Object>> originalRows,
-                                              EiamBatchResult batchResult) {
+    private EiamFormSyncResult retryWriteback(String formInstanceId, String formUuid,
+                                               String tableFieldId,
+                                               List<Map<String, Object>> originalRows,
+                                               EiamBatchResult batchResult) {
         log.warn("宜搭实例版本冲突,重新读取后重试回写, formInstanceId={}", formInstanceId);
         try {
             Map<String, Object> latestInstance = ydClientForm.getForm(auth(), formInstanceId, null);
             Map<String, Object> latestFormData = formData(latestInstance);
-            List<Map<String, Object>> latestRows = resolveDetailRows(formInstanceId, tableFieldId,
-                    latestFormData.get(tableFieldId));
+            List<Map<String, Object>> latestRows = resolveDetailRows(formInstanceId, formUuid,
+                    tableFieldId, latestFormData.get(tableFieldId));
             if (!samePeople(originalRows, latestRows, batchResult.getOperation())) {
                 throw new McException("YIDA_FORM_ROWS_CHANGED",
                         "宜搭人员子表已变化,停止自动重试回写");
@@ -242,9 +310,9 @@ public class BentelerYidaSyncService {
     }
 
     @SuppressWarnings("unchecked")
-    private List<Map<String, Object>> resolveDetailRows(String formInstanceId,
-                                                        String tableFieldId,
-                                                        Object inlineValue) {
+    private List<Map<String, Object>> resolveDetailRows(String formInstanceId, String formUuid,
+                                                         String tableFieldId,
+                                                         Object inlineValue) {
         List<Map<String, Object>> inlineRows = inlineValue instanceof List
                 ? (List<Map<String, Object>>) inlineValue : new ArrayList<>();
         // fixme 详情接口最多内联 50 行;只有恰好 50 行时才可能被截断,必须单独分页查询子表。
@@ -253,6 +321,7 @@ public class BentelerYidaSyncService {
                 List<Map> details = ydService.queryDetails(YDParam.builder()
                         .appType(ydConf.getAppType())
                         .systemToken(ydConf.getSystemToken())
+                        .formUuid(formUuid)
                         .formInstanceId(formInstanceId)
                         .tableFieldId(tableFieldId)
                         .pageNumber(1)

+ 27 - 8
mjava-benteler/src/main/java/com/malk/benteler/service/EiamOrganizationalUnitLocalService.java

@@ -110,7 +110,7 @@ public class EiamOrganizationalUnitLocalService {
         List<EiamOrganizationalUnitOption> organizationUnits = new ArrayList<>();
         Queue<OrganizationalUnitNode> queue = new ArrayDeque<>();
         Set<String> visited = new HashSet<>();
-        queue.add(new OrganizationalUnitNode(ROOT_ID, null));
+        queue.add(new OrganizationalUnitNode(ROOT_ID, Collections.<String>emptyList()));
         while (!queue.isEmpty()) {
             OrganizationalUnitNode parent = queue.poll();
             if (!visited.add(parent.getId())) {
@@ -121,12 +121,16 @@ public class EiamOrganizationalUnitLocalService {
                 if (child == null || StringUtils.isBlank(child.getOrganizationalUnitId())) {
                     continue;
                 }
+                List<String> ancestorNames = new ArrayList<>(parent.getAncestorNames());
+                if (StringUtils.isNotBlank(parent.getName())) {
+                    ancestorNames.add(parent.getName());
+                }
                 queue.add(new OrganizationalUnitNode(child.getOrganizationalUnitId(),
-                        child.getOrganizationalUnitName()));
+                        child.getOrganizationalUnitName(), ancestorNames));
                 organizationUnits.add(EiamOrganizationalUnitOption.builder()
                         .organizationalUnitId(child.getOrganizationalUnitId())
                         .organizationalUnitName(displayName(child.getOrganizationalUnitName(),
-                                parent.getName()))
+                                ancestorNames))
                         .build());
             }
         }
@@ -182,10 +186,15 @@ public class EiamOrganizationalUnitLocalService {
                 StringUtils.trim(keyword));
     }
 
-    private String displayName(String currentName, String parentName) {
-        // prd 同名部门显示格式统一为“当前部门名称 (父部门名称)”,ID 保持不变。
-        return StringUtils.isBlank(parentName)
-                ? currentName : currentName + " (" + parentName + ")";
+    private String displayName(String currentName, List<String> ancestorNames) {
+        // 同名部门仅显示最近两级祖先,ID 保持不变。
+        if (ancestorNames.isEmpty()) {
+            return currentName;
+        }
+        int start = Math.max(0, ancestorNames.size() - 2);
+        List<String> displayAncestors = ancestorNames.subList(start, ancestorNames.size());
+        return displayAncestors.isEmpty()
+                ? currentName : currentName + " (" + StringUtils.join(displayAncestors, "-") + ")";
     }
 
     private String currentName(String displayName) {
@@ -199,10 +208,16 @@ public class EiamOrganizationalUnitLocalService {
 
         private final String id;
         private final String name;
+        private final List<String> ancestorNames;
+
+        private OrganizationalUnitNode(String id, List<String> ancestorNames) {
+            this(id, null, ancestorNames);
+        }
 
-        private OrganizationalUnitNode(String id, String name) {
+        private OrganizationalUnitNode(String id, String name, List<String> ancestorNames) {
             this.id = id;
             this.name = name;
+            this.ancestorNames = ancestorNames;
         }
 
         private String getId() {
@@ -212,5 +227,9 @@ public class EiamOrganizationalUnitLocalService {
         private String getName() {
             return name;
         }
+
+        private List<String> getAncestorNames() {
+            return ancestorNames;
+        }
     }
 }

+ 24 - 0
mjava-benteler/src/main/resources/application.yml

@@ -44,6 +44,7 @@ benteler:
     onboardingCompanyFieldId: selectField_mrcw2nqw
     onboardingCompanyCodeFieldId: textField_mrn4cg3r
     onboardingDepartmentFieldId: textField_mrncpnxi
+    onboardingOutsourcingCompanyFieldId: selectField_msfvoxmn
     updateFormUuid: FORM-37575577023F4ADE90685238D6A86D14908Y
     updateTableFieldId: tableField_mrcw2nqc
     updateEmployeeFieldId: employeeField_mrcxpt3g
@@ -53,6 +54,11 @@ benteler:
     updatePhoneFieldId: textField_mrt7u3hp
     updateEmployeeNumberFieldId: textField_mrofclv5
     updateJobTitleFieldId: selectField_mrn4cg3s
+    updateOutsourcingCompanyFieldId: textField_msdyohrv
+    updateNameFieldId: textField_mti9mulz
+    updateFactoryFieldId: selectField_motg7pxc
+    updateProbationConfirmationFieldId: selectField_mti9mum0
+    updateDingTalkDepartmentFieldId: departmentSelectField_mrcw2nqg
     offboardingFormUuid: FORM-3D8B56B5A9EE45738A03D4DB6C1D22E16Z5T
     offboardingTableFieldId: tableField_mrcxjcq5
     offboardingEmployeeFieldId: employeeField_mrcxgkq0
@@ -65,6 +71,24 @@ benteler:
     totalFieldId: numberField_mrcxjcq9
     successFieldId: numberField_mrcxjcqe
     failedFieldId: numberField_mrcxjcqf
+    dingTalkSyncTaskFormUuid: FORM-F1522CE0009A4C18BD4784576880BD04K2GR
+    dingTalkSyncTaskKeyFieldId: textField_w2ce1wtbh
+    dingTalkSyncTaskTypeFieldId: selectField_w2cf2agqx
+    dingTalkSyncSourceFormInstanceIdFieldId: textField_w2cf3slga
+    dingTalkSyncSourceRowIndexFieldId: numberField_w2cf49v04
+    dingTalkSyncUserIdFieldId: textField_w2cf5e4ip
+    dingTalkSyncTargetFieldCodeFieldId: textField_w2cf6k19o
+    dingTalkSyncTargetFieldValueFieldId: textField_w2cf7dzm9
+    dingTalkSyncRoleGroupIdFieldId: textField_w2cf80p9s
+    dingTalkSyncRoleIdFieldId: textField_w2cf9bmml
+    dingTalkSyncStatusFieldId: selectField_w2cfaev00
+    dingTalkSyncRetryCountFieldId: numberField_w2cfb9mzh
+    dingTalkSyncNextExecuteAtFieldId: dateField_w2cgcrvbj
+    dingTalkSyncLastErrorFieldId: textareaField_w2cgd9ozq
+    dingTalkSyncCompletedAtFieldId: dateField_w2cge7g3r
+    dingTalkRosterAgentId: ${BENTELER_DINGTALK_ROSTER_AGENT_ID:4784847516}
+    dingTalkRosterGroupId: ${BENTELER_DINGTALK_ROSTER_GROUP_ID:sys00}
+    dingTalkRosterOutsourcingFieldCode: aea319c2-91fa-45e4-b3f8-55b1627fce36
 
 mjava:
   auth:

+ 60 - 0
mjava-benteler/src/test/java/com/malk/benteler/schedule/BentelerDingTalkSyncScheduleTest.java

@@ -0,0 +1,60 @@
+package com.malk.benteler.schedule;
+
+import com.malk.benteler.service.BentelerDingTalkSyncTaskService;
+import org.junit.Test;
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
+import org.springframework.core.env.MapPropertySource;
+
+import java.util.Collections;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Scheduler feature-flag tests.
+ */
+public class BentelerDingTalkSyncScheduleTest {
+
+    @Test
+    public void schedulerFlag_trueCreatesSchedulerBean() {
+        AnnotationConfigApplicationContext context = context("true");
+        try {
+            assertEquals(1, context.getBeansOfType(BentelerDingTalkSyncSchedule.class).size());
+        } finally {
+            context.close();
+        }
+    }
+
+    @Test
+    public void schedulerFlag_falseDoesNotCreateSchedulerBean() {
+        AnnotationConfigApplicationContext context = context("false");
+        try {
+            assertEquals(0, context.getBeansOfType(BentelerDingTalkSyncSchedule.class).size());
+        } finally {
+            context.close();
+        }
+    }
+
+    @Test
+    public void schedulerFlag_missingDoesNotCreateSchedulerBean() {
+        AnnotationConfigApplicationContext context = context(null);
+        try {
+            assertEquals(0, context.getBeansOfType(BentelerDingTalkSyncSchedule.class).size());
+        } finally {
+            context.close();
+        }
+    }
+
+    private AnnotationConfigApplicationContext context(String flag) {
+        AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
+        if (flag != null) {
+            context.getEnvironment().getPropertySources().addFirst(new MapPropertySource(
+                    "test", Collections.<String, Object>singletonMap("spel.scheduling", flag)));
+        }
+        context.getBeanFactory().registerSingleton("taskService",
+                mock(BentelerDingTalkSyncTaskService.class));
+        context.register(BentelerDingTalkSyncSchedule.class);
+        context.refresh();
+        return context;
+    }
+}

+ 518 - 0
mjava-benteler/src/test/java/com/malk/benteler/service/BentelerDingTalkSyncTaskServiceTest.java

@@ -0,0 +1,518 @@
+package com.malk.benteler.service;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.benteler.config.BentelerYidaConf;
+import com.malk.benteler.dto.DingTalkSyncTask;
+import com.malk.server.aliwork.YDAuth;
+import com.malk.server.aliwork.YDConf;
+import com.malk.service.aliwork.YDClient_Form;
+import com.malk.service.dingtalk.DDClient_Personnel;
+import com.malk.service.dingtalk.DDClient_Contacts;
+import com.malk.service.dingtalk.DDService;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * {@link BentelerDingTalkSyncTaskService} durable queue behavior tests.
+ */
+public class BentelerDingTalkSyncTaskServiceTest {
+
+    private static final Instant NOW = Instant.parse("2026-08-15T08:00:00Z");
+
+    private YDClient_Form ydClientForm;
+    private DDService ddService;
+    private DDClient_Personnel personnelClient;
+    private DDClient_Contacts contactsClient;
+    private BentelerYidaConf conf;
+    private BentelerDingTalkSyncTaskService service;
+
+    @Before
+    public void setUp() {
+        ydClientForm = mock(YDClient_Form.class);
+        ddService = mock(DDService.class);
+        personnelClient = mock(DDClient_Personnel.class);
+        contactsClient = mock(DDClient_Contacts.class);
+        conf = TestBentelerYidaConf.create();
+        YDConf ydConf = new YDConf();
+        ydConf.setAppType("APP_TEST");
+        ydConf.setSystemToken("SYSTEM_TEST");
+        service = new BentelerDingTalkSyncTaskService(ydClientForm, ydConf, ddService,
+                personnelClient, contactsClient, conf, Clock.fixed(NOW, ZoneOffset.UTC));
+    }
+
+    @Test
+    public void enqueueRosterUpdate_createsPendingTaskTenMinutesLater() {
+        service.enqueueRosterUpdate("source_1", 0, "user_1", "众川");
+
+        Map<String, Object> saved = capturedUpsertedForm();
+        assertEquals("source_1:0:ROSTER_FIELD_UPDATE:outsourcing_code", saved.get("task_key"));
+        assertEquals(DingTalkSyncTask.ROSTER_FIELD_UPDATE, saved.get("task_type"));
+        assertEquals(DingTalkSyncTask.PENDING, saved.get("task_status"));
+        assertEquals(0, ((Number) saved.get("retry_count")).intValue());
+        assertEquals(NOW.plusSeconds(600).toEpochMilli(),
+                ((Number) saved.get("next_execute_at")).longValue());
+    }
+
+    @Test
+    public void enqueueRosterUpdate_usesStructuredSearchCondition() {
+        service.enqueueRosterUpdate("source_1", 0, "user_1", "众川");
+
+        ArgumentCaptor<String> search = ArgumentCaptor.forClass(String.class);
+        verify(ydClientForm).upsertForm(any(YDAuth.class), eq("FORM_TASK"),
+                search.capture(), anyString(), anyMap());
+        List<Map> conditions = JSON.parseArray(search.getValue(), Map.class);
+        assertEquals(1, conditions.size());
+        assertEquals("task_key", conditions.get(0).get("key"));
+        assertEquals("source_1:0:ROSTER_FIELD_UPDATE:outsourcing_code",
+                conditions.get(0).get("value"));
+        assertEquals("TEXT", conditions.get(0).get("type"));
+        assertEquals("eq", conditions.get(0).get("operator"));
+        assertEquals("TextField", conditions.get(0).get("componentName"));
+    }
+
+    @Test
+    public void enqueueRosterUpdate_blankValueDoesNotCreateTask() {
+        service.enqueueRosterUpdate("source_1", 0, "user_1", "  ");
+
+        verify(ydClientForm, never()).upsertForm(any(), anyString(), anyString(),
+                anyString(), anyMap());
+    }
+
+    @Test
+    public void enqueueRosterClear_createsTaskWithEmptyFieldValue() {
+        service.enqueueRosterClear("source_1", 0, "user_1");
+
+        assertEquals("", capturedUpsertedForm().get("field_value"));
+    }
+
+    @Test
+    public void executeDueTasks_contactDepartmentPutsTargetDepartmentFirst() {
+        Map<String, Object> record = taskRecord("task_1", DingTalkSyncTask.PENDING, 0,
+                NOW, "user_1", "100");
+        Map<String, Object> data = formData(record);
+        data.put("task_type", DingTalkSyncTask.CONTACT_PRIMARY_DEPARTMENT_UPDATE);
+        data.put("task_key", "task_1:0:CONTACT_PRIMARY_DEPARTMENT_UPDATE:100");
+        dueTasks(record);
+        when(ddService.getAccessToken()).thenReturn("cached_token");
+        when(contactsClient.getUserInfoById("cached_token", "user_1"))
+                .thenReturn(Collections.singletonMap("dept_id_list", Arrays.asList(200L, 100L)));
+
+        service.executeDueTasks();
+
+        ArgumentCaptor<Map> bodyCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(contactsClient).updateUser(eq("cached_token"), eq("user_1"), bodyCaptor.capture());
+        assertEquals(Arrays.asList(100L, 200L), bodyCaptor.getValue().get("dept_id_list"));
+    }
+
+    @Test
+    public void enqueueRosterUpdate_existingUnfinishedTaskIsResetInsteadOfDuplicated() {
+        when(ydClientForm.searchForm(any(YDAuth.class), eq("FORM_TASK"), anyString(),
+                eq(1), eq(100), isNull())).thenReturn(page(Collections.singletonList(
+                taskRecord("task_1", DingTalkSyncTask.RETRYING, 3,
+                        NOW.minusSeconds(60), "old_user", "old value"))));
+
+        service.enqueueRosterUpdate("source_1", 0, "new_user", "new value");
+
+        Map<String, Object> updated = capturedUpsertedForm();
+        assertEquals("new_user", updated.get("user_id"));
+        assertEquals("new value", updated.get("field_value"));
+        assertEquals(DingTalkSyncTask.PENDING, updated.get("task_status"));
+        assertEquals(0, ((Number) updated.get("retry_count")).intValue());
+        assertEquals(NOW.plusSeconds(600).toEpochMilli(),
+                ((Number) updated.get("next_execute_at")).longValue());
+        assertEquals("", updated.get("last_error"));
+        assertEquals("", updated.get("completed_at"));
+    }
+
+    @Test
+    public void enqueueRosterUpdate_concurrentCallsUseSameAtomicUpsertKey() throws Exception {
+        CountDownLatch start = new CountDownLatch(1);
+        Thread first = new Thread(() -> awaitAndEnqueue(start));
+        Thread second = new Thread(() -> awaitAndEnqueue(start));
+        first.start();
+        second.start();
+        start.countDown();
+        first.join(5000);
+        second.join(5000);
+
+        ArgumentCaptor<String> search = ArgumentCaptor.forClass(String.class);
+        verify(ydClientForm, times(2)).upsertForm(any(YDAuth.class), eq("FORM_TASK"),
+                search.capture(), anyString(), anyMap());
+        List<Map> expected = Collections.singletonList(new HashMap<String, Object>() {{
+            put("key", "task_key");
+            put("value", "source_1:0:ROSTER_FIELD_UPDATE:outsourcing_code");
+            put("type", "TEXT");
+            put("operator", "eq");
+            put("componentName", "TextField");
+        }});
+        assertEquals(expected, JSON.parseArray(search.getAllValues().get(0), Map.class));
+        assertEquals(expected, JSON.parseArray(search.getAllValues().get(1), Map.class));
+        verify(ydClientForm, never()).saveForm(any(), anyString(), anyString(), any());
+    }
+
+    @Test
+    public void executeDueTasks_futureTaskIsNotExecuted() {
+        when(ydClientForm.searchForm(any(YDAuth.class), eq("FORM_TASK"), eq("{}"),
+                eq(1), eq(100), isNull())).thenReturn(page(Collections.singletonList(
+                taskRecord("task_1", DingTalkSyncTask.PENDING, 0,
+                        NOW.plusSeconds(1), "user_1", "众川"))));
+
+        service.executeDueTasks();
+
+        verify(personnelClient, never()).updateEmployeeRosterField(anyString(), any(),
+                anyString(), anyString(), anyString(), anyString(), any());
+        verify(ydClientForm, never()).updateForm(any(), anyString(), anyString(), anyMap());
+    }
+
+    @Test
+    public void executeDueTasks_successMarksTaskCompleted() {
+        dueTasks(taskRecord("task_1", DingTalkSyncTask.PENDING, 0,
+                NOW, "user_1", "众川"));
+        when(ddService.getAccessToken()).thenReturn("cached_token");
+
+        service.executeDueTasks();
+
+        verify(personnelClient).updateEmployeeRosterField("cached_token", 4784847516L,
+                "user_1", "sys00", "outsourcing_code", "众川", null);
+        Map<String, Object> updated = capturedUpdate("task_1");
+        assertEquals(DingTalkSyncTask.SUCCESS, updated.get("task_status"));
+        assertEquals(NOW.toEpochMilli(), ((Number) updated.get("completed_at")).longValue());
+        assertEquals("", updated.get("last_error"));
+    }
+
+    @Test
+    public void executeDueTasks_firstFailureRetriesAfterFifteenMinutes() {
+        dueTasks(taskRecord("task_1", DingTalkSyncTask.PENDING, 0,
+                NOW, "user_1", "众川"));
+        when(ddService.getAccessToken()).thenReturn("cached_token");
+        doThrow(new IllegalStateException("temporary failure"))
+                .when(personnelClient).updateEmployeeRosterField(anyString(), any(),
+                anyString(), anyString(), anyString(), anyString(), any());
+
+        service.executeDueTasks();
+
+        Map<String, Object> updated = capturedUpdate("task_1");
+        assertEquals(DingTalkSyncTask.RETRYING, updated.get("task_status"));
+        assertEquals(1, ((Number) updated.get("retry_count")).intValue());
+        assertEquals(NOW.plusSeconds(900).toEpochMilli(),
+                ((Number) updated.get("next_execute_at")).longValue());
+        assertEquals("IllegalStateException: temporary failure", updated.get("last_error"));
+    }
+
+    @Test
+    public void executeDueTasks_fifthFailureMarksTaskFailed() {
+        dueTasks(taskRecord("task_1", DingTalkSyncTask.RETRYING, 4,
+                NOW, "user_1", "众川"));
+        when(ddService.getAccessToken()).thenReturn("cached_token");
+        doThrow(new IllegalStateException("permanent failure"))
+                .when(personnelClient).updateEmployeeRosterField(anyString(), any(),
+                anyString(), anyString(), anyString(), anyString(), any());
+
+        service.executeDueTasks();
+
+        Map<String, Object> updated = capturedUpdate("task_1");
+        assertEquals(DingTalkSyncTask.FAILED, updated.get("task_status"));
+        assertEquals(5, ((Number) updated.get("retry_count")).intValue());
+        assertEquals(NOW.toEpochMilli(), ((Number) updated.get("completed_at")).longValue());
+    }
+
+    @Test
+    public void executeDueTasks_failureIsSanitizedAndDoesNotStopNextTask() {
+        dueTasks(taskRecord("task_1", DingTalkSyncTask.PENDING, 0,
+                        NOW, "user_1", "众川"),
+                taskRecord("task_2", DingTalkSyncTask.PENDING, 0,
+                        NOW, "user_2", "外包二"));
+        when(ddService.getAccessToken()).thenReturn("cached_token");
+        doThrow(new IllegalStateException("access_token=secret\nrequest failed"))
+                .doNothing().when(personnelClient).updateEmployeeRosterField(
+                anyString(), any(), anyString(), anyString(), anyString(), anyString(), any());
+
+        service.executeDueTasks();
+
+        verify(personnelClient, times(2)).updateEmployeeRosterField(anyString(), any(),
+                anyString(), anyString(), anyString(), anyString(), any());
+        List<Map<String, Object>> updates = capturedUpdates();
+        assertEquals("IllegalStateException: access_token=*** request failed",
+                updates.get(0).get("last_error"));
+        assertEquals(DingTalkSyncTask.SUCCESS, updates.get(1).get("task_status"));
+    }
+
+    @Test
+    public void executeDueTasks_failureStateWriteDoesNotStopNextTask() {
+        dueTasks(taskRecord("task_1", DingTalkSyncTask.PENDING, 0,
+                        NOW, "user_1", "众川"),
+                taskRecord("task_2", DingTalkSyncTask.PENDING, 0,
+                        NOW, "user_2", "外包二"));
+        when(ddService.getAccessToken()).thenReturn("cached_token");
+        doThrow(new IllegalStateException("DingTalk failed"))
+                .doNothing().when(personnelClient).updateEmployeeRosterField(
+                anyString(), any(), anyString(), anyString(), anyString(), anyString(), any());
+        when(ydClientForm.updateForm(any(YDAuth.class), eq("task_1"), anyString(), anyMap()))
+                .thenReturn(Collections.emptyMap())
+                .thenThrow(new IllegalStateException("YiDa failed"));
+
+        service.executeDueTasks();
+
+        verify(personnelClient, times(2)).updateEmployeeRosterField(anyString(), any(),
+                anyString(), anyString(), anyString(), anyString(), any());
+        verify(ydClientForm, org.mockito.Mockito.atLeastOnce()).updateForm(
+                any(YDAuth.class), eq("task_2"), anyString(), anyMap());
+    }
+
+    @Test
+    public void executeDueTasks_malformedRecordDoesNotStopValidTask() {
+        Map<String, Object> malformed = new HashMap<>();
+        malformed.put("formInstanceId", "task_bad\naccess_token=secret");
+        malformed.put("formData", "{not-json");
+        dueTasks(malformed, taskRecord("task_2", DingTalkSyncTask.PENDING, 0,
+                NOW, "user_2", "外包二"));
+        claimSucceeds("task_2");
+        when(ddService.getAccessToken()).thenReturn("cached_token");
+
+        service.executeDueTasks();
+
+        verify(personnelClient).updateEmployeeRosterField("cached_token", 4784847516L,
+                "user_2", "sys00", "outsourcing_code", "外包二", null);
+    }
+
+    @Test
+    public void executeDueTasks_competingClaimOwnerPreventsRosterCall() {
+        dueTasks(taskRecord("task_1", DingTalkSyncTask.PENDING, 0,
+                NOW, "user_1", "众川"));
+        when(ydClientForm.getForm(any(YDAuth.class), eq("task_1"), isNull()))
+                .thenReturn(claimedTask("task_1", "CLAIM:other-instance",
+                        NOW.plusSeconds(600)));
+
+        service.executeDueTasks();
+
+        verify(personnelClient, never()).updateEmployeeRosterField(anyString(), any(),
+                anyString(), anyString(), anyString(), anyString(), any());
+        ArgumentCaptor<String> claimJson = ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<Map> claimExt = ArgumentCaptor.forClass(Map.class);
+        verify(ydClientForm).updateForm(any(YDAuth.class), eq("task_1"),
+                claimJson.capture(), claimExt.capture());
+        Map<String, Object> claim = JSON.parseObject(claimJson.getValue(), Map.class);
+        assertEquals(NOW.plusSeconds(600).toEpochMilli(),
+                ((Number) claim.get("next_execute_at")).longValue());
+        assertEquals(false, claimExt.getValue().get("useLatestVersion"));
+    }
+
+    @Test
+    public void executeDueTasks_expiredClaimIsRecoveredAndExecuted() {
+        Map<String, Object> abandoned = taskRecord("task_1", DingTalkSyncTask.RETRYING, 2,
+                NOW, "user_1", "众川");
+        formData(abandoned).put("last_error", "CLAIM:dead-instance");
+        dueTasks(abandoned);
+        claimSucceeds("task_1");
+        when(ddService.getAccessToken()).thenReturn("cached_token");
+
+        service.executeDueTasks();
+
+        verify(personnelClient).updateEmployeeRosterField("cached_token", 4784847516L,
+                "user_1", "sys00", "outsourcing_code", "众川", null);
+    }
+
+    @Test
+    public void executeDueTasks_overlappingLocalRunReturnsWithoutSecondScan() throws Exception {
+        CountDownLatch entered = new CountDownLatch(1);
+        CountDownLatch release = new CountDownLatch(1);
+        when(ydClientForm.searchForm(any(YDAuth.class), eq("FORM_TASK"), eq("{}"),
+                eq(1), eq(100), isNull())).thenAnswer(invocation -> {
+            entered.countDown();
+            release.await(5, TimeUnit.SECONDS);
+            return page(Collections.emptyList());
+        });
+        Thread first = new Thread(service::executeDueTasks);
+        first.start();
+        assertTrue(entered.await(5, TimeUnit.SECONDS));
+
+        service.executeDueTasks();
+        release.countDown();
+        first.join(5000);
+
+        verify(ydClientForm, times(1)).searchForm(any(YDAuth.class), eq("FORM_TASK"),
+                eq("{}"), eq(1), eq(100), isNull());
+    }
+
+    @Test
+    public void executeDueTasks_retryDelaysCoverThirtyAndSixtyMinuteBranches() {
+        assertRetryDelay(1, 30);
+        assertRetryDelay(2, 60);
+        assertRetryDelay(3, 60);
+    }
+
+    @Test
+    public void executeDueTasks_duplicateUnfinishedKeyExecutesOnlyCanonicalRecord() {
+        Map<String, Object> first = taskRecord("task_1", DingTalkSyncTask.PENDING, 0,
+                NOW, "user_1", "众川");
+        Map<String, Object> duplicate = taskRecord("task_2", DingTalkSyncTask.PENDING, 0,
+                NOW, "user_1", "众川");
+        formData(duplicate).put("task_key", formData(first).get("task_key"));
+        dueTasks(first, duplicate);
+        claimSucceeds("task_1");
+        when(ddService.getAccessToken()).thenReturn("cached_token");
+
+        service.executeDueTasks();
+
+        verify(personnelClient, times(1)).updateEmployeeRosterField(anyString(), any(),
+                anyString(), anyString(), anyString(), anyString(), any());
+        Map<String, Object> duplicateUpdate = capturedUpdate("task_2");
+        assertEquals(DingTalkSyncTask.FAILED, duplicateUpdate.get("task_status"));
+        assertTrue(String.valueOf(duplicateUpdate.get("last_error")).contains("重复任务"));
+    }
+
+    private void assertRetryDelay(int existingRetryCount, long expectedMinutes) {
+        resetMocks();
+        dueTasks(taskRecord("task_retry", DingTalkSyncTask.RETRYING, existingRetryCount,
+                NOW, "user_1", "众川"));
+        claimSucceeds("task_retry");
+        when(ddService.getAccessToken()).thenReturn("cached_token");
+        doThrow(new IllegalStateException("temporary failure"))
+                .when(personnelClient).updateEmployeeRosterField(anyString(), any(),
+                anyString(), anyString(), anyString(), anyString(), any());
+
+        service.executeDueTasks();
+
+        List<Map<String, Object>> updates = capturedUpdates("task_retry");
+        Map<String, Object> retry = updates.get(updates.size() - 1);
+        assertEquals(NOW.plusSeconds(expectedMinutes * 60).toEpochMilli(),
+                ((Number) retry.get("next_execute_at")).longValue());
+    }
+
+    private void resetMocks() {
+        org.mockito.Mockito.reset(ydClientForm, ddService, personnelClient);
+    }
+
+    private void awaitAndEnqueue(CountDownLatch start) {
+        try {
+            start.await(5, TimeUnit.SECONDS);
+            service.enqueueRosterUpdate("source_1", 0, "user_1", "众川");
+        } catch (InterruptedException ex) {
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException(ex);
+        }
+    }
+
+    private void claimSucceeds(String formInstanceId) {
+        claimSucceeds(formInstanceId, taskRecord(formInstanceId, DingTalkSyncTask.PENDING,
+                0, NOW, "user_1", "众川"));
+    }
+
+    private void claimSucceeds(String formInstanceId, Map<String, Object> original) {
+        when(ydClientForm.getForm(any(YDAuth.class), eq(formInstanceId), isNull()))
+                .thenAnswer(invocation -> {
+                    List<Map<String, Object>> updates = capturedUpdates(formInstanceId);
+                    Map<String, Object> claim = updates.get(0);
+                    Map<String, Object> persisted = new HashMap<>(original);
+                    Map<String, Object> data = new HashMap<>(formData(original));
+                    data.put("task_status", DingTalkSyncTask.RETRYING);
+                    data.put("last_error", claim.get("last_error"));
+                    data.put("next_execute_at", claim.get("next_execute_at"));
+                    persisted.put("formData", data);
+                    return persisted;
+                });
+    }
+
+    private Map<String, Object> claimedTask(String instanceId, String owner, Instant leaseUntil) {
+        Map<String, Object> record = taskRecord(instanceId, DingTalkSyncTask.RETRYING, 0,
+                leaseUntil, "user_1", "众川");
+        formData(record).put("last_error", owner);
+        return record;
+    }
+
+    private void dueTasks(Map<String, Object>... records) {
+        when(ydClientForm.searchForm(any(YDAuth.class), eq("FORM_TASK"), eq("{}"),
+                eq(1), eq(100), isNull())).thenReturn(page(Arrays.asList(records)));
+        for (Map<String, Object> record : records) {
+            Object id = record.get("formInstanceId");
+            if (id != null) {
+                claimSucceeds(String.valueOf(id), record);
+            }
+        }
+    }
+
+    private Map<String, Object> capturedUpsertedForm() {
+        ArgumentCaptor<String> json = ArgumentCaptor.forClass(String.class);
+        verify(ydClientForm).upsertForm(any(YDAuth.class), eq("FORM_TASK"), anyString(),
+                json.capture(), anyMap());
+        return JSON.parseObject(json.getValue(), Map.class);
+    }
+
+    private Map<String, Object> capturedUpdate(String formInstanceId) {
+        List<Map<String, Object>> updates = capturedUpdates(formInstanceId);
+        return updates.get(updates.size() - 1);
+    }
+
+    private List<Map<String, Object>> capturedUpdates() {
+        return Arrays.asList(capturedUpdate("task_1"), capturedUpdate("task_2"));
+    }
+
+    private List<Map<String, Object>> capturedUpdates(String formInstanceId) {
+        ArgumentCaptor<String> json = ArgumentCaptor.forClass(String.class);
+        verify(ydClientForm, org.mockito.Mockito.atLeastOnce()).updateForm(
+                any(YDAuth.class), eq(formInstanceId), json.capture(), anyMap());
+        List<Map<String, Object>> values = new java.util.ArrayList<>();
+        for (String value : json.getAllValues()) {
+            values.add(JSON.parseObject(value, Map.class));
+        }
+        return values;
+    }
+
+    private Map<String, Object> taskRecord(String instanceId, String status, int retryCount,
+                                           Instant nextExecuteAt, String userId, String value) {
+        Map<String, Object> formData = new HashMap<>();
+        formData.put("task_key", instanceId + ":0:ROSTER_FIELD_UPDATE:outsourcing_code");
+        formData.put("task_type", DingTalkSyncTask.ROSTER_FIELD_UPDATE);
+        formData.put("source_id", "source_1");
+        formData.put("source_row", 0);
+        formData.put("user_id", userId);
+        formData.put("field_code", "outsourcing_code");
+        formData.put("field_value", value);
+        formData.put("task_status", status);
+        formData.put("retry_count", retryCount);
+        formData.put("next_execute_at", nextExecuteAt.toEpochMilli());
+        Map<String, Object> record = new HashMap<>();
+        record.put("formInstanceId", instanceId);
+        record.put("formData", formData);
+        return record;
+    }
+
+    private Map<String, Object> page(List<Map<String, Object>> records) {
+        Map<String, Object> page = new HashMap<>();
+        page.put("data", records);
+        page.put("totalCount", records.size());
+        return page;
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> formData(Map<String, Object> record) {
+        return (Map<String, Object>) record.get("formData");
+    }
+}

+ 16 - 0
mjava-benteler/src/test/java/com/malk/benteler/service/BentelerYidaFormMapperTest.java

@@ -147,6 +147,22 @@ public class BentelerYidaFormMapperTest {
         assertEquals(null, item.getCustomFields());
     }
 
+    @Test
+    public void mapUpdateItems_nameUpdate_mapsDisplayNameAndRebuildPreservesName() {
+        Map<String, Object> row = new HashMap<>();
+        row.put(conf.getUpdateEmployeeFieldId() + "_id", Collections.singletonList("user_1"));
+        row.put(conf.getUpdateNameFieldId(), "新姓名");
+
+        EiamUpdateUserItem item = mapper.mapUpdateItems("form_update",
+                Collections.singletonList(row), null,
+                Collections.singletonList(BentelerYidaFormMapper.UPDATE_NAME)).get(0);
+        List<Map<String, Object>> rebuilt = mapper.rebuildRows(conf.getUpdateTableFieldId(),
+                Collections.singletonList(row), Collections.singletonList(result(true)));
+
+        assertEquals("新姓名", item.getDisplayName());
+        assertEquals("新姓名", rebuilt.get(0).get(conf.getUpdateNameFieldId()));
+    }
+
     private EiamBatchItemResult result(boolean success) {
         return EiamBatchItemResult.builder().success(success).stage(EiamBatchStage.COMPLETED)
                 .build();

+ 140 - 1
mjava-benteler/src/test/java/com/malk/benteler/service/BentelerYidaSyncServiceTest.java

@@ -46,6 +46,7 @@ public class BentelerYidaSyncServiceTest {
     private YDClient_Form ydClientForm;
     private YDService ydService;
     private EiamLocalService eiamLocalService;
+    private BentelerDingTalkSyncTaskService dingTalkSyncTaskService;
     private BentelerYidaConf conf;
     private BentelerYidaSyncService service;
 
@@ -54,12 +55,13 @@ public class BentelerYidaSyncServiceTest {
         ydClientForm = mock(YDClient_Form.class);
         ydService = mock(YDService.class);
         eiamLocalService = mock(EiamLocalService.class);
+        dingTalkSyncTaskService = mock(BentelerDingTalkSyncTaskService.class);
         conf = TestBentelerYidaConf.create();
         YDConf ydConf = new YDConf();
         ydConf.setAppType("APP_TEST");
         ydConf.setSystemToken("TOKEN_TEST");
         service = new BentelerYidaSyncService(ydClientForm, ydService, ydConf, conf,
-                new BentelerYidaFormMapper(conf), eiamLocalService);
+                new BentelerYidaFormMapper(conf), eiamLocalService, dingTalkSyncTaskService);
     }
 
     @Test
@@ -94,6 +96,109 @@ public class BentelerYidaSyncServiceTest {
         assertEquals("人员二 - 执行失败", update.get(conf.getFailureMessageFieldId()));
     }
 
+    @Test
+    public void syncCreate_afterWritebackEnqueuesOutsourcingCompanyForSuccessfulRows() {
+        Map<String, Object> formData = new HashMap<>();
+        formData.put(conf.getOnboardingOrganizationalUnitIdFieldId(), "ou_technical");
+        formData.put(conf.getOnboardingOutsourcingCompanyFieldId(), "众川");
+        formData.put(conf.getOnboardingTableFieldId(), Collections.singletonList(
+                createRow("人员一", "13800000001")));
+        when(ydClientForm.getForm(any(YDAuth.class), anyString(), isNull()))
+                .thenReturn(instance(formData));
+        when(eiamLocalService.batchCreate(any())).thenReturn(batchResultWithUserId(
+                EiamBatchOperation.CREATE, "user_1", true));
+        when(ydClientForm.updateForm(any(YDAuth.class), anyString(), anyString(), anyMap()))
+                .thenReturn(Collections.emptyMap());
+
+        service.syncCreate("form_create");
+
+        verify(dingTalkSyncTaskService).enqueueRosterUpdate("form_create", 0, "user_1", "众川");
+    }
+
+    @Test
+    public void syncUpdate_afterWritebackEnqueuesNonBlankOutsourcingCompany() {
+        Map<String, Object> row = new HashMap<>();
+        row.put(conf.getUpdateEmployeeFieldId() + "_id", Collections.singletonList("user_1"));
+        Map<String, Object> formData = new HashMap<>();
+        formData.put(conf.getUpdateContentFieldId(),
+                Collections.singletonList(BentelerYidaFormMapper.UPDATE_PHONE));
+        formData.put(conf.getUpdateOutsourcingCompanyFieldId(), "翰辉");
+        formData.put(conf.getUpdateTableFieldId(), Collections.singletonList(row));
+        when(ydClientForm.getForm(any(YDAuth.class), anyString(), isNull()))
+                .thenReturn(instance(formData));
+        when(eiamLocalService.batchUpdate(any())).thenReturn(batchResult(
+                EiamBatchOperation.UPDATE, true));
+        when(ydClientForm.updateForm(any(YDAuth.class), anyString(), anyString(), anyMap()))
+                .thenReturn(Collections.emptyMap());
+
+        service.syncUpdate("form_update");
+
+        verify(dingTalkSyncTaskService).enqueueRosterUpdate("form_update", 0, "user_1", "翰辉");
+    }
+
+    @Test
+    public void syncUpdate_tianjinProbationConfirmationEnqueuesRosterClear() {
+        Map<String, Object> row = new HashMap<>();
+        row.put(conf.getUpdateEmployeeFieldId() + "_id", Collections.singletonList("user_1"));
+        Map<String, Object> formData = new HashMap<>();
+        formData.put(conf.getUpdateContentFieldId(),
+                Collections.singletonList(BentelerYidaFormMapper.UPDATE_PHONE));
+        formData.put(conf.getUpdateFactoryFieldId(), "BATJ II");
+        formData.put(conf.getUpdateProbationConfirmationFieldId(), "是");
+        formData.put(conf.getUpdateTableFieldId(), Collections.singletonList(row));
+        when(ydClientForm.getForm(any(YDAuth.class), anyString(), isNull()))
+                .thenReturn(instance(formData));
+        when(eiamLocalService.batchUpdate(any())).thenReturn(batchResult(EiamBatchOperation.UPDATE, true));
+        when(ydClientForm.updateForm(any(YDAuth.class), anyString(), anyString(), anyMap()))
+                .thenReturn(Collections.emptyMap());
+
+        service.syncUpdate("form_update");
+
+        verify(dingTalkSyncTaskService).enqueueRosterClear("form_update", 0, "user_1");
+    }
+
+    @Test
+    public void syncUpdate_addDepartmentEnqueuesContactPrimaryDepartmentUpdate() {
+        Map<String, Object> row = new HashMap<>();
+        row.put(conf.getUpdateEmployeeFieldId() + "_id", Collections.singletonList("user_1"));
+        Map<String, Object> formData = new HashMap<>();
+        formData.put(conf.getUpdateContentFieldId(),
+                Collections.singletonList(BentelerYidaFormMapper.ADD_ORG));
+        formData.put(conf.getUpdateOrganizationalUnitIdFieldId(), "ou_target");
+        formData.put(conf.getUpdateDingTalkDepartmentFieldId(), Collections.singletonList("100"));
+        formData.put(conf.getUpdateTableFieldId(), Collections.singletonList(row));
+        when(ydClientForm.getForm(any(YDAuth.class), anyString(), isNull()))
+                .thenReturn(instance(formData));
+        when(eiamLocalService.batchUpdate(any())).thenReturn(batchResult(EiamBatchOperation.UPDATE, true));
+        when(ydClientForm.updateForm(any(YDAuth.class), anyString(), anyString(), anyMap()))
+                .thenReturn(Collections.emptyMap());
+
+        service.syncUpdate("form_update");
+
+        verify(dingTalkSyncTaskService).enqueueContactPrimaryDepartmentUpdate(
+                "form_update", 0, "user_1", 100L);
+    }
+
+    @Test
+    public void syncCreate_blankOutsourcingCompanyDoesNotEnqueueTask() {
+        Map<String, Object> formData = new HashMap<>();
+        formData.put(conf.getOnboardingOrganizationalUnitIdFieldId(), "ou_technical");
+        formData.put(conf.getOnboardingOutsourcingCompanyFieldId(), " ");
+        formData.put(conf.getOnboardingTableFieldId(), Collections.singletonList(
+                createRow("人员一", "13800000001")));
+        when(ydClientForm.getForm(any(YDAuth.class), anyString(), isNull()))
+                .thenReturn(instance(formData));
+        when(eiamLocalService.batchCreate(any())).thenReturn(batchResultWithUserId(
+                EiamBatchOperation.CREATE, "user_1", true));
+        when(ydClientForm.updateForm(any(YDAuth.class), anyString(), anyString(), anyMap()))
+                .thenReturn(Collections.emptyMap());
+
+        service.syncCreate("form_blank");
+
+        verify(dingTalkSyncTaskService, never()).enqueueRosterUpdate(anyString(),
+                org.mockito.ArgumentMatchers.anyInt(), anyString(), anyString());
+    }
+
     @Test
     public void syncCreate_writebackFailureReturnsExecutionResultWithoutRetryingEiam() {
         Map<String, Object> formData = new HashMap<>();
@@ -221,6 +326,34 @@ public class BentelerYidaSyncServiceTest {
         assertEquals(null, items.get(0).getPrimaryOrganizationalUnitId());
     }
 
+    @Test
+    public void syncUpdate_exactlyFiftyRowsQueriesDetailsWithConfiguredFormUuid() {
+        List<Map<String, Object>> inlineRows = new ArrayList<>();
+        for (int index = 0; index < 50; index++) {
+            Map<String, Object> row = new HashMap<>();
+            row.put(conf.getUpdateEmployeeFieldId() + "_id",
+                    Collections.singletonList("user_" + index));
+            inlineRows.add(row);
+        }
+        Map<String, Object> formData = new HashMap<>();
+        formData.put(conf.getUpdateContentFieldId(),
+                Collections.singletonList(BentelerYidaFormMapper.UPDATE_PHONE));
+        formData.put(conf.getUpdateTableFieldId(), inlineRows);
+        when(ydClientForm.getForm(any(YDAuth.class), anyString(), isNull()))
+                .thenReturn(instance(formData));
+        when(ydService.queryDetails(any(YDParam.class))).thenReturn((List) inlineRows);
+        when(eiamLocalService.batchUpdate(any())).thenReturn(batchResult(
+                EiamBatchOperation.UPDATE, successFlags(50)));
+        when(ydClientForm.updateForm(any(YDAuth.class), anyString(), anyString(), anyMap()))
+                .thenReturn(Collections.emptyMap());
+
+        service.syncUpdate("form_update_50");
+
+        ArgumentCaptor<YDParam> paramCaptor = ArgumentCaptor.forClass(YDParam.class);
+        verify(ydService).queryDetails(paramCaptor.capture());
+        assertEquals("FORM-UPDATE", paramCaptor.getValue().getFormUuid());
+    }
+
     @Test
     public void syncDelete_exactlyFiftyRowsFallsBackWhenDetailQueryThrowsNpe() {
         List<Map<String, Object>> inlineRows = new ArrayList<>();
@@ -297,4 +430,10 @@ public class BentelerYidaSyncServiceTest {
         }
         return EiamBatchResult.of(operation, items);
     }
+
+    private EiamBatchResult batchResultWithUserId(EiamBatchOperation operation, String userId,
+                                                   boolean success) {
+        return EiamBatchResult.of(operation, Collections.singletonList(EiamBatchItemResult.builder()
+                .index(0).userId(userId).success(success).stage(EiamBatchStage.COMPLETED).build()));
+    }
 }

+ 52 - 0
mjava-benteler/src/test/java/com/malk/benteler/service/EiamOrganizationalUnitLocalServiceTest.java

@@ -69,6 +69,58 @@ public class EiamOrganizationalUnitLocalServiceTest {
                 anyString(), anyString(), anyInt(), anyInt());
     }
 
+    @Test
+    public void searchByName_includesGrandparentBeforeDirectParent() {
+        when(client.listOrganizationalUnits(anyString(), anyString(), anyString(),
+                anyString(), anyInt(), anyInt())).thenAnswer(invocation -> {
+            String parentId = invocation.getArgument(3);
+            if ("ou_root".equals(parentId)) {
+                return page(unit("ou_corporate", "Corporate", "ou_root"));
+            }
+            if ("ou_corporate".equals(parentId)) {
+                return page(unit("ou_technical", "Technical accounts", "ou_corporate"));
+            }
+            if ("ou_technical".equals(parentId)) {
+                return page(unit("ou_production", "Production", "ou_technical"));
+            }
+            return page();
+        });
+
+        List<EiamOrganizationalUnitOption> result = service.searchByName("Production");
+
+        assertEquals(1, result.size());
+        assertEquals("ou_production", result.get(0).getOrganizationalUnitId());
+        assertEquals("Production (Corporate-Technical accounts)",
+                result.get(0).getOrganizationalUnitName());
+    }
+
+    @Test
+    public void searchByName_keepsOnlyGrandparentAndDirectParentForDeepHierarchy() {
+        when(client.listOrganizationalUnits(anyString(), anyString(), anyString(),
+                anyString(), anyInt(), anyInt())).thenAnswer(invocation -> {
+            String parentId = invocation.getArgument(3);
+            if ("ou_root".equals(parentId)) {
+                return page(unit("ou_region", "Region", "ou_root"));
+            }
+            if ("ou_region".equals(parentId)) {
+                return page(unit("ou_corporate", "Corporate", "ou_region"));
+            }
+            if ("ou_corporate".equals(parentId)) {
+                return page(unit("ou_technical", "Technical accounts", "ou_corporate"));
+            }
+            if ("ou_technical".equals(parentId)) {
+                return page(unit("ou_production", "Production", "ou_technical"));
+            }
+            return page();
+        });
+
+        List<EiamOrganizationalUnitOption> result = service.searchByName("Production");
+
+        assertEquals(1, result.size());
+        assertEquals("Production (Corporate-Technical accounts)",
+                result.get(0).getOrganizationalUnitName());
+    }
+
     @Test
     public void resolveUniqueId_supportsRootLevelDepartment() {
         when(client.listOrganizationalUnits(anyString(), anyString(), anyString(),

+ 25 - 0
mjava-benteler/src/test/java/com/malk/benteler/service/TestBentelerYidaConf.java

@@ -30,6 +30,8 @@ final class TestBentelerYidaConf {
         conf.setOnboardingCompanyFieldId("company");
         conf.setOnboardingCompanyCodeFieldId("company_code");
         conf.setOnboardingDepartmentFieldId("department");
+        conf.setOnboardingOutsourcingCompanyFieldId("outsourcing_onboarding");
+        conf.setUpdateFormUuid("FORM-UPDATE");
         conf.setUpdateTableFieldId("table_update");
         conf.setUpdateEmployeeFieldId("employee_update");
         conf.setUpdateDescriptionFieldId("update_description");
@@ -38,6 +40,11 @@ final class TestBentelerYidaConf {
         conf.setUpdatePhoneFieldId("update_phone");
         conf.setUpdateEmployeeNumberFieldId("update_employee_no");
         conf.setUpdateJobTitleFieldId("update_job_title");
+        conf.setUpdateOutsourcingCompanyFieldId("outsourcing_update");
+        conf.setUpdateNameFieldId("update_name");
+        conf.setUpdateFactoryFieldId("update_factory");
+        conf.setUpdateProbationConfirmationFieldId("probation_confirmation");
+        conf.setUpdateDingTalkDepartmentFieldId("dingtalk_department");
         conf.setOffboardingTableFieldId("table_delete");
         conf.setOffboardingEmployeeFieldId("employee_delete");
         conf.setOffboardingRemarkFieldId("remark");
@@ -48,6 +55,24 @@ final class TestBentelerYidaConf {
         conf.setTotalFieldId("total");
         conf.setSuccessFieldId("success");
         conf.setFailedFieldId("failed");
+        conf.setDingTalkSyncTaskFormUuid("FORM_TASK");
+        conf.setDingTalkSyncTaskKeyFieldId("task_key");
+        conf.setDingTalkSyncTaskTypeFieldId("task_type");
+        conf.setDingTalkSyncSourceFormInstanceIdFieldId("source_id");
+        conf.setDingTalkSyncSourceRowIndexFieldId("source_row");
+        conf.setDingTalkSyncUserIdFieldId("user_id");
+        conf.setDingTalkSyncTargetFieldCodeFieldId("field_code");
+        conf.setDingTalkSyncTargetFieldValueFieldId("field_value");
+        conf.setDingTalkSyncRoleGroupIdFieldId("role_group_id");
+        conf.setDingTalkSyncRoleIdFieldId("role_id");
+        conf.setDingTalkSyncStatusFieldId("task_status");
+        conf.setDingTalkSyncRetryCountFieldId("retry_count");
+        conf.setDingTalkSyncNextExecuteAtFieldId("next_execute_at");
+        conf.setDingTalkSyncLastErrorFieldId("last_error");
+        conf.setDingTalkSyncCompletedAtFieldId("completed_at");
+        conf.setDingTalkRosterAgentId(4784847516L);
+        conf.setDingTalkRosterGroupId("sys00");
+        conf.setDingTalkRosterOutsourcingFieldCode("outsourcing_code");
         return conf;
     }
 }

+ 2 - 2
mjava/src/main/java/com/malk/service/aliwork/impl/YDClient_FormImpl.java

@@ -180,8 +180,8 @@ public class YDClient_FormImpl implements YDClient_Form {
         body.put("currentPage", normPage(currentPage));
         body.put("pageSize", normPageSize(pageSize));
         body = mergeExt(body, body_ext);
-        DDR_New r = assertResult(DDR_New.doPost(url("/forms/instances/search"), header(auth), null, body), "searchForm");
-        return UtilRespMapper.asMap(r.getResult());
+        String response = UtilHttp.doPost(url("/forms/instances/search"), header(auth), null, body);
+        return mapResponse(response, "searchForm");
     }
 
     @Override

+ 20 - 0
mjava/src/main/java/com/malk/service/dingtalk/DDClient_Personnel.java

@@ -17,4 +17,24 @@ public interface DDClient_Personnel {
      * @param field_filter_list [非必填] 需要获取的花名册字段field_code值列表,多个字段之间使用逗号分隔,一次最多支持传100个值
      */
     List<Map> getEmployeeInfos(String access_token, List<String> userIds, Number agentId, List<String> field_filter_list);
+
+    /**
+     * 更新员工花名册的单个字段。
+     *
+     * @param accessToken 钉钉应用访问凭证
+     * @param agentId 应用AgentId
+     * @param userId 被更新员工的userid
+     * @param groupId 花名册分组标识
+     * @param fieldCode 花名册字段code
+     * @param fieldValue 花名册字段值
+     * @param bodyExt 官方可选请求字段,不做过滤。支持的已知结构:
+     *                - 顶层未知扩展字段
+     *                - param.extension等未知扩展字段
+     *                - param.groups[].extension等未知扩展字段
+     *                - param.groups[].sections[].old_index:明细下标
+     *                - param.groups[].sections[].section[]:同一明细的其他字段
+     * @apiNote https://open.dingtalk.com/document/development/intelligent-personnel-update-employee-file-information
+     */
+    void updateEmployeeRosterField(String accessToken, Number agentId, String userId,
+                                   String groupId, String fieldCode, String fieldValue, Map bodyExt);
 }

+ 7 - 0
mjava/src/main/java/com/malk/service/dingtalk/DDService.java

@@ -5,6 +5,13 @@ import java.util.Map;
 
 public interface DDService {
 
+    /**
+     * 获取应用访问凭证,复用底层客户端缓存。
+     *
+     * @return 钉钉应用访问凭证
+     */
+    String getAccessToken();
+
     /**
      * 新发起审批15s内不允许撤销, 异步执行
      */

+ 99 - 0
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Personnel.java

@@ -3,11 +3,14 @@ package com.malk.service.dingtalk.impl;
 import com.malk.server.dingtalk.DDConf;
 import com.malk.server.dingtalk.DDR;
 import com.malk.service.dingtalk.DDClient_Personnel;
+import com.malk.utils.UtilHttp;
 import com.malk.utils.UtilList;
 import com.malk.utils.UtilMap;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
+import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -36,4 +39,100 @@ public class DDImplClient_Personnel implements DDClient_Personnel {
         }
         return (List<Map>) DDR.doPost("https://oapi.dingtalk.com/topapi/smartwork/hrm/employee/v2/list", null, DDConf.initTokenParams(access_token), bodys).getResult();
     }
+
+    /**
+     * 更新员工花名册的单个字段。
+     *
+     * @param accessToken 钉钉应用访问凭证
+     * @param agentId 应用AgentId
+     * @param userId 被更新员工的userid
+     * @param groupId 花名册分组标识
+     * @param fieldCode 花名册字段code
+     * @param fieldValue 花名册字段值
+     * @param bodyExt 官方可选请求字段,不做过滤。支持的已知结构:
+     *                - 顶层未知扩展字段
+     *                - param.extension等未知扩展字段
+     *                - param.groups[].extension等未知扩展字段
+     *                - param.groups[].sections[].old_index:明细下标
+     *                - param.groups[].sections[].section[]:同一明细的其他字段
+     * @apiNote https://open.dingtalk.com/document/development/intelligent-personnel-update-employee-file-information
+     */
+    @Override
+    public void updateEmployeeRosterField(String accessToken, Number agentId, String userId,
+                                          String groupId, String fieldCode, String fieldValue, Map bodyExt) {
+        Map body = new HashMap();
+        if (bodyExt != null) {
+            body.putAll(bodyExt);
+        }
+        body.put("agentid", agentId);
+
+        Map param = copyMap(body.get("param"));
+        param.put("userid", userId);
+        List<Map> groups = copyMapList(param.get("groups"));
+        Map group = findBy(groups, "group_id", groupId);
+        if (group == null) {
+            group = new HashMap();
+            groups.add(group);
+        }
+        group.put("group_id", groupId);
+
+        List<Map> sections = copyMapList(group.get("sections"));
+        Map section;
+        if (sections.isEmpty()) {
+            section = new HashMap();
+            sections.add(section);
+        } else {
+            section = sections.get(0);
+        }
+        List<Map> fields = copyMapList(section.get("section"));
+        Map field = findBy(fields, "field_code", fieldCode);
+        if (field == null) {
+            field = new HashMap();
+            fields.add(field);
+        }
+        field.put("field_code", fieldCode);
+        field.put("value", fieldValue);
+        section.put("section", fields);
+        group.put("sections", sections);
+        param.put("groups", groups);
+        body.put("param", param);
+
+        UtilHttp.doPostStrict(employeeRosterUpdateUrl(), null,
+                DDConf.initTokenParams(accessToken), body, DDR.class);
+    }
+
+    /**
+     * 获取员工花名册更新接口地址。
+     *
+     * @return 官方接口地址
+     */
+    protected String employeeRosterUpdateUrl() {
+        return "https://oapi.dingtalk.com/topapi/smartwork/hrm/employee/v2/update";
+    }
+
+    private static Map copyMap(Object value) {
+        return value instanceof Map ? new HashMap((Map) value) : new HashMap();
+    }
+
+    private static List<Map> copyMapList(Object value) {
+        List<Map> copies = new ArrayList<>();
+        if (!(value instanceof List)) {
+            return copies;
+        }
+        for (Object item : (List) value) {
+            if (item instanceof Map) {
+                copies.add(new HashMap((Map) item));
+            }
+        }
+        return copies;
+    }
+
+    private static Map findBy(List<Map> values, String key, Object expected) {
+        for (Map value : values) {
+            if (expected == null ? value.get(key) == null : expected.equals(value.get(key))) {
+                return value;
+            }
+        }
+        return null;
+    }
 }

+ 10 - 0
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplService.java

@@ -47,6 +47,16 @@ public class DDImplService implements DDService {
     @Autowired
     private DDClient ddClient;
 
+    /**
+     * 获取应用访问凭证,复用 {@link DDClient} 的本地缓存。
+     *
+     * @return 钉钉应用访问凭证
+     */
+    @Override
+    public String getAccessToken() {
+        return ddClient.getAccessToken();
+    }
+
     /**
      * 新发起审批15s内不允许撤销, 异步执行 [审批同意/拒绝只能通过节点操作, 系统无法直接介入]   -- 异步需要中转一层进行触发, client为原子接口
      */

+ 30 - 1
mjava/src/main/java/com/malk/utils/UtilHttp.java

@@ -9,6 +9,7 @@ import cn.hutool.http.HttpUtil;
 import cn.hutool.http.webservice.SoapClient;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.serializer.SerializerFeature;
+import com.malk.server.common.McException;
 import com.malk.server.common.VenR;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
@@ -58,6 +59,11 @@ public abstract class UtilHttp {
 
     // todo: 认证格式 - Authorization:Basic base64(“admin:密码”)
     public static String doRequest(METHOD method, String url, Map header, Map<String, Object> param, Object body, Map form, String usr, String pwd) {
+        return doRequest(method, url, header, param, body, form, usr, pwd, false);
+    }
+
+    private static String doRequest(METHOD method, String url, Map header, Map<String, Object> param,
+                                    Object body, Map form, String usr, String pwd, boolean require2xx) {
         // ppExt: §3.5 审计起点 - 记录时间戳 + 推断 vendor / endpoint 供 success/error 路径共用
         long startMs = System.currentTimeMillis();
         String vendor = UtilHttpAudit.vendor(url);
@@ -98,9 +104,13 @@ public abstract class UtilHttp {
             }
             HttpResponse out = request.execute();
             log.debug("请求响应, {}, {}", out.getStatus(), out.body()); // http 状态判定
-            // ppExt: 外部接口http状态异常, 不直接阻断, 通过 r.assertSuccess(); 校验
+            // 默认兼容旧行为;明确要求strict的调用同时校验HTTP状态和响应体。
             //McException.assertException(out.getStatus() != 200, String.valueOf(out.getStatus()), "ERROR HTTP STATUS EXCEPTION");
             String respBody = out.body();
+            if (require2xx && (out.getStatus() < 200 || out.getStatus() >= 300)) {
+                throw new McException(
+                        String.valueOf(out.getStatus()), "ERROR HTTP STATUS: " + out.getStatus());
+            }
             UtilHttpAudit.logSuccess(vendor, method.name(), endpoint,
                     System.currentTimeMillis() - startMs,
                     respBody == null ? 0 : respBody.length());
@@ -145,6 +155,25 @@ public abstract class UtilHttp {
         return doPost(url, header, param, body, null, rClass);
     }
 
+    /**
+     * 发起POST请求,要求HTTP状态为2xx,并解析、校验第三方响应。
+     *
+     * @param url 请求地址
+     * @param header 请求头
+     * @param param 查询参数
+     * @param body JSON请求体
+     * @param rClass 第三方响应类型
+     * @return 已校验的第三方响应
+     */
+    public static VenR doPostStrict(String url, Map header, Map<String, Object> param,
+                                    Map body, Class rClass) {
+        String rsp = doRequest(METHOD.POST, url, header, param, body, null,
+                null, null, true);
+        VenR r = (VenR) JSON.parseObject(rsp, rClass);
+        r.assertSuccess();
+        return r;
+    }
+
     public static VenR doPost(String url, Map header, Map body, Class rClass) {
         return doPost(url, header, null, body, rClass);
     }

+ 174 - 0
mjava/src/test/java/com/malk/service/dingtalk/impl/DDImplClient_PersonnelTest.java

@@ -0,0 +1,174 @@
+package com.malk.service.dingtalk.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.malk.server.common.McException;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+/**
+ * 钉钉智能人事花名册原子接口测试。
+ */
+public class DDImplClient_PersonnelTest {
+
+    private static final long AGENT_ID = 4784847516L;
+    private static final String GROUP_ID = "sys00";
+    private static final String FIELD_CODE = "aea319c2-91fa-45e4-b3f8-55b1627fce36";
+
+    @Test
+    public void updateEmployeeRosterField_sendsOfficialV2RequestStructure() throws Exception {
+        AtomicReference<String> requestQuery = new AtomicReference<>();
+        AtomicReference<String> requestBody = new AtomicReference<>();
+        HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+        server.createContext("/topapi/smartwork/hrm/employee/v2/update", exchange -> {
+            requestQuery.set(exchange.getRequestURI().getQuery());
+            requestBody.set(readBody(exchange.getRequestBody()));
+            byte[] response = "{\"errcode\":0,\"errmsg\":\"ok\",\"result\":true}".getBytes(StandardCharsets.UTF_8);
+            exchange.sendResponseHeaders(200, response.length);
+            exchange.getResponseBody().write(response);
+            exchange.close();
+        });
+        server.start();
+        try {
+            TestPersonnelClient client = new TestPersonnelClient(server.getAddress().getPort());
+            Map<String, Object> bodyExt = new HashMap<>();
+            bodyExt.put("language", "zh_CN");
+
+            client.updateEmployeeRosterField("token", AGENT_ID, "user_1", GROUP_ID,
+                    FIELD_CODE, "vendor", bodyExt);
+
+            assertEquals("access_token=token", requestQuery.get());
+            JSONObject body = JSON.parseObject(requestBody.get());
+            assertEquals(AGENT_ID, body.getLongValue("agentid"));
+            assertEquals("zh_CN", body.getString("language"));
+            JSONObject param = body.getJSONObject("param");
+            assertEquals("user_1", param.getString("userid"));
+            JSONArray groups = param.getJSONArray("groups");
+            assertEquals(GROUP_ID, groups.getJSONObject(0).getString("group_id"));
+            JSONArray sections = groups.getJSONObject(0).getJSONArray("sections");
+            JSONArray fields = sections.getJSONObject(0).getJSONArray("section");
+            assertEquals(FIELD_CODE, fields.getJSONObject(0).getString("field_code"));
+            assertEquals("vendor", fields.getJSONObject(0).getString("value"));
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    @Test
+    public void updateEmployeeRosterField_preservesAndMergesOptionalParamValues() throws Exception {
+        AtomicReference<String> requestBody = new AtomicReference<>();
+        HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+        server.createContext("/topapi/smartwork/hrm/employee/v2/update", exchange -> {
+            requestBody.set(readBody(exchange.getRequestBody()));
+            byte[] response = "{\"errcode\":0,\"errmsg\":\"ok\",\"result\":true}".getBytes(StandardCharsets.UTF_8);
+            exchange.sendResponseHeaders(200, response.length);
+            exchange.getResponseBody().write(response);
+            exchange.close();
+        });
+        server.start();
+        try {
+            Map<String, Object> existingField = new HashMap<>();
+            existingField.put("field_code", "existing-field");
+            existingField.put("value", "existing-value");
+            Map<String, Object> existingSection = new HashMap<>();
+            existingSection.put("old_index", 2L);
+            existingSection.put("section", new ArrayList<>(Collections.singletonList(existingField)));
+            Map<String, Object> existingGroup = new HashMap<>();
+            existingGroup.put("group_id", GROUP_ID);
+            existingGroup.put("extension", "keep-group-value");
+            existingGroup.put("sections", new ArrayList<>(Collections.singletonList(existingSection)));
+            Map<String, Object> otherGroup = new HashMap<>();
+            otherGroup.put("group_id", "sys01");
+            otherGroup.put("sections", new ArrayList<>());
+            Map<String, Object> param = new HashMap<>();
+            param.put("extension", "keep-param-value");
+            param.put("groups", new ArrayList<>(java.util.Arrays.asList(otherGroup, existingGroup)));
+            Map<String, Object> bodyExt = new HashMap<>();
+            bodyExt.put("param", param);
+
+            new TestPersonnelClient(server.getAddress().getPort()).updateEmployeeRosterField(
+                    "token", AGENT_ID, "user_1", GROUP_ID, FIELD_CODE, "vendor", bodyExt);
+
+            JSONObject sentParam = JSON.parseObject(requestBody.get()).getJSONObject("param");
+            assertEquals("keep-param-value", sentParam.getString("extension"));
+            assertEquals("user_1", sentParam.getString("userid"));
+            JSONArray groups = sentParam.getJSONArray("groups");
+            assertEquals(2, groups.size());
+            assertEquals("sys01", groups.getJSONObject(0).getString("group_id"));
+            JSONObject mergedGroup = groups.getJSONObject(1);
+            assertEquals("keep-group-value", mergedGroup.getString("extension"));
+            JSONObject mergedSection = mergedGroup.getJSONArray("sections").getJSONObject(0);
+            assertEquals(2L, mergedSection.getLongValue("old_index"));
+            JSONArray fields = mergedSection.getJSONArray("section");
+            assertEquals(2, fields.size());
+            assertEquals("existing-field", fields.getJSONObject(0).getString("field_code"));
+            assertEquals(FIELD_CODE, fields.getJSONObject(1).getString("field_code"));
+            assertEquals("vendor", fields.getJSONObject(1).getString("value"));
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    @Test
+    public void updateEmployeeRosterField_non2xxSuccessBody_throwsHttpStatusException() throws Exception {
+        HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+        server.createContext("/topapi/smartwork/hrm/employee/v2/update", exchange -> {
+            byte[] response = "{\"errcode\":0,\"errmsg\":\"ok\",\"result\":true}".getBytes(StandardCharsets.UTF_8);
+            exchange.sendResponseHeaders(500, response.length);
+            exchange.getResponseBody().write(response);
+            exchange.close();
+        });
+        server.start();
+        try {
+            TestPersonnelClient client = new TestPersonnelClient(server.getAddress().getPort());
+
+            try {
+                client.updateEmployeeRosterField("token", AGENT_ID, "user_1", GROUP_ID,
+                        FIELD_CODE, "vendor", null);
+                fail("HTTP非2xx响应必须抛出异常");
+            } catch (McException ex) {
+                assertEquals("500", ex.getCode());
+            }
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    private static String readBody(InputStream input) throws IOException {
+        ByteArrayOutputStream output = new ByteArrayOutputStream();
+        byte[] buffer = new byte[1024];
+        int length;
+        while ((length = input.read(buffer)) != -1) {
+            output.write(buffer, 0, length);
+        }
+        return new String(output.toByteArray(), StandardCharsets.UTF_8);
+    }
+
+    private static class TestPersonnelClient extends DDImplClient_Personnel {
+        private final String updateUrl;
+
+        private TestPersonnelClient(int port) {
+            this.updateUrl = "http://127.0.0.1:" + port + "/topapi/smartwork/hrm/employee/v2/update";
+        }
+
+        @Override
+        protected String employeeRosterUpdateUrl() {
+            return updateUrl;
+        }
+    }
+}