Kaynağa Gözat

企业微信数据智能专区-修改获取企微会话功能

cgp 1 hafta önce
ebeveyn
işleme
c6b40a157d
39 değiştirilmiş dosya ile 2107 ekleme ve 1157 silme
  1. 3 4
      fs-admin/src/main/java/com/fs/qw/controller/CorporateWeChatSpaceController.java
  2. 1 1
      fs-service/src/main/java/com/fs/qw/service/ICorporateWeChatSpaceService.java
  3. 149 34
      fs-service/src/main/java/com/fs/qw/service/impl/ICorporateWeChatSpaceServiceImpl.java
  4. 49 0
      fs-service/src/main/java/com/fs/qw/utils/WeChatSpaceDecryptUtil.java
  5. 32 2
      fs-service/src/main/java/com/fs/qw/utils/WeChatSpaceUtil.java
  6. 0 38
      fs-service/src/main/java/com/fs/qw/utils/WeComSignatureUtil.java
  7. 22 8
      fs-service/src/main/java/com/fs/qw/vo/QwSessionConfigVo.java
  8. 212 0
      fs-service/src/main/java/com/tencent/wework/SpecCallbackSDK.java
  9. 163 0
      fs-service/src/main/java/com/tencent/wework/SpecSDK.java
  10. 168 0
      fs-service/src/main/java/com/tencent/wework/SpecUtil.java
  11. 38 7
      fs-spec-zone/Dockerfile
  12. 18 0
      fs-spec-zone/docker镜像制作步骤
  13. 45 37
      fs-spec-zone/pom.xml
  14. 0 32
      fs-spec-zone/src/main/java/README_SDK.md
  15. 0 43
      fs-spec-zone/src/main/java/com/fs/speczone/DebugModeRunner.java
  16. 0 13
      fs-spec-zone/src/main/java/com/fs/speczone/SpecZoneApplication.java
  17. 0 237
      fs-spec-zone/src/main/java/com/fs/speczone/controller/CallbackController.java
  18. 0 33
      fs-spec-zone/src/main/java/com/fs/speczone/controller/WeComApiController.java
  19. 0 25
      fs-spec-zone/src/main/java/com/fs/speczone/handler/FetchConversationsHandler.java
  20. 0 22
      fs-spec-zone/src/main/java/com/fs/speczone/handler/ProgramActionHandler.java
  21. 0 155
      fs-spec-zone/src/main/java/com/fs/speczone/sdk/SpecSdkAdapter.java
  22. 0 132
      fs-spec-zone/src/main/java/com/fs/speczone/service/ConversationService.java
  23. 0 11
      fs-spec-zone/src/main/java/com/fs/speczone/service/WeComService.java
  24. 0 149
      fs-spec-zone/src/main/java/com/fs/speczone/service/impl/WeComServiceImpl.java
  25. 0 65
      fs-spec-zone/src/main/java/com/fs/speczone/util/WeChatDecryptUtil.java
  26. 0 63
      fs-spec-zone/src/main/java/com/fs/speczone/util/WeChatTokenUtil.java
  27. 0 46
      fs-spec-zone/src/main/java/com/fs/speczone/util/WeComSignatureUtil.java
  28. 63 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/CommonUtils.java
  29. 175 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/DataBaseUtils.java
  30. 79 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/DemoCallProgramHandler.java
  31. 72 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/DemoReceiveCallBackHandler.java
  32. 123 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/NetworkService.java
  33. 62 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/RequestContext.java
  34. 263 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/RequestProcessor.java
  35. 171 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/ResourceMonitor.java
  36. 115 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/SpecDemo.java
  37. 31 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/SvrConfig.java
  38. 33 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/ThreadPoolSingleton.java
  39. 20 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/UserLogicHandler.java

+ 3 - 4
fs-admin/src/main/java/com/fs/qw/controller/CorporateWeChatSpaceController.java

