Ver código fonte

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

cgp 2 semanas atrás
pai
commit
73a6d47903
39 arquivos alterados com 2165 adições e 1 exclusões
  1. 56 0
      fs-admin/src/main/java/com/fs/qw/controller/CorporateWeChatSpaceController.java
  2. 9 0
      fs-admin/src/main/java/com/fs/qw/controller/QwUserController.java
  3. 8 0
      fs-service/src/main/java/com/fs/qw/mapper/QwUserMapper.java
  4. 26 0
      fs-service/src/main/java/com/fs/qw/service/ICorporateWeChatSpaceService.java
  5. 8 0
      fs-service/src/main/java/com/fs/qw/service/IQwUserService.java
  6. 158 0
      fs-service/src/main/java/com/fs/qw/service/impl/ICorporateWeChatSpaceServiceImpl.java
  7. 11 0
      fs-service/src/main/java/com/fs/qw/service/impl/QwUserServiceImpl.java
  8. 61 0
      fs-service/src/main/java/com/fs/qw/utils/WeChatTokenUtil.java
  9. 38 0
      fs-service/src/main/java/com/fs/qw/utils/WeComSignatureUtil.java
  10. 4 0
      fs-service/src/main/java/com/fs/qw/vo/QwExternalContactVO.java
  11. 27 0
      fs-service/src/main/java/com/fs/qw/vo/QwSessionConfigVo.java
  12. 22 0
      fs-service/src/main/java/com/fs/qwApi/domain/SpecProgramCallRequest.java
  13. 27 0
      fs-service/src/main/java/com/fs/qwApi/domain/SpecProgramCallResponse.java
  14. 22 0
      fs-service/src/main/java/com/fs/qwApi/domain/SpecProgramResultResponse.java
  15. 5 0
      fs-service/src/main/java/com/fs/system/service/ISysConfigService.java
  16. 19 0
      fs-service/src/main/java/com/fs/system/service/impl/SysConfigServiceImpl.java
  17. 1 1
      fs-service/src/main/resources/mapper/qw/QwExternalContactMapper.xml
  18. 5 0
      fs-service/src/main/resources/mapper/qw/QwUserMapper.xml
  19. 12 0
      fs-spec-zone/Dockerfile
  20. BIN
      fs-spec-zone/libWeWorkSpecSDK.so
  21. 75 0
      fs-spec-zone/pom.xml
  22. 32 0
      fs-spec-zone/src/main/java/README_SDK.md
  23. 43 0
      fs-spec-zone/src/main/java/com/fs/speczone/DebugModeRunner.java
  24. 13 0
      fs-spec-zone/src/main/java/com/fs/speczone/SpecZoneApplication.java
  25. 237 0
      fs-spec-zone/src/main/java/com/fs/speczone/controller/CallbackController.java
  26. 33 0
      fs-spec-zone/src/main/java/com/fs/speczone/controller/WeComApiController.java
  27. 25 0
      fs-spec-zone/src/main/java/com/fs/speczone/handler/FetchConversationsHandler.java
  28. 22 0
      fs-spec-zone/src/main/java/com/fs/speczone/handler/ProgramActionHandler.java
  29. 155 0
      fs-spec-zone/src/main/java/com/fs/speczone/sdk/SpecSdkAdapter.java
  30. 132 0
      fs-spec-zone/src/main/java/com/fs/speczone/service/ConversationService.java
  31. 11 0
      fs-spec-zone/src/main/java/com/fs/speczone/service/WeComService.java
  32. 149 0
      fs-spec-zone/src/main/java/com/fs/speczone/service/impl/WeComServiceImpl.java
  33. 65 0
      fs-spec-zone/src/main/java/com/fs/speczone/util/WeChatDecryptUtil.java
  34. 63 0
      fs-spec-zone/src/main/java/com/fs/speczone/util/WeChatTokenUtil.java
  35. 46 0
      fs-spec-zone/src/main/java/com/fs/speczone/util/WeComSignatureUtil.java
  36. 210 0
      fs-spec-zone/src/main/java/com/tencent/wework/SpecCallbackSDK.java
  37. 163 0
      fs-spec-zone/src/main/java/com/tencent/wework/SpecSDK.java
  38. 171 0
      fs-spec-zone/src/main/java/com/tencent/wework/SpecUtil.java
  39. 1 0
      pom.xml

+ 56 - 0
fs-admin/src/main/java/com/fs/qw/controller/CorporateWeChatSpaceController.java

