171 lines
6.2 KiB
Java
171 lines
6.2 KiB
Java
package com.tailbet.openim;
|
||
|
||
|
||
import com.tailbet.common.enums.ErrorCodeEnum;
|
||
import com.tailbet.common.exception.BusinessException;
|
||
import com.tailbet.config.OpenIMProperties;
|
||
import lombok.RequiredArgsConstructor;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.http.HttpEntity;
|
||
import org.springframework.http.HttpHeaders;
|
||
import org.springframework.http.MediaType;
|
||
import org.springframework.http.ResponseEntity;
|
||
import org.springframework.scheduling.annotation.Scheduled;
|
||
import org.springframework.stereotype.Component;
|
||
import org.springframework.web.client.RestTemplate;
|
||
|
||
import jakarta.annotation.PostConstruct;
|
||
import jakarta.annotation.Resource;
|
||
|
||
import java.util.HashMap;
|
||
import java.util.Map;
|
||
import java.util.UUID;
|
||
import java.util.concurrent.atomic.AtomicReference;
|
||
|
||
/**
|
||
* OpenIM adminToken 管理器
|
||
* <p>
|
||
* 负责 OpenIM 管理员 Token 的获取与定期刷新:服务启动时通过 OpenIM
|
||
* {@code /auth/get_admin_token} 接口获取一次,之后每 23 小时自动刷新一次
|
||
* (OpenIM 管理员 Token 默认 24 小时过期,提前 1 小时刷新以规避临界失效)。
|
||
* </p>
|
||
* <p>
|
||
* 本类不依赖 {@link OpenIMApiClient},避免出现
|
||
* OpenIMApiClient → OpenIMAdminTokenManager → OpenIMApiClient 的循环依赖,
|
||
* 内部直接使用专用 RestTemplate 调用 OpenIM 接口。
|
||
* </p>
|
||
*
|
||
* @author socialapp团队
|
||
* @since 1.0.0
|
||
*/
|
||
@RequiredArgsConstructor
|
||
@Slf4j
|
||
@Component
|
||
public class OpenIMAdminTokenManager {
|
||
|
||
/**
|
||
* 定时刷新周期:23 小时(毫秒)
|
||
*/
|
||
private static final long REFRESH_INTERVAL_MS = 23 * 60 * 60 * 1000L;
|
||
|
||
private final RestTemplate restTemplate;
|
||
|
||
/**
|
||
* OpenIM 集成配置属性
|
||
*/
|
||
private final OpenIMProperties openIMProperties;
|
||
|
||
/**
|
||
* 当前有效的 adminToken,使用原子引用保证多线程读取的可见性与线程安全
|
||
*/
|
||
private final AtomicReference<String> tokenRef = new AtomicReference<>("");
|
||
|
||
/**
|
||
* 服务启动时初始化 adminToken
|
||
* <p>
|
||
* 若启动时获取失败,仅记录错误日志,不阻塞服务启动;
|
||
* 后续定时任务会持续尝试刷新,业务调用方在 adminToken 为空时会因鉴权失败而收到明确异常。
|
||
* </p>
|
||
*/
|
||
@PostConstruct
|
||
public void init() {
|
||
try {
|
||
refreshToken();
|
||
} catch (Exception e) {
|
||
log.error("【OpenIM】服务启动时获取 adminToken 失败: {}", e.getMessage(), e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 每 23 小时自动刷新 adminToken(避免 24 小时过期)
|
||
*/
|
||
@Scheduled(fixedDelay = REFRESH_INTERVAL_MS)
|
||
public void scheduledRefresh() {
|
||
try {
|
||
refreshToken();
|
||
} catch (Exception e) {
|
||
log.error("【OpenIM】定时刷新 adminToken 失败: {}", e.getMessage(), e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取当前有效的 adminToken
|
||
* <p>
|
||
* 若 token 尚未初始化(null 或空),则尝试立即刷新一次,防止 NPE 及空 token 导致的鉴权失败。
|
||
* </p>
|
||
*
|
||
* @return adminToken 字符串,刷新失败时返回空字符串
|
||
*/
|
||
public String getAdminToken() {
|
||
String token = tokenRef.get();
|
||
if (token == null || token.isEmpty()) {
|
||
log.warn("【OpenIM】adminToken 尚未初始化,尝试立即刷新");
|
||
refreshToken();
|
||
token = tokenRef.get();
|
||
}
|
||
return token != null ? token : "";
|
||
}
|
||
|
||
/**
|
||
* 调用 OpenIM {@code /auth/get_admin_token} 接口刷新 adminToken
|
||
* <p>
|
||
* 获取 adminToken 属于鉴权前置操作,请求本身不能携带 adminToken 请求头。
|
||
* 方法加锁,避免同进程内并发刷新互相顶掉(OpenIM 同 user 新 token 会使旧 token 变为 TokenKicked)。
|
||
* </p>
|
||
*/
|
||
public synchronized void refreshToken() {
|
||
String url = openIMProperties.resolveServerApiUrl() + "/auth/get_admin_token";
|
||
|
||
Map<String, Object> body = new HashMap<>();
|
||
body.put("secret", openIMProperties.getSecret());
|
||
body.put("userID", openIMProperties.getAdminUserId());
|
||
|
||
HttpHeaders headers = new HttpHeaders();
|
||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||
headers.set("operationID", UUID.randomUUID().toString());
|
||
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
|
||
|
||
OpenIMApiClient.OpenIMBaseResp resp;
|
||
try {
|
||
ResponseEntity<OpenIMApiClient.OpenIMBaseResp> response =
|
||
restTemplate.postForEntity(url, entity, OpenIMApiClient.OpenIMBaseResp.class);
|
||
resp = response.getBody();
|
||
} catch (org.springframework.web.client.HttpStatusCodeException e) {
|
||
// OpenIM 偶发以 HTTP 4xx + 业务 JSON 返回,尽量解析 errMsg
|
||
String raw = e.getResponseBodyAsString();
|
||
log.error("【OpenIM】获取 adminToken HTTP 失败: status={}, body={}", e.getStatusCode().value(), raw);
|
||
throw new BusinessException(ErrorCodeEnum.IM_OPENIM_GET_ADMIN_TOKEN_FAILED,
|
||
raw != null && !raw.isBlank() ? raw : e.getMessage());
|
||
}
|
||
|
||
if (resp == null || resp.getErrCode() != 0) {
|
||
String errMsg = resp != null ? resp.getErrMsg() : "响应为空";
|
||
log.error("【OpenIM】获取 adminToken 失败: errMsg={}", errMsg);
|
||
throw new BusinessException(ErrorCodeEnum.IM_OPENIM_GET_ADMIN_TOKEN_FAILED, errMsg);
|
||
}
|
||
|
||
@SuppressWarnings("unchecked")
|
||
Map<String, Object> data = (Map<String, Object>) resp.getData();
|
||
String newToken = data != null ? (String) data.get("token") : null;
|
||
if (newToken == null) {
|
||
throw new BusinessException(ErrorCodeEnum.IM_OPENIM_GET_ADMIN_TOKEN_FAILED, "响应中缺少 token 字段");
|
||
}
|
||
tokenRef.set(newToken);
|
||
log.info("【OpenIM】adminToken 刷新成功: token={}", maskString(newToken));
|
||
}
|
||
|
||
/**
|
||
* 对敏感字符串进行脱敏处理
|
||
* 只显示前3位和后3位,中间用星号代替
|
||
*
|
||
* @param str 原始字符串
|
||
* @return 脱敏后的字符串
|
||
*/
|
||
private String maskString(String str) {
|
||
if (str == null || str.length() <= 6) {
|
||
return "***";
|
||
}
|
||
return str.substring(0, 3) + "****" + str.substring(str.length() - 3);
|
||
}
|
||
}
|