ソースを参照

feat(personnel-sync): 钉钉→宜搭人员档案单向同步

- mjava 扩展 DDClient_Contacts: listDepartmentUserDetail/_all/getAllUserDetails (topapi/v2/user/list + 自动分页 + userid 去重)
- 新增 PersonnelSyncConf/Service/Controller, 支持 full / dry-run / probe / probe-single / probe-stats / probe-diff
- 字段同步: userid/job_number/dept_id_list[升序首个]/active→状态/hired_date; 属性按 extattr 或部门白名单兜底
- update 显式 ignoreEmpty=false + useLatestVersion=true (否则 DateField 被过滤+记录版本冲突)
- dev 指向测试表 FORM-5D6C2D1BE7A0430AA8D2E604BB97A9B2BN64 验证, prod 保留生产 formUuid 待 fieldStatus/extAttrKey 补齐后切换
- 定时任务暂不加, 后续依据业务补

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
malk 2 週間 前
コミット
75bf88694f

+ 2 - 0
mjava-akdsbeisen/src/main/java/com/malk/BeisenApplication.java

@@ -20,10 +20,12 @@ import org.springframework.scheduling.annotation.EnableScheduling;
     "com.malk.service.aliwork",
     "com.malk.service.dingtalk",
     "com.malk.service.workhours",
+    "com.malk.service.personnel",
     "com.malk.server.beisen",
     "com.malk.server.aliwork",
     "com.malk.server.dingtalk",
     "com.malk.server.workhours",
+    "com.malk.server.personnel",
     "com.malk.server.common",
     "com.malk.controller",
     "com.malk.delegate",

+ 133 - 0
mjava-akdsbeisen/src/main/java/com/malk/controller/PersonnelSyncController.java