@@ -0,0 +1,56 @@
+package com.fs.qw.controller;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.exception.CustomException;
+import com.fs.qw.service.ICorporateWeChatSpaceService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 企业微信专区-统一前端 API 接口
+ * */
+@RestController
+@RequestMapping("/weChatSpace")
+@RequiredArgsConstructor
+public class CorporateWeChatSpaceController extends BaseController {
+
+    private final ICorporateWeChatSpaceService weChatSpaceService;
+
+    // 企业微信会话专区中转接口
+    @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 CustomException("客户id不能为空");
+        }else if (staffUserId == null|| staffUserId.isEmpty()) {
+            throw new CustomException("员工id不能为空");
+        }
+        return weChatSpaceService.fetchConversations(seq, limit, proxy, timeout, customerId,staffUserId);
+    }
+
+
+    // agentConfig 签名
+    @GetMapping("/getAgentConfigSignature")
+    public JSONObject getAgentConfigSignature(@RequestParam("url") String url) {
+        return weChatSpaceService.getAgentConfigSignature(url);
+    }
+
+    // Web 登录
+    @PostMapping("/login")
+    public JSONObject login(@RequestBody JSONObject param) {
+        return weChatSpaceService.login(param.getString("code"));
+    }
+
+    //获取企业微信专区会话配置
+    @GetMapping("/getQwSessionConfig")
+    public AjaxResult getQwSessionConfig() {
+        return AjaxResult.success(weChatSpaceService.getQwSessionConfig());
+    }
+}

+ 9 - 0
fs-admin/src/main/java/com/fs/qw/controller/QwUserController.java

@@ -157,6 +157,15 @@ public class QwUserController extends BaseController {
         return getDataTable(list);
     }
 
+    /**
+     * 查询所有企微员工列表
+     * */
+    @PreAuthorize("@ss.hasPermi('qw:user:list')")
+    @GetMapping("/listAllQwUserList")
+    public AjaxResult listAllQwUserList(QwUser qwUser) {
+        List<QwUser> list = qwUserService.selectNotDelQwUserList(qwUser);
+        return AjaxResult.success(list);
+    }
 
     /**
      * 导出企微员工列表

+ 8 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwUserMapper.java

@@ -67,6 +67,14 @@ public interface QwUserMapper extends BaseMapper<QwUser>
      */
     public List<QwUser> selectQwUserList(QwUser qwUser);
 
