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

feat(integration): 集成平台用户域 API(鉴权 + CRUD 4 接口)

新增 vendor 包 server/integration + service/integration:

- INTPConf: @ConfigurationProperties("integration"),含 baseUrl/clientId/clientSecret + 4 个 path 常量 + 缓存 key
- INTPR extends VenR: result/error/error_description/data + access_token/expires_in;assertSuccess 自动判 result 抛 McException
- VenR 注册 RC_INTP 常量
- INTPClient_User 接口 5 方法(getAccessToken + 4 CRUD),javadoc 完整枚举 body_ext/query 可选字段
- INTPImplClient_User 实现:UtilToken 缓存 access_token TTL=expires_in-60s;CRUD 走 INTPR.doPost/doGet 与 UtilHttp.doPatch

mvn clean package -DskipTests 全 reactor 7 模块 BUILD SUCCESS。

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

+ 1 - 0
mjava/src/main/java/com/malk/server/common/VenR.java

@@ -32,6 +32,7 @@ public class VenR extends BaseDto {
     public static final String RC_XBB = "com.malk.server.xbongbong.XBBR";
     public static final String RC_XBB = "com.malk.server.xbongbong.XBBR";
     public static final String RC_VK = "com.malk.server.vika.VKR";
     public static final String RC_VK = "com.malk.server.vika.VKR";
     public static final String RC_TB = "com.malk.server.teambition.TBR";
     public static final String RC_TB = "com.malk.server.teambition.TBR";
+    public static final String RC_INTP = "com.malk.server.integration.INTPR";
 
 
     /**
     /**
      * 通用post请求
      * 通用post请求

+ 59 - 0
mjava/src/main/java/com/malk/server/integration/INTPConf.java

@@ -0,0 +1,59 @@
+package com.malk.server.integration;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 集成平台(IAM / 身份治理)应用凭据
+ * <p>
+ * 配置示例(application.yml):
+ * <pre>
+ * integration:
+ *   baseUrl: https://iam.example.com    # 不含 /iam 路径前缀,由 Client 内部拼接
+ *   clientId: ${INTP_CLIENT_ID:}
+ *   clientSecret: ${INTP_CLIENT_SECRET:}
+ * </pre>
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "integration")
+public class INTPConf {
+
+    /**
+     * 集成平台域名(不含 /iam 前缀)
+     */
+    private String baseUrl;
+
+    /**
+     * 应用 ID
+     */
+    private String clientId;
+
+    /**
+     * 应用凭证
+     */
+    private String clientSecret;
+
+    /**
+     * access_token 缓存 key
+     */
+    public static final String CACHE_KEY_TOKEN = "integration:accessToken";
+
+    /**
+     * token 过期前提前失效(秒)
+     */
+    public static final int TOKEN_AHEAD_SECONDS = 60;
+
+    /**
+     * 鉴权 endpoint(请求体方式)
+     */
+    public static final String PATH_TOKEN = "/iam/token";
+
+    /**
+     * 用户管理 endpoint
+     */
+    public static final String PATH_USERS = "/iam/api/users";
+    public static final String PATH_USER = "/iam/api/user";
+    public static final String PATH_USERS_DELETE = "/iam/api/users/delete";
+}

+ 76 - 0
mjava/src/main/java/com/malk/server/integration/INTPR.java

@@ -0,0 +1,76 @@
+package com.malk.server.integration;
+
+import com.malk.server.common.McException;
+import com.malk.server.common.VenR;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.Map;
+
+/**
+ * 集成平台统一响应包装
+ * <p>
+ * 服务端返回结构:
+ * <pre>
+ * {
+ *   "result": true|false,
+ *   "error": "ERROR_CODE",
+ *   "error_description": "可读错误描述",
+ *   "data": {...} | [...]
+ * }
+ * </pre>
+ */
+@Data
+@NoArgsConstructor
+public class INTPR<T> extends VenR {
+
+    /**
+     * 业务成功标记
+     */
+    private boolean result;
+
+    /**
+     * 错误代码(result=false 时有值)
+     */
+    private String error;
+
+    /**
+     * 错误描述(result=false 时有值)
+     */
+    private String error_description;
+
+    /**
+     * 业务数据
+     */
+    private T data;
+
+    /**
+     * OAuth2 token endpoint 直接返回字段(非包装结构)
+     */
+    private String access_token;
+    private String token_type;
+    private Integer expires_in;
+    private String scope;
+
+    /**
+     * 断言错误信息
+     */
+    @Override
+    public void assertSuccess() {
+        // ppExt: token endpoint 不走包装结构,access_token 非空即视为成功
+        if (access_token != null) {
+            return;
+        }
+        McException.assertException(!result, error, error_description, "integration");
+    }
+
+    /// ---------- 静态请求入口 ----------
+
+    public static INTPR doPost(String url, Map header, Map param, Map body) {
+        return (INTPR) VenR.doPost(url, header, param, body, VenR.RC_INTP);
+    }
+
+    public static INTPR doGet(String url, Map header, Map param) {
+        return (INTPR) VenR.doGet(url, header, param, VenR.RC_INTP);
+    }
+}

