Browse Source

:new: #1952 增加腾讯企点子模块,用于对接企点开放平台。

fanxiayang12 4 years ago
parent
commit
e7f2bd62f8
54 changed files with 3928 additions and 12 deletions
  1. 3 0
      .gitignore
  2. 2 4
      pom.xml
  3. 2 3
      spring-boot-starters/pom.xml
  4. 1 1
      spring-boot-starters/wx-java-mp-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/mp/config/WxMpAutoConfiguration.java
  5. 3 3
      spring-boot-starters/wx-java-mp-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/mp/config/WxMpStorageAutoConfiguration.java
  6. 0 1
      spring-boot-starters/wx-java-mp-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/mp/properties/WxMpProperties.java
  7. 45 0
      spring-boot-starters/wx-java-qidian-spring-boot-starter/README.md
  8. 66 0
      spring-boot-starters/wx-java-qidian-spring-boot-starter/pom.xml
  9. 17 0
      spring-boot-starters/wx-java-qidian-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/qidian/config/WxQidianAutoConfiguration.java
  10. 63 0
      spring-boot-starters/wx-java-qidian-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/qidian/config/WxQidianServiceAutoConfiguration.java
  11. 166 0
      spring-boot-starters/wx-java-qidian-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/qidian/config/WxQidianStorageAutoConfiguration.java
  12. 22 0
      spring-boot-starters/wx-java-qidian-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/qidian/enums/HttpClientType.java
  13. 22 0
      spring-boot-starters/wx-java-qidian-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/qidian/enums/StorageType.java
  14. 18 0
      spring-boot-starters/wx-java-qidian-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/qidian/properties/HostConfig.java
  15. 56 0
      spring-boot-starters/wx-java-qidian-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/qidian/properties/RedisProperties.java
  16. 99 0
      spring-boot-starters/wx-java-qidian-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/qidian/properties/WxQidianProperties.java
  17. 1 0
      spring-boot-starters/wx-java-qidian-spring-boot-starter/src/main/resources/META-INF/spring.factories
  18. 201 0
      weixin-java-qidian/LICENSE
  19. 134 0
      weixin-java-qidian/pom.xml
  20. 13 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/api/WxQidianCallDataService.java
  21. 18 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/api/WxQidianDialService.java
  22. 348 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/api/WxQidianService.java
  23. 420 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/api/impl/BaseWxQidianServiceImpl.java
  24. 23 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/api/impl/WxQidianCallDataServiceImpl.java
  25. 43 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/api/impl/WxQidianDialServiceImpl.java
  26. 106 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/api/impl/WxQidianServiceHttpClientImpl.java
  27. 12 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/api/impl/WxQidianServiceImpl.java
  28. 90 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/api/impl/WxQidianServiceJoddHttpImpl.java
  29. 98 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/api/impl/WxQidianServiceOkHttpImpl.java
  30. 56 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/bean/WxQidianHostConfig.java
  31. 14 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/bean/call/GetSwitchBoardListResponse.java
  32. 13 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/bean/call/SwitchBoard.java
  33. 15 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/bean/call/SwitchBoardList.java
  34. 109 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/bean/common/QidianResponse.java
  35. 28 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/bean/dial/IVRDialRequest.java
  36. 20 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/bean/dial/IVRDialResponse.java
  37. 16 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/bean/dial/IVRListResponse.java
  38. 9 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/bean/dial/Ivr.java
  39. 210 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/config/WxQidianConfigStorage.java
  40. 196 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/config/impl/WxQidianDefaultConfigImpl.java
  41. 99 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/config/impl/WxQidianRedisConfigImpl.java
  42. 101 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/config/impl/WxQidianRedissonConfigImpl.java
  43. 155 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/enums/WxQidianApiUrl.java
  44. 29 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/util/WxQidianConfigStorageHolder.java
  45. 21 0
      weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/util/json/WxQidianGsonBuilder.java
  46. 64 0
      weixin-java-qidian/src/test/java/me/chanjar/weixin/qidian/api/WxMpBusyRetryTest.java
  47. 37 0
      weixin-java-qidian/src/test/java/me/chanjar/weixin/qidian/api/WxMpJsAPITest.java
  48. 407 0
      weixin-java-qidian/src/test/java/me/chanjar/weixin/qidian/api/impl/BaseWxQidianServiceImplTest.java
  49. 58 0
      weixin-java-qidian/src/test/java/me/chanjar/weixin/qidian/api/impl/WxQidianDialServiceImplTest.java
  50. 51 0
      weixin-java-qidian/src/test/java/me/chanjar/weixin/qidian/api/test/ApiTestModule.java
  51. 69 0
      weixin-java-qidian/src/test/java/me/chanjar/weixin/qidian/api/test/TestConfigStorage.java
  52. 13 0
      weixin-java-qidian/src/test/resources/logback-test.xml
  53. 16 0
      weixin-java-qidian/src/test/resources/test-config.sample.xml
  54. 30 0
      weixin-java-qidian/src/test/resources/testng.xml

+ 3 - 0
.gitignore

@@ -1,3 +1,6 @@
+.bash
+.history
+
 *.class
 test-output
 

+ 2 - 4
pom.xml

@@ -1,8 +1,5 @@
 <?xml version="1.0"?>
-<project
-  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"
-  xmlns="http://maven.apache.org/POM/4.0.0">
+<project 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" xmlns="http://maven.apache.org/POM/4.0.0">
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.github.binarywang</groupId>
   <artifactId>wx-java</artifactId>
@@ -111,6 +108,7 @@
     <module>weixin-java-pay</module>
     <module>weixin-java-miniapp</module>
     <module>weixin-java-open</module>
+    <module>weixin-java-qidian</module>
     <module>spring-boot-starters</module>
     <!--module>weixin-java-osgi</module-->
   </modules>

+ 2 - 3
spring-boot-starters/pom.xml

@@ -1,7 +1,5 @@
 <?xml version="1.0"?>
-<project 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"
-         xmlns="http://maven.apache.org/POM/4.0.0">
+<project 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" xmlns="http://maven.apache.org/POM/4.0.0">
   <modelVersion>4.0.0</modelVersion>
   <parent>
     <groupId>com.github.binarywang</groupId>
@@ -22,6 +20,7 @@
     <module>wx-java-mp-spring-boot-starter</module>
     <module>wx-java-pay-spring-boot-starter</module>
     <module>wx-java-open-spring-boot-starter</module>
+    <module>wx-java-qidian-spring-boot-starter</module>
   </modules>
 
   <dependencies>

+ 1 - 1
spring-boot-starters/wx-java-mp-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/mp/config/WxMpAutoConfiguration.java

@@ -12,6 +12,6 @@ import org.springframework.context.annotation.Import;
  */
 @Configuration
 @EnableConfigurationProperties(WxMpProperties.class)
-@Import({WxMpStorageAutoConfiguration.class, WxMpServiceAutoConfiguration.class})
+@Import({ WxMpStorageAutoConfiguration.class, WxMpServiceAutoConfiguration.class })
 public class WxMpAutoConfiguration {
 }

+ 3 - 3
spring-boot-starters/wx-java-mp-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/mp/config/WxMpStorageAutoConfiguration.java

@@ -88,7 +88,7 @@ public class WxMpStorageAutoConfiguration {
     }
     WxRedisOps redisOps = new JedisWxRedisOps(jedisPool);
     WxMpRedisConfigImpl wxMpRedisConfig = new WxMpRedisConfigImpl(redisOps,
-      wxMpProperties.getConfigStorage().getKeyPrefix());
+        wxMpProperties.getConfigStorage().getKeyPrefix());
     setWxMpInfo(wxMpRedisConfig);
     return wxMpRedisConfig;
   }
@@ -114,7 +114,7 @@ public class WxMpStorageAutoConfiguration {
 
     WxRedisOps redisOps = new RedisTemplateWxRedisOps(redisTemplate);
     WxMpRedisConfigImpl wxMpRedisConfig = new WxMpRedisConfigImpl(redisOps,
-      wxMpProperties.getConfigStorage().getKeyPrefix());
+        wxMpProperties.getConfigStorage().getKeyPrefix());
 
     setWxMpInfo(wxMpRedisConfig);
     return wxMpRedisConfig;
@@ -160,6 +160,6 @@ public class WxMpStorageAutoConfiguration {
     }
 
     return new JedisPool(config, redis.getHost(), redis.getPort(), redis.getTimeout(), redis.getPassword(),
-      redis.getDatabase());
+        redis.getDatabase());
   }
 }

+ 0 - 1
spring-boot-starters/wx-java-mp-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/mp/properties/WxMpProperties.java

@@ -11,7 +11,6 @@ import java.io.Serializable;
 import static com.binarywang.spring.starter.wxjava.mp.enums.StorageType.Memory;
 import static com.binarywang.spring.starter.wxjava.mp.properties.WxMpProperties.PREFIX;
 
-
 /**
  * 微信接入相关配置属性.
  *

+ 45 - 0
spring-boot-starters/wx-java-qidian-spring-boot-starter/README.md

@@ -0,0 +1,45 @@
+# wx-java-qidian-spring-boot-starter
+
+## 快速开始
+
+1. 引入依赖
+   ```xml
+   <dependency>
+       <groupId>com.github.binarywang</groupId>
+       <artifactId>wx-java-qidian-spring-boot-starter</artifactId>
+       <version>${version}</version>
+   </dependency>
+   ```
+2. 添加配置(application.properties)
+   ```properties
+   # 公众号配置(必填)
+   wx.mp.appId = appId
+   wx.mp.secret = @secret
+   wx.mp.token = @token
+   wx.mp.aesKey = @aesKey
+   # 存储配置redis(可选)
+   wx.mp.config-storage.type = Jedis                     # 配置类型: Memory(默认), Jedis, RedisTemplate
+   wx.mp.config-storage.key-prefix = wx                  # 相关redis前缀配置: wx(默认)
+   wx.mp.config-storage.redis.host = 127.0.0.1
+   wx.mp.config-storage.redis.port = 6379
+   #单机和sentinel同时存在时,优先使用sentinel配置
+   #wx.mp.config-storage.redis.sentinel-ips=127.0.0.1:16379,127.0.0.1:26379
+   #wx.mp.config-storage.redis.sentinel-name=mymaster
+   # http客户端配置
+   wx.mp.config-storage.http-client-type=httpclient      # http客户端类型: HttpClient(默认), OkHttp, JoddHttp
+   wx.mp.config-storage.http-proxy-host=
+   wx.mp.config-storage.http-proxy-port=
+   wx.mp.config-storage.http-proxy-username=
+   wx.mp.config-storage.http-proxy-password=
+   # 公众号地址host配置
+   #wx.mp.hosts.api-host=http://proxy.com/
+   #wx.mp.hosts.open-host=http://proxy.com/
+   #wx.mp.hosts.mp-host=http://proxy.com/
+   ```
+3. 自动注入的类型
+
+- `WxMpService`
+- `WxMpConfigStorage`
+
+4、参考 demo:
+https://github.com/binarywang/wx-java-mp-demo

+ 66 - 0
spring-boot-starters/wx-java-qidian-spring-boot-starter/pom.xml

@@ -0,0 +1,66 @@
+<?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">
+  <parent>
+    <artifactId>wx-java-spring-boot-starters</artifactId>
+    <groupId>com.github.binarywang</groupId>
+    <version>4.0.1.B</version>
+  </parent>
+  <modelVersion>4.0.0</modelVersion>
+
+  <artifactId>wx-java-qidian-spring-boot-starter</artifactId>
+  <name>WxJava - Spring Boot Starter for QiDian</name>
+  <description>腾讯企点的 Spring Boot Starter</description>
+
+  <dependencies>
+    <dependency>
+      <groupId>com.github.binarywang</groupId>
+      <artifactId>weixin-java-qidian</artifactId>
+      <version>${project.version}</version>
+    </dependency>
+    <dependency>
+      <groupId>redis.clients</groupId>
+      <artifactId>jedis</artifactId>
+      <scope>compile</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.springframework.data</groupId>
+      <artifactId>spring-data-redis</artifactId>
+      <version>${spring.boot.version}</version>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.jodd</groupId>
+      <artifactId>jodd-http</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>com.squareup.okhttp3</groupId>
+      <artifactId>okhttp</artifactId>
+      <scope>provided</scope>
+    </dependency>
+  </dependencies>
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.springframework.boot</groupId>
+        <artifactId>spring-boot-maven-plugin</artifactId>
+        <version>${spring.boot.version}</version>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-source-plugin</artifactId>
+        <version>2.2.1</version>
+        <executions>
+          <execution>
+            <id>attach-sources</id>
+            <goals>
+              <goal>jar-no-fork</goal>
+            </goals>
+          </execution>
+        </executions>
+      </plugin>
+    </plugins>
+  </build>
+
+</project>

+ 17 - 0
spring-boot-starters/wx-java-qidian-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/qidian/config/WxQidianAutoConfiguration.java

@@ -0,0 +1,17 @@
+package com.binarywang.spring.starter.wxjava.qidian.config;
+
+import com.binarywang.spring.starter.wxjava.qidian.properties.WxQidianProperties;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+
+/**
+ * .
+ *
+ * @author someone
+ */
+@Configuration
+@EnableConfigurationProperties(WxQidianProperties.class)
+@Import({ WxQidianStorageAutoConfiguration.class, WxQidianServiceAutoConfiguration.class })
+public class WxQidianAutoConfiguration {
+}

+ 63 - 0
spring-boot-starters/wx-java-qidian-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/qidian/config/WxQidianServiceAutoConfiguration.java

@@ -0,0 +1,63 @@
+package com.binarywang.spring.starter.wxjava.qidian.config;
+
+import com.binarywang.spring.starter.wxjava.qidian.enums.HttpClientType;
+import com.binarywang.spring.starter.wxjava.qidian.properties.WxQidianProperties;
+import me.chanjar.weixin.qidian.api.WxQidianService;
+import me.chanjar.weixin.qidian.api.impl.WxQidianServiceHttpClientImpl;
+import me.chanjar.weixin.qidian.api.impl.WxQidianServiceImpl;
+import me.chanjar.weixin.qidian.api.impl.WxQidianServiceJoddHttpImpl;
+import me.chanjar.weixin.qidian.api.impl.WxQidianServiceOkHttpImpl;
+import me.chanjar.weixin.qidian.config.WxQidianConfigStorage;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 腾讯企点相关服务自动注册.
+ *
+ * @author alegria
+ */
+@Configuration
+public class WxQidianServiceAutoConfiguration {
+
+  @Bean
+  @ConditionalOnMissingBean
+  public WxQidianService wxQidianService(WxQidianConfigStorage configStorage, WxQidianProperties wxQidianProperties) {
+    HttpClientType httpClientType = wxQidianProperties.getConfigStorage().getHttpClientType();
+    WxQidianService wxQidianService;
+    switch (httpClientType) {
+      case OkHttp:
+        wxQidianService = newWxQidianServiceOkHttpImpl();
+        break;
+      case JoddHttp:
+        wxQidianService = newWxQidianServiceJoddHttpImpl();
+        break;
+      case HttpClient:
+        wxQidianService = newWxQidianServiceHttpClientImpl();
+        break;
+      default:
+        wxQidianService = newWxQidianServiceImpl();
+        break;
+    }
+
+    wxQidianService.setWxMpConfigStorage(configStorage);
+    return wxQidianService;
+  }
+
+  private WxQidianService newWxQidianServiceImpl() {
+    return new WxQidianServiceImpl();
+  }
+
+  private WxQidianService newWxQidianServiceHttpClientImpl() {
+    return new WxQidianServiceHttpClientImpl();
+  }
+
+  private WxQidianService newWxQidianServiceOkHttpImpl() {
+    return new WxQidianServiceOkHttpImpl();
+  }
+
+  private WxQidianService newWxQidianServiceJoddHttpImpl() {
+    return new WxQidianServiceJoddHttpImpl();
+  }
+
+}

+ 166 - 0
spring-boot-starters/wx-java-qidian-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/qidian/config/WxQidianStorageAutoConfiguration.java