+    /**
+     * 查询未删除的企微用户列表
+     *
+     * @param qwUser 企微用户
+     * @return 企微用户集合
+     */
+    public List<QwUser> selectNotDelQwUserList(QwUser qwUser);
+
     @Select("select qw_user_id,qw_user_name,login_code_url from qw_user" +
             " where company_id=#{companyId} " +
             "and company_user_id =#{userId} " +

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

@@ -0,0 +1,26 @@
+package com.fs.qw.service;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.qw.vo.QwSessionConfigVo;
+
+public interface ICorporateWeChatSpaceService {
+    /**
+     * 通过专区中转获取会话记录
+     */
+    JSONObject fetchConversations(long seq, long limit, long proxy, long timeout, String customerId,String staffUserId);
+
+    /**
+     * 获取 agentConfig 签名(供前端 JS-SDK 使用)
+     */
+    JSONObject getAgentConfigSignature(String url);
+
+    /**
+     * Web 登录(用 code 换取 userId)
+     */
+    JSONObject login(String code);
+
+    /**
+     * 获取企业微信专区会话配置
+     * */
+    QwSessionConfigVo getQwSessionConfig();
+}

+ 8 - 0
fs-service/src/main/java/com/fs/qw/service/IQwUserService.java

@@ -55,6 +55,14 @@ public interface IQwUserService
      */
     public List<QwUser> selectQwUserList(QwUser qwUser);
 
+    /**
+     * 查询未删除企微用户列表
+     *
+     * @param qwUser 企微用户
+     * @return 企微用户集合
+     */
+    public List<QwUser> selectNotDelQwUserList(QwUser qwUser);
+
     /**
      * 新增企微用户
      *

+ 158 - 0
fs-service/src/main/java/com/fs/qw/service/impl/ICorporateWeChatSpaceServiceImpl.java

@@ -0,0 +1,158 @@
+package com.fs.qw.service.impl;
+
+
+import com.alibaba.fastjson.JSON;
+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.vo.QwSessionConfigVo;
+import com.fs.system.service.ISysConfigService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ICorporateWeChatSpaceServiceImpl implements ICorporateWeChatSpaceService {
+
+    @Autowired
+    private ISysConfigService sysConfigService;
+
+    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<>();
+
+    // =============== 核心:通过专区中转拉取会话 ===============
+    @Override
+    public JSONObject fetchConversations(long seq, long limit, long proxy, long timeout,
+                                         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);
+
+            // 3. 构建 request_data(与专区程序约定一致)
+            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未配置");
+            }
+            // 5. 调用 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("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);
+
+            // 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) {
+                        result.put("errcode", 0);
+                        result.put("errmsg", "ok");
+                        result.put("msgList", responseData.get("data"));
+                    } else {
+                        result.put("errcode", responseData.getInteger("errcode"));
+                        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") : "专区调用失败");
+            }
+        } catch (Exception e) {
+            log.error("专区中转调用异常", e);
+            result.put("errcode", -1);
+            result.put("errmsg", "内部错误:" + e.getMessage());
+        }
+        return result;
+    }
+
+
+    @Override
+    public JSONObject getAgentConfigSignature(String url) {
+        QwSessionConfigVo qwSessionConfig = getQwSessionConfig();
+        return WeComSignatureUtil.generateAgentConfigSignature(qwSessionConfig.getCorpid(), qwSessionConfig.getAgentSecret(), qwSessionConfig.getAgentid(), url);
+    }
+
+    @Override
+    public JSONObject login(String code) {
+        JSONObject result = new JSONObject();
+        if (code == null || code.isEmpty()) {
+            result.put("errcode", -1);
+            result.put("errmsg", "missing code");
+            return result;
+        }
+        if (consumedCodes.containsKey(code)) {
+            result.put("errcode", 40029);
+            result.put("errmsg", "code already used");
+            return result;
+        }
+        QwSessionConfigVo qwSessionConfig = getQwSessionConfig();
+        try {
+            String accessToken = WeChatTokenUtil.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);
+            if (resp != null && resp.getIntValue("errcode") == 0) {
+                consumedCodes.put(code, "used");
+                if (consumedCodes.size() > 1000) consumedCodes.clear();
+
+                JSONObject userInfo = new JSONObject();
+                userInfo.put("userid", resp.getString("UserId"));
+                result.put("errcode", 0);
+                result.put("errmsg", "ok");
+                result.put("data", userInfo);
+            } else {
+                result.put("errcode", resp != null ? resp.getIntValue("errcode") : -1);
+                result.put("errmsg", resp != null ? resp.getString("errmsg") : "fail");
+            }
+        } catch (Exception e) {
+            log.error("登录异常", e);
+            result.put("errcode", -1);
+            result.put("errmsg", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 获取企业微信专区会话配置
+     */
+    @Override
+    public QwSessionConfigVo getQwSessionConfig() {
+        QwSessionConfigVo qwSessionConfig = sysConfigService.getConfig(CONFIG_KEY, QwSessionConfigVo.class);
+        if (qwSessionConfig == null){
+            log.error("未找到企微专区配置,key:{}",CONFIG_KEY);
+            throw new CustomException("未找到企微专区配置");
+        }
+        return qwSessionConfig;
+    }
+}

+ 11 - 0
fs-service/src/main/java/com/fs/qw/service/impl/QwUserServiceImpl.java

@@ -848,6 +848,17 @@ public class QwUserServiceImpl implements IQwUserService
     {
         return qwUserMapper.selectQwUserList(qwUser);
     }
+    /**
+     * 查询未删除企微用户列表
+     *
+     * @param qwUser 企微用户
+     * @return 企微用户
+     */
+    @Override
+    public List<QwUser> selectNotDelQwUserList(QwUser qwUser)
+    {
+        return qwUserMapper.selectNotDelQwUserList(qwUser);
+    }
 
     /**
      * 新增企微用户

+ 61 - 0
fs-service/src/main/java/com/fs/qw/utils/WeChatTokenUtil.java

@@ -0,0 +1,61 @@
+package com.fs.qw.utils;
+
+import com.alibaba.fastjson.JSONObject;
+import org.springframework.web.client.RestTemplate;
+
+/**
+ * 企业微信 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,也可类似实现,但本场景不需要
+}

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

@@ -0,0 +1,38 @@
+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);
+        }
+    }
+}

+ 4 - 0
fs-service/src/main/java/com/fs/qw/vo/QwExternalContactVO.java

@@ -168,4 +168,8 @@ public class QwExternalContactVO {
      */
     private String intentionDegree;
 
+    private String qwUserId;
+
+    private Integer deptId;
+
 }

+ 27 - 0
fs-service/src/main/java/com/fs/qw/vo/QwSessionConfigVo.java

