PersonnelSyncServiceImpl.java 46 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024
  1. package com.malk.service.personnel.impl;
  2. import com.alibaba.fastjson.JSON;
  3. import com.alibaba.fastjson.JSONObject;
  4. import com.google.common.util.concurrent.RateLimiter;
  5. import com.malk.server.aliwork.YDConf;
  6. import com.malk.server.aliwork.YDParam;
  7. import com.malk.server.dingtalk.DDR_New;
  8. import com.malk.server.personnel.PersonnelSyncConf;
  9. import com.malk.service.aliwork.YDClient;
  10. import com.malk.service.dingtalk.DDClient;
  11. import com.malk.service.dingtalk.DDClient_Contacts;
  12. import com.malk.service.personnel.PersonnelSyncService;
  13. import lombok.extern.slf4j.Slf4j;
  14. import org.springframework.beans.factory.annotation.Autowired;
  15. import org.springframework.stereotype.Service;
  16. import javax.annotation.PostConstruct;
  17. import java.time.LocalDate;
  18. import java.time.ZoneId;
  19. import java.util.ArrayList;
  20. import java.util.Arrays;
  21. import java.util.Collections;
  22. import java.util.HashMap;
  23. import java.util.HashSet;
  24. import java.util.LinkedHashMap;
  25. import java.util.List;
  26. import java.util.Map;
  27. import java.util.Objects;
  28. import java.util.Set;
  29. import java.util.concurrent.ExecutorService;
  30. import java.util.concurrent.Executors;
  31. import java.util.concurrent.Future;
  32. import java.util.concurrent.TimeUnit;
  33. import java.util.concurrent.atomic.AtomicInteger;
  34. @Slf4j
  35. @Service
  36. public class PersonnelSyncServiceImpl implements PersonnelSyncService {
  37. @Autowired
  38. private YDClient ydClient;
  39. @Autowired
  40. private DDClient ddClient;
  41. @Autowired
  42. private DDClient_Contacts ddClient_contacts;
  43. @Autowired
  44. private PersonnelSyncConf conf;
  45. /** 钉钉 user/get 限速器 (官方 60 QPS, 默认留 10 余量) */
  46. private RateLimiter ddRateLimiter;
  47. /** 宜搭写接口限速器 (防止并发线程合计超限) */
  48. private RateLimiter yidaRateLimiter;
  49. @PostConstruct
  50. private void initRateLimiters() {
  51. ddRateLimiter = RateLimiter.create(DD_API_QPS);
  52. yidaRateLimiter = RateLimiter.create(YIDA_API_QPS);
  53. }
  54. private static final String ACTION_CREATE = "CREATE";
  55. private static final String ACTION_UPDATE = "UPDATE";
  56. private static final String ACTION_MARK_OFF = "MARK_OFF";
  57. // 钉钉官方 QPS 上限 60, 留 10 余量; 宜搭写接口 10 并发需整体压低
  58. private static final double DD_API_QPS = 50.0;
  59. private static final double YIDA_API_QPS = 30.0;
  60. private static final ZoneId CST = ZoneId.of("Asia/Shanghai");
  61. /** 当天 CST 00:00 epoch 毫秒 (离职时间按天存储, 与应报工时日期格式对齐) */
  62. private static long todayCstStartMillis() {
  63. return LocalDate.now(CST).atStartOfDay(CST).toInstant().toEpochMilli();
  64. }
  65. @Override
  66. public Map<String, Object> fullSync(Integer limitOverride) {
  67. long start = System.currentTimeMillis();
  68. int limit = effectiveLimit(limitOverride);
  69. boolean limited = limit > 0;
  70. log.info("[PersonnelSync] 全量同步开始 limit={}", limited ? limit : "none");
  71. List<Map> dingUsers = fetchAllDingUsers();
  72. Map<String, Map> dingUserMap = applyLimit(indexByUserid(dingUsers), limit);
  73. enrichManagers(dingUserMap);
  74. log.info("[PersonnelSync] 钉钉拉取 {} 人 (去重后{})", dingUserMap.size(), limited ? ", 已截断前" + limit + "条" : "");
  75. Map<String, YidaRecord> yidaMap = fetchAllYidaPersonnel();
  76. log.info("[PersonnelSync] 宜搭人员档案 {} 条", yidaMap.size());
  77. if (limited) log.info("[PersonnelSync] limit 模式: 本轮跳过 MARK_OFF (拉取非全量)");
  78. List<Action> actions = diff(dingUserMap, yidaMap, limited);
  79. Map<String, Long> actionStats = countActions(actions);
  80. log.info("[PersonnelSync] diff 完成: create={}, update={}, markOff={}, skip={}",
  81. actionStats.getOrDefault(ACTION_CREATE, 0L),
  82. actionStats.getOrDefault(ACTION_UPDATE, 0L),
  83. actionStats.getOrDefault(ACTION_MARK_OFF, 0L),
  84. (long) dingUserMap.size() + yidaMap.size() - actions.size());
  85. WriteStats writeStats = concurrentWrite(actions);
  86. long cost = System.currentTimeMillis() - start;
  87. Map<String, Object> result = new LinkedHashMap<>();
  88. result.put("fetched", dingUserMap.size());
  89. result.put("yidaExisting", yidaMap.size());
  90. result.put("created", writeStats.created.get());
  91. result.put("updated", writeStats.updated.get());
  92. result.put("markedInactive", writeStats.markedInactive.get());
  93. result.put("failed", writeStats.failed.get());
  94. result.put("durationMs", cost);
  95. log.info("[PersonnelSync] 全量同步完成 {}", result);
  96. return result;
  97. }
  98. @Override
  99. public Map<String, Object> dryRun(Integer limitOverride) {
  100. long start = System.currentTimeMillis();
  101. int limit = effectiveLimit(limitOverride);
  102. boolean limited = limit > 0;
  103. List<Map> dingUsers = fetchAllDingUsers();
  104. Map<String, Map> dingUserMap = applyLimit(indexByUserid(dingUsers), limit);
  105. enrichManagers(dingUserMap);
  106. Map<String, YidaRecord> yidaMap = fetchAllYidaPersonnel();
  107. List<Action> actions = diff(dingUserMap, yidaMap, limited);
  108. Map<String, Long> stats = countActions(actions);
  109. Map<String, Object> result = new LinkedHashMap<>();
  110. result.put("fetched", dingUserMap.size());
  111. result.put("yidaExisting", yidaMap.size());
  112. Map<String, Object> actionCounts = new LinkedHashMap<>();
  113. actionCounts.put("create", stats.getOrDefault(ACTION_CREATE, 0L));
  114. actionCounts.put("update", stats.getOrDefault(ACTION_UPDATE, 0L));
  115. actionCounts.put("markOff", stats.getOrDefault(ACTION_MARK_OFF, 0L));
  116. result.put("actions", actionCounts);
  117. result.put("durationMs", System.currentTimeMillis() - start);
  118. // 抽样 10 条展示预期动作
  119. List<Map<String, Object>> sample = new ArrayList<>();
  120. for (int i = 0; i < Math.min(10, actions.size()); i++) {
  121. Action a = actions.get(i);
  122. Map<String, Object> s = new LinkedHashMap<>();
  123. s.put("action", a.type);
  124. s.put("userid", a.userid);
  125. s.put("formData", a.formData);
  126. sample.add(s);
  127. }
  128. result.put("sample", sample);
  129. return result;
  130. }
  131. @Override
  132. public Map<String, Object> probeDingtalkUsers(int sampleSize) {
  133. long start = System.currentTimeMillis();
  134. String token = ddClient.getAccessToken();
  135. List<Long> deptIds = ddClient_contacts.getDepartmentId_all(token, true);
  136. List<Map> users = ddClient_contacts.getAllUserDetails(token, true);
  137. Map<String, Map> byUserid = indexByUserid(users);
  138. Map<String, Object> result = new LinkedHashMap<>();
  139. result.put("total", byUserid.size());
  140. result.put("deptCount", deptIds.size());
  141. result.put("durationMs", System.currentTimeMillis() - start);
  142. List<Map> sample = new ArrayList<>();
  143. int n = Math.min(sampleSize <= 0 ? 3 : sampleSize, users.size());
  144. for (int i = 0; i < n; i++) sample.add(users.get(i));
  145. result.put("sample", sample);
  146. return result;
  147. }
  148. @Override
  149. public List<Map> fetchAllDingUsers() {
  150. String token = ddClient.getAccessToken();
  151. List<Map> users = new ArrayList<>(ddClient_contacts.getAllUserDetails(token, true));
  152. // fixme 外部部门(如 1066052389)不在组织树根部门 1 的递归子树内,getAllUserDetails 拉不到 → 显式补抓
  153. List<Long> extDepts = conf.getExternalDeptIds();
  154. if (extDepts != null && !extDepts.isEmpty()) {
  155. Set<String> seen = new HashSet<>();
  156. for (Map u : users) {
  157. Object uid = u.get("userid");
  158. if (uid != null) seen.add(String.valueOf(uid));
  159. }
  160. for (Long dept : extDepts) {
  161. if (dept == null) continue;
  162. try {
  163. List<Map> ext = ddClient_contacts.listDepartmentUserDetail_all(token, dept);
  164. int added = 0;
  165. for (Map u : ext) {
  166. Object uid = u.get("userid");
  167. if (uid != null && seen.add(String.valueOf(uid))) {
  168. users.add(u);
  169. added++;
  170. }
  171. }
  172. log.info("[PersonnelSync] 补抓外部部门 dept={} 新增 {} 人 (该部门共 {})", dept, added, ext.size());
  173. } catch (Exception ex) {
  174. log.warn("[PersonnelSync] 抓外部部门失败 dept={} err={}", dept, ex.getMessage());
  175. }
  176. }
  177. }
  178. return users;
  179. }
  180. @Override
  181. public Map probeSingleUser(String userid) {
  182. String token = ddClient.getAccessToken();
  183. return ddClient_contacts.getUserInfoById(token, userid);
  184. }
  185. @SuppressWarnings("unchecked")
  186. public Map<String, Object> probeDiff(String userid) {
  187. Map<String, Object> r = new LinkedHashMap<>();
  188. // 钉钉
  189. String token = ddClient.getAccessToken();
  190. Map ding = ddClient_contacts.getUserInfoById(token, userid);
  191. r.put("dingUser", ding);
  192. // 宜搭
  193. DDR_New result = ydClient.queryData(YDParam.builder()
  194. .appType(conf.getYidaAppType())
  195. .systemToken(conf.getYidaSystemToken())
  196. .formUuid(conf.getFormUuidPersonnel())
  197. .searchFieldJson("{\"" + conf.getFieldEmployee() + "\":\"" + userid + "\"}")
  198. .build(), YDConf.FORM_QUERY.retrieve_search_form);
  199. List<Map> list = (List<Map>) result.getData();
  200. Map<String, Object> yidaFormData = (list != null && !list.isEmpty()) ? (Map<String, Object>) list.get(0).get("formData") : null;
  201. r.put("yidaFormData", yidaFormData);
  202. // 新 formData (在职判定改为存在性, probe 统一用 UPDATE)
  203. Map<String, Object> newData = toYidaFormData(userid, ding, ACTION_UPDATE);
  204. r.put("newFormData", newData);
  205. // 字段对比
  206. Map<String, Object> diff = new LinkedHashMap<>();
  207. if (yidaFormData != null) {
  208. for (Map.Entry<String, Object> e : newData.entrySet()) {
  209. String f = e.getKey();
  210. Object nv = e.getValue();
  211. Object ov = yidaFormData.get(f);
  212. Object ovId = yidaFormData.get(f + "_id");
  213. Map<String, Object> d = new LinkedHashMap<>();
  214. d.put("new", nv);
  215. d.put("newClass", nv == null ? null : nv.getClass().getSimpleName());
  216. d.put("old", ov);
  217. d.put("oldClass", ov == null ? null : ov.getClass().getSimpleName());
  218. d.put("oldId", ovId);
  219. d.put("oldIdClass", ovId == null ? null : ovId.getClass().getSimpleName());
  220. diff.put(f, d);
  221. }
  222. }
  223. r.put("diff", diff);
  224. return r;
  225. }
  226. @SuppressWarnings("unchecked")
  227. @Override
  228. public Map<String, Object> probeYidaByUserid(String userid) {
  229. Map<String, Object> r = new LinkedHashMap<>();
  230. r.put("userid", userid);
  231. DDR_New result = ydClient.queryData(YDParam.builder()
  232. .appType(conf.getYidaAppType())
  233. .systemToken(conf.getYidaSystemToken())
  234. .formUuid(conf.getFormUuidPersonnel())
  235. .searchFieldJson("{\"" + conf.getFieldEmployee() + "\":\"" + userid + "\"}")
  236. .pageSize(YDConf.PAGE_SIZE_LIMIT)
  237. .build(), YDConf.FORM_QUERY.retrieve_search_form);
  238. List<Map> list = (List<Map>) result.getData();
  239. r.put("count", list == null ? 0 : list.size());
  240. List<Map<String, Object>> records = new ArrayList<>();
  241. if (list != null) {
  242. for (Map item : list) {
  243. Map<String, Object> rec = new LinkedHashMap<>();
  244. rec.put("instanceId", item.get("formInstanceId"));
  245. rec.put("createTime", item.get("gmtCreate"));
  246. rec.put("modifyTime", item.get("gmtModified"));
  247. rec.put("creator", item.get("creator"));
  248. rec.put("formData", item.get("formData"));
  249. records.add(rec);
  250. }
  251. }
  252. r.put("records", records);
  253. return r;
  254. }
  255. @SuppressWarnings("unchecked")
  256. @Override
  257. public Map<String, Object> probeYidaDuplicates() {
  258. long start = System.currentTimeMillis();
  259. Map<String, List<Map<String, Object>>> byUserid = new LinkedHashMap<>();
  260. List<Map<String, Object>> emptyEmployee = new ArrayList<>();
  261. int currentPage = 1;
  262. long totalCount;
  263. int total = 0;
  264. do {
  265. DDR_New result = ydClient.queryData(YDParam.builder()
  266. .appType(conf.getYidaAppType())
  267. .systemToken(conf.getYidaSystemToken())
  268. .formUuid(conf.getFormUuidPersonnel())
  269. .currentPage(currentPage)
  270. .pageSize(YDConf.PAGE_SIZE_LIMIT)
  271. .build(), YDConf.FORM_QUERY.retrieve_search_form);
  272. totalCount = result.getTotalCount();
  273. List<Map> dataList = (List<Map>) result.getData();
  274. if (dataList == null || dataList.isEmpty()) break;
  275. for (Map item : dataList) {
  276. total++;
  277. Map<String, Object> formData = (Map<String, Object>) item.get("formData");
  278. String uid = formData == null ? null : extractEmployeeId(formData, conf.getFieldEmployee());
  279. Map<String, Object> brief = new LinkedHashMap<>();
  280. brief.put("instanceId", item.get("formInstanceId"));
  281. brief.put("createTime", item.get("gmtCreate"));
  282. brief.put("modifyTime", item.get("gmtModified"));
  283. if (formData != null) {
  284. brief.put("name", formData.get(conf.getFieldName()));
  285. brief.put("status", formData.get(conf.getFieldStatus()));
  286. brief.put("userType", formData.get(conf.getFieldUserType()));
  287. }
  288. if (uid == null || uid.isEmpty()) {
  289. emptyEmployee.add(brief);
  290. } else {
  291. byUserid.computeIfAbsent(uid, k -> new ArrayList<>()).add(brief);
  292. }
  293. }
  294. currentPage++;
  295. } while ((long) (currentPage - 1) * YDConf.PAGE_SIZE_LIMIT < totalCount);
  296. List<Map<String, Object>> dups = new ArrayList<>();
  297. for (Map.Entry<String, List<Map<String, Object>>> e : byUserid.entrySet()) {
  298. if (e.getValue().size() > 1) {
  299. Map<String, Object> g = new LinkedHashMap<>();
  300. g.put("userid", e.getKey());
  301. g.put("count", e.getValue().size());
  302. g.put("records", e.getValue());
  303. dups.add(g);
  304. }
  305. }
  306. Map<String, Object> r = new LinkedHashMap<>();
  307. r.put("totalRecords", total);
  308. r.put("uniqueUseridCount", byUserid.size());
  309. r.put("emptyEmployeeCount", emptyEmployee.size());
  310. r.put("duplicateGroupCount", dups.size());
  311. r.put("duplicateGroups", dups);
  312. r.put("emptyEmployeeRecords", emptyEmployee);
  313. r.put("durationMs", System.currentTimeMillis() - start);
  314. log.info("[PersonnelSync] probeYidaDuplicates total={} unique={} empty={} duplicateGroups={}",
  315. total, byUserid.size(), emptyEmployee.size(), dups.size());
  316. return r;
  317. }
  318. /**
  319. * 一次性清理重复条(本期任务):
  320. * - jingzhao: 直接删后建条 FHC66571O325JF0LK0EB54NKPOZV3YWZQ98OMOG7
  321. * - 626967876(吴超): 把同步条 4XC66W81HA43...的钉钉字段 merge 到业务条 XRD66E7127A5... 再删同步条
  322. * 钉钉字段以同步条为准:属性/员工编号/归属公司/Manager/是否CF/成本中心/入职时间/在职状态
  323. */
  324. @Override
  325. public Map<String, Object> cleanupKnownDuplicatesOnce(boolean dryRun) {
  326. long start = System.currentTimeMillis();
  327. Map<String, Object> r = new LinkedHashMap<>();
  328. r.put("dryRun", dryRun);
  329. List<Map<String, Object>> actions = new ArrayList<>();
  330. // ===== Group A: jingzhao =====
  331. String jingzhaoDeleteId = "FINST-FHC66571O325JF0LK0EB54NKPOZV3YWZQ98OMOG7";
  332. Map<String, Object> aDel = new LinkedHashMap<>();
  333. aDel.put("type", "DELETE");
  334. aDel.put("userid", "jingzhao");
  335. aDel.put("instanceId", jingzhaoDeleteId);
  336. aDel.put("reason", "保留早建条 4XC66W81C9Z40Q...; 删除后建空字段重复条");
  337. if (!dryRun) {
  338. try {
  339. ydClient.operateData(YDParam.builder()
  340. .appType(conf.getYidaAppType())
  341. .systemToken(conf.getYidaSystemToken())
  342. .formUuid(conf.getFormUuidPersonnel())
  343. .formInstanceId(jingzhaoDeleteId)
  344. .build(), YDConf.FORM_OPERATION.delete);
  345. aDel.put("ok", true);
  346. } catch (Exception ex) {
  347. aDel.put("ok", false);
  348. aDel.put("err", ex.getMessage());
  349. }
  350. }
  351. actions.add(aDel);
  352. // ===== Group B: 626967876 (吴超) merge then delete =====
  353. String wuchaoKeepId = "FINST-XRD66E7127A51G39GBQX1AG83I8525SWNWKOMCZB";
  354. String wuchaoDeleteId = "FINST-4XC66W81HA43S9G1H3ELL664BSEN23DTEE4NMS703";
  355. Map<String, Object> mergeFields = new LinkedHashMap<>();
  356. // 员工编号 textField_mh8xhqc1 = userid 字符串(同步代码 v1.2 后规范)
  357. mergeFields.put(conf.getFieldJobNumber(), "626967876");
  358. // 属性: 内部 (钉钉 dept_id=[1057430958] 内部部门, 同步条已写"内部")
  359. mergeFields.put(conf.getFieldUserType(), conf.getExtAttrValueInternal());
  360. // 归属公司: 上海 (同步条值)
  361. mergeFields.put(conf.getFieldCompany(), "上海");
  362. // Manager: Kevin Xu (640891109) 数组格式
  363. mergeFields.put(conf.getFieldManager(), Collections.singletonList("640891109"));
  364. // 是否CF: false (同步条值)
  365. mergeFields.put(conf.getFieldIsCf(), "false");
  366. // 成本中心: 35 (同步条值)
  367. mergeFields.put(conf.getFieldCostCenter(), "35");
  368. // 入职时间: 1716134400000 (同步条值)
  369. mergeFields.put(conf.getFieldHiredDate(), 1716134400000L);
  370. // 在职状态: 在职
  371. mergeFields.put(conf.getFieldStatus(), conf.getStatusValueActive());
  372. // 部门: 1057430958 (钉钉同步主部门)
  373. mergeFields.put(conf.getFieldDepartment(), Collections.singletonList("1057430958"));
  374. Map<String, Object> bUpd = new LinkedHashMap<>();
  375. bUpd.put("type", "UPDATE_MERGE");
  376. bUpd.put("userid", "626967876");
  377. bUpd.put("instanceId", wuchaoKeepId);
  378. bUpd.put("mergeFields", mergeFields);
  379. bUpd.put("note", "钉钉字段贴到业务条,业务字段(客户类型/合同号/numberField/北森编号)保留不动");
  380. if (!dryRun) {
  381. try {
  382. ydClient.operateData(YDParam.builder()
  383. .appType(conf.getYidaAppType())
  384. .systemToken(conf.getYidaSystemToken())
  385. .formUuid(conf.getFormUuidPersonnel())
  386. .formInstanceId(wuchaoKeepId)
  387. .updateFormDataJson(JSON.toJSONString(mergeFields))
  388. .ignoreEmpty(false)
  389. .useLatestVersion(true)
  390. .build(), YDConf.FORM_OPERATION.update);
  391. bUpd.put("ok", true);
  392. } catch (Exception ex) {
  393. bUpd.put("ok", false);
  394. bUpd.put("err", ex.getMessage());
  395. }
  396. }
  397. actions.add(bUpd);
  398. Map<String, Object> bDel = new LinkedHashMap<>();
  399. bDel.put("type", "DELETE");
  400. bDel.put("userid", "626967876");
  401. bDel.put("instanceId", wuchaoDeleteId);
  402. bDel.put("reason", "同步条信息已 merge 到业务条 XRD66E7127A5...");
  403. if (!dryRun) {
  404. try {
  405. ydClient.operateData(YDParam.builder()
  406. .appType(conf.getYidaAppType())
  407. .systemToken(conf.getYidaSystemToken())
  408. .formUuid(conf.getFormUuidPersonnel())
  409. .formInstanceId(wuchaoDeleteId)
  410. .build(), YDConf.FORM_OPERATION.delete);
  411. bDel.put("ok", true);
  412. } catch (Exception ex) {
  413. bDel.put("ok", false);
  414. bDel.put("err", ex.getMessage());
  415. }
  416. }
  417. actions.add(bDel);
  418. r.put("actions", actions);
  419. r.put("durationMs", System.currentTimeMillis() - start);
  420. log.info("[PersonnelSync] cleanupKnownDuplicatesOnce dryRun={} result={}", dryRun, r);
  421. return r;
  422. }
  423. @SuppressWarnings("unchecked")
  424. @Override
  425. public Map<String, Object> syncSingle(String userid) {
  426. long start = System.currentTimeMillis();
  427. Map<String, Object> result = new LinkedHashMap<>();
  428. result.put("userid", userid);
  429. // 1. 钉钉取这个人
  430. String token = ddClient.getAccessToken();
  431. Map ding = ddClient_contacts.getUserInfoById(token, userid);
  432. if (ding == null || ding.get("userid") == null) {
  433. result.put("action", "NOT_FOUND");
  434. result.put("durationMs", System.currentTimeMillis() - start);
  435. log.warn("[PersonnelSync] syncSingle 钉钉查无此人 userid={}", userid);
  436. return result;
  437. }
  438. // 2. 宜搭按 userid 查现有记录
  439. DDR_New q = ydClient.queryData(YDParam.builder()
  440. .appType(conf.getYidaAppType())
  441. .systemToken(conf.getYidaSystemToken())
  442. .formUuid(conf.getFormUuidPersonnel())
  443. .searchFieldJson("{\"" + conf.getFieldEmployee() + "\":\"" + userid + "\"}")
  444. .build(), YDConf.FORM_QUERY.retrieve_search_form);
  445. List<Map> list = (List<Map>) q.getData();
  446. String instanceId = null;
  447. Map<String, Object> existFormData = null;
  448. if (list != null && !list.isEmpty()) {
  449. instanceId = String.valueOf(list.get(0).get("formInstanceId"));
  450. existFormData = (Map<String, Object>) list.get(0).get("formData");
  451. }
  452. // 3. 构造 formData 并选 action
  453. String action = (instanceId == null) ? ACTION_CREATE : ACTION_UPDATE;
  454. Map<String, Object> formData = toYidaFormData(userid, ding, action);
  455. if (ACTION_UPDATE.equals(action) && isSameAsYida(formData, existFormData)) {
  456. result.put("action", "SKIP");
  457. result.put("instanceId", instanceId);
  458. result.put("formData", formData);
  459. result.put("durationMs", System.currentTimeMillis() - start);
  460. log.info("[PersonnelSync] syncSingle 幂等跳过 userid={}", userid);
  461. return result;
  462. }
  463. // 4. 写入 (复用 executeAction 的指数退避 + 限速)
  464. WriteStats stats = new WriteStats();
  465. executeAction(new Action(action, userid, instanceId, formData), stats);
  466. result.put("action", action);
  467. result.put("instanceId", instanceId);
  468. result.put("formData", formData);
  469. result.put("created", stats.created.get());
  470. result.put("updated", stats.updated.get());
  471. result.put("failed", stats.failed.get());
  472. result.put("durationMs", System.currentTimeMillis() - start);
  473. log.info("[PersonnelSync] syncSingle 完成 userid={} action={} result={}", userid, action, result);
  474. return result;
  475. }
  476. @Override
  477. public Map<String, Object> probeStats() {
  478. long start = System.currentTimeMillis();
  479. List<Map> users = fetchAllDingUsers();
  480. int total = users.size();
  481. int active = 0, inactive = 0, emptyDept = 0, hasExtattr = 0, emptyJobNumber = 0;
  482. for (Map u : users) {
  483. if (isActive(u)) active++; else inactive++;
  484. Object dept = u.get("dept_id_list");
  485. if (!(dept instanceof List) || ((List) dept).isEmpty()) emptyDept++;
  486. Object ext = u.get("extattr");
  487. if (ext instanceof Map && !((Map) ext).isEmpty()) hasExtattr++;
  488. Object job = u.get("job_number");
  489. if (job == null || String.valueOf(job).isEmpty()) emptyJobNumber++;
  490. }
  491. Map<String, Object> res = new LinkedHashMap<>();
  492. res.put("total", total);
  493. res.put("active", active);
  494. res.put("inactive", inactive);
  495. res.put("emptyDeptIdList", emptyDept);
  496. res.put("hasExtattr", hasExtattr);
  497. res.put("emptyJobNumber", emptyJobNumber);
  498. res.put("durationMs", System.currentTimeMillis() - start);
  499. return res;
  500. }
  501. // ==================== 内部: 数据抓取 ====================
  502. @SuppressWarnings("unchecked")
  503. private Map<String, YidaRecord> fetchAllYidaPersonnel() {
  504. Map<String, YidaRecord> yidaMap = new LinkedHashMap<>();
  505. int currentPage = 1;
  506. long totalCount;
  507. do {
  508. DDR_New result = ydClient.queryData(YDParam.builder()
  509. .appType(conf.getYidaAppType())
  510. .systemToken(conf.getYidaSystemToken())
  511. .formUuid(conf.getFormUuidPersonnel())
  512. .currentPage(currentPage)
  513. .pageSize(YDConf.PAGE_SIZE_LIMIT)
  514. .build(), YDConf.FORM_QUERY.retrieve_search_form);
  515. totalCount = result.getTotalCount();
  516. List<Map> dataList = (List<Map>) result.getData();
  517. if (dataList == null || dataList.isEmpty()) break;
  518. for (Map item : dataList) {
  519. Map<String, Object> formData = (Map<String, Object>) item.get("formData");
  520. if (formData == null) continue;
  521. String userid = extractEmployeeId(formData, conf.getFieldEmployee());
  522. if (userid == null || userid.isEmpty()) continue;
  523. YidaRecord rec = new YidaRecord();
  524. rec.instanceId = String.valueOf(item.get("formInstanceId"));
  525. rec.formData = formData;
  526. // fixme 同 userid 重复条防御: 保留早建条 (按 instanceId 字典序较小者) 并 WARN, 避免静默覆盖
  527. YidaRecord exist = yidaMap.get(userid);
  528. if (exist != null) {
  529. log.warn("[PersonnelSync] 宜搭检测到重复 userid={} 已扫到 instanceId={} 又扫到 instanceId={}",
  530. userid, exist.instanceId, rec.instanceId);
  531. if (exist.instanceId != null && rec.instanceId != null
  532. && rec.instanceId.compareTo(exist.instanceId) > 0) {
  533. continue; // 已有的更早建,保留它
  534. }
  535. }
  536. yidaMap.put(userid, rec);
  537. }
  538. currentPage++;
  539. } while ((long) (currentPage - 1) * YDConf.PAGE_SIZE_LIMIT < totalCount);
  540. return yidaMap;
  541. }
  542. // ==================== 内部: 差异计算 ====================
  543. private List<Action> diff(Map<String, Map> dingUserMap, Map<String, YidaRecord> yidaMap, boolean skipMarkOff) {
  544. List<Action> actions = new ArrayList<>();
  545. // 钉钉里查到的 -> 一律按"在职"写 (active 布尔不再参与)
  546. for (Map.Entry<String, Map> e : dingUserMap.entrySet()) {
  547. String userid = e.getKey();
  548. Map ding = e.getValue();
  549. YidaRecord yida = yidaMap.get(userid);
  550. if (yida == null) {
  551. actions.add(new Action(ACTION_CREATE, userid, null, toYidaFormData(userid, ding, ACTION_CREATE)));
  552. } else {
  553. Map<String, Object> formData = toYidaFormData(userid, ding, ACTION_UPDATE);
  554. if (isSameAsYida(formData, yida.formData)) continue; // 幂等跳过
  555. actions.add(new Action(ACTION_UPDATE, userid, yida.instanceId, formData));
  556. }
  557. }
  558. // 宜搭里有、钉钉里没有 -> 标记离职 (limit 模式下拉取非全量, 跳过此步)
  559. // ppExt 离职时间: 首次判定离职 status+offlineDate 一起写; 已"离职"但 offlineDate 空 -> 补写(当天);
  560. // 已有 offlineDate 且状态一致 -> 完全跳过, 保护首次判定日不被覆盖
  561. if (!skipMarkOff) {
  562. long todayMs = todayCstStartMillis();
  563. boolean writeOfflineDate = notBlank(conf.getFieldOfflineDate());
  564. for (Map.Entry<String, YidaRecord> e : yidaMap.entrySet()) {
  565. if (dingUserMap.containsKey(e.getKey())) continue;
  566. YidaRecord yida = e.getValue();
  567. Object currentStatus = yida.formData.get(conf.getFieldStatus());
  568. boolean alreadyInactive = conf.getStatusValueInactive().equals(String.valueOf(currentStatus));
  569. boolean offlineDateBlank = writeOfflineDate && isBlankValue(yida.formData.get(conf.getFieldOfflineDate()));
  570. if (alreadyInactive && (!writeOfflineDate || !offlineDateBlank)) continue; // 全部就绪, 跳过
  571. Map<String, Object> formData = new LinkedHashMap<>();
  572. if (!alreadyInactive) {
  573. formData.put(conf.getFieldStatus(), conf.getStatusValueInactive());
  574. }
  575. if (writeOfflineDate && offlineDateBlank) {
  576. formData.put(conf.getFieldOfflineDate(), todayMs);
  577. }
  578. if (formData.isEmpty()) continue; // 防御: 无字段变化不写入
  579. actions.add(new Action(ACTION_MARK_OFF, e.getKey(), yida.instanceId, formData));
  580. }
  581. }
  582. return actions;
  583. }
  584. /** dateField 在 formData 里可能是 Long 时间戳或 "yyyy-MM-dd HH:mm:ss" 字符串, 空/空串/字面 "null" 一律视为空 */
  585. private boolean isBlankValue(Object v) {
  586. if (v == null) return true;
  587. String s = String.valueOf(v).trim();
  588. return s.isEmpty() || "null".equalsIgnoreCase(s);
  589. }
  590. // ==================== 内部: 字段映射 ====================
  591. @SuppressWarnings("unchecked")
  592. private Map<String, Object> toYidaFormData(String userid, Map ding, String action) {
  593. Map<String, Object> formData = new LinkedHashMap<>();
  594. // 人员 (唯一键, 永远写入)
  595. formData.put(conf.getFieldEmployee(), Collections.singletonList(userid));
  596. // 在职状态
  597. String statusValue = ACTION_MARK_OFF.equals(action) ? conf.getStatusValueInactive() : conf.getStatusValueActive();
  598. formData.put(conf.getFieldStatus(), statusValue);
  599. // 离职软标记只更新状态字段, 保留其他原值
  600. if (ACTION_MARK_OFF.equals(action)) return formData;
  601. // 员工姓名 <- name (目标表该字段 READONLY, 仍按需求强写覆盖)
  602. Object name = ding.get("name");
  603. if (notBlank(conf.getFieldName()) && name != null && notBlank(String.valueOf(name))) {
  604. formData.put(conf.getFieldName(), String.valueOf(name).trim());
  605. }
  606. // 员工编号 <- userid (钉钉用户唯一 ID, 与人员 EmployeeField 同源, 但写入 TextField 便于跨模块按字符串引用)
  607. if (notBlank(conf.getFieldJobNumber())) {
  608. formData.put(conf.getFieldJobNumber(), userid);
  609. }
  610. // 员工工号 <- job_number (目标表 READONLY, 按需求强写覆盖; 空则跳过)
  611. Object jobNumber = ding.get("job_number");
  612. if (notBlank(conf.getFieldJobNumber2()) && jobNumber != null && notBlank(String.valueOf(jobNumber))) {
  613. formData.put(conf.getFieldJobNumber2(), String.valueOf(jobNumber).trim());
  614. }
  615. // 员工部门 <- dept_id_list 稳定排序后取首个 (钉钉返回顺序不固定, 避免 diff 抖动)
  616. Object deptObj = ding.get("dept_id_list");
  617. if (deptObj instanceof List && !((List) deptObj).isEmpty()) {
  618. List<Long> sorted = new ArrayList<>();
  619. for (Object d : (List) deptObj) {
  620. if (d != null) sorted.add(((Number) d).longValue());
  621. }
  622. Collections.sort(sorted);
  623. if (!sorted.isEmpty()) {
  624. formData.put(conf.getFieldDepartment(), Collections.singletonList(String.valueOf(sorted.get(0))));
  625. }
  626. }
  627. // 入职时间 <- hired_date (毫秒时间戳, 需要钉钉花名册权限才返回)
  628. if (notBlank(conf.getFieldHiredDate())) {
  629. Object hired = ding.get("hired_date");
  630. if (hired instanceof Number) {
  631. formData.put(conf.getFieldHiredDate(), ((Number) hired).longValue());
  632. }
  633. }
  634. // Manager <- manager_userid (EmployeeField, 数组格式)
  635. Object mgr = ding.get("manager_userid");
  636. if (notBlank(conf.getFieldManager()) && mgr != null && notBlank(String.valueOf(mgr))) {
  637. formData.put(conf.getFieldManager(), Collections.singletonList(String.valueOf(mgr).trim()));
  638. }
  639. // 北森编号 / 归属公司 / 是否CF / 成本中心 <- 钉钉 extattr 自定义字段
  640. putExtAttr(formData, ding, conf.getFieldBeisenJobNo(), conf.getExtAttrKeyBeisen());
  641. putExtAttr(formData, ding, conf.getFieldCompany(), conf.getExtAttrKeyCompany());
  642. putExtAttr(formData, ding, conf.getFieldIsCf(), conf.getExtAttrKeyIsCf());
  643. putExtAttr(formData, ding, conf.getFieldCostCenter(), conf.getExtAttrKeyCostCenter());
  644. // 属性 <- 部门含 externalDeptIds ? 外部 : 内部 (extAttrKeyUserType 留空时走部门白名单)
  645. String userType = resolveUserType(ding);
  646. if (userType != null) {
  647. formData.put(conf.getFieldUserType(), userType);
  648. }
  649. return formData;
  650. }
  651. /** extattr 自定义字段取值并写入 (fieldId 或 extKey 为空 / 取不到值 则不写, 保留宜搭原值) */
  652. private void putExtAttr(Map<String, Object> formData, Map ding, String fieldId, String extKey) {
  653. if (!notBlank(fieldId) || !notBlank(extKey)) return;
  654. String v = readExtAttr(ding, extKey);
  655. if (notBlank(v)) formData.put(fieldId, v.trim());
  656. }
  657. /**
  658. * 读钉钉自定义字段[key]:
  659. * - topapi/v2/user/list & user/get 把自定义字段放在 extension (JSON 字符串 {key: value}) 里
  660. * - 兼容旧式 extattr (Map, 值可能是纯字符串或 {text, value} 枚举)
  661. */
  662. @SuppressWarnings("unchecked")
  663. private String readExtAttr(Map ding, String key) {
  664. Object ext = ding.get("extension");
  665. if (ext instanceof String && !((String) ext).trim().isEmpty()) {
  666. try {
  667. Object parsed = JSON.parse((String) ext);
  668. if (parsed instanceof Map) {
  669. Object v = ((Map) parsed).get(key);
  670. if (v != null && !String.valueOf(v).trim().isEmpty()) return String.valueOf(v);
  671. }
  672. } catch (Exception ignored) {}
  673. }
  674. Object extattr = ding.get("extattr");
  675. if (extattr instanceof Map) {
  676. Object attr = ((Map) extattr).get(key);
  677. if (attr instanceof Map) {
  678. Map am = (Map) attr;
  679. Object v = am.get("value");
  680. if (v == null) v = am.get("text");
  681. return v == null ? null : String.valueOf(v);
  682. } else if (attr != null && !String.valueOf(attr).trim().isEmpty()) {
  683. return String.valueOf(attr);
  684. }
  685. }
  686. return null;
  687. }
  688. private boolean notBlank(String s) {
  689. return s != null && !s.trim().isEmpty();
  690. }
  691. @SuppressWarnings("unchecked")
  692. private String resolveUserType(Map ding) {
  693. // 1. 优先读 extattr
  694. String key = conf.getExtAttrKeyUserType();
  695. if (key != null && !key.isEmpty()) {
  696. Object extattrObj = ding.get("extattr");
  697. if (extattrObj instanceof Map) {
  698. Map extattr = (Map) extattrObj;
  699. Object attr = extattr.get(key);
  700. String raw = null;
  701. if (attr instanceof Map) {
  702. Map am = (Map) attr;
  703. Object value = am.get("value");
  704. if (value == null) value = am.get("text");
  705. if (value != null) raw = String.valueOf(value);
  706. } else if (attr != null) {
  707. raw = String.valueOf(attr);
  708. }
  709. if (raw != null && !raw.isEmpty()) {
  710. if (conf.getExtAttrValueInternal().equals(raw)) return conf.getExtAttrValueInternal();
  711. if (conf.getExtAttrValueExternal().equals(raw)) return conf.getExtAttrValueExternal();
  712. }
  713. }
  714. }
  715. // 2. 兜底: 部门白名单 (外部部门列表 + 默认规则)
  716. List<Long> externalDepts = conf.getExternalDeptIds();
  717. Object deptObj = ding.get("dept_id_list");
  718. if (deptObj instanceof List && !((List) deptObj).isEmpty()) {
  719. if (externalDepts != null && !externalDepts.isEmpty()) {
  720. boolean isExternal = false;
  721. for (Object d : (List) deptObj) {
  722. if (d == null) continue;
  723. long deptId = ((Number) d).longValue();
  724. if (externalDepts.contains(deptId)) { isExternal = true; break; }
  725. }
  726. return isExternal ? conf.getExtAttrValueExternal() : conf.getExtAttrValueInternal();
  727. }
  728. if (conf.isFallbackInternalByDefault()) {
  729. return conf.getExtAttrValueInternal();
  730. }
  731. }
  732. return null;
  733. }
  734. private boolean isActive(Map ding) {
  735. Object active = ding.get("active");
  736. if (active instanceof Boolean) return (Boolean) active;
  737. if (active == null) return true;
  738. return Boolean.parseBoolean(String.valueOf(active));
  739. }
  740. // 比较钉钉侧构造的 formData 与宜搭已有 formData 是否所有字段都相等
  741. @SuppressWarnings("unchecked")
  742. private boolean isSameAsYida(Map<String, Object> newData, Map<String, Object> yidaData) {
  743. for (Map.Entry<String, Object> e : newData.entrySet()) {
  744. String fieldId = e.getKey();
  745. Object newVal = e.getValue();
  746. Object oldVal = yidaData.get(fieldId);
  747. if (newVal instanceof List) {
  748. // 成员/部门类字段: 宜搭返回时用 _id 后缀取 id 列表
  749. Object oldIdList = yidaData.get(fieldId + "_id");
  750. if (oldIdList != null) oldVal = oldIdList;
  751. if (!listEquals((List) newVal, oldVal)) return false;
  752. } else {
  753. if (!Objects.equals(String.valueOf(newVal), String.valueOf(oldVal))) return false;
  754. }
  755. }
  756. return true;
  757. }
  758. @SuppressWarnings("unchecked")
  759. private boolean listEquals(List newList, Object oldObj) {
  760. List<String> newStr = new ArrayList<>();
  761. for (Object o : newList) newStr.add(String.valueOf(o));
  762. List<String> oldStr = new ArrayList<>();
  763. if (oldObj instanceof List) {
  764. for (Object o : (List) oldObj) oldStr.add(String.valueOf(o));
  765. } else if (oldObj != null) {
  766. oldStr.add(String.valueOf(oldObj));
  767. }
  768. if (newStr.size() != oldStr.size()) return false;
  769. for (String s : newStr) if (!oldStr.contains(s)) return false;
  770. return true;
  771. }
  772. // ==================== 内部: 写入 ====================
  773. private WriteStats concurrentWrite(List<Action> actions) {
  774. WriteStats stats = new WriteStats();
  775. if (actions.isEmpty()) return stats;
  776. ExecutorService pool = Executors.newFixedThreadPool(Math.max(1, conf.getConcurrency()));
  777. List<Future<?>> futures = new ArrayList<>();
  778. for (Action a : actions) {
  779. futures.add(pool.submit(() -> executeAction(a, stats)));
  780. }
  781. for (Future<?> f : futures) {
  782. try { f.get(); } catch (Exception ex) { log.warn("[PersonnelSync] future 异常", ex); }
  783. }
  784. pool.shutdown();
  785. try { pool.awaitTermination(10, TimeUnit.MINUTES); } catch (InterruptedException ignored) {}
  786. return stats;
  787. }
  788. private void executeAction(Action a, WriteStats stats) {
  789. int attempt = 0;
  790. while (true) {
  791. // 指数退避: 第 1 次不等, 之后 1s/2s/4s... 最长 8s
  792. if (attempt > 0) {
  793. long backoffMs = Math.min(1000L * (1L << (attempt - 1)), 8000L);
  794. try {
  795. Thread.sleep(backoffMs);
  796. } catch (InterruptedException ie) {
  797. Thread.currentThread().interrupt();
  798. stats.failed.incrementAndGet();
  799. return;
  800. }
  801. }
  802. yidaRateLimiter.acquire();
  803. try {
  804. if (ACTION_CREATE.equals(a.type)) {
  805. ydClient.operateData(YDParam.builder()
  806. .appType(conf.getYidaAppType())
  807. .systemToken(conf.getYidaSystemToken())
  808. .formUuid(conf.getFormUuidPersonnel())
  809. .formDataJson(JSON.toJSONString(a.formData))
  810. .build(), YDConf.FORM_OPERATION.create);
  811. stats.created.incrementAndGet();
  812. } else {
  813. ydClient.operateData(YDParam.builder()
  814. .appType(conf.getYidaAppType())
  815. .systemToken(conf.getYidaSystemToken())
  816. .formUuid(conf.getFormUuidPersonnel())
  817. .formInstanceId(a.instanceId)
  818. .updateFormDataJson(JSON.toJSONString(a.formData))
  819. .ignoreEmpty(false)
  820. .useLatestVersion(true)
  821. .build(), YDConf.FORM_OPERATION.update);
  822. if (ACTION_MARK_OFF.equals(a.type)) {
  823. stats.markedInactive.incrementAndGet();
  824. } else {
  825. stats.updated.incrementAndGet();
  826. }
  827. }
  828. return;
  829. } catch (Exception ex) {
  830. attempt++;
  831. if (attempt > conf.getMaxRetry()) {
  832. log.warn("[PersonnelSync] 写入失败 userid={} action={} err={}", a.userid, a.type, ex.getMessage());
  833. stats.failed.incrementAndGet();
  834. return;
  835. }
  836. log.info("[PersonnelSync] 写入重试 userid={} action={} attempt={} err={}", a.userid, a.type, attempt, ex.getMessage());
  837. }
  838. }
  839. }
  840. // ==================== 内部: 工具 ====================
  841. @SuppressWarnings("unchecked")
  842. private String extractEmployeeId(Map<String, Object> formData, String fieldId) {
  843. Object raw = formData.get(fieldId + "_id");
  844. if (raw == null) raw = formData.get(fieldId);
  845. if (raw == null) return null;
  846. if (raw instanceof List) {
  847. List list = (List) raw;
  848. return list.isEmpty() ? null : String.valueOf(list.get(0));
  849. }
  850. String str = String.valueOf(raw).trim();
  851. if (str.startsWith("[") && str.endsWith("]")) {
  852. Object parsed = JSON.parse(str);
  853. if (parsed instanceof List) {
  854. List pl = (List) parsed;
  855. return pl.isEmpty() ? null : String.valueOf(pl.get(0));
  856. }
  857. }
  858. return str.isEmpty() ? null : str;
  859. }
  860. private Map<String, Map> indexByUserid(List<Map> users) {
  861. Map<String, Map> map = new LinkedHashMap<>();
  862. for (Map u : users) {
  863. Object uid = u.get("userid");
  864. if (uid != null) map.putIfAbsent(String.valueOf(uid), u);
  865. }
  866. return map;
  867. }
  868. /** 解析生效的 limit: 入参优先, 否则用配置 limitFirstN; <=0 表示不限 */
  869. private int effectiveLimit(Integer override) {
  870. if (override != null) return Math.max(0, override);
  871. return Math.max(0, conf.getLimitFirstN());
  872. }
  873. /** limit>0 时按 userid 升序取前 N 条 (排序保证幂等可复现) */
  874. private Map<String, Map> applyLimit(Map<String, Map> byUserid, int limit) {
  875. if (limit <= 0 || byUserid.size() <= limit) return byUserid;
  876. List<String> keys = new ArrayList<>(byUserid.keySet());
  877. Collections.sort(keys);
  878. Map<String, Map> limited = new LinkedHashMap<>();
  879. for (int i = 0; i < limit; i++) limited.put(keys.get(i), byUserid.get(keys.get(i)));
  880. return limited;
  881. }
  882. /** topapi/v2/user/list 不返回 manager_userid, 按需逐人补 (仅当 fieldManager 已配置; 单人失败不影响整体) */
  883. @SuppressWarnings("unchecked")
  884. private void enrichManagers(Map<String, Map> dingUserMap) {
  885. if (!notBlank(conf.getFieldManager()) || dingUserMap.isEmpty()) return;
  886. String token = ddClient.getAccessToken();
  887. int filled = 0;
  888. for (Map u : dingUserMap.values()) {
  889. if (u.get("manager_userid") != null) { filled++; continue; }
  890. Object uid = u.get("userid");
  891. if (uid == null) continue;
  892. try {
  893. ddRateLimiter.acquire();
  894. Map detail = ddClient_contacts.getUserInfoById(token, String.valueOf(uid));
  895. Object mgr = detail == null ? null : detail.get("manager_userid");
  896. if (mgr != null && notBlank(String.valueOf(mgr))) {
  897. u.put("manager_userid", String.valueOf(mgr).trim());
  898. filled++;
  899. }
  900. } catch (Exception ex) {
  901. log.warn("[PersonnelSync] 取 manager_userid 失败 userid={} err={}", uid, ex.getMessage());
  902. }
  903. }
  904. log.info("[PersonnelSync] manager_userid 已就绪 {}/{} 人", filled, dingUserMap.size());
  905. }
  906. private Map<String, Long> countActions(List<Action> actions) {
  907. Map<String, Long> stats = new HashMap<>();
  908. for (Action a : actions) {
  909. stats.merge(a.type, 1L, Long::sum);
  910. }
  911. return stats;
  912. }
  913. // ==================== 内部: 数据结构 ====================
  914. private static class YidaRecord {
  915. String instanceId;
  916. Map<String, Object> formData;
  917. }
  918. private static class Action {
  919. final String type;
  920. final String userid;
  921. final String instanceId;
  922. final Map<String, Object> formData;
  923. Action(String type, String userid, String instanceId, Map<String, Object> formData) {
  924. this.type = type;
  925. this.userid = userid;
  926. this.instanceId = instanceId;
  927. this.formData = formData;
  928. }
  929. }
  930. private static class WriteStats {
  931. AtomicInteger created = new AtomicInteger();
  932. AtomicInteger updated = new AtomicInteger();
  933. AtomicInteger markedInactive = new AtomicInteger();
  934. AtomicInteger failed = new AtomicInteger();
  935. }
  936. }