@@ -21,18 +21,17 @@ public class CorporateWeChatSpaceController extends BaseController {
     // 企业微信会话专区中转接口
     @GetMapping("/conversations")
     public JSONObject getConversations(
-            @RequestParam(defaultValue = "0") long seq,
             @RequestParam(defaultValue = "100") long limit,
-            @RequestParam(defaultValue = "0") long proxy,
             @RequestParam(defaultValue = "30") long timeout,
+            @RequestParam(required = false) String cursor,
             @RequestParam(required = false) String customerId,
-            @RequestParam(required = false) String staffUserId) throws Exception {
+            @RequestParam(required = false) String staffUserId) {
         if (customerId == null|| customerId.isEmpty()) {
             throw new CustomException("客户id不能为空");
         }else if (staffUserId == null|| staffUserId.isEmpty()) {
             throw new CustomException("员工id不能为空");
         }
-        return weChatSpaceService.fetchConversations(seq, limit, proxy, timeout, customerId,staffUserId);
+        return weChatSpaceService.fetchConversations(limit, timeout, cursor, customerId,staffUserId);
     }
 
 

+ 1 - 1
fs-service/src/main/java/com/fs/qw/service/ICorporateWeChatSpaceService.java

@@ -7,7 +7,7 @@ public interface ICorporateWeChatSpaceService {
     /**
      * 通过专区中转获取会话记录
      */
-    JSONObject fetchConversations(long seq, long limit, long proxy, long timeout, String customerId,String staffUserId);
+    JSONObject fetchConversations(long limit,long timeout,String cursor, String customerId,String staffUserId);
 
     /**
      * 获取 agentConfig 签名(供前端 JS-SDK 使用)

+ 149 - 34
fs-service/src/main/java/com/fs/qw/service/impl/ICorporateWeChatSpaceServiceImpl.java

@@ -2,18 +2,27 @@ package com.fs.qw.service.impl;
 
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.fs.common.exception.CustomException;
 import com.fs.qw.service.ICorporateWeChatSpaceService;
-import com.fs.qw.utils.WeChatTokenUtil;
-import com.fs.qw.utils.WeComSignatureUtil;
+import com.fs.qw.utils.WeChatSpaceDecryptUtil;
+import com.fs.qw.utils.WeChatSpaceUtil;
 import com.fs.qw.vo.QwSessionConfigVo;
 import com.fs.system.service.ISysConfigService;
-import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.web.client.RestTemplate;
+import lombok.RequiredArgsConstructor;
+
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
 
 @Slf4j
 @Service
@@ -23,73 +32,100 @@ public class ICorporateWeChatSpaceServiceImpl implements ICorporateWeChatSpaceSe
     @Autowired
     private ISysConfigService sysConfigService;
 
+    private final RestTemplate restTemplate = new RestTemplate();
+
+    private final ConcurrentHashMap<String, String> consumedCodes = new ConcurrentHashMap<>();
+
+    // 系统配置缓存前缀
     private final static String CONFIG_KEY = "qw.sessionConfig";
 
-    private final RestTemplate restTemplate = new RestTemplate();
-    private final java.util.concurrent.ConcurrentHashMap<String, String> consumedCodes = new java.util.concurrent.ConcurrentHashMap<>();
+    //获取会话记录Key
+    private final static String targetKey = "invokeSyncMsg";
+
+    private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
 
     // =============== 核心:通过专区中转拉取会话 ===============
     @Override
-    public JSONObject fetchConversations(long seq, long limit, long proxy, long timeout,
+    public JSONObject fetchConversations(long limit,long timeout,String cursor,
                                          String customerId, String staffUserId) {
         JSONObject result = new JSONObject();
         try {
-            // 1. 获取企业微信配置(避免重复调用 getQwSessionConfig())
             QwSessionConfigVo qwConfig = getQwSessionConfig();
             String corpid = qwConfig.getCorpid();
             String agentSecret = qwConfig.getAgentSecret();
-            // 2. 获取 access_token
-            String accessToken = WeChatTokenUtil.getAccessToken(corpid, agentSecret);
+            String accessToken = WeChatSpaceUtil.getAccessToken(corpid, agentSecret);
 
-            // 3. 构建 request_data(与专区程序约定一致
+            // 构建 request_data(invoke_sync_msg 能力要求
             JSONObject requestData = new JSONObject();
-            requestData.put("action", qwConfig.getAbilityAction());  // 对应能力 action
-            requestData.put("seq", seq);
-            requestData.put("limit", limit);
-            requestData.put("proxy", proxy);
-            requestData.put("timeout", timeout);
-            requestData.put("customerId", customerId);
-            requestData.put("staffUserId", staffUserId);
-
-            // 4. 能力ID
-            String abilityId = qwConfig.getFetchConversationAbilityId();
-            if (abilityId==null){
-                throw new CustomException("专区能力ID未配置");
+            if (StringUtils.isNotBlank(cursor)){// 首次为空不传
+                requestData.put("cursor", cursor);
             }
-            // 5. 调用 sync_call_program 接口
+
+            requestData.put("limit", limit > 0 ? limit : 200);  // 限制 1-1000
+            //requestData.put("token", "");  // 暂不传 token,有频率限制但测试够用
+
+            String abilityId = null;
+
+            if (qwConfig.getAbilityIds() != null) {
+                //根据配置的能力Key找到对应的 abilityId
+                abilityId = qwConfig.getAbilityIds().stream()
+                        .filter(item -> targetKey.equals(item.getKey()))
+                        .map(QwSessionConfigVo.AbilityItem::getValue)
+                        .findFirst()
+                        .orElse(null);
+            }
+            if (abilityId == null) {
+                throw new CustomException("未配置获取会话记录的能力ID");
+            }
+
+            // 调用 sync_call_program
             String url = "https://qyapi.weixin.qq.com/cgi-bin/chatdata/sync_call_program?access_token=" + accessToken;
             JSONObject requestBody = new JSONObject();
+            requestBody.put("program_id", qwConfig.getProgramId());
             requestBody.put("ability_id", abilityId);
             requestBody.put("request_data", JSON.toJSONString(requestData));
-            requestBody.put("program_id", qwConfig.getProgramId());
+
             log.info("调用专区接口: ability_id={}, request_data={}", abilityId, requestData);
             JSONObject response = restTemplate.postForObject(url, requestBody, JSONObject.class);
-            log.info("专区响应: {}", response);
+            //log.info("专区响应: {}", response);
 
-            // 6. 处理返回结果
             if (response != null && response.getInteger("errcode") == 0) {
                 String responseDataStr = response.getString("response_data");
                 if (responseDataStr != null) {
                     JSONObject responseData = JSON.parseObject(responseDataStr);
-                    if (responseData.getInteger("errcode") == 0) {
+                    Integer innerErrCode = responseData.getInteger("errcode");
+                    if (innerErrCode != null && innerErrCode == 0) {
+                        //获取 cursor用于下次拉取更多数据
+                        String nextCursor = responseData.getString("next_cursor");
+                        // 获取消息列表并处理
+                        JSONArray msgList = responseData.getJSONArray("msg_list");
+                        if (msgList != null && !msgList.isEmpty()) {
+                            // 解密 + 过滤 + 格式化
+                            JSONArray processedList = processMessages(msgList, customerId, staffUserId, qwConfig);
+                            result.put("data", processedList);
+                        } else {
+                            result.put("data", new JSONArray());
+                        }
                         result.put("errcode", 0);
                         result.put("errmsg", "ok");
-                        result.put("msgList", responseData.get("data"));
+                        //返回 has_more 和 next_cursor 给前端,当没有更多数据时,返回 has_more 为 0
+                        result.put("has_more", responseData.getInteger("has_more"));
+                        result.put("next_cursor", nextCursor);
                     } else {
-                        result.put("errcode", responseData.getInteger("errcode"));
+                        // 专区内部错误
+                        result.put("errcode", innerErrCode);
                         result.put("errmsg", responseData.getString("errmsg"));
                     }
                 } else {
                     result.put("errcode", -1);
                     result.put("errmsg", "专区返回数据格式错误");
                 }
-
             } else {
                 result.put("errcode", response != null ? response.getInteger("errcode") : -1);
-                result.put("errmsg", response != null ? response.getString("errmsg") : "专区调用失败");
+                result.put("errmsg", response != null ? response.getString("errmsg") : "调用专区失败");
             }
         } catch (Exception e) {
-            log.error("专区中转调用异常", e);
+            log.error("获取会话记录失败", e);
             result.put("errcode", -1);
             result.put("errmsg", "内部错误:" + e.getMessage());
         }
@@ -97,10 +133,11 @@ public class ICorporateWeChatSpaceServiceImpl implements ICorporateWeChatSpaceSe
     }
 
 
+
     @Override
     public JSONObject getAgentConfigSignature(String url) {
         QwSessionConfigVo qwSessionConfig = getQwSessionConfig();
-        return WeComSignatureUtil.generateAgentConfigSignature(qwSessionConfig.getCorpid(), qwSessionConfig.getAgentSecret(), qwSessionConfig.getAgentid(), url);
+        return WeChatSpaceUtil.generateAgentConfigSignature(qwSessionConfig.getCorpid(), qwSessionConfig.getAgentSecret(), qwSessionConfig.getAgentid(), url);
     }
 
     @Override
@@ -118,7 +155,7 @@ public class ICorporateWeChatSpaceServiceImpl implements ICorporateWeChatSpaceSe
         }
         QwSessionConfigVo qwSessionConfig = getQwSessionConfig();
         try {
-            String accessToken = WeChatTokenUtil.getAccessToken(qwSessionConfig.getCorpid(), qwSessionConfig.getAgentSecret());
+            String accessToken = WeChatSpaceUtil.getAccessToken(qwSessionConfig.getCorpid(), qwSessionConfig.getAgentSecret());
             String url = "https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo?access_token="
                     + accessToken + "&code=" + code;
             JSONObject resp = restTemplate.getForObject(url, JSONObject.class);
@@ -155,4 +192,82 @@ public class ICorporateWeChatSpaceServiceImpl implements ICorporateWeChatSpaceSe
         }
         return qwSessionConfig;
     }
+
+
+    private JSONArray processMessages(JSONArray msgList, String customerId, String staffUserId, QwSessionConfigVo qwConfig) {
+        return msgList.parallelStream()
+                .map(obj -> (JSONObject) obj)
+                .filter(msg -> isMessageRelatedToUsers(msg, customerId, staffUserId))
+                .map(msg -> decryptAndFormatMessage(msg, qwConfig))
+                .filter(Objects::nonNull)
+                .collect(Collectors.toCollection(JSONArray::new));
+    }
+
+    private boolean isMessageRelatedToUsers(JSONObject msg, String customerId, String staffUserId) {
+        // 如果都为空,则不过滤,返回全部
+        if ((customerId == null || customerId.isEmpty()) && (staffUserId == null || staffUserId.isEmpty())) {
+            return true;
+        }
+        boolean hasCustomer = (customerId == null || customerId.isEmpty());
+        boolean hasStaff = (staffUserId == null || staffUserId.isEmpty());
+
+        JSONObject sender = msg.getJSONObject("sender");
+        JSONArray receivers = msg.getJSONArray("receiver_list");
+
+        // 检查发送者
+        if (sender != null) {
+            String senderId = sender.getString("id");
+            int senderType = sender.getIntValue("type");
+            if (!hasCustomer && senderType == 2 && customerId.equals(senderId)) hasCustomer = true;
+            if (!hasStaff && senderType == 1 && staffUserId.equals(senderId)) hasStaff = true;
+        }
+        if (hasCustomer && hasStaff) return true;
+
+        // 检查接收者
+        if (receivers != null) {
+            for (int i = 0; i < receivers.size(); i++) {
+                JSONObject recv = receivers.getJSONObject(i);
+                String recvId = recv.getString("id");
+                int recvType = recv.getIntValue("type");
+                if (!hasCustomer && recvType == 2 && customerId.equals(recvId)) hasCustomer = true;
+                if (!hasStaff && recvType == 1 && staffUserId.equals(recvId)) hasStaff = true;
+                if (hasCustomer && hasStaff) return true;
+            }
+        }
+        return hasCustomer && hasStaff;
+    }
+
+    private JSONObject decryptAndFormatMessage(JSONObject msg, QwSessionConfigVo qwConfig) {
+        JSONObject result = new JSONObject();
+        try {
+            JSONObject encryptInfo = msg.getJSONObject("service_encrypt_info");
+            if (encryptInfo == null) return null;
+            String encryptedKey = encryptInfo.getString("encrypted_secret_key");
+            if (encryptedKey == null) return null;
+
+            // 解密得到 secretKey
+            String secretKey = WeChatSpaceDecryptUtil.decryptSecretKey(encryptedKey, qwConfig.getPrivateKey());
+
+            // 复制需要返回的字段
+            result.put("msgid", msg.getString("msgid"));
+            result.put("secretKey", secretKey);
+            result.put("sender", msg.get("sender"));
+            result.put("receiver_list", msg.get("receiver_list"));
+            result.put("msgtype", msg.getInteger("msgtype"));
+
+            Long sendTime = msg.getLong("send_time");
+            if (sendTime != null) {
+                String formattedTime = Instant.ofEpochSecond(sendTime)
+                        .atZone(ZoneId.systemDefault())
+                        .toLocalDateTime()
+                        .format(DATE_TIME_FORMATTER);
+                result.put("send_time_str", formattedTime);
+                result.put("send_time", sendTime);
+            }
+            return result;
+        } catch (Exception e) {
+            log.error("解密消息失败, msgid: {}", msg.getString("msgid"), e);
+            return null;
+        }
+    }
 }

+ 49 - 0
fs-service/src/main/java/com/fs/qw/utils/WeChatSpaceDecryptUtil.java

@@ -0,0 +1,49 @@
+package com.fs.qw.utils;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.Cipher;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyFactory;
+import java.security.PrivateKey;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.util.Base64;
+
+@Component
+public class WeChatSpaceDecryptUtil {
+
+    private static final Logger log = LoggerFactory.getLogger(WeChatSpaceDecryptUtil.class);
+
+    /**
+     * 解密 encrypted_secret_key
+     * @param encryptedSecretKey Base64 编码的 RSA 密文
+     * @param privateKeyPem PEM 格式私钥字符串(含 -----BEGIN/END-----)
+     * @return 原始 AES 密钥字符串
+     */
+    public static String decryptSecretKey(String encryptedSecretKey, String privateKeyPem) throws Exception {
+        if (privateKeyPem == null || privateKeyPem.isEmpty()) {
+            throw new IllegalArgumentException("私钥不能为空");
+        }
+        PrivateKey privateKey = parsePrivateKey(privateKeyPem);
+        byte[] encryptedData = Base64.getDecoder().decode(encryptedSecretKey);
+        Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
+        cipher.init(Cipher.DECRYPT_MODE, privateKey);
+        byte[] decryptedData = cipher.doFinal(encryptedData);
+        return new String(decryptedData, StandardCharsets.UTF_8);
+    }
+
+    private static PrivateKey parsePrivateKey(String privateKeyPem) throws Exception {
+        String privateKeyBase64 = privateKeyPem
+                .replace("-----BEGIN PRIVATE KEY-----", "")
+                .replace("-----END PRIVATE KEY-----", "")
+                .replace("-----BEGIN RSA PRIVATE KEY-----", "")
+                .replace("-----END RSA PRIVATE KEY-----", "")
+                .replaceAll("\\s", "");
+        byte[] keyBytes = Base64.getDecoder().decode(privateKeyBase64);
+        PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
+        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
+        return keyFactory.generatePrivate(spec);
+    }
+}

+ 32 - 2
fs-service/src/main/java/com/fs/qw/utils/WeChatTokenUtil.java → fs-service/src/main/java/com/fs/qw/utils/WeChatSpaceUtil.java

@@ -1,12 +1,15 @@
 package com.fs.qw.utils;
 
 import com.alibaba.fastjson.JSONObject;
+import org.apache.commons.codec.digest.DigestUtils;
 import org.springframework.web.client.RestTemplate;
 
+import java.util.UUID;
+
 /**
  * 企业微信 token / ticket 工具类
  */
-public class WeChatTokenUtil {
+public class WeChatSpaceUtil {
 
     private static final RestTemplate restTemplate = new RestTemplate();
     // 缓存 access_token,实际生产应使用 redis 或数据库
@@ -57,5 +60,32 @@ public class WeChatTokenUtil {
         throw new RuntimeException("获取 agent_ticket 失败: " + (resp == null ? "null response" : resp.getString("errmsg")));
     }
 
-    // 如果需要普通 jsapi_ticket,也可类似实现,但本场景不需要
+    /**
+     * 生成 agentConfig 签名(使用 agent_ticket)
+     * @param corpId     企业ID
+     * @param corpSecret 应用 secret
+     * @param agentId    应用ID
+     * @param url        当前页面完整URL(不含#)
+     */
+    public static JSONObject generateAgentConfigSignature(String corpId, String corpSecret, String agentId, String url) {
+        try {
+            // 1. 获取 agent_ticket
+            String ticket = WeChatSpaceUtil.getAgentTicket(corpId, corpSecret, agentId);
+            // 2. 生成随机串和时间戳
+            String nonceStr = UUID.randomUUID().toString().replaceAll("-", "");
+            String timestamp = Long.toString(System.currentTimeMillis() / 1000);
+            // 3. 拼接签名字符串
+            String signStr = "jsapi_ticket=" + ticket + "&noncestr=" + nonceStr + "&timestamp=" + timestamp + "&url=" + url;
+            // 4. SHA1 签名
+            String signature = DigestUtils.sha1Hex(signStr);
+
+            JSONObject result = new JSONObject();
+            result.put("timestamp", timestamp);
+            result.put("nonceStr", nonceStr);
+            result.put("signature", signature);
+            return result;
+        } catch (Exception e) {
+            throw new RuntimeException("生成 agentConfig 签名失败", e);
+        }
+    }
 }

+ 0 - 38
fs-service/src/main/java/com/fs/qw/utils/WeComSignatureUtil.java

@@ -1,38 +0,0 @@
-package com.fs.qw.utils;
-
-import com.alibaba.fastjson.JSONObject;
-import org.apache.commons.codec.digest.DigestUtils;
-
-import java.util.UUID;
-
-public class WeComSignatureUtil {
-
-    /**
-     * 生成 agentConfig 签名(使用 agent_ticket)
-     * @param corpId     企业ID
-     * @param corpSecret 应用 secret
-     * @param agentId    应用ID
-     * @param url        当前页面完整URL(不含#)
-     */
-    public static JSONObject generateAgentConfigSignature(String corpId, String corpSecret, String agentId, String url) {
-        try {
-            // 1. 获取 agent_ticket
-            String ticket = WeChatTokenUtil.getAgentTicket(corpId, corpSecret, agentId);
-            // 2. 生成随机串和时间戳
-            String nonceStr = UUID.randomUUID().toString().replaceAll("-", "");
-            String timestamp = Long.toString(System.currentTimeMillis() / 1000);
-            // 3. 拼接签名字符串
-            String signStr = "jsapi_ticket=" + ticket + "&noncestr=" + nonceStr + "&timestamp=" + timestamp + "&url=" + url;
-            // 4. SHA1 签名
-            String signature = DigestUtils.sha1Hex(signStr);
-
-            JSONObject result = new JSONObject();
-            result.put("timestamp", timestamp);
-            result.put("nonceStr", nonceStr);
-            result.put("signature", signature);
-            return result;
-        } catch (Exception e) {
-            throw new RuntimeException("生成 agentConfig 签名失败", e);
-        }
-    }
-}

+ 22 - 8
fs-service/src/main/java/com/fs/qw/vo/QwSessionConfigVo.java

@@ -1,9 +1,12 @@
 package com.fs.qw.vo;
 
 import lombok.Data;
+
+import java.util.List;
+
 /**
  * 企业微信专区配置-会话
- * */
+ */
 @Data
 public class QwSessionConfigVo {
 
@@ -13,15 +16,26 @@ public class QwSessionConfigVo {
 
     private String agentSecret;
 
-    // 会话专区查询会话记录能力id
-    private String fetchConversationAbilityId;
-
     //专区程序ID
     private String programId;
 
-    //专区程序能力action
-    private String abilityAction;
-
     //自建应用可信域名
     private String domain;
-}
+
+    //企业会话密钥
+    private String privateKey;
+
+    /**
+     * 动态能力ID列表
+     */
+    private List<AbilityItem> abilityIds;
+
+    /**
+     * 内部类:承载具体的 Key-Value
+     */
+    @Data
+    public static class AbilityItem {
+        private String key;   // 能力标识,例如 "invokeSyncMsg"
+        private String value; // 能力具体ID,例如 "invoke_sync_msg"
+    }
+}

+ 212 - 0
fs-service/src/main/java/com/tencent/wework/SpecCallbackSDK.java

@@ -0,0 +1,212 @@
+package com.tencent.wework;
+
+import java.util.HashMap;
+import java.util.Map;
+
+;
+
+/**
+ * @warning: 1. 不要修改成员变量名,native方法内有反射调用
+ *           2. 调用本地方法需保持包结构,本工具需放在包com.tencent.wework内
+ *           3. 不允许继承,类名和函数名均不可修改,会影响本地方法的引用,详见:javah生成本地方法头文件
+ */
+public final class SpecCallbackSDK {
+
+    /**
+     * @description 调用本地方法后实例化的对象指针
+     */
+    private long specCallbackSDKptr = 0;
+
+    public long GetPtr() { return specCallbackSDKptr; }
+
+    /**
+     * @description: 回包的headers
+     */
+    private Map<String, String> responseHeaders;
+
+    public Map<String, String> GetResponseHeaders() { return responseHeaders; }
+
+    /**
+     * @description:  回包的加密后的body
+     */
+    private String responseBody;
+
+    public String GetResponseBody() { return responseBody; }
+
+    /**
+     * @description:   每个请求构造一个SpecCallbackSDK示例,
+     *                 SpecCallbackSDK仅持有headers和body的引用,
+     *                 因此需保证headers和body的生存期比SpecCallbackSDK长
+     * @param method:  请求方法GET/POST
+     * @param headers: 请求header
+     * @param body:    请求body
+     * @example: 
+     * SpecCallbackSDK sdk = new SpecCallbackSDK(method, headers, body);
+     * if (sdk.IsOk()) {
+     *   String corpid = sdk.GetCorpId();
+     *   String agentid = sdk.GetAgentId();
+     *   String call_type = sdk.GetCallType();
+     *   String data = sdk.GetData();
+     *   //do something...
+     * } 
+     * String response = ...;
+     * sdk.BuildResponseHeaderBody(response);
+     * Map<String, String> responseHeaders = sdk.GetResponseHeaders();
+     * String body = sdk.GetResponseBody();
+     * //do response
+     * 
+     * @return errorcode 示例如下:
+     *         -920001: 未设置请求方法
+     *         -920002: 未设置请求header
+     *         -920003: 未设置请求body
+     * */
+    public SpecCallbackSDK(String method, Map<String, String> headers, String body) {
+        try {
+            specCallbackSDKptr = NewCallbackSDK(method, headers, body);
+        } catch (Exception e) {
+            SpecUtil.WWSpecLogError("SpecCallbackSDK exception caught", e.getMessage());
+        }
+    }
+
+    private native long NewCallbackSDK(String method, Map<String, String> headers, String body);
+
+    /**
+     * @usage 在Java对象的内存回收前析构C++对象
+     */
+    @Override
+    protected void finalize() throws Throwable {
+        DeleteCPPInstance(specCallbackSDKptr);
+        super.finalize();
+    }
+
+    private native void DeleteCPPInstance(long specCallbackSDKptr);
+
+    /**
+     * @description: 判断构造函数中传入的请求是否解析成功
+     * @return:      成功与否
+     * */
+    public boolean IsOk() {
+        return IsOk(specCallbackSDKptr);
+    }
+
+    private native boolean IsOk(long specCallbackSDKptr);
+
+    /**
+     * @description: 获取请求的企业
+     * @require:     仅当IsOk() == true可调用
+     * @return:      corpid
+     * */
+    public String GetCorpId() {
+        return GetCorpId(specCallbackSDKptr);
+    }
+
+    private native String GetCorpId(long specCallbackSDKptr);
+
+    /**
+     * @description: 获取请求的应用
+     * @require:     仅当IsOk() == true可调用
+     * @return:      agentid
+     * */
+    public long GetAgentId() {
+        return GetAgentId(specCallbackSDKptr);
+    }
+
+    private native long GetAgentId(long specCallbackSDKptr);
+
+    /**
+     * @description: 获取请求的类型
+     * @require:     仅当IsOk() == true可调用
+     * @return:      1 - 来自[应用调用专区]的请求
+     *               2 - 来自企业微信的回调事件
+     * */
+    public long GetCallType() {
+        return GetCallType(specCallbackSDKptr);
+    }
+
+    private native long GetCallType(long specCallbackSDKptr);
+
+    /**
+     * @description: 获取请求数据
+     * @require:     仅当IsOk() == true可调用
+     * @return:      请求数据,根据call_type可能是:
+     *               - 企业微信回调事件
+     *               - [应用调用专区]接口中的request_data
+     * */
+    public String GetData() {
+        return GetData(specCallbackSDKptr);
+    }
+
+    private native String GetData(long specCallbackSDKptr);
+
+    /**
+     * @description: 是否异步请求
+     * @require:     仅当IsOk() == true可调用
+     * @return:      是否异步请求
+     * */
+    public boolean GetIsAsync() {
+        return GetIsAsync(specCallbackSDKptr);
+    }
+
+    private native boolean GetIsAsync(long specCallbackSDKptr);
+
+    /**
+     * @description: 获取请求的job_info,
+     * @require:     仅当IsOk() == true可调用
+     * @return:      job_info,无需理解内容,
+     *               在同一个请求上下文中使用SpecSDK的时候传入
+     * */
+    public String GetJobInfo() {
+        return GetJobInfo(specCallbackSDKptr);
+    }
+
+    private native String GetJobInfo(long specCallbackSDKptr);
+
+    /**
+     * @description: 获取请求的ability_id,[应用调用专区]接口时指定
+     * @require:     仅当IsOk() == true可调用
+     * @return:      ability_id
+     * */
+    public String GetAbilityId() {
+        return GetAbilityId(specCallbackSDKptr);
+    }
+
+    private native String GetAbilityId(long specCallbackSDKptr);
+
+    /**
+     * @description: 获取请求的notify_id,用于[应用同步调用专区程序]接口
+     * @require:     仅当IsOk() == true可调用
+     * @return:      notify_id
+     * */
+    public String GetNotifyId() {
+        return GetNotifyId(specCallbackSDKptr);
+    }
+
+    private native String GetNotifyId(long specCallbackSDKptr);
+
+    /**
+     * @description:    对返回包计算签名&加密
+     * @param response: 待加密的回包明文.如果IsOk()==false,传入空串即可
+     * @note 本接口的执行问题可查看日志
+     * */
+    public void BuildResponseHeaderBody(String response) {
+        try {
+            responseHeaders = new HashMap<String, String>();
+            responseBody = "";
+            BuildResponseHeaderBody(specCallbackSDKptr, response);
+        } catch (Exception e) {
+            SpecUtil.WWSpecLogError("SpecCallbackSDK exception caught", e.getMessage());
+        }
+    }
+
+    private native void BuildResponseHeaderBody(long specCallbackSDKptr, String response);
+
+    // 静态代码块内还无法调用native日志函数,这里的日志在管理系统无法查询
+    static {
+        try {
+            Class.forName("com.tencent.wework.SpecUtil");
+        } catch (ClassNotFoundException e) {
+            e.printStackTrace();
+            System.exit(1);
+        }
+    }
+}

+ 163 - 0
fs-service/src/main/java/com/tencent/wework/SpecSDK.java

@@ -0,0 +1,163 @@
+package com.tencent.wework;
+
+/**
+ * @warning: 1. 不要修改成员变量名,native方法内有反射调用
+ *           2. 调用本地方法需保持包结构,本工具需放在包com.tencent.wework内
+ *           3. 不允许继承,类名和函数名均不可修改,会影响本地方法的引用,详见:javah生成本地方法头文件
+ */
+public final class SpecSDK {
+
+    /**
+     * @description 调用本地方法后实例化的对象指针
+     */
+    private long specSDKptr = 0;
+
+    /**
+     * @usage invoke的请求
+     * @example "{\"limit\":1}
+     */
+    private String request;
+
+    public void SetRequest(String request) {
+        this.request = request;
+    }
+
+    /**
+     * @usage 访问上一次invoke的结果
+     */
+    private String response;
+    
+    public String GetResponse() { 
+        return response;
+    }
+
+    /**
+     * @param corpid:     企业corpid,必选参数
+     * @param agentid:    应用id,必选参数
+     * @param ability_id: 能力ID,可选参数
+     * @param job_info:   job_info,可选参数
+     * */
+    public SpecSDK(String corpId, long agentId) {
+        specSDKptr = NewSDK1(corpId, agentId);
+    }
+
+    private native long NewSDK1(String corpId, long agentId);
+
+    public SpecSDK(String corpId, long agentId, String abilityId) {
+        specSDKptr = NewSDK2(corpId, agentId, abilityId);
+    }
+
+    private native long NewSDK2(String corpId, long agentId, String abilityId);
+
+    public SpecSDK(String corpId, long agentId, String abilityId, String jobInfo) {
+        specSDKptr = NewSDK3(corpId, agentId, abilityId, jobInfo);
+    }
+
+    private native long NewSDK3(String corpId, long agentId, String abilityId, String jobInfo);
+
+    /**
+     * @description         使用callback的请求来初始化
+     * @param callback_sdk: 要求IsOk()==true
+     * @return C++内部指针,创建失败时指针仍为0,并输出错误日志
+     * */
+    public SpecSDK(SpecCallbackSDK callbackSDK) {
+        specSDKptr = NewSDK4(callbackSDK.GetPtr());
+    }
+
+    private native long NewSDK4(long callbackSDK);
+
+    /**
+     * @usage 在Java对象的内存回收前析构C++对象
+     */
+    @Override
+    protected void finalize() throws Throwable {
+        DeleteCPPInstance(specSDKptr);
+        super.finalize();
+    }
+
+    private native void DeleteCPPInstance(long specSDKptr);
+
+    /**
+     * @description     用于在专区内调用企业微信接口
+     * @param api_name 接口名
+     * @param request  json格式的请求数据
+     * @param response json格式的返回数据
+     * @return errorcode 参考如下:
+     *            0: 成功
+     *            -910001: SDK没有初始化
+     *            -910002: 没有设置请求体
+     *            -910003: 没有设置请求的API
+     *            -910004: 在SDK成员内找不到成员"response",注意lib内有反射机制,不要修改成员变量名
+     *            -910005: 使用未初始化的callback初始化SDK
+     *            -910006: invoke调用失败,应检查日志查看具体原因
+     *            -910007: 响应体为空
+     * @note 当返回0时,表示没有网络或请求协议层面或调用方法的失败,
+     *       调用方需继续检查response中的errcode字段确保业务层面的成功
+     * 
+     * @usage 当前版本sdk支持的接口列表,每个接口的具体协议请查看企业微信文档:
+     *        https://developer.work.weixin.qq.com/document/path/91201
+     * 
+     * +--------------------------------+--------------------------------+
+     * |接口名                          |描述                            |
+     * |--------------------------------|--------------------------------|
+     * |program_async_job_call_back     |上报异步任务结果                |
+     * |sync_msg                        |获取会话记录                    |
+     * |get_group_chat                  |获取内部群信息                  |
+     * |get_agree_status_single         |获取单聊会话同意情况            |
+     * |get_agree_status_room           |获取群聊会话同意情况            |
+     * |set_hide_sensitiveinfo_config   |设置成员会话组件敏感信息隐藏配置|
+     * |get_hide_sensitiveinfo_config   |获取成员会话组件敏感信息隐藏配置|
+     * |search_chat                     |会话名称搜索                    |
+     * |search_msg                      |会话消息搜索                    |
+     * |create_rule                     |新增关键词规则                  |
+     * |get_rule_list                   |获取关键词列表                  |
+     * |get_rule_detail                 |获取关键词规则详情              |
+     * |update_rule                     |修改关键词规则                  |
+     * |delete_rule                     |删除关键词规则                  |
+     * |get_hit_msg_list                |获取命中关键词规则的会话记录    |
+     * |create_sentiment_task           |创建情感分析任务                |
+     * |get_sentiment_result            |获取情感分析结果                |
+     * |create_summary_task             |创建摘要提取任务                |
+     * |get_summary_result              |获取摘要提取结果                |
+     * |create_customer_tag_task        |创建标签匹配任务                |
+     * |get_customer_tag_result         |获取标签任务结果                |
+     * |create_recommend_dialog_task    |创建话术推荐任务                |
+     * |get_recommend_dialog_result     |获取话术推荐结果                |
+     * |create_private_task             |创建自定义模型任务              |
+     * |get_private_task_result         |获取自定义模型结果              |
+     * |(废弃)document_list             |获取知识集列表                  |
+     * |create_spam_task                |会话反垃圾创建分析任务          |
+     * |get_spam_result                 |会话反垃圾获取任务结果          |
+     * |create_chatdata_export_job      |创建会话内容导出任务            |
+     * |get_chatdata_export_job_status  |获取会话内容导出任务结果        |
+     * |spec_notify_app                 |专区通知应用                    |
+     * |create_program_task             |创建自定义程序任务              |
+     * |get_program_task_result         |获取自定义程序结果              |
+     * |knowledge_base_list             |获取企业授权给应用的知识集列表  |
+     * |knowledge_base_create           |创建知识集                      |
+     * |knowledge_base_detail           |获取知识集详情                  |
+     * |knowledge_base_add_doc          |添加知识集內容                  |
+     * |knowledge_base_remove_doc       |删除知识集內容                  |
+     * |knowledge_base_modify_name      |修改知识集名称                  |
+     * |knowledge_base_delete           |删除知识集                      |
+     * |search_contact_or_customer      |员工或者客户名称搜索            |
+     * |create_ww_model_task            |创建企微通用模型任务            |
+     * |get_ww_model_result             |获取企微通用模型结果            |
+     * |get_msg_list_by_page_id         |page_id获取消息列表             |
+     * +-----------------------------------------------------------------+
+     * */
+    public int Invoke(String apiName) {
+        return Invoke(specSDKptr, apiName, request);
+    }
+
+    private native int Invoke(long sdk, String apiName, String request);
+
+    // 静态代码块内还无法调用native日志函数,这里的日志在管理系统无法查询
+    static {
+        try {
+            Class.forName("com.tencent.wework.SpecUtil");
+        } catch (ClassNotFoundException e) {
+            e.printStackTrace();
+        }
+    }
+}

+ 168 - 0
fs-service/src/main/java/com/tencent/wework/SpecUtil.java

@@ -0,0 +1,168 @@
+package com.tencent.wework;
+
+/**
+ * @warning: 1. 不要修改成员变量名,native方法内有反射调用
+ *           2. 调用本地方法需保持包结构,本工具需放在包com.tencent.wework内
+ *           3. 不允许继承,类名和函数名均不可修改,会影响本地方法的引用,详见:javah生成本地方法头文件
+ *           4. 使用其他工具打印的日志将无法被查询,如需使用SLF4j风格的日志或性能更好的日志框架,
+ *              请自行封装SpecUtil.SpecLog或SpecUtil.SpecLogNative方法
+ * 
+ * @usage:   1. 获取SDK的版本号
+ *           2. 打印三个级别的日志
+ *           3. 开启调试模式
+ */
+public final class SpecUtil {
+
+    /**
+     * @description SDK版本号
+     * @usage 可用于校对不同SDK版本,或后续针对不同的SDK版本添加业务逻辑
+     */
+    private static final String SDK_VERSION = "1.4.0";
+
+    public static String GetSDKVersion() {
+        return SDK_VERSION;
+    }
+
+    /**
+     * @description 正确的包名,SDK必须存放在"com.tencent.wework"下,否则会影响本地方法的调用
+     */
+    private static final String EXPECTED_PACKAGE_NAME = "com.tencent.wework";
+
+    public static String GetExpectedPackageName() {
+        return EXPECTED_PACKAGE_NAME;
+    }
+
+    private static final String LINE_SEPERATOR = System.getProperty("line.separator");
+
+    public static void WWSpecLogInfo(String... args) {
+        SpecLog('I', args);
+    }
+
+    public static void WWSpecLogError(String... args) {
+        SpecLog('E', args);
+    }
+
+    public static void WWSpecLogDebug(String... args) {
+        SpecLog('D', args);
+    }
+
+    public static void WWSpecLogInfoWithReqId(String reqId, String... args) {
+        SpecLogWithReqId(reqId, 'I', args);
+    }
+
+    public static void WWSpecLogErrorWithReqId(String reqId, String... args) {
+        SpecLogWithReqId(reqId, 'E', args);
+    }
+
+    public static void WWSpecLogDebugWithReqId(String reqId, String... args) {
+        SpecLogWithReqId(reqId, 'D', args);
+    }
+
+    /**
+     * @usage 打印标准日志
+     * @note  只有使用SpecLog和SpecLogNative函数打印的日志能被调试平台查询,其他框架的日志仅能本地查看
+     * @param logLevel 日志级别,使用char传递,目前支持I——INFO、E——ERROR、D——DEBUG
+     * @param args 自定义参数
+     */
+    public static void SpecLog(char logLevel, String... args) {
+        StackTraceElement element = Thread.currentThread().getStackTrace()[3];
+        SpecLogNative(
+            logLevel, 
+            element.getFileName(), 
+            element.getLineNumber(), 
+            String.join(",", args).replace(LINE_SEPERATOR, " ")
+        );
+    }
+
+    /**
+     * @usage 打印标准日志
+     * @note  只有使用SpecLog和SpecLogNative函数打印的日志能被调试平台查询,其他框架的日志仅能本地查看
+     * @param reqid  请求id 
+     * @param logLevel 日志级别,使用char传递,目前支持I——INFO、E——ERROR、D——DEBUG
+     * @param args 自定义参数
+     */
+    public static void SpecLogWithReqId(String reqId, char logLevel, String... args) {
+        StackTraceElement element = Thread.currentThread().getStackTrace()[3];
+        SpecLogNativeWithReqId(
+            reqId,
+            logLevel, 
+            element.getFileName(), 
+            element.getLineNumber(), 
+            String.join(",", args).replace(LINE_SEPERATOR, " ")
+        );
+    }
+
+    /**
+     * @usage 打印标准日志
+     * @note  只有使用SpecLog和SpecLogNative函数打印的日志能被调试平台查询,其他框架的日志仅能本地查看
+     *        如需SLF4J风格的接口或对日志性能有进一步需求,开发者可以自行封装该函数
+     * @param logLevel   日志级别,使用char传递,目前支持I——INFO、E——ERROR、D——DEBUG
+     * @param fileName   文件名(类名)
+     * @param lineNumber 行号
+     * @param argsString 自定义参数
+     */
+    public static native void SpecLogNative(char logLevel, String fileName, int lineNumber, String argsString);
+
+    /**
+     * @usage 打印标准日志
+     * @note  只有使用SpecLog和SpecLogNative函数打印的日志能被调试平台查询,其他框架的日志仅能本地查看
+     *        如需SLF4J风格的接口或对日志性能有进一步需求,开发者可以自行封装该函数
+     * @param reqid  请求id 
+     * @param logLevel   日志级别,使用char传递,目前支持I——INFO、E——ERROR、D——DEBUG
+     * @param fileName   文件名(类名)
+     * @param lineNumber 行号
+     * @param argsString 自定义参数
+     */
+    public static native void SpecLogNativeWithReqId(String reqId, char logLevel, String fileName, int lineNumber, String argsString);
+
+
+
+    /**
+     * @usage 开启调试模式,进程级别开关
+     * @param debugToken 调试凭证,在管理端获取
+     * @param accessToken 应用access token
+     * @return 是否开启成功
+     */
+    public static boolean SpecOpenDebugMode(String debugToken, String accessToken) {
+        return SpecOpenDebugModeNative(debugToken, accessToken);
+    }
+
+    private static native boolean SpecOpenDebugModeNative(String debugToken, String accessToken);
+
+    /**
+     * @usage  生成notify id。用户可调用本接口生成notify id,也可完全自定义生成
+     * @return 新的notify id,支持纳秒级隔离,内部异常时会输出日志并返回空串
+     * @note   1. 用户可先生成notify id,将其与回调数据关联存储后,再使用该notify id通知应用,
+     *            从而保证回调数据被请求时已存储完毕
+     */
+    public static String GenerateNotifyId() {
+        return GenerateNotifyIdNative();
+    }
+
+    private static native String GenerateNotifyIdNative();
+
+    static {
+        // 检查包名
+        String packageName = SpecUtil.class.getPackage().getName();
+        if (!EXPECTED_PACKAGE_NAME.equals(packageName)) {
+            // 静态代码块内还无法调用native日志函数,这里的日志在管理系统无法查询
+            System.out.println("SpecUtil class must be in package com.tencent.wework");
+            System.exit(1);
+        }
+
+        // 加载so库
+        try {
+            System.loadLibrary("WeWorkSpecSDK");
+        } catch (UnsatisfiedLinkError e) {
+            System.out.println("libWeWorkSpecSDK.so not found in java.library.path");
+            e.printStackTrace();
+            System.exit(1);
+        } catch (Exception e) {
+            System.out.println("unexpected exception: " + e.getMessage());
+            e.printStackTrace();
+            System.exit(1);
+        }
+
+        SpecUtil.WWSpecLogInfo("SDK init done", "packageName=" + packageName, "SDK_VERSION=" + SDK_VERSION);
+    }
+}

+ 38 - 7
fs-spec-zone/Dockerfile

@@ -1,12 +1,43 @@
-FROM alpine:3.18
+# 请自行寻找基础镜像,需包含包管理和bash
+# 使用 Debian 12 (bookworm) 精简版作为基础镜像,体积小且 glibc 兼容性极强
+FROM debian:bookworm-slim
 
-RUN apk add --no-cache openjdk8-jre openssl3
+# 设置非交互模式,防止 apt 安装过程中弹出交互式提示导致构建卡住
+ENV DEBIAN_FRONTEND=noninteractive
 
-WORKDIR /app
-COPY target/fs-spec-zone-1.0.0.jar app.jar
-COPY libWeWorkSpecSDK.so /usr/lib/
+# 更新软件源,并安装编译工具、OpenJDK以及 OpenSSL 编译所需的开发库
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    gcc \
+    make \
+    perl \
+    wget \
+    tar \
+    default-jdk-headless \
+    zlib1g-dev \
+    libssl-dev \
+    ca-certificates \
+    && rm -rf /var/lib/apt/lists/*
 
-ENV JAVA_OPTS="-Xms256m -Xmx512m"
+# 下载并编译 OpenSSL 3.0(LTS 版本),为企微SDK提供必须的 libcrypto.so.3 和 libssl.so.3
+RUN cd /tmp && \
+    wget https://www.openssl.org/source/openssl-3.0.12.tar.gz && \
+    tar -xzf openssl-3.0.12.tar.gz && \
+    cd openssl-3.0.12 && \
+    ./config --prefix=/usr/local/openssl3 --openssldir=/usr/local/openssl3 shared zlib && \
+    make -j$(nproc) && make install && \
+    cd /tmp && rm -rf /tmp/openssl-3.0.12*
+
+# 【修复】直接写死动态库路径,彻底消除 UndefinedVar 警告
+ENV LD_LIBRARY_PATH=/usr/local/openssl3/lib64
+
+# 复制 SDK 动态库到 Debian 标准的系统库路径
+COPY libWeWorkSpecSDK.so /usr/lib/x86_64-linux-gnu/
+# 复制打包好的业务 jar 包
+COPY target/SpecDemo-with-dependencies.jar /app/
 
 EXPOSE 8080
-ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -Djava.library.path=/usr/lib -jar app.jar"]
+WORKDIR /app/
+
+# 启动 Java 应用,明确指定 java.library.path 包含自定义的 OpenSSL 路径
+ENTRYPOINT [ "/usr/bin/java" ]
+CMD [ "-Djava.library.path=/usr/lib/x86_64-linux-gnu:/usr/local/openssl3/lib64", "-Dfile.encoding=UTF-8", "-jar", "/app/SpecDemo-with-dependencies.jar" ]

+ 18 - 0
fs-spec-zone/docker镜像制作步骤

@@ -0,0 +1,18 @@
+1.构建 Docker 镜像
+docker build --no-cache -t wecom-spec-demo:1.0 .
+
+2.启动容器进行本地仿真测试
+docker run -d -p 8080:8080 --name wecom-test wecom-spec-demo:1.0
+
+3.查看日志并验证接口
+docker logs -f wecom-test
+
+4.停止容器并使用 export 导出为 .tar 文件
+# 1. 停止测试容器
+docker stop wecom-test
+
+# 2. 使用 docker export 将容器的文件系统导出为 tar 包
+docker export -o wecom-spec-demo.tar wecom-test
+
+# 3. 导出完成后,删除本地测试容器(因为 tar 包已经生成)
+docker rm wecom-test

+ 45 - 37
fs-spec-zone/pom.xml

@@ -11,65 +11,73 @@
     <name>fs-spec-zone</name>
     <description>企业微信数据与智能专区 - 专区程序</description>
 
-    <parent>
-        <groupId>org.springframework.boot</groupId>
-        <artifactId>spring-boot-starter-parent</artifactId>
-        <version>2.2.13.RELEASE</version>
-        <relativePath/>
-    </parent>
-
     <properties>
-        <java.version>1.8</java.version>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <maven.compiler.source>1.8</maven.compiler.source>
+        <maven.compiler.target>1.8</maven.compiler.target>
     </properties>
 
     <dependencies>
         <dependency>
-            <groupId>org.springframework.boot</groupId>
-            <artifactId>spring-boot-starter-web</artifactId>
+            <groupId>com.alibaba.fastjson2</groupId>
+            <artifactId>fastjson2</artifactId>
+            <version>2.0.50</version>
+            <scope>compile</scope>
         </dependency>
 
         <dependency>
-            <groupId>com.alibaba</groupId>
-            <artifactId>fastjson</artifactId>
-            <version>1.2.83</version>
+            <groupId>io.netty</groupId>
+            <artifactId>netty-all</artifactId>
+            <version>4.1.68.Final</version>
         </dependency>
 
         <dependency>
-            <groupId>org.projectlombok</groupId>
-            <artifactId>lombok</artifactId>
-            <optional>true</optional>
+            <groupId>commons-cli</groupId>
+            <artifactId>commons-cli</artifactId>
+            <version>1.4</version>
         </dependency>
 
-        <dependency>
-            <groupId>org.springframework.boot</groupId>
-            <artifactId>spring-boot-starter-test</artifactId>
-            <scope>test</scope>
-        </dependency>
+        <!-- <dependency>
+            <groupId>io.micrometer</groupId>
+            <artifactId>micrometer-core</artifactId>
+            <version>1.13.2</version>
+        </dependency> -->
 
-        <dependency>
-            <groupId>org.bouncycastle</groupId>
-            <artifactId>bcpkix-jdk15on</artifactId>
-            <version>1.70</version>
-            <scope>compile</scope>
-        </dependency>
-
-        <dependency>
-            <groupId>commons-codec</groupId>
-            <artifactId>commons-codec</artifactId>
-            <version>1.15</version>
-        </dependency>
     </dependencies>
 
     <build>
         <plugins>
             <plugin>
-                <groupId>org.springframework.boot</groupId>
-                <artifactId>spring-boot-maven-plugin</artifactId>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-dependency-plugin</artifactId>
+                <version>3.1.2</version>
+            </plugin>
+
+            <plugin>
+                <artifactId>maven-assembly-plugin</artifactId>
+                <version>3.3.0</version>
                 <configuration>
-                    <mainClass>com.fs.speczone.SpecZoneApplication</mainClass>
+                    <finalName>SpecDemo-with-dependencies</finalName>
+                    <appendAssemblyId>false</appendAssemblyId>
+                    <archive>
+                        <manifest>
+                            <mainClass>mytype.mycom.mygroup.SpecDemo</mainClass>
+                        </manifest>
+                    </archive>
+                    <descriptorRefs>
+                        <descriptorRef>jar-with-dependencies</descriptorRef>
+                    </descriptorRefs>
                 </configuration>
+                <executions>
+                    <execution>
+                        <id>make-assembly</id>
+                        <phase>package</phase>
+                        <goals>
+                            <goal>single</goal>
+                        </goals>
+                    </execution>
+                </executions>
             </plugin>
         </plugins>
     </build>
-</project>
+</project>

+ 0 - 32
fs-spec-zone/src/main/java/README_SDK.md

@@ -1,32 +0,0 @@
-# wwopenspec java sdk
-
-## 目录结构
-
-- libWeWorkSpecSDK.so:java sdk所需的动态链接库
-- com:专区SDK的源码
-- README_SDK.md:本说明
-
-## SDK结构说明
-
-com
-└── tencent
-    └── wework                          注意:需保持com.tencent.wework结构
-        ├── SpecCallbackSDK.java:       SpecCallbackSDK接口
-        ├── SpecSDK.java:               SpecSDK接口
-        └── SpecUtil.java:              SDK内的通用工具
-
-## 环境配置
-
-- 使用时应将动态链接库拷贝到Java查找本地库的路径(`java.library.path`)下(如`/usr/lib`),或添加本地库查找路径
-    - 本地库查找路径可在Java程序内调用`System.getProperty("java.library.path")`或命令行界面调用`java -XshowSettings:properties -version`查看
-  
-- sdk依赖`openssl3`,开发者请自行下载最新版。需要`libcrypto.so.3`和`libssl.so.3`,配置参考如下
-    - 源码安装:进入openssl目录,构建需要的两个库:`make libcrypto.so`、`make libssl.so`。将so放入本地库加载路径,您的本地库加载路径可通过`cat /etc/ld.so.conf`查看。或者修改您的环境变量`LD_LIBRARY_PATH`添加动态链接库的查找路径
-    - 包管理安装:使用您镜像的包管理下载安装x86_64的openssl3即可
-
-## 其他说明
-
-- `.so`是类Unix系统(如Linux)的动态链接库,只能在类Unix系统使用,Windows系统(`.dll`)和Mac系统(`.dylib`)的动态链接库将在后续推出,敬请期待
-
-- **需保持包结构**,不要将sdk的源文件复制到项目包中,否则JNI的本地方法引用会失效
-    - 原因:JNI注册的方法包含包结构,详见javah生成本地方法头文件

+ 0 - 43
fs-spec-zone/src/main/java/com/fs/speczone/DebugModeRunner.java

@@ -1,43 +0,0 @@
-package com.fs.speczone;
-
-import com.fs.speczone.util.WeChatTokenUtil;
-import com.tencent.wework.SpecUtil;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.boot.CommandLineRunner;
-import org.springframework.stereotype.Component;
-
-@Slf4j
-@Component
-public class DebugModeRunner implements CommandLineRunner {
-
-    @Value("${debug.token:}")   // 冒号后面留空,代表默认空串
-    private String debugToken;
-
-    @Value("${debug.corp-id:}")
-    private String corpId;
-
-    @Value("${debug.corp-secret:}")
-    private String corpSecret;
-
-    @Override
-    public void run(String... args) {
-        // 如果配置为空,直接跳过,不尝试开启调试
-        if (debugToken == null || debugToken.isEmpty()) {
-            log.warn("未配置 debug.token,跳过本地调试模式开启");
-            return;
-        }
-        try {
-            String accessToken = WeChatTokenUtil.getAccessToken(corpId, corpSecret);
-            log.info("成功获取 access_token,准备开启调试模式...");
-            boolean success = SpecUtil.SpecOpenDebugMode(debugToken, accessToken);
-            if (success) {
-                log.info("✅ 本地调试模式已成功开启!");
-            } else {
-                log.error("❌ 本地调试模式开启失败");
-            }
-        } catch (Exception e) {
-            log.error("开启调试模式时发生异常: {}", e.getMessage(), e);
-        }
-    }
-}

+ 0 - 13
fs-spec-zone/src/main/java/com/fs/speczone/SpecZoneApplication.java

@@ -1,13 +0,0 @@
-package com.fs.speczone;
-
-import org.springframework.boot.SpringApplication;
-import org.springframework.boot.autoconfigure.SpringBootApplication;
-import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
-
-@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
-public class SpecZoneApplication {
-
-    public static void main(String[] args) {
-        SpringApplication.run(SpecZoneApplication.class, args);
-    }
-}

+ 0 - 237
fs-spec-zone/src/main/java/com/fs/speczone/controller/CallbackController.java

@@ -1,237 +0,0 @@
-package com.fs.speczone.controller;
-
-import com.alibaba.fastjson.JSON;
-import com.alibaba.fastjson.JSONObject;
-import com.fs.speczone.handler.ProgramActionHandler;
-import com.tencent.wework.SpecCallbackSDK;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.*;
-
-import javax.annotation.PostConstruct;
-import javax.annotation.Resource;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.function.Function;
-import java.util.stream.Collectors;
-
-/**
- * 企业微信数据与智能专区 - 统一回调入口
- *
- * 所有来自企业微信的请求(URL验证、应用调用、事件回调)都通过此Controller处理。
- * 由于企业微信会对请求进行加密,所有操作都必须使用 SpecCallbackSDK 进行解密和验签,
- * 并将返回数据加密后回传。
- *
- * 核心流程:
- * 1. GET  /callback → URL 验证(首次配置回调地址时使用)
- * 2. POST /callback → 接收具体业务调用或事件
- *    - callType=1:应用调用(SCRM 系统通过企微 API 触发)
- *    - callType=2:事件回调(会话存档同意、关键词命中等)
- */
-@Slf4j
-@RestController
-public class CallbackController {
-
-    // Spring 会自动将所有实现了 ProgramActionHandler 接口的 Bean 注入此列表
-    @Resource
-    private List<ProgramActionHandler> handlerList;
-
-    /**
-     * 动作 → 处理器的映射表
-     * key:action 名称(如 "fetch_conversations")
-     * value:对应的处理器实例
-     */
-    private Map<String, ProgramActionHandler> handlerMap;
-
-    /**
-     * 在 Bean 初始化后,将 handlerList 转换为 handlerMap,
-     * 便于后续根据 action 快速查找处理器。
-     *
-     * 这样设计的好处是新增一个 action 时,无需修改 Controller 代码,
-     * 只需创建一个新的 @Component 类实现 ProgramActionHandler 接口即可。
-     */
-    @PostConstruct
-    public void init() {
-        handlerMap = handlerList.stream()
-                .collect(Collectors.toMap(
-                        ProgramActionHandler::getAction,  // key:处理器能处理的 action
-                        Function.identity()               // value:处理器本身
-                ));
-    }
-
-    // ==================== URL 验证(GET) ====================
-
-    /**
-     * 企业微信首次配置回调 URL 时,会发送 GET 请求进行验证。
-     * 验证成功后,URL 才能被保存。
-     *
-     * @param msgSignature 企业微信生成的签名
-     * @param timestamp     时间戳
-     * @param nonce         随机字符串
-     * @param echostr       加密的验证字符串
-     * @return 解密后的 echostr 明文,企业微信确认后通过验证
-     */
-    @GetMapping("/callback")
-    public ResponseEntity<String> verifyUrl(
-            @RequestParam("msg_signature") String msgSignature,
-            @RequestParam("timestamp") String timestamp,
-            @RequestParam("nonce") String nonce,
-            @RequestParam("echostr") String echostr) {
-
-        log.info("收到应用 GET 回调验证");
-
-        // 构造请求头,SDK 需要从 headers 中提取签名信息
-        Map<String, String> headers = new HashMap<>();
-        headers.put("msg_signature", msgSignature);
-        headers.put("timestamp", timestamp);
-        headers.put("nonce", nonce);
-
-        // 使用 SpecCallbackSDK 进行解析和解密
-        SpecCallbackSDK sdk = new SpecCallbackSDK("GET", headers, echostr);
-        if (!sdk.IsOk()) {
-            log.error("URL 验证解密失败");
-            return ResponseEntity.status(403).body("verify failed");
-        }
-
-        // 解密后得到明文 echostr,原样返回即可完成验证
-        return ResponseEntity.ok(sdk.GetData());
-    }
-
-    // ==================== 业务入口(POST) ====================
-
-    /**
-     * 接收企业微信的后台调用请求(POST)。
-     * 解密后根据 callType 区分:
-     *   callType=1 → 应用调用(同步/异步程序调用)
-     *   callType=2 → 事件回调(会话存档同意、关键词命中等)
-     *
-     * @param headers 请求头,包含签名和加密信息
-     * @param body    加密的请求体
-     * @return 加密后的响应
-     */
-    @PostMapping("/callback")
-    public ResponseEntity<String> handleCallback(
-            @RequestHeader Map<String, String> headers,
-            @RequestBody String body) {
-
-        log.info("收到应用 POST 业务请求");
-
-        // 1. 解密和验签
-        SpecCallbackSDK sdk = new SpecCallbackSDK("POST", headers, body);
-        if (!sdk.IsOk()) {
-            log.error("回调验签/解密失败");
-            return ResponseEntity.ok("verify failed");
-        }
-
-        // 2. 提取解密后的数据
-        String decryptedData = sdk.GetData();           // 明文请求体(JSON)
-        long callType = sdk.GetCallType();              // 1: 应用调用, 2: 事件回调
-        String corpid = sdk.GetCorpId();                // 企业 ID
-        long agentId = sdk.GetAgentId();                // 应用 ID
-        log.info("收到回调 corpid={} agentid={} callType={} data={}",
-                corpid, agentId, callType, decryptedData);
-
-        // 3. 根据调用类型分发处理
-        String responsePlain;
-        if (callType == 1) {
-            // 应用调用(SCRM 系统通过企业微信 API 触发的请求)
-            responsePlain = handleProgramCall(decryptedData, sdk);
-        } else if (callType == 2) {
-            // 事件回调(企业微信主动推送的事件)
-            responsePlain = handleEvent(decryptedData);
-        } else {
-            responsePlain = "{}";
-        }
-
-        // 4. 加密响应并返回
-        sdk.BuildResponseHeaderBody(responsePlain);
-        Map<String, String> respHeaders = sdk.GetResponseHeaders();
-        String respBody = sdk.GetResponseBody();
-
-        HttpHeaders httpHeaders = new HttpHeaders();
-        respHeaders.forEach(httpHeaders::add);
-        return new ResponseEntity<>(respBody, httpHeaders, HttpStatus.OK);
-    }
-
-    // ==================== 应用调用处理 ====================
-
-    /**
-     * 处理来自 SCRM 系统的程序调用(callType=1)。
-     * data 本身即为输入协议(input_protocol),包含 action 和业务参数。
-     *
-     * 通过 action 找到对应的 ProgramActionHandler 并执行。
-     *
-     * @param data 解密后的请求数据(JSON 字符串)
-     * @param sdk  SpecCallbackSDK 实例,可获取 ability_id、job_info 等上下文
-     * @return 明文的响应 JSON(会被加密后返回)
-     */
-    private String handleProgramCall(String data, SpecCallbackSDK sdk) {
-        try {
-            JSONObject inputProtocol = JSON.parseObject(data);  // 直接解析 request_data
-            String action = inputProtocol.getString("action");   // 提取 action
-
-            ProgramActionHandler handler = handlerMap.get(action);
-            JSONObject output;
-            if (handler != null) {
-                output = handler.handle(inputProtocol, sdk);
-            } else {
-                output = new JSONObject();
-                output.put("errcode", 400);
-                output.put("errmsg", "未知的 action: " + action);
-            }
-            return JSON.toJSONString(output);
-        } catch (Exception e) {
-            log.error("处理程序调用异常", e);
-            JSONObject err = new JSONObject();
-            err.put("errcode", -1);
-            err.put("errmsg", "内部错误: " + e.getMessage());
-            return err.toJSONString();
-        }
-    }
-
-    // ==================== 事件回调处理 ====================
-
-    /**
-     * 处理企业微信推送的事件回调(callType=2)。
-     * 例如:客户同意会话存档、关键词规则命中、新消息产生等。
-     *
-     * 当前只打印日志,实际业务可在此扩展。
-     *
-     * @param data 解密后的事件数据(JSON 字符串)
-     * @return 空 JSON(暂无特殊处理)
-     */
-    private String handleEvent(String data) {
-        try {
-            JSONObject event = JSON.parseObject(data);
-            String eventType = event.getString("event_type");
-
-            // 根据事件类型进行不同处理(可扩展为类似 Handler 的策略模式)
-            if ("keyword_rule_hit".equals(eventType)) {
-                log.info("关键词规则命中事件: {}", event);
-                // TODO: 后续可调用 ConversationService 或通知 SCRM 系统
-            } else if ("chat_record".equals(eventType)) {
-                log.info("会话记录事件: {}", event);
-                // TODO: 后续可进行实时分析、存储等操作
-            } else {
-                log.warn("未处理的事件类型: {}", eventType);
-            }
-        } catch (Exception e) {
-            log.error("解析事件失败", e);
-        }
-        return "{}";
-    }
-
-    // ==================== 健康检查 ====================
-
-    /**
-     * 健康检查接口,用于确认服务是否正常运行。
-     * Nginx 反向代理或外部监控可通过此端点检测服务状态。
-     */
-    @GetMapping("/health")
-    public ResponseEntity<String> health() {
-        return ResponseEntity.ok("OK");
-    }
-}

+ 0 - 33
fs-spec-zone/src/main/java/com/fs/speczone/controller/WeComApiController.java

@@ -1,33 +0,0 @@
-package com.fs.speczone.controller;
-
-import com.alibaba.fastjson.JSONObject;
-import com.fs.speczone.service.WeComService;
-import lombok.RequiredArgsConstructor;
-import org.springframework.web.bind.annotation.*;
-/**
- * 企业微信会话-统一前端 API 接口
- * */
-@RestController
-@RequestMapping("/api")
-@RequiredArgsConstructor
-public class WeComApiController {
-
-    private final WeComService weComService;
-
-    // 会话记录
-    @GetMapping("/conversations")
-    public JSONObject getConversations(
-            @RequestParam(defaultValue = "0") long seq,
-            @RequestParam(defaultValue = "100") long limit,
-            @RequestParam(defaultValue = "0") long proxy,
-            @RequestParam(defaultValue = "30") long timeout,
-            @RequestParam(required = false) String customerId,
-            @RequestParam(required = false) String staffUserId) throws Exception {
-        if (customerId == null|| customerId.isEmpty()) {
-            throw new Exception("客户id不能为空");
-        }else if (staffUserId == null|| staffUserId.isEmpty()) {
-            throw new Exception("员工id不能为空");
-        }
-        return weComService.fetchConversations(seq, limit, proxy, timeout, customerId,staffUserId);
-    }
-}

+ 0 - 25
fs-spec-zone/src/main/java/com/fs/speczone/handler/FetchConversationsHandler.java

@@ -1,25 +0,0 @@
-package com.fs.speczone.handler;
-
-import com.alibaba.fastjson.JSONObject;
-import com.fs.speczone.service.ConversationService;
-import com.tencent.wework.SpecCallbackSDK;
-import org.springframework.stereotype.Component;
-
-import javax.annotation.Resource;
-
-@Component
-public class FetchConversationsHandler implements ProgramActionHandler {
-
-    @Resource
-    private ConversationService conversationService;
-
-    @Override
-    public String getAction() {
-        return "fetch_conversations";
-    }
-
-    @Override
-    public JSONObject handle(JSONObject inputProtocol, SpecCallbackSDK sdk) {
-        return conversationService.fetchConversations(inputProtocol, sdk);
-    }
-}

+ 0 - 22
fs-spec-zone/src/main/java/com/fs/speczone/handler/ProgramActionHandler.java

@@ -1,22 +0,0 @@
-package com.fs.speczone.handler;
-
-import com.alibaba.fastjson.JSONObject;
-import com.tencent.wework.SpecCallbackSDK;
-
-/**
- * 程序动作处理器接口(目前有知识集、关键词、搜索会话等)
- */
-public interface ProgramActionHandler {
-    /**
-     * 返回该处理器对应的 action 名称
-     */
-    String getAction();
-
-    /**
-     * 处理请求
-     * @param inputProtocol 请求参数(即 request_data 解析后的 JSON)
-     * @param sdk 回调上下文
-     * @return 处理结果
-     */
-    JSONObject handle(JSONObject inputProtocol, SpecCallbackSDK sdk);
-}

+ 0 - 155
fs-spec-zone/src/main/java/com/fs/speczone/sdk/SpecSdkAdapter.java

@@ -1,155 +0,0 @@
-package com.fs.speczone.sdk;
-
-import com.alibaba.fastjson.JSON;
-import com.alibaba.fastjson.JSONArray;
-import com.alibaba.fastjson.JSONObject;
-import com.tencent.wework.SpecCallbackSDK;
-import com.tencent.wework.SpecSDK;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.stereotype.Component;
-
-/**
- * 专区SDK适配器
- * 封装 com.tencent.wework.SpecSDK 的调用
- */
-@Slf4j
-@Component
-public class SpecSdkAdapter {
-
-    @Value("${wecom.corpid:test_corpid}")
-    private String corpid;
-    @Value("${wecom.agentid:0}")
-    private long agentId;
-
-    /**
-     * 获取会话记录
-     */
-    public JSONObject getConversations(long seq, long limit, long proxyId, long timeout,
-                                       SpecCallbackSDK callbackSdk) {
-        JSONObject result = new JSONObject();
-        try {
-            SpecSDK sdk = createSpecSDK(callbackSdk);
-            JSONObject req = new JSONObject();
-            req.put("seq", seq);
-            req.put("limit", limit);
-            req.put("proxy", proxyId);
-            req.put("timeout", timeout);
-            sdk.SetRequest(req.toJSONString());
-            int ret = sdk.Invoke("sync_msg");
-            if (ret == 0) {
-                result = JSON.parseObject(sdk.GetResponse());
-            } else {
-                result.put("errcode", ret);
-                result.put("errmsg", "SpecSDK.Invoke failed, ret=" + ret);
-            }
-        } catch (Exception e) {
-            log.error("获取会话异常", e);
-            result.put("errcode", -1);
-            result.put("errmsg", e.getMessage());
-        }
-        return result;
-    }
-
-    /**
-     * 关键词搜索会话(示例,未完整实现)
-     */
-    public JSONObject searchConversationsByKeyword(String keyword, int chatType,
-                                                   long startTime, long endTime, long limit,
-                                                   SpecCallbackSDK callbackSdk) {
-        JSONObject result = new JSONObject();
-        try {
-            // 实际调用 SpecSDK.Invoke("search_msg") 等接口,此处略
-            log.info("关键词搜索会话 keyword={}, chatType={}, start={}, end={}", keyword, chatType, startTime, endTime);
-            result.put("errcode", 0);
-        } catch (Exception e) {
-            log.error("关键词搜索失败", e);
-            result.put("errcode", -1);
-            result.put("errmsg", e.getMessage());
-        }
-        return result;
-    }
-
-    /**
-     * 获取内部群信息(示例)
-     */
-    public JSONObject getInternalGroup(String roomId) {
-        JSONObject result = new JSONObject();
-        try {
-            log.info("获取内部群信息 roomId={}", roomId);
-            result.put("errcode", 0);
-        } catch (Exception e) {
-            log.error("获取内部群信息失败", e);
-            result.put("errcode", -1);
-            result.put("errmsg", e.getMessage());
-        }
-        return result;
-    }
-
-    /**
-     * 管理关键词规则(示例)
-     */
-    public JSONObject manageKeywordRule(String action, JSONArray rules, SpecCallbackSDK callbackSdk) {
-        JSONObject result = new JSONObject();
-        try {
-            // 实际调用 SpecSDK.Invoke("create_rule") 等接口
-            log.info("关键词规则管理 action={}, rules={}", action, rules);
-            result.put("errcode", 0);
-        } catch (Exception e) {
-            log.error("关键词规则管理失败", e);
-            result.put("errcode", -1);
-            result.put("errmsg", e.getMessage());
-        }
-        return result;
-    }
-
-    /**
-     * 通知应用
-     */
-    public String notifyApp(String appId, String notifyData) {
-        try {
-            log.info("通知应用 appId={}, data={}", appId, notifyData);
-            JSONObject notify = new JSONObject();
-            notify.put("code", 0);
-            notify.put("msg", "success");
-            notify.put("notify_id", java.util.UUID.randomUUID().toString());
-            return JSON.toJSONString(notify);
-        } catch (Exception e) {
-            log.error("通知应用失败", e);
-            return "{\"code\":-1,\"msg\":\"" + e.getMessage() + "\"}";
-        }
-    }
-
-    /**
-     * 获取知识集列表
-     */
-    public JSONObject invokeKnowledgeBaseList(SpecCallbackSDK callbackSdk) {
-        JSONObject result = new JSONObject();
-        try {
-            SpecSDK sdk = createSpecSDK(callbackSdk);   // 复用已有的 createSpecSDK 方法
-            sdk.SetRequest("{}");  // knowledge_base_list 接口无参数
-            int ret = sdk.Invoke("knowledge_base_list");
-            if (ret == 0) {
-                result = JSON.parseObject(sdk.GetResponse());
-            } else {
-                result.put("errcode", ret);
-                result.put("errmsg", "SpecSDK.Invoke failed, ret=" + ret);
-            }
-        } catch (Exception e) {
-            log.error("获取知识集列表异常", e);
-            result.put("errcode", -1);
-            result.put("errmsg", e.getMessage());
-        }
-        return result;
-    }
-
-    /**
-     * 优先使用 SpecCallbackSDK 上下文构造 SpecSDK
-     */
-    private SpecSDK createSpecSDK(SpecCallbackSDK callbackSdk) {
-        if (callbackSdk != null && callbackSdk.IsOk()) {
-            return new SpecSDK(callbackSdk);
-        }
-        return new SpecSDK(corpid, agentId);
-    }
-}

+ 0 - 132
fs-spec-zone/src/main/java/com/fs/speczone/service/ConversationService.java

@@ -1,132 +0,0 @@
-package com.fs.speczone.service;
-
-import com.alibaba.fastjson.JSONArray;
-import com.alibaba.fastjson.JSONObject;
-import com.fs.speczone.sdk.SpecSdkAdapter;
-import com.fs.speczone.util.WeChatDecryptUtil;
-import com.tencent.wework.SpecCallbackSDK;
-import com.tencent.wework.SpecSDK;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Service;
-
-import javax.annotation.Resource;
-import java.time.Instant;
-import java.time.ZoneId;
-import java.time.format.DateTimeFormatter;
-import java.util.List;
-import java.util.Objects;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.stream.Collectors;
-
-@Slf4j
-@Service
-public class ConversationService {
-
-    @Resource
-    private SpecSdkAdapter specSdkAdapter;
-
-    private final ConcurrentHashMap<String, String> notifyDataStore = new ConcurrentHashMap<>();
-
-    /**
-     * 专区版:拉取会话记录(含解密、过滤、格式化)
-     */
-    public JSONObject fetchConversations(JSONObject inputProtocol, SpecCallbackSDK callbackSdk) {
-        JSONObject result = new JSONObject();
-
-        long seq = inputProtocol.getLongValue("seq");
-        long limit = inputProtocol.getLongValue("limit");
-        if (limit <= 0) limit = 1000;
-        long proxy = inputProtocol.getLongValue("proxy");
-        long timeout = inputProtocol.getLongValue("timeout");
-        if (timeout <= 0) timeout = 30;
-
-        String customerId = inputProtocol.getString("customerId");
-        String staffUserId = inputProtocol.getString("staffUserId");
-
-        // 调用 SpecSDK 拉取加密会话
-        JSONObject rawResp = specSdkAdapter.getConversations(seq, limit, proxy, timeout, callbackSdk);
-        if (rawResp == null || rawResp.getInteger("errcode") != 0) {
-            result.put("errcode", rawResp != null ? rawResp.getInteger("errcode") : -1);
-            result.put("errmsg", "获取会话失败");
-            result.put("data", new JSONArray());
-            return result;
-        }
-
-        JSONArray msgList = rawResp.getJSONArray("msg_list");
-        if (msgList == null || msgList.isEmpty()) {
-            result.put("errcode", 0);
-            result.put("errmsg", "ok");
-            result.put("data", new JSONArray());
-            return result;
-        }
-
-        // 解密、过滤、格式化
-        List<JSONObject> cleaned = msgList.parallelStream()
-                .map(obj -> (JSONObject) obj)
-                .filter(msg -> isMessageRelatedToUsers(msg, customerId, staffUserId))
-                .map(msg -> {
-                    JSONObject encryptInfo = msg.getJSONObject("service_encrypt_info");
-                    if (encryptInfo == null) return null;
-                    String encryptedKey = encryptInfo.getString("encrypted_secret_key");
-                    if (encryptedKey == null) return null;
-                    try {
-                        String secretKey = WeChatDecryptUtil.decryptSecretKey(encryptedKey);
-                        JSONObject item = new JSONObject();
-                        item.put("msgid", msg.getString("msgid"));
-                        item.put("secretKey", secretKey);
-                        item.put("sender", msg.get("sender"));
-                        item.put("receiver_list", msg.get("receiver_list"));
-                        Long sendTime = msg.getLong("send_time");
-                        if (sendTime != null) {
-                            String formattedTime = Instant.ofEpochMilli(sendTime * 1000)
-                                    .atZone(ZoneId.systemDefault())
-                                    .toLocalDateTime()
-                                    .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
-                            item.put("displayTime", formattedTime);
-                        }
-                        return item;
-                    } catch (Exception e) {
-                        log.error("解密失败 msgid: {}", msg.getString("msgid"), e);
-                        return null;
-                    }
-                })
-                .filter(Objects::nonNull)
-                .collect(Collectors.toList());
-
-        result.put("errcode", 0);
-        result.put("errmsg", "ok");
-        result.put("data", cleaned);
-        return result;
-    }
-
-    /**
-     * 过滤消息:是否与指定客户和员工相关
-     */
-    private boolean isMessageRelatedToUsers(JSONObject msg, String customerId, String staffUserId) {
-        boolean hasCustomer = (customerId == null || customerId.isEmpty());
-        boolean hasStaff = (staffUserId == null || staffUserId.isEmpty());
-
-        JSONObject sender = msg.getJSONObject("sender");
-        JSONArray receivers = msg.getJSONArray("receiver_list");
-
-        if (sender != null) {
-            String senderId = sender.getString("id");
-            int senderType = sender.getIntValue("type");
-            if (!hasCustomer && senderType == 2 && customerId.equals(senderId)) hasCustomer = true;
-            if (!hasStaff && senderType == 1 && staffUserId.equals(senderId)) hasStaff = true;
-        }
-        if (hasCustomer && hasStaff) return true;
-
-        if (receivers != null) {
-            for (int i = 0; i < receivers.size(); i++) {
-                JSONObject recv = receivers.getJSONObject(i);
-                String recvId = recv.getString("id");
-                int recvType = recv.getIntValue("type");
-                if (!hasCustomer && recvType == 2 && customerId.equals(recvId)) hasCustomer = true;
-                if (!hasStaff && recvType == 1 && staffUserId.equals(recvId)) hasStaff = true;
-                if (hasCustomer && hasStaff) return true;
-            }
-        }
-        return hasCustomer && hasStaff;
-    }
-}

+ 0 - 11
fs-spec-zone/src/main/java/com/fs/speczone/service/WeComService.java

@@ -1,11 +0,0 @@
-package com.fs.speczone.service;
-
-import com.alibaba.fastjson.JSONObject;
-
-public interface WeComService {
-    /**
-     * 拉取会话记录(主动调用,供前端 /api/conversations 使用)
-     */
-    JSONObject fetchConversations(long seq, long limit, long proxy, long timeout, String customerId,String staffUserId);
-
-}

+ 0 - 149
fs-spec-zone/src/main/java/com/fs/speczone/service/impl/WeComServiceImpl.java

@@ -1,149 +0,0 @@
-package com.fs.speczone.service.impl;
-
-import com.alibaba.fastjson.JSONArray;
-import com.alibaba.fastjson.JSONObject;
-import com.fs.speczone.service.WeComService;
-import com.fs.speczone.util.WeChatDecryptUtil;
-import com.fs.speczone.util.WeChatTokenUtil;
-import com.fs.speczone.util.WeComSignatureUtil;
-import com.tencent.wework.SpecSDK;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.stereotype.Service;
-import org.springframework.web.client.RestTemplate;
-
-import java.time.Instant;
-import java.time.ZoneId;
-import java.time.format.DateTimeFormatter;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Objects;
-import java.util.stream.Collectors;
-
-@Slf4j
-@Service
-@RequiredArgsConstructor
-public class WeComServiceImpl implements WeComService {
-
-    @Value("${wecom.corpid}")
-    private String corpId;
-
-    @Value("${wecom.agentid}")
-    private long agentId;
-
-    @Override
-    public JSONObject fetchConversations(long seq, long limit, long proxy, long timeout,
-                                         String customerId, String staffUserId) {
-        JSONObject result = new JSONObject();
-        SpecSDK sdk = new SpecSDK(corpId, agentId);
-
-        JSONObject req = new JSONObject();
-        req.put("seq", seq);
-        req.put("limit", limit);
-        req.put("proxy", proxy);
-        req.put("timeout", timeout);
-        sdk.SetRequest(req.toJSONString());
-
-        int ret = sdk.Invoke("sync_msg");
-        if (ret != 0) {
-            result.put("errcode", ret);
-            result.put("errmsg", "sync_msg failed");
-            return result;
-        }
-
-        JSONObject rawResp = JSONObject.parseObject(sdk.GetResponse());
-        if (rawResp.getInteger("errcode") != 0) return rawResp;
-
-        JSONArray msgList = rawResp.getJSONArray("msg_list");
-        List<JSONObject> cleaned = new ArrayList<>();
-        if (msgList != null) {
-            cleaned = msgList.parallelStream()
-                    .map(obj -> (JSONObject) obj)
-                    // ========== 核心修改:同时按客户和员工过滤 ==========
-                    .filter(msg -> {
-                        // 如果两个参数都没传,不过滤
-                        if ((customerId == null || customerId.isEmpty()) &&
-                                (staffUserId == null || staffUserId.isEmpty())) {
-                            return true;
-                        }
-                        // 否则检查消息是否同时满足客户和员工(如果都传了)或只满足其中一个
-                        return isMessageRelatedToUsers(msg, customerId, staffUserId);
-                    })
-                    // ====================================================
-                    .map(msg -> {
-                        JSONObject encryptInfo = msg.getJSONObject("service_encrypt_info");
-                        if (encryptInfo == null) return null;
-                        String encryptedKey = encryptInfo.getString("encrypted_secret_key");
-                        if (encryptedKey == null) return null;
-                        try {
-                            String secretKey = WeChatDecryptUtil.decryptSecretKey(encryptedKey);
-                            JSONObject item = new JSONObject();
-                            item.put("msgid", msg.getString("msgid"));
-                            item.put("secretKey", secretKey);
-                            item.put("sender", msg.get("sender"));
-                            item.put("receiver_list", msg.get("receiver_list"));
-                            Long sendTime = msg.getLong("send_time");
-                            if (sendTime != null) {
-                                String formattedTime = Instant.ofEpochMilli(sendTime * 1000)
-                                        .atZone(ZoneId.systemDefault())
-                                        .toLocalDateTime()
-                                        .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
-                                item.put("displayTime", formattedTime);
-                            }
-                            return item;
-                        } catch (Exception e) {
-                            log.error("解密失败 msgid: {}", msg.getString("msgid"), e);
-                            return null;
-                        }
-                    })
-                    .filter(Objects::nonNull)
-                    .collect(Collectors.toList());
-        }
-
-        result.put("errcode", 0);
-        result.put("errmsg", "ok");
-        result.put("msgList", cleaned);
-        return result;
-    }
-
-    /**
-     * 判断一条消息是否同时与指定客户和指定员工相关(新方法)
-     * 如果 customerId 不为空,则消息的发送者或接收者必须包含该客户(type=2)
-     * 如果 staffUserId 不为空,则消息的发送者或接收者必须包含该员工(type=1)
-     * 如果两个都为空,则由调用方提前返回 true,本方法不再处理
-     */
-    private boolean isMessageRelatedToUsers(JSONObject msg, String customerId, String staffUserId) {
-        boolean hasCustomer = (customerId == null || customerId.isEmpty());
-        boolean hasStaff = (staffUserId == null || staffUserId.isEmpty());
-
-        JSONObject sender = msg.getJSONObject("sender");
-        JSONArray receivers = msg.getJSONArray("receiver_list");
-
-        // 检查发送者
-        if (sender != null) {
-            String senderId = sender.getString("id");
-            int senderType = sender.getIntValue("type");
-            if (!hasCustomer && senderType == 2 && customerId.equals(senderId)) hasCustomer = true;
-            if (!hasStaff && senderType == 1 && staffUserId.equals(senderId)) hasStaff = true;
-        }
-
-        // 如果已经同时满足,直接返回 true
-        if (hasCustomer && hasStaff) return true;
-
-        // 检查接收者列表
-        if (receivers != null) {
-            for (int i = 0; i < receivers.size(); i++) {
-                JSONObject recv = receivers.getJSONObject(i);
-                String recvId = recv.getString("id");
-                int recvType = recv.getIntValue("type");
-                if (!hasCustomer && recvType == 2 && customerId.equals(recvId)) hasCustomer = true;
-                if (!hasStaff && recvType == 1 && staffUserId.equals(recvId)) hasStaff = true;
-                if (hasCustomer && hasStaff) return true; // 一旦两者都满足即可返回
-            }
-        }
-
-        // 返回是否两个条件都满足(如果只传了其中一个,则另一个默认为 true)
-        return hasCustomer && hasStaff;
-    }
-}

+ 0 - 65
fs-spec-zone/src/main/java/com/fs/speczone/util/WeChatDecryptUtil.java

@@ -1,65 +0,0 @@
-package com.fs.speczone.util;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.stereotype.Component;
-
-import javax.annotation.PostConstruct;
-import javax.crypto.Cipher;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Paths;
-import java.security.KeyFactory;
-import java.security.PrivateKey;
-import java.security.spec.PKCS8EncodedKeySpec;
-import java.util.Base64;
-
-@Component
-public class WeChatDecryptUtil {
-
-    private static final Logger log = LoggerFactory.getLogger(WeChatDecryptUtil.class);
-    private static PrivateKey privateKey;
-
-    @Value("${wecom.message.private-key-path:/speczone/private_key.pem}")
-    private String privateKeyPath;
-
-    @PostConstruct
-    public void init() {
-        try {
-            log.info("加载私钥文件: {}", privateKeyPath);
-            String privateKeyPem = new String(Files.readAllBytes(Paths.get(privateKeyPath)));
-            String privateKeyBase64 = privateKeyPem
-                    .replace("-----BEGIN PRIVATE KEY-----", "")
-                    .replace("-----END PRIVATE KEY-----", "")
-                    .replace("-----BEGIN RSA PRIVATE KEY-----", "")
-                    .replace("-----END RSA PRIVATE KEY-----", "")
-                    .replaceAll("\\s", "");
-            byte[] keyBytes = Base64.getDecoder().decode(privateKeyBase64);
-            PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
-            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
-            privateKey = keyFactory.generatePrivate(spec);
-            log.info("私钥加载成功");
-        } catch (Exception e) {
-            log.error("私钥加载失败", e);
-            throw new RuntimeException("初始化企业微信私钥失败", e);
-        }
-    }
-
-    /**
-     * 解密 encrypted_secret_key,返回原始 AES 密钥字符串(直接供前端使用)
-     * @param encryptedSecretKey Base64 编码的 RSA 密文
-     * @return 原始 AES 密钥字符串(无需再次编码)
-     */
-    public static String decryptSecretKey(String encryptedSecretKey) throws Exception {
-        if (privateKey == null) {
-            throw new IllegalStateException("私钥未初始化");
-        }
-        byte[] encryptedData = Base64.getDecoder().decode(encryptedSecretKey);
-        Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
-        cipher.init(Cipher.DECRYPT_MODE, privateKey);
-        byte[] decryptedData = cipher.doFinal(encryptedData);
-        // 关键:直接转为字符串,不要再次进行 Base64 编码
-        return new String(decryptedData, StandardCharsets.UTF_8);
-    }
-}

+ 0 - 63
fs-spec-zone/src/main/java/com/fs/speczone/util/WeChatTokenUtil.java

@@ -1,63 +0,0 @@
-package com.fs.speczone.util;
-
-import com.alibaba.fastjson.JSONObject;
-import org.springframework.web.client.RestTemplate;
-
-import java.util.concurrent.ConcurrentHashMap;
-
-/**
- * 企业微信 token / ticket 工具类
- */
-public class WeChatTokenUtil {
-
-    private static final RestTemplate restTemplate = new RestTemplate();
-    // 缓存 access_token,实际生产应使用 redis 或数据库
-    private static String accessTokenCache;
-    private static long accessTokenExpireTime = 0;
-
-    // 缓存 agent_ticket
-    private static String agentTicketCache;
-    private static long agentTicketExpireTime = 0;
-
-    /**
-     * 获取企业 access_token(带缓存)
-     */
-    public static String getAccessToken(String corpId, String corpSecret) {
-        long now = System.currentTimeMillis() / 1000;
-        if (accessTokenCache != null && now < accessTokenExpireTime) {
-            return accessTokenCache;
-        }
-        String url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + corpId + "&corpsecret=" + corpSecret;
-        JSONObject resp = restTemplate.getForObject(url, JSONObject.class);
-        if (resp != null && resp.getIntValue("errcode") == 0) {
-            accessTokenCache = resp.getString("access_token");
-            accessTokenExpireTime = now + resp.getIntValue("expires_in") - 300; // 提前5分钟过期
-            return accessTokenCache;
-        }
-        throw new RuntimeException("获取 access_token 失败: " + (resp == null ? "null response" : resp.getString("errmsg")));
-    }
-
-    /**
-     * 获取 agent_ticket(必须用于 agentConfig 签名)
-     * @param corpId     企业ID
-     * @param corpSecret 应用 secret
-     * @param agentId    应用ID
-     */
-    public static String getAgentTicket(String corpId, String corpSecret, String agentId) {
-        long now = System.currentTimeMillis() / 1000;
-        if (agentTicketCache != null && now < agentTicketExpireTime) {
-            return agentTicketCache;
-        }
-        String accessToken = getAccessToken(corpId, corpSecret);
-        String url = "https://qyapi.weixin.qq.com/cgi-bin/ticket/get?access_token=" + accessToken + "&type=agent_config";
-        JSONObject resp = restTemplate.getForObject(url, JSONObject.class);
-        if (resp != null && resp.getIntValue("errcode") == 0) {
-            agentTicketCache = resp.getString("ticket");
-            agentTicketExpireTime = now + resp.getIntValue("expires_in") - 300; // 提前5分钟刷新
-            return agentTicketCache;
-        }
-        throw new RuntimeException("获取 agent_ticket 失败: " + (resp == null ? "null response" : resp.getString("errmsg")));
-    }
-
-    // 如果需要普通 jsapi_ticket,也可类似实现,但本场景不需要
-}

+ 0 - 46
fs-spec-zone/src/main/java/com/fs/speczone/util/WeComSignatureUtil.java

@@ -1,46 +0,0 @@
-package com.fs.speczone.util;
-
-import com.alibaba.fastjson.JSONObject;
-import org.apache.commons.codec.digest.DigestUtils;
-
-import java.util.UUID;
-
-public class WeComSignatureUtil {
-
-    /**
-     * 生成普通 config 签名(使用 jsapi_ticket,本场景暂不调用)
-     */
-    public static JSONObject generateConfigSignature(String corpId, String corpSecret, String url) {
-        // 如果需要,可调用 WeChatTokenUtil.getJsapiTicket(...)
-        throw new UnsupportedOperationException("本演示未实现 jsapi_ticket 获取");
-    }
-
-    /**
-     * 生成 agentConfig 签名(使用 agent_ticket)
-     * @param corpId     企业ID
-     * @param corpSecret 应用 secret
-     * @param agentId    应用ID
-     * @param url        当前页面完整URL(不含#)
-     */
-    public static JSONObject generateAgentConfigSignature(String corpId, String corpSecret, String agentId, String url) {
-        try {
-            // 1. 获取 agent_ticket
-            String ticket = WeChatTokenUtil.getAgentTicket(corpId, corpSecret, agentId);
-            // 2. 生成随机串和时间戳
-            String nonceStr = UUID.randomUUID().toString().replaceAll("-", "");
-            String timestamp = Long.toString(System.currentTimeMillis() / 1000);
-            // 3. 拼接签名字符串
-            String signStr = "jsapi_ticket=" + ticket + "&noncestr=" + nonceStr + "&timestamp=" + timestamp + "&url=" + url;
-            // 4. SHA1 签名
-            String signature = DigestUtils.sha1Hex(signStr);
-
-            JSONObject result = new JSONObject();
-            result.put("timestamp", timestamp);
-            result.put("nonceStr", nonceStr);
-            result.put("signature", signature);
-            return result;
-        } catch (Exception e) {
-            throw new RuntimeException("生成 agentConfig 签名失败", e);
-        }
-    }
-}

+ 63 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/CommonUtils.java

@@ -0,0 +1,63 @@
+package mytype.mycom.mygroup;
+
+import com.tencent.wework.SpecUtil;
+
+import java.nio.charset.Charset;
+
+/**
+ * @usage 不含第三方依赖的通用工具类,提供接口如下
+ *        1. 查询接口是否支持
+ *        2. 生成包裹错误信息的json string
+ *        3. 展示部分系统参数
+ */
+public class CommonUtils {
+    /**
+     * @usage demo内部统一错误码,用户可根据需要自定义
+     */
+    private static final int DEMO_INTERNAL_ERRORCODE = 710660;
+
+    /**
+     * @usage 统一错误信息格式,注意错误字段应在输出协议中注册
+     */
+    private static final String ERROR_RESPONSE_FORMAT = "{\"errcode\":%d,\"errmsg\":\"%s,%s,%s\"}";
+
+    /**
+     * @usage 将错误信息包装为统一形式返回,以通过平台对用户注册能力的输出协议校验
+     * @param errMsg 内部错误提示信息
+     * @return 包装好的错误信息,json格式,errcode为统一的demo内部错误码,errmsg为文件名、行号和错误信息
+     */
+    public static String getErrorResponse(String errMsg) {
+        StackTraceElement element = Thread.currentThread().getStackTrace()[3];
+        SpecUtil.SpecLogNative(
+            'E', 
+            element.getFileName(), 
+            element.getLineNumber(), 
+            errMsg
+        );
+        return String.format(
+            ERROR_RESPONSE_FORMAT,
+            DEMO_INTERNAL_ERRORCODE,
+            element.getFileName(),
+            element.getLineNumber(),
+            errMsg
+        );
+    }
+
+    /**
+     * @usage 展示JVM关于字符编码的配置
+     */
+    public static void displayJvmCharsetConfig() {
+        StringBuffer sb = new StringBuffer();
+        sb.append("Default Charset: " + Charset.defaultCharset())
+            .append(", System Charset: " + System.getProperty("file.encoding"))
+            .append(", Default Charset in Use: " + new java.io.OutputStreamWriter(new java.io.ByteArrayOutputStream()).getEncoding())
+            .append(", Default OS Charset: " + System.getProperty("sun.jnu.encoding"))
+            .append(", Default Locale Charset: " + Charset.defaultCharset());
+        SpecUtil.WWSpecLogInfo(sb.toString());
+    }
+
+    /**
+     * @usage 私有构造函数,防止被实例化
+     */
+    private CommonUtils() {};
+}

+ 175 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/DataBaseUtils.java

@@ -0,0 +1,175 @@
+package mytype.mycom.mygroup;
+
+import com.tencent.wework.SpecUtil;
+
+import java.io.*;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.concurrent.TimeUnit;
+
+/** 
+ * @usage 提供数据的本地存储功能和过期数据的清理功能
+ *        每个记录使用一个文件,文件名为notifyId,文件内容为响应数据,单行
+ *        过期文件的定期清理使用独立线程完成,文件默认20分钟过期,每10分钟扫描一次
+ */
+public class DataBaseUtils {
+    /**
+     * @usage 存储响应数据的路径,类的静态代码会自动创建
+     */
+    private static final String DATA_DIR = "/mnt/data/callback_notify/";
+    
+    /**
+     * @usage 清理过期文件的扫描间隔,单位为分钟 
+     */
+    private static final int SCAN_INTERVAL = 10;  
+
+    /**
+     * @usage 文件过期时间,单位为分钟
+     */
+    private static final int EXPIRE_TIME = 20;  
+
+    /**
+     * @usage 1. 创建存放数据的目录
+     *        2. 启动清理过期文件的线程
+     */
+    static {
+        Path dirPath = Paths.get(DATA_DIR);
+        if (!Files.exists(dirPath)) {
+            try {
+                Files.createDirectories(dirPath);
+            } catch (IOException e) {
+                SpecUtil.WWSpecLogError("create directory " + DATA_DIR + " failed");
+                e.printStackTrace();
+                System.exit(1);
+            }
+            SpecUtil.WWSpecLogInfo("create directory " + DATA_DIR + " success");
+        }
+
+        Thread cleanerThread = new Thread(new Runnable() {
+            @Override
+            public void run() {
+                try {
+                    while (true) {
+                        File folder = new File(DATA_DIR);
+                        File[] files = folder.listFiles();
+                        if (files != null) {
+                            for (File file : files) {
+                                if (file.isFile() && isFileOld(file)) {
+                                    SpecUtil.WWSpecLogInfo("Deleted expired file: " + file.getName());
+                                    file.delete();
+                                }
+                            }
+                        }
+                        TimeUnit.MINUTES.sleep(SCAN_INTERVAL); // 每10分钟执行一次
+                    }
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    SpecUtil.WWSpecLogError("File cleaning thread was interrupted.");
+                }
+            }
+
+            private boolean isFileOld(File file) {
+                try {
+                    BasicFileAttributes attrs = Files.readAttributes(file.toPath(), BasicFileAttributes.class);
+                    long fileCreationTime = attrs.creationTime().toMillis();
+                    return (System.currentTimeMillis() - fileCreationTime) > EXPIRE_TIME * 60 * 1000;
+                } catch (Exception e) {
+                    SpecUtil.WWSpecLogError("Error reading file attributes: " + e.getStackTrace());
+                    return false;
+                }
+            }
+        });
+
+        cleanerThread.setDaemon(true);
+        cleanerThread.start();
+    }
+
+    public static enum ErrorCode {
+        // 成功
+        SUCCESS,
+        // IO异常
+        IO_FAIL,
+        // 文件夹不存在
+        DIRECTORY_NOT_EXIST,
+        // notify id(即文件)不存在
+        NOTIFY_ID_NOT_EXIST,
+        // 文件为空
+        EMPTY_FILE
+    }
+
+    /**
+     * @usage 存储响应数据
+     * @param notifyId 通知ID
+     * @param data 响应数据
+     * @return ErrorCode 错误码
+     * @warning 写入时需要指定为UTF-8编码
+     */
+    public static ErrorCode setNotifyData(String notifyId, String data) {
+        
+        try {
+            String fileName = DATA_DIR + notifyId;
+            File file = new File(fileName);
+            if (!file.exists()) {
+                file.createNewFile();
+                SpecUtil.WWSpecLogInfo("create file '" + fileName + "' success");
+            }
+
+            BufferedWriter writer = new BufferedWriter(
+                new OutputStreamWriter(
+                    new FileOutputStream(fileName), StandardCharsets.UTF_8));
+            writer.write(data);
+            writer.close();
+
+        } catch (IOException e) {
+            e.printStackTrace();
+            return ErrorCode.IO_FAIL;
+        }
+
+        return ErrorCode.SUCCESS;
+    }
+
+    /**
+     * @usage 读取指定notify_id的数据
+     * @param notifyId 通知ID
+     * @param dataBuffer 响应数据缓冲区,用于接收数据
+     * @return ErrorCode 错误码
+     * @warning 读取时需要指定为UTF-8编码
+     */
+    public static ErrorCode getByNotifyId(String notifyId, StringBuffer dataBuffer) {
+        String fileName = DATA_DIR + notifyId;
+        File file = new File(fileName);
+        if (!file.exists()) {
+            SpecUtil.WWSpecLogError("notify_id not exist");
+            return ErrorCode.NOTIFY_ID_NOT_EXIST;
+        }
+
+        try {
+            BufferedReader reader = new BufferedReader(
+                new InputStreamReader(
+                    new FileInputStream(fileName), StandardCharsets.UTF_8));
+            String line = reader.readLine();
+            if (line == null || line.isEmpty()) {
+                SpecUtil.WWSpecLogError("file: '" + fileName + "' is empty");
+                reader.close();
+                reader.close();
+                return ErrorCode.EMPTY_FILE;
+            }
+            dataBuffer.append(line);
+            reader.close();
+            
+        } catch (IOException e) {
+            e.printStackTrace();
+            return ErrorCode.IO_FAIL;
+        }
+
+        return ErrorCode.SUCCESS;
+    }
+
+    /**
+     * @usage 私有构造函数,防止外部实例化
+     */ 
+    private DataBaseUtils() {}
+}

+ 79 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/DemoCallProgramHandler.java

@@ -0,0 +1,79 @@
+package mytype.mycom.mygroup;
+
+import com.tencent.wework.SpecCallbackSDK;
+import com.tencent.wework.SpecSDK;
+
+/*
+ * @usage 应用调用专区接口的handler示例Demo类
+ */
+public class DemoCallProgramHandler implements  UserLogicHandler{
+
+
+    /*
+     * @usage 透传请求的能力ID标识
+     */
+    private static final String TRANSMIT_SDK_PREFIX = "invoke_";
+
+    /*
+     * @usage 企业应用来获取回调事件数据的请求的能力ID
+     */
+    private static final String ABILITY_GET_CALLBACK_DATA = "get_callback_data";
+
+    @Override
+    public boolean isValidAblility(String abilityId){
+        return abilityId.startsWith(TRANSMIT_SDK_PREFIX) || ABILITY_GET_CALLBACK_DATA.equals(abilityId);
+    }
+
+    @Override
+    public String process(SpecCallbackSDK callback){
+        String abilityId = callback.GetAbilityId();
+        if(!isValidAblility(abilityId)) {
+            return CommonUtils.getErrorResponse("unknown abilityId: " + abilityId);
+        }
+
+        if (abilityId.startsWith(TRANSMIT_SDK_PREFIX)) {
+            return transmitRequest(callback, abilityId.substring(7));
+        } else if (ABILITY_GET_CALLBACK_DATA.equals(abilityId)) {
+            return getCallbackData(callback);
+        } else {
+            return CommonUtils.getErrorResponse("unknown abilityId: " + abilityId);
+        }
+    }
+
+    /*
+     * @usage 透传企业应用的请求,转发到企微专区后台,相当于企业应用的代理
+     * @param callback 企业应用的请求
+     * @return sdk的回包
+     */
+    private static String transmitRequest(SpecCallbackSDK callback, String apiName) {
+        SpecSDK sdk = new SpecSDK(callback);
+        sdk.SetRequest(callback.GetData());
+
+        int ret = sdk.Invoke(apiName);
+        if (ret != 0) {
+            return CommonUtils.getErrorResponse("invoke failed, ret = " + ret + ", api name = " + apiName);
+        }
+
+        return sdk.GetResponse();
+    }
+
+    /*
+     * @usage 从专区程序本地读取回调事件的数据
+     * @param callback 企业应用的请求
+     * @return 回调事件的数据
+     */
+    private static String getCallbackData(SpecCallbackSDK callback) {
+        String notifyId = callback.GetNotifyId();
+        if (notifyId.isEmpty()) {
+            return CommonUtils.getErrorResponse("notify_id is empty");
+        }
+
+        StringBuffer dataBuffer = new StringBuffer();
+        DataBaseUtils.ErrorCode errorCode = DataBaseUtils.getByNotifyId(notifyId, dataBuffer);
+        if (errorCode != DataBaseUtils.ErrorCode.SUCCESS) {
+            return CommonUtils.getErrorResponse("get data by notify_id failed, errorCode = " + errorCode);
+        }
+
+        return dataBuffer.toString();
+    }
+}

+ 72 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/DemoReceiveCallBackHandler.java

@@ -0,0 +1,72 @@
+package mytype.mycom.mygroup;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.alibaba.fastjson2.JSONValidator;
+import com.tencent.wework.SpecCallbackSDK;
+import com.tencent.wework.SpecSDK;
+import com.tencent.wework.SpecUtil;
+
+public class DemoReceiveCallBackHandler implements UserLogicHandler{
+    /*
+     * @usage 企微专区后台回包的错误码的key
+     */
+    private static final String QW_BACKEND_ERRCODE_KEY = "errcode";
+
+    @Override
+    public boolean isValidAblility(String abilityid){
+        return true;
+    }
+
+    @Override
+    public String process(SpecCallbackSDK callback){
+        return backendCallbackEvent(callback);
+    }
+
+    /*
+     * @param callback 企微专区后台的回调事件
+     * @return 响应消息体,形式为json string
+     * @description 企业微信回调事件, 暂存回调数据,产生"program_notify"事件通知应用主动来获取,详见:
+     *              https://developer.work.weixin.qq.com/document/path/99843
+     *              https://developer.work.weixin.qq.com/document/path/99992
+     * @note 1. sdk version 1.0.6新增特性:spec notify app支持自定义notify id,
+     *          用户可使用SpecUtil.GenerateNotifyId生成notify idh或自定义notify id
+     *          先将其与回调数据作关联存储,再调用spec notify app将该notify id递送到企业应用
+     *       2. 用于仍可以按照原版本:先通知企业应用,再将回包的notify id与回调事件数据作关联存储
+     *          专区环境能保证专区程序比企业应用早收到回包,调试模式下可能出现数据还未写入完毕就被请求的情况
+     */
+    private static String backendCallbackEvent(SpecCallbackSDK callback) {
+        // 生成notify id,将其和回调事件的数据作关联存储
+        String notifyId = SpecUtil.GenerateNotifyId();
+        DataBaseUtils.ErrorCode errorCode = DataBaseUtils.setNotifyData(notifyId, callback.GetData());
+        if (errorCode != DataBaseUtils.ErrorCode.SUCCESS) {
+            return CommonUtils.getErrorResponse("notify data store failed, errorCode = " + errorCode);
+        }
+
+        // 存储完毕后,调用sdk通知企业应用来获取回调事件的数据
+        SpecSDK sdk = new SpecSDK(callback);
+        sdk.SetRequest(String.format("{\"notify_id\": \"%s\"}", notifyId));
+        int ret = sdk.Invoke("spec_notify_app");
+        if (ret != 0) {
+            return CommonUtils.getErrorResponse("invoke failed, ret = " + ret);
+        }
+        SpecUtil.WWSpecLogInfo("invoke spec notify app done", "notify id = " + notifyId);
+
+        // 检验回包参数
+        String responseStr = sdk.GetResponse();
+        if (!JSONValidator.from(responseStr).validate() || responseStr.isEmpty()) {
+            return CommonUtils.getErrorResponse("notifyRsp is invalid, notifyRsp = " + responseStr);
+        }
+        JSONObject responseJson = JSON.parseObject(responseStr);
+        if (!responseJson.containsKey(QW_BACKEND_ERRCODE_KEY)) {
+            return CommonUtils.getErrorResponse("missing errcode in notifyRsp");
+        }
+        Integer errcode = responseJson.getInteger("errcode");
+        if (errcode != 0) {
+            return CommonUtils.getErrorResponse("invoke spec notify app failed, errcode = " + errcode + ", errmsg = " + responseJson.getString("errmsg"));
+        }
+
+        // 这里不需要包装,回调事件无需发送回包
+        return "notify and store success";
+    }
+}

+ 123 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/NetworkService.java

@@ -0,0 +1,123 @@
+package mytype.mycom.mygroup;
+
+
+import com.tencent.wework.SpecUtil;
+
+import io.netty.bootstrap.ServerBootstrap;
+import io.netty.channel.*;
+import io.netty.channel.nio.NioEventLoopGroup;
+import io.netty.channel.socket.nio.NioServerSocketChannel;
+import io.netty.handler.codec.http.*;
+import io.netty.channel.socket.SocketChannel;
+import java.util.UUID;
+
+
+/*
+ * @usage 网络服务实现类
+ */
+public class NetworkService {
+
+    /*
+     * @description 网络服务监听的IP
+     */
+    public static final String HOST = "0.0.0.0";
+
+    /*
+     * @description 网络服务监听的端口
+     */
+    public static final int PORT = 8080;
+
+    /*
+     * @description DEMO使用的SDK的版本号
+     */
+    private static final String SDK_VERSION = SpecUtil.GetSDKVersion();
+
+    /*
+     * @usage 初始化并启动netty服务
+     */
+    public static void startServer() throws Exception {
+
+        EventLoopGroup bossGroup = new NioEventLoopGroup(SvrConfig.boss_group_size);
+        EventLoopGroup workerGroup = new NioEventLoopGroup(SvrConfig.io_gorup_size);
+
+        try {
+            // 初始化服务器
+            ServerBootstrap sbs = new ServerBootstrap();
+            sbs.group(bossGroup, workerGroup)
+                    .channel(NioServerSocketChannel.class)
+                    // 设置bossGroup接收连接的队列长度
+                    .option(ChannelOption.SO_BACKLOG, 4096)
+                    // 地址复用,允许绑定处于TIME_WAIT状态下的socket
+                    .option(ChannelOption.SO_REUSEADDR, true)
+                    // 禁用Nagle算法,允许立即发送数据包,从而降低延迟
+                    .childOption(ChannelOption.TCP_NODELAY, true)
+                    // 设置请求处理器的流水线
+                    .childHandler(new ChannelInitializer<SocketChannel>() {
+                        @Override
+                        protected void initChannel(SocketChannel socketChannel) {
+                            ChannelPipeline cp = socketChannel.pipeline();
+                            // HTTP 解码器:将字节流解码为 HTTP 对象
+                            cp.addLast(new HttpServerCodec());
+                            // 聚合器:将多个 HTTP 消息部分聚合成完整的 HTTP 消息
+                            cp.addLast(new HttpObjectAggregator(64 * 1024 * 1024));
+                            // 业务逻辑处理器
+                            cp.addLast(new SpecHandler());
+                        }
+                    });
+
+            // 启动服务器
+            Channel ch = sbs.bind(HOST, PORT).sync().channel();
+            SpecUtil.WWSpecLogInfo(
+                    "server started",
+                    "host: " + NetworkService.HOST,
+                    "port: " + NetworkService.PORT,
+                    "SDK version: " + SDK_VERSION,
+                    "boss group size: " + SvrConfig.boss_group_size,
+                    "io worker group size: " + SvrConfig.io_gorup_size,
+                    "process in io thread:" + SvrConfig.process_in_iothread
+            );
+
+            // 启动性能监控线程
+            ResourceMonitor monitor = new ResourceMonitor(SvrConfig.monitor_interval);
+            monitor.start();
+
+            ch.closeFuture().sync();
+
+        } finally {
+            bossGroup.shutdownGracefully();
+            workerGroup.shutdownGracefully();
+            SpecUtil.WWSpecLogInfo("server shutdown");
+        }
+    }
+
+    /*
+     * @usage 入参解析和发送响应,不涉及具体的业务处理逻辑
+     */
+    private static class SpecHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
+
+        @Override
+        protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) {
+            String requestId = request.headers().get("ww-req-id");
+            if(requestId==null || requestId.isEmpty()) {
+                requestId = UUID.randomUUID().toString().replace("-", "");
+                request.headers().set("ww-req-id", requestId);
+            }
+
+            RequestContext context = new RequestContext(ctx, requestId, SvrConfig.process_in_iothread);
+            RequestProcessor processor = new RequestProcessor();
+            processor.process(context, request);
+        }
+
+        @Override
+        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws   Exception {
+            SpecUtil.WWSpecLogError("server exception: " + cause.getStackTrace());
+            cause.printStackTrace();
+            ctx.close();
+        }
+    }
+
+    /*
+     * @usage 私有构造函数,防止被实例化
+     */
+    private NetworkService() {}
+}