@@ -0,0 +1,27 @@
+package com.fs.qw.vo;
+
+import lombok.Data;
+/**
+ * 企业微信专区配置-会话
+ * */
+@Data
+public class QwSessionConfigVo {
+
+    private String corpid;
+
+    private String agentid;
+
+    private String agentSecret;
+
+    // 会话专区查询会话记录能力id
+    private String fetchConversationAbilityId;
+
+    //专区程序ID
+    private String programId;
+
+    //专区程序能力action
+    private String abilityAction;
+
+    //自建应用可信域名
+    private String domain;
+}

+ 22 - 0
fs-service/src/main/java/com/fs/qwApi/domain/SpecProgramCallRequest.java

@@ -0,0 +1,22 @@
+package com.fs.qwApi.domain;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+@Data
+@ApiModel("专区程序调用请求")
+public class SpecProgramCallRequest {
+
+    @ApiModelProperty("程序能力ID,在企微管理后台配置专区程序时填写的")
+    private String abilityId;
+
+    @ApiModelProperty("输入协议数据,JSON格式,与专区程序约定的输入协议对应")
+    private String inputProtocol;
+
+    @ApiModelProperty("是否异步调用,true-异步 false-同步")
+    private Boolean async;
+
+    @ApiModelProperty("企业CorpID")
+    private String corpId;
+}

+ 27 - 0
fs-service/src/main/java/com/fs/qwApi/domain/SpecProgramCallResponse.java

@@ -0,0 +1,27 @@
+package com.fs.qwApi.domain;
+
+import com.google.gson.annotations.SerializedName;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+@Data
+@ApiModel("专区程序调用响应")
+public class SpecProgramCallResponse {
+
+    @ApiModelProperty("错误码")
+    @SerializedName("errcode")
+    private Integer errcode;
+
+    @ApiModelProperty("错误信息")
+    @SerializedName("errmsg")
+    private String errmsg;
+
+    @ApiModelProperty("任务ID,异步调用时返回,用于后续查询结果")
+    @SerializedName("task_id")
+    private String taskId;
+
+    @ApiModelProperty("输出协议数据,同步调用时直接返回专区程序的处理结果")
+    @SerializedName("output_protocol")
+    private String outputProtocol;
+}

+ 22 - 0
fs-service/src/main/java/com/fs/qwApi/domain/SpecProgramResultResponse.java

@@ -0,0 +1,22 @@
+package com.fs.qwApi.domain;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+@Data
+@ApiModel("专区程序异步任务查询响应")
+public class SpecProgramResultResponse {
+
+    @ApiModelProperty("错误码")
+    private Integer errcode;
+
+    @ApiModelProperty("错误信息")
+    private String errmsg;
+
+    @ApiModelProperty("任务状态:1-处理中,2-处理完成,3-处理失败")
+    private Integer status;
+
+    @ApiModelProperty("专区程序返回的输出协议数据")
+    private String outputProtocol;
+}

+ 5 - 0
fs-service/src/main/java/com/fs/system/service/ISysConfigService.java

@@ -89,4 +89,9 @@ public interface ISysConfigService
     public String checkConfigKeyUnique(SysConfig config);
 
     SysConfig selectConfigByConfigKey(String configKey);
+
+    /**
+     * 根据key获取配置
+     * */
+    public <T> T getConfig(String configKey, Class<T> clazz);
 }

+ 19 - 0
fs-service/src/main/java/com/fs/system/service/impl/SysConfigServiceImpl.java

@@ -1,5 +1,7 @@
 package com.fs.system.service.impl;
 
+
+import com.alibaba.fastjson.JSON;
 import com.fs.common.annotation.DataSource;
 import com.fs.common.constant.Constants;
 import com.fs.common.constant.UserConstants;
@@ -11,6 +13,7 @@ import com.fs.common.utils.StringUtils;
 import com.fs.system.domain.SysConfig;
 import com.fs.system.mapper.SysConfigMapper;
 import com.fs.system.service.ISysConfigService;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import javax.annotation.PostConstruct;
@@ -24,6 +27,7 @@ import java.util.concurrent.ConcurrentHashMap;
  *
 
  */
+@Slf4j
 @Service
 @ThreadSafe
 public class SysConfigServiceImpl implements ISysConfigService
@@ -238,6 +242,21 @@ public class SysConfigServiceImpl implements ISysConfigService
         return configMapper.selectConfigByConfigKey(configKey);
     }
 
