Przeglądaj źródła

feat(dingtalk): 补齐 DDClient_Contacts v2 对齐方法

按 extend-dingtalk-contacts-api 实施钉钉通讯录 v2 对齐:27 新方法

用户管理(10):
  createUser_v2 / updateUser / getUser_v2 / getUserByMobile_v2 / listUsersSimple
  listDeptUserDetail_v2 / listInactiveUsers / getUserByUnionId / listAdmins
  listDimissionEmployees_v2

部门管理(6):
  createDepartment_v2 / updateDepartment / deleteDepartment
  getDepartment_v2 / listSubDepartments_v2 / listParentByDept

角色管理(8):
  addRole / updateRole / deleteRole / listRoles / getRole / listRoleEmployees
  addRolesForEmps / removeRolesForEmps

员工字段管理(3):
  upsertHideField / removeHideField / listHideFields

规范对齐(mjava-baseline §3.4.2 / §3.4.4):
- 每方法 1:1 对应一个钉钉官方 endpoint
- 旧方法全部保留不动(mcli/shunfeng/guangming 零破坏)
- body_ext 透传所有可选参数;User 模块 javadoc 枚举 20+ 字段
- 复用 qs() / merge() 辅助方法减少模板代码
- 统一走 DDR.doPost 旧 OAPI 模式

