Просмотр исходного кода

feat(guangming): 钉钉应用权限同步 — 角色管理 + 应用可见范围反写

mjava 公共 SDK 扩展:
- DDClient_Role 接口(应用可见范围 + 应用列表 + 角色组/角色 CRUD + 角色成员批量增删)
- DDImplClient_Role 实现,体内统一 fastjson UTF-8 编码 + errcode 校验

guangming 子模块新增 service / controller / repository:
- AppRoleSyncService.initApp(agentId, appName?, appGroup?) 一站式初始化(读可见范围 → 部门递归展开 → 落库 → 建唯一角色组「开放平台应用权限」→ 建角色 + 绑人 → 反写应用可见范围 addRoleIds);幂等守卫
- AppRoleSyncService.updateUsers(agentId, addUserIds, delUserIds) 增量更新角色成员,不动应用可见范围
- 配套运维接口:single / preview / status / users-sync / all / import-apps
- t_dingtalk_app_mapping 实体 + JPA Dao(findByAppKey / findFirstByAgentId / findFirstByRoleGroupIdIsNotNull)

业务方主接口:
- POST /api/gm/role-sync/init
- POST /api/gm/role-sync/users-update

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
malk 1 месяц назад
Родитель
Сommit
08c4ab6f21

+ 107 - 0
mjava-guangming/src/main/java/com/malk/guangming/controller/AppRoleSyncController.java

@@ -0,0 +1,107 @@
+package com.malk.guangming.controller;
+
+import com.malk.guangming.dto.AppPreviewDto;
+import com.malk.guangming.dto.AppSyncResultDto;
+import com.malk.guangming.repository.entity.primary.DingTalkAppMappingPo;
+import com.malk.guangming.service.AppRoleSyncService;
+import com.malk.server.common.McException;
+import com.malk.server.common.McR;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Slf4j
+@RestController
+@RequestMapping("/role-sync")
+public class AppRoleSyncController {
+
+    @Autowired
+    private AppRoleSyncService appRoleSyncService;
+
+    @PostMapping("/single")
+    public McR<AppSyncResultDto> single(@RequestParam String appKey) {
+        try {
+            return McR.success(appRoleSyncService.syncOne(appKey));
+        } catch (McException e) {
+            return wrap(e);
+        }
+    }
+
+    @GetMapping("/preview")
+    public McR<AppPreviewDto> preview(@RequestParam String appKey) {
+        try {
+            return McR.success(appRoleSyncService.previewOne(appKey));
+        } catch (McException e) {
+            return wrap(e);
+        }
+    }
+
+    @GetMapping("/status")
+    public McR<DingTalkAppMappingPo> status(@RequestParam String appKey) {
+        try {
+            return McR.success(appRoleSyncService.getStatus(appKey));
+        } catch (McException e) {
+            return wrap(e);
+        }
+    }
+
+    /** Phase 2:对单应用绑定钉钉角色 + 全量推送人员 */
+    @PostMapping("/users-sync")
+    public McR<AppSyncResultDto> usersSync(@RequestParam String appKey) {
+        try {
+            return McR.success(appRoleSyncService.syncUsersForApp(appKey));
+        } catch (McException e) {
+            return wrap(e);
+        }
+    }
+
+    /** Phase 2:全表批量(顺序对每条 mapping 跑 single + users-sync) */
+    @PostMapping("/all")
+    public McR<java.util.List<AppSyncResultDto>> all() {
+        return McR.success(appRoleSyncService.syncAll());
+    }
+
+    /** 从钉钉拉取自建应用列表 → 自动 INSERT 到映射表(已存在则跳过) */
+    @PostMapping("/import-apps")
+    public McR<java.util.Map> importApps() {
+        try {
+            return McR.success(appRoleSyncService.importApps());
+        } catch (McException e) {
+            return wrap(e);
+        }
+    }
+
+    /** 一站式初始化:钉钉新应用 → 落库 + 建角色 + 绑人员 + 反写应用可见范围 */
+    @PostMapping("/init")
+    public McR<AppSyncResultDto> init(@RequestParam String agentId,
+                                       @RequestParam(required = false) String appName,
+                                       @RequestParam(required = false) String appGroup) {
+        try {
+            return McR.success(appRoleSyncService.initApp(agentId, appName, appGroup));
+        } catch (McException e) {
+            return wrap(e);
+        }
+    }
+
+    /** 增量更新角色成员(不动应用本身可见范围) */
+    @org.springframework.web.bind.annotation.PostMapping("/users-update")
+    public McR<java.util.Map> usersUpdate(@RequestParam String agentId,
+                                          @org.springframework.web.bind.annotation.RequestBody com.malk.guangming.dto.UsersUpdateDto body) {
+        try {
+            return McR.success(appRoleSyncService.updateUsers(agentId,
+                    body == null ? null : body.getAddUserIds(),
+                    body == null ? null : body.getDelUserIds()));
+        } catch (McException e) {
+            return wrap(e);
+        }
+    }
+
+    private static <T> McR<T> wrap(McException e) {
+        if ("404".equals(e.getCode())) return (McR<T>) McR.errorParam(e.getMessage());
+        return (McR<T>) McR.errorVendor(e.getMessage(), "dingtalk");
+    }
+}