+    @Override
+    public <T> T getConfig(String configKey, Class<T> clazz) {
+        String json = selectConfigByKey(configKey);
+        if (StringUtils.isBlank(json)) {
+            log.warn("系统配置为空, configKey={}", configKey);
+            return null;
+        }
+        try {
+            return JSON.parseObject(json, clazz);
+        } catch (Exception e) {
+            log.error("解析系统配置失败, configKey={}, json={}", configKey, json, e);
+            return null;
+        }
+    }
+
     /**
      * 设置cache key
      *

+ 1 - 1
fs-service/src/main/resources/mapper/qw/QwExternalContactMapper.xml

@@ -663,7 +663,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         where fs_user_id = #{userId} and company_user_id = #{companyUserId}
     </select>
     <select id="selectQwExternalContactListVONewSys" resultType="com.fs.qw.vo.QwExternalContactVO">
-            select ec.*, qu.qw_user_name, qd.dept_name as departmentName
+        select ec.*, qu.qw_user_id as qwUserId,qu.qw_user_name, qd.dept_name as departmentName,qd.dept_id as deptId
             from qw_external_contact ec
             left join qw_user qu on ec.user_id = qu.qw_user_id and qu.corp_id = ec.corp_id
             left join qw_dept qd on qd.dept_id = qu.department and qd.corp_id = qu.corp_id

+ 5 - 0
fs-service/src/main/resources/mapper/qw/QwUserMapper.xml

@@ -71,6 +71,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </where>
     </select>
 
+    <select id="selectNotDelQwUserList" parameterType="QwUser" resultMap="QwUserResult">
+    <include refid="selectQwUserVo"/>
+    where is_del = 0
+    </select>
+
     <select id="selectQwUserById" parameterType="Long" resultMap="QwUserResult">
         <include refid="selectQwUserVo"/>
         where id = #{id}

+ 12 - 0
fs-spec-zone/Dockerfile

@@ -0,0 +1,12 @@
+FROM alpine:3.18
+
+RUN apk add --no-cache openjdk8-jre openssl3
+
+WORKDIR /app
+COPY target/fs-spec-zone-1.0.0.jar app.jar
+COPY libWeWorkSpecSDK.so /usr/lib/
+
+ENV JAVA_OPTS="-Xms256m -Xmx512m"
+
+EXPOSE 8080
+ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -Djava.library.path=/usr/lib -jar app.jar"]

BIN
fs-spec-zone/libWeWorkSpecSDK.so


+ 75 - 0
fs-spec-zone/pom.xml

@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <groupId>com.fs</groupId>
+    <artifactId>fs-spec-zone</artifactId>
+    <version>1.0.0</version>
+    <packaging>jar</packaging>
+    <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>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>fastjson</artifactId>
+            <version>1.2.83</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <optional>true</optional>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </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>
+                <configuration>
+                    <mainClass>com.fs.speczone.SpecZoneApplication</mainClass>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>

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

@@ -0,0 +1,32 @@
+# 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生成本地方法头文件

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

@@ -0,0 +1,43 @@
+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);
+        }
+    }
+}

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

@@ -0,0 +1,13 @@
+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);
+    }
+}

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

@@ -0,0 +1,237 @@
+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");
+    }
+}

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

@@ -0,0 +1,33 @@
+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);
+    }
+}

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

@@ -0,0 +1,25 @@
+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);
+    }
+}

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

@@ -0,0 +1,22 @@
+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);
+}

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

@@ -0,0 +1,155 @@
+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);
+    }
+}

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

@@ -0,0 +1,132 @@
+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;
+    }
+}

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

@@ -0,0 +1,11 @@
+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);
+
+}

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

@@ -0,0 +1,149 @@
+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;
+    }
+}

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

@@ -0,0 +1,65 @@
+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);
+    }
+}

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

@@ -0,0 +1,63 @@
+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,也可类似实现,但本场景不需要
+}

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

@@ -0,0 +1,46 @@
+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);
+        }
+    }
+}

+ 210 - 0
fs-spec-zone/src/main/java/com/tencent/wework/SpecCallbackSDK.java

@@ -0,0 +1,210 @@
+package com.tencent.wework;
+
+import java.util.Map;
+import java.util.HashMap;;
+
+/**
+ * @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-spec-zone/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();
+        }
+    }
+}

+ 171 - 0
fs-spec-zone/src/main/java/com/tencent/wework/SpecUtil.java

@@ -0,0 +1,171 @@
+package com.tencent.wework;
+
+import java.lang.management.ManagementFactory;
+import java.lang.management.RuntimeMXBean;
+
+/**
+ * @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);
+    }
+}

+ 1 - 0
pom.xml

@@ -310,6 +310,7 @@
         <module>fs-qwhook-msg</module>
         <module>fs-qw-mq</module>
         <module>fs-repeat-api</module>
+        <module>fs-spec-zone</module>
         <module>fs-ipad-task</module>
         <module>fs-websocket</module>
         <module>fs-ad-new-api</module>