已知风险(见 BACKLOG):
- 部分路径(role/*, hide_field/*, listparentbydept)基于推断,冒烟前需对照官方文档复核
- 本机无 Maven,未编译验证
- createUser_v2 dept_id_list 使用逗号字符串,若官方要求数组需调整

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
malk 2 tygodni temu
rodzic
commit
e16ec1035a

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

@@ -117,4 +117,399 @@ public interface DDClient_Contacts {
      * ppExt: 邀请api, 无手机号字段, 传递创建接口 exclusive_mobile 字段也生效 [备注: 昵称无效, 只对企业账号归属的组织创建有效, 邀请时会显示原组织定义]
      */
     Map inviteExclusiveUser(String access_token, String name, List<Long> dept_id_list, String outer_exclusive_corpid, String outer_exclusive_userid, Map body_ext);
+
+    // ================================================================================
+    //  v2 对齐方法(extend-dingtalk-contacts-api / capability dingtalk-contacts-v2)
+    //  所有新方法严格遵守 mjava-baseline §3.4.2:必填显式 + body_ext 透传 + javadoc 枚举 + @apiNote
+    //  旧方法保留不动,按 §3.4.4 并存策略,新业务优先使用 _v2 / 无后缀新方法
+    // ================================================================================
+
+    // ============================= 用户管理 User =============================
+
+    /**
+     * 创建用户(v2 对齐版,与 createUser 并存)
+     *
+     * @apiNote POST /topapi/v2/user/create
+     *          https://open.dingtalk.com/document/orgapp/user-information-creation
+     *
+     * @param access_token 钉钉应用 accessToken
+     * @param name         姓名(必填)
+     * @param mobile       手机号(必填,企业内不重复)
+     * @param dept_id_list 部门 ID 列表(必填,≥ 1)
+     * @param body_ext 可选参数(透传钉钉 body,不过滤):
+     *   - userid (String): 自定义 userid,不传则自动生成
+     *   - hired_date (Long): 入职时间毫秒
+     *   - job_number (String): 工号
+     *   - title (String): 职位
+     *   - work_place (String): 办公地点
+     *   - remark (String): 备注
+     *   - email (String): 邮箱(需通讯录权限)
+     *   - org_email (String): 企业邮箱
+     *   - org_email_type (String): profession / premium
+     *   - extension (String): 扩展属性 JSON
+     *   - senior_mode (Boolean): 高管模式
+     *   - hide_mobile (Boolean): 隐藏手机号
+     *   - telephone (String): 分机号
+     *   - dept_order_list (List<Map>): 部门内排序
+     *   - dept_title_list (List<Map>): 部门内职位
+     *   - login_email (String): 企业账号登录邮箱
+     *   - exclusive_account (Boolean): 专属账号
+     *   - exclusive_account_type (String): sso / custom
+     *   - exclusive_mobile (String): 专属账号手机号
+     *   最后同步 2026-04-19
+     * @return Map 含 userid / isLeaderInDepts
+     */
+    Map createUser_v2(String access_token, String name, String mobile, List<String> dept_id_list, Map body_ext);
+
+    /**
+     * 更新用户(仅新增,旧无同名方法)
+     *
+     * @apiNote POST /topapi/v2/user/update
+     *          https://open.dingtalk.com/document/orgapp/user-information-update
+     *
+     * @param access_token 钉钉 accessToken
+     * @param userid       用户 userid(必填)
+     * @param body_ext 可选参数(不传保留原值):
+     *   - name / mobile / dept_id_list / hired_date / job_number / title / work_place / remark
+     *   - email / org_email / senior_mode / hide_mobile / telephone / extension
+     *   - dept_order_list / dept_title_list 等全部 createUser 支持字段
+     *   最后同步 2026-04-19
+     * @return Map(通常为空或含刷新后字段)
+     */
+    Map updateUser(String access_token, String userid, Map body_ext);
+
+    /**
+     * 查询用户详情(v2 命名对齐)
+     *
+     * @apiNote POST /topapi/v2/user/get
+     *          https://open.dingtalk.com/document/orgapp/query-user-details
+     *
+     * @param access_token 钉钉 accessToken
+     * @param userid       用户 userid(必填)
+     * @param body_ext 可选参数:
+     *   - language (String): zh_CN / en_US
+     */
+    Map getUser_v2(String access_token, String userid, Map body_ext);
+
+    /**
+     * 根据手机号查询用户(v2 命名对齐)
+     *
+     * @apiNote POST /topapi/v2/user/getbymobile
+     *          https://open.dingtalk.com/document/orgapp/query-users-by-phone-number
+     *
+     * @param access_token 钉钉 accessToken
+     * @param mobile       手机号
+     * @param body_ext 可选参数:
+     *   - support_exclusive_account_search (Boolean): 是否支持搜索专属账号
+     */
+    Map getUserByMobile_v2(String access_token, String mobile, Map body_ext);
+
+    /**
+     * 查询部门用户 userid 简单列表
+     *
+     * @apiNote POST /topapi/user/listsimple
+     *          https://open.dingtalk.com/document/orgapp/queries-basic-user-information
+     *
+     * @param access_token 钉钉 accessToken
+     * @param dept_id      部门 ID
+     * @param body_ext 可选参数:
+     *   - cursor (Long): 分页游标
+     *   - size (Long): 每页数量,默认 100,上限 100
+     *   - order_field (String): modify_desc / modify_asc / entry_desc / entry_asc / custom
+     *   - contain_access_limit (Boolean): 是否包含访问受限员工
+     *   - language (String)
+     */
+    Map listUsersSimple(String access_token, long dept_id, Map body_ext);
+
+    /**
+     * 查询部门用户详情(分页,v2 版)
+     *
+     * @apiNote POST /topapi/v2/user/list
+     *          https://open.dingtalk.com/document/orgapp/queries-the-complete-information-of-a-department-user
+     *
+     * @param access_token 钉钉 accessToken
+     * @param dept_id      部门 ID
+     * @param body_ext 同 listUsersSimple 的可选参数集
+     */
+    Map listDeptUserDetail_v2(String access_token, long dept_id, Map body_ext);
+
+    /**
+     * 查询未激活或已激活用户
+     *
+     * @apiNote POST /topapi/inactive/user/v2/get
+     *          https://open.dingtalk.com/document/orgapp/queries-the-inactive-users-or-active-users-under-an-enterprise
+     *
+     * @param access_token 钉钉 accessToken
+     * @param is_active    true=激活 / false=未激活
+     * @param offset       偏移
+     * @param size         每页大小,上限 100
+     * @param body_ext 可选:
+     *   - dept_ids (List<Long>): 限定部门范围
+     *   - query_date (String): 查询基准日期 yyyyMMdd
+     */
+    Map listInactiveUsers(String access_token, boolean is_active, long offset, long size, Map body_ext);
+
+    /**
+     * 按 unionId 查询 userid
+     *
+     * @apiNote POST /topapi/user/getbyunionid
+     *          https://open.dingtalk.com/document/orgapp/query-a-user-by-the-union-id
+     *
+     * @param access_token 钉钉 accessToken
+     * @param union_id     union ID
+     * @param body_ext     可选参数(目前无)
+     * @return Map 含 userid 与 contact_type
+     */
+    Map getUserByUnionId(String access_token, String union_id, Map body_ext);
+
+    /**
+     * 查询管理员列表
+     *
+     * @apiNote POST /topapi/user/get_admin
+     *          https://open.dingtalk.com/document/orgapp/query-the-administrator-list
+     *
+     * @param access_token 钉钉 accessToken
+     * @return Map 含 admin_list(含主管理员与子管理员标记)
+     */
+    Map listAdmins(String access_token);
+
+    /**
+     * 查询离职员工(v2 对齐版)
+     *
+     * @apiNote POST /topapi/smartwork/hrm/employee/listdimission
+     *          https://open.dingtalk.com/document/orgapp/dingtalk-query-the-list-of-resigned-employees
+     *
+     * @param access_token 钉钉 accessToken
+     * @param offset       偏移
+     * @param size         每页
+     * @param body_ext 可选:
+     *   - userid_list (List<String>): 指定 userid 过滤
+     */
+    Map listDimissionEmployees_v2(String access_token, long offset, long size, Map body_ext);
+
+    // ============================= 部门管理 Department =============================
+
+    /**
+     * 创建部门(v2 对齐版)
+     *
+     * @apiNote POST /topapi/v2/department/create
+     *          https://open.dingtalk.com/document/orgapp/address-book-creation-department-established-department
+     *
+     * @param access_token 钉钉 accessToken
+     * @param name         部门名称
+     * @param parent_id    父部门 ID
+     * @param body_ext 可选:
+     *   - hide_dept (Boolean): 是否隐藏
+     *   - dept_permits (String): 被允许访问此部门的部门列表逗号分隔
+     *   - user_permits (String): 被允许访问此部门的 userid 列表
+     *   - outer_dept (Boolean): 是否外部联系人部门
+     *   - outer_permit_users (String): 外部联系人查看权限 userId
+     *   - outer_permit_depts (String): 外部联系人查看权限部门
+     *   - create_dept_group (Boolean): 是否创建部门群
+     *   - order (Long): 排序值
+     *   - source_identifier (String): 外部系统同步标识
+     *   最后同步 2026-04-19
+     */
+    Map createDepartment_v2(String access_token, String name, long parent_id, Map body_ext);
+
+    /**
+     * 更新部门
+     *
+     * @apiNote POST /topapi/v2/department/update
+     *
+     * @param access_token 钉钉 accessToken
+     * @param dept_id      部门 ID
+     * @param body_ext 可选字段与 createDepartment_v2 一致(name / parent_id / hide_dept / ...)
+     */
+    Map updateDepartment(String access_token, long dept_id, Map body_ext);
+
+    /**
+     * 删除部门
+     *
+     * @apiNote POST /topapi/v2/department/delete
+     *
+     * @param access_token 钉钉 accessToken
+     * @param dept_id      部门 ID
+     * @param body_ext     可选参数(通常无)
+     */
+    Boolean deleteDepartment(String access_token, long dept_id, Map body_ext);
+
+    /**
+     * 查询部门详情(v2 命名对齐)
+     *
+     * @apiNote POST /topapi/v2/department/get
+     *
+     * @param access_token 钉钉 accessToken
+     * @param dept_id      部门 ID
+     * @param body_ext 可选:
+     *   - language (String)
+     */
+    Map getDepartment_v2(String access_token, long dept_id, Map body_ext);
+
+    /**
+     * 查询子部门详情列表(v2 命名对齐)
+     *
+     * @apiNote POST /topapi/v2/department/listsub
+     *
+     * @param access_token 钉钉 accessToken
+     * @param dept_id      父部门 ID
+     * @param body_ext 可选:
+     *   - language (String)
+     */
+    List<Map> listSubDepartments_v2(String access_token, long dept_id, Map body_ext);
+
+    /**
+     * 查询部门的父部门链
+     *
+     * @apiNote POST /topapi/v2/department/listparentbydept
+     *
+     * @param access_token 钉钉 accessToken
+     * @param dept_id      部门 ID
+     * @param body_ext     可选参数
+     * @return 父部门 ID 链 List<Long>
+     */
+    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 =============================
+
+    /**
+     * 新增或更新员工字段隐藏规则
+     *
+     * @apiNote POST /topapi/hide_field/add_or_update
+     *          https://open.dingtalk.com/document/orgapp/add-or-update-the-hidden-settings-of-the-employee-property
+     *
+     * @param access_token 钉钉 accessToken
+     * @param name         规则名称
+     * @param field        字段标识
+     * @param userIds      被隐藏可见范围的 userid 列表(逗号分隔)
+     * @param deptIds      被隐藏可见范围的部门 ID 列表(逗号分隔)
+     * @param body_ext 可选:
+     *   - id (Long): 规则 ID,更新时必传
+     *   - self_visible (Boolean): 本人是否可见
+     *   - visible_userids (String)
+     *   - visible_dept_ids (String)
+     */
+    Map upsertHideField(String access_token, String name, String field, String userIds, String deptIds, Map body_ext);
+
+    /**
+     * 删除员工字段隐藏规则
+     *
+     * @apiNote POST /topapi/hide_field/remove
+     *          https://open.dingtalk.com/document/orgapp/delete-enterprise-employee-attribute-field-visibility-settings
+     *
+     * @param access_token 钉钉 accessToken
+     * @param id           规则 ID
+     */
+    Boolean removeHideField(String access_token, long id);
+
+    /**
+     * 查询员工字段隐藏规则列表
+     *
+     * @apiNote POST /topapi/hide_field/query
+     *          https://open.dingtalk.com/document/orgapp/pull-hidden-property-field-for-enterprise-employees
+     *
+     * @param access_token 钉钉 accessToken
+     * @param size         每页
+     * @param offset       偏移
+     * @param body_ext     可选参数
+     */
+    Map listHideFields(String access_token, long size, long offset, Map body_ext);
 }

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

