Преглед на файлове

feat(benteler): 完成 EIAM 批量同步与宜搭回写

malk преди 3 седмици
родител
ревизия
470ab97cef
променени са 50 файла, в които са добавени 2940 реда и са изтрити 12 реда
  1. 4 0
      .gitignore
  2. 50 0
      mjava-benteler/pom.xml
  3. 32 0
      mjava-benteler/src/main/java/com/malk/benteler/Boot.java
  4. 46 0
      mjava-benteler/src/main/java/com/malk/benteler/config/BentelerYidaConf.java
  5. 60 0
      mjava-benteler/src/main/java/com/malk/benteler/controller/EiamController.java
  6. 37 0
      mjava-benteler/src/main/java/com/malk/benteler/controller/EiamOrganizationalUnitController.java
  7. 16 0
      mjava-benteler/src/main/java/com/malk/benteler/dto/BatchRequest.java
  8. 25 0
      mjava-benteler/src/main/java/com/malk/benteler/dto/EiamBatchItemResult.java
  9. 10 0
      mjava-benteler/src/main/java/com/malk/benteler/dto/EiamBatchOperation.java
  10. 43 0
      mjava-benteler/src/main/java/com/malk/benteler/dto/EiamBatchResult.java
  11. 13 0
      mjava-benteler/src/main/java/com/malk/benteler/dto/EiamBatchStage.java
  12. 23 0
      mjava-benteler/src/main/java/com/malk/benteler/dto/EiamCreateUserItem.java
  13. 14 0
      mjava-benteler/src/main/java/com/malk/benteler/dto/EiamDeleteUserItem.java
  14. 50 0
      mjava-benteler/src/main/java/com/malk/benteler/dto/EiamFormSyncResult.java
  15. 19 0
      mjava-benteler/src/main/java/com/malk/benteler/dto/EiamOrganizationalUnitOption.java
  16. 23 0
      mjava-benteler/src/main/java/com/malk/benteler/dto/EiamUpdateUserItem.java
  17. 12 0
      mjava-benteler/src/main/java/com/malk/benteler/dto/FormInstanceRequest.java
  18. 285 0
      mjava-benteler/src/main/java/com/malk/benteler/service/BentelerYidaFormMapper.java
  19. 223 0
      mjava-benteler/src/main/java/com/malk/benteler/service/BentelerYidaSyncService.java
  20. 295 0
      mjava-benteler/src/main/java/com/malk/benteler/service/EiamLocalService.java
  21. 216 0
      mjava-benteler/src/main/java/com/malk/benteler/service/EiamOrganizationalUnitLocalService.java
  22. 39 0
      mjava-benteler/src/main/resources/application-prod.yml.example
  23. 64 0
      mjava-benteler/src/main/resources/application.yml
  24. 112 0
      mjava-benteler/src/test/java/com/malk/benteler/service/BentelerYidaFormMapperTest.java
  25. 217 0
      mjava-benteler/src/test/java/com/malk/benteler/service/BentelerYidaSyncServiceTest.java
  26. 231 0
      mjava-benteler/src/test/java/com/malk/benteler/service/EiamLocalServiceTest.java
  27. 111 0
      mjava-benteler/src/test/java/com/malk/benteler/service/EiamOrganizationalUnitLocalServiceTest.java
  28. 47 0
      mjava-benteler/src/test/java/com/malk/benteler/service/TestBentelerYidaConf.java
  29. 1 1
      mjava/src/main/java/com/malk/server/aliwork/YDConf.java
  30. 24 0
      mjava/src/main/java/com/malk/server/eiam/EiamApiResponse.java
  31. 23 0
      mjava/src/main/java/com/malk/server/eiam/EiamConf.java
  32. 13 0
      mjava/src/main/java/com/malk/server/eiam/EiamCustomField.java
  33. 14 0
      mjava/src/main/java/com/malk/server/eiam/EiamCustomFieldUpdate.java
  34. 14 0
      mjava/src/main/java/com/malk/server/eiam/EiamOrganizationalUnit.java
  35. 15 0
      mjava/src/main/java/com/malk/server/eiam/EiamOrganizationalUnitPage.java
  36. 25 0
      mjava/src/main/java/com/malk/server/eiam/EiamTokenResponse.java
  37. 5 1
      mjava/src/main/java/com/malk/service/aliwork/YDService.java
  38. 37 4
      mjava/src/main/java/com/malk/service/aliwork/impl/YDClient_FormImpl.java
  39. 2 1
      mjava/src/main/java/com/malk/service/aliwork/impl/YDServiceImpl.java
  40. 4 4
      mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient.java
  41. 26 0
      mjava/src/main/java/com/malk/service/eiam/EiamClient.java
  42. 25 0
      mjava/src/main/java/com/malk/service/eiam/EiamClient_OrganizationalUnit.java
  43. 69 0
      mjava/src/main/java/com/malk/service/eiam/EiamClient_User.java
  44. 14 0
      mjava/src/main/java/com/malk/service/eiam/EiamService.java
  45. 36 0
      mjava/src/main/java/com/malk/service/eiam/impl/EiamClientImpl.java
  46. 51 0
      mjava/src/main/java/com/malk/service/eiam/impl/EiamClient_OrganizationalUnitImpl.java
  47. 118 0
      mjava/src/main/java/com/malk/service/eiam/impl/EiamClient_UserImpl.java
  48. 55 0
      mjava/src/main/java/com/malk/service/eiam/impl/EiamServiceImpl.java
  49. 50 0
      mjava/src/test/java/com/malk/service/eiam/impl/EiamClient_UserImplTest.java
  50. 2 1
      pom.xml

+ 4 - 0
.gitignore

@@ -37,6 +37,10 @@ mvnw.cmd
 */tmp
 */target
 
+# Local tool/login caches (may contain cookies and form instance data)
+.cache/
+**/.cache/
+
 # macOS
 .DS_Store
 */.DS_Store

+ 50 - 0
mjava-benteler/pom.xml

@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>java-mcli</artifactId>
+        <groupId>com.malk</groupId>
+        <version>1.0-SNAPSHOT</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>mjava-benteler</artifactId>
+    <description>本特勒 EIAM 用户批量同步</description>
+
+    <properties>
+        <maven.compiler.source>8</maven.compiler.source>
+        <maven.compiler.target>8</maven.compiler.target>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.malk</groupId>
+            <artifactId>mjava</artifactId>
+            <version>${mjava.version}</version>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>2.1.1.RELEASE</version>
+                <configuration>
+                    <includeSystemScope>true</includeSystemScope>
+                    <fork>false</fork>
+                    <jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>
+                </configuration>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+        <finalName>${project.artifactId}</finalName>
+    </build>
+</project>

+ 32 - 0
mjava-benteler/src/main/java/com/malk/benteler/Boot.java

@@ -0,0 +1,32 @@
+package com.malk.benteler;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+import javax.persistence.EntityManager;
+
+/**
+ * 本特勒客户子项目启动类。
+ */
+@EnableJpaAuditing
+@SpringBootApplication(scanBasePackages = {"com.malk"})
+public class Boot {
+
+    public static void main(String... args) {
+        SpringApplication.run(Boot.class, args);
+    }
+
+    /**
+     * 注册 QueryDSL 查询工厂。
+     *
+     * @param entityManager JPA EntityManager
+     * @return QueryDSL 查询工厂
+     */
+    @Bean
+    public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
+        return new JPAQueryFactory(entityManager);
+    }
+}

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

@@ -0,0 +1,46 @@
+package com.malk.benteler.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 本特勒宜搭表单字段配置。
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "benteler.yida")
+public class BentelerYidaConf {
+
+    private String onboardingFormUuid;
+    private String onboardingTableFieldId;
+    private String onboardingOrganizationalUnitIdFieldId;
+    private String onboardingNameFieldId;
+    private String onboardingPhoneFieldId;
+    private String onboardingEmailFieldId;
+    private String onboardingDescriptionFieldId;
+    private String onboardingEmployeeNumberFieldId;
+    private String onboardingJobTitleFieldId;
+    private String onboardingManagerFieldId;
+    private String onboardingFactoryFieldId;
+    private String onboardingCompanyFieldId;
+    private String onboardingCompanyCodeFieldId;
+    private String onboardingDepartmentFieldId;
+
+    private String updateFormUuid;
+    private String updateTableFieldId;
+    private String updateEmployeeFieldId;
+    private String updateDescriptionFieldId;
+    private String updateOrganizationalUnitIdFieldId;
+
+    private String offboardingFormUuid;
+    private String offboardingTableFieldId;
+    private String offboardingEmployeeFieldId;
+    private String offboardingRemarkFieldId;
+
+    private String detailStatusFieldId;
+    private String failureMessageFieldId;
+    private String totalFieldId;
+    private String successFieldId;
+    private String failedFieldId;
+}

+ 60 - 0
mjava-benteler/src/main/java/com/malk/benteler/controller/EiamController.java

@@ -0,0 +1,60 @@
+package com.malk.benteler.controller;
+
+import com.malk.benteler.dto.EiamFormSyncResult;
+import com.malk.benteler.dto.FormInstanceRequest;
+import com.malk.benteler.service.BentelerYidaSyncService;
+import com.malk.server.common.McR;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 本特勒宜搭调用 EIAM 批量用户接口。
+ */
+@RestController
+@RequestMapping("/benteler/eiam/users")
+public class EiamController {
+
+    private final BentelerYidaSyncService yidaSyncService;
+
+    public EiamController(BentelerYidaSyncService yidaSyncService) {
+        this.yidaSyncService = yidaSyncService;
+    }
+
+    /**
+     * 批量创建 EIAM 用户。
+     *
+     * @param request 批量创建请求
+     * @return 宜搭可逐条回写的执行结果
+     */
+    @PostMapping("/batch-create")
+    public McR<EiamFormSyncResult> batchCreate(@RequestBody FormInstanceRequest request) {
+        return McR.success(yidaSyncService.syncCreate(
+                request == null ? null : request.getFormInstanceId()));
+    }
+
+    /**
+     * 批量更新 EIAM 用户。
+     *
+     * @param request 批量更新请求
+     * @return 宜搭可逐条回写的执行结果
+     */
+    @PostMapping("/batch-update")
+    public McR<EiamFormSyncResult> batchUpdate(@RequestBody FormInstanceRequest request) {
+        return McR.success(yidaSyncService.syncUpdate(
+                request == null ? null : request.getFormInstanceId()));
+    }
+
+    /**
+     * 批量删除 EIAM 用户。
+     *
+     * @param request 批量删除请求
+     * @return 宜搭可逐条回写的执行结果
+     */
+    @PostMapping("/batch-delete")
+    public McR<EiamFormSyncResult> batchDelete(@RequestBody FormInstanceRequest request) {
+        return McR.success(yidaSyncService.syncDelete(
+                request == null ? null : request.getFormInstanceId()));
+    }
+}

+ 37 - 0
mjava-benteler/src/main/java/com/malk/benteler/controller/EiamOrganizationalUnitController.java

@@ -0,0 +1,37 @@
+package com.malk.benteler.controller;
+
+import com.malk.benteler.dto.EiamOrganizationalUnitOption;
+import com.malk.benteler.service.EiamOrganizationalUnitLocalService;
+import com.malk.server.common.McR;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 本特勒 EIAM 部门选择接口。
+ */
+@RestController
+@RequestMapping("/benteler/eiam/organizational-units")
+public class EiamOrganizationalUnitController {
+
+    private final EiamOrganizationalUnitLocalService organizationalUnitService;
+
+    public EiamOrganizationalUnitController(
+            EiamOrganizationalUnitLocalService organizationalUnitService) {
+        this.organizationalUnitService = organizationalUnitService;
+    }
+
+    /**
+     * 按部门名称查询 EIAM 部门选项。
+     *
+     * @param name 部门名称关键字
+     * @return 部门 ID 与“当前部门 (父部门)”显示名称
+     */
+    @GetMapping
+    public McR<List<EiamOrganizationalUnitOption>> search(@RequestParam String name) {
+        return McR.success(organizationalUnitService.searchByName(name));
+    }
+}

+ 16 - 0
mjava-benteler/src/main/java/com/malk/benteler/dto/BatchRequest.java

@@ -0,0 +1,16 @@
+package com.malk.benteler.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 通用批量请求。
+ *
+ * @param <T> 批量条目类型
+ */
+@Data
+public class BatchRequest<T> {
+
+    private List<T> items;
+}

+ 25 - 0
mjava-benteler/src/main/java/com/malk/benteler/dto/EiamBatchItemResult.java

@@ -0,0 +1,25 @@
+package com.malk.benteler.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * EIAM 批量单条执行结果。
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class EiamBatchItemResult {
+
+    private int index;
+    private String formInstanceId;
+    private String userKey;
+    private String userId;
+    private boolean success;
+    private EiamBatchStage stage;
+    private String code;
+    private String message;
+}

+ 10 - 0
mjava-benteler/src/main/java/com/malk/benteler/dto/EiamBatchOperation.java

@@ -0,0 +1,10 @@
+package com.malk.benteler.dto;
+
+/**
+ * EIAM 批量操作类型。
+ */
+public enum EiamBatchOperation {
+    CREATE,
+    UPDATE,
+    DELETE
+}

+ 43 - 0
mjava-benteler/src/main/java/com/malk/benteler/dto/EiamBatchResult.java

@@ -0,0 +1,43 @@
+package com.malk.benteler.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 宜搭可逐条回写的 EIAM 批量结果。
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class EiamBatchResult {
+
+    private EiamBatchOperation operation;
+    private int total;
+    private int successCount;
+    private int failedCount;
+    private List<EiamBatchItemResult> items;
+
+    /**
+     * 按明细列表生成批次统计。
+     *
+     * @param operation 操作类型
+     * @param items 逐条结果
+     * @return 批量结果
+     */
+    public static EiamBatchResult of(EiamBatchOperation operation,
+                                     List<EiamBatchItemResult> items) {
+        int successCount = (int) items.stream().filter(EiamBatchItemResult::isSuccess).count();
+        return EiamBatchResult.builder()
+                .operation(operation)
+                .total(items.size())
+                .successCount(successCount)
+                .failedCount(items.size() - successCount)
+                .items(items)
+                .build();
+    }
+}

+ 13 - 0
mjava-benteler/src/main/java/com/malk/benteler/dto/EiamBatchStage.java

@@ -0,0 +1,13 @@
+package com.malk.benteler.dto;
+
+/**
+ * EIAM 单条处理阶段。
+ */
+public enum EiamBatchStage {
+    VALIDATE,
+    CREATE_USER,
+    PATCH_USER,
+    SET_PRIMARY_ORG,
+    DELETE_USER,
+    COMPLETED
+}

+ 23 - 0
mjava-benteler/src/main/java/com/malk/benteler/dto/EiamCreateUserItem.java

@@ -0,0 +1,23 @@
+package com.malk.benteler.dto;
+
+import com.malk.server.eiam.EiamCustomField;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 批量创建 EIAM 账户条目。
+ */
+@Data
+public class EiamCreateUserItem {
+
+    private String formInstanceId;
+    private String username;
+    private String displayName;
+    private String phoneRegion;
+    private String phoneNumber;
+    private String email;
+    private String primaryOrganizationalUnitId;
+    private String description;
+    private List<EiamCustomField> customFields;
+}

+ 14 - 0
mjava-benteler/src/main/java/com/malk/benteler/dto/EiamDeleteUserItem.java

@@ -0,0 +1,14 @@
+package com.malk.benteler.dto;
+
+import lombok.Data;
+
+/**
+ * 批量删除 EIAM 账户条目。
+ */
+@Data
+public class EiamDeleteUserItem {
+
+    private String formInstanceId;
+    private String userId;
+    private String username;
+}

