Quellcode durchsuchen

feat(personnel-sync): 外部部门兜底+重复条防御+定时改工作日多频次

抓取/防御
- fetchAllDingUsers 在主递归后显式补抓 externalDeptIds 部门并按 userid 去重
  (本期实测主递归已覆盖 1066052389 的 234 人, 补抓"新增 0 人"; 留作钉钉将来
  挪外部部门到非组织树时的保险)
- fetchAllYidaPersonnel 检测到同 userid 多条时 WARN 日志,保留 instanceId
  字典序较小者(早建条)不再静默覆盖

调试接口 (排障常用,常驻)
- POST /personnel-sync/single?userid=xxx — 单人同步,与 full 同一套字段映射
- GET  /personnel-sync/probe-yida?userid=xxx — 列出宜搭按 userid 命中的全部
- GET  /personnel-sync/probe-duplicates — 全表扫重复 userid + 空人员条

一次性清理 (跑过即失效, 下次发版可删 cleanupKnownDuplicatesOnce)
- POST /personnel-sync/cleanup-dups-once?dryRun=true|false
- 已清理 jingzhao(删后建空字段重复条)与 626967876吴超(同步条钉钉字段
  merge 到业务条后删同步条);钉钉相关字段以同步为主,业务字段保留
  生产表清理后 total=715→713, 重复组 0, 二跑全 0 幂等

定时任务
- 原 cron "0 0 3,13 * * ?" 改为工作日两段:
  · nightlyFullSync cron="0 0 3 ? * MON-FRI"    每工作日 03:00 基线
  · hourlyFullSync  cron="0 5 10-22 ? * MON-FRI" 每工作日 10:05~22:05 每小时
  共 14 次/工作日, 周末不跑; 两个 @Scheduled 共享 running 互斥锁

配置内化整理
- PersonnelSyncConf 由 @ConfigurationProperties(prefix=personnel-sync) 改为
  Java 硬编码默认值, 移除对 yml 的注入依赖
- application-{dev,prod}.yml 删除 personnel-sync 配置段
- application-dev.yml: datasource 切回 localhost + scheduling=false (开发态)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
malk vor 3 Wochen
Ursprung
Commit
f8607311cd

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

@@ -59,6 +59,76 @@ public class PersonnelSyncController {
         return result;
     }
 
