5 次代碼提交 3027e67a71 ... 136468ad91

作者 SHA1 備註 提交日期
  malk 136468ad91 refactor(token-namespace): 基座 4 vendor token cache key 迁移到 namespace API 1 周之前
  malk b343a3b812 feat(utils): UtilToken 加 namespace API + DynamicDDService 切到新 API 1 周之前
  malk 2a1bf55d3e fix(dingtalk-contacts): 补 OAPI_BASE 常量 + 2 处直接 .get() NPE 高危补 assertSuccess 1 周之前
  malk ed0ae33088 refactor(yida): YDHelpers 抽出共用 helper + 修隐藏 bug 6 处 1 周之前
  malk 14def659ab feat(utils): 新增 UtilRespMapper 第三方响应类型守卫工具 1 周之前

+ 6 - 6
mjava-pro/src/main/java/com/malk/pro/service/dingtalk/DynamicDDService.java

@@ -25,8 +25,7 @@ import org.springframework.stereotype.Service;
 @Service
 public class DynamicDDService {
 
-    private static final String VENDOR = "dingtalk";
-    private static final String TOKEN_KEY_FMT = "%s:" + VENDOR + ":%s";
+    private static final String VENDOR = com.malk.server.dingtalk.DDConf.NS;
     // 钉钉 access_token 官方 TTL 7200s;UtilToken.put 内部会再扣 5s 容错
     private static final long DEFAULT_TOKEN_TTL_MS = 7200_000L;
 
@@ -54,17 +53,18 @@ public class DynamicDDService {
      */
     public String getAccessToken() {
         VendorCredential credential = currentCredential();
-        String key = String.format(TOKEN_KEY_FMT, TenantContext.currentTenantId(), credential.getAppKey());
-        String token = UtilToken.get(key);
+        String tenantId = TenantContext.currentTenantId();
+        String key = VENDOR + ":" + credential.getAppKey();
+        String token = UtilToken.get(tenantId, key);
         if (StringUtils.isNotBlank(token)) {
             return token;
         }
         token = ddClient.getAccessToken(credential.getAppKey(), credential.getAppSecret());
         if (StringUtils.isBlank(token)) {
             throw new McException("DD_TOKEN_FAILED",
-                    "tenant=" + TenantContext.currentTenantId() + " 钉钉 token 获取失败");
+                    "tenant=" + tenantId + " 钉钉 token 获取失败");
         }
-        UtilToken.put(key, token, DEFAULT_TOKEN_TTL_MS);
+        UtilToken.put(tenantId, key, token, DEFAULT_TOKEN_TTL_MS);
         return token;
     }
 

+ 6 - 0
mjava/src/main/java/com/malk/server/aliwork/YDConf.java

@@ -39,6 +39,12 @@ public class YDConf {
      */
     public static final Integer PAGE_SIZE_DETAILS = 50;
 
+    /**
+     * 宜搭开放平台 API 基础地址(新版)
+     */
+    public static final String BASE_V1 = "https://api.dingtalk.com/v1.0/yida";
+    public static final String BASE_V2 = "https://api.dingtalk.com/v2.0/yida";
+
 
     /**
      * 接口访问账号 [不能触发待办与消息通知, 业务规则亦不能]

+ 15 - 0
mjava/src/main/java/com/malk/server/dingtalk/DDConf.java

@@ -35,11 +35,26 @@ public class DDConf {
     // 机器人编号
     private String robotCode;
 
+    /**
+     * UtilToken namespace(多 appKey / 多租户场景按 vendor 隔离 cache key)
+     */
+    public static final String NS = "dingtalk";
+
     /**
      * 钉钉一级部门: 1
      */
     public static final long TOP_DEPARTMENT = 1L;
 
+    /**
+     * 钉钉开放平台 OAPI 基础地址(v1/v2 老接口共用)
+     */
+    public static final String OAPI_BASE = "https://oapi.dingtalk.com";
+
+    /**
+     * 钉钉开放平台新版 API 基础地址(v1.0 新接口)
+     */
+    public static final String API_BASE = "https://api.dingtalk.com";
+
     /**
      * 知识库权限设置成员上限: 30
      */

+ 5 - 0
mjava/src/main/java/com/malk/server/fxiaoke/FXKConf.java

@@ -21,4 +21,9 @@ public class FXKConf {
 
     /// 永久授权码
     private String permanentCode;
+
+    /**
+     * UtilToken namespace(按 vendor 隔离 cache key)
+     */
+    public static final String NS = "fxiaoke";
 }

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

@@ -36,9 +36,9 @@ public class INTPConf {
     private String clientSecret;
 
     /**
-     * access_token 缓存 key
+     * UtilToken namespace(按 vendor 隔离 cache key;与 dingtalk / aliwork 等并列)
      */
-    public static final String CACHE_KEY_TOKEN = "integration:accessToken";
+    public static final String NS = "integration";
 
     /**
      * token 过期前提前失效(秒)

+ 5 - 0
mjava/src/main/java/com/malk/server/teambition/TBConf.java

@@ -33,6 +33,11 @@ public class TBConf {
         return "https://open.teambition.com/api"; // 公有云环境
     }
 
+    /**
+     * UtilToken namespace(按 vendor 隔离 cache key)
+     */
+    public static final String NS = "teambition";
+
     /**
      * 一个分页数量上限 [上限 1000]
      */

+ 44 - 136
mjava/src/main/java/com/malk/service/aliwork/impl/YDClient_FormImpl.java

@@ -1,28 +1,37 @@
 package com.malk.service.aliwork.impl;
 
-import com.alibaba.fastjson.JSON;
 import com.malk.server.aliwork.YDAuth;
-import com.malk.server.aliwork.YDConf;
-import com.malk.server.common.McException;
 import com.malk.server.dingtalk.DDR_New;
 import com.malk.service.aliwork.YDClient_Form;
 import com.malk.service.dingtalk.DDClient;
 import com.malk.utils.UtilHttp;
+import com.malk.utils.UtilRespMapper;
 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.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
+import static com.malk.service.aliwork.impl.YDHelpers.assertResult;
+import static com.malk.service.aliwork.impl.YDHelpers.body;
+import static com.malk.service.aliwork.impl.YDHelpers.mergeExt;
+import static com.malk.service.aliwork.impl.YDHelpers.normPage;
+import static com.malk.service.aliwork.impl.YDHelpers.normPageSize;
+import static com.malk.service.aliwork.impl.YDHelpers.normSearchField;
+import static com.malk.service.aliwork.impl.YDHelpers.url;
+import static com.malk.service.aliwork.impl.YDHelpers.urlV2;
+import static com.malk.service.aliwork.impl.YDHelpers.validatePageSize;
+
 /**
  * 宜搭表单原子接口实现
  *
  * <p>严格对齐 {@code mjava-baseline §3.4.2}:所有 body_ext 字段透传到 HTTP body,不过滤。
  * accessToken 为 null 时回退到 {@code DDClient.initTokenHeader()} 全局 token。</p>
+ *
+ * <p>共用 helper(url/body/mergeExt/assertResult/normXxx)见 {@link YDHelpers};
+ * 响应类型守卫见 {@link UtilRespMapper}。</p>
  */
 @Slf4j
 @Service
@@ -31,21 +40,8 @@ public class YDClient_FormImpl implements YDClient_Form {
     @Autowired
     private DDClient ddClient;
 
-    // ---------------- 共用工具 ----------------
-
-    private static final String BASE_V1 = "https://api.dingtalk.com/v1.0/yida";
-    private static final String BASE_V2 = "https://api.dingtalk.com/v2.0/yida";
-
-    private String url(String uri) {
-        return BASE_V1 + uri;
-    }
-
-    private String urlV2(String uri) {
-        return BASE_V2 + uri;
-    }
-
     /**
-     * 构造请求头:accessToken 为 null 时回退到全局 DDClient。
+     * 构造请求头:accessToken 为 null 时回退到全局 DDClient(依赖注入,留在 impl 内)。
      */
     private Map<String, String> header(YDAuth auth) {
         if (auth.getAccessToken() != null && !auth.getAccessToken().isEmpty()) {
@@ -56,43 +52,6 @@ public class YDClient_FormImpl implements YDClient_Form {
         return ddClient.initTokenHeader();
     }
 
-    /**
-     * 构造基础 body:auth 三字段 + body_ext 透传。
-     */
-    private Map<String, Object> body(YDAuth auth) {
-        Map<String, Object> body = new HashMap<>();
-        body.put("appType", auth.getAppType());
-        body.put("systemToken", auth.getSystemToken());
-        body.put("userId", auth.resolvedUserId());
-        return body;
-    }
-
-    /**
-     * 合并 body_ext 透传(不过滤任何字段;优先级:显式参数 > body_ext > auth)
-     */
-    private Map<String, Object> mergeExt(Map<String, Object> body, Map<String, Object> body_ext) {
-        if (body_ext == null || body_ext.isEmpty()) {
-            return body;
-        }
-        // body_ext 先进(优先级低),然后显式参数覆盖
-        Map<String, Object> merged = new HashMap<>(body_ext);
-        merged.putAll(body);
-        return merged;
-    }
-
-    private DDR_New assertResult(DDR_New ddr, String action) {
-        if (ddr == null) {
-            throw new McException("YIDA_NULL_RESPONSE", "宜搭接口 [" + action + "] 返回空");
-        }
-        if (!ddr.isSuccess()) {
-            throw new McException(
-                    ddr.getCode() == null ? "YIDA_ERROR" : ddr.getCode(),
-                    "宜搭接口 [" + action + "] 失败: " + ddr.getMessage()
-            );
-        }
-        return ddr;
-    }
-
     // ================================================================
     //  表单实例 CRUD
     // ================================================================
@@ -104,7 +63,7 @@ public class YDClient_FormImpl implements YDClient_Form {
         body.put("formDataJson", formDataJson);
         body = mergeExt(body, body_ext);
         DDR_New r = assertResult(DDR_New.doPost(url("/forms/instances"), header(auth), null, body), "saveForm");
-        return String.valueOf(r.getResult());
+        return UtilRespMapper.asString(r.getResult());
     }
 
     @Override
@@ -114,8 +73,7 @@ public class YDClient_FormImpl implements YDClient_Form {
         body.put("updateFormDataJson", updateFormDataJson);
         body = mergeExt(body, body_ext);
         DDR_New r = assertResult((DDR_New) UtilHttp.doPut(url("/forms/instances"), header(auth), body, DDR_New.class), "updateForm");
-        Object result = r.getResult();
-        return result instanceof Map ? (Map<String, Object>) result : Collections.emptyMap();
+        return UtilRespMapper.asMap(r.getResult());
     }
 
     @Override
@@ -126,8 +84,7 @@ public class YDClient_FormImpl implements YDClient_Form {
         body.put("formDataJson", formDataJson);
         body = mergeExt(body, body_ext);
         DDR_New r = assertResult(DDR_New.doPost(urlV2("/forms/instances/insertOrUpdate"), header(auth), null, body), "upsertForm");
-        Object result = r.getResult();
-        return result instanceof Map ? (Map<String, Object>) result : Collections.emptyMap();
+        return UtilRespMapper.asMap(r.getResult());
     }
 
     @Override
@@ -136,7 +93,7 @@ public class YDClient_FormImpl implements YDClient_Form {
         body.put("formInstanceId", formInstanceId);
         body = mergeExt(body, body_ext);
         DDR_New r = assertResult((DDR_New) UtilHttp.doDelete(url("/forms/instances"), header(auth), body, DDR_New.class), "deleteForm");
-        return Boolean.TRUE.equals(r.getResult()) || "true".equalsIgnoreCase(String.valueOf(r.getResult()));
+        return UtilRespMapper.asBool(r.getResult());
     }
 
     @Override
@@ -146,11 +103,7 @@ public class YDClient_FormImpl implements YDClient_Form {
         body.put("searchFieldJson", searchFieldJson);
         body = mergeExt(body, body_ext);
         DDR_New r = assertResult(DDR_New.doPost(url("/forms/instances/batchRemove"), header(auth), null, body), "deleteFormByCondition");
-        Object result = r.getResult();
-        if (result instanceof Number) {
-            return ((Number) result).intValue();
-        }
-        return 0;
+        return UtilRespMapper.asInt(r.getResult(), 0);
     }
 
     @Override
@@ -160,8 +113,7 @@ public class YDClient_FormImpl implements YDClient_Form {
         body.put("updateFormDataJson", updateFormDataJson);
         body = mergeExt(body, body_ext);
         DDR_New r = assertResult((DDR_New) UtilHttp.doPut(url("/forms/instances/components"), header(auth), body, DDR_New.class), "updateFormComponents");
-        Object result = r.getResult();
-        return result instanceof Map ? (Map<String, Object>) result : Collections.emptyMap();
+        return UtilRespMapper.asMap(r.getResult());
     }
 
     @Override
@@ -171,11 +123,7 @@ public class YDClient_FormImpl implements YDClient_Form {
         body.put("formDataListJson", formDataListJson);
         body = mergeExt(body, body_ext);
         DDR_New r = assertResult(DDR_New.doPost(url("/forms/instances/batchSave"), header(auth), null, body), "batchSaveForm");
-        Object result = r.getResult();
-        if (result instanceof List) {
-            return JSON.parseArray(JSON.toJSONString(result), String.class);
-        }
-        return Collections.emptyList();
+        return UtilRespMapper.asStringList(r.getResult());
     }
 
     // ================================================================
@@ -184,51 +132,38 @@ public class YDClient_FormImpl implements YDClient_Form {
 
     @Override
     public Map<String, Object> getForm(YDAuth auth, String formInstanceId, Map<String, Object> body_ext) {
-        Map<String, Object> param = body(auth);
-        if (body_ext != null) {
-            param.putAll(body_ext);
-            // body_ext 优先级低,auth 字段覆盖
-            param.putAll(body(auth));
-        }
+        Map<String, Object> param = mergeExt(body(auth), body_ext);
         DDR_New r = assertResult(DDR_New.doGet(url("/forms/instances/" + formInstanceId), header(auth), param), "getForm");
-        Object result = r.getResult();
-        return result instanceof Map ? (Map<String, Object>) result : Collections.emptyMap();
+        return UtilRespMapper.asMap(r.getResult());
     }
 
     @Override
     public Map<String, Object> searchForm(YDAuth auth, String formUuid, String searchFieldJson,
                                           Integer currentPage, Integer pageSize, Map<String, Object> body_ext) {
-        if (pageSize != null && pageSize > YDConf.PAGE_SIZE_LIMIT) {
-            throw new McException("YIDA_PAGESIZE_EXCEEDED", "pageSize 不能超过 " + YDConf.PAGE_SIZE_LIMIT);
-        }
+        validatePageSize(pageSize);
         Map<String, Object> body = body(auth);
         body.put("formUuid", formUuid);
-        body.put("searchFieldJson", StringUtils.isBlank(searchFieldJson) ? "{}" : searchFieldJson);
-        body.put("currentPage", currentPage == null ? 1 : currentPage);
-        body.put("pageSize", pageSize == null ? YDConf.PAGE_SIZE_LIMIT : pageSize);
+        body.put("searchFieldJson", normSearchField(searchFieldJson));
+        body.put("currentPage", normPage(currentPage));
+        body.put("pageSize", normPageSize(pageSize));
         body = mergeExt(body, body_ext);
         DDR_New r = assertResult(DDR_New.doPost(url("/forms/instances/search"), header(auth), null, body), "searchForm");
-        Object result = r.getResult();
-        return result instanceof Map ? (Map<String, Object>) result : Collections.emptyMap();
+        return UtilRespMapper.asMap(r.getResult());
     }
 
     @Override
     public List<String> listFormIds(YDAuth auth, String formUuid, String searchFieldJson,
                                     Integer currentPage, Integer pageSize, Map<String, Object> body_ext) {
         Map<String, Object> body = body(auth);
-        body.put("searchFieldJson", StringUtils.isBlank(searchFieldJson) ? "{}" : searchFieldJson);
-        body.put("currentPage", currentPage == null ? 1 : currentPage);
-        body.put("pageSize", pageSize == null ? YDConf.PAGE_SIZE_LIMIT : pageSize);
+        body.put("searchFieldJson", normSearchField(searchFieldJson));
+        body.put("currentPage", normPage(currentPage));
+        body.put("pageSize", normPageSize(pageSize));
         body = mergeExt(body, body_ext);
         DDR_New r = assertResult(
-                DDR_New.doPost(url("/forms/instances/ids/" + auth.getAppType() + "/" + formUuid), header(auth), body, body),
+                DDR_New.doPost(url("/forms/instances/ids/" + auth.getAppType() + "/" + formUuid), header(auth), null, body),
                 "listFormIds"
         );
-        Object result = r.getResult();
-        if (result instanceof List) {
-            return JSON.parseArray(JSON.toJSONString(result), String.class);
-        }
-        return Collections.emptyList();
+        return UtilRespMapper.asStringList(r.getResult());
     }
 
     @Override
@@ -236,33 +171,22 @@ public class YDClient_FormImpl implements YDClient_Form {
                                             Integer currentPage, Integer pageSize, Map<String, Object> body_ext) {
         Map<String, Object> body = body(auth);
         body.put("formUuid", formUuid);
-        body.put("searchFieldJson", StringUtils.isBlank(searchFieldJson) ? "{}" : searchFieldJson);
-        body.put("currentPage", currentPage == null ? 1 : currentPage);
-        body.put("pageSize", pageSize == null ? YDConf.PAGE_SIZE_LIMIT : pageSize);
+        body.put("searchFieldJson", normSearchField(searchFieldJson));
+        body.put("currentPage", normPage(currentPage));
+        body.put("pageSize", normPageSize(pageSize));
         body = mergeExt(body, body_ext);
         DDR_New r = assertResult(DDR_New.doPost(url("/forms/instances/advances/queryAll"), header(auth), null, body), "listFormsAll");
-        Object result = r.getResult();
-        return result instanceof Map ? (Map<String, Object>) result : Collections.emptyMap();
+        return UtilRespMapper.asMap(r.getResult());
     }
 
     @Override
     public List<Map<String, Object>> listInnerTable(YDAuth auth, String formInstanceId, Map<String, Object> body_ext) {
-        Map<String, Object> param = body(auth);
-        if (body_ext != null) {
-            param.putAll(body_ext);
-            param.putAll(body(auth));
-        }
+        Map<String, Object> param = mergeExt(body(auth), body_ext);
         DDR_New r = assertResult(
                 DDR_New.doGet(url("/forms/innerTables/" + formInstanceId), header(auth), param),
                 "listInnerTable"
         );
-        Object result = r.getResult();
-        if (result instanceof List) {
-            return JSON.parseArray(JSON.toJSONString(result), Map.class).stream()
-                    .map(m -> (Map<String, Object>) m)
-                    .collect(java.util.stream.Collectors.toList());
-        }
-        return Collections.emptyList();
+        return UtilRespMapper.asMapList(r.getResult());
     }
 
     @Override
@@ -271,30 +195,14 @@ public class YDClient_FormImpl implements YDClient_Form {
         body.put("formInstanceId", formInstanceId);
         body = mergeExt(body, body_ext);
         DDR_New r = assertResult(DDR_New.doPost(url("/forms/operationsLogs/query"), header(auth), null, body), "listFormOperations");
-        Object result = r.getResult();
-        if (result instanceof List) {
-            return JSON.parseArray(JSON.toJSONString(result), Map.class).stream()
-                    .map(m -> (Map<String, Object>) m)
-                    .collect(java.util.stream.Collectors.toList());
-        }
-        return Collections.emptyList();
+        return UtilRespMapper.asMapList(r.getResult());
     }
 
     @Override
     public List<Map<String, Object>> listForms(YDAuth auth, Map<String, Object> body_ext) {
-        Map<String, Object> param = body(auth);
-        if (body_ext != null) {
-            param.putAll(body_ext);
-            param.putAll(body(auth));
-        }
+        Map<String, Object> param = mergeExt(body(auth), body_ext);
         DDR_New r = assertResult(DDR_New.doGet(url("/forms"), header(auth), param), "listForms");
-        Object result = r.getResult();
-        if (result instanceof List) {
-            return JSON.parseArray(JSON.toJSONString(result), Map.class).stream()
-                    .map(m -> (Map<String, Object>) m)
-                    .collect(java.util.stream.Collectors.toList());
-        }
-        return Collections.emptyList();
+        return UtilRespMapper.asMapList(r.getResult());
     }
 
     @Override
@@ -321,6 +229,6 @@ public class YDClient_FormImpl implements YDClient_Form {
                 DDR_New.doGet(url("/apps/temporaryUrls/" + auth.getAppType()), header(auth), param),
                 "convertTempUrl"
         );
-        return String.valueOf(r.getResult());
+        return UtilRespMapper.asString(r.getResult());
     }
 }

+ 36 - 87
mjava/src/main/java/com/malk/service/aliwork/impl/YDClient_ProcessImpl.java

@@ -1,27 +1,34 @@
 package com.malk.service.aliwork.impl;
 
-import com.alibaba.fastjson.JSON;
 import com.malk.server.aliwork.YDAuth;
-import com.malk.server.aliwork.YDConf;
-import com.malk.server.common.McException;
 import com.malk.server.dingtalk.DDR_New;
 import com.malk.service.aliwork.YDClient_Process;
 import com.malk.service.dingtalk.DDClient;
+import com.malk.utils.UtilRespMapper;
 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.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
+import static com.malk.service.aliwork.impl.YDHelpers.assertResult;
+import static com.malk.service.aliwork.impl.YDHelpers.body;
+import static com.malk.service.aliwork.impl.YDHelpers.mergeExt;
+import static com.malk.service.aliwork.impl.YDHelpers.normPage;
+import static com.malk.service.aliwork.impl.YDHelpers.normPageSize;
+import static com.malk.service.aliwork.impl.YDHelpers.normSearchField;
+import static com.malk.service.aliwork.impl.YDHelpers.url;
+import static com.malk.service.aliwork.impl.YDHelpers.validatePageSize;
+
 /**
  * 宜搭流程原子接口实现
  *
  * <p>URL 路径参照旧 YDClientImpl 已验证 endpoint(start/search_process);新增 endpoint 推断自
  * 宜搭开放平台惯例,实施冒烟时需对照最新官方文档确认。</p>
+ *
+ * <p>共用 helper 见 {@link YDHelpers};响应类型守卫见 {@link UtilRespMapper}。</p>
  */
 @Slf4j
 @Service
@@ -30,12 +37,6 @@ public class YDClient_ProcessImpl implements YDClient_Process {
     @Autowired
     private DDClient ddClient;
 
-    private static final String BASE_V1 = "https://api.dingtalk.com/v1.0/yida";
-
-    private String url(String uri) {
-        return BASE_V1 + uri;
-    }
-
     private Map<String, String> header(YDAuth auth) {
         if (auth.getAccessToken() != null && !auth.getAccessToken().isEmpty()) {
             Map<String, String> h = new HashMap<>();
@@ -45,36 +46,6 @@ public class YDClient_ProcessImpl implements YDClient_Process {
         return ddClient.initTokenHeader();
     }
 
-    private Map<String, Object> body(YDAuth auth) {
-        Map<String, Object> body = new HashMap<>();
-        body.put("appType", auth.getAppType());
-        body.put("systemToken", auth.getSystemToken());
-        body.put("userId", auth.resolvedUserId());
-        return body;
-    }
-
-    private Map<String, Object> mergeExt(Map<String, Object> body, Map<String, Object> body_ext) {
-        if (body_ext == null || body_ext.isEmpty()) {
-            return body;
-        }
-        Map<String, Object> merged = new HashMap<>(body_ext);
-        merged.putAll(body);
-        return merged;
-    }
-
-    private DDR_New assertResult(DDR_New ddr, String action) {
-        if (ddr == null) {
-            throw new McException("YIDA_NULL_RESPONSE", "宜搭流程接口 [" + action + "] 返回空");
-        }
-        if (!ddr.isSuccess()) {
-            throw new McException(
-                    ddr.getCode() == null ? "YIDA_ERROR" : ddr.getCode(),
-                    "宜搭流程接口 [" + action + "] 失败: " + ddr.getMessage()
-            );
-        }
-        return ddr;
-    }
-
     // ================================================================
     //  流程实例生命周期
     // ================================================================
@@ -87,7 +58,7 @@ public class YDClient_ProcessImpl implements YDClient_Process {
         body.put("formDataJson", formDataJson);
         body = mergeExt(body, body_ext);
         DDR_New r = assertResult(DDR_New.doPost(url("/processes/instances/start"), header(auth), null, body), "startProcess");
-        return String.valueOf(r.getResult());
+        return UtilRespMapper.asString(r.getResult());
     }
 
     @Override
@@ -96,7 +67,7 @@ public class YDClient_ProcessImpl implements YDClient_Process {
         body.put("processInstanceId", processInstanceId);
         body = mergeExt(body, body_ext);
         DDR_New r = assertResult(DDR_New.doPost(url("/processes/instances/terminate"), header(auth), null, body), "terminateProcess");
-        return Boolean.TRUE.equals(r.getResult()) || "true".equalsIgnoreCase(String.valueOf(r.getResult()));
+        return UtilRespMapper.asBool(r.getResult());
     }
 
     @Override
@@ -105,7 +76,7 @@ public class YDClient_ProcessImpl implements YDClient_Process {
         body.put("processInstanceId", processInstanceId);
         body = mergeExt(body, body_ext);
         DDR_New r = assertResult(DDR_New.doPost(url("/processes/instances/revoke"), header(auth), null, body), "revokeProcess");
-        return Boolean.TRUE.equals(r.getResult()) || "true".equalsIgnoreCase(String.valueOf(r.getResult()));
+        return UtilRespMapper.asBool(r.getResult());
     }
 
     @Override
@@ -115,7 +86,7 @@ public class YDClient_ProcessImpl implements YDClient_Process {
         body.put("targetActivityId", targetActivityId);
         body = mergeExt(body, body_ext);
         DDR_New r = assertResult(DDR_New.doPost(url("/processes/instances/redirect"), header(auth), null, body), "redirectProcess");
-        return Boolean.TRUE.equals(r.getResult()) || "true".equalsIgnoreCase(String.valueOf(r.getResult()));
+        return UtilRespMapper.asBool(r.getResult());
     }
 
     // ================================================================
@@ -130,7 +101,7 @@ public class YDClient_ProcessImpl implements YDClient_Process {
         body.put("remark", comment);
         body = mergeExt(body, body_ext);
         DDR_New r = assertResult(DDR_New.doPost(url("/processes/tasks/agree"), header(auth), null, body), "agreeTask");
-        return Boolean.TRUE.equals(r.getResult()) || "true".equalsIgnoreCase(String.valueOf(r.getResult()));
+        return UtilRespMapper.asBool(r.getResult());
     }
 
     @Override
@@ -141,7 +112,7 @@ public class YDClient_ProcessImpl implements YDClient_Process {
         body.put("remark", comment);
         body = mergeExt(body, body_ext);
         DDR_New r = assertResult(DDR_New.doPost(url("/processes/tasks/disagree"), header(auth), null, body), "disagreeTask");
-        return Boolean.TRUE.equals(r.getResult()) || "true".equalsIgnoreCase(String.valueOf(r.getResult()));
+        return UtilRespMapper.asBool(r.getResult());
     }
 
     @Override
@@ -153,7 +124,7 @@ public class YDClient_ProcessImpl implements YDClient_Process {
         body.put("remark", comment);
         body = mergeExt(body, body_ext);
         DDR_New r = assertResult(DDR_New.doPost(url("/processes/tasks/redirect"), header(auth), null, body), "redirectTask");
-        return Boolean.TRUE.equals(r.getResult()) || "true".equalsIgnoreCase(String.valueOf(r.getResult()));
+        return UtilRespMapper.asBool(r.getResult());
     }
 
     @Override
@@ -165,7 +136,7 @@ public class YDClient_ProcessImpl implements YDClient_Process {
         body.put("remark", comment);
         body = mergeExt(body, body_ext);
         DDR_New r = assertResult(DDR_New.doPost(url("/processes/tasks/cc"), header(auth), null, body), "ccTask");
-        return Boolean.TRUE.equals(r.getResult()) || "true".equalsIgnoreCase(String.valueOf(r.getResult()));
+        return UtilRespMapper.asBool(r.getResult());
     }
 
     @Override
@@ -176,7 +147,7 @@ public class YDClient_ProcessImpl implements YDClient_Process {
         body.put("remark", comment);
         body = mergeExt(body, body_ext);
         DDR_New r = assertResult(DDR_New.doPost(url("/processes/tasks/comment"), header(auth), null, body), "commentTask");
-        return Boolean.TRUE.equals(r.getResult()) || "true".equalsIgnoreCase(String.valueOf(r.getResult()));
+        return UtilRespMapper.asBool(r.getResult());
     }
 
     // ================================================================
@@ -185,35 +156,27 @@ public class YDClient_ProcessImpl implements YDClient_Process {
 
     @Override
     public Map<String, Object> getProcess(YDAuth auth, String processInstanceId, Map<String, Object> body_ext) {
-        Map<String, Object> param = body(auth);
-        if (body_ext != null) {
-            param.putAll(body_ext);
-            param.putAll(body(auth));
-        }
+        Map<String, Object> param = mergeExt(body(auth), body_ext);
         DDR_New r = assertResult(
                 DDR_New.doGet(url("/processes/instances/" + processInstanceId), header(auth), param),
                 "getProcess"
         );
-        Object result = r.getResult();
-        return result instanceof Map ? (Map<String, Object>) result : Collections.emptyMap();
+        return UtilRespMapper.asMap(r.getResult());
     }
 
     @Override
     public Map<String, Object> searchProcesses(YDAuth auth, String formUuid, String processCode, String searchFieldJson,
                                                Integer currentPage, Integer pageSize, Map<String, Object> body_ext) {
-        if (pageSize != null && pageSize > YDConf.PAGE_SIZE_LIMIT) {
-            throw new McException("YIDA_PAGESIZE_EXCEEDED", "pageSize 不能超过 " + YDConf.PAGE_SIZE_LIMIT);
-        }
+        validatePageSize(pageSize);
         Map<String, Object> body = body(auth);
         body.put("formUuid", formUuid);
         body.put("processCode", processCode);
-        body.put("searchFieldJson", StringUtils.isBlank(searchFieldJson) ? "{}" : searchFieldJson);
-        body.put("currentPage", currentPage == null ? 1 : currentPage);
-        body.put("pageSize", pageSize == null ? YDConf.PAGE_SIZE_LIMIT : pageSize);
+        body.put("searchFieldJson", normSearchField(searchFieldJson));
+        body.put("currentPage", normPage(currentPage));
+        body.put("pageSize", normPageSize(pageSize));
         body = mergeExt(body, body_ext);
-        DDR_New r = assertResult(DDR_New.doPost(url("/processes/instances"), header(auth), body, body), "searchProcesses");
-        Object result = r.getResult();
-        return result instanceof Map ? (Map<String, Object>) result : Collections.emptyMap();
+        DDR_New r = assertResult(DDR_New.doPost(url("/processes/instances"), header(auth), null, body), "searchProcesses");
+        return UtilRespMapper.asMap(r.getResult());
     }
 
     @Override
@@ -222,34 +185,20 @@ public class YDClient_ProcessImpl implements YDClient_Process {
         Map<String, Object> body = body(auth);
         body.put("formUuid", formUuid);
         body.put("processCode", processCode);
-        body.put("searchFieldJson", StringUtils.isBlank(searchFieldJson) ? "{}" : searchFieldJson);
-        body.put("currentPage", currentPage == null ? 1 : currentPage);
-        body.put("pageSize", pageSize == null ? YDConf.PAGE_SIZE_LIMIT : pageSize);
+        body.put("searchFieldJson", normSearchField(searchFieldJson));
+        body.put("currentPage", normPage(currentPage));
+        body.put("pageSize", normPageSize(pageSize));
         body = mergeExt(body, body_ext);
-        DDR_New r = assertResult(DDR_New.doPost(url("/processes/instanceIds"), header(auth), body, body), "listProcessIds");
-        Object result = r.getResult();
-        if (result instanceof List) {
-            return JSON.parseArray(JSON.toJSONString(result), String.class);
-        }
-        return Collections.emptyList();
+        DDR_New r = assertResult(DDR_New.doPost(url("/processes/instanceIds"), header(auth), null, body), "listProcessIds");
+        return UtilRespMapper.asStringList(r.getResult());
     }
 
     @Override
     public List<Map<String, Object>> listApprovalRecords(YDAuth auth, String processInstanceId, Map<String, Object> body_ext) {
         Map<String, Object> param = body(auth);
         param.put("processInstanceId", processInstanceId);
-        if (body_ext != null) {
-            Map<String, Object> merged = new HashMap<>(body_ext);
-            merged.putAll(param);
-            param = merged;
-        }
+        param = mergeExt(param, body_ext);
         DDR_New r = assertResult(DDR_New.doGet(url("/processes/operationRecords"), header(auth), param), "listApprovalRecords");
-        Object result = r.getResult();
-        if (result instanceof List) {
-            return JSON.parseArray(JSON.toJSONString(result), Map.class).stream()
-                    .map(m -> (Map<String, Object>) m)
-                    .collect(java.util.stream.Collectors.toList());
-        }
-        return Collections.emptyList();
+        return UtilRespMapper.asMapList(r.getResult());
     }
 }

+ 109 - 0
mjava/src/main/java/com/malk/service/aliwork/impl/YDHelpers.java

@@ -0,0 +1,109 @@
+package com.malk.service.aliwork.impl;
+
+import com.malk.server.aliwork.YDAuth;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.common.McException;
+import com.malk.server.dingtalk.DDR_New;
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 宜搭原子 Client 实现侧共用 helper(package-private)
+ *
+ * <p>{@link YDClient_FormImpl} 与 {@link YDClient_ProcessImpl} 共用同一套 URL 拼装 / body 构造 / 响应断言逻辑,
+ * 此前两份实现里各贴一份完全相同的私有方法。本类把无依赖 Spring bean 的部分抽出为 package-private 静态方法。</p>
+ *
+ * <p>{@code header(YDAuth)} 不在此处——它依赖 {@code DDClient} bean,留在各 impl 自行拼装。</p>
+ */
+abstract class YDHelpers {
+
+    /**
+     * v1 endpoint 完整 URL
+     */
+    static String url(String uri) {
+        return YDConf.BASE_V1 + uri;
+    }
+
+    /**
+     * v2 endpoint 完整 URL
+     */
+    static String urlV2(String uri) {
+        return YDConf.BASE_V2 + uri;
+    }
+
+    /**
+     * 构造基础 body:auth 三字段(appType / systemToken / userId)
+     */
+    static Map<String, Object> body(YDAuth auth) {
+        Map<String, Object> body = new HashMap<>();
+        body.put("appType", auth.getAppType());
+        body.put("systemToken", auth.getSystemToken());
+        body.put("userId", auth.resolvedUserId());
+        return body;
+    }
+
+    /**
+     * 合并 body_ext:显式字段优先级最高,body_ext 不覆盖已有 key。
+     * <p>等价于先 putAll(body_ext) 再 putAll(body),但避免临时 Map 分配。</p>
+     */
+    static Map<String, Object> mergeExt(Map<String, Object> body, Map<String, Object> body_ext) {
+        if (body_ext == null || body_ext.isEmpty()) {
+            return body;
+        }
+        Map<String, Object> merged = new HashMap<>(body_ext);
+        merged.putAll(body);
+        return merged;
+    }
+
+    /**
+     * 断言宜搭响应成功;失败抛 {@link McException},附宜搭原始 code + message。
+     *
+     * @param ddr    DDR_New 响应(宜搭走钉钉网关返回,沿用 DDR_New 包装)
+     * @param action 调用点名,用于错误信息定位
+     */
+    static DDR_New assertResult(DDR_New ddr, String action) {
+        if (ddr == null) {
+            throw new McException("YIDA_NULL_RESPONSE", "宜搭接口 [" + action + "] 返回空");
+        }
+        if (!ddr.isSuccess()) {
+            throw new McException(
+                    ddr.getCode() == null ? "YIDA_ERROR" : ddr.getCode(),
+                    "宜搭接口 [" + action + "] 失败: " + ddr.getMessage()
+            );
+        }
+        return ddr;
+    }
+
+    /**
+     * 分页参数规范:超过宜搭硬上限抛业务异常
+     */
+    static void validatePageSize(Integer pageSize) {
+        if (pageSize != null && pageSize > YDConf.PAGE_SIZE_LIMIT) {
+            throw new McException("YIDA_PAGESIZE_EXCEEDED",
+                    "pageSize 不能超过 " + YDConf.PAGE_SIZE_LIMIT);
+        }
+    }
+
+    /**
+     * currentPage 默认值:null → 1
+     */
+    static int normPage(Integer currentPage) {
+        return currentPage == null ? 1 : currentPage;
+    }
+
+    /**
+     * pageSize 默认值:null → PAGE_SIZE_LIMIT
+     */
+    static int normPageSize(Integer pageSize) {
+        return pageSize == null ? YDConf.PAGE_SIZE_LIMIT : pageSize;
+    }
+
+    /**
+     * searchFieldJson 默认值:空白 → "{}"
+     */
+    static String normSearchField(String searchFieldJson) {
+        return StringUtils.isBlank(searchFieldJson) ? "{}" : searchFieldJson;
+    }
+}

+ 10 - 6
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient.java

@@ -31,7 +31,8 @@ public class DDImplClient implements DDClient {
     @Synchronized
     @Override
     public String getAccessToken() {
-        String accessToken = UtilToken.get("invalid-token-dingtalk");
+        String key = "appToken:" + ddConf.getAppKey();
+        String accessToken = UtilToken.get(DDConf.NS, key);
         if (StringUtils.isNotBlank(accessToken)) return accessToken;
         Map param = new HashMap();
         param.put("appkey", ddConf.getAppKey());
@@ -40,7 +41,7 @@ public class DDImplClient implements DDClient {
         log.info("响应token, {}", r.getAccessToken());
         accessToken = r.getAccessToken();
         // token失效自动重置: DD重新调用会重置过期时间
-        UtilToken.put("invalid-token-dingtalk", accessToken, r.getExpiresIn() * 1000L);
+        UtilToken.put(DDConf.NS, key, accessToken, r.getExpiresIn() * 1000L);
         return accessToken;
     }
 
@@ -61,7 +62,8 @@ public class DDImplClient implements DDClient {
     @Synchronized
     @Override
     public String getUserAccessToken(boolean isRefresh, String code_refreshToken) {
-        String accessToken = UtilToken.get("invalid-user-token-dingtalk");
+        String key = "userToken:" + ddConf.getAppKey();
+        String accessToken = UtilToken.get(DDConf.NS, key);
         if (StringUtils.isNotBlank(accessToken)) return accessToken;
         Map boyd = UtilMap.map("clientId, clientSecret", ddConf.getAppKey(), ddConf.getAppSecret());
         if (isRefresh) {
@@ -75,7 +77,7 @@ public class DDImplClient implements DDClient {
         log.info("用户token, {}", r.getAccessToken());
         accessToken = r.getAccessToken();
         // token失效自动重置: DD重新调用会重置过期时间
-        UtilToken.put("invalid-user-token-dingtalk", accessToken, r.getExpiresIn() * 1000L);
+        UtilToken.put(DDConf.NS, key, accessToken, r.getExpiresIn() * 1000L);
         return accessToken;
     }
 
@@ -103,13 +105,15 @@ public class DDImplClient implements DDClient {
     @Synchronized
     @Override
     public String getJsApiTicket(String accessToken) {
-        String ticket = UtilToken.get("invalid-ticket-dingtalk");
+        // ppExt: jsapi ticket 是 corp/app 级别(用 appKey 区分),不绑特定 accessToken
+        String key = "jsapiTicket:" + ddConf.getAppKey();
+        String ticket = UtilToken.get(DDConf.NS, key);
         if (StringUtils.isNotBlank(ticket)) return ticket;
         DDR r = DDR.doGet("https://oapi.dingtalk.com/get_jsapi_ticket", null, UtilMap.map("access_token", accessToken));
         log.info("响应ticket, {}", r.getAccessToken());
         ticket = r.getTicket();
         // token失效自动重置: DD重新调用会重置过期时间
-        UtilToken.put("invalid-ticket-dingtalk", ticket, r.getExpiresIn() * 1000L);
+        UtilToken.put(DDConf.NS, key, ticket, r.getExpiresIn() * 1000L);
         return ticket;
     }
 

+ 9 - 3
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Contacts.java

@@ -35,7 +35,10 @@ public class DDImplClient_Contacts implements DDClient_Contacts {
         Map body = UtilMap.map("dept_id", dept_id);
         // ppExt:: 格屋临时添加, 后续上线限流重试功能
 //        Thread.sleep(50);
-        Map rsp = (Map) DDR.doPost("https://oapi.dingtalk.com/topapi/v2/department/listsubid", null, param, body).getResult();
+        DDR r = DDR.doPost("https://oapi.dingtalk.com/topapi/v2/department/listsubid", null, param, body);
+        // fixme: 失败时 result 为 null, 直接 .get("dept_id_list") 会 NPE; 先 assertSuccess 抛带 errcode 的业务异常
+        r.assertSuccess();
+        Map rsp = (Map) r.getResult();
         List<Number> list = (List<Number>) rsp.get("dept_id_list");
         // ppExt: 不要直接使用 Number 作为类型, 不同基本类型比较值时会有偏差 [可以作为父类接受数据, 避免直接强制类型转换错误, 再进行基本类型处理]
         return list.stream().map(item -> item.longValue()).collect(Collectors.toList());
@@ -101,7 +104,10 @@ public class DDImplClient_Contacts implements DDClient_Contacts {
     public List<String> listDepartmentUserId(String access_token, long dept_id) {
         Map param = UtilMap.map("access_token", access_token);
         Map body = UtilMap.map("dept_id", dept_id);
-        Map rsp = (Map) DDR.doPost("https://oapi.dingtalk.com/topapi/user/listid", null, param, body).getResult();
+        DDR r = DDR.doPost("https://oapi.dingtalk.com/topapi/user/listid", null, param, body);
+        // fixme: 失败时 result 为 null, 直接 .get("userid_list") 会 NPE; 先 assertSuccess 抛带 errcode 的业务异常
+        r.assertSuccess();
+        Map rsp = (Map) r.getResult();
         return (List<String>) rsp.get("userid_list");
     }
 
@@ -299,7 +305,7 @@ public class DDImplClient_Contacts implements DDClient_Contacts {
     //  统一模板:param = {access_token}; body 按 §3.4.2 必填显式 + body_ext 透传
     // ================================================================================
 
-    private static final String OAPI = "https://oapi.dingtalk.com";
+    private static final String OAPI = DDConf.OAPI_BASE;
 
     /** 统一构造含 access_token 的 param map */
     private Map qs(String access_token) {

+ 3 - 4
mjava/src/main/java/com/malk/service/fxiaoke/impl/FXKImplClient.java

@@ -25,8 +25,6 @@ public class FXKImplClient implements FXKClient {
     @Autowired
     private FXKConf fxkConf;
 
-    private final static String UNIQUE_STORAGE_ID = "invalid-token-fxiaoke";
-
     /**
      * 获取访问授权
      *
@@ -35,7 +33,8 @@ public class FXKImplClient implements FXKClient {
     @Synchronized
     @Override
     public String getAccessToken() {
-        String accessToken = UtilToken.get(UNIQUE_STORAGE_ID);
+        String key = "corpToken:" + fxkConf.getAppId();
+        String accessToken = UtilToken.get(FXKConf.NS, key);
         if (StringUtils.isNotBlank(accessToken)) return accessToken;
         Map body = new HashMap();
         body.put("appId", fxkConf.getAppId());
@@ -45,7 +44,7 @@ public class FXKImplClient implements FXKClient {
         log.info("响应token, {}", r.getCorpAccessToken());
         accessToken = r.getCorpAccessToken();
         // token失效自动重置: FXK重新调用不会重置过期时间
-        UtilToken.put(UNIQUE_STORAGE_ID, accessToken, r.getExpiresIn() * 1000L);
+        UtilToken.put(FXKConf.NS, key, accessToken, r.getExpiresIn() * 1000L);
         return accessToken;
     }
 

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

@@ -11,7 +11,7 @@ import java.util.Map;
  * 调用方约定:
  * <ul>
  *   <li>每个方法第一个参数 access_token 必传,调用方先调 {@link #getAccessToken()} 拿到再传入</li>
- *   <li>{@link #getAccessToken()} 内部由 UtilToken 缓存({@link com.malk.server.integration.INTPConf#CACHE_KEY_TOKEN}),过期前自动刷新</li>
+ *   <li>{@link #getAccessToken()} 内部由 UtilToken 缓存(namespace = {@link com.malk.server.integration.INTPConf#NS} / key = {@code "token:" + clientId}),过期前自动刷新</li>
  *   <li>响应失败由 INTPR.assertSuccess 抛 McException,全局拦截转 HTTP 4xx;调用方无需手动判 result</li>
  * </ul>
  * <p>

+ 3 - 2
mjava/src/main/java/com/malk/service/integration/impl/INTPImplClient_User.java

@@ -24,7 +24,8 @@ public class INTPImplClient_User implements INTPClient_User {
 
     @Override
     public String getAccessToken() {
-        String cached = UtilToken.get(INTPConf.CACHE_KEY_TOKEN);
+        String key = "token:" + intpConf.getClientId();
+        String cached = UtilToken.get(INTPConf.NS, key);
         if (StringUtils.isNotBlank(cached)) {
             return cached;
         }
@@ -35,7 +36,7 @@ public class INTPImplClient_User implements INTPClient_User {
                 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);
+        UtilToken.put(INTPConf.NS, key, r.getAccess_token(), ttlMillis);
         return r.getAccess_token();
     }
 

+ 3 - 2
mjava/src/main/java/com/malk/service/teambition/impl/TBClientImpl.java

@@ -35,7 +35,8 @@ public class TBClientImpl implements TBClient {
     // 获取访问授权
     @Synchronized
     private String getAccessToken() {
-        String accessToken = UtilToken.get("invalid-token-teambition");
+        String key = "token:" + tbConf.getAppID();
+        String accessToken = UtilToken.get(TBConf.NS, key);
         if (StringUtils.isNotBlank(accessToken)) return accessToken;
         Algorithm algorithm = Algorithm.HMAC256(tbConf.getAppSecret());
         long timestamp = System.currentTimeMillis();
@@ -48,7 +49,7 @@ public class TBClientImpl implements TBClient {
                 .sign(algorithm);
         log.info("响应token, {}", accessToken);
         // token失效自动重置: TB传递过期时间2h, 重复调用就会刷新
-        UtilToken.put("invalid-token-teambition", accessToken, EXPIRES_IN);
+        UtilToken.put(TBConf.NS, key, accessToken, EXPIRES_IN);
         return accessToken;
     }
 

+ 88 - 0
mjava/src/main/java/com/malk/utils/UtilRespMapper.java

@@ -0,0 +1,88 @@
+package com.malk.utils;
+
+import com.alibaba.fastjson.JSON;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 第三方响应 result 字段类型守卫工具
+ *
+ * <p>统一处理 VenR 子类(DDR / DDR_New / YDR / INTPR 等)的 {@code getResult()} / {@code getData()}
+ * 返回值——通常是 {@code Object},需要 instanceof + cast。本工具把"判类型 + 强转 + 容错默认值"
+ * 收敛为单方法调用,避免 Client 实现里散落 4-5 行的样板。</p>
+ *
+ * <p>典型用法:</p>
+ * <pre>
+ * DDR_New r = ...assertResult(...);
+ * Map data = UtilRespMapper.asMap(r.getResult());
+ * List&lt;String&gt; ids = UtilRespMapper.asStringList(r.getResult());
+ * List&lt;Map&lt;String, Object&gt;&gt; rows = UtilRespMapper.asMapList(r.getResult());
+ * </pre>
+ *
+ * <p>所有方法在 {@code result} 为 null 或类型不符时返回**安全默认值**(空集合 / null / 0 / false),
+ * 不抛异常——调用方已通过 assertSuccess 处理过失败响应,此处只关心成功响应的类型分发。</p>
+ */
+public abstract class UtilRespMapper {
+
+    /**
+     * Map<String, Object> 守卫;不符返回 emptyMap
+     */
+    @SuppressWarnings("unchecked")
+    public static Map<String, Object> asMap(Object result) {
+        if (result instanceof Map) {
+            return (Map<String, Object>) result;
+        }
+        return Collections.emptyMap();
+    }
+
+    /**
+     * List<Map<String, Object>> 守卫(数据行集合,常见于查询接口的 records / items)
+     */
+    @SuppressWarnings("unchecked")
+    public static List<Map<String, Object>> asMapList(Object result) {
+        if (!(result instanceof List)) {
+            return Collections.emptyList();
+        }
+        // fixme: 走 JSON round-trip 是为了把内部 LinkedHashMap/JSONObject 统一成 Map<String,Object>
+        return JSON.parseArray(JSON.toJSONString(result), Map.class).stream()
+                .map(m -> (Map<String, Object>) m)
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * List<String> 守卫(常见于 ID 列表 / 简单字符串数组)
+     */
+    public static List<String> asStringList(Object result) {
+        if (!(result instanceof List)) {
+            return Collections.emptyList();
+        }
+        return JSON.parseArray(JSON.toJSONString(result), String.class);
+    }
+
+    /**
+     * 字符串守卫;result 为 null 返回 null(非 "null" 字符串)
+     */
+    public static String asString(Object result) {
+        return result == null ? null : String.valueOf(result);
+    }
+
+    /**
+     * 整数守卫;非 Number 返回 defaultVal
+     */
+    public static int asInt(Object result, int defaultVal) {
+        if (result instanceof Number) {
+            return ((Number) result).intValue();
+        }
+        return defaultVal;
+    }
+
+    /**
+     * 布尔守卫;接受 Boolean.TRUE 或字面量 "true"(大小写忽略)
+     */
+    public static boolean asBool(Object result) {
+        return Boolean.TRUE.equals(result) || "true".equalsIgnoreCase(String.valueOf(result));
+    }
+}

+ 65 - 2
mjava/src/main/java/com/malk/utils/UtilToken.java

@@ -2,14 +2,42 @@ package com.malk.utils;
 
 import cn.hutool.cache.CacheUtil;
 import cn.hutool.cache.impl.TimedCache;
+import org.apache.commons.lang3.StringUtils;
 
 /**
- * token过期处理
+ * token 过期处理
+ *
+ * <p>提供两类 API:</p>
+ * <ol>
+ *   <li><b>单参 key</b>({@link #put(String, String, Long)} / {@link #get(String)})— 兼容原有调用方,
+ *       key 自包含命名空间(如 {@code "dingtalk:appKey123"})</li>
+ *   <li><b>namespace + key</b>({@link #put(String, String, String, Long)} / {@link #get(String, String)})—
+ *       推荐多租户 / 多应用场景使用,namespace 与 key 拼接为 {@code "{namespace}:{key}"} 后存入底层缓存。
+ *       namespace 为 null 或空时退化为单参 key 模式。</li>
+ * </ol>
+ *
+ * <p>典型用法(多租户):</p>
+ * <pre>
+ * // mjava-pro 多租户场景
+ * UtilToken.put(tenantId, "dingtalk:" + appKey, token, 7200_000L);
+ * String t = UtilToken.get(tenantId, "dingtalk:" + appKey);
+ *
+ * // 基座单租户场景
+ * UtilToken.put("dingtalk:appKey", token, 7200_000L);
+ * String t = UtilToken.get("dingtalk:appKey");
+ * </pre>
  */
 public abstract class UtilToken {
 
     private static final TimedCache<String, String> TIMED_CACHE = CacheUtil.newTimedCache(0);
 
+    /**
+     * 命名空间分隔符
+     */
+    public static final String NS_SEPARATOR = ":";
+
+    /// ---------- 单参 key API(向后兼容) ----------
+
     public static void put(String key, String value, Long timeout) {
         if (timeout > 5000) timeout -= 5000; // 避免极端情况, 冗余5s容错处理
         /** 设置消逝时间 */
@@ -25,4 +53,39 @@ public abstract class UtilToken {
         // 重新刷新消逝时间
         return TIMED_CACHE.get(key);
     }
-}
+
+    /// ---------- namespace + key API(推荐多租户场景使用) ----------
+
+    /**
+     * 按 namespace 隔离写入;namespace 为空时退化为单参 key 模式
+     *
+     * @param namespace 命名空间(如 tenantId / vendor / corp 标识);null 或空字符串退化
+     * @param key       业务 key(如 {@code "dingtalk:" + appKey})
+     * @param value     token / ticket 等待缓存的字符串值
+     * @param timeout   TTL 毫秒;put 内部会冗余扣 5s
+     */
+    public static void put(String namespace, String key, String value, Long timeout) {
+        put(buildKey(namespace, key), value, timeout);
+    }
+
+    /**
+     * 按 namespace 隔离读取;不刷新消逝时间
+     */
+    public static String get(String namespace, String key) {
+        return get(buildKey(namespace, key));
+    }
+
+    /**
+     * 按 namespace 隔离读取并刷新消逝时间
+     */
+    public static String getWithRefresh(String namespace, String key) {
+        return getWithRefresh(buildKey(namespace, key));
+    }
+
+    /**
+     * 拼接 namespace + key 为底层缓存 key;namespace 空白时直接返回 key
+     */
+    public static String buildKey(String namespace, String key) {
+        return StringUtils.isBlank(namespace) ? key : namespace + NS_SEPARATOR + key;
+    }
+}