@@ -0,0 +1,166 @@
+package com.binarywang.spring.starter.wxjava.qidian.config;
+
+import com.binarywang.spring.starter.wxjava.qidian.enums.StorageType;
+import com.binarywang.spring.starter.wxjava.qidian.properties.RedisProperties;
+import com.binarywang.spring.starter.wxjava.qidian.properties.WxQidianProperties;
+import com.google.common.collect.Sets;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.common.redis.JedisWxRedisOps;
+import me.chanjar.weixin.common.redis.RedisTemplateWxRedisOps;
+import me.chanjar.weixin.common.redis.WxRedisOps;
+import me.chanjar.weixin.qidian.bean.WxQidianHostConfig;
+import me.chanjar.weixin.qidian.config.WxQidianConfigStorage;
+import me.chanjar.weixin.qidian.config.impl.WxQidianDefaultConfigImpl;
+import me.chanjar.weixin.qidian.config.impl.WxQidianRedisConfigImpl;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import redis.clients.jedis.JedisPool;
+import redis.clients.jedis.JedisPoolAbstract;
+import redis.clients.jedis.JedisPoolConfig;
+import redis.clients.jedis.JedisSentinelPool;
+
+import java.util.Set;
+
+/**
+ * 腾讯企点存储策略自动配置.
+ *
+ * @author alegria
+ */
+@Slf4j
+@Configuration
+@RequiredArgsConstructor
+public class WxQidianStorageAutoConfiguration {
+  private final ApplicationContext applicationContext;
+
+  private final WxQidianProperties wxQidianProperties;
+
+  @Value("${wx.mp.config-storage.redis.host:")
+  private String redisHost;
+
+  @Value("${wx.mp.configStorage.redis.host:")
+  private String redisHost2;
+
+  @Bean
+  @ConditionalOnMissingBean(WxQidianConfigStorage.class)
+  public WxQidianConfigStorage wxQidianConfigStorage() {
+    StorageType type = wxQidianProperties.getConfigStorage().getType();
+    WxQidianConfigStorage config;
+    switch (type) {
+      case Jedis:
+        config = jedisConfigStorage();
+        break;
+      case RedisTemplate:
+        config = redisTemplateConfigStorage();
+        break;
+      default:
+        config = defaultConfigStorage();
+        break;
+    }
+    // wx host config
+    if (null != wxQidianProperties.getHosts() && StringUtils.isNotEmpty(wxQidianProperties.getHosts().getApiHost())) {
+      WxQidianHostConfig hostConfig = new WxQidianHostConfig();
+      hostConfig.setApiHost(wxQidianProperties.getHosts().getApiHost());
+      hostConfig.setQidianHost(wxQidianProperties.getHosts().getQidianHost());
+      hostConfig.setOpenHost(wxQidianProperties.getHosts().getOpenHost());
+      config.setHostConfig(hostConfig);
+    }
+    return config;
+  }
+
+  private WxQidianConfigStorage defaultConfigStorage() {
+    WxQidianDefaultConfigImpl config = new WxQidianDefaultConfigImpl();
+    setWxMpInfo(config);
+    return config;
+  }
+
+  private WxQidianConfigStorage jedisConfigStorage() {
+    JedisPoolAbstract jedisPool;
+    if (StringUtils.isNotEmpty(redisHost) || StringUtils.isNotEmpty(redisHost2)) {
+      jedisPool = getJedisPool();
+    } else {
+      jedisPool = applicationContext.getBean(JedisPool.class);
+    }
+    WxRedisOps redisOps = new JedisWxRedisOps(jedisPool);
+    WxQidianRedisConfigImpl wxQidianRedisConfig = new WxQidianRedisConfigImpl(redisOps,
+        wxQidianProperties.getConfigStorage().getKeyPrefix());
+    setWxMpInfo(wxQidianRedisConfig);
+    return wxQidianRedisConfig;
+  }
+
+  private WxQidianConfigStorage redisTemplateConfigStorage() {
+    StringRedisTemplate redisTemplate = null;
+    try {
+      redisTemplate = applicationContext.getBean(StringRedisTemplate.class);
+    } catch (Exception e) {
+      log.error(e.getMessage(), e);
+    }
+    try {
+      if (null == redisTemplate) {
+        redisTemplate = (StringRedisTemplate) applicationContext.getBean("stringRedisTemplate");
+      }
+    } catch (Exception e) {
+      log.error(e.getMessage(), e);
+    }
+
+    if (null == redisTemplate) {
+      redisTemplate = (StringRedisTemplate) applicationContext.getBean("redisTemplate");
+    }
+
+    WxRedisOps redisOps = new RedisTemplateWxRedisOps(redisTemplate);
+    WxQidianRedisConfigImpl wxMpRedisConfig = new WxQidianRedisConfigImpl(redisOps,
+        wxQidianProperties.getConfigStorage().getKeyPrefix());
+
+    setWxMpInfo(wxMpRedisConfig);
+    return wxMpRedisConfig;
+  }
+
+  private void setWxMpInfo(WxQidianDefaultConfigImpl config) {
+    WxQidianProperties properties = wxQidianProperties;
+    WxQidianProperties.ConfigStorage configStorageProperties = properties.getConfigStorage();
+    config.setAppId(properties.getAppId());
+    config.setSecret(properties.getSecret());
+    config.setToken(properties.getToken());
+    config.setAesKey(properties.getAesKey());
+
+    config.setHttpProxyHost(configStorageProperties.getHttpProxyHost());
+    config.setHttpProxyUsername(configStorageProperties.getHttpProxyUsername());
+    config.setHttpProxyPassword(configStorageProperties.getHttpProxyPassword());
+    if (configStorageProperties.getHttpProxyPort() != null) {
+      config.setHttpProxyPort(configStorageProperties.getHttpProxyPort());
+    }
+  }
+
+  private JedisPoolAbstract getJedisPool() {
+    WxQidianProperties.ConfigStorage storage = wxQidianProperties.getConfigStorage();
+    RedisProperties redis = storage.getRedis();
+
+    JedisPoolConfig config = new JedisPoolConfig();
+    if (redis.getMaxActive() != null) {
+      config.setMaxTotal(redis.getMaxActive());
+    }
+    if (redis.getMaxIdle() != null) {
+      config.setMaxIdle(redis.getMaxIdle());
+    }
+    if (redis.getMaxWaitMillis() != null) {
+      config.setMaxWaitMillis(redis.getMaxWaitMillis());
+    }
+    if (redis.getMinIdle() != null) {
+      config.setMinIdle(redis.getMinIdle());
+    }
+    config.setTestOnBorrow(true);
+    config.setTestWhileIdle(true);
+    if (StringUtils.isNotEmpty(redis.getSentinelIps())) {
+      Set<String> sentinels = Sets.newHashSet(redis.getSentinelIps().split(","));
+      return new JedisSentinelPool(redis.getSentinelName(), sentinels);
+    }
+
+    return new JedisPool(config, redis.getHost(), redis.getPort(), redis.getTimeout(), redis.getPassword(),
+        redis.getDatabase());
+  }
+}

+ 22 - 0
spring-boot-starters/wx-java-qidian-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/qidian/enums/HttpClientType.java

@@ -0,0 +1,22 @@
+package com.binarywang.spring.starter.wxjava.qidian.enums;
+
+/**
+ * httpclient类型.
+ *
+ * @author <a href="https://github.com/binarywang">Binary Wang</a>
+ * @date 2020-08-30
+ */
+public enum HttpClientType {
+  /**
+   * HttpClient.
+   */
+  HttpClient,
+  /**
+   * OkHttp.
+   */
+  OkHttp,
+  /**
+   * JoddHttp.
+   */
+  JoddHttp,
+}

+ 22 - 0
spring-boot-starters/wx-java-qidian-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/qidian/enums/StorageType.java

@@ -0,0 +1,22 @@
+package com.binarywang.spring.starter.wxjava.qidian.enums;
+
+/**
+ * storage类型.
+ *
+ * @author <a href="https://github.com/binarywang">Binary Wang</a>
+ * @date 2020-08-30
+ */
+public enum StorageType {
+  /**
+   * 内存.
+   */
+  Memory,
+  /**
+   * redis(JedisClient).
+   */
+  Jedis,
+  /**
+   * redis(RedisTemplate).
+   */
+  RedisTemplate
+}

+ 18 - 0
spring-boot-starters/wx-java-qidian-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/qidian/properties/HostConfig.java

@@ -0,0 +1,18 @@
+package com.binarywang.spring.starter.wxjava.qidian.properties;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+public class HostConfig implements Serializable {
+
+  private static final long serialVersionUID = -4172767630740346001L;
+
+  private String apiHost;
+
+  private String openHost;
+
+  private String qidianHost;
+
+}

+ 56 - 0
spring-boot-starters/wx-java-qidian-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/qidian/properties/RedisProperties.java

@@ -0,0 +1,56 @@
+package com.binarywang.spring.starter.wxjava.qidian.properties;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * redis 配置属性.
+ *
+ * @author <a href="https://github.com/binarywang">Binary Wang</a>
+ * @date 2020-08-30
+ */
+@Data
+public class RedisProperties implements Serializable {
+  private static final long serialVersionUID = -5924815351660074401L;
+
+  /**
+   * 主机地址.
+   */
+  private String host = "127.0.0.1";
+
+  /**
+   * 端口号.
+   */
+  private int port = 6379;
+
+  /**
+   * 密码.
+   */
+  private String password;
+
+  /**
+   * 超时.
+   */
+  private int timeout = 2000;
+
+  /**
+   * 数据库.
+   */
+  private int database = 0;
+
+  /**
+   * sentinel ips
+   */
+  private String sentinelIps;
+
+  /**
+   * sentinel name
+   */
+  private String sentinelName;
+
+  private Integer maxActive;
+  private Integer maxIdle;
+  private Integer maxWaitMillis;
+  private Integer minIdle;
+}

+ 99 - 0
spring-boot-starters/wx-java-qidian-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/qidian/properties/WxQidianProperties.java

@@ -0,0 +1,99 @@
+package com.binarywang.spring.starter.wxjava.qidian.properties;
+
+import com.binarywang.spring.starter.wxjava.qidian.enums.HttpClientType;
+import com.binarywang.spring.starter.wxjava.qidian.enums.StorageType;
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import java.io.Serializable;
+
+import static com.binarywang.spring.starter.wxjava.qidian.enums.StorageType.Memory;
+import static com.binarywang.spring.starter.wxjava.qidian.properties.WxQidianProperties.PREFIX;
+
+/**
+ * 企点接入相关配置属性.
+ *
+ * @author someone
+ */
+@Data
+@ConfigurationProperties(PREFIX)
+public class WxQidianProperties {
+  public static final String PREFIX = "wx.qidian";
+
+  /**
+   * 设置腾讯企点的appid.
+   */
+  private String appId;
+
+  /**
+   * 设置腾讯企点的app secret.
+   */
+  private String secret;
+
+  /**
+   * 设置腾讯企点的token.
+   */
+  private String token;
+
+  /**
+   * 设置腾讯企点的EncodingAESKey.
+   */
+  private String aesKey;
+
+  /**
+   * 自定义host配置
+   */
+  private HostConfig hosts;
+
+  /**
+   * 存储策略
+   */
+  private ConfigStorage configStorage = new ConfigStorage();
+
+  @Data
+  public static class ConfigStorage implements Serializable {
+    private static final long serialVersionUID = 4815731027000065434L;
+
+    /**
+     * 存储类型.
+     */
+    private StorageType type = Memory;
+
+    /**
+     * 指定key前缀.
+     */
+    private String keyPrefix = "wx";
+
+    /**
+     * redis连接配置.
+     */
+    private RedisProperties redis = new RedisProperties();
+
+    /**
+     * http客户端类型.
+     */
+    private HttpClientType httpClientType = HttpClientType.HttpClient;
+
+    /**
+     * http代理主机.
+     */
+    private String httpProxyHost;
+
+    /**
+     * http代理端口.
+     */
+    private Integer httpProxyPort;
+
+    /**
+     * http代理用户名.
+     */
+    private String httpProxyUsername;
+
+    /**
+     * http代理密码.
+     */
+    private String httpProxyPassword;
+
+  }
+
+}

+ 1 - 0
spring-boot-starters/wx-java-qidian-spring-boot-starter/src/main/resources/META-INF/spring.factories

@@ -0,0 +1 @@
+org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.binarywang.spring.starter.wxjava.qidian.config.WxQidianAutoConfiguration

+ 201 - 0
weixin-java-qidian/LICENSE

@@ -0,0 +1,201 @@
+Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "{}"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright {yyyy} {name of copyright owner}
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.

+ 134 - 0
weixin-java-qidian/pom.xml

@@ -0,0 +1,134 @@
+<?xml version="1.0"?>
+<project
+  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"
+  xmlns="http://maven.apache.org/POM/4.0.0">
+  <modelVersion>4.0.0</modelVersion>
+  <parent>
+    <groupId>com.github.binarywang</groupId>
+    <artifactId>wx-java</artifactId>
+    <version>4.0.1.B</version>
+  </parent>
+
+  <artifactId>weixin-java-qidian</artifactId>
+  <name>WxJava - 企点 Java SDK</name>
+  <description>腾讯企点Java SDK</description>
+
+  <dependencies>
+    <dependency>
+      <groupId>com.github.binarywang</groupId>
+      <artifactId>weixin-java-common</artifactId>
+      <version>${project.version}</version>
+    </dependency>
+
+    <dependency>
+      <groupId>org.jodd</groupId>
+      <artifactId>jodd-http</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>com.squareup.okhttp3</groupId>
+      <artifactId>okhttp</artifactId>
+      <scope>provided</scope>
+    </dependency>
+
+    <dependency>
+      <groupId>org.testng</groupId>
+      <artifactId>testng</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.mockito</groupId>
+      <artifactId>mockito-all</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>com.google.inject</groupId>
+      <artifactId>guice</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.eclipse.jetty</groupId>
+      <artifactId>jetty-server</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.eclipse.jetty</groupId>
+      <artifactId>jetty-servlet</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>joda-time</groupId>
+      <artifactId>joda-time</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>redis.clients</groupId>
+      <artifactId>jedis</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>ch.qos.logback</groupId>
+      <artifactId>logback-classic</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.assertj</groupId>
+      <artifactId>assertj-guava</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.projectlombok</groupId>
+      <artifactId>lombok</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>org.redisson</groupId>
+      <artifactId>redisson</artifactId>
+    </dependency>
+  </dependencies>
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-surefire-plugin</artifactId>
+        <configuration>
+          <suiteXmlFiles>
+            <suiteXmlFile>src/test/resources/testng.xml</suiteXmlFile>
+          </suiteXmlFiles>
+        </configuration>
+      </plugin>
+    </plugins>
+  </build>
+
+  <profiles>
+    <profile>
+      <id>native-image</id>
+      <activation>
+        <activeByDefault>false</activeByDefault>
+      </activation>
+
+      <build>
+        <plugins>
+          <plugin>
+            <groupId>org.apache.maven.plugins</groupId>
+            <artifactId>maven-compiler-plugin</artifactId>
+            <version>3.5.1</version>
+            <configuration>
+              <annotationProcessors>
+                com.github.binarywang.wx.graal.GraalProcessor,lombok.launch.AnnotationProcessorHider$AnnotationProcessor,lombok.launch.AnnotationProcessorHider$ClaimingProcessor
+              </annotationProcessors>
+              <annotationProcessorPaths>
+                <path>
+                  <groupId>com.github.binarywang</groupId>
+                  <artifactId>weixin-graal</artifactId>
+                  <version>${project.version}</version>
+                </path>
+              </annotationProcessorPaths>
+            </configuration>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
+  </profiles>
+
+</project>

+ 13 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/api/WxQidianCallDataService.java

@@ -0,0 +1,13 @@
+package me.chanjar.weixin.qidian.api;
+
+import me.chanjar.weixin.common.error.WxErrorException;
+import me.chanjar.weixin.qidian.bean.call.GetSwitchBoardListResponse;
+
+/**
+ * 通话数据相关操作接口.
+ *
+ * @author alegria
+ */
+public interface WxQidianCallDataService {
+  public GetSwitchBoardListResponse getSwitchBoardList() throws WxErrorException;
+}

+ 18 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/api/WxQidianDialService.java

@@ -0,0 +1,18 @@
+package me.chanjar.weixin.qidian.api;
+
+import me.chanjar.weixin.common.error.WxErrorException;
+import me.chanjar.weixin.qidian.bean.dial.IVRDialRequest;
+import me.chanjar.weixin.qidian.bean.dial.IVRDialResponse;
+import me.chanjar.weixin.qidian.bean.dial.IVRListResponse;
+
+/**
+ * 基础话务相关操作接口.
+ *
+ * @author alegria
+ */
+public interface WxQidianDialService {
+  IVRDialResponse ivrDial(IVRDialRequest ivrDial) throws WxErrorException;
+
+  IVRListResponse getIVRList() throws WxErrorException;
+
+}

+ 348 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/api/WxQidianService.java