+ 24 - 0
mjava-guangming/src/main/java/com/malk/guangming/dto/AppPreviewDto.java

@@ -0,0 +1,24 @@
+package com.malk.guangming.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@Builder
+public class AppPreviewDto {
+
+    private String appName;
+
+    private List<Long> rawDeptIds;
+
+    private List<String> rawUserIds;
+
+    private Boolean isAllVisible;
+
+    private Boolean isHidden;
+
+    /** null when isAllVisible */
+    private List<String> expanded;
+}

+ 21 - 0
mjava-guangming/src/main/java/com/malk/guangming/dto/AppSyncResultDto.java

@@ -0,0 +1,21 @@
+package com.malk.guangming.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class AppSyncResultDto {
+
+    private String appName;
+
+    private String roleGroupId;
+
+    private Boolean isAllVisible;
+
+    /** null when isAllVisible */
+    private Integer expandedCount;
+
+    /** success / failed */
+    private String syncStatus;
+}

+ 13 - 0
mjava-guangming/src/main/java/com/malk/guangming/dto/UsersUpdateDto.java

@@ -0,0 +1,13 @@
+package com.malk.guangming.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class UsersUpdateDto {
+
+    private List<String> addUserIds;
+
+    private List<String> delUserIds;
+}

+ 24 - 0
mjava-guangming/src/main/java/com/malk/guangming/repository/dao/DingTalkAppMappingDao.java

@@ -0,0 +1,24 @@
+package com.malk.guangming.repository.dao;
+
+import com.malk.guangming.repository.entity.primary.DingTalkAppMappingPo;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Optional;
+
+public interface DingTalkAppMappingDao extends JpaRepository<DingTalkAppMappingPo, Long> {
+
+    Optional<DingTalkAppMappingPo> findByAppKey(String appKey);
+
+    Optional<DingTalkAppMappingPo> findFirstByAgentId(String agentId);
+
+    Optional<DingTalkAppMappingPo> findFirstByRoleGroupIdIsNotNull();
+
+    @Modifying
+    @Transactional
+    @Query("update DingTalkAppMappingPo p set p.roleGroupId = :groupId where p.roleGroupId is null")
+    int updateAllRoleGroupId(@Param("groupId") String groupId);
+}

+ 82 - 0
mjava-guangming/src/main/java/com/malk/guangming/repository/entity/primary/DingTalkAppMappingPo.java

@@ -0,0 +1,82 @@
+package com.malk.guangming.repository.entity.primary;
+
+import com.malk.base.BasePo;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.Table;
+import java.util.Date;
+
+/**
+ * 钉钉开放平台应用 → 角色映射
+ * <p>
+ * BasePo 自动注入 id / createTime / updateTime,无需重复声明。
+ */
+@Entity
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Table(name = "t_dingtalk_app_mapping",
+        indexes = {
+                @Index(name = "uk_app_key", columnList = "appKey", unique = true),
+                @Index(name = "idx_agent_id", columnList = "agentId"),
+                @Index(name = "idx_app_name", columnList = "appName")
+        })
+public class DingTalkAppMappingPo extends BasePo {
+
+    /** 应用名称(即未来角色名) */
+    private String appName;
+
+    /** 应用分组,如 集团应用 / DHR人力助手 */
+    private String appGroup;
+
+    /** 钉钉 agentId */
+    private String agentId;
+
+    /** 钉钉应用 appKey */
+    private String appKey;
+
+    /** 保留字段,本期不用(corp 级凭据已够) */
+    private String appSecret;
+
+    /** 全表共享同一值,首次创建后回写所有行 */
+    private String roleGroupId;
+
+    /** 本期不创建,第二期回写 */
+    private String roleId;
+
+    /** 角色标签:正常 "分组 - 应用名",全员可见 "全员(无角色)",用于回写 axls 角色列 */
+    @Column(length = 128)
+    private String roleLabel;
+
+    /** 可见范围原始 deptIds JSON 数组 */
+    @Column(columnDefinition = "TEXT")
+    private String rawDeptIds;
+
+    /** 可见范围原始 userIds JSON 数组 */
+    @Column(columnDefinition = "TEXT")
+    private String rawUserIds;
+
+    /** 部门展开后去重 userId JSON 数组;全员可见时为空 */
+    @Column(columnDefinition = "MEDIUMTEXT")
+    private String expandedUsers;
+
+    /** 展开后人数;全员可见时为空 */
+    private Integer expandedCount;
+
+    /** 是否全员可见(deptIds 含 1) */
+    private Boolean isAllVisible;
+
+    /** 钉钉 isHidden 字段 */
+    private Boolean isHidden;
+
+    private Date lastSyncAt;
+
+    /** pending / success / failed */
+    private String syncStatus;
+
+    @Column(length = 512)
+    private String lastErrMsg;
+}