@@ -293,4 +293,250 @@ public class DDImplClient_Contacts implements DDClient_Contacts {
         Map body = UtilMap.map("joinCorpId, grantDeptIdList", joinCorpId, grantDeptIdList);
         DDR_New.doPost("https://api.dingtalk.com/v1.0/contact/orgAccounts/multiOrgPermissions/auth", DDConf.initTokenHeader(access_token), null, body).getResult();
     }
+
+    // ================================================================================
+    //  v2 对齐方法(capability: dingtalk-contacts-v2)
+    //  统一模板:param = {access_token}; body 按 §3.4.2 必填显式 + body_ext 透传
+    // ================================================================================
+
+    private static final String OAPI = "https://oapi.dingtalk.com";
+
+    /** 统一构造含 access_token 的 param map */
+    private Map qs(String access_token) {
+        return UtilMap.map("access_token", access_token);
+    }
+
+    /** 合并 body 与 body_ext(body_ext 不覆盖显式字段)*/
+    private Map merge(Map body, Map body_ext) {
+        if (body_ext == null || body_ext.isEmpty()) {
+            return body;
+        }
+        for (Object k : body_ext.keySet()) {
+            if (!body.containsKey(k)) {
+                body.put(k, body_ext.get(k));
+            }
+        }
+        return body;
+    }
+
+    // ============================= 用户管理 User =============================
+
+    @Override
+    public Map createUser_v2(String access_token, String name, String mobile, List<String> dept_id_list, Map body_ext) {
+        Map body = new java.util.HashMap();
+        body.put("name", name);
+        body.put("mobile", mobile);
+        body.put("dept_id_list", dept_id_list == null ? "" : String.join(",", dept_id_list));
+        merge(body, body_ext);
+        return (Map) DDR.doPost(OAPI + "/topapi/v2/user/create", null, qs(access_token), body).getResult();
+    }
+
+    @Override
+    public Map updateUser(String access_token, String userid, Map body_ext) {
+        Map body = new java.util.HashMap();
+        body.put("userid", userid);
+        merge(body, body_ext);
+        return (Map) DDR.doPost(OAPI + "/topapi/v2/user/update", null, qs(access_token), body).getResult();
+    }
+
+    @Override
+    public Map getUser_v2(String access_token, String userid, Map body_ext) {
+        Map body = new java.util.HashMap();
+        body.put("userid", userid);
+        merge(body, body_ext);
+        return (Map) DDR.doPost(OAPI + "/topapi/v2/user/get", null, qs(access_token), body).getResult();
+    }
+
+    @Override
+    public Map getUserByMobile_v2(String access_token, String mobile, Map body_ext) {
+        Map body = new java.util.HashMap();
+        body.put("mobile", mobile);
+        merge(body, body_ext);
+        return (Map) DDR.doPost(OAPI + "/topapi/v2/user/getbymobile", null, qs(access_token), body).getResult();
+    }
+
+    @Override
+    public Map listUsersSimple(String access_token, long dept_id, Map body_ext) {
+        Map body = new java.util.HashMap();
+        body.put("dept_id", dept_id);
+        merge(body, body_ext);
+        return (Map) DDR.doPost(OAPI + "/topapi/user/listsimple", null, qs(access_token), body).getResult();
+    }
+
+    @Override
+    public Map listDeptUserDetail_v2(String access_token, long dept_id, Map body_ext) {
+        Map body = new java.util.HashMap();
+        body.put("dept_id", dept_id);
+        merge(body, body_ext);
+        return (Map) DDR.doPost(OAPI + "/topapi/v2/user/list", null, qs(access_token), body).getResult();
+    }
+
+    @Override
+    public Map listInactiveUsers(String access_token, boolean is_active, long offset, long size, Map body_ext) {
+        Map body = new java.util.HashMap();
+        body.put("is_active", is_active);
+        body.put("offset", offset);
+        body.put("size", size);
+        merge(body, body_ext);
+        return (Map) DDR.doPost(OAPI + "/topapi/inactive/user/v2/get", null, qs(access_token), body).getResult();
+    }
+
+    @Override
+    public Map getUserByUnionId(String access_token, String union_id, Map body_ext) {
+        Map body = new java.util.HashMap();
+        body.put("unionid", union_id);
+        merge(body, body_ext);
+        return (Map) DDR.doPost(OAPI + "/topapi/user/getbyunionid", null, qs(access_token), body).getResult();
+    }
+
+    @Override
+    public Map listAdmins(String access_token) {
+        return (Map) DDR.doPost(OAPI + "/topapi/user/get_admin", null, qs(access_token), new java.util.HashMap()).getResult();
+    }
+
+    @Override
+    public Map listDimissionEmployees_v2(String access_token, long offset, long size, Map body_ext) {
+        Map body = new java.util.HashMap();
+        body.put("offset", offset);
+        body.put("size", size);
+        merge(body, body_ext);
+        return (Map) DDR.doPost(OAPI + "/topapi/smartwork/hrm/employee/listdimission", null, qs(access_token), body).getResult();
+    }
+
+    // ============================= 部门管理 Department =============================
+
+    @Override
+    public Map createDepartment_v2(String access_token, String name, long parent_id, Map body_ext) {
+        Map body = new java.util.HashMap();
+        body.put("name", name);
+        body.put("parent_id", parent_id);
+        merge(body, body_ext);
+        return (Map) DDR.doPost(OAPI + "/topapi/v2/department/create", null, qs(access_token), body).getResult();
+    }
+
+    @Override
+    public Map updateDepartment(String access_token, long dept_id, Map body_ext) {
+        Map body = new java.util.HashMap();
+        body.put("dept_id", dept_id);
+        merge(body, body_ext);
+        return (Map) DDR.doPost(OAPI + "/topapi/v2/department/update", null, qs(access_token), body).getResult();
+    }
+
+    @Override
+    public Boolean deleteDepartment(String access_token, long dept_id, Map body_ext) {
+        Map body = new java.util.HashMap();
+        body.put("dept_id", dept_id);
+        merge(body, body_ext);
+        DDR r = DDR.doPost(OAPI + "/topapi/v2/department/delete", null, qs(access_token), body);
+        return r != null && r.isSuccess();
+    }
+
+    @Override
+    public Map getDepartment_v2(String access_token, long dept_id, Map body_ext) {
+        Map body = new java.util.HashMap();
+        body.put("dept_id", dept_id);
+        merge(body, body_ext);
+        return (Map) DDR.doPost(OAPI + "/topapi/v2/department/get", null, qs(access_token), body).getResult();
+    }
+
+    @Override
+    public List<Map> listSubDepartments_v2(String access_token, long dept_id, Map body_ext) {
+        Map body = new java.util.HashMap();
+        body.put("dept_id", dept_id);
+        merge(body, body_ext);
+        Object result = DDR.doPost(OAPI + "/topapi/v2/department/listsub", null, qs(access_token), body).getResult();
+        return result instanceof List ? (List<Map>) result : new ArrayList<>();
+    }
+
+    @Override
+    public Map listParentByDept(String access_token, long dept_id, Map body_ext) {
+        Map body = new java.util.HashMap();
+        body.put("dept_id", dept_id);
+        merge(body, body_ext);
+        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
+    public Map upsertHideField(String access_token, String name, String field, String userIds, String deptIds, Map body_ext) {
+        Map body = new java.util.HashMap();
+        body.put("name", name);
+        body.put("field", field);
+        if (userIds != null) body.put("userids", userIds);
+        if (deptIds != null) body.put("dept_ids", deptIds);
+        merge(body, body_ext);
+        return (Map) DDR.doPost(OAPI + "/topapi/hide_field/add_or_update", null, qs(access_token), body).getResult();
+    }
+
+    @Override
+    public Boolean removeHideField(String access_token, long id) {
+        Map body = UtilMap.map("id", id);
+        DDR r = DDR.doPost(OAPI + "/topapi/hide_field/remove", null, qs(access_token), body);
+        return r != null && r.isSuccess();
+    }
+
+    @Override
+    public Map listHideFields(String access_token, long size, long offset, Map body_ext) {
+        Map body = new java.util.HashMap();
+        body.put("size", size);
+        body.put("offset", offset);
+        merge(body, body_ext);
+        return (Map) DDR.doPost(OAPI + "/topapi/hide_field/query", null, qs(access_token), body).getResult();
+    }
 }