@@ -0,0 +1,348 @@
+package me.chanjar.weixin.qidian.api;
+
+import java.util.Map;
+
+import com.google.gson.JsonObject;
+
+import me.chanjar.weixin.common.bean.WxJsapiSignature;
+import me.chanjar.weixin.common.bean.WxNetCheckResult;
+import me.chanjar.weixin.common.enums.TicketType;
+import me.chanjar.weixin.common.error.WxErrorException;
+import me.chanjar.weixin.common.service.WxService;
+import me.chanjar.weixin.common.util.http.MediaUploadRequestExecutor;
+import me.chanjar.weixin.common.util.http.RequestExecutor;
+import me.chanjar.weixin.common.util.http.RequestHttp;
+import me.chanjar.weixin.qidian.config.WxQidianConfigStorage;
+import me.chanjar.weixin.qidian.enums.WxQidianApiUrl;
+
+/**
+ * 腾讯企点API的Service.
+ *
+ * @author alegria
+ */
+public interface WxQidianService extends WxService {
+  /**
+   * <pre>
+   * 验证消息的确来自微信服务器.
+   * 详情请见: http://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421135319&token=&lang=zh_CN
+   * </pre>
+   *
+   * @param timestamp 时间戳
+   * @param nonce     随机串
+   * @param signature 签名
+   * @return 是否验证通过 boolean
+   */
+  boolean checkSignature(String timestamp, String nonce, String signature);
+
+  /**
+   * 获取access_token, 不强制刷新access_token.
+   *
+   * @return token access token
+   * @throws WxErrorException .
+   * @see #getAccessToken(boolean) #getAccessToken(boolean)
+   */
+  String getAccessToken() throws WxErrorException;
+
+  /**
+   * <pre>
+   * 获取access_token,本方法线程安全.
+   * 且在多线程同时刷新时只刷新一次,避免超出2000次/日的调用次数上限
+   *
+   * 另:本service的所有方法都会在access_token过期时调用此方法
+   *
+   * 程序员在非必要情况下尽量不要主动调用此方法
+   *
+   * 详情请见: http://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421140183&token=&lang=zh_CN
+   * </pre>
+   *
+   * @param forceRefresh 是否强制刷新
+   * @return token access token
+   * @throws WxErrorException .
+   */
+  String getAccessToken(boolean forceRefresh) throws WxErrorException;
+
+  /**
+   * 获得ticket,不强制刷新ticket.
+   *
+   * @param type ticket 类型
+   * @return ticket ticket
+   * @throws WxErrorException .
+   * @see #getTicket(TicketType, boolean) #getTicket(TicketType, boolean)
+   */
+  String getTicket(TicketType type) throws WxErrorException;
+
+  /**
+   * <pre>
+   * 获得ticket.
+   * 获得时会检查 Token是否过期,如果过期了,那么就刷新一下,否则就什么都不干
+   * </pre>
+   *
+   * @param type         ticket类型
+   * @param forceRefresh 强制刷新
+   * @return ticket ticket
+   * @throws WxErrorException .
+   */
+  String getTicket(TicketType type, boolean forceRefresh) throws WxErrorException;
+
+  /**
+   * 获得jsapi_ticket,不强制刷新jsapi_ticket.
+   *
+   * @return jsapi ticket
+   * @throws WxErrorException .
+   * @see #getJsapiTicket(boolean) #getJsapiTicket(boolean)
+   */
+  String getJsapiTicket() throws WxErrorException;
+
+  /**
+   * <pre>
+   * 获得jsapi_ticket.
+   * 获得时会检查jsapiToken是否过期,如果过期了,那么就刷新一下,否则就什么都不干
+   *
+   * 详情请见:http://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421141115&token=&lang=zh_CN
+   * </pre>
+   *
+   * @param forceRefresh 强制刷新
+   * @return jsapi ticket
+   * @throws WxErrorException .
+   */
+  String getJsapiTicket(boolean forceRefresh) throws WxErrorException;
+
+  /**
+   * <pre>
+   * 创建调用jsapi时所需要的签名.
+   *
+   * 详情请见:http://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421141115&token=&lang=zh_CN
+   * </pre>
+   *
+   * @param url 地址
+   * @return 生成的签名对象 wx jsapi signature
+   * @throws WxErrorException .
+   */
+  WxJsapiSignature createJsapiSignature(String url) throws WxErrorException;
+
+  /**
+   * <pre>
+   * 长链接转短链接接口.
+   * 详情请见: http://mp.weixin.qq.com/wiki/index.php?title=长链接转短链接接口
+   * </pre>
+   *
+   * @param longUrl 长url
+   * @return 生成的短地址 string
+   * @throws WxErrorException .
+   */
+  String shortUrl(String longUrl) throws WxErrorException;
+
+  /**
+   * <pre>
+   * 构造第三方使用网站应用授权登录的url.
+   * 详情请见: <a href=
+  "https://open.weixin.qq.com/cgi-bin/showdocument?action=dir_list&t=resource/res_list&verify=1&id=open1419316505&token=&lang=zh_CN">网站应用微信登录开发指南</a>
+   * URL格式为:https://open.weixin.qq.com/connect/qrconnect?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=SCOPE&state=STATE#wechat_redirect
+   * </pre>
+   *
+   * @param redirectUri 用户授权完成后的重定向链接,无需urlencode, 方法内会进行encode
+   * @param scope       应用授权作用域,拥有多个作用域用逗号(,)分隔,网页应用目前仅填写snsapi_login即可
+   * @param state       非必填,用于保持请求和回调的状态,授权请求后原样带回给第三方。该参数可用于防止csrf攻击(跨站请求伪造攻击),建议第三方带上该参数,可设置为简单的随机数加session进行校验
+   * @return url string
+   */
+  String buildQrConnectUrl(String redirectUri, String scope, String state);
+
+  /**
+   * <pre>
+   * 获取微信服务器IP地址
+   * http://mp.weixin.qq.com/wiki/0/2ad4b6bfd29f30f71d39616c2a0fcedc.html
+   * </pre>
+   *
+   * @return 微信服务器ip地址数组 string [ ]
+   * @throws WxErrorException .
+   */
+  String[] getCallbackIP() throws WxErrorException;
+
+  /**
+   * <pre>
+   *  网络检测
+   *  https://mp.weixin.qq.com/wiki?t=resource/res_main&id=21541575776DtsuT
+   *  为了帮助开发者排查回调连接失败的问题,提供这个网络检测的API。它可以对开发者URL做域名解析,然后对所有IP进行一次ping操作,得到丢包率和耗时。
+   * </pre>
+   *
+   * @param action   执行的检测动作
+   * @param operator 指定平台从某个运营商进行检测
+   * @return 检测结果 wx net check result
+   * @throws WxErrorException .
+   */
+  WxNetCheckResult netCheck(String action, String operator) throws WxErrorException;
+
+  /**
+   * <pre>
+   *  公众号调用或第三方平台帮公众号调用对公众号的所有api调用(包括第三方帮其调用)次数进行清零:
+   *  HTTP调用:https://api.weixin.qq.com/cgi-bin/clear_quota?access_token=ACCESS_TOKEN
+   *  接口文档地址:https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1433744592
+   *
+   * </pre>
+   *
+   * @param appid 公众号的APPID
+   * @throws WxErrorException the wx error exception
+   */
+  void clearQuota(String appid) throws WxErrorException;
+
+  /**
+   * <pre>
+   * Service没有实现某个API的时候,可以用这个,
+   * 比{@link #get}和{@link #post}方法更灵活,可以自己构造RequestExecutor用来处理不同的参数和不同的返回类型。
+   * 可以参考,{@link MediaUploadRequestExecutor}的实现方法
+   * </pre>
+   *
+   * @param <T>      the type parameter
+   * @param <E>      the type parameter
+   * @param executor 执行器
+   * @param url      接口地址
+   * @param data     参数数据
+   * @return 结果 t
+   * @throws WxErrorException 异常
+   */
+  <T, E> T execute(RequestExecutor<T, E> executor, String url, E data) throws WxErrorException;
+
+  /**
+   * 当本Service没有实现某个API的时候,可以用这个,针对所有微信API中的GET请求.
+   *
+   * @param url        请求接口地址
+   * @param queryParam 参数
+   * @return 接口响应字符串 string
+   * @throws WxErrorException 异常
+   */
+  String get(WxQidianApiUrl url, String queryParam) throws WxErrorException;
+
+  /**
+   * 当本Service没有实现某个API的时候,可以用这个,针对所有微信API中的POST请求.
+   *
+   * @param url      请求接口地址
+   * @param postData 请求参数json值
+   * @return 接口响应字符串 string
+   * @throws WxErrorException 异常
+   */
+  String post(WxQidianApiUrl url, String postData) throws WxErrorException;
+
+  /**
+   * 当本Service没有实现某个API的时候,可以用这个,针对所有微信API中的POST请求.
+   *
+   * @param url        请求接口地址
+   * @param jsonObject 请求参数json对象
+   * @return 接口响应字符串 string
+   * @throws WxErrorException 异常
+   */
+  String post(WxQidianApiUrl url, JsonObject jsonObject) throws WxErrorException;
+
+  /**
+   * <pre>
+   * Service没有实现某个API的时候,可以用这个,
+   * 比{@link #get}和{@link #post}方法更灵活,可以自己构造RequestExecutor用来处理不同的参数和不同的返回类型。
+   * 可以参考,{@link MediaUploadRequestExecutor}的实现方法
+   * </pre>
+   *
+   * @param <T>      the type parameter
+   * @param <E>      the type parameter
+   * @param executor 执行器
+   * @param url      接口地址
+   * @param data     参数数据
+   * @return 结果 t
+   * @throws WxErrorException 异常
+   */
+  <T, E> T execute(RequestExecutor<T, E> executor, WxQidianApiUrl url, E data) throws WxErrorException;
+
+  /**
+   * 设置当微信系统响应系统繁忙时,要等待多少 retrySleepMillis(ms) * 2^(重试次数 - 1) 再发起重试.
+   *
+   * @param retrySleepMillis 默认:1000ms
+   */
+  void setRetrySleepMillis(int retrySleepMillis);
+
+  /**
+   * <pre>
+   * 设置当微信系统响应系统繁忙时,最大重试次数.
+   * 默认:5次
+   * </pre>
+   *
+   * @param maxRetryTimes 最大重试次数
+   */
+  void setMaxRetryTimes(int maxRetryTimes);
+
+  /**
+   * 获取WxMpConfigStorage 对象.
+   *
+   * @return WxMpConfigStorage wx mp config storage
+   */
+  WxQidianConfigStorage getWxMpConfigStorage();
+
+  /**
+   * 设置 {@link WxQidianConfigStorage} 的实现. 兼容老版本
+   *
+   * @param wxConfigProvider .
+   */
+  void setWxMpConfigStorage(WxQidianConfigStorage wxConfigProvider);
+
+  /**
+   * Map里 加入新的 {@link WxQidianConfigStorage},适用于动态添加新的微信公众号配置.
+   *
+   * @param mpId          公众号id
+   * @param configStorage 新的微信配置
+   */
+  void addConfigStorage(String mpId, WxQidianConfigStorage configStorage);
+
+  /**
+   * 从 Map中 移除 {@link String mpId} 所对应的
+   * {@link WxQidianConfigStorage},适用于动态移除微信公众号配置.
+   *
+   * @param mpId 对应公众号的标识
+   */
+  void removeConfigStorage(String mpId);
+
+  /**
+   * 注入多个 {@link WxQidianConfigStorage} 的实现. 并为每个 {@link WxQidianConfigStorage}
+   * 赋予不同的 {@link String mpId} 值 随机采用一个{@link String mpId}进行Http初始化操作
+   *
+   * @param configStorages WxMpConfigStorage map
+   */
+  void setMultiConfigStorages(Map<String, WxQidianConfigStorage> configStorages);
+
+  /**
+   * 注入多个 {@link WxQidianConfigStorage} 的实现. 并为每个 {@link WxQidianConfigStorage}
+   * 赋予不同的 {@link String label} 值
+   *
+   * @param configStorages WxMpConfigStorage map
+   * @param defaultMpId    设置一个{@link WxQidianConfigStorage} 所对应的{@link String
+   *                       mpId}进行Http初始化
+   */
+  void setMultiConfigStorages(Map<String, WxQidianConfigStorage> configStorages, String defaultMpId);
+
+  /**
+   * 进行相应的公众号切换.
+   *
+   * @param mpId 公众号标识
+   * @return 切换是否成功 boolean
+   */
+  boolean switchover(String mpId);
+
+  /**
+   * 进行相应的公众号切换.
+   *
+   * @param mpId 公众号标识
+   * @return 切换成功 ,则返回当前对象,方便链式调用,否则抛出异常
+   */
+  WxQidianService switchoverTo(String mpId);
+
+  /**
+   * 初始化http请求对象.
+   */
+  void initHttp();
+
+  /**
+   * 获取RequestHttp对象.
+   *
+   * @return RequestHttp对象 request http
+   */
+  RequestHttp getRequestHttp();
+
+  WxQidianDialService getDialService();
+
+  WxQidianCallDataService getCallDataService();
+}

+ 420 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/api/impl/BaseWxQidianServiceImpl.java