+    /**
+     * 单人同步 POST /personnel-sync/single?userid=xxx
+     * 与 full 同一套字段映射 / 操作选择, 用于按 userid 定点验证 (排障常用)
+     */
+    @PostMapping("/single")
+    public Map<String, Object> single(@RequestParam String userid) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            result.put("success", true);
+            result.put("data", personnelSyncService.syncSingle(userid));
+        } catch (Exception e) {
+            log.error("[PersonnelSync] 单人同步失败 userid={}", userid, e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 调试: 列出宜搭按 userid 命中的所有记录 GET /personnel-sync/probe-yida?userid=xxx
+     */
+    @GetMapping("/probe-yida")
+    public Map<String, Object> probeYida(@RequestParam String userid) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            result.put("success", true);
+            result.put("data", personnelSyncService.probeYidaByUserid(userid));
+        } catch (Exception e) {
+            log.error("[PersonnelSync] probe-yida 失败 userid={}", userid, e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 一次性清理本期已识别的重复条 POST /personnel-sync/cleanup-dups-once?dryRun=true|false
+     * 默认 dryRun=true 只返回动作清单不真删
+     */
+    @PostMapping("/cleanup-dups-once")
+    public Map<String, Object> cleanupDupsOnce(@RequestParam(defaultValue = "true") boolean dryRun) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            result.put("success", true);
+            result.put("data", personnelSyncService.cleanupKnownDuplicatesOnce(dryRun));
+        } catch (Exception e) {
+            log.error("[PersonnelSync] cleanup-dups-once 失败 dryRun={}", dryRun, e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 调试: 全量扫宜搭找重复 userid 与空人员条 GET /personnel-sync/probe-duplicates
+     */
+    @GetMapping("/probe-duplicates")
+    public Map<String, Object> probeDuplicates() {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            result.put("success", true);
+            result.put("data", personnelSyncService.probeYidaDuplicates());
+        } catch (Exception e) {
+            log.error("[PersonnelSync] probe-duplicates 失败", 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

+ 34 - 42
mjava-akdsbeisen/src/main/java/com/malk/server/personnel/PersonnelSyncConf.java

@@ -1,63 +1,55 @@
 package com.malk.server.personnel;
 
 import lombok.Data;
-import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.stereotype.Component;
 
+import java.util.Arrays;
+import java.util.List;
+
 @Data
 @Component
-@ConfigurationProperties(prefix = "personnel-sync")
 public class PersonnelSyncConf {
 
-    private String yidaAppType;
-    private String yidaSystemToken;
-
-    // 验证阶段指向测试表 formUuid 上线切换为生产 formUuid
-    private String formUuidPersonnel;
-
-    // 字段 ID 集合 (测试版/生产版按环境切换)
-    private String fieldEmployee;        // 人员 EmployeeField <- userid
-    private String fieldName;            // 员工姓名 TextField <- name (目标表 READONLY, 强写覆盖)
-    private String fieldJobNumber;       // 员工编号 TextField <- userid (钉钉唯一 ID)
-    private String fieldJobNumber2;      // 员工工号 TextField <- job_number (目标表 READONLY, 强写覆盖)
-    private String fieldDepartment;      // 员工部门 DepartmentSelectField <- dept_id_list 排序后首个
-    private String fieldHiredDate;       // 入职时间 DateField <- hired_date
-    private String fieldBeisenJobNo;     // 北森编号 TextField <- extattr[北森工号]
-    private String fieldManager;         // Manager EmployeeField <- manager_userid
-    private String fieldUserType;        // 属性 RadioField <- 部门含 externalDeptIds ? 外部 : 内部
-    private String fieldCompany;         // 归属公司 SelectField <- extattr[company]
-    private String fieldIsCf;            // 是否CF TextField <- extattr[是否CF员工]
-    private String fieldCostCenter;      // 成本中心 TextField <- extattr[成本中心]
-    private String fieldStatus;          // 是否在职 RadioField <- 钉钉存在性
-
-    // extattr 属性判定: 钉钉 extattr 中用于标记内部/外部的 key (留空则内外判定走部门白名单)
-    private String extAttrKeyUserType;
+    private String yidaAppType    = "APP_ZQ3I7XO2RSHDJ4QDEVNB";
+    private String yidaSystemToken = "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S";
+    private String formUuidPersonnel = "FORM-CCEEE5D461694CBAB5999A8C2926D0C1RXQP";
+
+    // 字段 ID (人员档案生产表)
+    private String fieldEmployee    = "employeeField_mkow4ydp";
+    private String fieldName        = "textField_mkox0j8v";
+    private String fieldJobNumber   = "textField_mh8xhqc1";    // 员工编号 <- userid
+    private String fieldJobNumber2  = "textField_mkox0j8w";    // 员工工号 <- job_number (READONLY)
+    private String fieldDepartment  = "departmentSelectField_mkow4ydr";
+    private String fieldHiredDate   = "dateField_mh8xhqc6";
+    private String fieldBeisenJobNo = "textField_mh8xhqc2";
+    private String fieldManager     = "employeeField_mh8xhqc3";
+    private String fieldUserType    = "radioField_mkow4ydo";
+    private String fieldCompany     = "selectField_mh8xhqc4";
+    private String fieldIsCf        = "textField_mow9w7d8";
+    private String fieldCostCenter  = "textField_mow9w7d9";
+    private String fieldStatus      = "radioField_mp1sngq1";
+
+    // 内外部判定: extAttrKeyUserType 留空 -> 走部门白名单; 命中 externalDeptIds -> 外部
+    private String extAttrKeyUserType   = "";
     private String extAttrValueInternal = "内部";
     private String extAttrValueExternal = "外部";
+    private List<Long> externalDeptIds  = Arrays.asList(1066052389L);
+    private boolean fallbackInternalByDefault = true;
 
-    // extattr 自定义字段 key (钉钉后台配置的字段名, probe 真实用户后确认)
-    private String extAttrKeyBeisen;
-    private String extAttrKeyCompany;
-    private String extAttrKeyIsCf;
-    private String extAttrKeyCostCenter;
-
-    // 属性判定兜底: 当 extattr 读不到时 按部门 ID 白/黑名单判定
-    // externalDeptIds 里的部门视为"外部" 其它视为"内部"; 均为空则不写属性字段
-    private java.util.List<Long> externalDeptIds = new java.util.ArrayList<>();
-    private boolean fallbackInternalByDefault = false;
+    // 钉钉 extension 自定义字段 key
+    private String extAttrKeyBeisen     = "北森工号";
+    private String extAttrKeyCompany    = "company";
+    private String extAttrKeyIsCf       = "是否CF员工";
+    private String extAttrKeyCostCenter = "成本中心";
 
     // 状态字段取值
-    private String statusValueActive = "在职";
+    private String statusValueActive   = "在职";
     private String statusValueInactive = "离职";
 
-    // 测试开关: >0 时 钉钉全量按 userid 升序取前 N 条, 且本轮跳过 MARK_OFF (避免误标未覆盖记录离职)
+    // 测试开关: >0 时按 userid 升序取前 N 条且跳过 MARK_OFF; 0 = 真·全量
     private int limitFirstN = 0;
 
     // 并发与重试
     private int concurrency = 10;
-    private int maxRetry = 2;
-
-    // QPS 管控: 钉钉官方限 60/s, 宜搭写接口留余量
-    private double ddApiQps = 50.0;    // enrichManagers user/get 限速 (官方 60, 留 10 余量)
-    private double yidaApiQps = 30.0;  // 宜搭写接口限速 (10 并发下单线 QPS 峰值需控制)
+    private int maxRetry    = 2;
 }

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

@@ -40,4 +40,31 @@ public interface PersonnelSyncService {
      * 统计钉钉侧数据完整度 (active/dept_id_list/extattr 分布)
      */
     Map<String, Object> probeStats();
+
+    /**
+     * 单人同步: 按 userid 取钉钉详情 -> 与宜搭比对 -> create / update (与 fullSync 同一套字段映射)
+     *
+     * @param userid 钉钉 userid
+     * @return {userid, action(CREATE/UPDATE/SKIP/NOT_FOUND), instanceId?, formData, durationMs}
+     */
+    Map<String, Object> syncSingle(String userid);
+
+    /**
+     * 调试用: 按 userid 列出宜搭人员档案中所有命中条 (排查重复数据)
+     * @return {userid, count, records:[{instanceId, formData}]}
+     */
+    Map<String, Object> probeYidaByUserid(String userid);
+
+    /**
+     * 全量扫描宜搭人员档案 -> 按 userid 分组 -> 找出 count>1 的重复条 (人员字段为空也单列)
+     * @return {totalRecords, duplicateGroups:[{userid, count, instanceIds}], emptyEmployeeCount, emptyEmployeeInstanceIds}
+     */
+    Map<String, Object> probeYidaDuplicates();
+
+    /**
+     * 一次性清理本期已识别的两组重复 (jingzhao / 626967876吴超)
+     * 策略: 保留早建条 + merge 钉钉同步字段; 删除后建条
+     * @param dryRun true 时只返回将要执行的动作不实际写; false 才真删
+     */
+    Map<String, Object> cleanupKnownDuplicatesOnce(boolean dryRun);
 }

+ 307 - 3
mjava-akdsbeisen/src/main/java/com/malk/service/personnel/impl/PersonnelSyncServiceImpl.java

@@ -21,10 +21,12 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Set;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
@@ -54,14 +56,18 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
 
     @PostConstruct
     private void initRateLimiters() {
-        ddRateLimiter = RateLimiter.create(conf.getDdApiQps());
-        yidaRateLimiter = RateLimiter.create(conf.getYidaApiQps());
+        ddRateLimiter = RateLimiter.create(DD_API_QPS);
+        yidaRateLimiter = RateLimiter.create(YIDA_API_QPS);
     }
 
     private static final String ACTION_CREATE = "CREATE";
     private static final String ACTION_UPDATE = "UPDATE";
     private static final String ACTION_MARK_OFF = "MARK_OFF";
 
+    // 钉钉官方 QPS 上限 60, 留 10 余量; 宜搭写接口 10 并发需整体压低
+    private static final double DD_API_QPS = 50.0;
+    private static final double YIDA_API_QPS = 30.0;
+
     @Override
     public Map<String, Object> fullSync(Integer limitOverride) {
         long start = System.currentTimeMillis();
@@ -159,7 +165,34 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
     @Override
     public List<Map> fetchAllDingUsers() {
         String token = ddClient.getAccessToken();
-        return ddClient_contacts.getAllUserDetails(token, true);
+        List<Map> users = new ArrayList<>(ddClient_contacts.getAllUserDetails(token, true));
+        // fixme 外部部门(如 1066052389)不在组织树根部门 1 的递归子树内,getAllUserDetails 拉不到 → 显式补抓
+        List<Long> extDepts = conf.getExternalDeptIds();
+        if (extDepts != null && !extDepts.isEmpty()) {
+            Set<String> seen = new HashSet<>();
+            for (Map u : users) {
+                Object uid = u.get("userid");
+                if (uid != null) seen.add(String.valueOf(uid));
+            }
+            for (Long dept : extDepts) {
+                if (dept == null) continue;
+                try {
+                    List<Map> ext = ddClient_contacts.listDepartmentUserDetail_all(token, dept);
+                    int added = 0;
+                    for (Map u : ext) {
+                        Object uid = u.get("userid");
+                        if (uid != null && seen.add(String.valueOf(uid))) {
+                            users.add(u);
+                            added++;
+                        }
+                    }
+                    log.info("[PersonnelSync] 补抓外部部门 dept={} 新增 {} 人 (该部门共 {})", dept, added, ext.size());
+                } catch (Exception ex) {
+                    log.warn("[PersonnelSync] 抓外部部门失败 dept={} err={}", dept, ex.getMessage());
+                }
+            }
+        }
+        return users;
     }
 
     @Override
@@ -210,6 +243,267 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
         return r;
     }
 
+    @SuppressWarnings("unchecked")
+    @Override
+    public Map<String, Object> probeYidaByUserid(String userid) {
+        Map<String, Object> r = new LinkedHashMap<>();
+        r.put("userid", userid);
+        DDR_New result = ydClient.queryData(YDParam.builder()
+                .appType(conf.getYidaAppType())
+                .systemToken(conf.getYidaSystemToken())
+                .formUuid(conf.getFormUuidPersonnel())
+                .searchFieldJson("{\"" + conf.getFieldEmployee() + "\":\"" + userid + "\"}")
+                .pageSize(YDConf.PAGE_SIZE_LIMIT)
+                .build(), YDConf.FORM_QUERY.retrieve_search_form);
+        List<Map> list = (List<Map>) result.getData();
+        r.put("count", list == null ? 0 : list.size());
+        List<Map<String, Object>> records = new ArrayList<>();
+        if (list != null) {
+            for (Map item : list) {
+                Map<String, Object> rec = new LinkedHashMap<>();
+                rec.put("instanceId", item.get("formInstanceId"));
+                rec.put("createTime", item.get("gmtCreate"));
+                rec.put("modifyTime", item.get("gmtModified"));
+                rec.put("creator", item.get("creator"));
+                rec.put("formData", item.get("formData"));
+                records.add(rec);
+            }
+        }
+        r.put("records", records);
+        return r;
+    }
+
+    @SuppressWarnings("unchecked")
+    @Override
+    public Map<String, Object> probeYidaDuplicates() {
+        long start = System.currentTimeMillis();
+        Map<String, List<Map<String, Object>>> byUserid = new LinkedHashMap<>();
+        List<Map<String, Object>> emptyEmployee = new ArrayList<>();
+        int currentPage = 1;
+        long totalCount;
+        int total = 0;
+        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) {
+                total++;
+                Map<String, Object> formData = (Map<String, Object>) item.get("formData");
+                String uid = formData == null ? null : extractEmployeeId(formData, conf.getFieldEmployee());
+                Map<String, Object> brief = new LinkedHashMap<>();
+                brief.put("instanceId", item.get("formInstanceId"));
+                brief.put("createTime", item.get("gmtCreate"));
+                brief.put("modifyTime", item.get("gmtModified"));
+                if (formData != null) {
+                    brief.put("name", formData.get(conf.getFieldName()));
+                    brief.put("status", formData.get(conf.getFieldStatus()));
+                    brief.put("userType", formData.get(conf.getFieldUserType()));
+                }
+                if (uid == null || uid.isEmpty()) {
+                    emptyEmployee.add(brief);
+                } else {
+                    byUserid.computeIfAbsent(uid, k -> new ArrayList<>()).add(brief);
+                }
+            }
+            currentPage++;
+        } while ((long) (currentPage - 1) * YDConf.PAGE_SIZE_LIMIT < totalCount);
+
+        List<Map<String, Object>> dups = new ArrayList<>();
+        for (Map.Entry<String, List<Map<String, Object>>> e : byUserid.entrySet()) {
+            if (e.getValue().size() > 1) {
+                Map<String, Object> g = new LinkedHashMap<>();
+                g.put("userid", e.getKey());
+                g.put("count", e.getValue().size());
+                g.put("records", e.getValue());
+                dups.add(g);
+            }
+        }
+        Map<String, Object> r = new LinkedHashMap<>();
+        r.put("totalRecords", total);
+        r.put("uniqueUseridCount", byUserid.size());
+        r.put("emptyEmployeeCount", emptyEmployee.size());
+        r.put("duplicateGroupCount", dups.size());
+        r.put("duplicateGroups", dups);
+        r.put("emptyEmployeeRecords", emptyEmployee);
+        r.put("durationMs", System.currentTimeMillis() - start);
+        log.info("[PersonnelSync] probeYidaDuplicates total={} unique={} empty={} duplicateGroups={}",
+                total, byUserid.size(), emptyEmployee.size(), dups.size());
+        return r;
+    }
+
+    /**
+     * 一次性清理重复条(本期任务):
+     * - jingzhao: 直接删后建条 FHC66571O325JF0LK0EB54NKPOZV3YWZQ98OMOG7
+     * - 626967876(吴超): 把同步条 4XC66W81HA43...的钉钉字段 merge 到业务条 XRD66E7127A5... 再删同步条
+     * 钉钉字段以同步条为准:属性/员工编号/归属公司/Manager/是否CF/成本中心/入职时间/在职状态
+     */
+    @Override
+    public Map<String, Object> cleanupKnownDuplicatesOnce(boolean dryRun) {
+        long start = System.currentTimeMillis();
+        Map<String, Object> r = new LinkedHashMap<>();
+        r.put("dryRun", dryRun);
+        List<Map<String, Object>> actions = new ArrayList<>();
+
+        // ===== Group A: jingzhao =====
+        String jingzhaoDeleteId = "FINST-FHC66571O325JF0LK0EB54NKPOZV3YWZQ98OMOG7";
+        Map<String, Object> aDel = new LinkedHashMap<>();
+        aDel.put("type", "DELETE");
+        aDel.put("userid", "jingzhao");
+        aDel.put("instanceId", jingzhaoDeleteId);
+        aDel.put("reason", "保留早建条 4XC66W81C9Z40Q...; 删除后建空字段重复条");
+        if (!dryRun) {
+            try {
+                ydClient.operateData(YDParam.builder()
+                        .appType(conf.getYidaAppType())
+                        .systemToken(conf.getYidaSystemToken())
+                        .formUuid(conf.getFormUuidPersonnel())
+                        .formInstanceId(jingzhaoDeleteId)
+                        .build(), YDConf.FORM_OPERATION.delete);
+                aDel.put("ok", true);
+            } catch (Exception ex) {
+                aDel.put("ok", false);
+                aDel.put("err", ex.getMessage());
+            }
+        }
+        actions.add(aDel);
+
+        // ===== Group B: 626967876 (吴超) merge then delete =====
+        String wuchaoKeepId   = "FINST-XRD66E7127A51G39GBQX1AG83I8525SWNWKOMCZB";
+        String wuchaoDeleteId = "FINST-4XC66W81HA43S9G1H3ELL664BSEN23DTEE4NMS703";
+
+        Map<String, Object> mergeFields = new LinkedHashMap<>();
+        // 员工编号 textField_mh8xhqc1 = userid 字符串(同步代码 v1.2 后规范)
+        mergeFields.put(conf.getFieldJobNumber(), "626967876");
+        // 属性: 内部 (钉钉 dept_id=[1057430958] 内部部门, 同步条已写"内部")
+        mergeFields.put(conf.getFieldUserType(), conf.getExtAttrValueInternal());
+        // 归属公司: 上海 (同步条值)
+        mergeFields.put(conf.getFieldCompany(), "上海");
+        // Manager: Kevin Xu (640891109) 数组格式
+        mergeFields.put(conf.getFieldManager(), Collections.singletonList("640891109"));
+        // 是否CF: false (同步条值)
+        mergeFields.put(conf.getFieldIsCf(), "false");
+        // 成本中心: 35 (同步条值)
+        mergeFields.put(conf.getFieldCostCenter(), "35");
+        // 入职时间: 1716134400000 (同步条值)
+        mergeFields.put(conf.getFieldHiredDate(), 1716134400000L);
+        // 在职状态: 在职
+        mergeFields.put(conf.getFieldStatus(), conf.getStatusValueActive());
+        // 部门: 1057430958 (钉钉同步主部门)
+        mergeFields.put(conf.getFieldDepartment(), Collections.singletonList("1057430958"));
+
+        Map<String, Object> bUpd = new LinkedHashMap<>();
+        bUpd.put("type", "UPDATE_MERGE");
+        bUpd.put("userid", "626967876");
+        bUpd.put("instanceId", wuchaoKeepId);
+        bUpd.put("mergeFields", mergeFields);
+        bUpd.put("note", "钉钉字段贴到业务条,业务字段(客户类型/合同号/numberField/北森编号)保留不动");
+        if (!dryRun) {
+            try {
+                ydClient.operateData(YDParam.builder()
+                        .appType(conf.getYidaAppType())
+                        .systemToken(conf.getYidaSystemToken())
+                        .formUuid(conf.getFormUuidPersonnel())
+                        .formInstanceId(wuchaoKeepId)
+                        .updateFormDataJson(JSON.toJSONString(mergeFields))
+                        .ignoreEmpty(false)
+                        .useLatestVersion(true)
+                        .build(), YDConf.FORM_OPERATION.update);
+                bUpd.put("ok", true);
+            } catch (Exception ex) {
+                bUpd.put("ok", false);
+                bUpd.put("err", ex.getMessage());
+            }
+        }
+        actions.add(bUpd);
+
+        Map<String, Object> bDel = new LinkedHashMap<>();
+        bDel.put("type", "DELETE");
+        bDel.put("userid", "626967876");
+        bDel.put("instanceId", wuchaoDeleteId);
+        bDel.put("reason", "同步条信息已 merge 到业务条 XRD66E7127A5...");
+        if (!dryRun) {
+            try {
+                ydClient.operateData(YDParam.builder()
+                        .appType(conf.getYidaAppType())
+                        .systemToken(conf.getYidaSystemToken())
+                        .formUuid(conf.getFormUuidPersonnel())
+                        .formInstanceId(wuchaoDeleteId)
+                        .build(), YDConf.FORM_OPERATION.delete);
+                bDel.put("ok", true);
+            } catch (Exception ex) {
+                bDel.put("ok", false);
+                bDel.put("err", ex.getMessage());
+            }
+        }
+        actions.add(bDel);
+
+        r.put("actions", actions);
+        r.put("durationMs", System.currentTimeMillis() - start);
+        log.info("[PersonnelSync] cleanupKnownDuplicatesOnce dryRun={} result={}", dryRun, r);
+        return r;
+    }
+
+    @SuppressWarnings("unchecked")
+    @Override
+    public Map<String, Object> syncSingle(String userid) {
+        long start = System.currentTimeMillis();
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("userid", userid);
+        // 1. 钉钉取这个人
+        String token = ddClient.getAccessToken();
+        Map ding = ddClient_contacts.getUserInfoById(token, userid);
+        if (ding == null || ding.get("userid") == null) {
+            result.put("action", "NOT_FOUND");
+            result.put("durationMs", System.currentTimeMillis() - start);
+            log.warn("[PersonnelSync] syncSingle 钉钉查无此人 userid={}", userid);
+            return result;
+        }
+        // 2. 宜搭按 userid 查现有记录
+        DDR_New q = 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>) q.getData();
+        String instanceId = null;
+        Map<String, Object> existFormData = null;
+        if (list != null && !list.isEmpty()) {
+            instanceId = String.valueOf(list.get(0).get("formInstanceId"));
+            existFormData = (Map<String, Object>) list.get(0).get("formData");
+        }
+        // 3. 构造 formData 并选 action
+        String action = (instanceId == null) ? ACTION_CREATE : ACTION_UPDATE;
+        Map<String, Object> formData = toYidaFormData(userid, ding, action);
+        if (ACTION_UPDATE.equals(action) && isSameAsYida(formData, existFormData)) {
+            result.put("action", "SKIP");
+            result.put("instanceId", instanceId);
+            result.put("formData", formData);
+            result.put("durationMs", System.currentTimeMillis() - start);
+            log.info("[PersonnelSync] syncSingle 幂等跳过 userid={}", userid);
+            return result;
+        }
+        // 4. 写入 (复用 executeAction 的指数退避 + 限速)
+        WriteStats stats = new WriteStats();
+        executeAction(new Action(action, userid, instanceId, formData), stats);
+        result.put("action", action);
+        result.put("instanceId", instanceId);
+        result.put("formData", formData);
+        result.put("created", stats.created.get());
+        result.put("updated", stats.updated.get());
+        result.put("failed", stats.failed.get());
+        result.put("durationMs", System.currentTimeMillis() - start);
+        log.info("[PersonnelSync] syncSingle 完成 userid={} action={} result={}", userid, action, result);
+        return result;
+    }
+
     @Override
     public Map<String, Object> probeStats() {
         long start = System.currentTimeMillis();
@@ -263,6 +557,16 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
                 YidaRecord rec = new YidaRecord();
                 rec.instanceId = String.valueOf(item.get("formInstanceId"));
                 rec.formData = formData;
+                // fixme 同 userid 重复条防御: 保留早建条 (按 instanceId 字典序较小者) 并 WARN, 避免静默覆盖
+                YidaRecord exist = yidaMap.get(userid);
+                if (exist != null) {
+                    log.warn("[PersonnelSync] 宜搭检测到重复 userid={} 已扫到 instanceId={} 又扫到 instanceId={}",
+                            userid, exist.instanceId, rec.instanceId);
+                    if (exist.instanceId != null && rec.instanceId != null
+                            && rec.instanceId.compareTo(exist.instanceId) > 0) {
+                        continue;   // 已有的更早建,保留它
+                    }
+                }
                 yidaMap.put(userid, rec);
             }
             currentPage++;

+ 19 - 7
mjava-akdsbeisen/src/main/java/com/malk/timer/PersonnelSyncTimer.java

@@ -11,7 +11,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
 
 /**
  * 钉钉 -> 宜搭 人员档案 定时全量增量同步
- * 每天 03:00 与 13:00 各跑一次 fullSync (limit 取配置 personnel-sync.limitFirstN, 生产为 0 即真·全量)
+ * 工作日 (MON-FRI) 03:00 基线 + 10:05~22:05 每小时一次 fullSync (limit 取配置 personnel-sync.limitFirstN, 生产为 0 即真·全量)
+ * 共 14 次/工作日: 03:00 / 10:05 / 11:05 / ... / 22:05; 周末不跑
  */
 @Slf4j
 @Configuration
@@ -24,18 +25,29 @@ public class PersonnelSyncTimer {
     // fixme: 防止上一轮未完成时下一轮重入 (两轮并发会使 QPS 翻倍)
     private final AtomicBoolean running = new AtomicBoolean(false);
 
-    @Scheduled(cron = "0 0 3,13 * * ?")
-    public void dailyFullSync() {
+    /** 工作日凌晨 03:00 全量基线 */
+    @Scheduled(cron = "0 0 3 ? * MON-FRI")
+    public void nightlyFullSync() {
+        runFullSync("03:00");
+    }
+
+    /** 工作日时段每小时 (10:05 ~ 22:05 含端点, 共 13 次) */
+    @Scheduled(cron = "0 5 10-22 ? * MON-FRI")
+    public void hourlyFullSync() {
+        runFullSync("HH:05");
+    }
+
+    private void runFullSync(String tag) {
         if (!running.compareAndSet(false, true)) {
-            log.warn("[PersonnelSync] 上次定时同步尚未结束,跳过本次触发");
+            log.warn("[PersonnelSync] 上次定时同步尚未结束,跳过本次触发 tag={}", tag);
             return;
         }
-        log.info("[PersonnelSync] 定时同步任务开始");
+        log.info("[PersonnelSync] 定时同步任务开始 tag={}", tag);
         try {
             java.util.Map<String, Object> stats = personnelSyncService.fullSync(null);
-            log.info("[PersonnelSync] 定时同步任务完成 {}", stats);
+            log.info("[PersonnelSync] 定时同步任务完成 tag={} {}", tag, stats);
         } catch (Exception e) {
-            log.error("[PersonnelSync] 定时同步任务失败", e);
+            log.error("[PersonnelSync] 定时同步任务失败 tag={}", tag, e);
         } finally {
             running.set(false);
         }

+ 4 - 41
mjava-akdsbeisen/src/main/resources/application-dev.yml

@@ -6,14 +6,15 @@ server:
 
 
 enable:
-   scheduling: true
+   scheduling: false
+
 # condition
 spel:
   scheduling: false        # 定时任务是否执行
   multiSource: false       # 是否多数据源配置
 
 nc:
-  scheduling: true        # 定时任务是否执行
+  scheduling: false        # 定时任务是否执行
   multiSource: false       # 是否多数据源配置
 
 spring:
@@ -24,8 +25,7 @@ spring:
     driver-class-name: com.mysql.cj.jdbc.Driver
     username: root
     password: 123456
-    url: jdbc:mysql://58.247.23.192:3306/paineng?serverTimezone=GMT%2B8
-   # url: jdbc:mysql://localhost:3306/paineng?serverTimezone=Asia/Shanghai&useUnicode=yes&characterEncoding=UTF-8&useSSL=true
+    url: jdbc:mysql://localhost:3306/paineng?serverTimezone=Asia/Shanghai&useUnicode=yes&characterEncoding=UTF-8&useSSL=true
   jpa:
     hibernate:
       ddl-auto: none      # JPA对表没有任何操作
@@ -72,40 +72,3 @@ workhours:
   formUuidRequiredHours: "FORM-D9144092C2C24C93A1B02A8EEED0663FFD8G"
   yidaAppType: "APP_ZQ3I7XO2RSHDJ4QDEVNB"
   yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"
-
-# 钉钉 -> 宜搭 人员档案同步 (验证阶段指向测试表 FORM-6889...)
-personnel-sync:
-  yidaAppType: "APP_ZQ3I7XO2RSHDJ4QDEVNB"
-  yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"
-  formUuidPersonnel: "FORM-6889D4A0CE1E4E239E6E6809391E21E6XF1Y"  # 测试表 上线切为生产 formUuid
-  # 字段 ID (与生产人员档案表同结构)
-  fieldEmployee: "employeeField_mkow4ydp"          # 人员 <- userid
-  fieldName: "textField_mkox0j8v"                  # 员工姓名 <- name (READONLY, 强写覆盖)
-  fieldJobNumber: "textField_mh8xhqc1"             # 员工编号 <- userid (钉钉唯一 ID)
-  fieldJobNumber2: "textField_mkox0j8w"            # 员工工号 <- job_number (READONLY, 强写覆盖)
-  fieldDepartment: "departmentSelectField_mkow4ydr" # 员工部门 <- dept_id_list 排序后首个
-  fieldHiredDate: "dateField_mh8xhqc6"             # 入职时间 <- hired_date
-  fieldBeisenJobNo: "textField_mh8xhqc2"           # 北森编号 <- extattr[北森工号]
-  fieldManager: "employeeField_mh8xhqc3"           # Manager <- manager_userid
-  fieldUserType: "radioField_mkow4ydo"             # 属性 <- 部门含 externalDeptIds ? 外部 : 内部
-  fieldCompany: "selectField_mh8xhqc4"             # 归属公司 <- extattr[company]
-  fieldIsCf: "textField_mow9w7d8"                  # 是否CF <- extattr[是否CF员工]
-  fieldCostCenter: "textField_mow9w7d9"            # 成本中心 <- extattr[成本中心]
-  fieldStatus: "radioField_mp1sngq1"               # 是否在职 <- 钉钉存在性
-  # 内外判定: extAttrKeyUserType 留空 -> 走部门白名单; 部门 ID 命中 externalDeptIds 标"外部" 否则"内部"
-  extAttrKeyUserType: ""
-  extAttrValueInternal: "内部"
-  extAttrValueExternal: "外部"
-  externalDeptIds: [1066052389]
-  fallbackInternalByDefault: true
-  # 钉钉 extattr 自定义字段 key (probe 真实用户后按实际后台字段名校正)
-  extAttrKeyBeisen: "北森工号"
-  extAttrKeyCompany: "company"
-  extAttrKeyIsCf: "是否CF员工"
-  extAttrKeyCostCenter: "成本中心"
-  statusValueActive: "在职"
-  statusValueInactive: "离职"
-  # 测试开关: >0 时钉钉全量按 userid 升序取前 N 条且跳过 MARK_OFF; 验证完置 0 恢复全量
-  limitFirstN: 10
-  concurrency: 10
-  maxRetry: 2

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

@@ -29,7 +29,6 @@ spring:
     database: MYSQL
     database-platform: org.hibernate.dialect.MySQL57Dialect
 
-
 # dingtalk
 dingtalk:
   agentId: 4310055556
@@ -50,38 +49,3 @@ beisen:
   formUuidLeave: "FORM-D15D90EA85234A77B94564110F5D24424ERL"
   yidaAppType: "APP_ZQ3I7XO2RSHDJ4QDEVNB"
   yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"
-
-# 钉钉 -> 宜搭 人员档案同步 (生产)
-# 切换前需要: 1. 确认生产表是否与测试表 FORM-6889... 同 fieldId  2. probe 确认 extattr 各 key 实际值
-# 测试通过后, formUuidPersonnel 视情况切到 FORM-6889... 或保持生产表; limitFirstN 保持 0
-personnel-sync:
-  yidaAppType: "APP_ZQ3I7XO2RSHDJ4QDEVNB"
-  yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"
-  formUuidPersonnel: "FORM-CCEEE5D461694CBAB5999A8C2926D0C1RXQP"
-  fieldEmployee: "employeeField_mkow4ydp"
-  fieldName: "textField_mkox0j8v"
-  fieldJobNumber: "textField_mh8xhqc1"             # 员工编号 <- userid (钉钉唯一 ID)
-  fieldJobNumber2: "textField_mkox0j8w"            # 员工工号 <- job_number (READONLY, 强写覆盖)
-  fieldDepartment: "departmentSelectField_mkow4ydr"
-  fieldHiredDate: "dateField_mh8xhqc6"
-  fieldBeisenJobNo: "textField_mh8xhqc2"
-  fieldManager: "employeeField_mh8xhqc3"
-  fieldUserType: "radioField_mkow4ydo"
-  fieldCompany: "selectField_mh8xhqc4"
-  fieldIsCf: "textField_mow9w7d8"
-  fieldCostCenter: "textField_mow9w7d9"
-  fieldStatus: "radioField_mp1sngq1"
-  extAttrKeyUserType: ""
-  extAttrValueInternal: "内部"
-  extAttrValueExternal: "外部"
-  externalDeptIds: [1066052389]
-  fallbackInternalByDefault: true
-  extAttrKeyBeisen: "北森工号"
-  extAttrKeyCompany: "company"
-  extAttrKeyIsCf: "是否CF员工"
-  extAttrKeyCostCenter: "成本中心"
-  statusValueActive: "在职"
-  statusValueInactive: "离职"
-  limitFirstN: 0
-  concurrency: 10
-  maxRetry: 2