+ 537 - 0
mjava-guangming/src/main/java/com/malk/guangming/service/AppRoleSyncService.java

@@ -0,0 +1,537 @@
+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.stereotype.Service;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class AppRoleSyncService {
+
+    private static final String ROLE_GROUP_NAME = "开放平台应用权限";
+
+    @Autowired
+    private GuangmingConfig guangmingConfig;
+
+    @Autowired
+    private DDClient ddClient;
+
+    @Autowired
+    private DDClient_Contacts ddClient_contacts;
+
+    @Autowired
+    private DDClient_Role ddClient_role;
+
+    @Autowired
+    private DingTalkAppMappingDao mappingDao;
+
+    /**
+     * 单应用同步:读取可见范围 → 部门展开 → 落库;全表首次同步时创建角色组
+     */
+    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();
+    }
+
+    /**
+     * 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);
+        }
+
+        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);
+        if (existing != null && existing.getLastSyncAt() != null &&
+            ("success".equals(existing.getSyncStatus()) || "success_all_visible".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;
+    }
+
+    /** 反写为根部门: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());
+    }
+
+    @SuppressWarnings("unchecked")
+    private VisibleScopeResolved resolveScope(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);
+
+        if (!scope.isAllVisible) {
+            try {
+                Set<Long> allDept = new HashSet<>();
+                for (Long d : deptIds) {
+                    allDept.addAll(ddClient_contacts.getDepartmentId_all(token, true, d));
+                }
+                Set<String> users = new LinkedHashSet<>(userIds);
+                for (Long d : allDept) {
+                    users.addAll(ddClient_contacts.listDepartmentUserId(token, d));
+                }
+                scope.expanded = users;
+                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;
+    }
+
+    /**
+     * 全局角色组只创建一次。
+     * 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;
+    }
+}

+ 79 - 0
mjava/src/main/java/com/malk/service/dingtalk/DDClient_Role.java

@@ -0,0 +1,79 @@
+package com.malk.service.dingtalk;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 钉钉应用 + 角色管理
+ * <p>
+ * Phase 1:getMicroAppVisibleScopes + roleGroupCreate(数据落库 + 全局角色组)
+ * Phase 2:roleCreate + roleAddUsers + roleDeleteUsers(每应用一角色 + 全量绑人员)
+ *
+ * @apiNote https://open.dingtalk.com/document/orgapp/contacts-overview
+ */
+public interface DDClient_Role {
+
+    /**
+     * 获取微应用可见范围
+     *
+     * @return Map { deptVisibleScopes: List<Long>, userVisibleScopes: List<String>, isHidden: Boolean }
+     * @apiNote https://open.dingtalk.com/document/orgapp/queries-the-visible-range-of-a-microapp
+     */
+    Map getMicroAppVisibleScopes(String access_token, long agentId);
+
+    /**
+     * 创建角色组
+     *
+     * @return groupId(字符串形式)
+     * @apiNote https://open.dingtalk.com/document/development/add-a-role-group
+     */
+    String roleGroupCreate(String access_token, String name);
+
+    /**
+     * 创建角色
+     *
+     * @return roleId(字符串形式)
+     * @apiNote https://open.dingtalk.com/document/development/add-roles
+     */
+    String roleCreate(String access_token, String roleName, String groupId);
+
+    /**
+     * 批量增加员工角色(单批最多 100 用户;impl 内部自动分批)
+     *
+     * @apiNote https://open.dingtalk.com/document/development/add-role-information-to-employees-in-batches
+     */
+    void roleAddUsers(String access_token, String roleId, Collection<String> userIds);
+
+    /**
+     * 批量删除员工角色(单批最多 100 用户;impl 内部自动分批)
+     *
+     * @apiNote https://open.dingtalk.com/document/development/delete-the-color-information-of-employee-corners-in-batches
+     */
+    void roleDeleteUsers(String access_token, String roleId, Collection<String> userIds);
+
+    /**
+     * 更新角色名
+     *
+     * @apiNote https://open.dingtalk.com/document/development/update-role-information
+     */
+    void roleUpdate(String access_token, String roleId, String roleName);
+
+    /**
+     * 设置应用可见范围(支持 dept/user/role 三种维度 add/del)
+     * <p>
+     * 钉钉单次 delUserIds 上限约 50,业务方需自行分批
+     *
+     * @apiNote https://open.dingtalk.com/document/development/update-the-visible-range-of-micro-applications
+     */
+    void setMicroAppVisibleScopes(String access_token, long agentId,
+                                  List<Long> addDeptIds, List<String> addUserIds, List<String> addRoleIds,
+                                  List<Long> delDeptIds, List<String> delUserIds, List<String> delRoleIds);
+
+    /**
+     * 获取企业自建应用列表(返回 [ {agentId, appKey, name}, ... ])
+     *
+     * @apiNote https://open.dingtalk.com/document/development/obtain-the-list-of-internal-h5-applications-created-by-the
+     */
+    List<Map> listMicroApp(String access_token);
+}