@@ -0,0 +1,420 @@
+package me.chanjar.weixin.qidian.api.impl;
+
+import static me.chanjar.weixin.qidian.enums.WxQidianApiUrl.Other.CLEAR_QUOTA_URL;
+import static me.chanjar.weixin.qidian.enums.WxQidianApiUrl.Other.GET_CALLBACK_IP_URL;
+import static me.chanjar.weixin.qidian.enums.WxQidianApiUrl.Other.GET_CURRENT_AUTOREPLY_INFO_URL;
+import static me.chanjar.weixin.qidian.enums.WxQidianApiUrl.Other.GET_TICKET_URL;
+import static me.chanjar.weixin.qidian.enums.WxQidianApiUrl.Other.NETCHECK_URL;
+import static me.chanjar.weixin.qidian.enums.WxQidianApiUrl.Other.QRCONNECT_URL;
+import static me.chanjar.weixin.qidian.enums.WxQidianApiUrl.Other.SHORTURL_API_URL;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.concurrent.locks.Lock;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
+import org.apache.commons.lang3.StringUtils;
+
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.common.api.WxConsts;
+import me.chanjar.weixin.common.bean.ToJson;
+import me.chanjar.weixin.common.bean.WxAccessToken;
+import me.chanjar.weixin.common.bean.WxJsapiSignature;
+import me.chanjar.weixin.common.bean.WxNetCheckResult;
+import me.chanjar.weixin.common.enums.TicketType;
+import me.chanjar.weixin.common.enums.WxType;
+import me.chanjar.weixin.common.error.WxError;
+import me.chanjar.weixin.common.error.WxErrorException;
+import me.chanjar.weixin.common.error.WxRuntimeException;
+import me.chanjar.weixin.common.util.DataUtils;
+import me.chanjar.weixin.common.util.RandomUtils;
+import me.chanjar.weixin.common.util.crypto.SHA1;
+import me.chanjar.weixin.common.util.http.RequestExecutor;
+import me.chanjar.weixin.common.util.http.RequestHttp;
+import me.chanjar.weixin.common.util.http.SimpleGetRequestExecutor;
+import me.chanjar.weixin.common.util.http.SimplePostRequestExecutor;
+import me.chanjar.weixin.common.util.http.URIUtil;
+import me.chanjar.weixin.common.util.json.GsonParser;
+import me.chanjar.weixin.common.util.json.WxGsonBuilder;
+import me.chanjar.weixin.qidian.api.WxQidianCallDataService;
+import me.chanjar.weixin.qidian.api.WxQidianDialService;
+import me.chanjar.weixin.qidian.api.WxQidianService;
+import me.chanjar.weixin.qidian.config.WxQidianConfigStorage;
+import me.chanjar.weixin.qidian.enums.WxQidianApiUrl;
+import me.chanjar.weixin.qidian.util.WxQidianConfigStorageHolder;
+
+/**
+ * 基础实现类.
+ *
+ * @author someone
+ */
+@Slf4j
+public abstract class BaseWxQidianServiceImpl<H, P> implements WxQidianService, RequestHttp<H, P> {
+  @Getter
+  private WxQidianDialService dialService = new WxQidianDialServiceImpl(this);
+  @Getter
+  private WxQidianCallDataService callDataService = new WxQidianCallDataServiceImpl(this);
+
+  private Map<String, WxQidianConfigStorage> configStorageMap;
+
+  private int retrySleepMillis = 1000;
+  private int maxRetryTimes = 5;
+
+  @Override
+  public boolean checkSignature(String timestamp, String nonce, String signature) {
+    try {
+      return SHA1.gen(this.getWxMpConfigStorage().getToken(), timestamp, nonce).equals(signature);
+    } catch (Exception e) {
+      log.error("Checking signature failed, and the reason is :" + e.getMessage());
+      return false;
+    }
+  }
+
+  @Override
+  public String getTicket(TicketType type) throws WxErrorException {
+    return this.getTicket(type, false);
+  }
+
+  @Override
+  public String getTicket(TicketType type, boolean forceRefresh) throws WxErrorException {
+
+    if (forceRefresh) {
+      this.getWxMpConfigStorage().expireTicket(type);
+    }
+
+    if (this.getWxMpConfigStorage().isTicketExpired(type)) {
+      Lock lock = this.getWxMpConfigStorage().getTicketLock(type);
+      lock.lock();
+      try {
+        if (this.getWxMpConfigStorage().isTicketExpired(type)) {
+          String responseContent = execute(SimpleGetRequestExecutor.create(this),
+              GET_TICKET_URL.getUrl(this.getWxMpConfigStorage()) + type.getCode(), null);
+          JsonObject tmpJsonObject = GsonParser.parse(responseContent);
+          String jsapiTicket = tmpJsonObject.get("ticket").getAsString();
+          int expiresInSeconds = tmpJsonObject.get("expires_in").getAsInt();
+          this.getWxMpConfigStorage().updateTicket(type, jsapiTicket, expiresInSeconds);
+        }
+      } finally {
+        lock.unlock();
+      }
+    }
+
+    return this.getWxMpConfigStorage().getTicket(type);
+  }
+
+  @Override
+  public String getJsapiTicket() throws WxErrorException {
+    return this.getJsapiTicket(false);
+  }
+
+  @Override
+  public String getJsapiTicket(boolean forceRefresh) throws WxErrorException {
+    return this.getTicket(TicketType.JSAPI, forceRefresh);
+  }
+
+  @Override
+  public WxJsapiSignature createJsapiSignature(String url) throws WxErrorException {
+    long timestamp = System.currentTimeMillis() / 1000;
+    String randomStr = RandomUtils.getRandomStr();
+    String jsapiTicket = getJsapiTicket(false);
+    String signature = SHA1.genWithAmple("jsapi_ticket=" + jsapiTicket, "noncestr=" + randomStr,
+        "timestamp=" + timestamp, "url=" + url);
+    WxJsapiSignature jsapiSignature = new WxJsapiSignature();
+    jsapiSignature.setAppId(this.getWxMpConfigStorage().getAppId());
+    jsapiSignature.setTimestamp(timestamp);
+    jsapiSignature.setNonceStr(randomStr);
+    jsapiSignature.setUrl(url);
+    jsapiSignature.setSignature(signature);
+    return jsapiSignature;
+  }
+
+  @Override
+  public String getAccessToken() throws WxErrorException {
+    return getAccessToken(false);
+  }
+
+  @Override
+  public String shortUrl(String longUrl) throws WxErrorException {
+    if (longUrl.contains("&access_token=")) {
+      throw new WxErrorException("要转换的网址中存在非法字符{&access_token=}," + "会导致微信接口报错,属于微信bug,请调整地址,否则不建议使用此方法!");
+    }
+
+    JsonObject o = new JsonObject();
+    o.addProperty("action", "long2short");
+    o.addProperty("long_url", longUrl);
+    String responseContent = this.post(SHORTURL_API_URL, o.toString());
+    return GsonParser.parse(responseContent).get("short_url").getAsString();
+  }
+
+  @Override
+  public String buildQrConnectUrl(String redirectUri, String scope, String state) {
+    return String.format(QRCONNECT_URL.getUrl(this.getWxMpConfigStorage()), this.getWxMpConfigStorage().getAppId(),
+        URIUtil.encodeURIComponent(redirectUri), scope, StringUtils.trimToEmpty(state));
+  }
+
+  @Override
+  public String[] getCallbackIP() throws WxErrorException {
+    String responseContent = this.get(GET_CALLBACK_IP_URL, null);
+    JsonObject tmpJsonObject = GsonParser.parse(responseContent);
+    JsonArray ipList = tmpJsonObject.get("ip_list").getAsJsonArray();
+    String[] ipArray = new String[ipList.size()];
+    for (int i = 0; i < ipList.size(); i++) {
+      ipArray[i] = ipList.get(i).getAsString();
+    }
+    return ipArray;
+  }
+
+  @Override
+  public WxNetCheckResult netCheck(String action, String operator) throws WxErrorException {
+    JsonObject o = new JsonObject();
+    o.addProperty("action", action);
+    o.addProperty("check_operator", operator);
+    String responseContent = this.post(NETCHECK_URL, o.toString());
+    return WxNetCheckResult.fromJson(responseContent);
+  }
+
+  @Override
+  public void clearQuota(String appid) throws WxErrorException {
+    JsonObject o = new JsonObject();
+    o.addProperty("appid", appid);
+    this.post(CLEAR_QUOTA_URL, o.toString());
+  }
+
+  @Override
+  public String get(String url, String queryParam) throws WxErrorException {
+    return execute(SimpleGetRequestExecutor.create(this), url, queryParam);
+  }
+
+  @Override
+  public String get(WxQidianApiUrl url, String queryParam) throws WxErrorException {
+    return this.get(url.getUrl(this.getWxMpConfigStorage()), queryParam);
+  }
+
+  @Override
+  public String post(String url, String postData) throws WxErrorException {
+    return execute(SimplePostRequestExecutor.create(this), url, postData);
+  }
+
+  @Override
+  public String post(WxQidianApiUrl url, String postData) throws WxErrorException {
+    return this.post(url.getUrl(this.getWxMpConfigStorage()), postData);
+  }
+
+  @Override
+  public String post(WxQidianApiUrl url, JsonObject jsonObject) throws WxErrorException {
+    return this.post(url.getUrl(this.getWxMpConfigStorage()), jsonObject.toString());
+  }
+
+  @Override
+  public String post(String url, ToJson obj) throws WxErrorException {
+    return this.post(url, obj.toJson());
+  }
+
+  @Override
+  public String post(String url, JsonObject jsonObject) throws WxErrorException {
+    return this.post(url, jsonObject.toString());
+  }
+
+  @Override
+  public String post(String url, Object obj) throws WxErrorException {
+    return this.execute(SimplePostRequestExecutor.create(this), url, WxGsonBuilder.create().toJson(obj));
+  }
+
+  @Override
+  public <T, E> T execute(RequestExecutor<T, E> executor, WxQidianApiUrl url, E data) throws WxErrorException {
+    return this.execute(executor, url.getUrl(this.getWxMpConfigStorage()), data);
+  }
+
+  /**
+   * 向微信端发送请求,在这里执行的策略是当发生access_token过期时才去刷新,然后重新执行请求,而不是全局定时请求.
+   */
+  @Override
+  public <T, E> T execute(RequestExecutor<T, E> executor, String uri, E data) throws WxErrorException {
+    int retryTimes = 0;
+    do {
+      try {
+        return this.executeInternal(executor, uri, data);
+      } catch (WxErrorException e) {
+        if (retryTimes + 1 > this.maxRetryTimes) {
+          log.warn("重试达到最大次数【{}】", maxRetryTimes);
+          // 最后一次重试失败后,直接抛出异常,不再等待
+          throw new WxRuntimeException("微信服务端异常,超出重试次数");
+        }
+
+        WxError error = e.getError();
+        // -1 系统繁忙, 1000ms后重试
+        if (error.getErrorCode() == -1) {
+          int sleepMillis = this.retrySleepMillis * (1 << retryTimes);
+          try {
+            log.warn("微信系统繁忙,{} ms 后重试(第{}次)", sleepMillis, retryTimes + 1);
+            Thread.sleep(sleepMillis);
+          } catch (InterruptedException e1) {
+            throw new WxRuntimeException(e1);
+          }
+        } else {
+          throw e;
+        }
+      }
+    } while (retryTimes++ < this.maxRetryTimes);
+
+    log.warn("重试达到最大次数【{}】", this.maxRetryTimes);
+    throw new WxRuntimeException("微信服务端异常,超出重试次数");
+  }
+
+  protected <T, E> T executeInternal(RequestExecutor<T, E> executor, String uri, E data) throws WxErrorException {
+    E dataForLog = DataUtils.handleDataWithSecret(data);
+
+    if (uri.contains("access_token=")) {
+      throw new IllegalArgumentException("uri参数中不允许有access_token: " + uri);
+    }
+
+    String accessToken = getAccessToken(false);
+    String uriWithAccessToken = uri + (uri.contains("?") ? "&" : "?") + "access_token=" + accessToken;
+
+    try {
+      T result = executor.execute(uriWithAccessToken, data, WxType.MP);
+      log.debug("\n【请求地址】: {}\n【请求参数】:{}\n【响应数据】:{}", uriWithAccessToken, dataForLog, result);
+      return result;
+    } catch (WxErrorException e) {
+      WxError error = e.getError();
+      if (WxConsts.ACCESS_TOKEN_ERROR_CODES.contains(error.getErrorCode())) {
+        // 强制设置wxMpConfigStorage它的access token过期了,这样在下一次请求里就会刷新access token
+        Lock lock = this.getWxMpConfigStorage().getAccessTokenLock();
+        lock.lock();
+        try {
+          if (StringUtils.equals(this.getWxMpConfigStorage().getAccessToken(), accessToken)) {
+            this.getWxMpConfigStorage().expireAccessToken();
+          }
+        } catch (Exception ex) {
+          this.getWxMpConfigStorage().expireAccessToken();
+        } finally {
+          lock.unlock();
+        }
+        if (this.getWxMpConfigStorage().autoRefreshToken()) {
+          log.warn("即将重新获取新的access_token,错误代码:{},错误信息:{}", error.getErrorCode(), error.getErrorMsg());
+          return this.execute(executor, uri, data);
+        }
+      }
+
+      if (error.getErrorCode() != 0) {
+        log.error("\n【请求地址】: {}\n【请求参数】:{}\n【错误信息】:{}", uriWithAccessToken, dataForLog, error);
+        throw new WxErrorException(error, e);
+      }
+      return null;
+    } catch (IOException e) {
+      log.error("\n【请求地址】: {}\n【请求参数】:{}\n【异常信息】:{}", uriWithAccessToken, dataForLog, e.getMessage());
+      throw new WxErrorException(e);
+    }
+  }
+
+  @Override
+  public WxQidianConfigStorage getWxMpConfigStorage() {
+    if (this.configStorageMap.size() == 1) {
+      // 只有一个公众号,直接返回其配置即可
+      return this.configStorageMap.values().iterator().next();
+    }
+
+    return this.configStorageMap.get(WxQidianConfigStorageHolder.get());
+  }
+
+  protected String extractAccessToken(String resultContent) throws WxErrorException {
+    WxQidianConfigStorage config = this.getWxMpConfigStorage();
+    WxError error = WxError.fromJson(resultContent, WxType.MP);
+    if (error.getErrorCode() != 0) {
+      throw new WxErrorException(error);
+    }
+    WxAccessToken accessToken = WxAccessToken.fromJson(resultContent);
+    config.updateAccessToken(accessToken.getAccessToken(), accessToken.getExpiresIn());
+    return config.getAccessToken();
+  }
+
+  @Override
+  public void setWxMpConfigStorage(WxQidianConfigStorage wxConfigProvider) {
+    final String defaultMpId = wxConfigProvider.getAppId();
+    this.setMultiConfigStorages(ImmutableMap.of(defaultMpId, wxConfigProvider), defaultMpId);
+  }
+
+  @Override
+  public void setMultiConfigStorages(Map<String, WxQidianConfigStorage> configStorages) {
+    this.setMultiConfigStorages(configStorages, configStorages.keySet().iterator().next());
+  }
+
+  @Override
+  public void setMultiConfigStorages(Map<String, WxQidianConfigStorage> configStorages, String defaultMpId) {
+    this.configStorageMap = Maps.newHashMap(configStorages);
+    WxQidianConfigStorageHolder.set(defaultMpId);
+    this.initHttp();
+  }
+
+  @Override
+  public void addConfigStorage(String mpId, WxQidianConfigStorage configStorages) {
+    synchronized (this) {
+      if (this.configStorageMap == null) {
+        this.setWxMpConfigStorage(configStorages);
+      } else {
+        this.configStorageMap.put(mpId, configStorages);
+      }
+    }
+  }
+
+  @Override
+  public void removeConfigStorage(String mpId) {
+    synchronized (this) {
+      if (this.configStorageMap.size() == 1) {
+        this.configStorageMap.remove(mpId);
+        log.warn("已删除最后一个公众号配置:{},须立即使用setWxMpConfigStorage或setMultiConfigStorages添加配置", mpId);
+        return;
+      }
+      if (WxQidianConfigStorageHolder.get().equals(mpId)) {
+        this.configStorageMap.remove(mpId);
+        final String defaultMpId = this.configStorageMap.keySet().iterator().next();
+        WxQidianConfigStorageHolder.set(defaultMpId);
+        log.warn("已删除默认公众号配置,公众号【{}】被设为默认配置", defaultMpId);
+        return;
+      }
+      this.configStorageMap.remove(mpId);
+    }
+  }
+
+  @Override
+  public WxQidianService switchoverTo(String mpId) {
+    if (this.configStorageMap.containsKey(mpId)) {
+      WxQidianConfigStorageHolder.set(mpId);
+      return this;
+    }
+
+    throw new WxRuntimeException(String.format("无法找到对应【%s】的公众号配置信息,请核实!", mpId));
+  }
+
+  @Override
+  public boolean switchover(String mpId) {
+    if (this.configStorageMap.containsKey(mpId)) {
+      WxQidianConfigStorageHolder.set(mpId);
+      return true;
+    }
+
+    log.error("无法找到对应【{}】的公众号配置信息,请核实!", mpId);
+    return false;
+  }
+
+  @Override
+  public void setRetrySleepMillis(int retrySleepMillis) {
+    this.retrySleepMillis = retrySleepMillis;
+  }
+
+  @Override
+  public void setMaxRetryTimes(int maxRetryTimes) {
+    this.maxRetryTimes = maxRetryTimes;
+  }
+
+  @Override
+  public RequestHttp getRequestHttp() {
+    return this;
+  }
+
+}

+ 23 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/api/impl/WxQidianCallDataServiceImpl.java

@@ -0,0 +1,23 @@
+package me.chanjar.weixin.qidian.api.impl;
+
+import static me.chanjar.weixin.qidian.enums.WxQidianApiUrl.CallData.GET_SWITCH_BOARD_LIST;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.common.error.WxErrorException;
+import me.chanjar.weixin.qidian.api.WxQidianCallDataService;
+import me.chanjar.weixin.qidian.api.WxQidianService;
+import me.chanjar.weixin.qidian.bean.call.GetSwitchBoardListResponse;
+
+@Slf4j
+@RequiredArgsConstructor
+public class WxQidianCallDataServiceImpl implements WxQidianCallDataService {
+  private final WxQidianService wxQidianService;
+
+  @Override
+  public GetSwitchBoardListResponse getSwitchBoardList() throws WxErrorException {
+    String result = this.wxQidianService.get(GET_SWITCH_BOARD_LIST, null);
+    return GetSwitchBoardListResponse.fromJson(result);
+  }
+
+}

+ 43 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/api/impl/WxQidianDialServiceImpl.java

@@ -0,0 +1,43 @@
+package me.chanjar.weixin.qidian.api.impl;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.common.error.WxErrorException;
+import me.chanjar.weixin.qidian.api.WxQidianDialService;
+import me.chanjar.weixin.qidian.api.WxQidianService;
+import me.chanjar.weixin.qidian.bean.dial.IVRDialRequest;
+import me.chanjar.weixin.qidian.bean.dial.IVRDialResponse;
+import me.chanjar.weixin.qidian.bean.dial.IVRListResponse;
+
+import static me.chanjar.weixin.qidian.enums.WxQidianApiUrl.Dial.GET_IVR_LIST;
+import static me.chanjar.weixin.qidian.enums.WxQidianApiUrl.Dial.IVR_DIAL;
+
+/**
+ * Created by Binary Wang on 2016/7/21.
+ *
+ * @author Binary Wang
+ */
+@Slf4j
+@RequiredArgsConstructor
+public class WxQidianDialServiceImpl implements WxQidianDialService {
+  private final WxQidianService wxQidianService;
+
+  @Override
+  public IVRDialResponse ivrDial(IVRDialRequest ivrDial) throws WxErrorException {
+    String json = ivrDial.toJson();
+
+    log.debug("IVR外呼:{}", json);
+
+    String result = this.wxQidianService.post(IVR_DIAL, json);
+    log.debug("创建菜单:{},结果:{}", json, result);
+
+    return IVRDialResponse.fromJson(result);
+  }
+
+  @Override
+  public IVRListResponse getIVRList() throws WxErrorException {
+    String result = this.wxQidianService.get(GET_IVR_LIST, null);
+    return IVRListResponse.fromJson(result);
+  }
+
+}

+ 106 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/api/impl/WxQidianServiceHttpClientImpl.java

@@ -0,0 +1,106 @@
+package me.chanjar.weixin.qidian.api.impl;
+
+import me.chanjar.weixin.common.error.WxErrorException;
+import me.chanjar.weixin.common.error.WxRuntimeException;
+import me.chanjar.weixin.common.util.http.HttpType;
+import me.chanjar.weixin.common.util.http.apache.ApacheHttpClientBuilder;
+import me.chanjar.weixin.common.util.http.apache.DefaultApacheHttpClientBuilder;
+import me.chanjar.weixin.qidian.config.WxQidianConfigStorage;
+import org.apache.http.HttpHost;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.client.BasicResponseHandler;
+import org.apache.http.impl.client.CloseableHttpClient;
+
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
+
+import static me.chanjar.weixin.qidian.enums.WxQidianApiUrl.Other.GET_ACCESS_TOKEN_URL;
+
+/**
+ * apache http client方式实现.
+ *
+ * @author someone
+ */
+public class WxQidianServiceHttpClientImpl extends BaseWxQidianServiceImpl<CloseableHttpClient, HttpHost> {
+  private CloseableHttpClient httpClient;
+  private HttpHost httpProxy;
+
+  @Override
+  public CloseableHttpClient getRequestHttpClient() {
+    return httpClient;
+  }
+
+  @Override
+  public HttpHost getRequestHttpProxy() {
+    return httpProxy;
+  }
+
+  @Override
+  public HttpType getRequestType() {
+    return HttpType.APACHE_HTTP;
+  }
+
+  @Override
+  public void initHttp() {
+    WxQidianConfigStorage configStorage = this.getWxMpConfigStorage();
+    ApacheHttpClientBuilder apacheHttpClientBuilder = configStorage.getApacheHttpClientBuilder();
+    if (null == apacheHttpClientBuilder) {
+      apacheHttpClientBuilder = DefaultApacheHttpClientBuilder.get();
+    }
+
+    apacheHttpClientBuilder.httpProxyHost(configStorage.getHttpProxyHost())
+        .httpProxyPort(configStorage.getHttpProxyPort()).httpProxyUsername(configStorage.getHttpProxyUsername())
+        .httpProxyPassword(configStorage.getHttpProxyPassword());
+
+    if (configStorage.getHttpProxyHost() != null && configStorage.getHttpProxyPort() > 0) {
+      this.httpProxy = new HttpHost(configStorage.getHttpProxyHost(), configStorage.getHttpProxyPort());
+    }
+
+    this.httpClient = apacheHttpClientBuilder.build();
+  }
+
+  @Override
+  public String getAccessToken(boolean forceRefresh) throws WxErrorException {
+    final WxQidianConfigStorage config = this.getWxMpConfigStorage();
+    if (!config.isAccessTokenExpired() && !forceRefresh) {
+      return config.getAccessToken();
+    }
+
+    Lock lock = config.getAccessTokenLock();
+    boolean locked = false;
+    try {
+      do {
+        locked = lock.tryLock(100, TimeUnit.MILLISECONDS);
+        if (!forceRefresh && !config.isAccessTokenExpired()) {
+          return config.getAccessToken();
+        }
+      } while (!locked);
+
+      String url = String.format(GET_ACCESS_TOKEN_URL.getUrl(config), config.getAppId(), config.getSecret());
+      try {
+        HttpGet httpGet = new HttpGet(url);
+        if (this.getRequestHttpProxy() != null) {
+          RequestConfig requestConfig = RequestConfig.custom().setProxy(this.getRequestHttpProxy()).build();
+          httpGet.setConfig(requestConfig);
+        }
+        try (CloseableHttpResponse response = getRequestHttpClient().execute(httpGet)) {
+          return this.extractAccessToken(new BasicResponseHandler().handleResponse(response));
+        } finally {
+          httpGet.releaseConnection();
+        }
+      } catch (IOException e) {
+        throw new WxRuntimeException(e);
+      }
+    } catch (InterruptedException e) {
+      throw new WxRuntimeException(e);
+    } finally {
+      if (locked) {
+        lock.unlock();
+      }
+    }
+  }
+
+}