+ 62 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/RequestContext.java

@@ -0,0 +1,62 @@
+package mytype.mycom.mygroup;
+
+
+/*
+ * @usage 请求包上下文
+ *
+ * */
+
+import io.netty.channel.ChannelHandlerContext;
+
+
+public class RequestContext {
+    private ChannelHandlerContext channelContext;
+    /*
+     * @usage 请求包在io线程中开始channelRead0处理时间
+     */
+    private long startReadTime;
+
+    /*
+     * @usage 请求包在io线程投递到业务线程时间
+     */
+    private long addTaskTime;
+
+
+    /*
+     * @usage 请求id标识
+     */
+    private String reqid;
+
+    private boolean processInIoThread;
+
+    public RequestContext(ChannelHandlerContext channelContext, String requestId, boolean processInIoThread) {
+        this.channelContext = channelContext;
+        this.startReadTime = System.currentTimeMillis();
+        this.reqid = requestId;
+        this.processInIoThread = processInIoThread;
+    }
+
+    public ChannelHandlerContext getChannelHandlerContext() {
+        return channelContext;
+    }
+
+    public long getStartReadTime() {
+        return startReadTime;
+    }
+
+    public String getReqid() {
+        return reqid;
+    }
+
+    public boolean isProcessInIoThread() {return processInIoThread;}
+
+    public void setAddTaskTime(long addTaskTime) {
+        this.addTaskTime = addTaskTime;
+    }
+
+    public long getAddTaskTime() {
+        return addTaskTime;
+    }
+
+
+}