+ 110 - 0
mjava/src/main/java/com/malk/service/integration/INTPClient_User.java

@@ -0,0 +1,110 @@
+package com.malk.service.integration;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 集成平台 - 用户管理原子接口
+ * <p>
+ * 文档来源:apifox 公开站;本接口覆盖鉴权 + 用户 CRUD(4 个写/查方法)。
+ * <p>
+ * 调用方约定:
+ * <ul>
+ *   <li>每个方法第一个参数 access_token 必传,调用方先调 {@link #getAccessToken()} 拿到再传入</li>
+ *   <li>{@link #getAccessToken()} 内部由 UtilToken 缓存({@link com.malk.server.integration.INTPConf#CACHE_KEY_TOKEN}),过期前自动刷新</li>
+ *   <li>响应失败由 INTPR.assertSuccess 抛 McException,全局拦截转 HTTP 4xx;调用方无需手动判 result</li>
+ * </ul>
+ * <p>
+ * 典型用法:
+ * <pre>
+ * String token = client.getAccessToken();
+ * client.createUser(token, "alice", "Pwd@123", null);
+ * </pre>
+ */
+public interface INTPClient_User {
+
+    /**
+     * 获取 access_token(自带缓存)
+     * <p>
+     * 接口:POST /iam/token,grant_type=client_credentials
+     *
+     * @return Bearer 令牌字符串
+     */
+    String getAccessToken();
+
+    /**
+     * 创建用户
+     * <p>
+     * 接口:POST /iam/api/users
+     * <p>
+     * body_ext 可选字段(全部非必填):
+     * <ul>
+     *   <li>name (String) - 用户全名,1-64 字符</li>
+     *   <li>email (String) - 邮箱地址</li>
+     *   <li>phone_number (String) - 手机号</li>
+     *   <li>user_job_number (String) - 员工 ID,1-32 字符</li>
+     *   <li>nick_name (String) - 昵称,1-64 字符</li>
+     *   <li>picture (String) - 头像 base64 编码</li>
+     *   <li>org_ids (List&lt;String&gt;) - 用户所属组织 ID 列表</li>
+     *   <li>address (String) - 办公地点</li>
+     *   <li>title (String) - 职位</li>
+     *   <li>hired_date (String) - 入职日期(时间戳)</li>
+     *   <li>tag (List&lt;Map&gt;) - 标签/角色信息,子字段:id/name/code/type/description/expression/app_id/app_name/app_code/group_is_default/group_description</li>
+     *   <li>group_positions (List&lt;Map&gt;) - 部门属性,子字段:is_main/is_manager/position/user_code/org_id/org_name/user_order</li>
+     * </ul>
+     *
+     * @param access_token Bearer token
+     * @param username     登录名(必填,1-64 字符)
+     * @param password     密码(必填)
+     * @param body_ext     可选字段,传 null 表示不传
+     * @return data 字段:{ sub, username, name, phone_number, user_job_number, email, nickname, org_ids, address, title, hired_date, created_at }
+     */
+    Map createUser(String access_token, String username, String password, Map body_ext);
+
+    /**
+     * 修改用户
+     * <p>
+     * 接口:PATCH /iam/api/user?username={username}
+     * <p>
+     * username 走 Query Param 而非 Path(**与 RESTful 惯例不同**)。
+     * body_ext 字段同 createUser(除 username/password 外),不传字段保持服务端原值。
+     *
+     * @param access_token Bearer token
+     * @param username     待修改用户的登录名(必填)
+     * @param body_ext     待更新字段
+     * @return result=true 时返回 true
+     */
+    Boolean updateUser(String access_token, String username, Map body_ext);
+
+    /**
+     * 批量删除用户(按登录名)
+     * <p>
+     * 接口:POST /iam/api/users/delete(**HTTP 方法是 POST,因 body 需带数组**)
+     *
+     * @param access_token Bearer token
+     * @param usernames    待删除用户的登录名列表
+     * @return result=true 时返回 true
+     */
+    Boolean deleteUsers(String access_token, List<String> usernames);
+
+    /**
+     * 查询用户列表
+     * <p>
+     * 接口:GET /iam/api/users
+     * <p>
+     * query 可选字段:
+     * <ul>
+     *   <li>q (String) - 姓名/用户名/手机/邮箱/工号 模糊查询</li>
+     *   <li>org_id (String) - 组/部门 id</li>
+     *   <li>page (Integer) - 页码</li>
+     *   <li>size (Integer) - 每页条数</li>
+     *   <li>attrs (String) - 用户属性,多个属性用逗号分隔</li>
+     *   <li>return_users_in_sub_org (Boolean) - 是否查询子组/子部门用户,默认 true</li>
+     * </ul>
+     *
+     * @param access_token Bearer token
+     * @param query        查询条件,传 null 表示返回全量第一页
+     * @return data 字段:{ items: [...], total, size, page, pages }
+     */
+    Map queryUsers(String access_token, Map query);
+}

+ 76 - 0
mjava/src/main/java/com/malk/service/integration/impl/INTPImplClient_User.java

@@ -0,0 +1,76 @@
+package com.malk.service.integration.impl;
+
+import com.malk.server.integration.INTPConf;
+import com.malk.server.integration.INTPR;
+import com.malk.service.integration.INTPClient_User;
+import com.malk.utils.UtilHttp;
+import com.malk.utils.UtilMap;
+import com.malk.utils.UtilToken;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+@Slf4j
+public class INTPImplClient_User implements INTPClient_User {
+
+    @Autowired
+    private INTPConf intpConf;
+
+    @Override
+    public String getAccessToken() {
+        String cached = UtilToken.get(INTPConf.CACHE_KEY_TOKEN);
+        if (StringUtils.isNotBlank(cached)) {
+            return cached;
+        }
+        // ppExt: OAuth2 token endpoint 走 form-urlencoded,不是 JSON body
+        Map form = UtilMap.map("grant_type, client_id, client_secret",
+                "client_credentials", intpConf.getClientId(), intpConf.getClientSecret());
+        INTPR r = (INTPR) UtilHttp.doPost(intpConf.getBaseUrl() + INTPConf.PATH_TOKEN,
+                null, null, null, form, INTPR.class);
+        // fixme: expires_in 通常 7200s;提前 60s 失效,UtilToken.put 内部还会再扣 5s
+        long ttlMillis = (long) (r.getExpires_in() - INTPConf.TOKEN_AHEAD_SECONDS) * 1000L;
+        UtilToken.put(INTPConf.CACHE_KEY_TOKEN, r.getAccess_token(), ttlMillis);
+        return r.getAccess_token();
+    }
+
+    @Override
+    public Map createUser(String access_token, String username, String password, Map body_ext) {
+        Map header = UtilMap.map("Authorization", "Bearer " + access_token);
+        Map body = UtilMap.map("username, password", username, password);
+        if (body_ext != null) {
+            body.putAll(body_ext);
+        }
+        return (Map) INTPR.doPost(intpConf.getBaseUrl() + INTPConf.PATH_USERS, header, null, body).getData();
+    }
+
+    @Override
+    public Boolean updateUser(String access_token, String username, Map body_ext) {
+        Map header = UtilMap.map("Authorization", "Bearer " + access_token);
+        // ppExt: username 走 Query Param 而非 Path(apifox 文档原样保留)
+        Map param = UtilMap.map("username", username);
+        Map body = body_ext != null ? body_ext : new HashMap();
+        INTPR r = (INTPR) UtilHttp.doPatch(intpConf.getBaseUrl() + INTPConf.PATH_USER,
+                header, param, body, INTPR.class);
+        return r.isResult();
+    }
+
+    @Override
+    public Boolean deleteUsers(String access_token, List<String> usernames) {
+        Map header = UtilMap.map("Authorization", "Bearer " + access_token);
+        // ppExt: HTTP 方法是 POST 而非 DELETE(因 body 需带数组)
+        Map body = UtilMap.map("usernames", usernames);
+        return INTPR.doPost(intpConf.getBaseUrl() + INTPConf.PATH_USERS_DELETE, header, null, body).isResult();
+    }
+
+    @Override
+    public Map queryUsers(String access_token, Map query) {
+        Map header = UtilMap.map("Authorization", "Bearer " + access_token);
+        return (Map) INTPR.doGet(intpConf.getBaseUrl() + INTPConf.PATH_USERS, header, query).getData();
+    }
+}