+ 12 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/api/impl/WxQidianServiceImpl.java

@@ -0,0 +1,12 @@
+package me.chanjar.weixin.qidian.api.impl;
+
+/**
+ * <pre>
+ * 默认接口实现类,使用apache httpclient实现
+ * Created by Binary Wang on 2017-5-27.
+ * </pre>
+ *
+ * @author <a href="https://github.com/binarywang">Binary Wang</a>
+ */
+public class WxQidianServiceImpl extends WxQidianServiceHttpClientImpl {
+}

+ 90 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/api/impl/WxQidianServiceJoddHttpImpl.java

@@ -0,0 +1,90 @@
+package me.chanjar.weixin.qidian.api.impl;
+
+import jodd.http.HttpConnectionProvider;
+import jodd.http.HttpRequest;
+import jodd.http.ProxyInfo;
+import jodd.http.net.SocketHttpConnectionProvider;
+import me.chanjar.weixin.common.error.WxErrorException;
+import me.chanjar.weixin.common.error.WxRuntimeException;
+import me.chanjar.weixin.common.util.http.HttpType;
+import me.chanjar.weixin.qidian.config.WxQidianConfigStorage;
+
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
+
+import static me.chanjar.weixin.qidian.enums.WxQidianApiUrl.Other.GET_ACCESS_TOKEN_URL;
+
+/**
+ * jodd-http方式实现.
+ *
+ * @author someone
+ */
+public class WxQidianServiceJoddHttpImpl extends BaseWxQidianServiceImpl<HttpConnectionProvider, ProxyInfo> {
+  private HttpConnectionProvider httpClient;
+  private ProxyInfo httpProxy;
+
+  @Override
+  public HttpConnectionProvider getRequestHttpClient() {
+    return httpClient;
+  }
+
+  @Override
+  public ProxyInfo getRequestHttpProxy() {
+    return httpProxy;
+  }
+
+  @Override
+  public HttpType getRequestType() {
+    return HttpType.JODD_HTTP;
+  }
+
+  @Override
+  public void initHttp() {
+
+    WxQidianConfigStorage configStorage = this.getWxMpConfigStorage();
+
+    if (configStorage.getHttpProxyHost() != null && configStorage.getHttpProxyPort() > 0) {
+      httpProxy = new ProxyInfo(ProxyInfo.ProxyType.HTTP, configStorage.getHttpProxyHost(),
+          configStorage.getHttpProxyPort(), configStorage.getHttpProxyUsername(), configStorage.getHttpProxyPassword());
+    }
+
+    httpClient = new SocketHttpConnectionProvider();
+  }
+
+  @Override
+  public String getAccessToken(boolean forceRefresh) throws WxErrorException {
+    final WxQidianConfigStorage config = this.getWxMpConfigStorage();
+    if (!config.isAccessTokenExpired() && !forceRefresh) {
+      return config.getAccessToken();
+    }
+
+    Lock lock = config.getAccessTokenLock();
+    boolean locked = false;
+    try {
+      do {
+        locked = lock.tryLock(100, TimeUnit.MILLISECONDS);
+        if (!forceRefresh && !config.isAccessTokenExpired()) {
+          return config.getAccessToken();
+        }
+      } while (!locked);
+      String url = String.format(GET_ACCESS_TOKEN_URL.getUrl(config), config.getAppId(), config.getSecret());
+
+      HttpRequest request = HttpRequest.get(url);
+      if (this.getRequestHttpProxy() != null) {
+        SocketHttpConnectionProvider provider = new SocketHttpConnectionProvider();
+        provider.useProxy(getRequestHttpProxy());
+
+        request.withConnectionProvider(provider);
+      }
+
+      return this.extractAccessToken(request.send().bodyText());
+    } catch (InterruptedException e) {
+      throw new WxRuntimeException(e);
+    } finally {
+      if (locked) {
+        lock.unlock();
+      }
+    }
+  }
+
+}

+ 98 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/api/impl/WxQidianServiceOkHttpImpl.java

@@ -0,0 +1,98 @@
+package me.chanjar.weixin.qidian.api.impl;
+
+import me.chanjar.weixin.common.error.WxErrorException;
+import me.chanjar.weixin.common.error.WxRuntimeException;
+import me.chanjar.weixin.common.util.http.HttpType;
+import me.chanjar.weixin.common.util.http.okhttp.OkHttpProxyInfo;
+import me.chanjar.weixin.qidian.config.WxQidianConfigStorage;
+import okhttp3.*;
+
+import java.io.IOException;
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
+
+import static me.chanjar.weixin.qidian.enums.WxQidianApiUrl.Other.GET_ACCESS_TOKEN_URL;
+
+/**
+ * okhttp实现.
+ *
+ * @author someone
+ */
+public class WxQidianServiceOkHttpImpl extends BaseWxQidianServiceImpl<OkHttpClient, OkHttpProxyInfo> {
+  private OkHttpClient httpClient;
+  private OkHttpProxyInfo httpProxy;
+
+  @Override
+  public OkHttpClient getRequestHttpClient() {
+    return httpClient;
+  }
+
+  @Override
+  public OkHttpProxyInfo getRequestHttpProxy() {
+    return httpProxy;
+  }
+
+  @Override
+  public HttpType getRequestType() {
+    return HttpType.OK_HTTP;
+  }
+
+  @Override
+  public String getAccessToken(boolean forceRefresh) throws WxErrorException {
+    final WxQidianConfigStorage config = this.getWxMpConfigStorage();
+    if (!config.isAccessTokenExpired() && !forceRefresh) {
+      return config.getAccessToken();
+    }
+
+    Lock lock = config.getAccessTokenLock();
+    boolean locked = false;
+    try {
+      do {
+        locked = lock.tryLock(100, TimeUnit.MILLISECONDS);
+        if (!forceRefresh && !config.isAccessTokenExpired()) {
+          return config.getAccessToken();
+        }
+      } while (!locked);
+      String url = String.format(GET_ACCESS_TOKEN_URL.getUrl(config), config.getAppId(), config.getSecret());
+
+      Request request = new Request.Builder().url(url).get().build();
+      Response response = getRequestHttpClient().newCall(request).execute();
+      return this.extractAccessToken(Objects.requireNonNull(response.body()).string());
+    } catch (IOException e) {
+      throw new WxRuntimeException(e);
+    } catch (InterruptedException e) {
+      throw new WxRuntimeException(e);
+    } finally {
+      if (locked) {
+        lock.unlock();
+      }
+    }
+  }
+
+  @Override
+  public void initHttp() {
+    WxQidianConfigStorage wxMpConfigStorage = getWxMpConfigStorage();
+    // 设置代理
+    if (wxMpConfigStorage.getHttpProxyHost() != null && wxMpConfigStorage.getHttpProxyPort() > 0) {
+      httpProxy = OkHttpProxyInfo.httpProxy(wxMpConfigStorage.getHttpProxyHost(), wxMpConfigStorage.getHttpProxyPort(),
+          wxMpConfigStorage.getHttpProxyUsername(), wxMpConfigStorage.getHttpProxyPassword());
+    }
+
+    OkHttpClient.Builder clientBuilder = new OkHttpClient.Builder();
+    if (httpProxy != null) {
+      clientBuilder.proxy(getRequestHttpProxy().getProxy());
+
+      // 设置授权
+      clientBuilder.authenticator(new Authenticator() {
+        @Override
+        public Request authenticate(Route route, Response response) throws IOException {
+          String credential = Credentials.basic(httpProxy.getProxyUsername(), httpProxy.getProxyPassword());
+          return response.request().newBuilder().header("Authorization", credential).build();
+        }
+      });
+    }
+    httpClient = clientBuilder.build();
+  }
+
+}

+ 56 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/bean/WxQidianHostConfig.java

@@ -0,0 +1,56 @@
+package me.chanjar.weixin.qidian.bean;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 企点接口地址域名部分的自定义设置信息.
+ *
+ * @author alegria
+ * @date 2020-12-24
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class WxQidianHostConfig {
+  public static final String API_DEFAULT_HOST_URL = "https://api.weixin.qq.com";
+  public static final String OPEN_DEFAULT_HOST_URL = "https://open.weixin.qq.com";
+  public static final String QIDIAN_DEFAULT_HOST_URL = "https://api.qidian.qq.com";
+
+  /**
+   * 对应于:https://api.weixin.qq.com
+   */
+  private String apiHost;
+
+  /**
+   * 对应于:https://open.weixin.qq.com
+   */
+  private String openHost;
+  /**
+   * 对应于:https://api.qidian.qq.com
+   */
+  private String qidianHost;
+
+  public static String buildUrl(WxQidianHostConfig hostConfig, String prefix, String path) {
+    if (hostConfig == null) {
+      return prefix + path;
+    }
+
+    if (hostConfig.getApiHost() != null && prefix.equals(API_DEFAULT_HOST_URL)) {
+      return hostConfig.getApiHost() + path;
+    }
+
+    if (hostConfig.getQidianHost() != null && prefix.equals(QIDIAN_DEFAULT_HOST_URL)) {
+      return hostConfig.getQidianHost() + path;
+    }
+
+    if (hostConfig.getOpenHost() != null && prefix.equals(OPEN_DEFAULT_HOST_URL)) {
+      return hostConfig.getOpenHost() + path;
+    }
+
+    return prefix + path;
+  }
+}

+ 14 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/bean/call/GetSwitchBoardListResponse.java

@@ -0,0 +1,14 @@
+package me.chanjar.weixin.qidian.bean.call;
+
+import lombok.Data;
+import me.chanjar.weixin.common.util.json.WxGsonBuilder;
+import me.chanjar.weixin.qidian.bean.common.QidianResponse;
+
+@Data
+public class GetSwitchBoardListResponse extends QidianResponse {
+    private SwitchBoardList data;
+
+    public static GetSwitchBoardListResponse fromJson(String result) {
+        return WxGsonBuilder.create().fromJson(result, GetSwitchBoardListResponse.class);
+    }
+}

+ 13 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/bean/call/SwitchBoard.java

@@ -0,0 +1,13 @@
+package me.chanjar.weixin.qidian.bean.call;
+
+import lombok.Data;
+
+@Data
+public class SwitchBoard {
+    private String switchboard;
+    private String createTime;
+    private Boolean callinStatus;
+    private Boolean calloutStatus;
+    private String spName;
+    private String cityName;
+}

+ 15 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/bean/call/SwitchBoardList.java

@@ -0,0 +1,15 @@
+package me.chanjar.weixin.qidian.bean.call;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import lombok.Data;
+
+@Data
+public class SwitchBoardList {
+    private List<SwitchBoard> records;
+
+    public List<String> switchBoards() {
+        return records.stream().map(SwitchBoard::getSwitchboard).collect(Collectors.toList());
+    }
+}

+ 109 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/bean/common/QidianResponse.java

@@ -0,0 +1,109 @@
+package me.chanjar.weixin.qidian.bean.common;
+
+import lombok.Data;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Data
+public class QidianResponse {
+	private static Map<Integer, String> errorCodesMap = new HashMap<Integer, String>() {
+		private static final long serialVersionUID = 1125349909878104934L;
+		{
+			put(-1, "系统繁忙");
+			put(0, "请求成功");
+			put(40001, "获取access_token时AppSecret错误,或者access_token无效");
+			put(40002, "不合法的凭证类型");
+			put(40003, "不合法的OpenID");
+			put(40004, "不合法的媒体文件类型");
+			put(40005, "不合法的文件类型");
+			put(40006, "不合法的文件大小");
+			put(40007, "不合法的媒体文件id");
+			put(40008, "不合法的消息类型");
+			put(40009, "不合法的图片文件大小");
+			put(40010, "不合法的语音文件大小");
+			put(40011, "不合法的视频文件大小");
+			put(40012, "不合法的缩略图文件大小");
+			put(40013, "不合法的APPID");
+			put(40014, "不合法的access_token");
+			put(40015, "不合法的菜单类型");
+			put(40016, "不合法的按钮个数");
+			put(40017, "不合法的按钮个数");
+			put(40018, "不合法的按钮名字长度");
+			put(40019, "不合法的按钮KEY长度");
+			put(40020, "不合法的按钮URL长度");
+			put(40021, "不合法的菜单版本号");
+			put(40022, "不合法的子菜单级数");
+			put(40023, "不合法的子菜单按钮个数");
+			put(40024, "不合法的子菜单按钮类型");
+			put(40025, "不合法的子菜单按钮名字长度");
+			put(40026, "不合法的子菜单按钮KEY长度");
+			put(40027, "不合法的子菜单按钮URL长度");
+			put(40028, "不合法的自定义菜单使用用户");
+			put(40029, "不合法的oauth_code");
+			put(40030, "不合法的refresh_token");
+			put(40031, "不合法的openid列表");
+			put(40032, "不合法的openid列表长度");
+			put(40033, "不合法的请求字符,不能包含\\uxxxx格式的字符");
+			put(40035, "不合法的参数");
+			put(40038, "不合法的请求格式");
+			put(40039, "不合法的URL长度");
+			put(40050, "不合法的分组id");
+			put(40051, "分组名字不合法");
+			put(41001, "缺少access_token参数");
+			put(41002, "缺少appid参数");
+			put(41003, "缺少refresh_token参数");
+			put(41004, "缺少secret参数");
+			put(41005, "缺少多媒体文件数据");
+			put(41006, "缺少media_id参数");
+			put(41007, "缺少子菜单数据");
+			put(41008, "缺少oauth code");
+			put(41009, "缺少openid");
+			put(42001, "access_token超时");
+			put(42002, "refresh_token超时");
+			put(42003, "oauth_code超时");
+			put(43001, "需要GET请求");
+			put(43002, "需要POST请求");
+			put(43003, "需要HTTPS请求");
+			put(43004, "需要接收者关注");
+			put(43005, "需要好友关系");
+			put(44001, "多媒体文件为空");
+			put(44002, "POST的数据包为空");
+			put(44003, "图文消息内容为空");
+			put(44004, "文本消息内容为空");
+			put(45001, "多媒体文件大小超过限制");
+			put(45002, "消息内容超过限制");
+			put(45003, "标题字段超过限制");
+			put(45004, "描述字段超过限制");
+			put(45005, "链接字段超过限制");
+			put(45006, "图片链接字段超过限制");
+			put(45007, "语音播放时间超过限制");
+			put(45008, "图文消息超过限制");
+			put(45009, "接口调用超过限制");
+			put(45010, "创建菜单个数超过限制");
+			put(45015, "回复时间超过限制");
+			put(45016, "系统分组,不允许修改");
+			put(45017, "分组名字过长");
+			put(45018, "分组数量超过上限");
+			put(46001, "不存在媒体数据");
+			put(46002, "不存在的菜单版本");
+			put(46003, "不存在的菜单数据");
+			put(46004, "不存在的用户");
+			put(47001, "解析JSON/XML内容错误");
+			put(48001, "api功能未授权");
+			put(50001, "用户未授权该api");
+		}
+	};
+	private Integer code = 0;
+	private String msg;
+	private Integer errcode = 0;
+	private String errmsg = "ok";
+	private String errmsgChinese;
+
+	public String getErrmsgChinese() {
+		if (errcode != null && errmsgChinese == null) {
+			errmsgChinese = errorCodesMap.get(errcode);
+		}
+		return errmsgChinese;
+	}
+}

