对接企业微信
This commit is contained in:
297
src/test/java/com/rj/qiwei/QiWeiContactsReadTest.java
Normal file
297
src/test/java/com/rj/qiwei/QiWeiContactsReadTest.java
Normal file
@@ -0,0 +1,297 @@
|
||||
package com.rj.qiwei;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.rj.common.DictItemConstants;
|
||||
import com.rj.common.PasswordUtil;
|
||||
import com.rj.dto.QiWeiConfig;
|
||||
import com.rj.entity.DictItem;
|
||||
import com.rj.mapper.DictItemMapper;
|
||||
import com.rj.tenant.TenantContextHolder;
|
||||
import cn.felord.DefaultAgent;
|
||||
import cn.felord.WeComException;
|
||||
import cn.felord.WeComTokenCacheable;
|
||||
import cn.felord.api.UserApi;
|
||||
import cn.felord.domain.contactbook.user.DeptUserListResponse;
|
||||
import cn.felord.domain.contactbook.user.DeptUser;
|
||||
import cn.felord.domain.contactbook.user.UserInfoResponse;
|
||||
import cn.felord.domain.contactbook.user.SimpleUser;
|
||||
import cn.felord.domain.GenericResponse;
|
||||
import cn.felord.retrofit.AccessTokenApi;
|
||||
import cn.felord.retrofit.WorkWechatRetrofitFactory;
|
||||
import okhttp3.ConnectionPool;
|
||||
import okhttp3.logging.HttpLoggingInterceptor;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
public class QiWeiContactsReadTest {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private DictItemMapper dictItemMapper;
|
||||
|
||||
@Test
|
||||
public void readContactsFromWeCom() throws Exception {
|
||||
QiWeiConfig cfg = loadQiWeiConfigFromDbOrSystemProps();
|
||||
|
||||
// 支持通过 JVM 参数覆盖 DB 配置,便于快速联调:
|
||||
// -Dqiwei.corpid=xxx -Dqiwei.contactSecret=xxx -Dqiwei.appSecret=xxx
|
||||
String corpId = requireNonBlank(firstNonBlank(System.getProperty("qiwei.corpid"), cfg.getCorpid()),
|
||||
"qiwei_config.corpid 不能为空");
|
||||
String appSecret = trimToNull(firstNonBlank(System.getProperty("qiwei.appSecret"), cfg.getAppSecret()));
|
||||
String contactSecret = trimToNull(firstNonBlank(System.getProperty("qiwei.contactSecret"), cfg.getContractSecret()));
|
||||
assertTrue(appSecret != null || contactSecret != null, "qiwei_config.appSecret/contractSecret 至少一个不能为空");
|
||||
|
||||
// 通讯录读取优先使用通讯录 secret,若失败再尝试 app secret,便于自动命中有权限的一组凭证。
|
||||
List<SecretCandidate> candidates = buildSecretCandidates(contactSecret, appSecret);
|
||||
List<String> failures = new ArrayList<>();
|
||||
UserApi userApi = null;
|
||||
DeptUserListResponse idListResp = null;
|
||||
String selectedSecretType = null;
|
||||
|
||||
// 1) 拉取成员ID列表(适用于通讯录同步类 secret)
|
||||
for (SecretCandidate candidate : candidates) {
|
||||
System.out.println("qiwei test trying secretType=" + candidate.type
|
||||
+ ", corpId=" + corpId
|
||||
+ ", secretTail=" + maskSecretTail(candidate.secret));
|
||||
userApi = createUserApi(corpId, candidate.secret);
|
||||
try {
|
||||
idListResp = userApi.userList("", 100);
|
||||
selectedSecretType = candidate.type;
|
||||
break;
|
||||
} catch (WeComException e) {
|
||||
failures.add("secretType=" + candidate.type + ", error=" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (idListResp == null) {
|
||||
fail("读取通讯录失败:所有可用 secret 均不可用。corpId=" + corpId
|
||||
+ ";排查建议:1) 若报 e=60020 请把出口 IP 36.143.223.91 加入该 secret 的可信 IP;"
|
||||
+ "2) 若报 e=48002 请给对应应用开通通讯录读取权限并放开可见范围;"
|
||||
+ "3) 确认优先使用的是通讯录 secret。失败明细=" + failures);
|
||||
}
|
||||
assertNotNull(userApi, "UserApi 初始化失败");
|
||||
assertNotNull(idListResp, "企微 user/list_id 响应不应为空");
|
||||
System.out.println("qiwei test selected secretType=" + selectedSecretType);
|
||||
System.out.println("userList resp: " + OBJECT_MAPPER.writeValueAsString(idListResp));
|
||||
|
||||
List<DeptUser> deptUsersFromList = idListResp.getDeptUser();
|
||||
List<String> userIds = deptUsersFromList == null ? null : deptUsersFromList.stream()
|
||||
.map(DeptUser::getUserid)
|
||||
.toList();
|
||||
if (userIds != null && !userIds.isEmpty()) {
|
||||
String firstUserId = userIds.get(0);
|
||||
UserInfoResponse user = tryGetUserWithFallback(userApi, firstUserId, corpId, appSecret);
|
||||
if (user != null) {
|
||||
System.out.println("first user(" + firstUserId + "): " + OBJECT_MAPPER.writeValueAsString(user));
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 拉取部门 1 的成员简要信息(需要应用对该部门有查看权限)
|
||||
GenericResponse<List<SimpleUser>> deptUsers = tryGetDeptUsersWithFallback(userApi, corpId, appSecret);
|
||||
if (deptUsers != null) {
|
||||
System.out.println("dept(1) simple users: " + OBJECT_MAPPER.writeValueAsString(deptUsers));
|
||||
}
|
||||
}
|
||||
|
||||
private QiWeiConfig loadQiWeiConfigFromDbOrSystemProps() throws Exception {
|
||||
// 可选:通过 -DtenantId=xxx 指定租户;不传则使用默认测试租户
|
||||
String tenantId = System.getProperty("tenantId", "TENANT_ID_CST_2026");
|
||||
|
||||
if (tenantId != null && !tenantId.trim().isEmpty()) {
|
||||
TenantContextHolder.setTenantId(tenantId.trim());
|
||||
try {
|
||||
LambdaQueryWrapper<DictItem> q = new LambdaQueryWrapper<DictItem>()
|
||||
.eq(DictItem::getName, DictItemConstants.QIWEI_CONFIG)
|
||||
.orderByDesc(DictItem::getUpdatedTime)
|
||||
.orderByDesc(DictItem::getCreatedTime)
|
||||
.last("limit 1");
|
||||
|
||||
DictItem item = dictItemMapper.selectOne(q);
|
||||
if (item != null && item.getValue() != null && !item.getValue().trim().isEmpty()) {
|
||||
String json = PasswordUtil.decryptReversibleWithDefaultSalt(item.getValue());
|
||||
QiWeiConfig cfg = OBJECT_MAPPER.readValue(json, QiWeiConfig.class);
|
||||
if (cfg != null) {
|
||||
return cfg;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
}
|
||||
Assumptions.assumeTrue(false,
|
||||
"未找到 qiwei_config(且未提供 -Dqiwei.corpid/-Dqiwei.contractSecret 或 -Dqiwei.appSecret),跳过该测试");
|
||||
return null; // never reached
|
||||
}
|
||||
|
||||
private static String requireNonBlank(String v, String message) {
|
||||
if (v == null || v.trim().isEmpty()) {
|
||||
throw new IllegalStateException(message);
|
||||
}
|
||||
return v.trim();
|
||||
}
|
||||
|
||||
private static UserApi createUserApi(String corpId, String secret) {
|
||||
String agentId = "0";
|
||||
WeComTokenCacheable cacheable = new InMemoryWeComTokenCacheable();
|
||||
AccessTokenApi tokenApi = new AccessTokenApi(cacheable, DefaultAgent.of(corpId, secret, agentId));
|
||||
var retrofit = WorkWechatRetrofitFactory.create(
|
||||
tokenApi,
|
||||
new ConnectionPool(),
|
||||
HttpLoggingInterceptor.Level.NONE
|
||||
);
|
||||
return retrofit.create(UserApi.class);
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String a, String b) {
|
||||
if (a != null && !a.trim().isEmpty()) {
|
||||
return a.trim();
|
||||
}
|
||||
if (b != null && !b.trim().isEmpty()) {
|
||||
return b.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<SecretCandidate> buildSecretCandidates(String contactSecret, String appSecret) {
|
||||
List<SecretCandidate> candidates = new ArrayList<>(2);
|
||||
if (contactSecret != null) {
|
||||
candidates.add(new SecretCandidate("contact", contactSecret));
|
||||
}
|
||||
if (appSecret != null && (contactSecret == null || !appSecret.equals(contactSecret))) {
|
||||
candidates.add(new SecretCandidate("app", appSecret));
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private static String trimToNull(String s) {
|
||||
if (s == null) {
|
||||
return null;
|
||||
}
|
||||
String t = s.trim();
|
||||
return t.isEmpty() ? null : t;
|
||||
}
|
||||
|
||||
private static UserInfoResponse tryGetUserWithFallback(UserApi currentApi, String userId, String corpId, String appSecret) {
|
||||
try {
|
||||
UserInfoResponse user = currentApi.getUser(userId);
|
||||
assertNotNull(user, "读取成员信息响应不应为空");
|
||||
return user;
|
||||
} catch (WeComException e) {
|
||||
if (!isContactAssistantForbidden(e)) {
|
||||
throw e;
|
||||
}
|
||||
System.out.println("[WARN] 当前 secret 调用 user/get 被限制(48009),尝试使用 appSecret 重试"+e.getMessage());
|
||||
if (appSecret == null) {
|
||||
System.out.println("[WARN] appSecret 为空,跳过 user/get");
|
||||
return null;
|
||||
}
|
||||
UserApi appApi = createUserApi(corpId, appSecret);
|
||||
try {
|
||||
UserInfoResponse user = appApi.getUser(userId);
|
||||
assertNotNull(user, "读取成员信息响应不应为空");
|
||||
return user;
|
||||
} catch (WeComException ex) {
|
||||
System.out.println("[WARN] appSecret 调用 user/get 仍失败,跳过。error=" + ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static GenericResponse<List<SimpleUser>> tryGetDeptUsersWithFallback(UserApi currentApi, String corpId, String appSecret) {
|
||||
try {
|
||||
GenericResponse<List<SimpleUser>> deptUsers = currentApi.getDeptUsers(1L);
|
||||
assertNotNull(deptUsers, "企微 user/simplelist 响应不应为空");
|
||||
return deptUsers;
|
||||
} catch (WeComException e) {
|
||||
if (!isContactAssistantForbidden(e)) {
|
||||
throw e;
|
||||
}
|
||||
System.out.println("[WARN] 当前 secret 调用 user/simplelist 被限制(48009),尝试使用 appSecret 重试");
|
||||
if (appSecret == null) {
|
||||
System.out.println("[WARN] appSecret 为空,跳过 user/simplelist");
|
||||
return null;
|
||||
}
|
||||
UserApi appApi = createUserApi(corpId, appSecret);
|
||||
try {
|
||||
GenericResponse<List<SimpleUser>> deptUsers = appApi.getDeptUsers(1L);
|
||||
assertNotNull(deptUsers, "企微 user/simplelist 响应不应为空");
|
||||
return deptUsers;
|
||||
} catch (WeComException ex) {
|
||||
System.out.println("[WARN] appSecret 调用 user/simplelist 仍失败,跳过。error=" + ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isContactAssistantForbidden(WeComException e) {
|
||||
String msg = e.getMessage();
|
||||
return msg != null && (msg.contains("e=48009") || msg.contains("api forbidden for contact assistant"));
|
||||
}
|
||||
|
||||
private static String maskSecretTail(String secret) {
|
||||
int n = Math.min(6, secret.length());
|
||||
return "***" + secret.substring(secret.length() - n);
|
||||
}
|
||||
|
||||
private record SecretCandidate(String type, String secret) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试用内存缓存:避免引入 Redis 依赖/配置。
|
||||
*/
|
||||
private static final class InMemoryWeComTokenCacheable implements WeComTokenCacheable {
|
||||
private final ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public String putCorpTicket(String corpId, String agentId, String corpTicket) {
|
||||
map.put("ticket:corp:" + corpId + ":" + agentId, corpTicket);
|
||||
return corpTicket;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCorpTicket(String corpId, String agentId) {
|
||||
return map.get("ticket:corp:" + corpId + ":" + agentId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String putAgentTicket(String corpId, String agentId, String agentTicket) {
|
||||
map.put("ticket:agent:" + corpId + ":" + agentId, agentTicket);
|
||||
return agentTicket;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAgentTicket(String corpId, String agentId) {
|
||||
return map.get("ticket:agent:" + corpId + ":" + agentId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String putAccessToken(String corpId, String agentId, String accessToken) {
|
||||
map.put("token:" + corpId + ":" + agentId, accessToken);
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAccessToken(String corpId, String agentId) {
|
||||
return map.get("token:" + corpId + ":" + agentId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearAccessToken(String corpId, String agentId) {
|
||||
map.remove("token:" + corpId + ":" + agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user