+ 263 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/RequestProcessor.java

@@ -0,0 +1,263 @@
+package mytype.mycom.mygroup;
+
+import io.netty.channel.ChannelFuture;
+import io.netty.handler.codec.http.*;
+
+import java.util.Arrays;
+import java.util.concurrent.*;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import com.tencent.wework.SpecCallbackSDK;
+import com.tencent.wework.SpecUtil;
+
+import io.netty.buffer.Unpooled;
+import io.netty.util.CharsetUtil;
+
+/*
+ * @usage 请求包处理器
+ * 支持使用自定义线程池处理请求包
+ *
+ * */
+
+public class RequestProcessor {
+    /*
+     * @description 处理POST请求的路径
+     */
+    private static final String POST_CONTEXT = "/";
+
+    public RequestProcessor() {
+    }
+
+    /*
+     * @usage 验证http请求是否合法
+     */
+    private HttpResponseStatus isValidRequest(FullHttpRequest request) {
+        // 检查请求是否按照HTTP协议规范发送
+        if (request.decoderResult().isFailure()) {
+            return HttpResponseStatus.BAD_REQUEST;
+        }
+        // 检查请求方法是否为POST
+        if (!HttpMethod.POST.equals(request.method())) {
+
+            return HttpResponseStatus.METHOD_NOT_ALLOWED;
+        }
+
+        // 检查请求路径是否为"/"
+        if (!request.uri().equals(POST_CONTEXT)) {
+            return HttpResponseStatus.NOT_FOUND;
+        }
+        return HttpResponseStatus.OK;
+    }
+
+    /*
+     * @param response http回包结构
+     * @usage 通过io线程发送回包
+     */
+    public void sendResponse(RequestContext ctx, FullHttpResponse response) {
+        long beginWriteTime = System.currentTimeMillis();
+        ChannelFuture future = ctx.getChannelHandlerContext().writeAndFlush(response);
+        future.addListener(writeFunc -> {
+            // 打印请求处理耗时、等待耗时(从io线程收到请求到业务线程开始执行)、以及写回包耗时
+            long writeTimeMs = System.currentTimeMillis() - beginWriteTime;
+            if (!writeFunc.isSuccess()) {
+                SpecUtil.WWSpecLogInfoWithReqId(ctx.getReqid(), "write err cost" + writeTimeMs);
+            }
+            else if(writeTimeMs > 10) {
+                SpecUtil.WWSpecLogInfoWithReqId(ctx.getReqid(), "write cost  " + writeTimeMs + " ms");
+            }
+        });
+    }
+
+    /*
+     * @return FullHttpResponse
+     * @usage 构造出错时回包
+     */
+    private FullHttpResponse buildErrResponse(HttpResponseStatus errStatus) {
+        return new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, errStatus);
+    }
+
+    /*
+     * @param ctx 请求上下文
+     * @param request http请求体FullHttpRequest
+     * @usage 将请求投递到业务线程异池步处理
+     */
+    public void process(RequestContext ctx, FullHttpRequest request) {
+
+        //校验请求是否合法
+        HttpResponseStatus status = isValidRequest(request);
+        if(status != HttpResponseStatus.OK) {
+            SpecUtil.WWSpecLogErrorWithReqId(ctx.getReqid(), "invalid http method: " + request.method() +  "status:" + status.code());
+            FullHttpResponse response = buildErrResponse(status);
+            sendResponse(ctx, response);
+            ctx.getChannelHandlerContext().close();
+            return;
+        }
+
+        if(ctx.isProcessInIoThread())
+        {
+            new ProcessTask(ctx, request).run();
+        }
+        else {
+            try {
+                request.retain();
+                ctx.setAddTaskTime(System.currentTimeMillis());
+                ThreadPoolSingleton.getInstance().execute(new ProcessTask(ctx, request));
+
+            } catch (RejectedExecutionException e) {
+                SpecUtil.WWSpecLogErrorWithReqId(ctx.getReqid(), "reject:queue is full reject " + Arrays.toString(e.getStackTrace()));
+                FullHttpResponse response = buildErrResponse(HttpResponseStatus.INTERNAL_SERVER_ERROR);
+                sendResponse(ctx, response);
+                ctx.getChannelHandlerContext().close();
+            }
+        }
+    }
+
+    class ProcessTask implements Runnable {
+
+        RequestContext ctx;
+        FullHttpRequest req;
+
+        public ProcessTask(RequestContext ctx, FullHttpRequest req) {
+            this.ctx = ctx;
+            this.req = req;
+        }
+
+        @Override
+        public void run() {
+            long processStartTime = System.currentTimeMillis();
+            try {
+                RequestHandler handler= new RequestHandler();
+                handler.process(ctx, req);
+            } catch (Throwable e) {
+                SpecUtil.WWSpecLogInfoWithReqId(ctx.getReqid(), " exception " + Arrays.toString(e.getStackTrace()));
+            } finally {
+                if(!ctx.isProcessInIoThread()) {
+                    req.release();
+                }
+
+                long currentTimeMillis = System.currentTimeMillis();
+                long total_cost = currentTimeMillis - ctx.getStartReadTime();
+                long processTimeMs = currentTimeMillis - processStartTime;
+
+                if(ctx.isProcessInIoThread()){
+                    SpecUtil.WWSpecLogInfoWithReqId(ctx.getReqid(), "run total " + total_cost +
+                            " ms handle " + processTimeMs +  " ms");
+                }
+                else {
+                    long wait_cost = processStartTime - ctx.getAddTaskTime();
+                    SpecUtil.WWSpecLogInfoWithReqId(ctx.getReqid(), "run total " + total_cost + " ms wait " +
+                            wait_cost + " ms handle " + processTimeMs + " ms");
+                }
+            }
+        }
+    }
+
+    /*
+     * @usage 请求包处理逻辑类
+     * 包括解包、调用业务逻辑、发送回包
+     */
+    class  RequestHandler{
+
+        /*
+         * @return SDK的SpecCallbackSDK结构
+         * @usage 将netty的FullHttpRequest结构解析为SDK的SpecCallbackSDK结构
+         */
+        private SpecCallbackSDK parseRequest(RequestContext ctx, FullHttpRequest request) {
+            Map<String, String> requestHeaders = new HashMap<>(request.headers().size());
+            for (Map.Entry<String, String> entry : request.headers().entries()) {
+                requestHeaders.put(entry.getKey(), entry.getValue());
+            }
+
+            //SpecUtil.WWSpecLogInfoWithReqId(ctx.getReqid(),"req header: " + requestHeaders);
+
+            // 使用UTF-8编码解析请求体,请求体为加密后经过base64编码的形式
+            String requestBody = request.content().toString(CharsetUtil.UTF_8);
+
+            //SpecUtil.WWSpecLogInfoWithReqId(ctx.getReqid(),"req body: " + requestBody);
+            return new SpecCallbackSDK("POST", requestHeaders, requestBody);
+        }
+
+        /*
+         * @return FullHttpResponse
+         * @usage 构造回包
+         */
+        private FullHttpResponse buildFullResponse(SpecCallbackSDK callback, String responseContent) {
+
+            // 构造响应头,加密响应体
+            callback.BuildResponseHeaderBody(responseContent);
+            Map<String, String> responseHeaders = callback.GetResponseHeaders();
+            String responseBody = callback.GetResponseBody();
+            //SpecUtil.WWSpecLogInfo("response build done, response headers: " + responseHeaders);
+
+            // 返回响应
+            FullHttpResponse response = new DefaultFullHttpResponse(
+                    HttpVersion.HTTP_1_1,
+                    HttpResponseStatus.OK,
+                    Unpooled.copiedBuffer(responseBody, CharsetUtil.UTF_8)
+            );
+            response.headers().set(HttpHeaderNames.CONTENT_LENGTH, response.content().readableBytes());
+            for (Map.Entry<String, String> header : responseHeaders.entrySet()) {
+                response.headers().set(header.getKey(), header.getValue());
+            }
+
+            return response;
+        }
+
+        /*
+         * @usage 执行用户的业务逻辑
+         * 先解包,然后根据请求的类型分别调用业务自己实现的业务逻辑类,然后返回响应
+         * 请求类型说明:1-应用主动调用,2-专区后台通知
+         */
+        public void process(RequestContext ctx, FullHttpRequest request) {
+
+            SpecCallbackSDK callback = parseRequest(ctx, request);
+            String responseContent;
+
+            // 解包校验签名等失败
+            if (!callback.IsOk()) {
+                responseContent = CommonUtils.getErrorResponse("callback is not ok");
+            }
+            else
+            {
+                //打印解密后的请求参数
+                SpecUtil.WWSpecLogInfoWithReqId(ctx.getReqid(),
+                        "req args corpId = " + callback.GetCorpId(),
+                        "agentId = " + callback.GetAgentId(),
+                        "callType = " + callback.GetCallType(),
+                        "isAsync = " + callback.GetIsAsync(),
+                        "jobInfo = " + callback.GetJobInfo(),
+                        "abilityId = " + callback.GetAbilityId(),
+                        "notifyId = " + callback.GetNotifyId(),
+                        "data = " + callback.GetData());
+
+
+                //根据callType来分别调用不同的用户业务逻辑处理类
+                switch ((int) callback.GetCallType()) {
+                    // 应用主动调用专区程序,详见:https://developer.work.weixin.qq.com/document/53336
+                    case 1: {
+                        DemoCallProgramHandler callProgramHandler = new DemoCallProgramHandler();
+                        responseContent = callProgramHandler.process(callback);
+                        break;
+
+                    }
+                    // 专区后台通知专区程序,详见:https://developer.work.weixin.qq.com/document/53419
+                    case 2: {
+                        DemoReceiveCallBackHandler receiveCallBackHandler = new DemoReceiveCallBackHandler();
+                        responseContent = receiveCallBackHandler.process(callback);
+                        break;
+                    }
+                    default:
+                        responseContent = CommonUtils.getErrorResponse("unknown call type: " + callback.GetCallType());
+                        break;
+                }
+            }
+
+            SpecUtil.WWSpecLogInfoWithReqId(ctx.getReqid(), "sdk_ver:"+ SpecUtil.GetSDKVersion(), "sz "+ responseContent.length() + " rsp " + responseContent);
+            FullHttpResponse response = buildFullResponse(callback, responseContent);
+            RequestProcessor.this.sendResponse(ctx, response);
+        }
+    }
+}
+