+ 28 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/bean/dial/IVRDialRequest.java

@@ -0,0 +1,28 @@
+package me.chanjar.weixin.qidian.bean.dial;
+
+import lombok.Data;
+import me.chanjar.weixin.common.util.json.WxGsonBuilder;
+
+import java.io.Serializable;
+import java.util.List;
+
+@Data
+public class IVRDialRequest implements Serializable {
+  private static final long serialVersionUID = -5552935329136465927L;
+
+  private String phone_number;
+  private String ivr_id;
+  private List<String> corp_phone_list;
+  private Integer loc_pref_on = 1;
+  private List<String> backup_corp_phone_list;
+  private Boolean skip_restrict = false;
+
+  @Override
+  public String toString() {
+    return this.toJson();
+  }
+
+  public String toJson() {
+    return WxGsonBuilder.create().toJson(this);
+  }
+}

+ 20 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/bean/dial/IVRDialResponse.java

@@ -0,0 +1,20 @@
+package me.chanjar.weixin.qidian.bean.dial;
+
+import lombok.Data;
+import me.chanjar.weixin.common.util.json.WxGsonBuilder;
+import me.chanjar.weixin.qidian.bean.common.QidianResponse;
+import me.chanjar.weixin.qidian.util.json.WxQidianGsonBuilder;
+
+@Data
+public class IVRDialResponse extends QidianResponse {
+    private String callid;
+
+    public static IVRDialResponse fromJson(String json) {
+        return WxGsonBuilder.create().fromJson(json, IVRDialResponse.class);
+    }
+
+    @Override
+    public String toString() {
+        return WxQidianGsonBuilder.create().toJson(this);
+    }
+}

+ 16 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/bean/dial/IVRListResponse.java

@@ -0,0 +1,16 @@
+package me.chanjar.weixin.qidian.bean.dial;
+
+import lombok.Data;
+import me.chanjar.weixin.common.util.json.WxGsonBuilder;
+import me.chanjar.weixin.qidian.bean.common.QidianResponse;
+
+import java.util.List;
+
+@Data
+public class IVRListResponse extends QidianResponse {
+  private List<Ivr> node;
+
+  public static IVRListResponse fromJson(String json) {
+    return WxGsonBuilder.create().fromJson(json, IVRListResponse.class);
+  }
+}

+ 9 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/bean/dial/Ivr.java

@@ -0,0 +1,9 @@
+package me.chanjar.weixin.qidian.bean.dial;
+
+import lombok.Data;
+
+@Data
+public class Ivr {
+    private String ivr_id;
+    private String ivr_name;
+}

+ 210 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/config/WxQidianConfigStorage.java

@@ -0,0 +1,210 @@
+package me.chanjar.weixin.qidian.config;
+
+import me.chanjar.weixin.common.bean.WxAccessToken;
+import me.chanjar.weixin.common.enums.TicketType;
+import me.chanjar.weixin.common.util.http.apache.ApacheHttpClientBuilder;
+import me.chanjar.weixin.qidian.bean.WxQidianHostConfig;
+
+import java.io.File;
+import java.util.concurrent.locks.Lock;
+
+/**
+ * 微信客户端配置存储.
+ *
+ * @author chanjarster
+ */
+public interface WxQidianConfigStorage {
+  /**
+   * Gets access token.
+   *
+   * @return the access token
+   */
+  String getAccessToken();
+
+  /**
+   * Gets access token lock.
+   *
+   * @return the access token lock
+   */
+  Lock getAccessTokenLock();
+
+  /**
+   * Is access token expired boolean.
+   *
+   * @return the boolean
+   */
+  boolean isAccessTokenExpired();
+
+  /**
+   * 强制将access token过期掉.
+   */
+  void expireAccessToken();
+
+  /**
+   * 应该是线程安全的.
+   *
+   * @param accessToken 要更新的WxAccessToken对象
+   */
+  void updateAccessToken(WxAccessToken accessToken);
+
+  /**
+   * 应该是线程安全的.
+   *
+   * @param accessToken      新的accessToken值
+   * @param expiresInSeconds 过期时间,以秒为单位
+   */
+  void updateAccessToken(String accessToken, int expiresInSeconds);
+
+  /**
+   * Gets ticket.
+   *
+   * @param type the type
+   * @return the ticket
+   */
+  String getTicket(TicketType type);
+
+  /**
+   * Gets ticket lock.
+   *
+   * @param type the type
+   * @return the ticket lock
+   */
+  Lock getTicketLock(TicketType type);
+
+  /**
+   * Is ticket expired boolean.
+   *
+   * @param type the type
+   * @return the boolean
+   */
+  boolean isTicketExpired(TicketType type);
+
+  /**
+   * 强制将ticket过期掉.
+   *
+   * @param type the type
+   */
+  void expireTicket(TicketType type);
+
+  /**
+   * 更新ticket.
+   * 应该是线程安全的
+   *
+   * @param type             ticket类型
+   * @param ticket           新的ticket值
+   * @param expiresInSeconds 过期时间,以秒为单位
+   */
+  void updateTicket(TicketType type, String ticket, int expiresInSeconds);
+
+  /**
+   * Gets app id.
+   *
+   * @return the app id
+   */
+  String getAppId();
+
+  /**
+   * Gets secret.
+   *
+   * @return the secret
+   */
+  String getSecret();
+
+  /**
+   * Gets token.
+   *
+   * @return the token
+   */
+  String getToken();
+
+  /**
+   * Gets aes key.
+   *
+   * @return the aes key
+   */
+  String getAesKey();
+
+  /**
+   * Gets template id.
+   *
+   * @return the template id
+   */
+  String getTemplateId();
+
+  /**
+   * Gets expires time.
+   *
+   * @return the expires time
+   */
+  long getExpiresTime();
+
+  /**
+   * Gets oauth 2 redirect uri.
+   *
+   * @return the oauth 2 redirect uri
+   */
+  String getOauth2redirectUri();
+
+  /**
+   * Gets http proxy host.
+   *
+   * @return the http proxy host
+   */
+  String getHttpProxyHost();
+
+  /**
+   * Gets http proxy port.
+   *
+   * @return the http proxy port
+   */
+  int getHttpProxyPort();
+
+  /**
+   * Gets http proxy username.
+   *
+   * @return the http proxy username
+   */
+  String getHttpProxyUsername();
+
+  /**
+   * Gets http proxy password.
+   *
+   * @return the http proxy password
+   */
+  String getHttpProxyPassword();
+
+  /**
+   * Gets tmp dir file.
+   *
+   * @return the tmp dir file
+   */
+  File getTmpDirFile();
+
+  /**
+   * http client builder.
+   *
+   * @return ApacheHttpClientBuilder apache http client builder
+   */
+  ApacheHttpClientBuilder getApacheHttpClientBuilder();
+
+  /**
+   * 是否自动刷新token.
+   *
+   * @return the boolean
+   */
+  boolean autoRefreshToken();
+
+  /**
+   * 得到微信接口地址域名部分的自定义设置信息.
+   *
+   * @return the host config
+   */
+  WxQidianHostConfig getHostConfig();
+
+  /**
+   * 设置微信接口地址域名部分的自定义设置信息.
+   *
+   * @param hostConfig host config
+   */
+  void setHostConfig(WxQidianHostConfig hostConfig);
+}

+ 196 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/config/impl/WxQidianDefaultConfigImpl.java

@@ -0,0 +1,196 @@
+package me.chanjar.weixin.qidian.config.impl;
+
+import lombok.Data;
+import me.chanjar.weixin.common.bean.WxAccessToken;
+import me.chanjar.weixin.common.enums.TicketType;
+import me.chanjar.weixin.common.util.http.apache.ApacheHttpClientBuilder;
+import me.chanjar.weixin.qidian.bean.WxQidianHostConfig;
+import me.chanjar.weixin.qidian.config.WxQidianConfigStorage;
+import me.chanjar.weixin.qidian.util.json.WxQidianGsonBuilder;
+
+import java.io.File;
+import java.io.Serializable;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * 基于内存的微信配置provider,在实际生产环境中应该将这些配置持久化.
+ *
+ * @author chanjarster
+ */
+@Data
+public class WxQidianDefaultConfigImpl implements WxQidianConfigStorage, Serializable {
+  private static final long serialVersionUID = -6646519023303395185L;
+
+  protected volatile String appId;
+  protected volatile String secret;
+  protected volatile String token;
+  protected volatile String templateId;
+  protected volatile String accessToken;
+  protected volatile String aesKey;
+  protected volatile long expiresTime;
+
+  protected volatile String oauth2redirectUri;
+
+  protected volatile String httpProxyHost;
+  protected volatile int httpProxyPort;
+  protected volatile String httpProxyUsername;
+  protected volatile String httpProxyPassword;
+
+  protected volatile String jsapiTicket;
+  protected volatile long jsapiTicketExpiresTime;
+
+  protected volatile String sdkTicket;
+  protected volatile long sdkTicketExpiresTime;
+
+  protected volatile String cardApiTicket;
+  protected volatile long cardApiTicketExpiresTime;
+
+  protected volatile Lock accessTokenLock = new ReentrantLock();
+  protected volatile Lock jsapiTicketLock = new ReentrantLock();
+  protected volatile Lock sdkTicketLock = new ReentrantLock();
+  protected volatile Lock cardApiTicketLock = new ReentrantLock();
+
+  protected volatile File tmpDirFile;
+
+  protected volatile ApacheHttpClientBuilder apacheHttpClientBuilder;
+
+  private WxQidianHostConfig hostConfig = null;
+
+  @Override
+  public boolean isAccessTokenExpired() {
+    return System.currentTimeMillis() > this.expiresTime;
+  }
+
+  @Override
+  public synchronized void updateAccessToken(WxAccessToken accessToken) {
+    updateAccessToken(accessToken.getAccessToken(), accessToken.getExpiresIn());
+  }
+
+  @Override
+  public synchronized void updateAccessToken(String accessToken, int expiresInSeconds) {
+    this.accessToken = accessToken;
+    this.expiresTime = System.currentTimeMillis() + (expiresInSeconds - 200) * 1000L;
+  }
+
+  @Override
+  public void expireAccessToken() {
+    this.expiresTime = 0;
+  }
+
+  @Override
+  public String getTicket(TicketType type) {
+    switch (type) {
+      case SDK:
+        return this.sdkTicket;
+      case JSAPI:
+        return this.jsapiTicket;
+      case WX_CARD:
+        return this.cardApiTicket;
+      default:
+        return null;
+    }
+  }
+
+  public void setTicket(TicketType type, String ticket) {
+    switch (type) {
+      case JSAPI:
+        this.jsapiTicket = ticket;
+        break;
+      case WX_CARD:
+        this.cardApiTicket = ticket;
+        break;
+      case SDK:
+        this.sdkTicket = ticket;
+        break;
+      default:
+    }
+  }
+
+  @Override
+  public Lock getTicketLock(TicketType type) {
+    switch (type) {
+      case SDK:
+        return this.sdkTicketLock;
+      case JSAPI:
+        return this.jsapiTicketLock;
+      case WX_CARD:
+        return this.cardApiTicketLock;
+      default:
+        return null;
+    }
+  }
+
+  @Override
+  public boolean isTicketExpired(TicketType type) {
+    switch (type) {
+      case SDK:
+        return System.currentTimeMillis() > this.sdkTicketExpiresTime;
+      case JSAPI:
+        return System.currentTimeMillis() > this.jsapiTicketExpiresTime;
+      case WX_CARD:
+        return System.currentTimeMillis() > this.cardApiTicketExpiresTime;
+      default:
+        return false;
+    }
+  }
+
+  @Override
+  public synchronized void updateTicket(TicketType type, String ticket, int expiresInSeconds) {
+    switch (type) {
+      case JSAPI:
+        this.jsapiTicket = ticket;
+        // 预留200秒的时间
+        this.jsapiTicketExpiresTime = System.currentTimeMillis() + (expiresInSeconds - 200) * 1000L;
+        break;
+      case WX_CARD:
+        this.cardApiTicket = ticket;
+        // 预留200秒的时间
+        this.cardApiTicketExpiresTime = System.currentTimeMillis() + (expiresInSeconds - 200) * 1000L;
+        break;
+      case SDK:
+        this.sdkTicket = ticket;
+        // 预留200秒的时间
+        this.sdkTicketExpiresTime = System.currentTimeMillis() + (expiresInSeconds - 200) * 1000L;
+        break;
+      default:
+    }
+  }
+
+  @Override
+  public void expireTicket(TicketType type) {
+    switch (type) {
+      case JSAPI:
+        this.jsapiTicketExpiresTime = 0;
+        break;
+      case WX_CARD:
+        this.cardApiTicketExpiresTime = 0;
+        break;
+      case SDK:
+        this.sdkTicketExpiresTime = 0;
+        break;
+      default:
+    }
+  }
+
+  @Override
+  public String toString() {
+    return WxQidianGsonBuilder.create().toJson(this);
+  }
+
+  @Override
+  public boolean autoRefreshToken() {
+    return true;
+  }
+
+  @Override
+  public WxQidianHostConfig getHostConfig() {
+    return this.hostConfig;
+  }
+
+  @Override
+  public void setHostConfig(WxQidianHostConfig hostConfig) {
+    this.hostConfig = hostConfig;
+  }
+
+}

+ 99 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/config/impl/WxQidianRedisConfigImpl.java

@@ -0,0 +1,99 @@
+package me.chanjar.weixin.qidian.config.impl;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import me.chanjar.weixin.common.enums.TicketType;
+import me.chanjar.weixin.common.redis.WxRedisOps;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 基于Redis的微信配置provider.
+ *
+ * <pre>
+ *    使用说明:本实现仅供参考,并不完整,
+ *    比如为减少项目依赖,未加入redis分布式锁的实现,如有需要请自行实现。
+ * </pre>
+ *
+ * @author nickwong
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+public class WxQidianRedisConfigImpl extends WxQidianDefaultConfigImpl {
+  private static final long serialVersionUID = -988502871997239733L;
+
+  private static final String ACCESS_TOKEN_KEY_TPL = "%s:access_token:%s";
+  private static final String TICKET_KEY_TPL = "%s:ticket:key:%s:%s";
+  private static final String LOCK_KEY_TPL = "%s:lock:%s:";
+
+  private final WxRedisOps redisOps;
+  private final String keyPrefix;
+
+  private String accessTokenKey;
+  private String lockKey;
+
+  public WxQidianRedisConfigImpl(WxRedisOps redisOps, String keyPrefix) {
+    this.redisOps = redisOps;
+    this.keyPrefix = keyPrefix;
+  }
+
+  /**
+   * 每个公众号生成独有的存储key.
+   */
+  @Override
+  public void setAppId(String appId) {
+    super.setAppId(appId);
+    this.accessTokenKey = String.format(ACCESS_TOKEN_KEY_TPL, this.keyPrefix, appId);
+    this.lockKey = String.format(LOCK_KEY_TPL, this.keyPrefix, appId);
+    accessTokenLock = this.redisOps.getLock(lockKey.concat("accessTokenLock"));
+    jsapiTicketLock = this.redisOps.getLock(lockKey.concat("jsapiTicketLock"));
+    sdkTicketLock = this.redisOps.getLock(lockKey.concat("sdkTicketLock"));
+    cardApiTicketLock = this.redisOps.getLock(lockKey.concat("cardApiTicketLock"));
+  }
+
+  private String getTicketRedisKey(TicketType type) {
+    return String.format(TICKET_KEY_TPL, this.keyPrefix, appId, type.getCode());
+  }
+
+  @Override
+  public String getAccessToken() {
+    return redisOps.getValue(this.accessTokenKey);
+  }
+
+  @Override
+  public boolean isAccessTokenExpired() {
+    Long expire = redisOps.getExpire(this.accessTokenKey);
+    return expire == null || expire < 2;
+  }
+
+  @Override
+  public synchronized void updateAccessToken(String accessToken, int expiresInSeconds) {
+    redisOps.setValue(this.accessTokenKey, accessToken, expiresInSeconds - 200, TimeUnit.SECONDS);
+  }
+
+  @Override
+  public void expireAccessToken() {
+    redisOps.expire(this.accessTokenKey, 0, TimeUnit.SECONDS);
+  }
+
+  @Override
+  public String getTicket(TicketType type) {
+    return redisOps.getValue(this.getTicketRedisKey(type));
+  }
+
+  @Override
+  public boolean isTicketExpired(TicketType type) {
+    return redisOps.getExpire(this.getTicketRedisKey(type)) < 2;
+  }
+
+  @Override
+  public synchronized void updateTicket(TicketType type, String jsapiTicket, int expiresInSeconds) {
+    redisOps.setValue(this.getTicketRedisKey(type), jsapiTicket, expiresInSeconds - 200, TimeUnit.SECONDS);
+  }
+
+  @Override
+  public void expireTicket(TicketType type) {
+    redisOps.expire(this.getTicketRedisKey(type), 0, TimeUnit.SECONDS);
+  }
+
+}

+ 101 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/config/impl/WxQidianRedissonConfigImpl.java

