对接企业微信

This commit is contained in:
2026-04-24 20:10:51 +08:00
parent 03ba0c9090
commit d1ff7bde35
16 changed files with 1097 additions and 3 deletions

View 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);
}
}
}