+ 50 - 0
mjava-benteler/src/main/java/com/malk/benteler/dto/EiamFormSyncResult.java

@@ -0,0 +1,50 @@
+package com.malk.benteler.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * EIAM 执行及宜搭回写结果。
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class EiamFormSyncResult {
+
+    private String formInstanceId;
+    private EiamBatchOperation operation;
+    private int total;
+    private int successCount;
+    private int failedCount;
+    private List<EiamBatchItemResult> items;
+    private boolean writebackSuccess;
+    private String writebackMessage;
+
+    /**
+     * 合并批量执行结果和宜搭回写状态。
+     *
+     * @param formInstanceId 宜搭实例 ID
+     * @param batchResult EIAM 批量结果
+     * @param writebackSuccess 是否回写成功
+     * @param writebackMessage 回写说明
+     * @return 同步结果
+     */
+    public static EiamFormSyncResult of(String formInstanceId, EiamBatchResult batchResult,
+                                        boolean writebackSuccess, String writebackMessage) {
+        return EiamFormSyncResult.builder()
+                .formInstanceId(formInstanceId)
+                .operation(batchResult.getOperation())
+                .total(batchResult.getTotal())
+                .successCount(batchResult.getSuccessCount())
+                .failedCount(batchResult.getFailedCount())
+                .items(batchResult.getItems())
+                .writebackSuccess(writebackSuccess)
+                .writebackMessage(writebackMessage)
+                .build();
+    }
+}

+ 19 - 0
mjava-benteler/src/main/java/com/malk/benteler/dto/EiamOrganizationalUnitOption.java

@@ -0,0 +1,19 @@
+package com.malk.benteler.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 宜搭部门选择器使用的 EIAM 组织选项。
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class EiamOrganizationalUnitOption {
+
+    private String organizationalUnitId;
+    private String organizationalUnitName;
+}

+ 23 - 0
mjava-benteler/src/main/java/com/malk/benteler/dto/EiamUpdateUserItem.java

@@ -0,0 +1,23 @@
+package com.malk.benteler.dto;
+
+import com.malk.server.eiam.EiamCustomFieldUpdate;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 批量更新 EIAM 账户条目。
+ */
+@Data
+public class EiamUpdateUserItem {
+
+    private String formInstanceId;
+    private String userId;
+    private String username;
+    private String displayName;
+    private String phoneRegion;
+    private String phoneNumber;
+    private String email;
+    private String primaryOrganizationalUnitId;
+    private List<EiamCustomFieldUpdate> customFields;
+}

+ 12 - 0
mjava-benteler/src/main/java/com/malk/benteler/dto/FormInstanceRequest.java

@@ -0,0 +1,12 @@
+package com.malk.benteler.dto;
+
+import lombok.Data;
+
+/**
+ * 宜搭单实例同步请求。
+ */
+@Data
+public class FormInstanceRequest {
+
+    private String formInstanceId;
+}

+ 285 - 0
mjava-benteler/src/main/java/com/malk/benteler/service/BentelerYidaFormMapper.java

@@ -0,0 +1,285 @@
+package com.malk.benteler.service;
+
+import com.malk.benteler.config.BentelerYidaConf;
+import com.malk.benteler.dto.EiamBatchItemResult;
+import com.malk.benteler.dto.EiamCreateUserItem;
+import com.malk.benteler.dto.EiamDeleteUserItem;
+import com.malk.benteler.dto.EiamUpdateUserItem;
+import com.malk.server.eiam.EiamCustomField;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 宜搭真实 fieldId 与 EIAM DTO 的纯映射器。
+ */
+@Component
+public class BentelerYidaFormMapper {
+
+    private static final String STATUS_SUCCESS = "成功";
+    private static final String STATUS_FAILED = "失败";
+
+    private final BentelerYidaConf conf;
+
+    public BentelerYidaFormMapper(BentelerYidaConf conf) {
+        this.conf = conf;
+    }
+
+    /**
+     * 映射入职人员明细。
+     *
+     * @param formInstanceId 宜搭实例 ID
+     * @param formData 主表数据
+     * @param rows 人员子表全量数据
+     * @return EIAM 创建条目
+     */
+    public List<EiamCreateUserItem> mapCreateItems(String formInstanceId,
+                                                   Map<String, Object> formData,
+                                                   List<Map<String, Object>> rows) {
+        List<EiamCreateUserItem> items = new ArrayList<>();
+        String organizationalUnitId = stringValue(formData.get(
+                conf.getOnboardingOrganizationalUnitIdFieldId()));
+        for (Map<String, Object> row : rows) {
+            EiamCreateUserItem item = new EiamCreateUserItem();
+            item.setFormInstanceId(formInstanceId);
+            item.setUsername(stringValue(row.get(conf.getOnboardingPhoneFieldId())));
+            item.setDisplayName(stringValue(row.get(conf.getOnboardingNameFieldId())));
+            item.setPhoneNumber(item.getUsername());
+            item.setEmail(stringValue(row.get(conf.getOnboardingEmailFieldId())));
+            item.setDescription(stringValue(row.get(conf.getOnboardingDescriptionFieldId())));
+            item.setPrimaryOrganizationalUnitId(organizationalUnitId);
+            item.setCustomFields(createCustomFields(formData, row));
+            items.add(item);
+        }
+        return items;
+    }
+
+    /**
+     * 映射人员部门更新明细。
+     *
+     * @param formInstanceId 宜搭实例 ID
+     * @param rows 人员子表全量数据
+     * @param organizationalUnitId EIAM 目标部门 ID
+     * @return EIAM 更新条目
+     */
+    public List<EiamUpdateUserItem> mapUpdateItems(String formInstanceId,
+                                                   List<Map<String, Object>> rows,
+                                                   String organizationalUnitId) {
+        List<EiamUpdateUserItem> items = new ArrayList<>();
+        for (Map<String, Object> row : rows) {
+            EiamUpdateUserItem item = new EiamUpdateUserItem();
+            item.setFormInstanceId(formInstanceId);
+            item.setUserId(firstString(row.get(conf.getUpdateEmployeeFieldId() + "_id")));
+            item.setPrimaryOrganizationalUnitId(organizationalUnitId);
+            items.add(item);
+        }
+        return items;
+    }
+
+    /**
+     * 映射离职人员明细。
+     *
+     * @param formInstanceId 宜搭实例 ID
+     * @param rows 人员子表全量数据
+     * @return EIAM 删除条目
+     */
+    public List<EiamDeleteUserItem> mapDeleteItems(String formInstanceId,
+                                                   List<Map<String, Object>> rows) {
+        List<EiamDeleteUserItem> items = new ArrayList<>();
+        for (Map<String, Object> row : rows) {
+            EiamDeleteUserItem item = new EiamDeleteUserItem();
+            item.setFormInstanceId(formInstanceId);
+            item.setUserId(firstString(row.get(conf.getOffboardingEmployeeFieldId() + "_id")));
+            items.add(item);
+        }
+        return items;
+    }
+
+    /**
+     * 按表单类型全量重建子表,成员字段只写 userId 数组。
+     *
+     * @param tableFieldId 子表 fieldId
+     * @param rows 原始全量行
+     * @param results EIAM 逐行执行结果
+     * @return 可用于宜搭 updateForm 的全量行
+     */
+    public List<Map<String, Object>> rebuildRows(String tableFieldId,
+                                                 List<Map<String, Object>> rows,
+                                                 List<EiamBatchItemResult> results) {
+        List<Map<String, Object>> rebuilt = new ArrayList<>();
+        for (int index = 0; index < rows.size(); index++) {
+            Map<String, Object> row = rows.get(index);
+            Map<String, Object> target = new LinkedHashMap<>();
+            if (StringUtils.equals(tableFieldId, conf.getOnboardingTableFieldId())) {
+                copyFields(row, target, Arrays.asList(conf.getOnboardingNameFieldId(),
+                        conf.getOnboardingPhoneFieldId(), conf.getOnboardingEmailFieldId(),
+                        conf.getOnboardingDescriptionFieldId(), conf.getOnboardingEmployeeNumberFieldId(),
+                        conf.getOnboardingJobTitleFieldId()));
+                copyEmployee(row, target, conf.getOnboardingManagerFieldId());
+            } else if (StringUtils.equals(tableFieldId, conf.getUpdateTableFieldId())) {
+                copyFields(row, target, Arrays.asList(conf.getUpdateDescriptionFieldId()));
+                copyEmployee(row, target, conf.getUpdateEmployeeFieldId());
+            } else if (StringUtils.equals(tableFieldId, conf.getOffboardingTableFieldId())) {
+                copyFields(row, target, Arrays.asList(conf.getOffboardingRemarkFieldId()));
+                copyEmployee(row, target, conf.getOffboardingEmployeeFieldId());
+            }
+            target.put(conf.getDetailStatusFieldId(), results.get(index).isSuccess()
+                    ? STATUS_SUCCESS : STATUS_FAILED);
+            rebuilt.add(target);
+        }
+        return rebuilt;
+    }
+
+    /**
+     * 聚合失败人员及原因,用于宜搭主表失败说明字段。
+     *
+     * @param tableFieldId 子表 fieldId
+     * @param rows 原始全量行
+     * @param results EIAM 逐行执行结果
+     * @return 每条失败占一行;全部成功时返回空字符串
+     */
+    public String buildFailureMessage(String tableFieldId,
+                                      List<Map<String, Object>> rows,
+                                      List<EiamBatchItemResult> results) {
+        List<String> messages = new ArrayList<>();
+        for (int index = 0; index < results.size(); index++) {
+            EiamBatchItemResult result = results.get(index);
+            if (result.isSuccess()) {
+                continue;
+            }
+            Map<String, Object> row = index < rows.size() ? rows.get(index) : null;
+            String personName = personName(tableFieldId, row);
+            if (StringUtils.isBlank(personName)) {
+                personName = "第" + (index + 1) + "行";
+            }
+            String reason = StringUtils.defaultIfBlank(result.getMessage(), result.getCode());
+            messages.add(personName + " - " + StringUtils.defaultIfBlank(reason, "执行失败"));
+        }
+        return String.join("\n", messages);
+    }
+
+    private List<EiamCustomField> createCustomFields(Map<String, Object> formData,
+                                                     Map<String, Object> row) {
+        List<EiamCustomField> fields = new ArrayList<>();
+        addCustomField(fields, "company_name", formData.get(conf.getOnboardingCompanyFieldId()));
+        addCustomField(fields, "company_code", formData.get(conf.getOnboardingCompanyCodeFieldId()));
+        addCustomField(fields, "employee_id_number", row.get(conf.getOnboardingEmployeeNumberFieldId()));
+        addCustomField(fields, "job_title", row.get(conf.getOnboardingJobTitleFieldId()));
+        addCustomField(fields, "department", formData.get(conf.getOnboardingDepartmentFieldId()));
+        addCustomField(fields, "direct_line_manager",
+                firstString(row.get(conf.getOnboardingManagerFieldId() + "_id")));
+        return fields.isEmpty() ? null : fields;
+    }
+
+    private void addCustomField(List<EiamCustomField> fields, String fieldName, Object value) {
+        String fieldValue = stringValue(value);
+        if (StringUtils.isBlank(fieldValue)) {
+            return;
+        }
+        EiamCustomField field = new EiamCustomField();
+        field.setFieldName(fieldName);
+        field.setFieldValue(fieldValue);
+        fields.add(field);
+    }
+
+    private void copyFields(Map<String, Object> source, Map<String, Object> target,
+                            List<String> fieldIds) {
+        for (String fieldId : fieldIds) {
+            if (StringUtils.isNotBlank(fieldId) && source.get(fieldId) != null) {
+                target.put(fieldId, source.get(fieldId));
+            }
+        }
+    }
+
+    private void copyEmployee(Map<String, Object> source, Map<String, Object> target,
+                              String fieldId) {
+        if (StringUtils.isBlank(fieldId)) {
+            return;
+        }
+        List<String> userIds = stringList(source.get(fieldId + "_id"));
+        if (!userIds.isEmpty()) {
+            target.put(fieldId, userIds);
+        }
+    }
+
+    private String personName(String tableFieldId, Map<String, Object> row) {
+        if (row == null) {
+            return null;
+        }
+        if (StringUtils.equals(tableFieldId, conf.getOnboardingTableFieldId())) {
+            return displayText(row.get(conf.getOnboardingNameFieldId()));
+        }
+        if (StringUtils.equals(tableFieldId, conf.getUpdateTableFieldId())) {
+            return displayText(row.get(conf.getUpdateEmployeeFieldId()));
+        }
+        if (StringUtils.equals(tableFieldId, conf.getOffboardingTableFieldId())) {
+            return displayText(row.get(conf.getOffboardingEmployeeFieldId()));
+        }
+        return null;
+    }
+
+    private String displayText(Object value) {
+        if (value instanceof Iterable) {
+            for (Object item : (Iterable<?>) value) {
+                String text = displayText(item);
+                if (StringUtils.isNotBlank(text)) {
+                    return text;
+                }
+            }
+            return null;
+        }
+        if (value instanceof Map) {
+            Map<?, ?> map = (Map<?, ?>) value;
+            for (String key : Arrays.asList("name", "displayName", "label")) {
+                String text = displayText(map.get(key));
+                if (StringUtils.isNotBlank(text)) {
+                    return text;
+                }
+            }
+            return null;
+        }
+        return stringValue(value);
+    }
+
+    private String firstString(Object value) {
+        List<String> values = stringList(value);
+        return values.isEmpty() ? null : values.get(0);
+    }
+
+    private List<String> stringList(Object value) {
+        List<String> values = new ArrayList<>();
+        if (value instanceof Iterable) {
+            for (Object item : (Iterable<?>) value) {
+                String text = stringValue(item);
+                if (StringUtils.isNotBlank(text)) {
+                    values.add(text);
+                }
+            }
+        } else {
+            String text = stringValue(value);
+            if (StringUtils.isNotBlank(text)) {
+                values.add(text);
+            }
+        }
+        return values;
+    }
+
+    private String stringValue(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof BigDecimal) {
+            return ((BigDecimal) value).toPlainString();
+        }
+        if (value instanceof Number) {
+            return new BigDecimal(value.toString()).toPlainString();
+        }
+        return StringUtils.trimToNull(String.valueOf(value));
+    }
+}

+ 223 - 0
mjava-benteler/src/main/java/com/malk/benteler/service/BentelerYidaSyncService.java