@@ -0,0 +1,101 @@
+package me.chanjar.weixin.qidian.config.impl;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NonNull;
+import me.chanjar.weixin.common.enums.TicketType;
+import me.chanjar.weixin.common.redis.RedissonWxRedisOps;
+import me.chanjar.weixin.common.redis.WxRedisOps;
+import org.redisson.api.RedissonClient;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * @author wuxingye
+ * @date 2020/6/12
+ */
+@EqualsAndHashCode(callSuper = true)
+@Data
+public class WxQidianRedissonConfigImpl extends WxQidianDefaultConfigImpl {
+
+  private static final long serialVersionUID = -5139855123878455556L;
+  private static final String ACCESS_TOKEN_KEY_TPL = "%s:access_token:%s";
+  private static final String TICKET_KEY_TPL = "%s:ticket:key:%s:%s";
+  private static final String LOCK_KEY_TPL = "%s:lock:%s:";
+  private final WxRedisOps redisOps;
+  private final String keyPrefix;
+  private String accessTokenKey;
+  private String lockKey;
+
+  public WxQidianRedissonConfigImpl(@NonNull RedissonClient redissonClient, String keyPrefix) {
+    this(new RedissonWxRedisOps(redissonClient), keyPrefix);
+  }
+
+  public WxQidianRedissonConfigImpl(@NonNull RedissonClient redissonClient) {
+    this(redissonClient, null);
+  }
+
+  private WxQidianRedissonConfigImpl(@NonNull WxRedisOps redisOps, String keyPrefix) {
+    this.redisOps = redisOps;
+    this.keyPrefix = keyPrefix;
+  }
+
+  /**
+   * 每个公众号生成独有的存储key.
+   */
+  @Override
+  public void setAppId(String appId) {
+    super.setAppId(appId);
+    this.accessTokenKey = String.format(ACCESS_TOKEN_KEY_TPL, this.keyPrefix, appId);
+    this.lockKey = String.format(LOCK_KEY_TPL, this.keyPrefix, appId);
+    accessTokenLock = this.redisOps.getLock(lockKey.concat("accessTokenLock"));
+    jsapiTicketLock = this.redisOps.getLock(lockKey.concat("jsapiTicketLock"));
+    sdkTicketLock = this.redisOps.getLock(lockKey.concat("sdkTicketLock"));
+    cardApiTicketLock = this.redisOps.getLock(lockKey.concat("cardApiTicketLock"));
+  }
+
+  private String getTicketRedisKey(TicketType type) {
+    return String.format(TICKET_KEY_TPL, this.keyPrefix, appId, type.getCode());
+  }
+
+  @Override
+  public String getAccessToken() {
+    return redisOps.getValue(this.accessTokenKey);
+  }
+
+  @Override
+  public boolean isAccessTokenExpired() {
+    Long expire = redisOps.getExpire(this.accessTokenKey);
+    return expire == null || expire < 2;
+  }
+
+  @Override
+  public synchronized void updateAccessToken(String accessToken, int expiresInSeconds) {
+    redisOps.setValue(this.accessTokenKey, accessToken, expiresInSeconds - 200, TimeUnit.SECONDS);
+  }
+
+  @Override
+  public void expireAccessToken() {
+    redisOps.expire(this.accessTokenKey, 0, TimeUnit.SECONDS);
+  }
+
+  @Override
+  public String getTicket(TicketType type) {
+    return redisOps.getValue(this.getTicketRedisKey(type));
+  }
+
+  @Override
+  public boolean isTicketExpired(TicketType type) {
+    return redisOps.getExpire(this.getTicketRedisKey(type)) < 2;
+  }
+
+  @Override
+  public synchronized void updateTicket(TicketType type, String jsapiTicket, int expiresInSeconds) {
+    redisOps.setValue(this.getTicketRedisKey(type), jsapiTicket, expiresInSeconds - 200, TimeUnit.SECONDS);
+  }
+
+  @Override
+  public void expireTicket(TicketType type) {
+    redisOps.expire(this.getTicketRedisKey(type), 0, TimeUnit.SECONDS);
+  }
+}

+ 155 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/enums/WxQidianApiUrl.java

@@ -0,0 +1,155 @@
+package me.chanjar.weixin.qidian.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import me.chanjar.weixin.qidian.bean.WxQidianHostConfig;
+import me.chanjar.weixin.qidian.config.WxQidianConfigStorage;
+
+import static me.chanjar.weixin.qidian.bean.WxQidianHostConfig.*;
+
+/**
+ * <pre>
+ *  腾讯企点接口api地址
+ *  Created by alegria on 2020年12月26日.
+ * </pre>
+ */
+public interface WxQidianApiUrl {
+
+  /**
+   * 得到api完整地址.
+   *
+   * @param config 微信公众号配置
+   * @return api地址
+   */
+  default String getUrl(WxQidianConfigStorage config) {
+    WxQidianHostConfig hostConfig = null;
+    if (config != null) {
+      hostConfig = config.getHostConfig();
+    }
+    return buildUrl(hostConfig, this.getPrefix(), this.getPath());
+
+  }
+
+  /**
+   * the path
+   *
+   * @return path
+   */
+  String getPath();
+
+  /**
+   * the prefix
+   *
+   * @return prefix
+   */
+  String getPrefix();
+
+  @AllArgsConstructor
+  @Getter
+  enum OAuth2 implements WxQidianApiUrl {
+    /**
+     * 用code换取oauth2的access token.
+     */
+    OAUTH2_ACCESS_TOKEN_URL(API_DEFAULT_HOST_URL,
+        "/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code"),
+    /**
+     * 刷新oauth2的access token.
+     */
+    OAUTH2_REFRESH_TOKEN_URL(API_DEFAULT_HOST_URL,
+        "/sns/oauth2/refresh_token?appid=%s&grant_type=refresh_token&refresh_token=%s"),
+    /**
+     * 用oauth2获取用户信息.
+     */
+    OAUTH2_USERINFO_URL(API_DEFAULT_HOST_URL, "/sns/userinfo?access_token=%s&openid=%s&lang=%s"),
+    /**
+     * 验证oauth2的access token是否有效.
+     */
+    OAUTH2_VALIDATE_TOKEN_URL(API_DEFAULT_HOST_URL, "/sns/auth?access_token=%s&openid=%s"),
+    /**
+     * oauth2授权的url连接.
+     */
+    CONNECT_OAUTH2_AUTHORIZE_URL(OPEN_DEFAULT_HOST_URL,
+        "/connect/oauth2/authorize?appid=%s&redirect_uri=%s&response_type=code&scope=%s&state=%s&connect_redirect=1#wechat_redirect");
+
+    private final String prefix;
+    private final String path;
+
+  }
+
+  @AllArgsConstructor
+  @Getter
+  enum Other implements WxQidianApiUrl {
+    /**
+     * 获取access_token.
+     */
+    GET_ACCESS_TOKEN_URL(QIDIAN_DEFAULT_HOST_URL, "/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s"),
+    /**
+     * 获得各种类型的ticket.
+     */
+    GET_TICKET_URL(API_DEFAULT_HOST_URL, "/cgi-bin/ticket/getticket?type="),
+    /**
+     * 长链接转短链接接口.
+     */
+    SHORTURL_API_URL(API_DEFAULT_HOST_URL, "/cgi-bin/shorturl"),
+    /**
+     * 语义查询接口.
+     */
+    SEMANTIC_SEMPROXY_SEARCH_URL(API_DEFAULT_HOST_URL, "/semantic/semproxy/search"),
+    /**
+     * 获取微信服务器IP地址.
+     */
+    GET_CALLBACK_IP_URL(API_DEFAULT_HOST_URL, "/cgi-bin/getcallbackip"),
+    /**
+     * 网络检测.
+     */
+    NETCHECK_URL(API_DEFAULT_HOST_URL, "/cgi-bin/callback/check"),
+    /**
+     * 第三方使用网站应用授权登录的url.
+     */
+    QRCONNECT_URL(OPEN_DEFAULT_HOST_URL,
+        "/connect/qrconnect?appid=%s&redirect_uri=%s&response_type=code&scope=%s&state=%s#wechat_redirect"),
+    /**
+     * 获取公众号的自动回复规则.
+     */
+    GET_CURRENT_AUTOREPLY_INFO_URL(API_DEFAULT_HOST_URL, "/cgi-bin/get_current_autoreply_info"),
+    /**
+     * 公众号调用或第三方平台帮公众号调用对公众号的所有api调用(包括第三方帮其调用)次数进行清零.
+     */
+    CLEAR_QUOTA_URL(API_DEFAULT_HOST_URL, "/cgi-bin/clear_quota");
+
+    private final String prefix;
+    private final String path;
+
+  }
+
+  @AllArgsConstructor
+  @Getter
+  enum Dial implements WxQidianApiUrl {
+    /**
+     * IVR外呼.
+     */
+    IVR_DIAL(QIDIAN_DEFAULT_HOST_URL, "/cgi-bin/call/dial/ivrdial"),
+    /**
+     * 拉取IVR列表.
+     */
+    GET_IVR_LIST(QIDIAN_DEFAULT_HOST_URL, "/cgi-bin/call/dial/getivrlist");
+
+    private final String prefix;
+    private final String path;
+
+  }
+
+  @AllArgsConstructor
+  @Getter
+  enum CallData implements WxQidianApiUrl {
+    /**
+     * 总机号列表拉取.
+     */
+    GET_SWITCH_BOARD_LIST(QIDIAN_DEFAULT_HOST_URL, "/cgi-bin/call/callData/getswitchboardlist");
+
+    private final String prefix;
+    private final String path;
+
+  }
+
+}

+ 29 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/util/WxQidianConfigStorageHolder.java

@@ -0,0 +1,29 @@
+package me.chanjar.weixin.qidian.util;
+
+/**
+ * @author alegria
+ * @date 2020年12月26日
+ */
+public class WxQidianConfigStorageHolder {
+  private final static ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<String>() {
+    @Override
+    protected String initialValue() {
+      return "default";
+    }
+  };
+
+  public static String get() {
+    return THREAD_LOCAL.get();
+  }
+
+  public static void set(String label) {
+    THREAD_LOCAL.set(label);
+  }
+
+  /**
+   * 此方法需要用户根据自己程序代码,在适当位置手动触发调用,本SDK里无法判断调用时机
+   */
+  public static void remove() {
+    THREAD_LOCAL.remove();
+  }
+}

+ 21 - 0
weixin-java-qidian/src/main/java/me/chanjar/weixin/qidian/util/json/WxQidianGsonBuilder.java

@@ -0,0 +1,21 @@
+package me.chanjar.weixin.qidian.util.json;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+
+/**
+ * @author someone
+ */
+public class WxQidianGsonBuilder {
+
+  private static final GsonBuilder INSTANCE = new GsonBuilder();
+
+  static {
+    INSTANCE.disableHtmlEscaping();
+  }
+
+  public static Gson create() {
+    return INSTANCE.create();
+  }
+
+}

+ 64 - 0
weixin-java-qidian/src/test/java/me/chanjar/weixin/qidian/api/WxMpBusyRetryTest.java

@@ -0,0 +1,64 @@
+package me.chanjar.weixin.qidian.api;
+
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.common.error.WxErrorException;
+import me.chanjar.weixin.common.error.WxRuntimeException;
+import me.chanjar.weixin.common.util.http.RequestExecutor;
+import me.chanjar.weixin.qidian.api.impl.WxQidianServiceHttpClientImpl;
+import org.testng.annotations.*;
+
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+
+@Test
+@Slf4j
+public class WxMpBusyRetryTest {
+
+  @DataProvider(name = "getService")
+  public Object[][] getService() {
+    WxQidianService service = new WxQidianServiceHttpClientImpl() {
+
+      @Override
+      public synchronized <T, E> T executeInternal(
+        RequestExecutor<T, E> executor, String uri, E data)
+        throws WxErrorException {
+        log.info("Executed");
+        throw new WxErrorException("something");
+      }
+    };
+
+    service.setMaxRetryTimes(3);
+    service.setRetrySleepMillis(500);
+    return new Object[][]{{service}};
+  }
+
+  @Test(dataProvider = "getService", expectedExceptions = RuntimeException.class)
+  public void testRetry(WxQidianService service) throws WxErrorException {
+    service.execute(null, (String)null, null);
+  }
+
+  @Test(dataProvider = "getService")
+  public void testRetryInThreadPool(final WxQidianService service) throws InterruptedException, ExecutionException {
+    // 当线程池中的线程复用的时候,还是能保证相同的重试次数
+    ExecutorService executorService = Executors.newFixedThreadPool(1);
+    Runnable runnable = () -> {
+      try {
+        System.out.println("=====================");
+        System.out.println(Thread.currentThread().getName() + ": testRetry");
+        service.execute(null, (String)null, null);
+      } catch (WxErrorException e) {
+        throw new WxRuntimeException(e);
+      } catch (RuntimeException e) {
+        // OK
+      }
+    };
+    Future<?> submit1 = executorService.submit(runnable);
+    Future<?> submit2 = executorService.submit(runnable);
+
+    submit1.get();
+    submit2.get();
+  }
+
+}

+ 37 - 0
weixin-java-qidian/src/test/java/me/chanjar/weixin/qidian/api/WxMpJsAPITest.java

@@ -0,0 +1,37 @@
+package me.chanjar.weixin.qidian.api;
+
+import com.google.inject.Inject;
+import me.chanjar.weixin.common.util.crypto.SHA1;
+import me.chanjar.weixin.qidian.api.test.ApiTestModule;
+import org.testng.Assert;
+import org.testng.annotations.Guice;
+import org.testng.annotations.Test;
+
+/**
+ * 测试jsapi ticket接口
+ *
+ * @author chanjarster
+ */
+@Test
+@Guice(modules = ApiTestModule.class)
+public class WxMpJsAPITest {
+
+  @Inject
+  protected WxQidianService wxService;
+
+  public void test() {
+    long timestamp = 1419835025L;
+    String url = "http://omstest.vmall.com:23568/thirdparty/wechat/vcode/gotoshare?quantity=1&batchName=MATE7";
+    String noncestr = "82693e11-b9bc-448e-892f-f5289f46cd0f";
+    String jsapiTicket = "bxLdikRXVbTPdHSM05e5u4RbEYQn7pNQMPrfzl8lJNb1foLDa3HIwI3BRMkQmSO_5F64VFa75uURcq6Uz7QHgA";
+    String result = SHA1.genWithAmple(
+      "jsapi_ticket=" + jsapiTicket,
+      "noncestr=" + noncestr,
+      "timestamp=" + timestamp,
+      "url=" + url
+    );
+
+    Assert.assertEquals(result, "c6f04b64d6351d197b71bd23fb7dd2d44c0db486");
+  }
+
+}

+ 407 - 0
weixin-java-qidian/src/test/java/me/chanjar/weixin/qidian/api/impl/BaseWxQidianServiceImplTest.java