+ 171 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/ResourceMonitor.java

@@ -0,0 +1,171 @@
+package mytype.mycom.mygroup;
+
+import com.tencent.wework.SpecUtil;
+
+import java.lang.management.*;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * @usage 收集服务器指标,输出到日志
+ * @note  1. 监控日志同样需要使用SpecUtil输出,才能被日志管理平台收集
+ *        2. 性能监控日志应结合
+ */
+public class ResourceMonitor {
+
+    /**
+     * @description 监控指标收集器
+     */
+    private final MemoryMXBean memoryMXBean;
+    private final List<GarbageCollectorMXBean> gcBeans;
+    private final ThreadMXBean threadMXBean;
+    private final ClassLoadingMXBean classLoadingMXBean;
+    private final OperatingSystemMXBean osMXBean;
+
+    /**
+     * @description 收集监控指标用到的常量
+     */
+    private static final int INITIAL_DELAY = 0;
+    private   int OUTPUT_PERIOD = 10;
+    private static final double MB_SIZE = 1024.0 * 1024.0;
+    private static final int WAIT_THREADS_TIME = 100;
+
+    /**
+     * @description 日志格式
+     */
+    private static final String MEMORY_LOG_FORMAT = "MEM(MB) Heap: Init: %.2f, Max: %.2f, Used: %.2f, Committed: %.2f " +
+                                                    "Non-Heap: Init: %.2f, Max: %.2fMB, Used: %.2f, Committed: %.2f ";
+    private static final String GC_LOG_FORMAT = "GC : [%s], Cnt: %d, Time: %dms ";
+    private static final String THREAD_LOG_FORMAT = "ThreadCnt: %d, busy IO: %d Worker: %d. ";
+    private static final String CLASS_LOADING_LOG_FORMAT = "ClassCnt Total: %d, Loaded: %d, Unloaded: %d ";
+    private static final String OS_LOG_FORMAT = "Cpu Load: %.2f ";
+
+    /**
+     * @usage 实例化JMX监控对象
+     */
+    public ResourceMonitor(int monitor_interval) {
+        this.memoryMXBean = ManagementFactory.getMemoryMXBean();
+        this.gcBeans = ManagementFactory.getGarbageCollectorMXBeans();
+        this.threadMXBean = ManagementFactory.getThreadMXBean();
+        this.classLoadingMXBean = ManagementFactory.getClassLoadingMXBean();
+        this.osMXBean = ManagementFactory.getOperatingSystemMXBean();
+        this.OUTPUT_PERIOD = monitor_interval;
+
+    }
+
+    /**
+     * @usage 启动输出监控日志的定时任务
+     */
+    public void start() {
+        ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+        scheduler.scheduleAtFixedRate(() -> {
+            try {
+                SpecUtil.WWSpecLogInfo(collectData());
+            } catch (Exception e) {
+                SpecUtil.WWSpecLogError("collect data fail", e.getMessage());
+                e.printStackTrace();
+            }
+        }, INITIAL_DELAY, OUTPUT_PERIOD, TimeUnit.SECONDS);        
+    }
+
+    /**
+     * @usage 收集监控数据,考虑到日志会被刷新,这里将输出部分静态的配置信息
+     * @return JMX能监控的数据
+     */
+    private String collectData() {
+        StringBuffer sb = new StringBuffer();
+
+        // 获取负载
+        sb.append(String.format(OS_LOG_FORMAT,
+                osMXBean.getSystemLoadAverage()
+        ));
+
+        // 获取线程信息
+        sb.append(String.format(THREAD_LOG_FORMAT,
+                threadMXBean.getThreadCount(),
+                getActiveThreadCount("nioEventLoopGroup"),
+                SvrConfig.process_in_iothread?0:ThreadPoolSingleton.getInstance().getActiveCount()
+        ));
+
+        // 获取内存使用情况
+        MemoryUsage heapMemoryUsage = memoryMXBean.getHeapMemoryUsage();
+        MemoryUsage nonHeapMemoryUsage = memoryMXBean.getNonHeapMemoryUsage();        
+        sb.append(String.format(MEMORY_LOG_FORMAT, 
+            bytesToMb(heapMemoryUsage.getInit()),
+            bytesToMb(heapMemoryUsage.getMax()),
+            bytesToMb(heapMemoryUsage.getUsed()),
+            bytesToMb(heapMemoryUsage.getCommitted()),
+            bytesToMb(nonHeapMemoryUsage.getInit()),
+            bytesToMb(nonHeapMemoryUsage.getMax()),
+            bytesToMb(nonHeapMemoryUsage.getUsed()),
+            bytesToMb(nonHeapMemoryUsage.getCommitted())
+        ));
+
+        // 获取垃圾回收信息
+        for (GarbageCollectorMXBean gcBean : gcBeans) {
+            sb.append(String.format(GC_LOG_FORMAT,
+                gcBean.getName(),
+                gcBean.getCollectionCount(),
+                gcBean.getCollectionTime()
+            ));
+        }
+
+
+        // 获取类加载信息
+        sb.append(String.format(CLASS_LOADING_LOG_FORMAT,
+            classLoadingMXBean.getTotalLoadedClassCount(),
+            classLoadingMXBean.getLoadedClassCount(),
+            classLoadingMXBean.getUnloadedClassCount()
+        ));
+
+
+
+        return sb.toString();
+    }
+
+    private static double bytesToMb(long bytes) {
+        return bytes / MB_SIZE;
+    }
+
+    /**
+     * @usage 获取指定线程名前缀的活跃线程数
+     */
+    private int getActiveThreadCount(String threadNamePrefix) {
+        if (!threadMXBean.isThreadCpuTimeSupported()) {
+            return -1;
+        }
+
+        // 收集netty线程的累计运行时间,单位纳秒
+        Map<Long, Long> nettyThreadRunningTime = new HashMap<>(threadMXBean.getThreadCount());
+        for (long threadId : threadMXBean.getAllThreadIds()) {
+            ThreadInfo threadInfo = threadMXBean.getThreadInfo(threadId);
+            if (threadInfo != null && threadInfo.getThreadName().startsWith(threadNamePrefix)) {
+                if (threadMXBean.getThreadCpuTime(threadId) > 0) {
+                    nettyThreadRunningTime.put(threadId, threadMXBean.getThreadCpuTime(threadId));
+                }
+            }
+        }
+
+        // 让出CPU等待,确保线程的CPU时间已经更新
+        try {
+            TimeUnit.MILLISECONDS.sleep(WAIT_THREADS_TIME);
+        } catch (InterruptedException e) {
+            SpecUtil.WWSpecLogError("Failed to sleep", e.getMessage());
+            e.printStackTrace();
+            return -1;
+        }
+
+        // 通过检查CPU时间是否增长来判断线程是否活跃
+        int activeThreadCount = 0;
+        for (long threadId : nettyThreadRunningTime.keySet()) {
+            if (threadMXBean.getThreadCpuTime(threadId) > nettyThreadRunningTime.get(threadId)) {
+                activeThreadCount++;
+            }
+        }
+        return activeThreadCount;
+    }
+}

