|
|
@@ -0,0 +1,712 @@
|
|
|
+package com.malk.guangming.service;
|
|
|
+
|
|
|
+import com.alibaba.fastjson.JSON;
|
|
|
+import com.malk.guangming.config.GuangmingConfig;
|
|
|
+import com.malk.guangming.dto.AppPreviewDto;
|
|
|
+import com.malk.guangming.dto.AppSyncResultDto;
|
|
|
+import com.malk.guangming.repository.dao.DingTalkAppMappingDao;
|
|
|
+import com.malk.guangming.repository.entity.primary.DingTalkAppMappingPo;
|
|
|
+import com.malk.server.common.McException;
|
|
|
+import com.malk.server.dingtalk.DDConf;
|
|
|
+import com.malk.service.dingtalk.DDClient;
|
|
|
+import com.malk.service.dingtalk.DDClient_Contacts;
|
|
|
+import com.malk.service.dingtalk.DDClient_Role;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
+import org.springframework.context.annotation.Lazy;
|
|
|
+import org.springframework.scheduling.annotation.Async;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import java.util.*;
|
|
|
+import java.util.concurrent.ConcurrentHashMap;
|
|
|
+import java.util.concurrent.ForkJoinPool;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+public class AppRoleSyncService {
|
|
|
+
|
|
|
+ private static final String ROLE_GROUP_NAME = "开放平台应用权限";
|
|
|
+
|
|
|
+ /** 部门展开并行度:大部门可见范围同步展开时的并发钉钉调用数,避免顺序展开超 HTTP 超时 */
|
|
|
+ private static final int EXPAND_CONCURRENCY = 10;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private GuangmingConfig guangmingConfig;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private DDClient ddClient;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private DDClient_Contacts ddClient_contacts;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private DDClient_Role ddClient_role;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private DingTalkAppMappingDao mappingDao;
|
|
|
+
|
|
|
+ /** 自注入代理:@Async 自调用不生效,异步展开需经此代理触发 */
|
|
|
+ @Lazy
|
|
|
+ @Autowired
|
|
|
+ private AppRoleSyncService self;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 单应用同步:读取可见范围 → 部门展开 → 落库;全表首次同步时创建角色组
|
|
|
+ */
|
|
|
+ public AppSyncResultDto syncOne(String appKey) {
|
|
|
+ DingTalkAppMappingPo mapping = mappingDao.findByAppKey(appKey)
|
|
|
+ .orElseThrow(() -> new McException("404", "appKey not registered: " + appKey));
|
|
|
+
|
|
|
+ String token = getAccessToken();
|
|
|
+ VisibleScopeResolved scope;
|
|
|
+ try {
|
|
|
+ scope = resolveScope(token, mapping.getAgentId());
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[role-sync] resolveScope 失败, appKey={}", appKey, e);
|
|
|
+ mapping.setSyncStatus("failed");
|
|
|
+ mapping.setLastErrMsg(truncate("resolve scope failed: " + e.getMessage(), 500));
|
|
|
+ mapping.setLastSyncAt(new Date());
|
|
|
+ mappingDao.save(mapping);
|
|
|
+ throw e instanceof McException ? (McException) e : new McException("502", "dingtalk vendor error: " + e.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ // 1. 先把 raw + expanded 落库(visible_scopes 已成功)
|
|
|
+ mapping.setRawDeptIds(JSON.toJSONString(scope.deptIds));
|
|
|
+ mapping.setRawUserIds(JSON.toJSONString(scope.userIds));
|
|
|
+ mapping.setIsAllVisible(scope.isAllVisible);
|
|
|
+ mapping.setIsHidden(scope.isHidden);
|
|
|
+ if (scope.isAllVisible) {
|
|
|
+ mapping.setExpandedUsers(null);
|
|
|
+ mapping.setExpandedCount(null);
|
|
|
+ } else if (scope.expandOk) {
|
|
|
+ mapping.setExpandedUsers(JSON.toJSONString(scope.expanded));
|
|
|
+ mapping.setExpandedCount(scope.expanded.size());
|
|
|
+ }
|
|
|
+ mapping.setLastSyncAt(new Date());
|
|
|
+
|
|
|
+ // 2. 尝试创建/拿角色组(失败不丢 raw)
|
|
|
+ String roleGroupId = null;
|
|
|
+ String roleGroupErr = null;
|
|
|
+ try {
|
|
|
+ ensureRoleGroup(token);
|
|
|
+ roleGroupId = mappingDao.findFirstByRoleGroupIdIsNotNull()
|
|
|
+ .map(DingTalkAppMappingPo::getRoleGroupId).orElse(null);
|
|
|
+ mapping.setRoleGroupId(roleGroupId);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("[role-sync] 角色组创建失败(可能权限未开通 qyapi_manage_addresslist): {}", e.getMessage());
|
|
|
+ roleGroupErr = e.getMessage();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. 统一 sync_status
|
|
|
+ String status;
|
|
|
+ StringBuilder err = new StringBuilder();
|
|
|
+ if (scope.expandErr != null) err.append("expand:").append(scope.expandErr).append(";");
|
|
|
+ if (roleGroupErr != null) err.append("role_group:").append(roleGroupErr).append(";");
|
|
|
+ if (scope.expandErr == null && roleGroupErr == null) {
|
|
|
+ status = "success";
|
|
|
+ } else if (roleGroupErr != null && scope.expandErr != null) {
|
|
|
+ status = "partial_no_expand_no_role_group";
|
|
|
+ } else if (roleGroupErr != null) {
|
|
|
+ status = "partial_no_role_group";
|
|
|
+ } else {
|
|
|
+ status = "partial_no_expand";
|
|
|
+ }
|
|
|
+ mapping.setSyncStatus(status);
|
|
|
+ mapping.setLastErrMsg(err.length() == 0 ? null : truncate(err.toString(), 500));
|
|
|
+ mappingDao.save(mapping);
|
|
|
+
|
|
|
+ return AppSyncResultDto.builder()
|
|
|
+ .appName(mapping.getAppName())
|
|
|
+ .roleGroupId(roleGroupId)
|
|
|
+ .isAllVisible(scope.isAllVisible)
|
|
|
+ .expandedCount(mapping.getExpandedCount())
|
|
|
+ .syncStatus(status)
|
|
|
+ .build();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 异步初始化(大部门可见范围优化):只读可见范围(快)+ 建角色组,状态置 expanding 后立即返回;
|
|
|
+ * 部门展开放后台 @Async 执行,完成转 success。避免大部门(差旅类数千部门)同步展开阻塞 HTTP 超时。
|
|
|
+ * 调用方:轮询 GET /status 至 success 后再 /users-sync 绑成员。
|
|
|
+ */
|
|
|
+ public AppSyncResultDto syncOneAsync(String appKey) {
|
|
|
+ DingTalkAppMappingPo mapping = mappingDao.findByAppKey(appKey)
|
|
|
+ .orElseThrow(() -> new McException("404", "appKey not registered: " + appKey));
|
|
|
+ String token = getAccessToken();
|
|
|
+ VisibleScopeResolved scope;
|
|
|
+ try {
|
|
|
+ scope = readScope(token, mapping.getAgentId());
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[role-sync] readScope 失败, appKey={}", appKey, e);
|
|
|
+ mapping.setSyncStatus("failed");
|
|
|
+ mapping.setLastErrMsg(truncate("read scope failed: " + e.getMessage(), 500));
|
|
|
+ mapping.setLastSyncAt(new Date());
|
|
|
+ mappingDao.save(mapping);
|
|
|
+ throw e instanceof McException ? (McException) e : new McException("502", "dingtalk vendor error: " + e.getMessage());
|
|
|
+ }
|
|
|
+ mapping.setRawDeptIds(JSON.toJSONString(scope.deptIds));
|
|
|
+ mapping.setRawUserIds(JSON.toJSONString(scope.userIds));
|
|
|
+ mapping.setIsAllVisible(scope.isAllVisible);
|
|
|
+ mapping.setIsHidden(scope.isHidden);
|
|
|
+ mapping.setLastSyncAt(new Date());
|
|
|
+
|
|
|
+ String roleGroupErr = null;
|
|
|
+ try {
|
|
|
+ ensureRoleGroup(token);
|
|
|
+ mapping.setRoleGroupId(mappingDao.findFirstByRoleGroupIdIsNotNull()
|
|
|
+ .map(DingTalkAppMappingPo::getRoleGroupId).orElse(null));
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("[role-sync] 角色组创建失败: {}", e.getMessage());
|
|
|
+ roleGroupErr = e.getMessage();
|
|
|
+ }
|
|
|
+
|
|
|
+ String status;
|
|
|
+ if (scope.isAllVisible) {
|
|
|
+ mapping.setExpandedUsers(null);
|
|
|
+ mapping.setExpandedCount(null);
|
|
|
+ status = roleGroupErr == null ? "success" : "partial_no_role_group";
|
|
|
+ } else if (roleGroupErr != null) {
|
|
|
+ status = "partial_no_role_group";
|
|
|
+ } else {
|
|
|
+ status = "expanding";
|
|
|
+ }
|
|
|
+ mapping.setSyncStatus(status);
|
|
|
+ mapping.setLastErrMsg(roleGroupErr == null ? null : truncate("role_group:" + roleGroupErr, 500));
|
|
|
+ mappingDao.save(mapping);
|
|
|
+
|
|
|
+ if ("expanding".equals(status)) {
|
|
|
+ self.expandAsync(appKey); // @Async 经自注入代理触发,立即返回,后台展开
|
|
|
+ }
|
|
|
+ return AppSyncResultDto.builder()
|
|
|
+ .appName(mapping.getAppName())
|
|
|
+ .roleGroupId(mapping.getRoleGroupId())
|
|
|
+ .isAllVisible(scope.isAllVisible)
|
|
|
+ .syncStatus(status)
|
|
|
+ .build();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 后台异步展开部门成员:基于 mapping 已落库的 raw_dept/user 并行展开,完成后落 expanded + 状态。
|
|
|
+ * 完成转 success;展开失败转 partial_no_expand(保留 raw)。重载 mapping 避免跨线程共享实体。
|
|
|
+ */
|
|
|
+ @Async
|
|
|
+ public void expandAsync(String appKey) {
|
|
|
+ DingTalkAppMappingPo mapping = mappingDao.findByAppKey(appKey).orElse(null);
|
|
|
+ if (mapping == null) return;
|
|
|
+ String token = getAccessToken();
|
|
|
+ try {
|
|
|
+ List<Long> depts = mapping.getRawDeptIds() == null ? Collections.emptyList()
|
|
|
+ : JSON.parseArray(mapping.getRawDeptIds(), Long.class);
|
|
|
+ List<String> users = mapping.getRawUserIds() == null ? Collections.emptyList()
|
|
|
+ : JSON.parseArray(mapping.getRawUserIds(), String.class);
|
|
|
+ Set<String> expanded = expandDeptUsersParallel(token, depts, users);
|
|
|
+ mapping.setExpandedUsers(JSON.toJSONString(expanded));
|
|
|
+ mapping.setExpandedCount(expanded.size());
|
|
|
+ mapping.setSyncStatus("success");
|
|
|
+ mapping.setLastErrMsg(null);
|
|
|
+ log.info("[role-sync] expandAsync 完成 appKey={} expanded={}", appKey, expanded.size());
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[role-sync] expandAsync 失败 appKey={}", appKey, e);
|
|
|
+ mapping.setSyncStatus("partial_no_expand");
|
|
|
+ mapping.setLastErrMsg(truncate("expand:" + e.getMessage(), 500));
|
|
|
+ }
|
|
|
+ mapping.setLastSyncAt(new Date());
|
|
|
+ mappingDao.save(mapping);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * dryRun:不落库
|
|
|
+ */
|
|
|
+ public AppPreviewDto previewOne(String appKey) {
|
|
|
+ DingTalkAppMappingPo mapping = mappingDao.findByAppKey(appKey)
|
|
|
+ .orElseThrow(() -> new McException("404", "appKey not registered: " + appKey));
|
|
|
+
|
|
|
+ String token = getAccessToken();
|
|
|
+ VisibleScopeResolved scope = resolveScope(token, mapping.getAgentId());
|
|
|
+
|
|
|
+ return AppPreviewDto.builder()
|
|
|
+ .appName(mapping.getAppName())
|
|
|
+ .rawDeptIds(scope.deptIds)
|
|
|
+ .rawUserIds(scope.userIds)
|
|
|
+ .isAllVisible(scope.isAllVisible)
|
|
|
+ .isHidden(scope.isHidden)
|
|
|
+ .expanded(scope.isAllVisible ? null : new ArrayList<>(scope.expanded))
|
|
|
+ .build();
|
|
|
+ }
|
|
|
+
|
|
|
+ public DingTalkAppMappingPo getStatus(String appKey) {
|
|
|
+ return mappingDao.findByAppKey(appKey)
|
|
|
+ .orElseThrow(() -> new McException("404", "appKey not registered: " + appKey));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Phase 2:对单个应用绑定钉钉角色 + 全量推送人员。
|
|
|
+ * 前置:必须先跑过 syncOne(库内有 expanded_users 与 role_group_id)。
|
|
|
+ * 行为:
|
|
|
+ * - is_all_visible=1 跳过角色绑定(应用可见范围就是全员,无需 role)
|
|
|
+ * - role_id 为空时 roleCreate 拿到 roleId 回写
|
|
|
+ * - 全量 roleAddUsers(本期不做 diff 删除;V2 再加)
|
|
|
+ */
|
|
|
+ public AppSyncResultDto syncUsersForApp(String appKey) {
|
|
|
+ DingTalkAppMappingPo mapping = mappingDao.findByAppKey(appKey)
|
|
|
+ .orElseThrow(() -> new McException("404", "appKey not registered: " + appKey));
|
|
|
+
|
|
|
+ if (mapping.getRoleGroupId() == null) {
|
|
|
+ throw new McException("400", "role_group_id is null, run /single first: " + appKey);
|
|
|
+ }
|
|
|
+ if ("expanding".equals(mapping.getSyncStatus())) {
|
|
|
+ throw new McException("409", "部门展开进行中(异步),请待 /status 为 success 后再绑成员: " + appKey);
|
|
|
+ }
|
|
|
+
|
|
|
+ String token = getAccessToken();
|
|
|
+ try {
|
|
|
+ // 分支 A:全员可见 → 不建角色,改调钉钉 set_visible_scopes 把应用可见范围 addDeptIds=[1]
|
|
|
+ if (Boolean.TRUE.equals(mapping.getIsAllVisible())) {
|
|
|
+ ddClient_role.setMicroAppVisibleScopes(
|
|
|
+ token, Long.parseLong(mapping.getAgentId()),
|
|
|
+ java.util.Arrays.asList(1L), null, null, null, null, null);
|
|
|
+ mapping.setRoleLabel("全员(无角色)");
|
|
|
+ mapping.setSyncStatus("success_all_visible");
|
|
|
+ mapping.setLastErrMsg(null);
|
|
|
+ mapping.setLastSyncAt(new Date());
|
|
|
+ mappingDao.save(mapping);
|
|
|
+ return AppSyncResultDto.builder()
|
|
|
+ .appName(mapping.getAppName())
|
|
|
+ .roleGroupId(mapping.getRoleGroupId())
|
|
|
+ .isAllVisible(Boolean.TRUE)
|
|
|
+ .syncStatus("success_all_visible").build();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 分支 B:非全员 → 建角色 + 加人,角色名 = "分组 - 应用名"
|
|
|
+ String roleName = buildRoleName(mapping);
|
|
|
+ if (mapping.getRoleId() == null) {
|
|
|
+ String roleId = ddClient_role.roleCreate(token, roleName, mapping.getRoleGroupId());
|
|
|
+ if (roleId == null) {
|
|
|
+ throw new McException("502", "roleCreate returned null roleId");
|
|
|
+ }
|
|
|
+ mapping.setRoleId(roleId);
|
|
|
+ } else if (!roleName.equals(mapping.getRoleLabel())) {
|
|
|
+ // 已建但名称需要按新规则更新(如 DHR → "集团应用 - DHR")
|
|
|
+ ddClient_role.roleUpdate(token, mapping.getRoleId(), roleName);
|
|
|
+ }
|
|
|
+
|
|
|
+ List<String> users = JSON.parseArray(
|
|
|
+ mapping.getExpandedUsers() == null ? "[]" : mapping.getExpandedUsers(),
|
|
|
+ String.class);
|
|
|
+ ddClient_role.roleAddUsers(token, mapping.getRoleId(), users);
|
|
|
+
|
|
|
+ mapping.setRoleLabel(roleName);
|
|
|
+ mapping.setSyncStatus("success");
|
|
|
+ mapping.setLastErrMsg(null);
|
|
|
+ mapping.setLastSyncAt(new Date());
|
|
|
+ mappingDao.save(mapping);
|
|
|
+
|
|
|
+ return AppSyncResultDto.builder()
|
|
|
+ .appName(mapping.getAppName())
|
|
|
+ .roleGroupId(mapping.getRoleGroupId())
|
|
|
+ .isAllVisible(Boolean.FALSE)
|
|
|
+ .expandedCount(users.size())
|
|
|
+ .syncStatus("success").build();
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[role-sync] syncUsersForApp 失败, appKey={}", appKey, e);
|
|
|
+ mapping.setSyncStatus("failed");
|
|
|
+ mapping.setLastErrMsg(truncate(e.getMessage(), 500));
|
|
|
+ mapping.setLastSyncAt(new Date());
|
|
|
+ mappingDao.save(mapping);
|
|
|
+ throw e instanceof McException ? (McException) e : new McException("502", "dingtalk vendor error: " + e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String buildRoleName(DingTalkAppMappingPo mapping) {
|
|
|
+ String group = mapping.getAppGroup();
|
|
|
+ if (group == null || group.isEmpty()) return mapping.getAppName();
|
|
|
+ return group + " - " + mapping.getAppName();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从钉钉拉取自建应用列表,新增到 t_dingtalk_app_mapping;
|
|
|
+ * 已存在 appKey 跳过。返回新增数。
|
|
|
+ */
|
|
|
+ public Map importApps() {
|
|
|
+ String token = getAccessToken();
|
|
|
+ List<Map> apps = ddClient_role.listMicroApp(token);
|
|
|
+ int inserted = 0, skipped = 0;
|
|
|
+ // 钉钉 /microapp/list 不返回 appKey,以 agentId 作为唯一标识;app_key 字段写占位 "ag-{agentId}"
|
|
|
+ for (Map app : apps) {
|
|
|
+ Object agentIdObj = app.get("agentId");
|
|
|
+ String name = String.valueOf(app.get("name"));
|
|
|
+ if (agentIdObj == null) continue;
|
|
|
+ String agentId = String.valueOf(agentIdObj);
|
|
|
+ String appKeyPlaceholder = "ag-" + agentId;
|
|
|
+ if (mappingDao.findByAppKey(appKeyPlaceholder).isPresent() ||
|
|
|
+ mappingDao.findByAppKey(agentId).isPresent()) {
|
|
|
+ skipped++;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ // 也排查真实 appKey 已存在的应用(如 DHR 手工录入)
|
|
|
+ boolean existsByAgent = mappingDao.findAll().stream()
|
|
|
+ .anyMatch(p -> agentId.equals(p.getAgentId()));
|
|
|
+ if (existsByAgent) {
|
|
|
+ skipped++;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ DingTalkAppMappingPo po = new DingTalkAppMappingPo();
|
|
|
+ po.setAppName(name);
|
|
|
+ po.setAgentId(agentId);
|
|
|
+ po.setAppKey(appKeyPlaceholder);
|
|
|
+ po.setSyncStatus("pending");
|
|
|
+ mappingDao.save(po);
|
|
|
+ inserted++;
|
|
|
+ }
|
|
|
+ Map ret = new java.util.HashMap();
|
|
|
+ ret.put("totalFromDingtalk", apps.size());
|
|
|
+ ret.put("inserted", inserted);
|
|
|
+ ret.put("skipped", skipped);
|
|
|
+ return ret;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 全量批量:对映射表里**所有**应用按顺序跑 syncOne → syncUsersForApp。
|
|
|
+ * 单条失败不中断其它,落 sync_status 即可。
|
|
|
+ */
|
|
|
+ /**
|
|
|
+ * 一站式初始化:落库 + 建角色 + 绑人员 + 反写应用可见范围
|
|
|
+ * 业务方主要入口,幂等(重复跑同 agentId,会重读可见范围并补齐数据)
|
|
|
+ */
|
|
|
+ public AppSyncResultDto initApp(String agentId, String appName, String appGroup) {
|
|
|
+ String token = getAccessToken();
|
|
|
+ // 0. 幂等守卫:已成功初始化的应用直接返回当前状态(可见范围已反写为角色或根部门,再次读 visible_scopes 会丢原始数据)
|
|
|
+ DingTalkAppMappingPo existing = mappingDao.findFirstByAgentId(agentId).orElse(null);
|
|
|
+ // fixme: skip_shortcut = OA办公组快捷应用(工作台快捷方式配置地址,默认全员可见),不动钉钉应用本身可见范围;
|
|
|
+ // 必须纳入幂等守卫,否则重跑会走全员分支误调 writeBackToRootDept 改可见范围
|
|
|
+ if (existing != null && existing.getLastSyncAt() != null &&
|
|
|
+ ("success".equals(existing.getSyncStatus()) || "success_all_visible".equals(existing.getSyncStatus())
|
|
|
+ || "skip_shortcut".equals(existing.getSyncStatus()))) {
|
|
|
+ log.info("[role-sync] initApp 幂等返回 agentId={}, 已 {} 于 {}", agentId, existing.getSyncStatus(), existing.getLastSyncAt());
|
|
|
+ return AppSyncResultDto.builder()
|
|
|
+ .appName(existing.getAppName())
|
|
|
+ .roleGroupId(existing.getRoleGroupId())
|
|
|
+ .isAllVisible(Boolean.TRUE.equals(existing.getIsAllVisible()))
|
|
|
+ .expandedCount(existing.getExpandedCount())
|
|
|
+ .syncStatus(existing.getSyncStatus()).build();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 1. 查或建 mapping
|
|
|
+ DingTalkAppMappingPo mapping = existing;
|
|
|
+ if (mapping == null) {
|
|
|
+ String resolvedName = (appName != null && !appName.isEmpty()) ? appName : lookupAppNameFromDingtalk(token, agentId);
|
|
|
+ mapping = new DingTalkAppMappingPo();
|
|
|
+ mapping.setAppName(resolvedName);
|
|
|
+ mapping.setAppGroup(appGroup);
|
|
|
+ mapping.setAgentId(agentId);
|
|
|
+ mapping.setAppKey("ag-" + agentId);
|
|
|
+ mapping.setSyncStatus("pending");
|
|
|
+ mapping = mappingDao.save(mapping);
|
|
|
+ } else {
|
|
|
+ if (appName != null && !appName.isEmpty()) mapping.setAppName(appName);
|
|
|
+ if (appGroup != null) mapping.setAppGroup(appGroup);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 读可见范围 + 展开
|
|
|
+ VisibleScopeResolved scope = resolveScope(token, mapping.getAgentId());
|
|
|
+
|
|
|
+ // 3. 落 raw
|
|
|
+ mapping.setRawDeptIds(JSON.toJSONString(scope.deptIds));
|
|
|
+ mapping.setRawUserIds(JSON.toJSONString(scope.userIds));
|
|
|
+ mapping.setIsAllVisible(scope.isAllVisible);
|
|
|
+ mapping.setIsHidden(scope.isHidden);
|
|
|
+
|
|
|
+ // 4. 全局角色组
|
|
|
+ ensureRoleGroup(token);
|
|
|
+ String roleGroupId = mappingDao.findFirstByRoleGroupIdIsNotNull()
|
|
|
+ .map(DingTalkAppMappingPo::getRoleGroupId).orElse(null);
|
|
|
+ mapping.setRoleGroupId(roleGroupId);
|
|
|
+
|
|
|
+ long agentIdNum = Long.parseLong(mapping.getAgentId());
|
|
|
+ String status;
|
|
|
+ if (scope.isAllVisible) {
|
|
|
+ // 全员路径:反写应用可见范围 addDept=1 + 清非根 + 清 user
|
|
|
+ mapping.setExpandedUsers(null);
|
|
|
+ mapping.setExpandedCount(null);
|
|
|
+ mapping.setRoleLabel("全员(无角色)");
|
|
|
+ writeBackToRootDept(token, agentIdNum, scope.deptIds, scope.userIds);
|
|
|
+ status = "success_all_visible";
|
|
|
+ } else {
|
|
|
+ if (!scope.expandOk) {
|
|
|
+ throw new McException("502", "部门展开失败: " + scope.expandErr);
|
|
|
+ }
|
|
|
+ mapping.setExpandedUsers(JSON.toJSONString(scope.expanded));
|
|
|
+ mapping.setExpandedCount(scope.expanded.size());
|
|
|
+ String roleName = buildRoleName(mapping);
|
|
|
+ if (mapping.getRoleId() == null) {
|
|
|
+ String roleId = ddClient_role.roleCreate(token, roleName, roleGroupId);
|
|
|
+ if (roleId == null) throw new McException("502", "roleCreate returned null");
|
|
|
+ mapping.setRoleId(roleId);
|
|
|
+ }
|
|
|
+ mapping.setRoleLabel(roleName);
|
|
|
+ // 全量加人
|
|
|
+ ddClient_role.roleAddUsers(token, mapping.getRoleId(), scope.expanded);
|
|
|
+ // 反写:addRole + 清 dept/user
|
|
|
+ writeBackToRole(token, agentIdNum, mapping.getRoleId(), scope.deptIds, scope.userIds);
|
|
|
+ status = "success";
|
|
|
+ }
|
|
|
+ mapping.setSyncStatus(status);
|
|
|
+ mapping.setLastErrMsg(null);
|
|
|
+ mapping.setLastSyncAt(new Date());
|
|
|
+ mappingDao.save(mapping);
|
|
|
+
|
|
|
+ return AppSyncResultDto.builder()
|
|
|
+ .appName(mapping.getAppName())
|
|
|
+ .roleGroupId(roleGroupId)
|
|
|
+ .isAllVisible(scope.isAllVisible)
|
|
|
+ .expandedCount(mapping.getExpandedCount())
|
|
|
+ .syncStatus(status).build();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 增量更新角色成员(不动应用可见范围、不读可见范围)
|
|
|
+ */
|
|
|
+ public Map updateUsers(String agentId, List<String> addUserIds, List<String> delUserIds) {
|
|
|
+ DingTalkAppMappingPo mapping = mappingDao.findFirstByAgentId(agentId)
|
|
|
+ .orElseThrow(() -> new McException("404", "agentId not registered: " + agentId));
|
|
|
+ if (mapping.getRoleId() == null) {
|
|
|
+ throw new McException("400", "该应用未建角色(可能为全员可见),无法增量更新角色成员");
|
|
|
+ }
|
|
|
+ String token = getAccessToken();
|
|
|
+ int addedCount = 0, deletedCount = 0;
|
|
|
+ if (addUserIds != null && !addUserIds.isEmpty()) {
|
|
|
+ ddClient_role.roleAddUsers(token, mapping.getRoleId(), addUserIds);
|
|
|
+ addedCount = addUserIds.size();
|
|
|
+ }
|
|
|
+ if (delUserIds != null && !delUserIds.isEmpty()) {
|
|
|
+ ddClient_role.roleDeleteUsers(token, mapping.getRoleId(), delUserIds);
|
|
|
+ deletedCount = delUserIds.size();
|
|
|
+ }
|
|
|
+ // 更新 mapping.expanded_users
|
|
|
+ List<String> current = mapping.getExpandedUsers() == null
|
|
|
+ ? new ArrayList<>()
|
|
|
+ : JSON.parseArray(mapping.getExpandedUsers(), String.class);
|
|
|
+ LinkedHashSet<String> set = new LinkedHashSet<>(current);
|
|
|
+ if (addUserIds != null) set.addAll(addUserIds);
|
|
|
+ if (delUserIds != null) set.removeAll(delUserIds);
|
|
|
+ mapping.setExpandedUsers(JSON.toJSONString(set));
|
|
|
+ mapping.setExpandedCount(set.size());
|
|
|
+ mapping.setLastSyncAt(new Date());
|
|
|
+ mappingDao.save(mapping);
|
|
|
+
|
|
|
+ Map ret = new java.util.HashMap();
|
|
|
+ ret.put("agentId", agentId);
|
|
|
+ ret.put("roleId", mapping.getRoleId());
|
|
|
+ ret.put("addedCount", addedCount);
|
|
|
+ ret.put("deletedCount", deletedCount);
|
|
|
+ ret.put("totalCount", set.size());
|
|
|
+ return ret;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 第二步(独立):仅把应用可见范围写回为已建角色(addRoleIds + 清原 dept/user)。
|
|
|
+ * 不读可见范围、不展开、不建角色;要求该应用已建角色(role_id 非空)。
|
|
|
+ * 用 mapping 落库的 raw_dept_ids/raw_user_ids 作为待清的原可见范围。
|
|
|
+ */
|
|
|
+ public Map writeBackOne(String agentId) {
|
|
|
+ DingTalkAppMappingPo mapping = mappingDao.findFirstByAgentId(agentId)
|
|
|
+ .orElseThrow(() -> new McException("404", "agentId not registered: " + agentId));
|
|
|
+ if (mapping.getRoleId() == null) {
|
|
|
+ throw new McException("400", "该应用未建角色(可能为全员可见),无法写回可见范围为角色: " + agentId);
|
|
|
+ }
|
|
|
+ String token = getAccessToken();
|
|
|
+ List<Long> curDepts = mapping.getRawDeptIds() == null ? Collections.emptyList()
|
|
|
+ : JSON.parseArray(mapping.getRawDeptIds(), Long.class);
|
|
|
+ List<String> curUsers = mapping.getRawUserIds() == null ? Collections.emptyList()
|
|
|
+ : JSON.parseArray(mapping.getRawUserIds(), String.class);
|
|
|
+ writeBackToRole(token, Long.parseLong(mapping.getAgentId()), mapping.getRoleId(), curDepts, curUsers);
|
|
|
+ mapping.setLastSyncAt(new Date());
|
|
|
+ mappingDao.save(mapping);
|
|
|
+
|
|
|
+ Map ret = new java.util.HashMap();
|
|
|
+ ret.put("agentId", agentId);
|
|
|
+ ret.put("roleId", mapping.getRoleId());
|
|
|
+ ret.put("roleLabel", mapping.getRoleLabel());
|
|
|
+ ret.put("clearedDepts", curDepts.size());
|
|
|
+ ret.put("clearedUsers", curUsers.size());
|
|
|
+ return ret;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 反写为根部门:addDeptIds=[1] + 删非根 dept + 删全部 user(user 分 50/批) */
|
|
|
+ private void writeBackToRootDept(String token, long agentId, List<Long> curDepts, List<String> curUsers) {
|
|
|
+ List<Long> delDepts = curDepts == null ? Collections.emptyList()
|
|
|
+ : curDepts.stream().filter(d -> d != 1L).collect(Collectors.toList());
|
|
|
+ ddClient_role.setMicroAppVisibleScopes(token, agentId,
|
|
|
+ java.util.Arrays.asList(1L), null, null,
|
|
|
+ delDepts.isEmpty() ? null : delDepts, null, null);
|
|
|
+ delUsersBatched(token, agentId, curUsers);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 反写为角色:addRoleIds=[roleId] + 删 dept + 删 user(user 分 50/批) */
|
|
|
+ private void writeBackToRole(String token, long agentId, String roleId, List<Long> curDepts, List<String> curUsers) {
|
|
|
+ ddClient_role.setMicroAppVisibleScopes(token, agentId,
|
|
|
+ null, null, java.util.Arrays.asList(roleId),
|
|
|
+ curDepts == null || curDepts.isEmpty() ? null : curDepts, null, null);
|
|
|
+ delUsersBatched(token, agentId, curUsers);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 钉钉 set_visible_scopes 单次 delUserIds 上限约 50,分批 */
|
|
|
+ private void delUsersBatched(String token, long agentId, List<String> users) {
|
|
|
+ if (users == null || users.isEmpty()) return;
|
|
|
+ for (int i = 0; i < users.size(); i += 50) {
|
|
|
+ List<String> batch = users.subList(i, Math.min(i + 50, users.size()));
|
|
|
+ ddClient_role.setMicroAppVisibleScopes(token, agentId,
|
|
|
+ null, null, null, null, batch, null);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private String lookupAppNameFromDingtalk(String token, String agentId) {
|
|
|
+ long aid = Long.parseLong(agentId);
|
|
|
+ List<Map> apps = ddClient_role.listMicroApp(token);
|
|
|
+ return apps.stream()
|
|
|
+ .filter(a -> a.get("agentId") != null && Long.parseLong(String.valueOf(a.get("agentId"))) == aid)
|
|
|
+ .map(a -> String.valueOf(a.get("name")))
|
|
|
+ .findFirst()
|
|
|
+ .orElseThrow(() -> new McException("404", "agentId 在钉钉应用列表中未找到: " + agentId));
|
|
|
+ }
|
|
|
+
|
|
|
+ public List<AppSyncResultDto> syncAll() {
|
|
|
+ List<DingTalkAppMappingPo> all = mappingDao.findAll();
|
|
|
+ List<AppSyncResultDto> results = new ArrayList<>(all.size());
|
|
|
+ for (DingTalkAppMappingPo po : all) {
|
|
|
+ try {
|
|
|
+ syncOne(po.getAppKey());
|
|
|
+ results.add(syncUsersForApp(po.getAppKey()));
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[role-sync] syncAll 单条失败, appKey={}", po.getAppKey(), e);
|
|
|
+ results.add(AppSyncResultDto.builder()
|
|
|
+ .appName(po.getAppName())
|
|
|
+ .syncStatus("failed")
|
|
|
+ .build());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return results;
|
|
|
+ }
|
|
|
+
|
|
|
+ /// 内部工具 ///
|
|
|
+
|
|
|
+ private String getAccessToken() {
|
|
|
+ return ddClient.getAccessToken(
|
|
|
+ guangmingConfig.getDingtalk().getAppKey(),
|
|
|
+ guangmingConfig.getDingtalk().getAppSecret());
|
|
|
+ }
|
|
|
+
|
|
|
+ private VisibleScopeResolved resolveScope(String token, String agentIdStr) {
|
|
|
+ VisibleScopeResolved scope = readScope(token, agentIdStr);
|
|
|
+ if (!scope.isAllVisible) {
|
|
|
+ try {
|
|
|
+ scope.expanded = expandDeptUsersParallel(token, scope.deptIds, scope.userIds);
|
|
|
+ scope.expandOk = true;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("[role-sync] 部门展开失败(可能权限未开通 qyapi_get_department_list),降级 raw-only: {}", e.getMessage());
|
|
|
+ scope.expandErr = e.getMessage();
|
|
|
+ scope.expandOk = false;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ scope.expandOk = true;
|
|
|
+ }
|
|
|
+ return scope;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 只读可见范围(不展开):1 次 visible_scopes 调用,快;供异步展开路径先拿 raw 再后台展开 */
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private VisibleScopeResolved readScope(String token, String agentIdStr) {
|
|
|
+ long agentId = Long.parseLong(agentIdStr);
|
|
|
+ Map rsp = ddClient_role.getMicroAppVisibleScopes(token, agentId);
|
|
|
+
|
|
|
+ List<Number> rawDept = rsp.get("deptVisibleScopes") instanceof List ? (List<Number>) rsp.get("deptVisibleScopes") : Collections.emptyList();
|
|
|
+ List<String> rawUser = rsp.get("userVisibleScopes") instanceof List ? (List<String>) rsp.get("userVisibleScopes") : Collections.emptyList();
|
|
|
+ Boolean isHidden = rsp.get("isHidden") instanceof Boolean ? (Boolean) rsp.get("isHidden") : Boolean.FALSE;
|
|
|
+
|
|
|
+ List<Long> deptIds = new ArrayList<>();
|
|
|
+ for (Number n : rawDept) deptIds.add(n.longValue());
|
|
|
+ List<String> userIds = new ArrayList<>(rawUser);
|
|
|
+
|
|
|
+ VisibleScopeResolved scope = new VisibleScopeResolved();
|
|
|
+ scope.deptIds = deptIds;
|
|
|
+ scope.userIds = userIds;
|
|
|
+ scope.isHidden = isHidden;
|
|
|
+ scope.isAllVisible = deptIds.contains(DDConf.TOP_DEPARTMENT);
|
|
|
+ return scope;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 并行展开部门成员:并行 BFS 收集子部门 + 并行取各部门成员。
|
|
|
+ * 替代 base 顺序递归 getDepartmentId_all + 逐部门 listDepartmentUserId,
|
|
|
+ * 降低大部门可见范围在同步 HTTP 内展开的耗时(差旅类 400+ 部门曾顺序 800+ 调用超时)。
|
|
|
+ */
|
|
|
+ private Set<String> expandDeptUsersParallel(String token, List<Long> deptIds, List<String> seedUserIds) throws Exception {
|
|
|
+ // 1. 并行 BFS 逐层收集子部门;visited(allDept)去重,避免重叠/嵌套部门重复展开
|
|
|
+ Set<Long> allDept = ConcurrentHashMap.newKeySet();
|
|
|
+ allDept.addAll(deptIds);
|
|
|
+ List<Long> frontier = new ArrayList<>(deptIds);
|
|
|
+ ForkJoinPool pool = new ForkJoinPool(EXPAND_CONCURRENCY);
|
|
|
+ try {
|
|
|
+ while (!frontier.isEmpty()) {
|
|
|
+ final List<Long> level = frontier;
|
|
|
+ List<Long> children = pool.submit(() ->
|
|
|
+ level.parallelStream()
|
|
|
+ .flatMap(d -> ddClient_contacts.listSubDepartmentId(token, d).stream())
|
|
|
+ .collect(Collectors.toList())
|
|
|
+ ).get();
|
|
|
+ List<Long> next = new ArrayList<>();
|
|
|
+ for (Long c : children) {
|
|
|
+ // fixme: allDept.add 返回 false = 已访问,跳过,防重叠部门导致的重复 BFS
|
|
|
+ if (allDept.add(c)) next.add(c);
|
|
|
+ }
|
|
|
+ frontier = next;
|
|
|
+ }
|
|
|
+ // 2. 并行取各部门成员(seed = 可见范围里直接指定的 userIds)
|
|
|
+ List<Long> deptList = new ArrayList<>(allDept);
|
|
|
+ Set<String> deptUsers = pool.submit(() ->
|
|
|
+ deptList.parallelStream()
|
|
|
+ .flatMap(d -> ddClient_contacts.listDepartmentUserId(token, d).stream())
|
|
|
+ .collect(Collectors.toSet())
|
|
|
+ ).get();
|
|
|
+ Set<String> users = new LinkedHashSet<>(seedUserIds);
|
|
|
+ users.addAll(deptUsers);
|
|
|
+ return users;
|
|
|
+ } finally {
|
|
|
+ pool.shutdown();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 全局角色组只创建一次。
|
|
|
+ * synchronized 保护并发同步同一个 appKey 时不重复创建。
|
|
|
+ */
|
|
|
+ private synchronized void ensureRoleGroup(String token) {
|
|
|
+ if (mappingDao.findFirstByRoleGroupIdIsNotNull().isPresent()) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ String groupId = ddClient_role.roleGroupCreate(token, ROLE_GROUP_NAME);
|
|
|
+ if (groupId == null) {
|
|
|
+ throw new McException("502", "roleGroupCreate returned null groupId");
|
|
|
+ }
|
|
|
+ int updated = mappingDao.updateAllRoleGroupId(groupId);
|
|
|
+ log.info("[role-sync] 角色组创建成功 groupId={}, 回填行数={}", groupId, updated);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String truncate(String s, int max) {
|
|
|
+ if (s == null) return null;
|
|
|
+ return s.length() <= max ? s : s.substring(0, max);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 内部值对象 */
|
|
|
+ private static class VisibleScopeResolved {
|
|
|
+ List<Long> deptIds;
|
|
|
+ List<String> userIds;
|
|
|
+ Boolean isHidden;
|
|
|
+ boolean isAllVisible;
|
|
|
+ Set<String> expanded = Collections.emptySet();
|
|
|
+ boolean expandOk;
|
|
|
+ String expandErr;
|
|
|
+ }
|
|
|
+}
|