PersonnelSyncServiceImpl.java 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662
  1. package com.malk.service.personnel.impl;
  2. import com.alibaba.fastjson.JSON;
  3. import com.alibaba.fastjson.JSONObject;
  4. import com.malk.server.aliwork.YDConf;
  5. import com.malk.server.aliwork.YDParam;
  6. import com.malk.server.dingtalk.DDR_New;
  7. import com.malk.server.personnel.PersonnelSyncConf;
  8. import com.malk.service.aliwork.YDClient;
  9. import com.malk.service.dingtalk.DDClient;
  10. import com.malk.service.dingtalk.DDClient_Contacts;
  11. import com.malk.service.personnel.PersonnelSyncService;
  12. import lombok.extern.slf4j.Slf4j;
  13. import org.springframework.beans.factory.annotation.Autowired;
  14. import org.springframework.stereotype.Service;
  15. import java.util.ArrayList;
  16. import java.util.Arrays;
  17. import java.util.Collections;
  18. import java.util.HashMap;
  19. import java.util.LinkedHashMap;
  20. import java.util.List;
  21. import java.util.Map;
  22. import java.util.Objects;
  23. import java.util.concurrent.ExecutorService;
  24. import java.util.concurrent.Executors;
  25. import java.util.concurrent.Future;
  26. import java.util.concurrent.TimeUnit;
  27. import java.util.concurrent.atomic.AtomicInteger;
  28. @Slf4j
  29. @Service
  30. public class PersonnelSyncServiceImpl implements PersonnelSyncService {
  31. @Autowired
  32. private YDClient ydClient;
  33. @Autowired
  34. private DDClient ddClient;
  35. @Autowired
  36. private DDClient_Contacts ddClient_contacts;
  37. @Autowired
  38. private PersonnelSyncConf conf;
  39. private static final String ACTION_CREATE = "CREATE";
  40. private static final String ACTION_UPDATE = "UPDATE";
  41. private static final String ACTION_MARK_OFF = "MARK_OFF";
  42. @Override
  43. public Map<String, Object> fullSync(Integer limitOverride) {
  44. long start = System.currentTimeMillis();
  45. int limit = effectiveLimit(limitOverride);
  46. boolean limited = limit > 0;
  47. log.info("[PersonnelSync] 全量同步开始 limit={}", limited ? limit : "none");
  48. List<Map> dingUsers = fetchAllDingUsers();
  49. Map<String, Map> dingUserMap = applyLimit(indexByUserid(dingUsers), limit);
  50. enrichManagers(dingUserMap);
  51. log.info("[PersonnelSync] 钉钉拉取 {} 人 (去重后{})", dingUserMap.size(), limited ? ", 已截断前" + limit + "条" : "");
  52. Map<String, YidaRecord> yidaMap = fetchAllYidaPersonnel();
  53. log.info("[PersonnelSync] 宜搭人员档案 {} 条", yidaMap.size());
  54. if (limited) log.info("[PersonnelSync] limit 模式: 本轮跳过 MARK_OFF (拉取非全量)");
  55. List<Action> actions = diff(dingUserMap, yidaMap, limited);
  56. Map<String, Long> actionStats = countActions(actions);
  57. log.info("[PersonnelSync] diff 完成: create={}, update={}, markOff={}, skip={}",
  58. actionStats.getOrDefault(ACTION_CREATE, 0L),
  59. actionStats.getOrDefault(ACTION_UPDATE, 0L),
  60. actionStats.getOrDefault(ACTION_MARK_OFF, 0L),
  61. (long) dingUserMap.size() + yidaMap.size() - actions.size());
  62. WriteStats writeStats = concurrentWrite(actions);
  63. long cost = System.currentTimeMillis() - start;
  64. Map<String, Object> result = new LinkedHashMap<>();
  65. result.put("fetched", dingUserMap.size());
  66. result.put("yidaExisting", yidaMap.size());
  67. result.put("created", writeStats.created.get());
  68. result.put("updated", writeStats.updated.get());
  69. result.put("markedInactive", writeStats.markedInactive.get());
  70. result.put("failed", writeStats.failed.get());
  71. result.put("durationMs", cost);
  72. log.info("[PersonnelSync] 全量同步完成 {}", result);
  73. return result;
  74. }
  75. @Override
  76. public Map<String, Object> dryRun(Integer limitOverride) {
  77. long start = System.currentTimeMillis();
  78. int limit = effectiveLimit(limitOverride);
  79. boolean limited = limit > 0;
  80. List<Map> dingUsers = fetchAllDingUsers();
  81. Map<String, Map> dingUserMap = applyLimit(indexByUserid(dingUsers), limit);
  82. enrichManagers(dingUserMap);
  83. Map<String, YidaRecord> yidaMap = fetchAllYidaPersonnel();
  84. List<Action> actions = diff(dingUserMap, yidaMap, limited);
  85. Map<String, Long> stats = countActions(actions);
  86. Map<String, Object> result = new LinkedHashMap<>();
  87. result.put("fetched", dingUserMap.size());
  88. result.put("yidaExisting", yidaMap.size());
  89. Map<String, Object> actionCounts = new LinkedHashMap<>();
  90. actionCounts.put("create", stats.getOrDefault(ACTION_CREATE, 0L));
  91. actionCounts.put("update", stats.getOrDefault(ACTION_UPDATE, 0L));
  92. actionCounts.put("markOff", stats.getOrDefault(ACTION_MARK_OFF, 0L));
  93. result.put("actions", actionCounts);
  94. result.put("durationMs", System.currentTimeMillis() - start);
  95. // 抽样 10 条展示预期动作
  96. List<Map<String, Object>> sample = new ArrayList<>();
  97. for (int i = 0; i < Math.min(10, actions.size()); i++) {
  98. Action a = actions.get(i);
  99. Map<String, Object> s = new LinkedHashMap<>();
  100. s.put("action", a.type);
  101. s.put("userid", a.userid);
  102. s.put("formData", a.formData);
  103. sample.add(s);
  104. }
  105. result.put("sample", sample);
  106. return result;
  107. }
  108. @Override
  109. public Map<String, Object> probeDingtalkUsers(int sampleSize) {
  110. long start = System.currentTimeMillis();
  111. String token = ddClient.getAccessToken();
  112. List<Long> deptIds = ddClient_contacts.getDepartmentId_all(token, true);
  113. List<Map> users = ddClient_contacts.getAllUserDetails(token, true);
  114. Map<String, Map> byUserid = indexByUserid(users);
  115. Map<String, Object> result = new LinkedHashMap<>();
  116. result.put("total", byUserid.size());
  117. result.put("deptCount", deptIds.size());
  118. result.put("durationMs", System.currentTimeMillis() - start);
  119. List<Map> sample = new ArrayList<>();
  120. int n = Math.min(sampleSize <= 0 ? 3 : sampleSize, users.size());
  121. for (int i = 0; i < n; i++) sample.add(users.get(i));
  122. result.put("sample", sample);
  123. return result;
  124. }
  125. @Override
  126. public List<Map> fetchAllDingUsers() {
  127. String token = ddClient.getAccessToken();
  128. return ddClient_contacts.getAllUserDetails(token, true);
  129. }
  130. @Override
  131. public Map probeSingleUser(String userid) {
  132. String token = ddClient.getAccessToken();
  133. return ddClient_contacts.getUserInfoById(token, userid);
  134. }
  135. @SuppressWarnings("unchecked")
  136. public Map<String, Object> probeDiff(String userid) {
  137. Map<String, Object> r = new LinkedHashMap<>();
  138. // 钉钉
  139. String token = ddClient.getAccessToken();
  140. Map ding = ddClient_contacts.getUserInfoById(token, userid);
  141. r.put("dingUser", ding);
  142. // 宜搭
  143. DDR_New result = ydClient.queryData(YDParam.builder()
  144. .appType(conf.getYidaAppType())
  145. .systemToken(conf.getYidaSystemToken())
  146. .formUuid(conf.getFormUuidPersonnel())
  147. .searchFieldJson("{\"" + conf.getFieldEmployee() + "\":\"" + userid + "\"}")
  148. .build(), YDConf.FORM_QUERY.retrieve_search_form);
  149. List<Map> list = (List<Map>) result.getData();
  150. Map<String, Object> yidaFormData = (list != null && !list.isEmpty()) ? (Map<String, Object>) list.get(0).get("formData") : null;
  151. r.put("yidaFormData", yidaFormData);
  152. // 新 formData (在职判定改为存在性, probe 统一用 UPDATE)
  153. Map<String, Object> newData = toYidaFormData(userid, ding, ACTION_UPDATE);
  154. r.put("newFormData", newData);
  155. // 字段对比
  156. Map<String, Object> diff = new LinkedHashMap<>();
  157. if (yidaFormData != null) {
  158. for (Map.Entry<String, Object> e : newData.entrySet()) {
  159. String f = e.getKey();
  160. Object nv = e.getValue();
  161. Object ov = yidaFormData.get(f);
  162. Object ovId = yidaFormData.get(f + "_id");
  163. Map<String, Object> d = new LinkedHashMap<>();
  164. d.put("new", nv);
  165. d.put("newClass", nv == null ? null : nv.getClass().getSimpleName());
  166. d.put("old", ov);
  167. d.put("oldClass", ov == null ? null : ov.getClass().getSimpleName());
  168. d.put("oldId", ovId);
  169. d.put("oldIdClass", ovId == null ? null : ovId.getClass().getSimpleName());
  170. diff.put(f, d);
  171. }
  172. }
  173. r.put("diff", diff);
  174. return r;
  175. }
  176. @Override
  177. public Map<String, Object> probeStats() {
  178. long start = System.currentTimeMillis();
  179. List<Map> users = fetchAllDingUsers();
  180. int total = users.size();
  181. int active = 0, inactive = 0, emptyDept = 0, hasExtattr = 0, emptyJobNumber = 0;
  182. for (Map u : users) {
  183. if (isActive(u)) active++; else inactive++;
  184. Object dept = u.get("dept_id_list");
  185. if (!(dept instanceof List) || ((List) dept).isEmpty()) emptyDept++;
  186. Object ext = u.get("extattr");
  187. if (ext instanceof Map && !((Map) ext).isEmpty()) hasExtattr++;
  188. Object job = u.get("job_number");
  189. if (job == null || String.valueOf(job).isEmpty()) emptyJobNumber++;
  190. }
  191. Map<String, Object> res = new LinkedHashMap<>();
  192. res.put("total", total);
  193. res.put("active", active);
  194. res.put("inactive", inactive);
  195. res.put("emptyDeptIdList", emptyDept);
  196. res.put("hasExtattr", hasExtattr);
  197. res.put("emptyJobNumber", emptyJobNumber);
  198. res.put("durationMs", System.currentTimeMillis() - start);
  199. return res;
  200. }
  201. // ==================== 内部: 数据抓取 ====================
  202. @SuppressWarnings("unchecked")
  203. private Map<String, YidaRecord> fetchAllYidaPersonnel() {
  204. Map<String, YidaRecord> yidaMap = new LinkedHashMap<>();
  205. int currentPage = 1;
  206. long totalCount;
  207. do {
  208. DDR_New result = ydClient.queryData(YDParam.builder()
  209. .appType(conf.getYidaAppType())
  210. .systemToken(conf.getYidaSystemToken())
  211. .formUuid(conf.getFormUuidPersonnel())
  212. .currentPage(currentPage)
  213. .pageSize(YDConf.PAGE_SIZE_LIMIT)
  214. .build(), YDConf.FORM_QUERY.retrieve_search_form);
  215. totalCount = result.getTotalCount();
  216. List<Map> dataList = (List<Map>) result.getData();
  217. if (dataList == null || dataList.isEmpty()) break;
  218. for (Map item : dataList) {
  219. Map<String, Object> formData = (Map<String, Object>) item.get("formData");
  220. if (formData == null) continue;
  221. String userid = extractEmployeeId(formData, conf.getFieldEmployee());
  222. if (userid == null || userid.isEmpty()) continue;
  223. YidaRecord rec = new YidaRecord();
  224. rec.instanceId = String.valueOf(item.get("formInstanceId"));
  225. rec.formData = formData;
  226. yidaMap.put(userid, rec);
  227. }
  228. currentPage++;
  229. } while ((long) (currentPage - 1) * YDConf.PAGE_SIZE_LIMIT < totalCount);
  230. return yidaMap;
  231. }
  232. // ==================== 内部: 差异计算 ====================
  233. private List<Action> diff(Map<String, Map> dingUserMap, Map<String, YidaRecord> yidaMap, boolean skipMarkOff) {
  234. List<Action> actions = new ArrayList<>();
  235. // 钉钉里查到的 -> 一律按"在职"写 (active 布尔不再参与)
  236. for (Map.Entry<String, Map> e : dingUserMap.entrySet()) {
  237. String userid = e.getKey();
  238. Map ding = e.getValue();
  239. YidaRecord yida = yidaMap.get(userid);
  240. if (yida == null) {
  241. actions.add(new Action(ACTION_CREATE, userid, null, toYidaFormData(userid, ding, ACTION_CREATE)));
  242. } else {
  243. Map<String, Object> formData = toYidaFormData(userid, ding, ACTION_UPDATE);
  244. if (isSameAsYida(formData, yida.formData)) continue; // 幂等跳过
  245. actions.add(new Action(ACTION_UPDATE, userid, yida.instanceId, formData));
  246. }
  247. }
  248. // 宜搭里有、钉钉里没有 -> 标记离职 (limit 模式下拉取非全量, 跳过此步)
  249. if (!skipMarkOff) {
  250. for (Map.Entry<String, YidaRecord> e : yidaMap.entrySet()) {
  251. if (dingUserMap.containsKey(e.getKey())) continue;
  252. YidaRecord yida = e.getValue();
  253. Object currentStatus = yida.formData.get(conf.getFieldStatus());
  254. if (conf.getStatusValueInactive().equals(String.valueOf(currentStatus))) continue; // 已离职跳过
  255. Map<String, Object> formData = new LinkedHashMap<>();
  256. formData.put(conf.getFieldStatus(), conf.getStatusValueInactive());
  257. actions.add(new Action(ACTION_MARK_OFF, e.getKey(), yida.instanceId, formData));
  258. }
  259. }
  260. return actions;
  261. }
  262. // ==================== 内部: 字段映射 ====================
  263. @SuppressWarnings("unchecked")
  264. private Map<String, Object> toYidaFormData(String userid, Map ding, String action) {
  265. Map<String, Object> formData = new LinkedHashMap<>();
  266. // 人员 (唯一键, 永远写入)
  267. formData.put(conf.getFieldEmployee(), Collections.singletonList(userid));
  268. // 在职状态
  269. String statusValue = ACTION_MARK_OFF.equals(action) ? conf.getStatusValueInactive() : conf.getStatusValueActive();
  270. formData.put(conf.getFieldStatus(), statusValue);
  271. // 离职软标记只更新状态字段, 保留其他原值
  272. if (ACTION_MARK_OFF.equals(action)) return formData;
  273. // 员工姓名 <- name (目标表该字段 READONLY, 仍按需求强写覆盖)
  274. Object name = ding.get("name");
  275. if (notBlank(conf.getFieldName()) && name != null && notBlank(String.valueOf(name))) {
  276. formData.put(conf.getFieldName(), String.valueOf(name).trim());
  277. }
  278. // 员工编号 <- userid (钉钉用户唯一 ID, 与人员 EmployeeField 同源, 但写入 TextField 便于跨模块按字符串引用)
  279. if (notBlank(conf.getFieldJobNumber())) {
  280. formData.put(conf.getFieldJobNumber(), userid);
  281. }
  282. // 员工工号 <- job_number (目标表 READONLY, 按需求强写覆盖; 空则跳过)
  283. Object jobNumber = ding.get("job_number");
  284. if (notBlank(conf.getFieldJobNumber2()) && jobNumber != null && notBlank(String.valueOf(jobNumber))) {
  285. formData.put(conf.getFieldJobNumber2(), String.valueOf(jobNumber).trim());
  286. }
  287. // 员工部门 <- dept_id_list 稳定排序后取首个 (钉钉返回顺序不固定, 避免 diff 抖动)
  288. Object deptObj = ding.get("dept_id_list");
  289. if (deptObj instanceof List && !((List) deptObj).isEmpty()) {
  290. List<Long> sorted = new ArrayList<>();
  291. for (Object d : (List) deptObj) {
  292. if (d != null) sorted.add(((Number) d).longValue());
  293. }
  294. Collections.sort(sorted);
  295. if (!sorted.isEmpty()) {
  296. formData.put(conf.getFieldDepartment(), Collections.singletonList(String.valueOf(sorted.get(0))));
  297. }
  298. }
  299. // 入职时间 <- hired_date (毫秒时间戳, 需要钉钉花名册权限才返回)
  300. if (notBlank(conf.getFieldHiredDate())) {
  301. Object hired = ding.get("hired_date");
  302. if (hired instanceof Number) {
  303. formData.put(conf.getFieldHiredDate(), ((Number) hired).longValue());
  304. }
  305. }
  306. // Manager <- manager_userid (EmployeeField, 数组格式)
  307. Object mgr = ding.get("manager_userid");
  308. if (notBlank(conf.getFieldManager()) && mgr != null && notBlank(String.valueOf(mgr))) {
  309. formData.put(conf.getFieldManager(), Collections.singletonList(String.valueOf(mgr).trim()));
  310. }
  311. // 北森编号 / 归属公司 / 是否CF / 成本中心 <- 钉钉 extattr 自定义字段
  312. putExtAttr(formData, ding, conf.getFieldBeisenJobNo(), conf.getExtAttrKeyBeisen());
  313. putExtAttr(formData, ding, conf.getFieldCompany(), conf.getExtAttrKeyCompany());
  314. putExtAttr(formData, ding, conf.getFieldIsCf(), conf.getExtAttrKeyIsCf());
  315. putExtAttr(formData, ding, conf.getFieldCostCenter(), conf.getExtAttrKeyCostCenter());
  316. // 属性 <- 部门含 externalDeptIds ? 外部 : 内部 (extAttrKeyUserType 留空时走部门白名单)
  317. String userType = resolveUserType(ding);
  318. if (userType != null) {
  319. formData.put(conf.getFieldUserType(), userType);
  320. }
  321. return formData;
  322. }
  323. /** extattr 自定义字段取值并写入 (fieldId 或 extKey 为空 / 取不到值 则不写, 保留宜搭原值) */
  324. private void putExtAttr(Map<String, Object> formData, Map ding, String fieldId, String extKey) {
  325. if (!notBlank(fieldId) || !notBlank(extKey)) return;
  326. String v = readExtAttr(ding, extKey);
  327. if (notBlank(v)) formData.put(fieldId, v.trim());
  328. }
  329. /**
  330. * 读钉钉自定义字段[key]:
  331. * - topapi/v2/user/list & user/get 把自定义字段放在 extension (JSON 字符串 {key: value}) 里
  332. * - 兼容旧式 extattr (Map, 值可能是纯字符串或 {text, value} 枚举)
  333. */
  334. @SuppressWarnings("unchecked")
  335. private String readExtAttr(Map ding, String key) {
  336. Object ext = ding.get("extension");
  337. if (ext instanceof String && !((String) ext).trim().isEmpty()) {
  338. try {
  339. Object parsed = JSON.parse((String) ext);
  340. if (parsed instanceof Map) {
  341. Object v = ((Map) parsed).get(key);
  342. if (v != null && !String.valueOf(v).trim().isEmpty()) return String.valueOf(v);
  343. }
  344. } catch (Exception ignored) {}
  345. }
  346. Object extattr = ding.get("extattr");
  347. if (extattr instanceof Map) {
  348. Object attr = ((Map) extattr).get(key);
  349. if (attr instanceof Map) {
  350. Map am = (Map) attr;
  351. Object v = am.get("value");
  352. if (v == null) v = am.get("text");
  353. return v == null ? null : String.valueOf(v);
  354. } else if (attr != null && !String.valueOf(attr).trim().isEmpty()) {
  355. return String.valueOf(attr);
  356. }
  357. }
  358. return null;
  359. }
  360. private boolean notBlank(String s) {
  361. return s != null && !s.trim().isEmpty();
  362. }
  363. @SuppressWarnings("unchecked")
  364. private String resolveUserType(Map ding) {
  365. // 1. 优先读 extattr
  366. String key = conf.getExtAttrKeyUserType();
  367. if (key != null && !key.isEmpty()) {
  368. Object extattrObj = ding.get("extattr");
  369. if (extattrObj instanceof Map) {
  370. Map extattr = (Map) extattrObj;
  371. Object attr = extattr.get(key);
  372. String raw = null;
  373. if (attr instanceof Map) {
  374. Map am = (Map) attr;
  375. Object value = am.get("value");
  376. if (value == null) value = am.get("text");
  377. if (value != null) raw = String.valueOf(value);
  378. } else if (attr != null) {
  379. raw = String.valueOf(attr);
  380. }
  381. if (raw != null && !raw.isEmpty()) {
  382. if (conf.getExtAttrValueInternal().equals(raw)) return conf.getExtAttrValueInternal();
  383. if (conf.getExtAttrValueExternal().equals(raw)) return conf.getExtAttrValueExternal();
  384. }
  385. }
  386. }
  387. // 2. 兜底: 部门白名单 (外部部门列表 + 默认规则)
  388. List<Long> externalDepts = conf.getExternalDeptIds();
  389. Object deptObj = ding.get("dept_id_list");
  390. if (deptObj instanceof List && !((List) deptObj).isEmpty()) {
  391. if (externalDepts != null && !externalDepts.isEmpty()) {
  392. boolean isExternal = false;
  393. for (Object d : (List) deptObj) {
  394. if (d == null) continue;
  395. long deptId = ((Number) d).longValue();
  396. if (externalDepts.contains(deptId)) { isExternal = true; break; }
  397. }
  398. return isExternal ? conf.getExtAttrValueExternal() : conf.getExtAttrValueInternal();
  399. }
  400. if (conf.isFallbackInternalByDefault()) {
  401. return conf.getExtAttrValueInternal();
  402. }
  403. }
  404. return null;
  405. }
  406. private boolean isActive(Map ding) {
  407. Object active = ding.get("active");
  408. if (active instanceof Boolean) return (Boolean) active;
  409. if (active == null) return true;
  410. return Boolean.parseBoolean(String.valueOf(active));
  411. }
  412. // 比较钉钉侧构造的 formData 与宜搭已有 formData 是否所有字段都相等
  413. @SuppressWarnings("unchecked")
  414. private boolean isSameAsYida(Map<String, Object> newData, Map<String, Object> yidaData) {
  415. for (Map.Entry<String, Object> e : newData.entrySet()) {
  416. String fieldId = e.getKey();
  417. Object newVal = e.getValue();
  418. Object oldVal = yidaData.get(fieldId);
  419. if (newVal instanceof List) {
  420. // 成员/部门类字段: 宜搭返回时用 _id 后缀取 id 列表
  421. Object oldIdList = yidaData.get(fieldId + "_id");
  422. if (oldIdList != null) oldVal = oldIdList;
  423. if (!listEquals((List) newVal, oldVal)) return false;
  424. } else {
  425. if (!Objects.equals(String.valueOf(newVal), String.valueOf(oldVal))) return false;
  426. }
  427. }
  428. return true;
  429. }
  430. @SuppressWarnings("unchecked")
  431. private boolean listEquals(List newList, Object oldObj) {
  432. List<String> newStr = new ArrayList<>();
  433. for (Object o : newList) newStr.add(String.valueOf(o));
  434. List<String> oldStr = new ArrayList<>();
  435. if (oldObj instanceof List) {
  436. for (Object o : (List) oldObj) oldStr.add(String.valueOf(o));
  437. } else if (oldObj != null) {
  438. oldStr.add(String.valueOf(oldObj));
  439. }
  440. if (newStr.size() != oldStr.size()) return false;
  441. for (String s : newStr) if (!oldStr.contains(s)) return false;
  442. return true;
  443. }
  444. // ==================== 内部: 写入 ====================
  445. private WriteStats concurrentWrite(List<Action> actions) {
  446. WriteStats stats = new WriteStats();
  447. if (actions.isEmpty()) return stats;
  448. ExecutorService pool = Executors.newFixedThreadPool(Math.max(1, conf.getConcurrency()));
  449. List<Future<?>> futures = new ArrayList<>();
  450. for (Action a : actions) {
  451. futures.add(pool.submit(() -> executeAction(a, stats)));
  452. }
  453. for (Future<?> f : futures) {
  454. try { f.get(); } catch (Exception ex) { log.warn("[PersonnelSync] future 异常", ex); }
  455. }
  456. pool.shutdown();
  457. try { pool.awaitTermination(10, TimeUnit.MINUTES); } catch (InterruptedException ignored) {}
  458. return stats;
  459. }
  460. private void executeAction(Action a, WriteStats stats) {
  461. int attempt = 0;
  462. while (true) {
  463. try {
  464. if (ACTION_CREATE.equals(a.type)) {
  465. ydClient.operateData(YDParam.builder()
  466. .appType(conf.getYidaAppType())
  467. .systemToken(conf.getYidaSystemToken())
  468. .formUuid(conf.getFormUuidPersonnel())
  469. .formDataJson(JSON.toJSONString(a.formData))
  470. .build(), YDConf.FORM_OPERATION.create);
  471. stats.created.incrementAndGet();
  472. } else {
  473. ydClient.operateData(YDParam.builder()
  474. .appType(conf.getYidaAppType())
  475. .systemToken(conf.getYidaSystemToken())
  476. .formUuid(conf.getFormUuidPersonnel())
  477. .formInstanceId(a.instanceId)
  478. .updateFormDataJson(JSON.toJSONString(a.formData))
  479. .ignoreEmpty(false)
  480. .useLatestVersion(true)
  481. .build(), YDConf.FORM_OPERATION.update);
  482. if (ACTION_MARK_OFF.equals(a.type)) {
  483. stats.markedInactive.incrementAndGet();
  484. } else {
  485. stats.updated.incrementAndGet();
  486. }
  487. }
  488. return;
  489. } catch (Exception ex) {
  490. attempt++;
  491. if (attempt > conf.getMaxRetry()) {
  492. log.warn("[PersonnelSync] 写入失败 userid={} action={} err={}", a.userid, a.type, ex.getMessage());
  493. stats.failed.incrementAndGet();
  494. return;
  495. }
  496. }
  497. }
  498. }
  499. // ==================== 内部: 工具 ====================
  500. @SuppressWarnings("unchecked")
  501. private String extractEmployeeId(Map<String, Object> formData, String fieldId) {
  502. Object raw = formData.get(fieldId + "_id");
  503. if (raw == null) raw = formData.get(fieldId);
  504. if (raw == null) return null;
  505. if (raw instanceof List) {
  506. List list = (List) raw;
  507. return list.isEmpty() ? null : String.valueOf(list.get(0));
  508. }
  509. String str = String.valueOf(raw).trim();
  510. if (str.startsWith("[") && str.endsWith("]")) {
  511. Object parsed = JSON.parse(str);
  512. if (parsed instanceof List) {
  513. List pl = (List) parsed;
  514. return pl.isEmpty() ? null : String.valueOf(pl.get(0));
  515. }
  516. }
  517. return str.isEmpty() ? null : str;
  518. }
  519. private Map<String, Map> indexByUserid(List<Map> users) {
  520. Map<String, Map> map = new LinkedHashMap<>();
  521. for (Map u : users) {
  522. Object uid = u.get("userid");
  523. if (uid != null) map.putIfAbsent(String.valueOf(uid), u);
  524. }
  525. return map;
  526. }
  527. /** 解析生效的 limit: 入参优先, 否则用配置 limitFirstN; <=0 表示不限 */
  528. private int effectiveLimit(Integer override) {
  529. if (override != null) return Math.max(0, override);
  530. return Math.max(0, conf.getLimitFirstN());
  531. }
  532. /** limit>0 时按 userid 升序取前 N 条 (排序保证幂等可复现) */
  533. private Map<String, Map> applyLimit(Map<String, Map> byUserid, int limit) {
  534. if (limit <= 0 || byUserid.size() <= limit) return byUserid;
  535. List<String> keys = new ArrayList<>(byUserid.keySet());
  536. Collections.sort(keys);
  537. Map<String, Map> limited = new LinkedHashMap<>();
  538. for (int i = 0; i < limit; i++) limited.put(keys.get(i), byUserid.get(keys.get(i)));
  539. return limited;
  540. }
  541. /** topapi/v2/user/list 不返回 manager_userid, 按需逐人补 (仅当 fieldManager 已配置; 单人失败不影响整体) */
  542. @SuppressWarnings("unchecked")
  543. private void enrichManagers(Map<String, Map> dingUserMap) {
  544. if (!notBlank(conf.getFieldManager()) || dingUserMap.isEmpty()) return;
  545. String token = ddClient.getAccessToken();
  546. int filled = 0;
  547. for (Map u : dingUserMap.values()) {
  548. if (u.get("manager_userid") != null) { filled++; continue; }
  549. Object uid = u.get("userid");
  550. if (uid == null) continue;
  551. try {
  552. Map detail = ddClient_contacts.getUserInfoById(token, String.valueOf(uid));
  553. Object mgr = detail == null ? null : detail.get("manager_userid");
  554. if (mgr != null && notBlank(String.valueOf(mgr))) {
  555. u.put("manager_userid", String.valueOf(mgr).trim());
  556. filled++;
  557. }
  558. } catch (Exception ex) {
  559. log.warn("[PersonnelSync] 取 manager_userid 失败 userid={} err={}", uid, ex.getMessage());
  560. }
  561. }
  562. log.info("[PersonnelSync] manager_userid 已就绪 {}/{} 人", filled, dingUserMap.size());
  563. }
  564. private Map<String, Long> countActions(List<Action> actions) {
  565. Map<String, Long> stats = new HashMap<>();
  566. for (Action a : actions) {
  567. stats.merge(a.type, 1L, Long::sum);
  568. }
  569. return stats;
  570. }
  571. // ==================== 内部: 数据结构 ====================
  572. private static class YidaRecord {
  573. String instanceId;
  574. Map<String, Object> formData;
  575. }
  576. private static class Action {
  577. final String type;
  578. final String userid;
  579. final String instanceId;
  580. final Map<String, Object> formData;
  581. Action(String type, String userid, String instanceId, Map<String, Object> formData) {
  582. this.type = type;
  583. this.userid = userid;
  584. this.instanceId = instanceId;
  585. this.formData = formData;
  586. }
  587. }
  588. private static class WriteStats {
  589. AtomicInteger created = new AtomicInteger();
  590. AtomicInteger updated = new AtomicInteger();
  591. AtomicInteger markedInactive = new AtomicInteger();
  592. AtomicInteger failed = new AtomicInteger();
  593. }
  594. }