+ 115 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/SpecDemo.java

@@ -0,0 +1,115 @@
+package mytype.mycom.mygroup;
+
+import com.tencent.wework.SpecUtil;
+import org.apache.commons.cli.*;
+
+import java.io.PrintStream;
+
+/**
+ * @note 开发调试前必读:
+ * 1. 如需用于生产环境,建议完善程序的可靠性
+ * 2. 启动app需指定字符编码为UTF-8:java -Dfile.encoding=UTF-8 -jar myapp.jar
+ * 3. 调试前请先阅读各public类的注释,了解demo server和SDK的基本框架和逻辑功能
+ * 4. 如需打日志,必须封装SpecUtil.SpecLog或SpecUtil.SpecLogNative方法
+ * 5. 所有demo源代码和SDK包含大量中文注释,源文件的字符编码均为UTF-8
+ * @expamle 调试前请先查看下列示例:
+ * 1. 应用使用token获取会话记录全流程:https://developer.work.weixin.qq.com/document/path/100052#应用获取会话记录的流程示例
+ * 2. 专区程序示例镜像配置说明:https://developer.work.weixin.qq.com/document/54166
+ */
+public class SpecDemo {
+
+    /**
+     * @usage 1. 设置全局字符编码为UTF-8(企微专区后台为UTF-8)
+     *        2. 加载DatabaseUtil,启动过期文件清除线程
+     *        3. 检查部分关键配置
+     */
+    static {
+        try {
+            System.setOut(new PrintStream(System.out, true, "UTF-8"));
+            System.setErr(new PrintStream(System.err, true, "UTF-8"));
+            System.setProperty("file.encoding", "UTF-8");
+            // Java Native Interface (JNI) 使用的编码方式
+            System.setProperty("sun.jnu.encoding", "UTF-8");
+        } catch (Exception e) {
+            SpecUtil.WWSpecLogError("charset init error");
+            e.printStackTrace();
+            System.exit(1);
+        }
+
+        try {
+            Class.forName("mytype.mycom.mygroup.DataBaseUtils");
+        } catch (ClassNotFoundException e) {
+            SpecUtil.WWSpecLogError("load DataBaseUtils failed");
+            e.printStackTrace();
+            System.exit(1);
+        }
+
+        CommonUtils.displayJvmCharsetConfig();
+    }
+
+    /**
+     * @usage 解析入参,处理进程级调用,启动服务器,启动调试模式的参数如下:
+     */
+    public static void main(String[] args) {
+        // CommandLineParser parser = new DefaultParser(args);
+        CommandLineParser parser = new DefaultParser();
+        HelpFormatter formatter = new HelpFormatter();
+        Options options = new Options();
+        options.addOption("d", true, "debugToken 调试模式时必须传该参数");
+        options.addOption("a", true, "accessToken 调试模式时必须传该参数");
+        options.addOption("i", true, "io worker thread count NioEventLoop worker线程池大小,默认为256。若开启业务线程执行,io线程建议调小");
+        options.addOption("b", true, "business worker thread count  自定义业务worker线程池大小,默认为256,仅当processInIoThread为1时生效");
+        options.addOption("p", true, "processInIoThread 业务逻辑是否在io线程执行。1:使用io线程执行业务逻辑 0: 使用自定义业务线程执行业务逻辑,默认为1");
+        options.addOption("m", true, "monitor interval 监控线程打日志时间间隔,默认为60s");
+
+        try {
+            CommandLine cmd = parser.parse(options, args);
+            if (cmd.hasOption("i")) {
+                SvrConfig.io_gorup_size = Integer.parseInt(cmd.getOptionValue("i"));
+            }
+
+            if (cmd.hasOption("b")) {
+                SvrConfig.business_gorup_size = Integer.parseInt(cmd.getOptionValue("b"));
+            }
+
+            if (cmd.hasOption("p")) {
+                SvrConfig.process_in_iothread = Integer.parseInt(cmd.getOptionValue("p")) != 0;
+                if(!SvrConfig.process_in_iothread)
+                {
+                    ThreadPoolSingleton.initialize(SvrConfig.business_gorup_size);
+                }
+            }
+
+            if (cmd.hasOption("m")) {
+                SvrConfig.monitor_interval = Integer.parseInt(cmd.getOptionValue("m"));
+            }
+
+            if (cmd.hasOption("d")) {
+                if (!cmd.hasOption("a")) {
+                    SpecUtil.WWSpecLogError("cmdline miss -a access_token");
+                    System.exit(1);
+                }
+                if (!SpecUtil.SpecOpenDebugMode(cmd.getOptionValue("d"), cmd.getOptionValue("a"))) {
+                    SpecUtil.WWSpecLogError("open debug mode failed");
+                    System.exit(1);
+                }
+            }
+
+            try {
+                NetworkService.startServer();
+            } catch (Exception e) {
+                SpecUtil.WWSpecLogError("start server failed" + e.getStackTrace());
+                formatter.printHelp("Options", options);
+                e.printStackTrace();
+            }
+
+
+
+        } catch (ParseException e) {
+            SpecUtil.WWSpecLogError("cmd options err");
+            formatter.printHelp("Options", options);
+            System.exit(1);
+        }
+
+    }
+}