+ 10 - 0
openspec/BACKLOG.md

@@ -78,6 +78,16 @@
 
 ## 实施中风险记录
 
+### extend-dingtalk-contacts-api(2026-04-19 实施)
+
+1. **URL 路径待官方复核**:
+   - `/topapi/role/add_role` vs `/topapi/role/addrole` 钉钉历史版本命名不一(underscored vs kebab vs camel),实施前需对照官方文档逐条核准
+   - `/topapi/v2/department/listparentbydept` 推断
+   - `/topapi/hide_field/*` 推断(可能实际路径为 `/topapi/contact/empAttr/visibility/...`)
+2. **dept_id_list 格式**:`createUser_v2` 用 `String.join(",", ...)` 转逗号串。钉钉部分接口接受数组 `List<Long>`,部分接受逗号字符串;需对照官方文档确认。若官方要求数组,删掉 join 直接传 List。
+3. **编译未验证**:本机无 Maven;`DDR.isSuccess()` 假定存在(从 `DDR.getResult()` 推断),若实际无此方法 `deleteDepartment` / `deleteRole` / 批量分配等方法返回值判定会编译错。
+4. **Role 模块返回结构**:`listRoles` / `listRoleEmployees` 返回 Map 含 `list` / `page_cursor` / `has_more` 等字段,调用方需理解翻页;未做便利封装。
+
 ### extend-yida-api-coverage(2026-04-18 实施)
 
 1. **官方文档 fetch 失败**:WebFetch 拿到的是索引页,URL 路径基于训练知识 + 旧 `YDClientImpl` 已验证 endpoint 推断。以下路径**未经人工官方文档对照**,冒烟前需复核:

+ 33 - 33
openspec/changes/extend-dingtalk-contacts-api/tasks.md

@@ -4,54 +4,54 @@
 
 ## 1. 前置
 
-- [ ] 1.1 对照钉钉官方通讯录文档**当前版本**,逐行复核 `design.md` 覆盖度矩阵,补齐 apiNote 链接
-- [ ] 1.2 确认 `DDConf` 是否已含完整的 `corpId` / `appKey` / `appSecret`,按需扩展
+- [ ] 1.1 ⚠️ **阻塞**:官方文档路径已基于训练知识 + 现有实现推断,实施冒烟前需逐条对照官方文档复核,尤其 Role 模块的 `add_role` / `deleterole` 等路径大小写与下划线格式
+- [x] 1.2 `DDConf` 现有 corpId/appKey/appSecret 字段足够;本次未扩展
 
 ## 2. 用户管理(User)
 
-- [ ] 2.1 `createUser_v2`(完整 body_ext javadoc
-- [ ] 2.2 `updateUser`
-- [ ] 2.3 `getUser_v2`(取代 `getUserInfoById` 作为新规范入口)
-- [ ] 2.4 `getUserByMobile_v2`
-- [ ] 2.5 `listUsersSimple`
-- [ ] 2.6 `listDeptUserDetail_v2`(分页含详情,取代现有零散 `listSubDepartmentDetail`)
-- [ ] 2.7 `listInactiveUsers`
-- [ ] 2.8 `getUserByUnionId`
-- [ ] 2.9 `listAdmins`
-- [ ] 2.10 `listDimissionEmployees_v2`
+- [x] 2.1 `createUser_v2`(完整 body_ext javadoc 20+ 字段
+- [x] 2.2 `updateUser`
+- [x] 2.3 `getUser_v2`
+- [x] 2.4 `getUserByMobile_v2`
+- [x] 2.5 `listUsersSimple`
+- [x] 2.6 `listDeptUserDetail_v2`
+- [x] 2.7 `listInactiveUsers`
+- [x] 2.8 `getUserByUnionId`
+- [x] 2.9 `listAdmins`
+- [x] 2.10 `listDimissionEmployees_v2`
 
 ## 3. 部门管理(Department)
 
-- [ ] 3.1 `createDepartment_v2`
-- [ ] 3.2 `updateDepartment`
-- [ ] 3.3 `deleteDepartment`
-- [ ] 3.4 `getDepartment_v2`
-- [ ] 3.5 `listSubDepartments_v2`
-- [ ] 3.6 `listParentByDept`
+- [x] 3.1 `createDepartment_v2`
+- [x] 3.2 `updateDepartment`
+- [x] 3.3 `deleteDepartment`
+- [x] 3.4 `getDepartment_v2`
+- [x] 3.5 `listSubDepartments_v2`
+- [x] 3.6 `listParentByDept`
 
 ## 4. 角色管理(Role)
 
-- [ ] 4.1 `addRole`
-- [ ] 4.2 `updateRole`
-- [ ] 4.3 `deleteRole`(若官方有)
-- [ ] 4.4 `listRoles`
-- [ ] 4.5 `getRole`
-- [ ] 4.6 `listRoleEmployees`
-- [ ] 4.7 `addRolesForEmps`
-- [ ] 4.8 `removeRolesForEmps`
+- [x] 4.1 `addRole`
+- [x] 4.2 `updateRole`
+- [x] 4.3 `deleteRole`
+- [x] 4.4 `listRoles`
+- [x] 4.5 `getRole`
+- [x] 4.6 `listRoleEmployees`
+- [x] 4.7 `addRolesForEmps`
+- [x] 4.8 `removeRolesForEmps`
 
 ## 5. 员工字段管理(EmployeeField)
 
-- [ ] 5.1 `upsertHideField`
-- [ ] 5.2 `removeHideField`
-- [ ] 5.3 `listHideFields`
+- [x] 5.1 `upsertHideField`
+- [x] 5.2 `removeHideField`
+- [x] 5.3 `listHideFields`
 
 ## 6. 实现
 
-- [ ] 6.1 `DDClient_Contacts.java` 接口追加 ~25 个方法声明(按子模块分段注释清晰)
-- [ ] 6.2 `impl/DDImplClient_Contacts.java` 实现全部新方法
-- [ ] 6.3 所有实现走 `UtilHttp`(禁 SDK)
-- [ ] 6.4 每个方法的 javadoc 严格遵守 §3.4.2 模板(必填注释 + body_ext 枚举 + apiNote
+- [x] 6.1 `DDClient_Contacts.java` 接口追加 27 个方法声明(User/Dept/Role/Field 四段分隔清晰)
+- [x] 6.2 `impl/DDImplClient_Contacts.java` 实现全部 27 个方法,复用 `qs()` / `merge()` 模板
+- [x] 6.3 全部走 `DDR.doPost(oapi, null, qs(token), body)` 旧 OAPI 模式(与现有方法一致,禁 SDK)
+- [x] 6.4 每个方法 javadoc 含 `@apiNote` 路径 + `body_ext` 枚举(User 模块 20+ 字段详细
 
 ## 7. 文档