+ 151 - 0
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Role.java

@@ -0,0 +1,151 @@
+package com.malk.service.dingtalk.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.server.common.McException;
+import com.malk.server.dingtalk.DDR;
+import com.malk.service.dingtalk.DDClient_Role;
+import com.malk.utils.UtilHttp;
+import com.malk.utils.UtilMap;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Service
+@Slf4j
+public class DDImplClient_Role implements DDClient_Role {
+
+    /** 钉钉员工角色批量增/删接口单批用户上限(addrolesforemps 文档限 20) */
+    private static final int USER_BATCH_SIZE = 20;
+
+    /**
+     * 钉钉老接口响应直接平铺(无 result 嵌套):
+     * { "errcode":0, "errmsg":"ok", "deptVisibleScopes":[...], "userVisibleScopes":[...], "isHidden":false }
+     * 用 UtilHttp 拿原始字符串自解析,绕开 DDR.result 字段限制。
+     */
+    @Override
+    public Map getMicroAppVisibleScopes(String access_token, long agentId) {
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = UtilMap.map("agentId", agentId);
+        String raw = UtilHttp.doPost("https://oapi.dingtalk.com/microapp/visible_scopes", null, param, body);
+        log.info("[钉钉] 应用可见范围, agentId={}, raw={}", agentId, raw);
+        Map rsp = JSON.parseObject(raw, Map.class);
+        assertDingSuccess(rsp);
+        return rsp;
+    }
+
+    @Override
+    public String roleGroupCreate(String access_token, String name) {
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = UtilMap.map("name", name);
+        String raw = UtilHttp.doPost("https://oapi.dingtalk.com/role/add_role_group", null, param, body);
+        log.info("[钉钉] 创建角色组, name={}, raw={}", name, raw);
+        Map rsp = JSON.parseObject(raw, Map.class);
+        assertDingSuccess(rsp);
+        Object groupId = rsp.get("groupId");
+        if (groupId == null && rsp.get("result") instanceof Map) {
+            groupId = ((Map) rsp.get("result")).get("groupId");
+        }
+        return groupId == null ? null : String.valueOf(groupId);
+    }
+
+    private static void assertDingSuccess(Map rsp) {
+        Object errcode = rsp.get("errcode");
+        if (errcode != null && !"0".equals(String.valueOf(errcode))) {
+            throw new McException("502", "dingtalk errcode=" + errcode + ", errmsg=" + rsp.get("errmsg"));
+        }
+    }
+
+    @Override
+    public String roleCreate(String access_token, String roleName, String groupId) {
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = UtilMap.map("roleName, groupId", roleName, groupId);
+        String raw = UtilHttp.doPost("https://oapi.dingtalk.com/role/add_role", null, param, body);
+        log.info("[钉钉] 创建角色, name={}, groupId={}, raw={}", roleName, groupId, raw);
+        Map rsp = JSON.parseObject(raw, Map.class);
+        assertDingSuccess(rsp);
+        Object roleId = rsp.get("roleId");
+        if (roleId == null && rsp.get("result") instanceof Map) {
+            roleId = ((Map) rsp.get("result")).get("roleId");
+        }
+        return roleId == null ? null : String.valueOf(roleId);
+    }
+
+    @Override
+    public void roleAddUsers(String access_token, String roleId, Collection<String> userIds) {
+        callBatch(access_token, "https://oapi.dingtalk.com/topapi/role/addrolesforemps", roleId, userIds, "add");
+    }
+
+    @Override
+    public void roleDeleteUsers(String access_token, String roleId, Collection<String> userIds) {
+        callBatch(access_token, "https://oapi.dingtalk.com/topapi/role/removerolesforemps", roleId, userIds, "remove");
+    }
+
+    @Override
+    public void roleUpdate(String access_token, String roleId, String roleName) {
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = UtilMap.map("roleId, roleName", roleId, roleName);
+        String raw = UtilHttp.doPost("https://oapi.dingtalk.com/role/update_role", null, param, body);
+        log.info("[钉钉] 更新角色, roleId={}, roleName={}, raw={}", roleId, roleName, raw);
+        assertDingSuccess(JSON.parseObject(raw, Map.class));
+    }
+
+    @Override
+    public void setMicroAppVisibleScopes(String access_token, long agentId,
+                                         List<Long> addDeptIds, List<String> addUserIds, List<String> addRoleIds,
+                                         List<Long> delDeptIds, List<String> delUserIds, List<String> delRoleIds) {
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = new java.util.HashMap();
+        body.put("agentId", agentId);
+        body.put("isHidden", false);
+        if (addDeptIds != null && !addDeptIds.isEmpty())
+            body.put("addDeptIds", addDeptIds.stream().map(String::valueOf).collect(Collectors.joining(",")));
+        if (addUserIds != null && !addUserIds.isEmpty())
+            body.put("addUserIds", String.join(",", addUserIds));
+        if (addRoleIds != null && !addRoleIds.isEmpty())
+            body.put("addRoleIds", String.join(",", addRoleIds));
+        if (delDeptIds != null && !delDeptIds.isEmpty())
+            body.put("delDeptIds", delDeptIds.stream().map(String::valueOf).collect(Collectors.joining(",")));
+        if (delUserIds != null && !delUserIds.isEmpty())
+            body.put("delUserIds", String.join(",", delUserIds));
+        if (delRoleIds != null && !delRoleIds.isEmpty())
+            body.put("delRoleIds", String.join(",", delRoleIds));
+        String raw = UtilHttp.doPost("https://oapi.dingtalk.com/microapp/set_visible_scopes", null, param, body);
+        log.info("[钉钉] 设置应用可见范围, agentId={}, body={}, raw={}", agentId, body, raw);
+        assertDingSuccess(JSON.parseObject(raw, Map.class));
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public List<Map> listMicroApp(String access_token) {
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = new java.util.HashMap();
+        String raw = UtilHttp.doPost("https://oapi.dingtalk.com/microapp/list", null, param, body);
+        log.info("[钉钉] 自建应用列表, raw 长度={}", raw == null ? 0 : raw.length());
+        Map rsp = JSON.parseObject(raw, Map.class);
+        assertDingSuccess(rsp);
+        Object app_list = rsp.get("app_list");
+        if (app_list == null) app_list = rsp.get("appList");
+        return app_list instanceof List ? (List<Map>) app_list : new ArrayList<>();
+    }
+
+    private void callBatch(String access_token, String url, String roleId, Collection<String> userIds, String op) {
+        if (userIds == null || userIds.isEmpty()) return;
+        List<String> all = new ArrayList<>(userIds);
+        Map param = UtilMap.map("access_token", access_token);
+        for (int i = 0; i < all.size(); i += USER_BATCH_SIZE) {
+            List<String> batch = all.subList(i, Math.min(i + USER_BATCH_SIZE, all.size()));
+            String userIdsCsv = batch.stream().collect(Collectors.joining(","));
+            Map body = UtilMap.map("roleIds, userIds", roleId, userIdsCsv);
+            String raw = UtilHttp.doPost(url, null, param, body);
+            log.info("[钉钉] 角色成员{}, roleId={}, 批次={}/{}, 数量={}, raw={}",
+                    op, roleId, i / USER_BATCH_SIZE + 1,
+                    (all.size() + USER_BATCH_SIZE - 1) / USER_BATCH_SIZE, batch.size(), raw);
+            assertDingSuccess(JSON.parseObject(raw, Map.class));
+        }
+    }
+}