@@ -0,0 +1,133 @@
+package com.malk.controller;
+
+import com.malk.service.personnel.PersonnelSyncService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 钉钉 -> 宜搭 人员档案同步接口
+ */
+@Slf4j
+@RestController
+@RequestMapping("/personnel-sync")
+public class PersonnelSyncController {
+
+    @Autowired
+    private PersonnelSyncService personnelSyncService;
+
+    /**
+     * 全量同步 POST /personnel-sync/full
+     */
+    @PostMapping("/full")
+    public Map<String, Object> full() {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            Map<String, Object> stats = personnelSyncService.fullSync();
+            result.put("success", true);
+            result.put("stats", stats);
+        } catch (Exception e) {
+            log.error("[PersonnelSync] 全量同步失败", e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 干跑 GET /personnel-sync/dry-run
+     */
+    @GetMapping("/dry-run")
+    public Map<String, Object> dryRun() {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            result.put("success", true);
+            result.put("stats", personnelSyncService.dryRun());
+        } catch (Exception e) {
+            log.error("[PersonnelSync] dry-run 失败", e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 单人 probe GET /personnel-sync/probe-single?userid=xxx
+     * 验证 topapi/v2/user/get 单人接口是否返回 extattr
+     */
+    @GetMapping("/probe-single")
+    public Map<String, Object> probeSingle(@RequestParam String userid) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            result.put("success", true);
+            result.put("data", personnelSyncService.probeSingleUser(userid));
+        } catch (Exception e) {
+            log.error("[PersonnelSync] probe-single 失败", e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * Diff 调试 GET /personnel-sync/probe-diff?userid=xxx
+     */
+    @GetMapping("/probe-diff")
+    public Map<String, Object> probeDiff(@RequestParam String userid) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            com.malk.service.personnel.impl.PersonnelSyncServiceImpl impl =
+                    (com.malk.service.personnel.impl.PersonnelSyncServiceImpl) personnelSyncService;
+            result.put("success", true);
+            result.put("data", impl.probeDiff(userid));
+        } catch (Exception e) {
+            log.error("[PersonnelSync] probe-diff 失败", e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 全量统计 GET /personnel-sync/probe-stats
+     * 统计钉钉侧数据完整度 (active/dept/extattr/job_number 分布)
+     */
+    @GetMapping("/probe-stats")
+    public Map<String, Object> probeStats() {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            result.put("success", true);
+            result.put("data", personnelSyncService.probeStats());
+        } catch (Exception e) {
+            log.error("[PersonnelSync] probe-stats 失败", e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * Phase 0 探针 GET /personnel-sync/probe?sample=3
+     * 拉取钉钉全量员工并返回抽样 方便人工确认 extattr 真实结构
+     */
+    @GetMapping("/probe")
+    public Map<String, Object> probe(@RequestParam(defaultValue = "3") int sample) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            result.put("success", true);
+            result.put("data", personnelSyncService.probeDingtalkUsers(sample));
+        } catch (Exception e) {
+            log.error("[PersonnelSync] probe 失败", e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+}

+ 43 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/personnel/PersonnelSyncConf.java

@@ -0,0 +1,43 @@
+package com.malk.server.personnel;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "personnel-sync")
+public class PersonnelSyncConf {
+
+    private String yidaAppType;
+    private String yidaSystemToken;
+
+    // 验证阶段指向测试表 formUuid 上线切换为生产 formUuid
+    private String formUuidPersonnel;
+
+    // 字段 ID 集合 (测试版/生产版按环境切换)
+    private String fieldEmployee;
+    private String fieldUserType;
+    private String fieldJobNumber;
+    private String fieldDepartment;
+    private String fieldStatus;
+    private String fieldHiredDate;
+
+    // extattr 属性判定: 钉钉 extattr 中用于标记内部/外部的 key
+    private String extAttrKeyUserType;
+    private String extAttrValueInternal = "内部";
+    private String extAttrValueExternal = "外部";
+
+    // 属性判定兜底: 当 extattr 读不到时 按部门 ID 白/黑名单判定
+    // externalDeptIds 里的部门视为"外部" 其它视为"内部"; 均为空则不写属性字段
+    private java.util.List<Long> externalDeptIds = new java.util.ArrayList<>();
+    private boolean fallbackInternalByDefault = false;
+
+    // 状态字段取值
+    private String statusValueActive = "在职";
+    private String statusValueInactive = "离职";
+
+    // 并发与重试
+    private int concurrency = 10;
+    private int maxRetry = 2;
+}

+ 41 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/personnel/PersonnelSyncService.java

@@ -0,0 +1,41 @@
+package com.malk.service.personnel;
+
+import java.util.List;
+import java.util.Map;
+
+public interface PersonnelSyncService {
+
+    /**
+     * 全量同步: 钉钉 -> 宜搭人员档案
+     *
+     * @return 统计 {fetched, created, updated, markedInactive, skipped, failed, durationMs}
+     */
+    Map<String, Object> fullSync();
+
+    /**
+     * 干跑: 只计算 diff 不写入
+     *
+     * @return {fetched, actions:{create,update,markOff,skip}, durationMs}
+     */
+    Map<String, Object> dryRun();
+
+    /**
+     * Phase 0 探针: 返回钉钉全量员工抽样 [total, deptCount, durationMs, sample]
+     */
+    Map<String, Object> probeDingtalkUsers(int sampleSize);
+
+    /**
+     * 直接返回钉钉全量员工 (debug 用)
+     */
+    List<Map> fetchAllDingUsers();
+
+    /**
+     * 单人详情 probe: 用 topapi/v2/user/get 看 extattr 是否返回
+     */
+    Map probeSingleUser(String userid);
+
+    /**
+     * 统计钉钉侧数据完整度 (active/dept_id_list/extattr 分布)
+     */
+    Map<String, Object> probeStats();
+}

+ 553 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/personnel/impl/PersonnelSyncServiceImpl.java

@@ -0,0 +1,553 @@
+package com.malk.service.personnel.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.personnel.PersonnelSyncConf;
+import com.malk.service.aliwork.YDClient;
+import com.malk.service.dingtalk.DDClient;
+import com.malk.service.dingtalk.DDClient_Contacts;
+import com.malk.service.personnel.PersonnelSyncService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+@Slf4j
+@Service
+public class PersonnelSyncServiceImpl implements PersonnelSyncService {
+
+    @Autowired
+    private YDClient ydClient;
+
+    @Autowired
+    private DDClient ddClient;
+
+    @Autowired
+    private DDClient_Contacts ddClient_contacts;
+
+    @Autowired
+    private PersonnelSyncConf conf;
+
+    private static final String ACTION_CREATE = "CREATE";
+    private static final String ACTION_UPDATE = "UPDATE";
+    private static final String ACTION_MARK_OFF = "MARK_OFF";
+
+    @Override
+    public Map<String, Object> fullSync() {
+        long start = System.currentTimeMillis();
+        log.info("[PersonnelSync] 全量同步开始");
+
+        List<Map> dingUsers = fetchAllDingUsers();
+        Map<String, Map> dingUserMap = indexByUserid(dingUsers);
+        log.info("[PersonnelSync] 钉钉全量拉取 {} 人 (去重后)", dingUserMap.size());
+
+        Map<String, YidaRecord> yidaMap = fetchAllYidaPersonnel();
+        log.info("[PersonnelSync] 宜搭人员档案 {} 条", yidaMap.size());
+
+        List<Action> actions = diff(dingUserMap, yidaMap);
+        Map<String, Long> actionStats = countActions(actions);
+        log.info("[PersonnelSync] diff 完成: create={}, update={}, markOff={}, skip={}",
+                actionStats.getOrDefault(ACTION_CREATE, 0L),
+                actionStats.getOrDefault(ACTION_UPDATE, 0L),
+                actionStats.getOrDefault(ACTION_MARK_OFF, 0L),
+                (long) dingUserMap.size() + yidaMap.size() - actions.size());
+
+        WriteStats writeStats = concurrentWrite(actions);
+
+        long cost = System.currentTimeMillis() - start;
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("fetched", dingUserMap.size());
+        result.put("yidaExisting", yidaMap.size());
+        result.put("created", writeStats.created.get());
+        result.put("updated", writeStats.updated.get());
+        result.put("markedInactive", writeStats.markedInactive.get());
+        result.put("failed", writeStats.failed.get());
+        result.put("durationMs", cost);
+        log.info("[PersonnelSync] 全量同步完成 {}", result);
+        return result;
+    }
+
+    @Override
+    public Map<String, Object> dryRun() {
+        long start = System.currentTimeMillis();
+        List<Map> dingUsers = fetchAllDingUsers();
+        Map<String, Map> dingUserMap = indexByUserid(dingUsers);
+        Map<String, YidaRecord> yidaMap = fetchAllYidaPersonnel();
+        List<Action> actions = diff(dingUserMap, yidaMap);
+
+        Map<String, Long> stats = countActions(actions);
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("fetched", dingUserMap.size());
+        result.put("yidaExisting", yidaMap.size());
+        Map<String, Object> actionCounts = new LinkedHashMap<>();
+        actionCounts.put("create", stats.getOrDefault(ACTION_CREATE, 0L));
+        actionCounts.put("update", stats.getOrDefault(ACTION_UPDATE, 0L));
+        actionCounts.put("markOff", stats.getOrDefault(ACTION_MARK_OFF, 0L));
+        result.put("actions", actionCounts);
+        result.put("durationMs", System.currentTimeMillis() - start);
+        // 抽样 10 条展示预期动作
+        List<Map<String, Object>> sample = new ArrayList<>();
+        for (int i = 0; i < Math.min(10, actions.size()); i++) {
+            Action a = actions.get(i);
+            Map<String, Object> s = new LinkedHashMap<>();
+            s.put("action", a.type);
+            s.put("userid", a.userid);
+            s.put("formData", a.formData);
+            sample.add(s);
+        }
+        result.put("sample", sample);
+        return result;
+    }
+
+    @Override
+    public Map<String, Object> probeDingtalkUsers(int sampleSize) {
+        long start = System.currentTimeMillis();
+        String token = ddClient.getAccessToken();
+        List<Long> deptIds = ddClient_contacts.getDepartmentId_all(token, true);
+        List<Map> users = ddClient_contacts.getAllUserDetails(token, true);
+        Map<String, Map> byUserid = indexByUserid(users);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("total", byUserid.size());
+        result.put("deptCount", deptIds.size());
+        result.put("durationMs", System.currentTimeMillis() - start);
+
+        List<Map> sample = new ArrayList<>();
+        int n = Math.min(sampleSize <= 0 ? 3 : sampleSize, users.size());
+        for (int i = 0; i < n; i++) sample.add(users.get(i));
+        result.put("sample", sample);
+        return result;
+    }
+
+    @Override
+    public List<Map> fetchAllDingUsers() {
+        String token = ddClient.getAccessToken();
+        return ddClient_contacts.getAllUserDetails(token, true);
+    }
+
+    @Override
+    public Map probeSingleUser(String userid) {
+        String token = ddClient.getAccessToken();
+        return ddClient_contacts.getUserInfoById(token, userid);
+    }
+
+    @SuppressWarnings("unchecked")
+    public Map<String, Object> probeDiff(String userid) {
+        Map<String, Object> r = new LinkedHashMap<>();
+        // 钉钉
+        String token = ddClient.getAccessToken();
+        Map ding = ddClient_contacts.getUserInfoById(token, userid);
+        r.put("dingUser", ding);
+        // 宜搭
+        DDR_New result = ydClient.queryData(YDParam.builder()
+                .appType(conf.getYidaAppType())
+                .systemToken(conf.getYidaSystemToken())
+                .formUuid(conf.getFormUuidPersonnel())
+                .searchFieldJson("{\"" + conf.getFieldEmployee() + "\":\"" + userid + "\"}")
+                .build(), YDConf.FORM_QUERY.retrieve_search_form);
+        List<Map> list = (List<Map>) result.getData();
+        Map<String, Object> yidaFormData = (list != null && !list.isEmpty()) ? (Map<String, Object>) list.get(0).get("formData") : null;
+        r.put("yidaFormData", yidaFormData);
+        // 新 formData
+        Map<String, Object> newData = toYidaFormData(userid, ding, isActive(ding) ? ACTION_UPDATE : ACTION_MARK_OFF);
+        r.put("newFormData", newData);
+        // 字段对比
+        Map<String, Object> diff = new LinkedHashMap<>();
+        if (yidaFormData != null) {
+            for (Map.Entry<String, Object> e : newData.entrySet()) {
+                String f = e.getKey();
+                Object nv = e.getValue();
+                Object ov = yidaFormData.get(f);
+                Object ovId = yidaFormData.get(f + "_id");
+                Map<String, Object> d = new LinkedHashMap<>();
+                d.put("new", nv);
+                d.put("newClass", nv == null ? null : nv.getClass().getSimpleName());
+                d.put("old", ov);
+                d.put("oldClass", ov == null ? null : ov.getClass().getSimpleName());
+                d.put("oldId", ovId);
+                d.put("oldIdClass", ovId == null ? null : ovId.getClass().getSimpleName());
+                diff.put(f, d);
+            }
+        }
+        r.put("diff", diff);
+        return r;
+    }
+
+    @Override
+    public Map<String, Object> probeStats() {
+        long start = System.currentTimeMillis();
+        List<Map> users = fetchAllDingUsers();
+        int total = users.size();
+        int active = 0, inactive = 0, emptyDept = 0, hasExtattr = 0, emptyJobNumber = 0;
+        for (Map u : users) {
+            if (isActive(u)) active++; else inactive++;
+            Object dept = u.get("dept_id_list");
+            if (!(dept instanceof List) || ((List) dept).isEmpty()) emptyDept++;
+            Object ext = u.get("extattr");
+            if (ext instanceof Map && !((Map) ext).isEmpty()) hasExtattr++;
+            Object job = u.get("job_number");
+            if (job == null || String.valueOf(job).isEmpty()) emptyJobNumber++;
+        }
+        Map<String, Object> res = new LinkedHashMap<>();
+        res.put("total", total);
+        res.put("active", active);
+        res.put("inactive", inactive);
+        res.put("emptyDeptIdList", emptyDept);
+        res.put("hasExtattr", hasExtattr);
+        res.put("emptyJobNumber", emptyJobNumber);
+        res.put("durationMs", System.currentTimeMillis() - start);
+        return res;
+    }
+
+    // ==================== 内部: 数据抓取 ====================
+
+    @SuppressWarnings("unchecked")
+    private Map<String, YidaRecord> fetchAllYidaPersonnel() {
+        Map<String, YidaRecord> yidaMap = new LinkedHashMap<>();
+        int currentPage = 1;
+        long totalCount;
+        do {
+            DDR_New result = ydClient.queryData(YDParam.builder()
+                    .appType(conf.getYidaAppType())
+                    .systemToken(conf.getYidaSystemToken())
+                    .formUuid(conf.getFormUuidPersonnel())
+                    .currentPage(currentPage)
+                    .pageSize(YDConf.PAGE_SIZE_LIMIT)
+                    .build(), YDConf.FORM_QUERY.retrieve_search_form);
+            totalCount = result.getTotalCount();
+            List<Map> dataList = (List<Map>) result.getData();
+            if (dataList == null || dataList.isEmpty()) break;
+
+            for (Map item : dataList) {
+                Map<String, Object> formData = (Map<String, Object>) item.get("formData");
+                if (formData == null) continue;
+                String userid = extractEmployeeId(formData, conf.getFieldEmployee());
+                if (userid == null || userid.isEmpty()) continue;
+                YidaRecord rec = new YidaRecord();
+                rec.instanceId = String.valueOf(item.get("formInstanceId"));
+                rec.formData = formData;
+                yidaMap.put(userid, rec);
+            }
+            currentPage++;
+        } while ((long) (currentPage - 1) * YDConf.PAGE_SIZE_LIMIT < totalCount);
+        return yidaMap;
+    }
+
+    // ==================== 内部: 差异计算 ====================
+
+    private List<Action> diff(Map<String, Map> dingUserMap, Map<String, YidaRecord> yidaMap) {
+        List<Action> actions = new ArrayList<>();
+
+        for (Map.Entry<String, Map> e : dingUserMap.entrySet()) {
+            String userid = e.getKey();
+            Map ding = e.getValue();
+            YidaRecord yida = yidaMap.get(userid);
+            boolean active = isActive(ding);
+
+            if (yida == null) {
+                Map<String, Object> formData = toYidaFormData(userid, ding, active ? ACTION_CREATE : ACTION_MARK_OFF);
+                actions.add(new Action(ACTION_CREATE, userid, null, formData));
+            } else {
+                String actionType = active ? ACTION_UPDATE : ACTION_MARK_OFF;
+                Map<String, Object> formData = toYidaFormData(userid, ding, actionType);
+                if (isSameAsYida(formData, yida.formData)) {
+                    // 幂等跳过 (UPDATE/MARK_OFF 都适用)
+                    continue;
+                }
+                actions.add(new Action(actionType, userid, yida.instanceId, formData));
+            }
+        }
+
+        // 钉钉已无, 宜搭还有 -> 标记离职
+        for (Map.Entry<String, YidaRecord> e : yidaMap.entrySet()) {
+            if (dingUserMap.containsKey(e.getKey())) continue;
+            YidaRecord yida = e.getValue();
+            // 已是离职状态则跳过
+            Object currentStatus = yida.formData.get(conf.getFieldStatus());
+            if (conf.getStatusValueInactive().equals(String.valueOf(currentStatus))) continue;
+            Map<String, Object> formData = new LinkedHashMap<>();
+            formData.put(conf.getFieldStatus(), conf.getStatusValueInactive());
+            actions.add(new Action(ACTION_MARK_OFF, e.getKey(), yida.instanceId, formData));
+        }
+        return actions;
+    }
+
+    // ==================== 内部: 字段映射 ====================
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> toYidaFormData(String userid, Map ding, String action) {
+        Map<String, Object> formData = new LinkedHashMap<>();
+
+        // 人员 (唯一键, 永远写入)
+        formData.put(conf.getFieldEmployee(), Collections.singletonList(userid));
+
+        // 在职状态
+        String statusValue = ACTION_MARK_OFF.equals(action) ? conf.getStatusValueInactive() : conf.getStatusValueActive();
+        formData.put(conf.getFieldStatus(), statusValue);
+
+        // 离职软标记只更新状态字段, 保留其他原值
+        if (ACTION_MARK_OFF.equals(action)) return formData;
+
+        // 员工编号 <- job_number
+        Object jobNumber = ding.get("job_number");
+        if (jobNumber != null && !String.valueOf(jobNumber).isEmpty()) {
+            formData.put(conf.getFieldJobNumber(), String.valueOf(jobNumber));
+        }
+
+        // 员工部门 <- dept_id_list 稳定排序后取首个 (钉钉返回顺序不固定, 避免 diff 抖动)
+        Object deptObj = ding.get("dept_id_list");
+        if (deptObj instanceof List && !((List) deptObj).isEmpty()) {
+            List<Long> sorted = new ArrayList<>();
+            for (Object d : (List) deptObj) {
+                if (d != null) sorted.add(((Number) d).longValue());
+            }
+            Collections.sort(sorted);
+            if (!sorted.isEmpty()) {
+                formData.put(conf.getFieldDepartment(), Collections.singletonList(String.valueOf(sorted.get(0))));
+            }
+        }
+
+        // 入职时间 <- hired_date (毫秒时间戳, 需要钉钉花名册权限才返回)
+        if (conf.getFieldHiredDate() != null && !conf.getFieldHiredDate().isEmpty()) {
+            Object hired = ding.get("hired_date");
+            if (hired instanceof Number) {
+                formData.put(conf.getFieldHiredDate(), ((Number) hired).longValue());
+            }
+        }
+
+        // 人员属性 <- extattr.<key> 或 部门白名单兜底
+        String userType = resolveUserType(ding);
+        if (userType != null) {
+            formData.put(conf.getFieldUserType(), userType);
+        }
+
+        return formData;
+    }
+
+    @SuppressWarnings("unchecked")
+    private String resolveUserType(Map ding) {
+        // 1. 优先读 extattr
+        String key = conf.getExtAttrKeyUserType();
+        if (key != null && !key.isEmpty()) {
+            Object extattrObj = ding.get("extattr");
+            if (extattrObj instanceof Map) {
+                Map extattr = (Map) extattrObj;
+                Object attr = extattr.get(key);
+                String raw = null;
+                if (attr instanceof Map) {
+                    Map am = (Map) attr;
+                    Object value = am.get("value");
+                    if (value == null) value = am.get("text");
+                    if (value != null) raw = String.valueOf(value);
+                } else if (attr != null) {
+                    raw = String.valueOf(attr);
+                }
+                if (raw != null && !raw.isEmpty()) {
+                    if (conf.getExtAttrValueInternal().equals(raw)) return conf.getExtAttrValueInternal();
+                    if (conf.getExtAttrValueExternal().equals(raw)) return conf.getExtAttrValueExternal();
+                }
+            }
+        }
+        // 2. 兜底: 部门白名单 (外部部门列表 + 默认规则)
+        List<Long> externalDepts = conf.getExternalDeptIds();
+        Object deptObj = ding.get("dept_id_list");
+        if (deptObj instanceof List && !((List) deptObj).isEmpty()) {
+            if (externalDepts != null && !externalDepts.isEmpty()) {
+                boolean isExternal = false;
+                for (Object d : (List) deptObj) {
+                    if (d == null) continue;
+                    long deptId = ((Number) d).longValue();
+                    if (externalDepts.contains(deptId)) { isExternal = true; break; }
+                }
+                return isExternal ? conf.getExtAttrValueExternal() : conf.getExtAttrValueInternal();
+            }
+            if (conf.isFallbackInternalByDefault()) {
+                return conf.getExtAttrValueInternal();
+            }
+        }
+        return null;
+    }
+
+    private boolean isActive(Map ding) {
+        Object active = ding.get("active");
+        if (active instanceof Boolean) return (Boolean) active;
+        if (active == null) return true;
+        return Boolean.parseBoolean(String.valueOf(active));
+    }
+
+    // 比较钉钉侧构造的 formData 与宜搭已有 formData 是否所有字段都相等
+    @SuppressWarnings("unchecked")
+    private boolean isSameAsYida(Map<String, Object> newData, Map<String, Object> yidaData) {
+        for (Map.Entry<String, Object> e : newData.entrySet()) {
+            String fieldId = e.getKey();
+            Object newVal = e.getValue();
+            Object oldVal = yidaData.get(fieldId);
+            if (newVal instanceof List) {
+                // 成员/部门类字段: 宜搭返回时用 _id 后缀取 id 列表
+                Object oldIdList = yidaData.get(fieldId + "_id");
+                if (oldIdList != null) oldVal = oldIdList;
+                if (!listEquals((List) newVal, oldVal)) return false;
+            } else {
+                if (!Objects.equals(String.valueOf(newVal), String.valueOf(oldVal))) return false;
+            }
+        }
+        return true;
+    }
+
+    @SuppressWarnings("unchecked")
+    private boolean listEquals(List newList, Object oldObj) {
+        List<String> newStr = new ArrayList<>();
+        for (Object o : newList) newStr.add(String.valueOf(o));
+        List<String> oldStr = new ArrayList<>();
+        if (oldObj instanceof List) {
+            for (Object o : (List) oldObj) oldStr.add(String.valueOf(o));
+        } else if (oldObj != null) {
+            oldStr.add(String.valueOf(oldObj));
+        }
+        if (newStr.size() != oldStr.size()) return false;
+        for (String s : newStr) if (!oldStr.contains(s)) return false;
+        return true;
+    }
+
+    // ==================== 内部: 写入 ====================
+
+    private WriteStats concurrentWrite(List<Action> actions) {
+        WriteStats stats = new WriteStats();
+        if (actions.isEmpty()) return stats;
+
+        ExecutorService pool = Executors.newFixedThreadPool(Math.max(1, conf.getConcurrency()));
+        List<Future<?>> futures = new ArrayList<>();
+        for (Action a : actions) {
+            futures.add(pool.submit(() -> executeAction(a, stats)));
+        }
+        for (Future<?> f : futures) {
+            try { f.get(); } catch (Exception ex) { log.warn("[PersonnelSync] future 异常", ex); }
+        }
+        pool.shutdown();
+        try { pool.awaitTermination(10, TimeUnit.MINUTES); } catch (InterruptedException ignored) {}
+        return stats;
+    }
+
+    private void executeAction(Action a, WriteStats stats) {
+        int attempt = 0;
+        while (true) {
+            try {
+                if (ACTION_CREATE.equals(a.type)) {
+                    ydClient.operateData(YDParam.builder()
+                            .appType(conf.getYidaAppType())
+                            .systemToken(conf.getYidaSystemToken())
+                            .formUuid(conf.getFormUuidPersonnel())
+                            .formDataJson(JSON.toJSONString(a.formData))
+                            .build(), YDConf.FORM_OPERATION.create);
+                    stats.created.incrementAndGet();
+                } else {
+                    ydClient.operateData(YDParam.builder()
+                            .appType(conf.getYidaAppType())
+                            .systemToken(conf.getYidaSystemToken())
+                            .formUuid(conf.getFormUuidPersonnel())
+                            .formInstanceId(a.instanceId)
+                            .updateFormDataJson(JSON.toJSONString(a.formData))
+                            .ignoreEmpty(false)
+                            .useLatestVersion(true)
+                            .build(), YDConf.FORM_OPERATION.update);
+                    if (ACTION_MARK_OFF.equals(a.type)) {
+                        stats.markedInactive.incrementAndGet();
+                    } else {
+                        stats.updated.incrementAndGet();
+                    }
+                }
+                return;
+            } catch (Exception ex) {
+                attempt++;
+                if (attempt > conf.getMaxRetry()) {
+                    log.warn("[PersonnelSync] 写入失败 userid={} action={} err={}", a.userid, a.type, ex.getMessage());
+                    stats.failed.incrementAndGet();
+                    return;
+                }
+            }
+        }
+    }
+
+    // ==================== 内部: 工具 ====================
+
+    @SuppressWarnings("unchecked")
+    private String extractEmployeeId(Map<String, Object> formData, String fieldId) {
+        Object raw = formData.get(fieldId + "_id");
+        if (raw == null) raw = formData.get(fieldId);
+        if (raw == null) return null;
+        if (raw instanceof List) {
+            List list = (List) raw;
+            return list.isEmpty() ? null : String.valueOf(list.get(0));
+        }
+        String str = String.valueOf(raw).trim();
+        if (str.startsWith("[") && str.endsWith("]")) {
+            Object parsed = JSON.parse(str);
+            if (parsed instanceof List) {
+                List pl = (List) parsed;
+                return pl.isEmpty() ? null : String.valueOf(pl.get(0));
+            }
+        }
+        return str.isEmpty() ? null : str;
+    }
+
+    private Map<String, Map> indexByUserid(List<Map> users) {
+        Map<String, Map> map = new LinkedHashMap<>();
+        for (Map u : users) {
+            Object uid = u.get("userid");
+            if (uid != null) map.putIfAbsent(String.valueOf(uid), u);
+        }
+        return map;
+    }
+
+    private Map<String, Long> countActions(List<Action> actions) {
+        Map<String, Long> stats = new HashMap<>();
+        for (Action a : actions) {
+            stats.merge(a.type, 1L, Long::sum);
+        }
+        return stats;
+    }
+
+    // ==================== 内部: 数据结构 ====================
+
+    private static class YidaRecord {
+        String instanceId;
+        Map<String, Object> formData;
+    }
+
+    private static class Action {
+        final String type;
+        final String userid;
+        final String instanceId;
+        final Map<String, Object> formData;
+
+        Action(String type, String userid, String instanceId, Map<String, Object> formData) {
+            this.type = type;
+            this.userid = userid;
+            this.instanceId = instanceId;
+            this.formData = formData;
+        }
+    }
+
+    private static class WriteStats {
+        AtomicInteger created = new AtomicInteger();
+        AtomicInteger updated = new AtomicInteger();
+        AtomicInteger markedInactive = new AtomicInteger();
+        AtomicInteger failed = new AtomicInteger();
+    }
+}

+ 25 - 0
mjava-akdsbeisen/src/main/resources/application-dev.yml

@@ -72,3 +72,28 @@ workhours:
   formUuidRequiredHours: "FORM-D9144092C2C24C93A1B02A8EEED0663FFD8G"
   yidaAppType: "APP_ZQ3I7XO2RSHDJ4QDEVNB"
   yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"
+
+# 钉钉 -> 宜搭 人员档案同步 (验证阶段指向测试表)
+personnel-sync:
+  yidaAppType: "APP_ZQ3I7XO2RSHDJ4QDEVNB"
+  yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"
+  formUuidPersonnel: "FORM-5D6C2D1BE7A0430AA8D2E604BB97A9B2BN64"  # 测试表 上线切为生产 formUuid
+  # 字段 ID (测试版)
+  fieldEmployee: "employeeField_yt3j1muz8"
+  fieldUserType: "radioField_yt3j2rwjh"
+  fieldJobNumber: "textField_yt3j3u5i0"
+  fieldDepartment: "departmentSelectField_yt3j4h3dr"
+  fieldStatus: "radioField_yt3j52vog"
+  fieldHiredDate: "dateField_6gth31wa9"
+  # 钉钉 extattr 中标记内部/外部的 key (当前企业未开通该权限 读不到 使用兜底规则)
+  extAttrKeyUserType: ""
+  extAttrValueInternal: "内部"
+  extAttrValueExternal: "外部"
+  # 属性判定兜底: 部门 ID 属于 externalDeptIds 则标"外部" 否则标"内部"
+  # 两者都为空时默认全部"内部" (fallbackInternalByDefault=true)
+  externalDeptIds: []
+  fallbackInternalByDefault: true
+  statusValueActive: "在职"
+  statusValueInactive: "离职"
+  concurrency: 10
+  maxRetry: 2

+ 21 - 0
mjava-akdsbeisen/src/main/resources/application-prod.yml

@@ -50,3 +50,24 @@ beisen:
   formUuidLeave: "FORM-D15D90EA85234A77B94564110F5D24424ERL"
   yidaAppType: "APP_ZQ3I7XO2RSHDJ4QDEVNB"
   yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"
+
+# 钉钉 -> 宜搭 人员档案同步 (生产)
+# 切换前需要:
+# 1. 在生产人员档案表新增"在职状态"字段 (RadioField 选项 在职/离职) 拿到 fieldId 回填
+# 2. 通过 /personnel-sync/probe 确认 extAttrKeyUserType 实际 key
+personnel-sync:
+  yidaAppType: "APP_ZQ3I7XO2RSHDJ4QDEVNB"
+  yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"
+  formUuidPersonnel: "FORM-CCEEE5D461694CBAB5999A8C2926D0C1RXQP"
+  fieldEmployee: "employeeField_mkow4ydp"
+  fieldUserType: "radioField_mkow4ydo"
+  fieldJobNumber: "textField_mh8xhqc1"
+  fieldDepartment: "departmentSelectField_mkow4ydr"
+  fieldStatus: ""   # 待生产表新增"在职状态"字段后填入
+  extAttrKeyUserType: ""
+  extAttrValueInternal: "内部"
+  extAttrValueExternal: "外部"
+  statusValueActive: "在职"
+  statusValueInactive: "离职"
+  concurrency: 10
+  maxRetry: 2

+ 19 - 0
mjava/src/main/java/com/malk/service/dingtalk/DDClient_Contacts.java

@@ -38,6 +38,25 @@ public interface DDClient_Contacts {
      */
     List<String> listDepartmentUserId(String access_token, long dept_id);
 
+    /**
+     * 分页查询部门下用户详情列表 [单次最多 100 条]
+     * -
+     * ppExt: 返回结构 { has_more:Boolean, next_cursor:Long, list:List<Map> }
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/queries-the-complete-information-of-a-department-user
+     */
+    Map listDepartmentUserDetail(String access_token, long dept_id, long cursor, long size);
+
+    /**
+     * 获取部门下全部用户详情 [自动分页拼接, 内部循环调用 listDepartmentUserDetail]
+     */
+    List<Map> listDepartmentUserDetail_all(String access_token, long dept_id);
+
+    /**
+     * 获取企业全量用户详情 [遍历全部部门 + 按 userid 去重]
+     */
+    List<Map> getAllUserDetails(String access_token, boolean containsTop);
+
     /**
      * 查询用户详情
      * -

+ 54 - 0
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Contacts.java

@@ -14,6 +14,7 @@ import org.springframework.stereotype.Service;
 
 import java.util.ArrayList;
 import java.util.Date;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
@@ -101,6 +102,59 @@ public class DDImplClient_Contacts implements DDClient_Contacts {
         return (List<String>) rsp.get("userid_list");
     }
 
+    /**
+     * 分页查询部门下用户详情列表
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/queries-the-complete-information-of-a-department-user
+     */
+    @Override
+    public Map listDepartmentUserDetail(String access_token, long dept_id, long cursor, long size) {
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = UtilMap.map("dept_id, cursor, size", dept_id, cursor, size);
+        return (Map) DDR.doPost("https://oapi.dingtalk.com/topapi/v2/user/list", null, param, body).getResult();
+    }
+
+    /**
+     * 获取部门下全部用户详情 [自动分页]
+     */
+    @Override
+    public List<Map> listDepartmentUserDetail_all(String access_token, long dept_id) {
+        List<Map> all = new ArrayList<>();
+        long cursor = 0;
+        while (true) {
+            Map rsp = listDepartmentUserDetail(access_token, dept_id, cursor, 100);
+            List<Map> list = (List<Map>) rsp.get("list");
+            if (ObjectUtil.isNotNull(list) && !list.isEmpty()) {
+                all.addAll(list);
+            }
+            Object hasMore = rsp.get("has_more");
+            if (!Boolean.TRUE.equals(hasMore)) break;
+            Object next = rsp.get("next_cursor");
+            if (ObjectUtil.isNull(next)) break;
+            cursor = ((Number) next).longValue();
+        }
+        return all;
+    }
+
+    /**
+     * 获取企业全量用户详情 [遍历全部部门 + 按 userid 去重]
+     */
+    @Override
+    public List<Map> getAllUserDetails(String access_token, boolean containsTop) {
+        List<Long> deptIds = getDepartmentId_all(access_token, containsTop);
+        Map<String, Map> byUserid = new LinkedHashMap<>();
+        for (Long deptId : deptIds) {
+            List<Map> users = listDepartmentUserDetail_all(access_token, deptId);
+            for (Map user : users) {
+                Object useridObj = user.get("userid");
+                if (ObjectUtil.isNotNull(useridObj)) {
+                    byUserid.putIfAbsent(String.valueOf(useridObj), user);
+                }
+            }
+        }
+        return new ArrayList<>(byUserid.values());
+    }
+
     /**
      * 查询用户详情
      *