@@ -0,0 +1,407 @@
+package me.chanjar.weixin.qidian.api.impl;
+
+import com.google.common.collect.Sets;
+import com.google.inject.Inject;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import me.chanjar.weixin.common.api.WxConsts;
+import me.chanjar.weixin.common.bean.WxJsapiSignature;
+import me.chanjar.weixin.common.bean.WxNetCheckResult;
+import me.chanjar.weixin.common.error.WxErrorException;
+import me.chanjar.weixin.qidian.api.WxQidianService;
+import me.chanjar.weixin.qidian.api.test.ApiTestModule;
+import me.chanjar.weixin.qidian.util.WxQidianConfigStorageHolder;
+import org.testng.Assert;
+import org.testng.annotations.Guice;
+import org.testng.annotations.Test;
+
+import java.util.Arrays;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+/**
+ * <pre>
+ *  Created by BinaryWang on 2019/3/29.
+ * </pre>
+ *
+ * @author <a href="https://github.com/binarywang">Binary Wang</a>
+ */
+@Test
+@Guice(modules = ApiTestModule.class)
+public class BaseWxQidianServiceImplTest {
+  @Inject
+  private WxQidianService wxService;
+
+  @Test
+  public void testSwitchover() {
+    assertTrue(this.wxService.switchover("another"));
+    assertThat(WxQidianConfigStorageHolder.get()).isEqualTo("another");
+    assertFalse(this.wxService.switchover("whatever"));
+    assertFalse(this.wxService.switchover("default"));
+  }
+
+  @Test
+  public void testSwitchoverTo() throws WxErrorException {
+    assertThat(this.wxService.switchoverTo("another").getAccessToken()).isNotEmpty();
+    assertThat(WxQidianConfigStorageHolder.get()).isEqualTo("another");
+  }
+
+  @Test
+  public void testNetCheck() throws WxErrorException {
+    WxNetCheckResult result = this.wxService.netCheck(WxConsts.NetCheckArgs.ACTIONALL, WxConsts.NetCheckArgs.OPERATORDEFAULT);
+    Assert.assertNotNull(result);
+
+  }
+
+  @Test
+  public void testGetCallbackIP() throws WxErrorException {
+    String[] ipArray = this.wxService.getCallbackIP();
+    System.out.println(Arrays.toString(ipArray));
+    Assert.assertNotNull(ipArray);
+    Assert.assertNotEquals(ipArray.length, 0);
+  }
+
+  public void testShortUrl() throws WxErrorException {
+    String shortUrl = this.wxService.shortUrl("http://www.baidu.com/test?access_token=123");
+    assertThat(shortUrl).isNotEmpty();
+    System.out.println(shortUrl);
+  }
+
+  @Test(expectedExceptions = WxErrorException.class)
+  public void testShortUrl_with_exceptional_url() throws WxErrorException {
+    this.wxService.shortUrl("http://www.baidu.com/test?redirect_count=1&access_token=123");
+  }
+
+  @Test
+  public void refreshAccessTokenDuplicatelyTest() throws InterruptedException {
+    // 测试多线程刷新accessToken时是否重复刷新
+    wxService.getWxMpConfigStorage().expireAccessToken();
+    final Set<String> set = Sets.newConcurrentHashSet();
+    Runnable r = () -> {
+      try {
+        String accessToken = wxService.getAccessToken();
+        set.add(accessToken);
+      } catch (WxErrorException e) {
+        e.printStackTrace();
+      }
+    };
+
+    final int threadNumber = 10;
+    ExecutorService executorService = Executors.newFixedThreadPool(threadNumber);
+    for ( int i = 0; i < threadNumber; i++ ) {
+      executorService.submit(r);
+    }
+    executorService.shutdown();
+    boolean isTerminated = executorService.awaitTermination(15, TimeUnit.SECONDS);
+    System.out.println("isTerminated: " + isTerminated);
+    System.out.println("times of refreshing accessToken: " + set.size());
+
+    assertEquals(set.size(), 1);
+
+  }
+
+  @Test
+  public void testCheckSignature() {
+  }
+
+  @Test
+  public void testGetTicket() {
+  }
+
+  @Test
+  public void testTestGetTicket() {
+  }
+
+  @Test
+  public void testGetJsapiTicket() {
+  }
+
+  @Test
+  public void testTestGetJsapiTicket() {
+  }
+
+  @Test
+  public void testCreateJsapiSignature() throws WxErrorException {
+    final WxJsapiSignature jsapiSignature = this.wxService.createJsapiSignature("http://www.baidu.com");
+    assertThat(jsapiSignature).isNotNull();
+    assertThat(jsapiSignature.getSignature()).isNotNull();
+    System.out.println(jsapiSignature);
+  }
+
+  @Test
+  public void testGetAccessToken() {
+  }
+
+  @Test
+  public void testSemanticQuery() {
+  }
+
+  @Test
+  public void testOauth2buildAuthorizationUrl() {
+  }
+
+  @Test
+  public void testBuildQrConnectUrl() {
+  }
+
+  @Test
+  public void testOauth2getAccessToken() {
+  }
+
+  @Test
+  public void testOauth2refreshAccessToken() {
+  }
+
+  @Test
+  public void testOauth2getUserInfo() {
+  }
+
+  @Test
+  public void testOauth2validateAccessToken() {
+  }
+
+  @Test
+  public void testGetCurrentAutoReplyInfo() {
+  }
+
+  @Test
+  public void testClearQuota() {
+  }
+
+  @Test
+  public void testGet() {
+  }
+
+  @Test
+  public void testTestGet() {
+  }
+
+  @Test
+  public void testPost() {
+  }
+
+  @Test
+  public void testTestPost() {
+  }
+
+  @Test
+  public void testExecute() {
+  }
+
+  @Test
+  public void testTestExecute() {
+  }
+
+  @Test
+  public void testExecuteInternal() {
+  }
+
+  @Test
+  public void testGetWxMpConfigStorage() {
+  }
+
+  @Test
+  public void testSetWxMpConfigStorage() {
+  }
+
+  @Test
+  public void testSetMultiConfigStorages() {
+  }
+
+  @Test
+  public void testTestSetMultiConfigStorages() {
+  }
+
+  @Test
+  public void testAddConfigStorage() {
+  }
+
+  @Test
+  public void testRemoveConfigStorage() {
+  }
+
+  @Test
+  public void testSetRetrySleepMillis() {
+  }
+
+  @Test
+  public void testSetMaxRetryTimes() {
+  }
+
+  @Test
+  public void testGetKefuService() {
+  }
+
+  @Test
+  public void testGetMaterialService() {
+  }
+
+  @Test
+  public void testGetMenuService() {
+  }
+
+  @Test
+  public void testGetUserService() {
+  }
+
+  @Test
+  public void testGetUserTagService() {
+  }
+
+  @Test
+  public void testGetQrcodeService() {
+  }
+
+  @Test
+  public void testGetCardService() {
+  }
+
+  @Test
+  public void testGetDataCubeService() {
+  }
+
+  @Test
+  public void testGetBlackListService() {
+  }
+
+  @Test
+  public void testGetStoreService() {
+  }
+
+  @Test
+  public void testGetTemplateMsgService() {
+  }
+
+  @Test
+  public void testGetSubscribeMsgService() {
+  }
+
+  @Test
+  public void testGetDeviceService() {
+  }
+
+  @Test
+  public void testGetShakeService() {
+  }
+
+  @Test
+  public void testGetMemberCardService() {
+  }
+
+  @Test
+  public void testGetRequestHttp() {
+  }
+
+  @Test
+  public void testGetMassMessageService() {
+  }
+
+  @Test
+  public void testSetKefuService() {
+  }
+
+  @Test
+  public void testSetMaterialService() {
+  }
+
+  @Test
+  public void testSetMenuService() {
+  }
+
+  @Test
+  public void testSetUserService() {
+  }
+
+  @Test
+  public void testSetTagService() {
+  }
+
+  @Test
+  public void testSetQrCodeService() {
+  }
+
+  @Test
+  public void testSetCardService() {
+  }
+
+  @Test
+  public void testSetStoreService() {
+  }
+
+  @Test
+  public void testSetDataCubeService() {
+  }
+
+  @Test
+  public void testSetBlackListService() {
+  }
+
+  @Test
+  public void testSetTemplateMsgService() {
+  }
+
+  @Test
+  public void testSetDeviceService() {
+  }
+
+  @Test
+  public void testSetShakeService() {
+  }
+
+  @Test
+  public void testSetMemberCardService() {
+  }
+
+  @Test
+  public void testSetMassMessageService() {
+  }
+
+  @Test
+  public void testGetAiOpenService() {
+  }
+
+  @Test
+  public void testSetAiOpenService() {
+  }
+
+  @Test
+  public void testGetWifiService() {
+  }
+
+  @Test
+  public void testGetOcrService() {
+  }
+
+  @Test
+  public void testGetMarketingService() {
+  }
+
+  @Test
+  public void testSetMarketingService() {
+  }
+
+  @Test
+  public void testSetOcrService() {
+  }
+
+  @Test
+  public void testGetCommentService() {
+  }
+
+  @Test
+  public void testSetCommentService() {
+  }
+
+  @Test
+  public void testGetImgProcService() {
+  }
+
+  @Test
+  public void testSetImgProcService() {
+  }
+}

+ 58 - 0
weixin-java-qidian/src/test/java/me/chanjar/weixin/qidian/api/impl/WxQidianDialServiceImplTest.java

@@ -0,0 +1,58 @@
+package me.chanjar.weixin.qidian.api.impl;
+
+import java.util.List;
+import java.util.Optional;
+
+import com.google.inject.Inject;
+
+import org.testng.Assert;
+import org.testng.annotations.Guice;
+import org.testng.annotations.Test;
+
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.common.error.WxErrorException;
+import me.chanjar.weixin.qidian.api.WxQidianService;
+import me.chanjar.weixin.qidian.api.test.ApiTestModule;
+import me.chanjar.weixin.qidian.bean.call.GetSwitchBoardListResponse;
+import me.chanjar.weixin.qidian.bean.dial.IVRDialRequest;
+import me.chanjar.weixin.qidian.bean.dial.IVRDialResponse;
+import me.chanjar.weixin.qidian.bean.dial.IVRListResponse;
+import me.chanjar.weixin.qidian.bean.dial.Ivr;
+
+@Test
+@Guice(modules = ApiTestModule.class)
+@Slf4j
+public class WxQidianDialServiceImplTest {
+  @Inject
+  private WxQidianService wxService;
+
+  @Test
+  public void dial() throws WxErrorException {
+    // ivr
+    IVRListResponse iVRListResponse = this.wxService.getDialService().getIVRList();
+    Assert.assertEquals(iVRListResponse.getErrcode(), new Integer(0));
+    log.info("ivr size:" + iVRListResponse.getNode().size());
+    Optional<Ivr> optional = iVRListResponse.getNode().stream().filter((o) -> o.getIvr_name().equals("自动接听需求测试"))
+        .findFirst();
+    Assert.assertTrue(optional.isPresent());
+    Ivr ivr = optional.get();
+    String ivr_id = ivr.getIvr_id();
+    // ivr_id = "433";
+
+    // switch
+    GetSwitchBoardListResponse getSwitchBoardListResponse = this.wxService.getCallDataService().getSwitchBoardList();
+    Assert.assertEquals(getSwitchBoardListResponse.getErrcode(), new Integer(0));
+    log.info("switch size:" + getSwitchBoardListResponse.getData().switchBoards().size());
+    List<String> switchBoards = getSwitchBoardListResponse.getData().switchBoards();
+
+    // ivrdial
+    IVRDialRequest ivrDial = new IVRDialRequest();
+    ivrDial.setPhone_number("18434399105");
+    // ivrDial.setPhone_number("13811768266");
+    ivrDial.setIvr_id(ivr_id);
+    ivrDial.setCorp_phone_list(switchBoards);
+    IVRDialResponse ivrDialResponse = this.wxService.getDialService().ivrDial(ivrDial);
+    Assert.assertEquals(ivrDialResponse.getCode(), new Integer(0));
+    log.info(ivrDialResponse.getCallid());
+  }
+}

+ 51 - 0
weixin-java-qidian/src/test/java/me/chanjar/weixin/qidian/api/test/ApiTestModule.java

@@ -0,0 +1,51 @@
+package me.chanjar.weixin.qidian.api.test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.concurrent.locks.ReentrantLock;
+
+import com.google.inject.Binder;
+import com.google.inject.Module;
+import com.thoughtworks.xstream.XStream;
+
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.common.error.WxRuntimeException;
+import me.chanjar.weixin.common.util.xml.XStreamInitializer;
+import me.chanjar.weixin.qidian.api.WxQidianService;
+import me.chanjar.weixin.qidian.api.impl.WxQidianServiceHttpClientImpl;
+import me.chanjar.weixin.qidian.config.WxQidianConfigStorage;
+
+@Slf4j
+public class ApiTestModule implements Module {
+  private static final String TEST_CONFIG_XML = "test-config.xml";
+
+  @Override
+  public void configure(Binder binder) {
+    try (InputStream inputStream = ClassLoader.getSystemResourceAsStream(TEST_CONFIG_XML)) {
+      if (inputStream == null) {
+        throw new WxRuntimeException("测试配置文件【" + TEST_CONFIG_XML + "】未找到,请参照test-config-sample.xml文件生成");
+      }
+
+      TestConfigStorage config = this.fromXml(TestConfigStorage.class, inputStream);
+      config.setAccessTokenLock(new ReentrantLock());
+      WxQidianService mpService = new WxQidianServiceHttpClientImpl();
+
+      mpService.setWxMpConfigStorage(config);
+      mpService.addConfigStorage("another", config);
+
+      binder.bind(WxQidianConfigStorage.class).toInstance(config);
+      binder.bind(WxQidianService.class).toInstance(mpService);
+    } catch (IOException e) {
+      log.error(e.getMessage(), e);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  private <T> T fromXml(Class<T> clazz, InputStream is) {
+    XStream xstream = XStreamInitializer.getInstance();
+    xstream.alias("xml", clazz);
+    xstream.processAnnotations(clazz);
+    return (T) xstream.fromXML(is);
+  }
+
+}

+ 69 - 0
weixin-java-qidian/src/test/java/me/chanjar/weixin/qidian/api/test/TestConfigStorage.java

@@ -0,0 +1,69 @@
+package me.chanjar.weixin.qidian.api.test;
+
+import com.thoughtworks.xstream.annotations.XStreamAlias;
+import me.chanjar.weixin.qidian.config.impl.WxQidianDefaultConfigImpl;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+
+import java.util.concurrent.locks.Lock;
+
+@XStreamAlias("xml")
+public class TestConfigStorage extends WxQidianDefaultConfigImpl {
+
+  private String openid;
+  private String kfAccount;
+  private String qrconnectRedirectUrl;
+  private String templateId;
+  private String keyPath;
+
+  public String getKeyPath() {
+    return keyPath;
+  }
+
+  public void setKeyPath(String keyPath) {
+    this.keyPath = keyPath;
+  }
+
+  public String getOpenid() {
+    return this.openid;
+  }
+
+  public void setOpenid(String openid) {
+    this.openid = openid;
+  }
+
+  @Override
+  public String toString() {
+    return ToStringBuilder.reflectionToString(this);
+  }
+
+  public String getKfAccount() {
+    return this.kfAccount;
+  }
+
+  public void setKfAccount(String kfAccount) {
+    this.kfAccount = kfAccount;
+  }
+
+  public String getQrconnectRedirectUrl() {
+    return this.qrconnectRedirectUrl;
+  }
+
+  public void setQrconnectRedirectUrl(String qrconnectRedirectUrl) {
+    this.qrconnectRedirectUrl = qrconnectRedirectUrl;
+  }
+
+  @Override
+  public String getTemplateId() {
+    return this.templateId;
+  }
+
+  @Override
+  public void setTemplateId(String templateId) {
+    this.templateId = templateId;
+  }
+
+  public void setAccessTokenLock(Lock lock) {
+    super.accessTokenLock = lock;
+  }
+
+}

+ 13 - 0
weixin-java-qidian/src/test/resources/logback-test.xml

@@ -0,0 +1,13 @@
+<configuration>
+
+  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+    <encoder>
+      <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %replace(%caller{1}){'Caller', ''} - %msg%n</pattern>
+    </encoder>
+  </appender>
+
+  <root level="debug">
+    <appender-ref ref="STDOUT"/>
+  </root>
+
+</configuration>

+ 16 - 0
weixin-java-qidian/src/test/resources/test-config.sample.xml

@@ -0,0 +1,16 @@
+<xml>
+  <appId>公众号appID</appId>
+  <secret>公众号appsecret</secret>
+  <token>公众号Token</token>
+  <aesKey>公众号EncodingAESKey</aesKey>
+  <accessToken>可以不填写</accessToken>
+  <expiresTime>可以不填写</expiresTime>
+  <openid>某个加你公众号的用户的openId</openid>
+  <partnerId>微信商户平台ID</partnerId>
+  <partnerKey>商户平台设置的API密钥</partnerKey>
+  <keyPath>商户平台的证书文件地址</keyPath>
+  <templateId>模版消息的模版ID</templateId>
+  <oauth2redirectUri>网页授权获取用户信息回调地址</oauth2redirectUri>
+  <qrconnectRedirectUrl>网页应用授权登陆回调地址</qrconnectRedirectUrl>
+  <kfAccount>完整客服账号,格式为:账号前缀@公众号微信号</kfAccount>
+</xml>

+ 30 - 0
weixin-java-qidian/src/test/resources/testng.xml

@@ -0,0 +1,30 @@
+<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd" >
+
+<suite name="Weixin-java-tool-suite" verbose="1">
+  <test name="API_Test">
+    <classes>
+      <class name="me.chanjar.weixin.qidian.api.WxMpBusyRetryTest"/>
+      <class name="me.chanjar.weixin.qidian.api.WxMpBaseAPITest"/>
+      <class name="me.chanjar.weixin.qidian.api.impl.WxMpMassMessageServiceImplTest"/>
+      <class name="me.chanjar.weixin.qidian.api.impl.WxMpUserServiceImplTest"/>
+      <class name="me.chanjar.weixin.qidian.api.impl.WxMpQrcodeServiceImplTest"/>
+      <class name="me.chanjar.weixin.qidian.api.WxMpShortUrlAPITest"/>
+      <class name="me.chanjar.weixin.qidian.api.WxMpMessageRouterTest"/>
+      <class name="me.chanjar.weixin.qidian.api.WxMpJsAPITest"/>
+      <class name="me.chanjar.weixin.qidian.api.WxMpMiscAPITest"/>
+    </classes>
+  </test>
+
+  <test name="Bean_Test">
+    <classes>
+      <class name="me.chanjar.weixin.qidian.bean.kefu.WxMpKefuMessageTest"/>
+      <class name="me.chanjar.weixin.qidian.bean.message.WxMpXmlMessageTest"/>
+      <class name="me.chanjar.weixin.qidian.bean.message.WxMpXmlOutImageMessageTest"/>
+      <class name="me.chanjar.weixin.qidian.bean.message.WxMpXmlOutMusicMessageTest"/>
+      <class name="me.chanjar.weixin.qidian.bean.message.WxMpXmlOutNewsMessageTest"/>
+      <class name="me.chanjar.weixin.qidian.bean.message.WxMpXmlOutVideoMessageTest"/>
+      <class name="me.chanjar.weixin.qidian.bean.message.WxMpXmlOutVoiceMessageTest"/>
+      <class name="me.chanjar.weixin.qidian.bean.message.WxMpXmlOutTextMessageTest"/>
+    </classes>
+  </test>
+</suite>