Explorar el Código

refactor(dingtalk): consolidate role client

malk hace 2 semanas
padre
commit
56ecd62b43

+ 0 - 98
mjava/src/main/java/com/malk/service/dingtalk/DDClient_Contacts.java

@@ -370,104 +370,6 @@ public interface DDClient_Contacts {
      */
     Map listParentByDept(String access_token, long dept_id, Map body_ext);
 
-    // ============================= 角色管理 Role =============================
-
-    /**
-     * 新增角色
-     *
-     * @apiNote POST /topapi/role/add_role
-     *          https://open.dingtalk.com/document/orgapp/address-book-add-role
-     *
-     * @param access_token 钉钉 accessToken
-     * @param roleName     角色名
-     * @param groupId      角色组 ID
-     * @return Map 含 roleId
-     */
-    Map addRole(String access_token, String roleName, long groupId);
-
-    /**
-     * 更新角色名
-     *
-     * @apiNote POST /topapi/role/update_role
-     *          https://open.dingtalk.com/document/orgapp/update-the-character-name
-     *
-     * @param access_token 钉钉 accessToken
-     * @param roleId       角色 ID
-     * @param roleName     新名称
-     */
-    Boolean updateRole(String access_token, long roleId, String roleName);
-
-    /**
-     * 删除角色
-     *
-     * @apiNote POST /topapi/role/deleterole
-     *
-     * @param access_token 钉钉 accessToken
-     * @param role_id      角色 ID
-     */
-    Boolean deleteRole(String access_token, long role_id);
-
-    /**
-     * 查询角色列表
-     *
-     * @apiNote POST /topapi/role/list
-     *          https://open.dingtalk.com/document/orgapp/obtains-a-list-of-enterprise-roles
-     *
-     * @param access_token 钉钉 accessToken
-     * @param body_ext 可选:
-     *   - size (Long)
-     *   - offset (Long)
-     */
-    Map listRoles(String access_token, Map body_ext);
-
-    /**
-     * 查询角色详情
-     *
-     * @apiNote POST /topapi/role/getrole
-     *          https://open.dingtalk.com/document/orgapp/queries-role-details
-     *
-     * @param access_token 钉钉 accessToken
-     * @param roleId       角色 ID
-     */
-    Map getRole(String access_token, long roleId);
-
-    /**
-     * 查询角色下员工列表
-     *
-     * @apiNote POST /topapi/role/simplelist
-     *          https://open.dingtalk.com/document/orgapp/obtain-the-list-of-employees-of-a-role
-     *
-     * @param access_token 钉钉 accessToken
-     * @param role_id      角色 ID
-     * @param size         每页
-     * @param offset       偏移
-     */
-    Map listRoleEmployees(String access_token, long role_id, long size, long offset);
-
-    /**
-     * 批量为员工分配角色
-     *
-     * @apiNote POST /topapi/role/addrolesforemps
-     *          https://open.dingtalk.com/document/orgapp/add-role-information-to-employees-in-batches
-     *
-     * @param access_token 钉钉 accessToken
-     * @param roleIds      角色 ID 列表(逗号分隔字符串)
-     * @param userIds      员工 userid 列表(逗号分隔字符串)
-     */
-    Boolean addRolesForEmps(String access_token, String roleIds, String userIds);
-
-    /**
-     * 批量移除员工角色
-     *
-     * @apiNote POST /topapi/role/removerolesforemps
-     *          https://open.dingtalk.com/document/orgapp/delete-role-information
-     *
-     * @param access_token 钉钉 accessToken
-     * @param roleIds      角色 ID 列表(逗号分隔字符串)
-     * @param userIds      员工 userid 列表(逗号分隔字符串)
-     */
-    Boolean removeRolesForEmps(String access_token, String roleIds, String userIds);
-
     // ============================= 员工字段隐藏 EmployeeField =============================
 
     /**

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

@@ -0,0 +1,120 @@
+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);
+
+    /**
+     * 删除角色。
+     *
+     * @param access_token 钉钉 accessToken
+     * @param roleId       角色 ID
+     * @apiNote https://open.dingtalk.com/document/orgapp/delete-role
+     */
+    void roleDelete(String access_token, String roleId);
+
+    /**
+     * 查询角色列表。
+     *
+     * @param access_token 钉钉 accessToken
+     * @param body_ext     可选参数:size、offset
+     * @return 钉钉角色列表 result
+     * @apiNote https://open.dingtalk.com/document/orgapp/obtains-a-list-of-enterprise-roles
+     */
+    Map roleList(String access_token, Map body_ext);
+
+    /**
+     * 查询角色详情。
+     *
+     * @param access_token 钉钉 accessToken
+     * @param roleId       角色 ID
+     * @return 钉钉角色详情 result
+     * @apiNote https://open.dingtalk.com/document/orgapp/queries-role-details
+     */
+    Map roleGet(String access_token, String roleId);
+
+    /**
+     * 查询角色下员工列表。
+     *
+     * @param access_token 钉钉 accessToken
+     * @param roleId       角色 ID
+     * @param size         每页数量
+     * @param offset       分页偏移
+     * @return 钉钉角色员工列表 result
+     * @apiNote https://open.dingtalk.com/document/orgapp/obtain-the-list-of-employees-of-a-role
+     */
+    Map roleListEmployees(String access_token, String roleId, long size, long offset);
+
+    /**
+     * 设置应用可见范围(支持 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);
+}

+ 0 - 55
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Contacts.java

@@ -462,61 +462,6 @@ public class DDImplClient_Contacts implements DDClient_Contacts {
         return (Map) DDR.doPost(OAPI + "/topapi/v2/department/listparentbydept", null, qs(access_token), body).getResult();
     }
 
-    // ============================= 角色管理 Role =============================
-
-    @Override
-    public Map addRole(String access_token, String roleName, long groupId) {
-        Map body = UtilMap.map("roleName, groupId", roleName, groupId);
-        return (Map) DDR.doPost(OAPI + "/topapi/role/add_role", null, qs(access_token), body).getResult();
-    }
-
-    @Override
-    public Boolean updateRole(String access_token, long roleId, String roleName) {
-        Map body = UtilMap.map("roleId, roleName", roleId, roleName);
-        DDR r = DDR.doPost(OAPI + "/topapi/role/update_role", null, qs(access_token), body);
-        return r != null && r.isSuccess();
-    }
-
-    @Override
-    public Boolean deleteRole(String access_token, long role_id) {
-        Map body = UtilMap.map("role_id", role_id);
-        DDR r = DDR.doPost(OAPI + "/topapi/role/deleterole", null, qs(access_token), body);
-        return r != null && r.isSuccess();
-    }
-
-    @Override
-    public Map listRoles(String access_token, Map body_ext) {
-        Map body = new java.util.HashMap();
-        merge(body, body_ext);
-        return (Map) DDR.doPost(OAPI + "/topapi/role/list", null, qs(access_token), body).getResult();
-    }
-
-    @Override
-    public Map getRole(String access_token, long roleId) {
-        Map body = UtilMap.map("roleId", roleId);
-        return (Map) DDR.doPost(OAPI + "/topapi/role/getrole", null, qs(access_token), body).getResult();
-    }
-
-    @Override
-    public Map listRoleEmployees(String access_token, long role_id, long size, long offset) {
-        Map body = UtilMap.map("role_id, size, offset", role_id, size, offset);
-        return (Map) DDR.doPost(OAPI + "/topapi/role/simplelist", null, qs(access_token), body).getResult();
-    }
-
-    @Override
-    public Boolean addRolesForEmps(String access_token, String roleIds, String userIds) {
-        Map body = UtilMap.map("roleIds, userIds", roleIds, userIds);
-        DDR r = DDR.doPost(OAPI + "/topapi/role/addrolesforemps", null, qs(access_token), body);
-        return r != null && r.isSuccess();
-    }
-
-    @Override
-    public Boolean removeRolesForEmps(String access_token, String roleIds, String userIds) {
-        Map body = UtilMap.map("roleIds, userIds", roleIds, userIds);
-        DDR r = DDR.doPost(OAPI + "/topapi/role/removerolesforemps", null, qs(access_token), body);
-        return r != null && r.isSuccess();
-    }
-
     // ============================= 员工字段隐藏 EmployeeField =============================
 
     @Override

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

@@ -0,0 +1,203 @@
+package com.malk.service.dingtalk.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.server.common.McException;
+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 {
+
+    private static final String OAPI = "https://oapi.dingtalk.com";
+
+    /** 钉钉员工角色批量增/删接口单批用户上限(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 = post(OAPI + "/microapp/visible_scopes", param, body);
+        Map rsp = JSON.parseObject(raw, Map.class);
+        assertDingSuccess(rsp);
+        log.info("[钉钉] 应用可见范围查询成功, agentId={}, 部门数={}, 用户数={}",
+                agentId, collectionSize(rsp.get("deptVisibleScopes")), collectionSize(rsp.get("userVisibleScopes")));
+        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 = post(OAPI + "/topapi/role/add_role_group", param, body);
+        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");
+        }
+        log.info("[钉钉] 创建角色组成功, groupId={}", 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 = post(OAPI + "/topapi/role/add_role", param, body);
+        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");
+        }
+        log.info("[钉钉] 创建角色成功, groupId={}, roleId={}", groupId, roleId);
+        return roleId == null ? null : String.valueOf(roleId);
+    }
+
+    @Override
+    public void roleAddUsers(String access_token, String roleId, Collection<String> userIds) {
+        callBatch(access_token, OAPI + "/topapi/role/addrolesforemps", roleId, userIds, "add");
+    }
+
+    @Override
+    public void roleDeleteUsers(String access_token, String roleId, Collection<String> userIds) {
+        callBatch(access_token, OAPI + "/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 = post(OAPI + "/topapi/role/update_role", param, body);
+        assertDingSuccess(JSON.parseObject(raw, Map.class));
+        log.info("[钉钉] 更新角色成功, roleId={}", roleId);
+    }
+
+    @Override
+    public void roleDelete(String access_token, String roleId) {
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = UtilMap.map("role_id", roleId);
+        String raw = post(OAPI + "/topapi/role/deleterole", param, body);
+        assertDingSuccess(JSON.parseObject(raw, Map.class));
+        log.info("[钉钉] 删除角色成功, roleId={}", roleId);
+    }
+
+    @Override
+    public Map roleList(String access_token, Map body_ext) {
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = new java.util.HashMap();
+        if (body_ext != null) {
+            body.putAll(body_ext);
+        }
+        return resultMap(post(OAPI + "/topapi/role/list", param, body));
+    }
+
+    @Override
+    public Map roleGet(String access_token, String roleId) {
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = UtilMap.map("roleId", roleId);
+        return resultMap(post(OAPI + "/topapi/role/getrole", param, body));
+    }
+
+    @Override
+    public Map roleListEmployees(String access_token, String roleId, long size, long offset) {
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = UtilMap.map("role_id, size, offset", roleId, size, offset);
+        return resultMap(post(OAPI + "/topapi/role/simplelist", param, body));
+    }
+
+    @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 = post(OAPI + "/microapp/set_visible_scopes", param, body);
+        assertDingSuccess(JSON.parseObject(raw, Map.class));
+        log.info("[钉钉] 设置应用可见范围成功, agentId={}, addDept={}, addUser={}, addRole={}, delDept={}, delUser={}, delRole={}",
+                agentId, collectionSize(addDeptIds), collectionSize(addUserIds), collectionSize(addRoleIds),
+                collectionSize(delDeptIds), collectionSize(delUserIds), collectionSize(delRoleIds));
+    }
+
+    @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 = post(OAPI + "/microapp/list", param, body);
+        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");
+        List<Map> apps = app_list instanceof List ? (List<Map>) app_list : new ArrayList<>();
+        log.info("[钉钉] 自建应用列表查询成功, 数量={}", apps.size());
+        return apps;
+    }
+
+    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 = post(url, param, body);
+            assertDingSuccess(JSON.parseObject(raw, Map.class));
+            log.info("[钉钉] 角色成员{}成功, roleId={}, 批次={}/{}, 数量={}",
+                    op, roleId, i / USER_BATCH_SIZE + 1,
+                    (all.size() + USER_BATCH_SIZE - 1) / USER_BATCH_SIZE, batch.size());
+        }
+    }
+
+    protected String post(String url, Map<String, Object> param, Map body) {
+        return UtilHttp.doPost(url, null, param, body);
+    }
+
+    private static Map resultMap(String raw) {
+        Map rsp = JSON.parseObject(raw, Map.class);
+        assertDingSuccess(rsp);
+        return rsp.get("result") instanceof Map ? (Map) rsp.get("result") : rsp;
+    }
+
+    private static int collectionSize(Object value) {
+        return value instanceof Collection ? ((Collection) value).size() : 0;
+    }
+}

+ 124 - 0
mjava/src/test/java/com/malk/service/dingtalk/impl/DDImplClient_RoleTest.java

@@ -0,0 +1,124 @@
+package com.malk.service.dingtalk.impl;
+
+import com.malk.server.common.McException;
+import org.junit.Test;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+/**
+ * 钉钉角色原子接口测试。
+ */
+public class DDImplClient_RoleTest {
+
+    @Test
+    public void roleCreate_usesTopApiEndpointAndReturnsRoleId() {
+        StubRoleClient client = new StubRoleClient();
+        client.enqueue("{\"errcode\":0,\"result\":{\"roleId\":123}}");
+
+        String roleId = client.roleCreate("token", "测试角色", "456");
+
+        assertEquals("123", roleId);
+        assertEquals("https://oapi.dingtalk.com/topapi/role/add_role", client.calls.get(0).url);
+        assertEquals("456", client.calls.get(0).body.get("groupId"));
+    }
+
+    @Test
+    public void roleGroupCreate_usesTopApiEndpointAndReturnsGroupId() {
+        StubRoleClient client = new StubRoleClient();
+        client.enqueue("{\"errcode\":0,\"result\":{\"groupId\":456}}");
+
+        String groupId = client.roleGroupCreate("token", "测试角色组");
+
+        assertEquals("456", groupId);
+        assertEquals("https://oapi.dingtalk.com/topapi/role/add_role_group", client.calls.get(0).url);
+    }
+
+    @Test
+    public void roleAddUsers_moreThanTwenty_splitsRequests() {
+        StubRoleClient client = new StubRoleClient();
+        List<String> userIds = IntStream.range(0, 21)
+                .mapToObj(index -> "user-" + index)
+                .collect(Collectors.toList());
+
+        client.roleAddUsers("token", "role-1", userIds);
+
+        assertEquals(2, client.calls.size());
+        assertEquals(String.join(",", userIds.subList(0, 20)), client.calls.get(0).body.get("userIds"));
+        assertEquals("user-20", client.calls.get(1).body.get("userIds"));
+    }
+
+    @Test
+    public void getMicroAppVisibleScopes_returnsParsedResponse() {
+        StubRoleClient client = new StubRoleClient();
+        client.enqueue("{\"errcode\":0,\"deptVisibleScopes\":[1,2],\"userVisibleScopes\":[\"u1\"],\"isHidden\":false}");
+
+        Map response = client.getMicroAppVisibleScopes("token", 100L);
+
+        assertEquals(Arrays.asList(1, 2), response.get("deptVisibleScopes"));
+        assertEquals(Collections.singletonList("u1"), response.get("userVisibleScopes"));
+    }
+
+    @Test
+    public void roleList_passesOptionalParametersAndReturnsResult() {
+        StubRoleClient client = new StubRoleClient();
+        client.enqueue("{\"errcode\":0,\"result\":{\"hasMore\":false}}");
+        Map<String, Object> bodyExt = new HashMap<>();
+        bodyExt.put("size", 20L);
+        bodyExt.put("offset", 0L);
+
+        Map result = client.roleList("token", bodyExt);
+
+        assertEquals(Boolean.FALSE, result.get("hasMore"));
+        assertEquals(bodyExt, client.calls.get(0).body);
+    }
+
+    @Test
+    public void roleUpdate_vendorError_throwsBusinessException() {
+        StubRoleClient client = new StubRoleClient();
+        client.enqueue("{\"errcode\":400,\"errmsg\":\"invalid role\"}");
+
+        try {
+            client.roleUpdate("token", "role-1", "测试角色");
+            fail("钉钉失败响应必须抛出业务异常");
+        } catch (McException ex) {
+            assertEquals("502", ex.getCode());
+        }
+    }
+
+    private static class StubRoleClient extends DDImplClient_Role {
+        private final List<Call> calls = new ArrayList<>();
+        private final Deque<String> responses = new ArrayDeque<>();
+
+        private void enqueue(String response) {
+            responses.add(response);
+        }
+
+        @Override
+        protected String post(String url, Map<String, Object> param, Map body) {
+            calls.add(new Call(url, new HashMap<>(body)));
+            return responses.isEmpty() ? "{\"errcode\":0}" : responses.removeFirst();
+        }
+    }
+
+    private static class Call {
+        private final String url;
+        private final Map body;
+
+        private Call(String url, Map body) {
+            this.url = url;
+            this.body = body;
+        }
+    }
+}