@@ -0,0 +1,223 @@
+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.EiamBatchResult;
+import com.malk.benteler.dto.EiamFormSyncResult;
+import com.malk.server.aliwork.YDAuth;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.common.McException;
+import com.malk.service.aliwork.YDClient_Form;
+import com.malk.service.aliwork.YDService;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 基于一个宜搭实例 ID 完成查询、批量 EIAM 执行和全量回写。
+ */
+@Slf4j
+@Service
+public class BentelerYidaSyncService {
+
+    private final YDClient_Form ydClientForm;
+    private final YDService ydService;
+    private final YDConf ydConf;
+    private final BentelerYidaConf conf;
+    private final BentelerYidaFormMapper mapper;
+    private final EiamLocalService eiamLocalService;
+
+    public BentelerYidaSyncService(YDClient_Form ydClientForm, YDService ydService,
+                                   YDConf ydConf, BentelerYidaConf conf,
+                                   BentelerYidaFormMapper mapper,
+                                   EiamLocalService eiamLocalService) {
+        this.ydClientForm = ydClientForm;
+        this.ydService = ydService;
+        this.ydConf = ydConf;
+        this.conf = conf;
+        this.mapper = mapper;
+        this.eiamLocalService = eiamLocalService;
+    }
+
+    /**
+     * 查询入职实例并批量创建 EIAM 用户。
+     *
+     * @param formInstanceId 宜搭实例 ID
+     * @return 执行及回写结果
+     */
+    public EiamFormSyncResult syncCreate(String formInstanceId) {
+        return sync(formInstanceId, EiamBatchOperation.CREATE,
+                conf.getOnboardingFormUuid(), conf.getOnboardingTableFieldId());
+    }
+
+    /**
+     * 查询人员部门更新实例并批量更新 EIAM 用户。
+     *
+     * @param formInstanceId 宜搭实例 ID
+     * @return 执行及回写结果
+     */
+    public EiamFormSyncResult syncUpdate(String formInstanceId) {
+        return sync(formInstanceId, EiamBatchOperation.UPDATE,
+                conf.getUpdateFormUuid(), conf.getUpdateTableFieldId());
+    }
+
+    /**
+     * 查询离职实例并批量删除 EIAM 用户。
+     *
+     * @param formInstanceId 宜搭实例 ID
+     * @return 执行及回写结果
+     */
+    public EiamFormSyncResult syncDelete(String formInstanceId) {
+        return sync(formInstanceId, EiamBatchOperation.DELETE,
+                conf.getOffboardingFormUuid(), conf.getOffboardingTableFieldId());
+    }
+
+    private EiamFormSyncResult sync(String formInstanceId, EiamBatchOperation operation,
+                                    String expectedFormUuid, String tableFieldId) {
+        validateRequest(formInstanceId, tableFieldId);
+        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));
+        if (rows.isEmpty()) {
+            throw new McException("YIDA_FORM_EMPTY", "宜搭人员子表不能为空");
+        }
+        EiamBatchResult batchResult = execute(operation, formInstanceId, formData, rows);
+        return writeback(formInstanceId, tableFieldId, rows, batchResult);
+    }
+
+    private EiamBatchResult execute(EiamBatchOperation operation, String formInstanceId,
+                                    Map<String, Object> formData,
+                                    List<Map<String, Object>> rows) {
+        if (operation == EiamBatchOperation.CREATE) {
+            String organizationalUnitId = text(formData.get(
+                    conf.getOnboardingOrganizationalUnitIdFieldId()));
+            if (!StringUtils.startsWith(organizationalUnitId, "ou_")) {
+                throw new McException("EIAM_ORG_INVALID", "iDaaS 部门 ID 无效");
+            }
+            return eiamLocalService.batchCreate(mapper.mapCreateItems(
+                    formInstanceId, formData, rows));
+        }
+        if (operation == EiamBatchOperation.UPDATE) {
+            String organizationalUnitId = firstText(formData.get(
+                    conf.getUpdateOrganizationalUnitIdFieldId()));
+            if (!StringUtils.startsWith(organizationalUnitId, "ou_")) {
+                throw new McException("EIAM_ORG_INVALID", "iDaaS 部门 ID 无效");
+            }
+            return eiamLocalService.batchUpdate(mapper.mapUpdateItems(
+                    formInstanceId, rows, organizationalUnitId));
+        }
+        return eiamLocalService.batchDelete(mapper.mapDeleteItems(formInstanceId, rows));
+    }
+
+    private EiamFormSyncResult writeback(String formInstanceId, String tableFieldId,
+                                         List<Map<String, Object>> rows,
+                                         EiamBatchResult batchResult) {
+        Map<String, Object> update = new HashMap<>();
+        update.put(tableFieldId, mapper.rebuildRows(tableFieldId, rows,
+                batchResult.getItems()));
+        update.put(conf.getFailureMessageFieldId(), mapper.buildFailureMessage(
+                tableFieldId, rows, batchResult.getItems()));
+        update.put(conf.getTotalFieldId(), batchResult.getTotal());
+        update.put(conf.getSuccessFieldId(), batchResult.getSuccessCount());
+        update.put(conf.getFailedFieldId(), batchResult.getFailedCount());
+        Map<String, Object> bodyExt = new HashMap<>();
+        bodyExt.put("useLatestVersion", true);
+        bodyExt.put("ignoreEmpty", false);
+        try {
+            ydClientForm.updateForm(auth(), formInstanceId, JSON.toJSONString(update), bodyExt);
+            return EiamFormSyncResult.of(formInstanceId, batchResult, true, "回写成功");
+        } catch (RuntimeException ex) {
+            log.error("宜搭实例执行结果回写失败, formInstanceId={}", formInstanceId, ex);
+            return EiamFormSyncResult.of(formInstanceId, batchResult, false,
+                    StringUtils.defaultIfBlank(ex.getMessage(), "宜搭回写失败"));
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> formData(Map<String, Object> instance) {
+        if (instance == null || instance.get("formData") == null) {
+            throw new McException("YIDA_FORM_DATA_EMPTY", "宜搭实例数据为空");
+        }
+        Object value = instance.get("formData");
+        if (value instanceof Map) {
+            return (Map<String, Object>) value;
+        }
+        Map<String, Object> parsed = JSON.parseObject(String.valueOf(value), Map.class);
+        if (parsed == null) {
+            throw new McException("YIDA_FORM_DATA_INVALID", "宜搭实例 formData 格式错误");
+        }
+        return parsed;
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Map<String, Object>> resolveDetailRows(String formInstanceId,
+                                                        String tableFieldId,
+                                                        Object inlineValue) {
+        List<Map<String, Object>> inlineRows = inlineValue instanceof List
+                ? (List<Map<String, Object>>) inlineValue : new ArrayList<>();
+        // fixme 详情接口最多内联 50 行;只有恰好 50 行时才可能被截断,必须单独分页查询子表。
+        if (inlineRows.size() == YDConf.PAGE_SIZE_DETAILS) {
+            try {
+                List<Map> details = ydService.queryDetails(YDParam.builder()
+                        .appType(ydConf.getAppType())
+                        .systemToken(ydConf.getSystemToken())
+                        .formInstanceId(formInstanceId)
+                        .tableFieldId(tableFieldId)
+                        .pageNumber(1)
+                        .build());
+                return details == null ? inlineRows : (List<Map<String, Object>>) (List<?>) details;
+            } catch (NullPointerException ex) {
+                log.warn("宜搭子表分页查询空数据,退回内联 50 行, formInstanceId={}, tableFieldId={}",
+                        formInstanceId, tableFieldId);
+            }
+        }
+        return inlineRows;
+    }
+
+    private void validateRequest(String formInstanceId, String tableFieldId) {
+        if (StringUtils.isBlank(formInstanceId)) {
+            throw new McException("YIDA_FORM_INSTANCE_ID_EMPTY", "formInstanceId 不能为空");
+        }
+        if (StringUtils.isBlank(tableFieldId)) {
+            throw new McException("YIDA_FIELD_CONFIG_EMPTY", "人员子表 fieldId 未配置");
+        }
+    }
+
+    private void validateForm(Map<String, Object> instance, String expectedFormUuid) {
+        if (instance == null) {
+            throw new McException("YIDA_FORM_EMPTY", "宜搭实例查询结果为空");
+        }
+        String actualFormUuid = text(instance.get("modelUuid"));
+        if (StringUtils.isNotBlank(expectedFormUuid) && StringUtils.isNotBlank(actualFormUuid)
+                && !StringUtils.equals(expectedFormUuid, actualFormUuid)) {
+            throw new McException("YIDA_FORM_MISMATCH", "宜搭实例与当前同步场景不匹配");
+        }
+    }
+
+    private YDAuth auth() {
+        return YDAuth.ofGlobal(ydConf);
+    }
+
+    private String firstText(Object value) {
+        if (value instanceof Iterable) {
+            for (Object item : (Iterable<?>) value) {
+                return text(item);
+            }
+            return null;
+        }
+        return text(value);
+    }
+
+    private String text(Object value) {
+        return value == null ? null : StringUtils.trimToNull(String.valueOf(value));
+    }
+}

+ 295 - 0
mjava-benteler/src/main/java/com/malk/benteler/service/EiamLocalService.java

@@ -0,0 +1,295 @@
+package com.malk.benteler.service;
+
+import com.malk.benteler.dto.EiamBatchItemResult;
+import com.malk.benteler.dto.EiamBatchOperation;
+import com.malk.benteler.dto.EiamBatchResult;
+import com.malk.benteler.dto.EiamBatchStage;
+import com.malk.benteler.dto.EiamCreateUserItem;
+import com.malk.benteler.dto.EiamDeleteUserItem;
+import com.malk.benteler.dto.EiamUpdateUserItem;
+import com.malk.server.common.McException;
+import com.malk.server.eiam.EiamApiResponse;
+import com.malk.server.eiam.EiamConf;
+import com.malk.server.eiam.EiamCustomField;
+import com.malk.service.eiam.EiamClient_User;
+import com.malk.service.eiam.EiamService;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 本特勒 EIAM 批量业务编排。
+ */
+@Service
+public class EiamLocalService {
+
+    private final EiamService eiamService;
+    private final EiamClient_User eiamClientUser;
+    private final EiamConf eiamConf;
+
+    public EiamLocalService(EiamService eiamService, EiamClient_User eiamClientUser,
+                            EiamConf eiamConf) {
+        this.eiamService = eiamService;
+        this.eiamClientUser = eiamClientUser;
+        this.eiamConf = eiamConf;
+    }
+
+    /**
+     * 批量创建用户,单条失败不中断后续条目。
+     *
+     * @param items 创建条目
+     * @return 批量结果
+     */
+    public EiamBatchResult batchCreate(List<EiamCreateUserItem> items) {
+        validateBatch(items);
+        String token = eiamService.getAccessToken();
+        List<EiamBatchItemResult> results = new ArrayList<>();
+        for (int index = 0; index < items.size(); index++) {
+            results.add(createOne(token, items.get(index), index));
+        }
+        return EiamBatchResult.of(EiamBatchOperation.CREATE, results);
+    }
+
+    /**
+     * 批量更新用户,支持账户属性和主组织变更。
+     *
+     * @param items 更新条目
+     * @return 批量结果
+     */
+    public EiamBatchResult batchUpdate(List<EiamUpdateUserItem> items) {
+        validateBatch(items);
+        String token = eiamService.getAccessToken();
+        List<EiamBatchItemResult> results = new ArrayList<>();
+        for (int index = 0; index < items.size(); index++) {
+            results.add(updateOne(token, items.get(index), index));
+        }
+        return EiamBatchResult.of(EiamBatchOperation.UPDATE, results);
+    }
+
+    /**
+     * 批量删除用户,单条失败不中断后续条目。
+     *
+     * @param items 删除条目
+     * @return 批量结果
+     */
+    public EiamBatchResult batchDelete(List<EiamDeleteUserItem> items) {
+        validateBatch(items);
+        String token = eiamService.getAccessToken();
+        List<EiamBatchItemResult> results = new ArrayList<>();
+        for (int index = 0; index < items.size(); index++) {
+            results.add(deleteOne(token, items.get(index), index));
+        }
+        return EiamBatchResult.of(EiamBatchOperation.DELETE, results);
+    }
+
+    private EiamBatchItemResult createOne(String token, EiamCreateUserItem item, int index) {
+        EiamBatchStage stage = EiamBatchStage.VALIDATE;
+        try {
+            validateCreateItem(item);
+            stage = EiamBatchStage.CREATE_USER;
+            EiamApiResponse response = eiamClientUser.createUser(token, eiamConf.getInstanceId(),
+                    eiamConf.getApplicationId(), item.getUsername(),
+                    item.getPrimaryOrganizationalUnitId(), createBody(item));
+            assertEiamSuccess(response);
+            if (StringUtils.isBlank(response.getUserId())) {
+                throw new McException("EIAM_EMPTY_USER_ID", "CreateUser 成功响应缺少 userId");
+            }
+            return success(index, item.getFormInstanceId(), item.getUsername(), response.getUserId());
+        } catch (RuntimeException ex) {
+            return failure(index, item == null ? null : item.getFormInstanceId(),
+                    item == null ? null : item.getUsername(), null, stage, ex, false);
+        }
+    }
+
+    private EiamBatchItemResult updateOne(String token, EiamUpdateUserItem item, int index) {
+        EiamBatchStage stage = EiamBatchStage.VALIDATE;
+        boolean patchCompleted = false;
+        try {
+            validateUpdateItem(item);
+            Map<String, Object> patchBody = updateBody(item);
+            if (!patchBody.isEmpty()) {
+                stage = EiamBatchStage.PATCH_USER;
+                assertEiamSuccess(eiamClientUser.patchUser(token, eiamConf.getInstanceId(),
+                        eiamConf.getApplicationId(), item.getUserId(), patchBody));
+                patchCompleted = true;
+            }
+            if (StringUtils.isNotBlank(item.getPrimaryOrganizationalUnitId())) {
+                stage = EiamBatchStage.SET_PRIMARY_ORG;
+                assertEiamSuccess(eiamClientUser.setUserPrimaryOrganizationalUnit(token,
+                        eiamConf.getInstanceId(), eiamConf.getApplicationId(), item.getUserId(),
+                        item.getPrimaryOrganizationalUnitId()));
+            }
+            return success(index, item.getFormInstanceId(), userKey(item), item.getUserId());
+        } catch (RuntimeException ex) {
+            return failure(index, item == null ? null : item.getFormInstanceId(),
+                    item == null ? null : userKey(item), item == null ? null : item.getUserId(),
+                    stage, ex, patchCompleted && stage == EiamBatchStage.SET_PRIMARY_ORG);
+        }
+    }
+
+    private EiamBatchItemResult deleteOne(String token, EiamDeleteUserItem item, int index) {
+        EiamBatchStage stage = EiamBatchStage.VALIDATE;
+        try {
+            validateFormInstanceId(item == null ? null : item.getFormInstanceId());
+            if (item == null || StringUtils.isBlank(item.getUserId())) {
+                throw new McException("EIAM_ITEM_INVALID", "userId 不能为空");
+            }
+            stage = EiamBatchStage.DELETE_USER;
+            assertEiamSuccess(eiamClientUser.deleteUser(token, eiamConf.getInstanceId(),
+                    eiamConf.getApplicationId(), item.getUserId()));
+            return success(index, item.getFormInstanceId(), deleteUserKey(item), item.getUserId());
+        } catch (RuntimeException ex) {
+            return failure(index, item == null ? null : item.getFormInstanceId(),
+                    item == null ? null : deleteUserKey(item), item == null ? null : item.getUserId(),
+                    stage, ex, false);
+        }
+    }
+
+    private Map<String, Object> createBody(EiamCreateUserItem item) {
+        Map<String, Object> body = new HashMap<>();
+        putIfNotBlank(body, "displayName", item.getDisplayName());
+        String phoneNumber = StringUtils.defaultIfBlank(item.getPhoneNumber(), item.getUsername());
+        putIfNotBlank(body, "phoneNumber", phoneNumber);
+        if (StringUtils.isNotBlank(phoneNumber)) {
+            body.put("phoneRegion", StringUtils.defaultIfBlank(item.getPhoneRegion(), "86"));
+            body.put("phoneNumberVerified", true);
+        }
+        putIfNotBlank(body, "email", item.getEmail());
+        if (StringUtils.isNotBlank(item.getEmail())) {
+            body.put("emailVerified", false);
+        }
+        putIfNotBlank(body, "description", item.getDescription());
+        if (item.getCustomFields() != null) {
+            body.put("customFields", item.getCustomFields());
+        }
+        Map<String, Object> passwordConfig = new HashMap<>();
+        passwordConfig.put("passwordInitializationPolicyPriority", "custom");
+        passwordConfig.put("passwordForcedUpdateStatus", "enabled");
+        // prd 客户不接收初始密码通知,用户首次登录时通过重置密码流程完成密码设置。
+        passwordConfig.put("passwordInitializationType", "random");
+        body.put("passwordInitializationConfig", passwordConfig);
+        return body;
+    }
+
+    private Map<String, Object> updateBody(EiamUpdateUserItem item) {
+        Map<String, Object> body = new HashMap<>();
+        putIfNotBlank(body, "username", item.getUsername());
+        putIfNotBlank(body, "displayName", item.getDisplayName());
+        putIfNotBlank(body, "phoneNumber", item.getPhoneNumber());
+        if (StringUtils.isNotBlank(item.getPhoneNumber())) {
+            body.put("phoneRegion", StringUtils.defaultIfBlank(item.getPhoneRegion(), "86"));
+            body.put("phoneNumberVerified", true);
+        }
+        putIfNotBlank(body, "email", item.getEmail());
+        if (StringUtils.isNotBlank(item.getEmail())) {
+            body.put("emailVerified", false);
+        }
+        if (item.getCustomFields() != null) {
+            body.put("customFields", item.getCustomFields());
+        }
+        return body;
+    }
+
+    private void validateCreateItem(EiamCreateUserItem item) {
+        validateFormInstanceId(item == null ? null : item.getFormInstanceId());
+        if (item == null || StringUtils.isBlank(item.getUsername())) {
+            throw new McException("EIAM_ITEM_INVALID", "username 不能为空");
+        }
+        if (StringUtils.isBlank(item.getDisplayName())) {
+            throw new McException("EIAM_ITEM_INVALID", "displayName 不能为空");
+        }
+        if (StringUtils.isBlank(item.getPrimaryOrganizationalUnitId())) {
+            throw new McException("EIAM_ITEM_INVALID", "primaryOrganizationalUnitId 不能为空");
+        }
+        // prd 新增账号必须完整携带宜搭主表和人员子表中的 EIAM 自定义字段。
+        validateRequiredCustomField(item, "department", "iDaaS 部门名称");
+        validateRequiredCustomField(item, "company_name", "所属公司");
+        validateRequiredCustomField(item, "company_code", "公司代码");
+        validateRequiredCustomField(item, "job_title", "职位");
+    }
+
+    private void validateRequiredCustomField(EiamCreateUserItem item, String fieldName,
+                                             String displayName) {
+        if (item.getCustomFields() != null) {
+            for (EiamCustomField field : item.getCustomFields()) {
+                if (field != null && StringUtils.equals(fieldName, field.getFieldName())
+                        && StringUtils.isNotBlank(field.getFieldValue())) {
+                    return;
+                }
+            }
+        }
+        throw new McException("EIAM_ITEM_INVALID", displayName + " 不能为空");
+    }
+
+    private void validateUpdateItem(EiamUpdateUserItem item) {
+        validateFormInstanceId(item == null ? null : item.getFormInstanceId());
+        if (item == null || StringUtils.isBlank(item.getUserId())) {
+            throw new McException("EIAM_ITEM_INVALID", "userId 不能为空");
+        }
+        if (updateBody(item).isEmpty() && StringUtils.isBlank(item.getPrimaryOrganizationalUnitId())) {
+            throw new McException("EIAM_ITEM_INVALID", "更新内容不能为空");
+        }
+    }
+
+    private void validateBatch(List<?> items) {
+        if (items == null || items.isEmpty()) {
+            throw new McException("EIAM_BATCH_EMPTY", "items 不能为空");
+        }
+    }
+
+    private void validateFormInstanceId(String formInstanceId) {
+        if (StringUtils.isBlank(formInstanceId)) {
+            throw new McException("EIAM_ITEM_INVALID", "formInstanceId 不能为空");
+        }
+    }
+
+    private void assertEiamSuccess(EiamApiResponse response) {
+        if (response == null) {
+            throw new McException("EIAM_EMPTY_RESPONSE", "EIAM 响应为空");
+        }
+        String code = StringUtils.defaultIfBlank(response.getCode(), response.getError());
+        if (StringUtils.isBlank(code)) {
+            return;
+        }
+        String message = StringUtils.defaultIfBlank(response.getMessage(), response.getErrorDescription());
+        throw new McException(code, StringUtils.defaultIfBlank(message, "EIAM 调用失败"));
+    }
+
+    private EiamBatchItemResult success(int index, String formInstanceId, String userKey,
+                                        String userId) {
+        return EiamBatchItemResult.builder().index(index).formInstanceId(formInstanceId)
+                .userKey(userKey).userId(userId).success(true).stage(EiamBatchStage.COMPLETED)
+                .code("SUCCESS").message("执行成功").build();
+    }
+
+    private EiamBatchItemResult failure(int index, String formInstanceId, String userKey,
+                                        String userId, EiamBatchStage stage, RuntimeException ex,
+                                        boolean partialUpdate) {
+        String code = ex instanceof McException ? ((McException) ex).getCode() : "EIAM_CALL_ERROR";
+        String message = StringUtils.defaultIfBlank(ex.getMessage(), "EIAM 调用失败");
+        if (partialUpdate) {
+            message = "账户属性可能已更新;" + message;
+        }
+        return EiamBatchItemResult.builder().index(index).formInstanceId(formInstanceId)
+                .userKey(userKey).userId(userId).success(false).stage(stage)
+                .code(code).message(message).build();
+    }
+
+    private void putIfNotBlank(Map<String, Object> target, String key, String value) {
+        if (StringUtils.isNotBlank(value)) {
+            target.put(key, value);
+        }
+    }
+
+    private String userKey(EiamUpdateUserItem item) {
+        return StringUtils.defaultIfBlank(item.getUsername(), item.getUserId());
+    }
+
+    private String deleteUserKey(EiamDeleteUserItem item) {
+        return StringUtils.defaultIfBlank(item.getUsername(), item.getUserId());
+    }
+}

+ 216 - 0
mjava-benteler/src/main/java/com/malk/benteler/service/EiamOrganizationalUnitLocalService.java

@@ -0,0 +1,216 @@
+package com.malk.benteler.service;
+
+import com.malk.benteler.dto.EiamOrganizationalUnitOption;
+import com.malk.server.common.McException;
+import com.malk.server.eiam.EiamConf;
+import com.malk.server.eiam.EiamOrganizationalUnit;
+import com.malk.server.eiam.EiamOrganizationalUnitPage;
+import com.malk.service.eiam.EiamClient_OrganizationalUnit;
+import com.malk.service.eiam.EiamService;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Queue;
+import java.util.Set;
+
+/**
+ * 本特勒 EIAM 组织树查询编排。
+ */
+@Slf4j
+@Service
+public class EiamOrganizationalUnitLocalService {
+
+    private static final String ROOT_ID = "ou_root";
+    private static final int PAGE_SIZE = 100;
+    private static final String CACHE_REFRESH_CRON = "0 0 8-19 * * MON-FRI";
+    private static final String CACHE_REFRESH_ZONE = "Asia/Shanghai";
+
+    private final EiamService eiamService;
+    private final EiamClient_OrganizationalUnit organizationalUnitClient;
+    private final EiamConf eiamConf;
+    private volatile List<EiamOrganizationalUnitOption> organizationalUnitCache =
+            Collections.emptyList();
+    private volatile boolean cacheLoaded;
+
+    public EiamOrganizationalUnitLocalService(EiamService eiamService,
+                                              EiamClient_OrganizationalUnit organizationalUnitClient,
+                                              EiamConf eiamConf) {
+        this.eiamService = eiamService;
+        this.organizationalUnitClient = organizationalUnitClient;
+        this.eiamConf = eiamConf;
+    }
+
+    /**
+     * 按当前部门名称包含匹配缓存中的组织树。
+     *
+     * @param name 部门名称关键字
+     * @return 部门选择项,名称格式为“当前部门 (父部门)”
+     */
+    public List<EiamOrganizationalUnitOption> searchByName(String name) {
+        if (StringUtils.isBlank(name)) {
+            throw new McException("EIAM_ORG_NAME_EMPTY", "部门名称不能为空");
+        }
+        List<EiamOrganizationalUnitOption> organizationUnits = getOrganizationUnits();
+        List<EiamOrganizationalUnitOption> matches = new ArrayList<>();
+        for (EiamOrganizationalUnitOption option : organizationUnits) {
+            if (containsIgnoreCase(currentName(option.getOrganizationalUnitName()), name)) {
+                matches.add(option);
+            }
+        }
+        matches.sort(Comparator.comparing(EiamOrganizationalUnitOption::getOrganizationalUnitName,
+                Comparator.nullsLast(String::compareToIgnoreCase)));
+        return matches;
+    }
+
+    /**
+     * 北京时间工作日 08:00 至 19:00 每小时整点刷新 EIAM 部门缓存。
+     */
+    @Scheduled(cron = CACHE_REFRESH_CRON, zone = CACHE_REFRESH_ZONE)
+    public void refreshOrganizationUnitCache() {
+        try {
+            synchronized (this) {
+                replaceOrganizationUnitCache(loadOrganizationUnits());
+            }
+        } catch (RuntimeException exception) {
+            // fixme 刷新失败时保留上一版缓存,避免连接器查询被短暂的 EIAM 异常影响。
+            log.error("刷新 EIAM 部门缓存失败", exception);
+        }
+    }
+
+    private List<EiamOrganizationalUnitOption> getOrganizationUnits() {
+        if (cacheLoaded) {
+            return organizationalUnitCache;
+        }
+        synchronized (this) {
+            if (!cacheLoaded) {
+                replaceOrganizationUnitCache(loadOrganizationUnits());
+            }
+            return organizationalUnitCache;
+        }
+    }
+
+    private void replaceOrganizationUnitCache(
+            List<EiamOrganizationalUnitOption> organizationUnits) {
+        organizationalUnitCache = Collections.unmodifiableList(
+                new ArrayList<>(organizationUnits));
+        cacheLoaded = true;
+        log.info("EIAM 部门缓存刷新完成,共 {} 个部门", organizationalUnitCache.size());
+    }
+
+    private List<EiamOrganizationalUnitOption> loadOrganizationUnits() {
+        String token = eiamService.getAccessToken();
+        List<EiamOrganizationalUnitOption> organizationUnits = new ArrayList<>();
+        Queue<OrganizationalUnitNode> queue = new ArrayDeque<>();
+        Set<String> visited = new HashSet<>();
+        queue.add(new OrganizationalUnitNode(ROOT_ID, null));
+        while (!queue.isEmpty()) {
+            OrganizationalUnitNode parent = queue.poll();
+            if (!visited.add(parent.getId())) {
+                continue;
+            }
+            List<EiamOrganizationalUnit> children = listAllChildren(token, parent.getId());
+            for (EiamOrganizationalUnit child : children) {
+                if (child == null || StringUtils.isBlank(child.getOrganizationalUnitId())) {
+                    continue;
+                }
+                queue.add(new OrganizationalUnitNode(child.getOrganizationalUnitId(),
+                        child.getOrganizationalUnitName()));
+                organizationUnits.add(EiamOrganizationalUnitOption.builder()
+                        .organizationalUnitId(child.getOrganizationalUnitId())
+                        .organizationalUnitName(displayName(child.getOrganizationalUnitName(),
+                                parent.getName()))
+                        .build());
+            }
+        }
+        return organizationUnits;
+    }
+
+    /**
+     * 按当前部门名称精确解析唯一的 EIAM 组织 ID。
+     *
+     * @param name 当前部门名称
+     * @return EIAM 组织 ID
+     */
+    public String resolveUniqueId(String name) {
+        List<EiamOrganizationalUnitOption> options = searchByName(name);
+        List<EiamOrganizationalUnitOption> exact = new ArrayList<>();
+        for (EiamOrganizationalUnitOption option : options) {
+            String currentName = currentName(option.getOrganizationalUnitName());
+            if (StringUtils.equalsIgnoreCase(StringUtils.trim(currentName), StringUtils.trim(name))) {
+                exact.add(option);
+            }
+        }
+        if (exact.size() != 1) {
+            throw new McException("EIAM_ORG_NOT_UNIQUE",
+                    "无法按名称唯一匹配 EIAM 部门: " + name);
+        }
+        return exact.get(0).getOrganizationalUnitId();
+    }
+
+    private List<EiamOrganizationalUnit> listAllChildren(String token, String parentId) {
+        List<EiamOrganizationalUnit> all = new ArrayList<>();
+        int pageNumber = 1;
+        while (true) {
+            EiamOrganizationalUnitPage page = organizationalUnitClient.listOrganizationalUnits(
+                    token, eiamConf.getInstanceId(), eiamConf.getApplicationId(), parentId,
+                    pageNumber, PAGE_SIZE);
+            if (page == null) {
+                throw new McException("EIAM_ORG_EMPTY_RESPONSE", "EIAM 部门列表响应为空");
+            }
+            List<EiamOrganizationalUnit> data = page.getData();
+            if (data != null) {
+                all.addAll(data);
+            }
+            int totalCount = page.getTotalCount() == null ? all.size() : page.getTotalCount();
+            if (all.size() >= totalCount || data == null || data.isEmpty()) {
+                return all;
+            }
+            pageNumber++;
+        }
+    }
+
+    private boolean containsIgnoreCase(String value, String keyword) {
+        return StringUtils.containsIgnoreCase(StringUtils.defaultString(value),
+                StringUtils.trim(keyword));
+    }
+
+    private String displayName(String currentName, String parentName) {
+        // prd 同名部门显示格式统一为“当前部门名称 (父部门名称)”,ID 保持不变。
+        return StringUtils.isBlank(parentName)
+                ? currentName : currentName + " (" + parentName + ")";
+    }
+
+    private String currentName(String displayName) {
+        String value = StringUtils.defaultString(displayName);
+        int parentStart = StringUtils.lastIndexOf(value, " (");
+        return parentStart >= 0 && StringUtils.endsWith(value, ")")
+                ? StringUtils.substring(value, 0, parentStart) : value;
+    }
+
+    private static class OrganizationalUnitNode {
+
+        private final String id;
+        private final String name;
+
+        private OrganizationalUnitNode(String id, String name) {
+            this.id = id;
+            this.name = name;
+        }
+
+        private String getId() {
+            return id;
+        }
+
+        private String getName() {
+            return name;
+        }
+    }
+}

+ 39 - 0
mjava-benteler/src/main/resources/application-prod.yml.example

@@ -0,0 +1,39 @@
+# 本特勒生产配置模板;.example 不会被 Spring 自动加载。
+# application.yml 已支持以下环境变量;部署时直接注入环境变量即可。
+# 如需独立 prod 文件,可复制为 application-prod.yml(已被 .gitignore 忽略),
+# 并使用 --spring.profiles.active=prod 启动。真实凭据禁止写入本文件。
+eiam:
+  baseUrl: ${EIAM_BASE_URL:https://eiam-developerapi.cn-hangzhou.aliyuncs.com}
+  instanceId: ${EIAM_INSTANCE_ID:idaas_lnmhaoqhanhrfsv7hndhk26sr4}
+  applicationId: ${EIAM_APPLICATION_ID:app_njhhk3fj4gzfc2fmjxwx75ijbe}
+  clientId: ${EIAM_CLIENT_ID:app_njhhk3fj4gzfc2fmjxwx75ijbe}
+  clientSecret: ${EIAM_CLIENT_SECRET}
+
+aliwork:
+  appType: ${ALIWORK_APP_TYPE:APP_MQP6UV1H5S5BDOU68TQD}
+  systemToken: ${ALIWORK_SYSTEM_TOKEN}
+
+dingtalk:
+  appKey: ${DINGTALK_APP_KEY}
+  appSecret: ${DINGTALK_APP_SECRET}
+
+spring:
+  # 本项目不使用数据库。保留 DataSource/JPA 壳配置仅用于满足 mjava 基座初始化,
+  # 连接地址指向不可用端口且禁止启动失败,MySQL 服务无需安装或启动。
+  datasource:
+    hikari:
+      initialization-fail-timeout: -1
+      minimum-idle: 0
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    username: unused
+    password: unused
+    url: jdbc:mysql://127.0.0.1:65535/benteler_no_db?useSSL=false
+  jpa:
+    hibernate:
+      ddl-auto: none
+    show-sql: false
+    database: MYSQL
+    database-platform: org.hibernate.dialect.MySQL8Dialect
+    open-in-view: false
+    properties:
+      hibernate.temp.use_jdbc_metadata_defaults: false

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

@@ -0,0 +1,64 @@
+server:
+  port: 9030
+  servlet:
+    context-path: /api
+
+spel:
+  scheduling: false
+  multiSource: false
+
+spring:
+  jpa:
+    hibernate:
+      ddl-auto: none
+    open-in-view: false
+
+eiam:
+  baseUrl: ${EIAM_BASE_URL:https://eiam-developerapi.cn-hangzhou.aliyuncs.com}
+  instanceId: ${EIAM_INSTANCE_ID:}
+  applicationId: ${EIAM_APPLICATION_ID:}
+  clientId: ${EIAM_CLIENT_ID:}
+  clientSecret: ${EIAM_CLIENT_SECRET:}
+
+aliwork:
+  appType: ${ALIWORK_APP_TYPE:APP_MQP6UV1H5S5BDOU68TQD}
+  systemToken: ${ALIWORK_SYSTEM_TOKEN:}
+
+dingtalk:
+  appKey: ${DINGTALK_APP_KEY:}
+  appSecret: ${DINGTALK_APP_SECRET:}
+
+benteler:
+  yida:
+    onboardingFormUuid: FORM-3794DBD2C83E42C5863853A80A3C0ACB5K6Q
+    onboardingTableFieldId: tableField_mrcw2nqc
+    onboardingOrganizationalUnitIdFieldId: selectField_mrmyvzrt_id
+    onboardingNameFieldId: textField_mrcw2nqz
+    onboardingPhoneFieldId: numberField_mrcw2nqe
+    onboardingEmailFieldId: textField_mrcw2nr0
+    onboardingDescriptionFieldId: textField_mrcw2nr1
+    onboardingEmployeeNumberFieldId: textField_mrcw2nqd
+    onboardingJobTitleFieldId: selectField_mrn4cg3s
+    onboardingManagerFieldId: employeeField_mrcw2nqy
+    onboardingFactoryFieldId: selectField_motg7pxc
+    onboardingCompanyFieldId: selectField_mrcw2nqw
+    onboardingCompanyCodeFieldId: textField_mrn4cg3r
+    onboardingDepartmentFieldId: textField_mrncpnxi
+    updateFormUuid: FORM-37575577023F4ADE90685238D6A86D14908Y
+    updateTableFieldId: tableField_mrcw2nqc
+    updateEmployeeFieldId: employeeField_mrcxpt3g
+    updateDescriptionFieldId: textField_mrcw2nr1
+    updateOrganizationalUnitIdFieldId: selectField_mrmyvzrt_id
+    offboardingFormUuid: FORM-3D8B56B5A9EE45738A03D4DB6C1D22E16Z5T
+    offboardingTableFieldId: tableField_mrcxjcq5
+    offboardingEmployeeFieldId: employeeField_mrcxgkq0
+    offboardingRemarkFieldId: textField_mrcxjcq6
+    detailStatusFieldId: selectField_mrcxjcq8
+    failureMessageFieldId: textareaField_mrn3kh8m
+    totalFieldId: numberField_mrcxjcq9
+    successFieldId: numberField_mrcxjcqe
+    failedFieldId: numberField_mrcxjcqf
+
+mjava:
+  auth:
+    enabled: false

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

@@ -0,0 +1,112 @@
+package com.malk.benteler.service;
+
+import com.malk.benteler.config.BentelerYidaConf;
+import com.malk.benteler.dto.EiamBatchItemResult;
+import com.malk.benteler.dto.EiamBatchStage;
+import com.malk.benteler.dto.EiamCreateUserItem;
+import com.malk.server.eiam.EiamCustomField;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+
+/**
+ * {@link BentelerYidaFormMapper} 字段映射和子表全量重建测试。
+ */
+public class BentelerYidaFormMapperTest {
+
+    private BentelerYidaConf conf;
+    private BentelerYidaFormMapper mapper;
+
+    @Before
+    public void setUp() {
+        conf = TestBentelerYidaConf.create();
+        mapper = new BentelerYidaFormMapper(conf);
+    }
+
+    @Test
+    public void mapCreateItems_readsOrganizationalUnitIdAndPlainPhone() {
+        Map<String, Object> formData = new HashMap<>();
+        formData.put(conf.getOnboardingOrganizationalUnitIdFieldId(), "ou_technical");
+        formData.put(conf.getOnboardingCompanyFieldId(), "测试公司");
+        formData.put(conf.getOnboardingCompanyCodeFieldId(), "COMPANY-01");
+        formData.put(conf.getOnboardingDepartmentFieldId(), "Technical accounts");
+        Map<String, Object> row = new HashMap<>();
+        row.put(conf.getOnboardingNameFieldId(), "测试人员");
+        row.put(conf.getOnboardingPhoneFieldId(), new BigDecimal("13800000001"));
+        row.put(conf.getOnboardingJobTitleFieldId(), "Software Engineer");
+        row.put(conf.getOnboardingManagerFieldId() + "_id",
+                Collections.singletonList("manager_1"));
+
+        EiamCreateUserItem item = mapper.mapCreateItems("form_1", formData,
+                Collections.singletonList(row)).get(0);
+
+        assertEquals("ou_technical", item.getPrimaryOrganizationalUnitId());
+        assertEquals("13800000001", item.getUsername());
+        assertEquals("manager_1", customFieldValue(item, "direct_line_manager"));
+        assertEquals("测试公司", customFieldValue(item, "company_name"));
+        assertEquals("COMPANY-01", customFieldValue(item, "company_code"));
+        assertEquals("Software Engineer", customFieldValue(item, "job_title"));
+        assertEquals("Technical accounts", customFieldValue(item, "department"));
+    }
+
+    @Test
+    public void rebuildRows_writesEmployeeIdsToBaseFieldAndPreservesAllRows() {
+        Map<String, Object> first = new HashMap<>();
+        first.put(conf.getUpdateEmployeeFieldId(), "显示姓名");
+        first.put(conf.getUpdateEmployeeFieldId() + "_id", Arrays.asList("user_1"));
+        first.put(conf.getUpdateDescriptionFieldId(), "说明一");
+        Map<String, Object> second = new HashMap<>();
+        second.put(conf.getUpdateEmployeeFieldId() + "_id", Arrays.asList("user_2"));
+        List<EiamBatchItemResult> results = Arrays.asList(result(true), result(false));
+
+        List<Map<String, Object>> rebuilt = mapper.rebuildRows(conf.getUpdateTableFieldId(),
+                Arrays.asList(first, second), results);
+
+        assertEquals(2, rebuilt.size());
+        assertEquals(Arrays.asList("user_1"), rebuilt.get(0).get(conf.getUpdateEmployeeFieldId()));
+        assertFalse(rebuilt.get(0).containsKey(conf.getUpdateEmployeeFieldId() + "_id"));
+        assertEquals("成功", rebuilt.get(0).get(conf.getDetailStatusFieldId()));
+        assertEquals("失败", rebuilt.get(1).get(conf.getDetailStatusFieldId()));
+    }
+
+    @Test
+    public void buildFailureMessage_usesPersonNameAndClearsWhenAllSuccess() {
+        Map<String, Object> first = new HashMap<>();
+        first.put(conf.getOnboardingNameFieldId(), "成功人员");
+        Map<String, Object> second = new HashMap<>();
+        second.put(conf.getOnboardingNameFieldId(), "失败人员");
+        EiamBatchItemResult failed = EiamBatchItemResult.builder().success(false)
+                .message("部门不存在").build();
+
+        String message = mapper.buildFailureMessage(conf.getOnboardingTableFieldId(),
+                Arrays.asList(first, second), Arrays.asList(result(true), failed));
+        String successMessage = mapper.buildFailureMessage(conf.getOnboardingTableFieldId(),
+                Collections.singletonList(first), Collections.singletonList(result(true)));
+
+        assertEquals("失败人员 - 部门不存在", message);
+        assertEquals("", successMessage);
+    }
+
+    private EiamBatchItemResult result(boolean success) {
+        return EiamBatchItemResult.builder().success(success).stage(EiamBatchStage.COMPLETED)
+                .build();
+    }
+
+    private String customFieldValue(EiamCreateUserItem item, String fieldName) {
+        for (EiamCustomField field : item.getCustomFields()) {
+            if (fieldName.equals(field.getFieldName())) {
+                return field.getFieldValue();
+            }
+        }
+        return null;
+    }
+}

+ 217 - 0
mjava-benteler/src/test/java/com/malk/benteler/service/BentelerYidaSyncServiceTest.java

@@ -0,0 +1,217 @@
+package com.malk.benteler.service;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.benteler.config.BentelerYidaConf;
+import com.malk.benteler.dto.EiamBatchItemResult;
+import com.malk.benteler.dto.EiamBatchOperation;
+import com.malk.benteler.dto.EiamBatchResult;
+import com.malk.benteler.dto.EiamBatchStage;
+import com.malk.benteler.dto.EiamFormSyncResult;
+import com.malk.benteler.dto.EiamUpdateUserItem;
+import com.malk.server.aliwork.YDAuth;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.service.aliwork.YDClient_Form;
+import com.malk.service.aliwork.YDService;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+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.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * {@link BentelerYidaSyncService} 单实例同步闭环测试。
+ */
+public class BentelerYidaSyncServiceTest {
+
+    private YDClient_Form ydClientForm;
+    private YDService ydService;
+    private EiamLocalService eiamLocalService;
+    private BentelerYidaConf conf;
+    private BentelerYidaSyncService service;
+
+    @Before
+    public void setUp() {
+        ydClientForm = mock(YDClient_Form.class);
+        ydService = mock(YDService.class);
+        eiamLocalService = mock(EiamLocalService.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);
+    }
+
+    @Test
+    public void syncCreate_queriesInstanceAndWritesBackFullRowsAndStatistics() {
+        Map<String, Object> formData = new HashMap<>();
+        formData.put(conf.getOnboardingOrganizationalUnitIdFieldId(), "ou_technical");
+        formData.put(conf.getOnboardingTableFieldId(), Arrays.asList(
+                createRow("人员一", "13800000001"),
+                createRow("人员二", "13800000002")));
+        when(ydClientForm.getForm(any(YDAuth.class), anyString(), isNull()))
+                .thenReturn(instance(formData));
+        when(eiamLocalService.batchCreate(any())).thenReturn(batchResult(
+                EiamBatchOperation.CREATE, true, false));
+        when(ydClientForm.updateForm(any(YDAuth.class), anyString(), anyString(), anyMap()))
+                .thenReturn(Collections.emptyMap());
+
+        EiamFormSyncResult result = service.syncCreate("form_test");
+
+        assertTrue(result.isWritebackSuccess());
+        assertEquals(2, result.getTotal());
+        assertEquals(1, result.getSuccessCount());
+        ArgumentCaptor<String> jsonCaptor = ArgumentCaptor.forClass(String.class);
+        verify(ydClientForm).updateForm(any(YDAuth.class), anyString(),
+                jsonCaptor.capture(), anyMap());
+        Map<String, Object> update = JSON.parseObject(jsonCaptor.getValue(), Map.class);
+        List<Map<String, Object>> rows = (List<Map<String, Object>>) update.get(
+                conf.getOnboardingTableFieldId());
+        assertEquals(2, rows.size());
+        assertEquals("成功", rows.get(0).get(conf.getDetailStatusFieldId()));
+        assertEquals("失败", rows.get(1).get(conf.getDetailStatusFieldId()));
+        assertEquals(2, ((Number) update.get(conf.getTotalFieldId())).intValue());
+        assertEquals("人员二 - 执行失败", update.get(conf.getFailureMessageFieldId()));
+    }
+
+    @Test
+    public void syncCreate_writebackFailureReturnsExecutionResultWithoutRetryingEiam() {
+        Map<String, Object> formData = new HashMap<>();
+        formData.put(conf.getOnboardingOrganizationalUnitIdFieldId(), "ou_technical");
+        formData.put(conf.getOnboardingTableFieldId(), Collections.singletonList(
+                createRow("人员一", "13800000001")));
+        when(ydClientForm.getForm(any(YDAuth.class), anyString(), isNull()))
+                .thenReturn(instance(formData));
+        when(eiamLocalService.batchCreate(any())).thenReturn(batchResult(
+                EiamBatchOperation.CREATE, true));
+        when(ydClientForm.updateForm(any(YDAuth.class), anyString(), anyString(), anyMap()))
+                .thenThrow(new IllegalStateException("writeback failed"));
+
+        EiamFormSyncResult result = service.syncCreate("form_test");
+
+        assertFalse(result.isWritebackSuccess());
+        assertEquals(1, result.getSuccessCount());
+        verify(eiamLocalService).batchCreate(any());
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void syncUpdate_readsEiamOrganizationalUnitIdDirectly() {
+        Map<String, Object> row = new HashMap<>();
+        row.put(conf.getUpdateEmployeeFieldId() + "_id",
+                Collections.singletonList("user_1"));
+        Map<String, Object> formData = new HashMap<>();
+        formData.put(conf.getUpdateOrganizationalUnitIdFieldId(), "ou_target");
+        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());
+
+        EiamFormSyncResult result = service.syncUpdate("form_update");
+
+        assertEquals(1, result.getSuccessCount());
+        ArgumentCaptor<List> itemsCaptor = ArgumentCaptor.forClass(List.class);
+        verify(eiamLocalService).batchUpdate(itemsCaptor.capture());
+        List<EiamUpdateUserItem> items = (List<EiamUpdateUserItem>) itemsCaptor.getValue();
+        assertEquals("user_1", items.get(0).getUserId());
+        assertEquals("ou_target", items.get(0).getPrimaryOrganizationalUnitId());
+    }
+
+    @Test
+    public void syncDelete_exactlyFiftyRowsFallsBackWhenDetailQueryThrowsNpe() {
+        List<Map<String, Object>> inlineRows = new ArrayList<>();
+        for (int index = 0; index < 50; index++) {
+            Map<String, Object> row = new HashMap<>();
+            row.put(conf.getOffboardingEmployeeFieldId() + "_id",
+                    Collections.singletonList("user_" + index));
+            inlineRows.add(row);
+        }
+        Map<String, Object> formData = new HashMap<>();
+        formData.put(conf.getOffboardingTableFieldId(), inlineRows);
+        when(ydClientForm.getForm(any(YDAuth.class), anyString(), isNull()))
+                .thenReturn(instance(formData));
+        when(ydService.queryDetails(any(YDParam.class))).thenThrow(new NullPointerException());
+        when(eiamLocalService.batchDelete(any())).thenReturn(batchResult(
+                EiamBatchOperation.DELETE, successFlags(50)));
+        when(ydClientForm.updateForm(any(YDAuth.class), anyString(), anyString(), anyMap()))
+                .thenReturn(Collections.emptyMap());
+
+        EiamFormSyncResult result = service.syncDelete("form_delete");
+
+        assertEquals(50, result.getTotal());
+        verify(ydService).queryDetails(any(YDParam.class));
+    }
+
+    @Test
+    public void syncDelete_lessThanFiftyRowsUsesInlineDetails() {
+        List<Map<String, Object>> inlineRows = new ArrayList<>();
+        for (int index = 0; index < 49; index++) {
+            Map<String, Object> row = new HashMap<>();
+            row.put(conf.getOffboardingEmployeeFieldId() + "_id",
+                    Collections.singletonList("user_" + index));
+            inlineRows.add(row);
+        }
+        Map<String, Object> formData = new HashMap<>();
+        formData.put(conf.getOffboardingTableFieldId(), inlineRows);
+        when(ydClientForm.getForm(any(YDAuth.class), anyString(), isNull()))
+                .thenReturn(instance(formData));
+        when(eiamLocalService.batchDelete(any())).thenReturn(batchResult(
+                EiamBatchOperation.DELETE, successFlags(49)));
+        when(ydClientForm.updateForm(any(YDAuth.class), anyString(), anyString(), anyMap()))
+                .thenReturn(Collections.emptyMap());
+
+        EiamFormSyncResult result = service.syncDelete("form_delete_49");
+
+        assertEquals(49, result.getTotal());
+        verify(ydService, never()).queryDetails(any(YDParam.class));
+    }
+
+    private Map<String, Object> createRow(String name, String phone) {
+        Map<String, Object> row = new HashMap<>();
+        row.put(conf.getOnboardingNameFieldId(), name);
+        row.put(conf.getOnboardingPhoneFieldId(), phone);
+        return row;
+    }
+
+    private Map<String, Object> instance(Map<String, Object> formData) {
+        Map<String, Object> instance = new HashMap<>();
+        instance.put("formData", formData);
+        return instance;
+    }
+
+    private boolean[] successFlags(int size) {
+        boolean[] flags = new boolean[size];
+        Arrays.fill(flags, true);
+        return flags;
+    }
+
+    private EiamBatchResult batchResult(EiamBatchOperation operation, boolean... successFlags) {
+        List<EiamBatchItemResult> items = new ArrayList<>();
+        for (int index = 0; index < successFlags.length; index++) {
+            items.add(EiamBatchItemResult.builder().index(index).success(successFlags[index])
+                    .stage(EiamBatchStage.COMPLETED).build());
+        }
+        return EiamBatchResult.of(operation, items);
+    }
+}

+ 231 - 0
mjava-benteler/src/test/java/com/malk/benteler/service/EiamLocalServiceTest.java

@@ -0,0 +1,231 @@
+package com.malk.benteler.service;
+
+import com.malk.benteler.dto.EiamBatchResult;
+import com.malk.benteler.dto.EiamBatchStage;
+import com.malk.benteler.dto.EiamCreateUserItem;
+import com.malk.benteler.dto.EiamDeleteUserItem;
+import com.malk.benteler.dto.EiamUpdateUserItem;
+import com.malk.server.common.McException;
+import com.malk.server.eiam.EiamApiResponse;
+import com.malk.server.eiam.EiamConf;
+import com.malk.server.eiam.EiamCustomField;
+import com.malk.service.eiam.EiamClient_User;
+import com.malk.service.eiam.EiamService;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * {@link EiamLocalService} 批量编排单元测试。
+ */
+public class EiamLocalServiceTest {
+
+    private EiamService eiamService;
+    private EiamClient_User eiamClientUser;
+    private EiamLocalService localService;
+
+    @Before
+    public void setUp() {
+        eiamService = mock(EiamService.class);
+        eiamClientUser = mock(EiamClient_User.class);
+        EiamConf eiamConf = new EiamConf();
+        eiamConf.setInstanceId("idaas_test");
+        eiamConf.setApplicationId("app_test");
+        localService = new EiamLocalService(eiamService, eiamClientUser, eiamConf);
+        when(eiamService.getAccessToken()).thenReturn("token_test");
+    }
+
+    @Test
+    public void batchCreate_allSuccess_returnsOrderedDetails() {
+        when(eiamClientUser.createUser(anyString(), anyString(), anyString(),
+                anyString(), anyString(), anyMap()))
+                .thenReturn(successResponse("user_1"), successResponse("user_2"));
+
+        EiamBatchResult result = localService.batchCreate(Arrays.asList(
+                createItem("form_1", "13800000001", "张一"),
+                createItem("form_2", "13800000002", "张二")));
+
+        assertEquals(2, result.getTotal());
+        assertEquals(2, result.getSuccessCount());
+        assertEquals(0, result.getFailedCount());
+        assertEquals("form_1", result.getItems().get(0).getFormInstanceId());
+        assertEquals("user_2", result.getItems().get(1).getUserId());
+        verify(eiamService, times(1)).getAccessToken();
+    }
+
+    @Test
+    public void batchCreate_partialFailure_continuesFollowingItems() {
+        when(eiamClientUser.createUser(anyString(), anyString(), anyString(),
+                anyString(), anyString(), anyMap()))
+                .thenReturn(successResponse("user_1"),
+                        errorResponse("ResourceDuplicated.Username", "username already exists"),
+                        successResponse("user_3"));
+
+        EiamBatchResult result = localService.batchCreate(Arrays.asList(
+                createItem("form_1", "13800000001", "张一"),
+                createItem("form_2", "13800000002", "张二"),
+                createItem("form_3", "13800000003", "张三")));
+
+        assertEquals(2, result.getSuccessCount());
+        assertEquals(1, result.getFailedCount());
+        assertFalse(result.getItems().get(1).isSuccess());
+        assertEquals("ResourceDuplicated.Username", result.getItems().get(1).getCode());
+        assertTrue(result.getItems().get(2).isSuccess());
+        verify(eiamClientUser, times(3)).createUser(anyString(), anyString(), anyString(),
+                anyString(), anyString(), anyMap());
+    }
+
+    @Test
+    public void batchCreate_invalidItem_isolatedFromFollowingItems() {
+        EiamCreateUserItem invalid = createItem("form_1", null, "无账户名");
+        when(eiamClientUser.createUser(anyString(), anyString(), anyString(),
+                anyString(), anyString(), anyMap()))
+                .thenReturn(successResponse("user_2"));
+
+        EiamBatchResult result = localService.batchCreate(Arrays.asList(
+                invalid, createItem("form_2", "13800000002", "张二")));
+
+        assertEquals(1, result.getSuccessCount());
+        assertEquals(1, result.getFailedCount());
+        assertEquals(EiamBatchStage.VALIDATE, result.getItems().get(0).getStage());
+        assertTrue(result.getItems().get(1).isSuccess());
+        verify(eiamClientUser, times(1)).createUser(anyString(), anyString(), anyString(),
+                anyString(), anyString(), anyMap());
+    }
+
+    @Test
+    public void batchCreate_missingCompanyCode_returnsValidationFailure() {
+        EiamCreateUserItem item = createItem("form_1", "13800000001", "缺少公司代码");
+        item.setCustomFields(Arrays.asList(customField("department", "Technical accounts"),
+                customField("company_name", "测试公司"),
+                customField("job_title", "Software Engineer")));
+
+        EiamBatchResult result = localService.batchCreate(Collections.singletonList(item));
+
+        assertEquals(0, result.getSuccessCount());
+        assertEquals(1, result.getFailedCount());
+        assertEquals(EiamBatchStage.VALIDATE, result.getItems().get(0).getStage());
+        assertTrue(result.getItems().get(0).getMessage().contains("公司代码"));
+        verify(eiamClientUser, times(0)).createUser(anyString(), anyString(), anyString(),
+                anyString(), anyString(), anyMap());
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void batchCreate_passwordConfigOmitsNotificationChannels() {
+        when(eiamClientUser.createUser(anyString(), anyString(), anyString(),
+                anyString(), anyString(), anyMap()))
+                .thenReturn(successResponse("user_1"));
+
+        localService.batchCreate(Collections.singletonList(
+                createItem("form_1", "13800000001", "测试人员")));
+
+        ArgumentCaptor<Map> bodyCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(eiamClientUser).createUser(anyString(), anyString(), anyString(),
+                anyString(), anyString(), bodyCaptor.capture());
+        Map<String, Object> passwordConfig = (Map<String, Object>) bodyCaptor.getValue()
+                .get("passwordInitializationConfig");
+        assertEquals("custom", passwordConfig.get("passwordInitializationPolicyPriority"));
+        assertEquals("enabled", passwordConfig.get("passwordForcedUpdateStatus"));
+        assertEquals("random", passwordConfig.get("passwordInitializationType"));
+        assertFalse(passwordConfig.containsKey("userNotificationChannels"));
+    }
+
+    @Test
+    public void batchUpdate_primaryOrgFailure_reportsPartialUpdate() {
+        EiamUpdateUserItem item = new EiamUpdateUserItem();
+        item.setFormInstanceId("form_update");
+        item.setUserId("user_1");
+        item.setDisplayName("新姓名");
+        item.setPrimaryOrganizationalUnitId("ou_new");
+        when(eiamClientUser.patchUser(anyString(), anyString(), anyString(),
+                anyString(), anyMap())).thenReturn(new EiamApiResponse());
+        when(eiamClientUser.setUserPrimaryOrganizationalUnit(anyString(), anyString(),
+                anyString(), anyString(), anyString()))
+                .thenReturn(errorResponse("OrganizationUnitIdNotInScopes", "OU not in scope"));
+
+        EiamBatchResult result = localService.batchUpdate(Collections.singletonList(item));
+
+        assertEquals(0, result.getSuccessCount());
+        assertEquals(1, result.getFailedCount());
+        assertEquals(EiamBatchStage.SET_PRIMARY_ORG, result.getItems().get(0).getStage());
+        assertTrue(result.getItems().get(0).getMessage().contains("账户属性可能已更新"));
+    }
+
+    @Test
+    public void batchDelete_partialFailure_continuesFollowingItems() {
+        when(eiamClientUser.deleteUser(anyString(), anyString(), anyString(), anyString()))
+                .thenReturn(errorResponse("ResourceNotFound.User", "user not found"),
+                        new EiamApiResponse());
+
+        EiamBatchResult result = localService.batchDelete(Arrays.asList(
+                deleteItem("form_1", "missing_user"), deleteItem("form_2", "user_2")));
+
+        assertEquals(1, result.getSuccessCount());
+        assertEquals(1, result.getFailedCount());
+        assertEquals(EiamBatchStage.DELETE_USER, result.getItems().get(0).getStage());
+        assertTrue(result.getItems().get(1).isSuccess());
+        verify(eiamClientUser, times(2)).deleteUser(anyString(), anyString(),
+                anyString(), anyString());
+    }
+
+    @Test(expected = McException.class)
+    public void emptyBatch_throwsRequestLevelError() {
+        localService.batchCreate(Collections.emptyList());
+    }
+
+    private EiamCreateUserItem createItem(String formInstanceId, String username,
+                                          String displayName) {
+        EiamCreateUserItem item = new EiamCreateUserItem();
+        item.setFormInstanceId(formInstanceId);
+        item.setUsername(username);
+        item.setDisplayName(displayName);
+        item.setPrimaryOrganizationalUnitId("ou_root");
+        item.setCustomFields(Arrays.asList(customField("department", "Technical accounts"),
+                customField("company_name", "测试公司"),
+                customField("company_code", "COMPANY-01"),
+                customField("job_title", "Software Engineer")));
+        return item;
+    }
+
+    private EiamCustomField customField(String fieldName, String fieldValue) {
+        EiamCustomField field = new EiamCustomField();
+        field.setFieldName(fieldName);
+        field.setFieldValue(fieldValue);
+        return field;
+    }
+
+    private EiamDeleteUserItem deleteItem(String formInstanceId, String userId) {
+        EiamDeleteUserItem item = new EiamDeleteUserItem();
+        item.setFormInstanceId(formInstanceId);
+        item.setUserId(userId);
+        return item;
+    }
+
+    private EiamApiResponse successResponse(String userId) {
+        EiamApiResponse response = new EiamApiResponse();
+        response.setUserId(userId);
+        return response;
+    }
+
+    private EiamApiResponse errorResponse(String code, String message) {
+        EiamApiResponse response = new EiamApiResponse();
+        response.setCode(code);
+        response.setMessage(message);
+        return response;
+    }
+}

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

@@ -0,0 +1,111 @@
+package com.malk.benteler.service;
+
+import com.malk.benteler.dto.EiamOrganizationalUnitOption;
+import com.malk.server.eiam.EiamConf;
+import com.malk.server.eiam.EiamOrganizationalUnit;
+import com.malk.server.eiam.EiamOrganizationalUnitPage;
+import com.malk.service.eiam.EiamClient_OrganizationalUnit;
+import com.malk.service.eiam.EiamService;
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.scheduling.annotation.Scheduled;
+
+import java.lang.reflect.Method;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * {@link EiamOrganizationalUnitLocalService} 组织树查询测试。
+ */
+public class EiamOrganizationalUnitLocalServiceTest {
+
+    private EiamService eiamService;
+    private EiamClient_OrganizationalUnit client;
+    private EiamOrganizationalUnitLocalService service;
+
+    @Before
+    public void setUp() {
+        eiamService = mock(EiamService.class);
+        client = mock(EiamClient_OrganizationalUnit.class);
+        EiamConf conf = new EiamConf();
+        conf.setInstanceId("instance_test");
+        conf.setApplicationId("app_test");
+        service = new EiamOrganizationalUnitLocalService(eiamService, client, conf);
+        when(eiamService.getAccessToken()).thenReturn("token_test");
+    }
+
+    @Test
+    public void searchByName_returnsCurrentAndParentNameWithoutChangingId() {
+        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_parent", "Corporate", "ou_root"));
+            }
+            if ("ou_parent".equals(parentId)) {
+                return page(unit("ou_technical", "Technical accounts", "ou_parent"));
+            }
+            return page();
+        });
+
+        List<EiamOrganizationalUnitOption> result = service.searchByName("Technical");
+
+        assertEquals(1, result.size());
+        assertEquals("ou_technical", result.get(0).getOrganizationalUnitId());
+        assertEquals("Technical accounts (Corporate)",
+                result.get(0).getOrganizationalUnitName());
+        assertEquals("ou_technical", service.resolveUniqueId("Technical accounts"));
+        verify(eiamService, times(1)).getAccessToken();
+        verify(client, times(3)).listOrganizationalUnits(anyString(), anyString(),
+                anyString(), anyString(), anyInt(), anyInt());
+    }
+
+    @Test
+    public void resolveUniqueId_supportsRootLevelDepartment() {
+        when(client.listOrganizationalUnits(anyString(), anyString(), anyString(),
+                anyString(), anyInt(), anyInt())).thenAnswer(invocation -> {
+            String parentId = invocation.getArgument(3);
+            return "ou_root".equals(parentId)
+                    ? page(unit("ou_asia", "Asia", "ou_root")) : page();
+        });
+
+        assertEquals("ou_asia", service.resolveUniqueId("Asia"));
+    }
+
+    @Test
+    public void refreshOrganizationUnitCache_runsHourlyDuringWeekdayBusinessHours()
+            throws NoSuchMethodException {
+        Method method = EiamOrganizationalUnitLocalService.class
+                .getMethod("refreshOrganizationUnitCache");
+        Scheduled scheduled = method.getAnnotation(Scheduled.class);
+
+        assertNotNull(scheduled);
+        assertEquals("0 0 8-19 * * MON-FRI", scheduled.cron());
+        assertEquals("Asia/Shanghai", scheduled.zone());
+    }
+
+    private EiamOrganizationalUnit unit(String id, String name, String parentId) {
+        EiamOrganizationalUnit unit = new EiamOrganizationalUnit();
+        unit.setOrganizationalUnitId(id);
+        unit.setOrganizationalUnitName(name);
+        unit.setParentId(parentId);
+        return unit;
+    }
+
+    private EiamOrganizationalUnitPage page(EiamOrganizationalUnit... units) {
+        EiamOrganizationalUnitPage page = new EiamOrganizationalUnitPage();
+        page.setData(units.length == 0
+                ? Collections.emptyList() : Collections.singletonList(units[0]));
+        page.setTotalCount(units.length);
+        return page;
+    }
+}

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

@@ -0,0 +1,47 @@
+package com.malk.benteler.service;
+
+import com.malk.benteler.config.BentelerYidaConf;
+
+/**
+ * 测试使用的宜搭 fieldId 配置。
+ */
+final class TestBentelerYidaConf {
+
+    private TestBentelerYidaConf() {
+    }
+
+    /**
+     * 创建完整测试配置。
+     *
+     * @return 测试配置
+     */
+    static BentelerYidaConf create() {
+        BentelerYidaConf conf = new BentelerYidaConf();
+        conf.setOnboardingTableFieldId("table_onboarding");
+        conf.setOnboardingOrganizationalUnitIdFieldId("org_id");
+        conf.setOnboardingNameFieldId("name");
+        conf.setOnboardingPhoneFieldId("phone");
+        conf.setOnboardingEmailFieldId("email");
+        conf.setOnboardingDescriptionFieldId("description");
+        conf.setOnboardingEmployeeNumberFieldId("employee_no");
+        conf.setOnboardingJobTitleFieldId("job_title");
+        conf.setOnboardingManagerFieldId("manager");
+        conf.setOnboardingFactoryFieldId("factory");
+        conf.setOnboardingCompanyFieldId("company");
+        conf.setOnboardingCompanyCodeFieldId("company_code");
+        conf.setOnboardingDepartmentFieldId("department");
+        conf.setUpdateTableFieldId("table_update");
+        conf.setUpdateEmployeeFieldId("employee_update");
+        conf.setUpdateDescriptionFieldId("update_description");
+        conf.setUpdateOrganizationalUnitIdFieldId("update_org_id");
+        conf.setOffboardingTableFieldId("table_delete");
+        conf.setOffboardingEmployeeFieldId("employee_delete");
+        conf.setOffboardingRemarkFieldId("remark");
+        conf.setDetailStatusFieldId("status");
+        conf.setFailureMessageFieldId("failure_message");
+        conf.setTotalFieldId("total");
+        conf.setSuccessFieldId("success");
+        conf.setFailedFieldId("failed");
+        return conf;
+    }
+}

+ 1 - 1
mjava/src/main/java/com/malk/server/aliwork/YDConf.java

@@ -35,7 +35,7 @@ public class YDConf {
 
 
     /**
-     * 子表超过50分页查询数量
+     * 子表详情接口的内联上限及分页大小;内联数量等于该值时需分页查询全量。
      */
     public static final Integer PAGE_SIZE_DETAILS = 50;
 

+ 24 - 0
mjava/src/main/java/com/malk/server/eiam/EiamApiResponse.java

@@ -0,0 +1,24 @@
+package com.malk.server.eiam;
+
+import com.alibaba.fastjson.annotation.JSONField;
+import lombok.Data;
+
+/**
+ * EIAM 用户写操作原始响应。
+ *
+ * <p>CreateUser 成功时返回 userId,Patch/Delete/设置主组织成功时返回空对象;
+ * 失败时由 code/error/message/error_description 承载信息。</p>
+ */
+@Data
+public class EiamApiResponse {
+
+    private String userId;
+    private String code;
+    private String error;
+    private String message;
+
+    @JSONField(name = "error_description")
+    private String errorDescription;
+
+    private String requestId;
+}

+ 23 - 0
mjava/src/main/java/com/malk/server/eiam/EiamConf.java

@@ -0,0 +1,23 @@
+package com.malk.server.eiam;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 阿里云 EIAM DeveloperAPI 配置。
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "eiam")
+public class EiamConf {
+
+    public static final String NS = "eiam";
+    public static final int TOKEN_AHEAD_SECONDS = 60;
+
+    private String baseUrl = "https://eiam-developerapi.cn-hangzhou.aliyuncs.com";
+    private String instanceId;
+    private String applicationId;
+    private String clientId;
+    private String clientSecret;
+}

+ 13 - 0
mjava/src/main/java/com/malk/server/eiam/EiamCustomField.java

@@ -0,0 +1,13 @@
+package com.malk.server.eiam;
+
+import lombok.Data;
+
+/**
+ * EIAM 账户扩展字段。
+ */
+@Data
+public class EiamCustomField {
+
+    private String fieldName;
+    private String fieldValue;
+}

+ 14 - 0
mjava/src/main/java/com/malk/server/eiam/EiamCustomFieldUpdate.java

@@ -0,0 +1,14 @@
+package com.malk.server.eiam;
+
+import lombok.Data;
+
+/**
+ * EIAM 账户扩展字段更新操作。
+ */
+@Data
+public class EiamCustomFieldUpdate {
+
+    private String fieldName;
+    private String fieldValue;
+    private String operation;
+}

+ 14 - 0
mjava/src/main/java/com/malk/server/eiam/EiamOrganizationalUnit.java

@@ -0,0 +1,14 @@
+package com.malk.server.eiam;
+
+import lombok.Data;
+
+/**
+ * EIAM 组织信息。
+ */
+@Data
+public class EiamOrganizationalUnit {
+
+    private String organizationalUnitId;
+    private String organizationalUnitName;
+    private String parentId;
+}

+ 15 - 0
mjava/src/main/java/com/malk/server/eiam/EiamOrganizationalUnitPage.java

@@ -0,0 +1,15 @@
+package com.malk.server.eiam;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * EIAM 直属子组织分页响应。
+ */
+@Data
+public class EiamOrganizationalUnitPage {
+
+    private Integer totalCount;
+    private List<EiamOrganizationalUnit> data;
+}

+ 25 - 0
mjava/src/main/java/com/malk/server/eiam/EiamTokenResponse.java

@@ -0,0 +1,25 @@
+package com.malk.server.eiam;
+
+import com.alibaba.fastjson.annotation.JSONField;
+import lombok.Data;
+
+/**
+ * EIAM GenerateToken 原始响应。
+ */
+@Data
+public class EiamTokenResponse {
+
+    @JSONField(name = "token_type")
+    private String tokenType;
+
+    @JSONField(name = "access_token")
+    private String accessToken;
+
+    @JSONField(name = "expires_in")
+    private Long expiresIn;
+
+    private String error;
+
+    @JSONField(name = "error_description")
+    private String errorDescription;
+}

+ 5 - 1
mjava/src/main/java/com/malk/service/aliwork/YDService.java

@@ -24,7 +24,11 @@ public interface YDService {
     Object operateData2(Map data, Map update, YDParam param, YDConf.FORM_OPERATION type);
 
     /**
-     * 查询数据 [子表] todo 参数控制, todo 提取合并到 queryAll, 带出子表全部数据
+     * 分页查询指定表单实例的全部子表明细。
+     * <p>
+     * 宜搭实例详情最多内联返回 50 行子表数据:内联数量小于 50 时可直接使用;
+     * 内联数量等于 50 时无法判断是否被截断,调用方必须使用本方法获取全量。
+     * 本方法的分页对象是子表明细,不得通过主表查询的 currentPage 翻页。
      * <p>
      * .formUuid("FORM-TD966Z81I9ODTCY66GH345S03VW03JJF6EQLL5")
      * .formInstanceId(data.get("formInstanceId").toString())

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

@@ -1,5 +1,7 @@
 package com.malk.service.aliwork.impl;
 
+import com.alibaba.fastjson.JSON;
+import com.malk.server.common.McException;
 import com.malk.server.aliwork.YDAuth;
 import com.malk.server.dingtalk.DDR_New;
 import com.malk.service.aliwork.YDClient_Form;
@@ -72,8 +74,8 @@ public class YDClient_FormImpl implements YDClient_Form {
         body.put("formInstanceId", formInstanceId);
         body.put("updateFormDataJson", updateFormDataJson);
         body = mergeExt(body, body_ext);
-        DDR_New r = assertResult((DDR_New) UtilHttp.doPut(url("/forms/instances"), header(auth), body, DDR_New.class), "updateForm");
-        return UtilRespMapper.asMap(r.getResult());
+        String response = UtilHttp.doPut(url("/forms/instances"), header(auth), null, body);
+        return mapResponse(response, "updateForm");
     }
 
     @Override
@@ -133,8 +135,39 @@ public class YDClient_FormImpl implements YDClient_Form {
     @Override
     public Map<String, Object> getForm(YDAuth auth, String formInstanceId, Map<String, Object> body_ext) {
         Map<String, Object> param = mergeExt(body(auth), body_ext);
-        DDR_New r = assertResult(DDR_New.doGet(url("/forms/instances/" + formInstanceId), header(auth), param), "getForm");
-        return UtilRespMapper.asMap(r.getResult());
+        String response = UtilHttp.doGet(url("/forms/instances/" + formInstanceId),
+                header(auth), param);
+        return mapResponse(response, "getForm");
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> mapResponse(String response, String action) {
+        if (response == null || response.trim().isEmpty()) {
+            throw new McException("YIDA_NULL_RESPONSE", "宜搭接口 [" + action + "] 返回空");
+        }
+        Map<String, Object> payload = JSON.parseObject(response, Map.class);
+        if (payload == null) {
+            throw new McException("YIDA_INVALID_RESPONSE", "宜搭接口 [" + action + "] 响应格式错误");
+        }
+        if (Boolean.FALSE.equals(payload.get("success")) || isErrorPayload(payload)) {
+            String code = String.valueOf(payload.get("code"));
+            String message = String.valueOf(payload.get("message"));
+            throw new McException("null".equals(code) ? "YIDA_ERROR" : code,
+                    "宜搭接口 [" + action + "] 失败: " + message);
+        }
+        Object result = payload.get("result");
+        if (result instanceof Map) {
+            return (Map<String, Object>) result;
+        }
+        if (result instanceof String && String.valueOf(result).trim().startsWith("{")) {
+            return JSON.parseObject(String.valueOf(result), Map.class);
+        }
+        return payload;
+    }
+
+    private boolean isErrorPayload(Map<String, Object> payload) {
+        return payload.containsKey("code") && !payload.containsKey("formData")
+                && !payload.containsKey("formInstId") && !payload.containsKey("result");
     }
 
     @Override

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

@@ -65,7 +65,8 @@ public class YDServiceImpl implements YDService {
 
 
     /**
-     * 子表全部数据获取, 最大分页为50 [可考虑中间表思路]
+     * 按 50 行分页查询子表全部数据。
+     * 调用方仅应在实例详情内联子表恰好为 50 行时调用;小于 50 行直接使用内联数据。
      */
     @Override
     public List<Map> queryDetails(YDParam ydParam) {

+ 4 - 4
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient.java

@@ -38,7 +38,7 @@ public class DDImplClient implements DDClient {
         param.put("appkey", ddConf.getAppKey());
         param.put("appsecret", ddConf.getAppSecret());
         DDR r = (DDR) UtilHttp.doGet("https://oapi.dingtalk.com/gettoken", param, DDR.class);
-        log.info("响应token, {}", r.getAccessToken());
+        log.info("响应token获取成功");
         accessToken = r.getAccessToken();
         // token失效自动重置: DD重新调用会重置过期时间
         UtilToken.put(DDConf.NS, key, accessToken, r.getExpiresIn() * 1000L);
@@ -50,7 +50,7 @@ public class DDImplClient implements DDClient {
     public String getAccessToken(String appKey, String appSecret) {
         Map param = UtilMap.map("appkey, appsecret", appKey, appSecret);
         DDR r = DDR.doGet("https://oapi.dingtalk.com/gettoken", null, param);
-        log.info("响应token, {}", r.getAccessToken());
+        log.info("响应token获取成功");
         return r.getAccessToken();
     }
 
@@ -74,7 +74,7 @@ public class DDImplClient implements DDClient {
             boyd.put("code", code_refreshToken);
         }
         DDR_New r = (DDR_New) UtilHttp.doPost("https://api.dingtalk.com/v1.0/oauth2/userAccessToken", null, boyd, DDR_New.class);
-        log.info("用户token, {}", r.getAccessToken());
+        log.info("用户token获取成功");
         accessToken = r.getAccessToken();
         // token失效自动重置: DD重新调用会重置过期时间
         UtilToken.put(DDConf.NS, key, accessToken, r.getExpiresIn() * 1000L);
@@ -124,4 +124,4 @@ public class DDImplClient implements DDClient {
     public Map getUserInfoByCode(String accessToken, String code) {
         return (Map) DDR.doPost("https://oapi.dingtalk.com/topapi/v2/user/getuserinfo", null, DDConf.initTokenParams(accessToken), UtilMap.map("code", code)).getResult();
     }
-}
+}

+ 26 - 0
mjava/src/main/java/com/malk/service/eiam/EiamClient.java

@@ -0,0 +1,26 @@
+package com.malk.service.eiam;
+
+import com.malk.server.eiam.EiamTokenResponse;
+
+import java.util.Map;
+
+/**
+ * EIAM DeveloperAPI OIDC 原子接口。
+ */
+public interface EiamClient {
+
+    /**
+     * 生成应用 access token。
+     *
+     * @param instanceId IDaaS 实例 ID
+     * @param applicationId 应用 ID
+     * @param grantType OAuth2 授权类型
+     * @param clientId 客户端 ID
+     * @param clientSecret 客户端密钥
+     * @param bodyExt 可选表单参数:scope、exclusive_tag、code、redirect_uri、refresh_token、code_verifier、device_code、username、password
+     * @return EIAM Token 原始响应
+     * @apiNote https://help.aliyun.com/zh/idaas/eiam/developer-reference/api-eiam-developerapi-2022-02-25-generatetoken
+     */
+    EiamTokenResponse generateToken(String instanceId, String applicationId, String grantType,
+                                    String clientId, String clientSecret, Map<String, Object> bodyExt);
+}

+ 25 - 0
mjava/src/main/java/com/malk/service/eiam/EiamClient_OrganizationalUnit.java

@@ -0,0 +1,25 @@
+package com.malk.service.eiam;
+
+import com.malk.server.eiam.EiamOrganizationalUnitPage;
+
+/**
+ * EIAM DeveloperAPI 组织管理原子接口。
+ */
+public interface EiamClient_OrganizationalUnit {
+
+    /**
+     * 分页查询指定父组织的直属子组织。
+     *
+     * @param accessToken Bearer token
+     * @param instanceId IDaaS 实例 ID
+     * @param applicationId 应用 ID
+     * @param parentId 父组织 ID
+     * @param pageNumber 页码,从 1 开始
+     * @param pageSize 每页数量
+     * @return 直属子组织分页响应
+     * @apiNote https://api.aliyun.com/api/Eiam-developerapi/2022-02-25/ListOrganizationalUnits
+     */
+    EiamOrganizationalUnitPage listOrganizationalUnits(String accessToken, String instanceId,
+                                                       String applicationId, String parentId,
+                                                       Integer pageNumber, Integer pageSize);
+}

+ 69 - 0
mjava/src/main/java/com/malk/service/eiam/EiamClient_User.java

@@ -0,0 +1,69 @@
+package com.malk.service.eiam;
+
+import com.malk.server.eiam.EiamApiResponse;
+
+import java.util.Map;
+
+/**
+ * EIAM DeveloperAPI 账户管理原子接口。
+ */
+public interface EiamClient_User {
+
+    /**
+     * 创建 EIAM 账户。
+     *
+     * @param accessToken Bearer token
+     * @param instanceId IDaaS 实例 ID
+     * @param applicationId 应用 ID
+     * @param username 账户名
+     * @param primaryOrganizationalUnitId 主组织 ID
+     * @param bodyExt 可选字段:displayName、password、phoneRegion、phoneNumber、phoneNumberVerified、email、emailVerified、userExternalId、description、passwordInitializationConfig、customFields
+     * @return EIAM 原始响应
+     * @apiNote https://help.aliyun.com/zh/idaas/eiam/developer-reference/api-eiam-developerapi-2022-02-25-createuser
+     */
+    EiamApiResponse createUser(String accessToken, String instanceId, String applicationId,
+                               String username, String primaryOrganizationalUnitId,
+                               Map<String, Object> bodyExt);
+
+    /**
+     * 部分更新 EIAM 账户。
+     *
+     * @param accessToken Bearer token
+     * @param instanceId IDaaS 实例 ID
+     * @param applicationId 应用 ID
+     * @param userId EIAM 账户 ID
+     * @param bodyExt 可选字段:username、displayName、phoneRegion、phoneNumber、phoneNumberVerified、email、emailVerified、customFields
+     * @return EIAM 原始响应
+     * @apiNote https://help.aliyun.com/zh/idaas/eiam/developer-reference/api-eiam-developerapi-2022-02-25-patchuser
+     */
+    EiamApiResponse patchUser(String accessToken, String instanceId, String applicationId,
+                              String userId, Map<String, Object> bodyExt);
+
+    /**
+     * 设置 EIAM 账户主组织。
+     *
+     * @param accessToken Bearer token
+     * @param instanceId IDaaS 实例 ID
+     * @param applicationId 应用 ID
+     * @param userId EIAM 账户 ID
+     * @param organizationalUnitId 新主组织 ID
+     * @return EIAM 原始响应
+     * @apiNote https://help.aliyun.com/zh/idaas/eiam/developer-reference/api-eiam-developerapi-2022-02-25-setuserprimaryorganizationalunit
+     */
+    EiamApiResponse setUserPrimaryOrganizationalUnit(String accessToken, String instanceId,
+                                                     String applicationId, String userId,
+                                                     String organizationalUnitId);
+
+    /**
+     * 删除 EIAM 账户。
+     *
+     * @param accessToken Bearer token
+     * @param instanceId IDaaS 实例 ID
+     * @param applicationId 应用 ID
+     * @param userId EIAM 账户 ID
+     * @return EIAM 原始响应
+     * @apiNote https://help.aliyun.com/zh/idaas/eiam/developer-reference/api-eiam-developerapi-2022-02-25-deleteuser
+     */
+    EiamApiResponse deleteUser(String accessToken, String instanceId, String applicationId,
+                               String userId);
+}

+ 14 - 0
mjava/src/main/java/com/malk/service/eiam/EiamService.java

@@ -0,0 +1,14 @@
+package com.malk.service.eiam;
+
+/**
+ * EIAM 通用横切服务。
+ */
+public interface EiamService {
+
+    /**
+     * 获取并缓存 M2M access token。
+     *
+     * @return Bearer token(不含 Bearer 前缀)
+     */
+    String getAccessToken();
+}

+ 36 - 0
mjava/src/main/java/com/malk/service/eiam/impl/EiamClientImpl.java

@@ -0,0 +1,36 @@
+package com.malk.service.eiam.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.server.eiam.EiamConf;
+import com.malk.server.eiam.EiamTokenResponse;
+import com.malk.service.eiam.EiamClient;
+import com.malk.utils.UtilHttp;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * EIAM DeveloperAPI OIDC 原子接口实现。
+ */
+@Service
+public class EiamClientImpl implements EiamClient {
+
+    @Autowired
+    private EiamConf eiamConf;
+
+    @Override
+    public EiamTokenResponse generateToken(String instanceId, String applicationId, String grantType,
+                                           String clientId, String clientSecret,
+                                           Map<String, Object> bodyExt) {
+        Map<String, Object> form = bodyExt == null
+                ? new HashMap<>() : new HashMap<>(bodyExt);
+        form.put("grant_type", grantType);
+        form.put("client_id", clientId);
+        form.put("client_secret", clientSecret);
+        String url = eiamConf.getBaseUrl() + "/v2/" + instanceId + "/" + applicationId + "/oauth2/token";
+        String response = UtilHttp.doPost(url, null, null, null, form);
+        return JSON.parseObject(response, EiamTokenResponse.class);
+    }
+}

+ 51 - 0
mjava/src/main/java/com/malk/service/eiam/impl/EiamClient_OrganizationalUnitImpl.java

@@ -0,0 +1,51 @@
+package com.malk.service.eiam.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.server.eiam.EiamConf;
+import com.malk.server.eiam.EiamOrganizationalUnitPage;
+import com.malk.service.eiam.EiamClient_OrganizationalUnit;
+import com.malk.utils.UtilHttp;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * EIAM DeveloperAPI 组织管理原子接口实现。
+ */
+@Service
+public class EiamClient_OrganizationalUnitImpl implements EiamClient_OrganizationalUnit {
+
+    private final EiamConf eiamConf;
+
+    public EiamClient_OrganizationalUnitImpl(EiamConf eiamConf) {
+        this.eiamConf = eiamConf;
+    }
+
+    @Override
+    public EiamOrganizationalUnitPage listOrganizationalUnits(String accessToken,
+                                                              String instanceId,
+                                                              String applicationId,
+                                                              String parentId,
+                                                              Integer pageNumber,
+                                                              Integer pageSize) {
+        Map<String, Object> params = new HashMap<>();
+        params.put("parentId", parentId);
+        params.put("pageNumber", pageNumber);
+        params.put("pageSize", pageSize);
+        String response = UtilHttp.doGet(usersBaseUrl(instanceId, applicationId),
+                headers(accessToken), params);
+        return JSON.parseObject(response, EiamOrganizationalUnitPage.class);
+    }
+
+    private String usersBaseUrl(String instanceId, String applicationId) {
+        return eiamConf.getBaseUrl() + "/v2/" + instanceId + "/" + applicationId
+                + "/organizationalUnits";
+    }
+
+    private Map<String, String> headers(String accessToken) {
+        Map<String, String> headers = new HashMap<>();
+        headers.put("Authorization", "Bearer " + accessToken);
+        return headers;
+    }
+}

+ 118 - 0
mjava/src/main/java/com/malk/service/eiam/impl/EiamClient_UserImpl.java

@@ -0,0 +1,118 @@
+package com.malk.service.eiam.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.server.common.McException;
+import com.malk.server.eiam.EiamApiResponse;
+import com.malk.server.eiam.EiamConf;
+import com.malk.service.eiam.EiamClient_User;
+import com.malk.utils.UtilHttp;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * EIAM DeveloperAPI 账户管理原子接口实现。
+ */
+@Service
+public class EiamClient_UserImpl implements EiamClient_User {
+
+    @Autowired
+    private EiamConf eiamConf;
+
+    @Override
+    public EiamApiResponse createUser(String accessToken, String instanceId, String applicationId,
+                                      String username, String primaryOrganizationalUnitId,
+                                      Map<String, Object> bodyExt) {
+        Map<String, Object> body = bodyExt == null
+                ? new HashMap<>() : new HashMap<>(bodyExt);
+        body.put("username", username);
+        body.put("primaryOrganizationalUnitId", primaryOrganizationalUnitId);
+        String response = UtilHttp.doPost(usersUrl(instanceId, applicationId),
+                headers(accessToken), null, body);
+        return parseResponse(response);
+    }
+
+    @Override
+    public EiamApiResponse patchUser(String accessToken, String instanceId, String applicationId,
+                                     String userId, Map<String, Object> bodyExt) {
+        Map<String, Object> body = bodyExt == null
+                ? new HashMap<>() : new HashMap<>(bodyExt);
+        String response = UtilHttp.doPatch(userUrl(instanceId, applicationId, userId),
+                headers(accessToken), null, body);
+        return parseResponse(response);
+    }
+
+    @Override
+    public EiamApiResponse setUserPrimaryOrganizationalUnit(String accessToken, String instanceId,
+                                                            String applicationId, String userId,
+                                                            String organizationalUnitId) {
+        Map<String, Object> body = new HashMap<>();
+        body.put("organizationalUnitId", organizationalUnitId);
+        String url = userUrl(instanceId, applicationId, userId)
+                + "/actions/setUserPrimaryOrganizationalUnit";
+        String response = UtilHttp.doPost(url, headers(accessToken), null, body);
+        return parseActionResponse(response);
+    }
+
+    @Override
+    public EiamApiResponse deleteUser(String accessToken, String instanceId, String applicationId,
+                                      String userId) {
+        String response = UtilHttp.doDelete(userUrl(instanceId, applicationId, userId),
+                headers(accessToken), null, (Map) null);
+        return parseResponse(response);
+    }
+
+    /**
+     * 将 EIAM 写接口响应转换为统一对象,屏蔽第三方非 JSON 响应导致的解析器异常。
+     *
+     * @param response EIAM 原始响应
+     * @return EIAM 响应对象
+     */
+    static EiamApiResponse parseResponse(String response) {
+        if (response == null || response.trim().isEmpty()) {
+            throw new McException("EIAM_EMPTY_RESPONSE", "EIAM 响应为空");
+        }
+        try {
+            EiamApiResponse payload = JSON.parseObject(response, EiamApiResponse.class);
+            if (payload == null) {
+                throw new McException("EIAM_INVALID_RESPONSE", "EIAM 响应格式错误");
+            }
+            return payload;
+        } catch (McException ex) {
+            throw ex;
+        } catch (RuntimeException ex) {
+            // fixme 第三方偶发返回纯文本时,不暴露原始响应,也不让 Fastjson 语法异常泄漏到业务层。
+            throw new McException("EIAM_INVALID_RESPONSE", "EIAM 响应不是有效 JSON,请按账户名核对实际落库状态");
+        }
+    }
+
+    /**
+     * 解析无响应体的 EIAM 动作接口;HTTP 层未抛异常即视为成功。
+     *
+     * @param response EIAM 原始响应
+     * @return EIAM 响应对象
+     */
+    static EiamApiResponse parseActionResponse(String response) {
+        if (response == null || response.trim().isEmpty()) {
+            return new EiamApiResponse();
+        }
+        return parseResponse(response);
+    }
+
+    private Map<String, String> headers(String accessToken) {
+        Map<String, String> headers = new HashMap<>();
+        headers.put("Authorization", "Bearer " + accessToken);
+        headers.put("Content-Type", "application/json");
+        return headers;
+    }
+
+    private String usersUrl(String instanceId, String applicationId) {
+        return eiamConf.getBaseUrl() + "/v2/" + instanceId + "/" + applicationId + "/users";
+    }
+
+    private String userUrl(String instanceId, String applicationId, String userId) {
+        return usersUrl(instanceId, applicationId) + "/" + userId;
+    }
+}

+ 55 - 0
mjava/src/main/java/com/malk/service/eiam/impl/EiamServiceImpl.java

@@ -0,0 +1,55 @@
+package com.malk.service.eiam.impl;
+
+import com.malk.server.common.McException;
+import com.malk.server.eiam.EiamConf;
+import com.malk.server.eiam.EiamTokenResponse;
+import com.malk.service.eiam.EiamClient;
+import com.malk.service.eiam.EiamService;
+import com.malk.utils.UtilToken;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+/**
+ * EIAM Token 缓存服务。
+ */
+@Service
+public class EiamServiceImpl implements EiamService {
+
+    @Autowired
+    private EiamConf eiamConf;
+
+    @Autowired
+    private EiamClient eiamClient;
+
+    @Override
+    public String getAccessToken() {
+        validateConfig();
+        String key = eiamConf.getInstanceId() + ":" + eiamConf.getApplicationId();
+        String cached = UtilToken.get(EiamConf.NS, key);
+        if (StringUtils.isNotBlank(cached)) {
+            return cached;
+        }
+        EiamTokenResponse response = eiamClient.generateToken(
+                eiamConf.getInstanceId(), eiamConf.getApplicationId(), "client_credentials",
+                eiamConf.getClientId(), eiamConf.getClientSecret(), null);
+        if (response == null || StringUtils.isBlank(response.getAccessToken())) {
+            String message = response == null ? "EIAM Token 响应为空"
+                    : StringUtils.defaultIfBlank(response.getErrorDescription(), response.getError());
+            throw new McException("EIAM_TOKEN_ERROR", message);
+        }
+        if (response.getExpiresIn() == null || response.getExpiresIn() <= 0) {
+            throw new McException("EIAM_TOKEN_ERROR", "EIAM Token 响应缺少有效 expires_in");
+        }
+        long ttlSeconds = Math.max(1L, response.getExpiresIn() - EiamConf.TOKEN_AHEAD_SECONDS);
+        UtilToken.put(EiamConf.NS, key, response.getAccessToken(), ttlSeconds * 1000L);
+        return response.getAccessToken();
+    }
+
+    private void validateConfig() {
+        if (StringUtils.isAnyBlank(eiamConf.getBaseUrl(), eiamConf.getInstanceId(),
+                eiamConf.getApplicationId(), eiamConf.getClientId(), eiamConf.getClientSecret())) {
+            throw new McException("EIAM_CONFIG_ERROR", "EIAM 配置不完整");
+        }
+    }
+}

+ 50 - 0
mjava/src/test/java/com/malk/service/eiam/impl/EiamClient_UserImplTest.java

@@ -0,0 +1,50 @@
+package com.malk.service.eiam.impl;
+
+import com.malk.server.common.McException;
+import com.malk.server.eiam.EiamApiResponse;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.fail;
+
+/**
+ * EIAM 用户写接口响应解析测试。
+ */
+public class EiamClient_UserImplTest {
+
+    @Test
+    public void parseResponse_validJson_returnsPayload() {
+        EiamApiResponse response = EiamClient_UserImpl.parseResponse(
+                "{\"userId\":\"user_test\"}");
+
+        assertEquals("user_test", response.getUserId());
+    }
+
+    @Test
+    public void parseResponse_plainText_throwsBusinessException() {
+        try {
+            EiamClient_UserImpl.parseResponse("error");
+            fail("应拒绝非 JSON 响应");
+        } catch (McException ex) {
+            assertEquals("EIAM_INVALID_RESPONSE", ex.getCode());
+        }
+    }
+
+    @Test
+    public void parseResponse_blank_throwsEmptyResponse() {
+        try {
+            EiamClient_UserImpl.parseResponse(" ");
+            fail("应拒绝空响应");
+        } catch (McException ex) {
+            assertEquals("EIAM_EMPTY_RESPONSE", ex.getCode());
+        }
+    }
+
+    @Test
+    public void parseActionResponse_blank_returnsSuccessPayload() {
+        EiamApiResponse response = EiamClient_UserImpl.parseActionResponse(" ");
+
+        assertNull(response.getCode());
+    }
+}

+ 2 - 1
pom.xml

@@ -13,6 +13,7 @@
         <module>mjava-mcli</module>
         <module>mjava-pro</module>
         <module>mjava-com</module>
+        <module>mjava-benteler</module>
     </modules>
     <packaging>pom</packaging>
 
@@ -443,4 +444,4 @@
             </plugin>
         </plugins>
     </build>
-</project>
+</project>