+ 31 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/SvrConfig.java

@@ -0,0 +1,31 @@
+package mytype.mycom.mygroup;
+
+public class SvrConfig {
+
+    /**
+     * @description 负责接收连接的线程池大小
+     */
+    public static int boss_group_size = 1;
+    /**
+     * @description 负责处理请求的NioEventLoop worker线程池大小
+     * @note 如需按照CPU处理器数的一定倍率设置worker数,需注意k8s集群环境下是否读取到了母机的配置
+     */
+    public static int io_gorup_size = 256;
+
+    /**
+     * @description 负责处理业务的自定义线程池大小
+     */
+    public static int business_gorup_size = 256;
+
+
+    /**
+     * @description 业务逻辑是否在io线程上处理,false:在自定义的newCachedThreadPool线程池执行逻辑
+     */
+    public static boolean process_in_iothread = true;
+
+    /**
+     * @description 监控线程打日志间隔,默认60秒
+     */
+    public static int monitor_interval = 60;
+
+}

+ 33 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/ThreadPoolSingleton.java

@@ -0,0 +1,33 @@
+package mytype.mycom.mygroup;
+
+import java.util.concurrent.*;
+
+/*
+ * @usage 自定义业务线程池单例
+ * */
+public class ThreadPoolSingleton {
+
+    private ThreadPoolSingleton() {}
+
+    // 静态内部类
+    private static class Holder {
+        private static ThreadPoolExecutor instance_;
+
+        private static void initialize(int poolSize) {
+            if (instance_ == null) {
+                instance_ = new ThreadPoolExecutor(32, poolSize, 1000, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(16));
+            }
+        }
+    }
+
+    public static void initialize(int poolSize) {
+        Holder.initialize(poolSize);
+    }
+
+    public static ThreadPoolExecutor getInstance() {
+        if (Holder.instance_ == null) {
+            throw new IllegalStateException("ThreadPoolExecutor is not initialized. Call initialize() first.");
+        }
+        return Holder.instance_;
+    }
+}

+ 20 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/UserLogicHandler.java

@@ -0,0 +1,20 @@
+package mytype.mycom.mygroup;
+
+import com.tencent.wework.SpecCallbackSDK;
+
+public interface UserLogicHandler{
+    /**
+     * @description 判断能力id是否合法
+     * @param abilityid 能力id
+     * @return 返回结果
+     */
+    public boolean isValidAblility(String abilityid);
+
+    /**
+     * @description 业务逻辑处理函数
+     * @param callback SpecCallbackSdk结构
+     * @return 返回http回包body
+     */
+    public String process(SpecCallbackSDK callback);
+
+}