OpenIMApiClient

This commit is contained in:
wells
2026-08-08 14:56:37 +08:00
parent 0e51812d80
commit aa48225d85
11 changed files with 3931 additions and 232 deletions
@@ -0,0 +1,284 @@
package com.tailbet.common.constant;
/**
* OpenIM 协议相关常量
* <p>
* 统一管理 Webhook 回调命令、请求字段、响应字段、消息类型等协议魔法值,
* 避免散落在 Controller / Service 中。
* </p>
*
* @author socialapp团队
* @since 1.0.7
*/
public final class OpenImConstants {
private OpenImConstants() {
}
// ==================== Webhook 回调命令 ====================
/** 单聊消息发送后回调 */
public static final String CALLBACK_AFTER_SEND_SINGLE_MSG = "callbackAfterSendSingleMsgCommand";
/** 群聊消息发送后回调 */
public static final String CALLBACK_AFTER_SEND_GROUP_MSG = "callbackAfterSendGroupMsgCommand";
/** 成员进群后回调 */
public static final String CALLBACK_AFTER_MEMBER_ENTER_GROUP = "callbackAfterMemberEnterGroupCommand";
/** 成员退群后回调 */
public static final String CALLBACK_AFTER_MEMBER_QUIT_GROUP = "callbackAfterMemberQuitGroupCommand";
/** 用户上线回调 */
public static final String CALLBACK_AFTER_USER_ONLINE = "callbackAfterUserOnlineCommand";
/** 用户下线回调 */
public static final String CALLBACK_AFTER_USER_OFFLINE = "callbackAfterUserOfflineCommand";
// ==================== 请求头 / JSON 字段名 ====================
/** OpenIM Webhook 鉴权请求头名 */
public static final String HEADER_TOKEN = "token";
/** 发送方用户 ID */
public static final String FIELD_SEND_ID = "sendID";
/** 接收方用户 ID */
public static final String FIELD_RECV_ID = "recvID";
/** 群组 IDOpenIM 群 ID */
public static final String FIELD_GROUP_ID = "groupID";
/** 用户 ID */
public static final String FIELD_USER_ID = "userID";
/** 平台 ID */
public static final String FIELD_PLATFORM_ID = "platformID";
/** 消息内容 */
public static final String FIELD_CONTENT = "content";
/** 消息内容类型 */
public static final String FIELD_CONTENT_TYPE = "contentType";
/** 客户端消息 ID */
public static final String FIELD_CLIENT_MSG_ID = "clientMsgID";
/** 服务端消息 ID */
public static final String FIELD_SERVER_MSG_ID = "serverMsgID";
// ==================== Webhook 成功响应 ====================
/** 响应字段:动作码 */
public static final String RESP_ACTION_CODE = "actionCode";
/** 响应字段:错误码 */
public static final String RESP_ERR_CODE = "errCode";
/** 响应字段:错误信息 */
public static final String RESP_ERR_MSG = "errMsg";
/** 响应字段:错误详情 */
public static final String RESP_ERR_DLT = "errDlt";
/** 响应字段:下一动作码 */
public static final String RESP_NEXT_CODE = "nextCode";
/** OpenIM 成功码(actionCode / errCode / nextCode */
public static final int RESP_CODE_SUCCESS = 0;
/** OpenIM 成功响应空字符串字段值 */
public static final String RESP_EMPTY_MSG = "";
// ==================== Token / 鉴权错误码(OpenIM 官方) ====================
/** Token 过期 */
public static final int ERR_TOKEN_EXPIRED = 1501;
/** Token 无效 */
public static final int ERR_TOKEN_INVALID = 1502;
/** Token 格式错误 */
public static final int ERR_TOKEN_MALFORMED = 1503;
/** Token 不存在 */
public static final int ERR_TOKEN_NOT_EXIST = 1504;
/** Token 平台不一致 */
public static final int ERR_TOKEN_DIFFERENT_PLATFORM = 1505;
/** Token 被踢(多端/多实例刷新 adminToken、重复 get_user_token 等) */
public static final int ERR_TOKEN_KICKED = 1506;
/** Token 尚未生效 */
public static final int ERR_TOKEN_NOT_VALID_YET = 1507;
/**
* 服务端代操作使用的平台 ID(Web)。
* 与 App 端 iOS/Android 平台隔离,避免顶掉用户已登录 session。
*/
public static final int PLATFORM_ID_SERVER_WEB = 5;
/** App 端 iOS 平台 ID */
public static final int PLATFORM_ID_IOS = 1;
/** App 端 Android 平台 ID */
public static final int PLATFORM_ID_ANDROID = 2;
// ==================== 会话摘要 / 未读 ====================
/** 会话 lastMessage 摘要最大长度 */
public static final int CONVERSATION_CONTENT_MAX_LEN = 100;
/** 未读数自增 SQL 片段 */
public static final String SQL_UNREAD_COUNT_INCREMENT = "unread_count = unread_count + 1";
/** 新建会话初始未读数(接收方) */
public static final int UNREAD_COUNT_INITIAL_RECEIVER = 1;
/** 新建会话初始未读数(发送方) */
public static final int UNREAD_COUNT_INITIAL_SENDER = 0;
// ==================== OpenIM contentType ====================
/** 文本 */
public static final int CONTENT_TYPE_TEXT = 101;
/** 图片 */
public static final int CONTENT_TYPE_IMAGE = 102;
/** 语音 */
public static final int CONTENT_TYPE_VOICE = 103;
/** 视频 */
public static final int CONTENT_TYPE_VIDEO = 104;
/** 文件 */
public static final int CONTENT_TYPE_FILE = 105;
/** 位置 */
public static final int CONTENT_TYPE_LOCATION = 106;
/** 名片 */
public static final int CONTENT_TYPE_CARD = 114;
/** 自定义消息 */
public static final int CONTENT_TYPE_CUSTOM = 110;
// ==================== OpenIM sessionType ====================
/** 单聊 */
public static final int SESSION_TYPE_SINGLE = 1;
/** 群聊 */
public static final int SESSION_TYPE_GROUP = 3;
// ==================== 离线推送 extras / 文案 ====================
/** 推送业务类型:IM 离线消息 */
public static final String PUSH_BIZ_TYPE_IM_MESSAGE = "IM_MESSAGE";
/** 推送标题 */
public static final String PUSH_TITLE_NEW_MESSAGE = "您有新消息";
/** 发送方昵称兜底 */
public static final String PUSH_DEFAULT_SENDER_NAME = "用户";
/** 系统/管理员代发时的发送方昵称兜底(如 OpenIM imAdmin */
public static final String PUSH_DEFAULT_SYSTEM_SENDER_NAME = "系统";
/** 群名称兜底 */
public static final String PUSH_DEFAULT_GROUP_NAME = "群聊";
/** extras:会话 ID */
public static final String EXTRAS_CONVERSATION_ID = "conversationId";
/** extras:发送方 ID */
public static final String EXTRAS_SENDER_ID = "senderId";
/** extras:消息类型 */
public static final String EXTRAS_MESSAGE_TYPE = "messageType";
/** extras:会话类型 */
public static final String EXTRAS_SESSION_TYPE = "sessionType";
/** extras:本地群组 ID */
public static final String EXTRAS_GROUP_ID = "groupId";
/** 单聊 conversationId 前缀 */
public static final String CONVERSATION_ID_SINGLE_PREFIX = "si_";
/** 群聊 conversationId 前缀(业务推送 extras 约定,非 OpenIM 原生 ID */
public static final String CONVERSATION_ID_GROUP_PREFIX = "group:";
/**
* OpenIM 原生群聊 conversationID 前缀(sessionType=3 ReadGroupChat
* 形如 {@code sg_{openimGroupId}}
*/
public static final String OPENIM_NATIVE_GROUP_CONVERSATION_PREFIX = "sg_";
/** 单聊 conversationId 分隔符 */
public static final String CONVERSATION_ID_SEPARATOR = "_";
/** 推送正文最大长度(对齐 PushSendReq */
public static final int PUSH_CONTENT_MAX_LEN = 200;
/** 推送 bizId 最大长度(对齐 PushSendReq */
public static final int PUSH_BIZ_ID_MAX_LEN = 64;
/** 推送摘要最大长度 */
public static final int PUSH_SUMMARY_MAX_LEN = 50;
/** 单次 Feign 推送批量用户数 */
public static final int PUSH_BATCH_SIZE = 500;
/** 离线推送幂等 TTL(分钟) */
public static final long PUSH_DEDUP_TTL_MINUTES = 10L;
/** Redis 幂等占位值 */
public static final String PUSH_DEDUP_PLACEHOLDER = "1";
/** 推送正文:昵称与摘要分隔符 */
public static final String PUSH_BODY_NAME_CONTENT_SEPARATOR = "";
/** 推送正文:群名与发送者分隔符 */
public static final String PUSH_BODY_GROUP_SEPARATOR = " | ";
/** 摘要:图片 */
public static final String SUMMARY_IMAGE = "[图片]";
/** 摘要:语音 */
public static final String SUMMARY_VOICE = "[语音]";
/** 摘要:视频 */
public static final String SUMMARY_VIDEO = "[视频]";
/** 摘要:文件 */
public static final String SUMMARY_FILE = "[文件]";
/** 摘要:位置 */
public static final String SUMMARY_LOCATION = "[位置]";
/** 摘要:名片 */
public static final String SUMMARY_CARD = "[名片]";
/** 摘要:其他消息 */
public static final String SUMMARY_DEFAULT = "[消息]";
/** JSON 对象起始字符(用于判断 content 是否为 JSON 字符串) */
public static final char JSON_OBJECT_START = '{';
/** 自定义消息 content.data / extension 字段 */
public static final String FIELD_DATA = "data";
/** 自定义消息 extension 字段 */
public static final String FIELD_EXTENSION = "extension";
/** 业务自定义类型字段 */
public static final String FIELD_CUSTOM_TYPE = "customType";
/**
* 红包领取提醒:仅聊天内系统记录,禁止离线推送 / 系统通知
*/
public static final String CUSTOM_TYPE_RED_ENVELOPE_CLAIM_NOTICE = "red_envelope_claim_notice";
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,113 @@
package com.tailbet.common.exception;
import com.tailbet.common.enums.ErrorCodeEnum;
import lombok.Getter;
/**
* 业务异常
* <p>
* 封装业务逻辑层面的异常,区别于系统运行时异常。
* 业务异常通常有明确的错误码和提示信息,可直接返回给前端展示。
* 使用时优先使用预定义的错误码枚举,也支持自定义消息。
* </p>
*
* @author socialapp团队
* @since 1.0.0
*/
@Getter
public class BusinessException extends RuntimeException {
/**
* 错误码
*/
private final int code;
/**
* 附加数据(如当前场次信息等)
*/
private final Object data;
/**
* 基于错误码枚举构造业务异常
*
* @param errorCode 错误码枚举
*/
public BusinessException(ErrorCodeEnum errorCode) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
this.data = null;
}
/**
* 基于错误码枚举和自定义消息构造业务异常
*
* @param errorCode 错误码枚举
* @param message 自定义错误消息
*/
public BusinessException(ErrorCodeEnum errorCode, String message) {
super(message);
this.code = errorCode.getCode();
this.data = null;
}
/**
* 基于错误码枚举、自定义消息和原因构造业务异常
*
* @param errorCode 错误码枚举
* @param message 自定义错误消息
* @param cause 原始异常
*/
public BusinessException(ErrorCodeEnum errorCode, String message, Throwable cause) {
super(message, cause);
this.code = errorCode.getCode();
this.data = null;
}
/**
* 基于状态码和自定义消息构造业务异常
*
* @param code 状态码
* @param message 错误消息
*/
public BusinessException(int code, String message) {
super(message);
this.code = code;
this.data = null;
}
/**
* 基于自定义消息构造业务异常(默认错误码 INTERNAL_ERROR
*
* @param message 错误消息
*/
public BusinessException(String message) {
super(message);
this.code = ErrorCodeEnum.INTERNAL_ERROR.getCode();
this.data = null;
}
/**
* 基于错误码枚举和附加数据构造业务异常
* <p>
* 用于需要向前端返回额外数据的异常场景,
* 如 LIVE_ALREADY_ACTIVE 时携带已有场次信息。
* </p>
*
* @param errorCode 错误码枚举
* @param data 附加数据
*/
public BusinessException(ErrorCodeEnum errorCode, Object data) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
this.data = data;
}
/**
* 获取对应的错误码枚举
*
* @return 错误码枚举
*/
public ErrorCodeEnum getErrorCodeEnum() {
return ErrorCodeEnum.of(this.code);
}
}
@@ -1,26 +1,208 @@
package com.tailbet.config;
import com.tailbet.common.enums.ErrorCodeEnum;
import com.tailbet.common.exception.BusinessException;
import com.tailbet.model.vo.R;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.MessageSource;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.util.Locale;
import java.util.stream.Collectors;
/**
* 全局异常处理
* 全局异常处理
* <p>
* 统一捕获并处理Controller层抛出的各类异常,将异常转换为统一的Result响应格式。
* 处理优先级:具体异常 > 通用异常 > 兜底异常。
* 支持国际化:根据请求头 Accept-Language 自动返回对应语言的错误信息。
* </p>
*
* @author socialapp团队
* @since 1.0.0
*/
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
@ExceptionHandler(RuntimeException.class)
public R<?> handleRuntimeException(RuntimeException e) {
log.error("业务异常: {}", e.getMessage());
return R.fail(e.getMessage());
/**
* 国际化消息源,用于将消息 key 解析为对应语言的文本
*/
private final MessageSource messageSource;
/**
* 处理业务异常(最高优先级)
*
* @param e 业务异常
* @param request HTTP请求
* @return 统一错误响应
*/
@ExceptionHandler(BusinessException.class)
@SuppressWarnings({"unchecked", "rawtypes"})
public R<?> handleBusinessException(BusinessException e, HttpServletRequest request) {
// 业务异常的 message 可能是 key,也可能是业务自定义文本,统一走 resolveMessage 处理
String message = e.getMessage();
log.warn("【业务异常】URI: {}, 错误码: {}, 消息: {}",
request.getRequestURI(), e.getCode(), message);
if (e.getData() != null) {
R r = R.fail(e.getCode(), message);
r.setData(e.getData());
return r;
}
return R.fail(e.getCode(), message);
}
/**
* 处理参数校验异常(@Valid / @RequestBody
*
* @param e 方法参数校验异常
* @param request HTTP请求
* @return 统一错误响应
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public R<Void> handleMethodArgumentNotValid(MethodArgumentNotValidException e, HttpServletRequest request) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
log.warn("【参数校验失败】URI: {}, 错误: {}", request.getRequestURI(), message);
// 参数校验失败的 message 来自注解,直接使用,不走国际化 key 解析
return R.fail(ErrorCodeEnum.PARAM_VALIDATE_ERROR, message);
}
/**
* 处理参数绑定异常(@ModelAttribute
*
* @param e 参数绑定异常
* @param request HTTP请求
* @return 统一错误响应
*/
@ExceptionHandler(BindException.class)
public R<Void> handleBindException(BindException e, HttpServletRequest request) {
String message = e.getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining(", "));
log.warn("【参数绑定失败】URI: {}, 错误: {}", request.getRequestURI(), message);
return R.fail(ErrorCodeEnum.PARAM_VALIDATE_ERROR, message);
}
/**
* 处理约束校验异常(@Validated 方法级别)
*
* @param e 约束校验异常
* @param request HTTP请求
* @return 统一错误响应
*/
@ExceptionHandler(ConstraintViolationException.class)
public R<Void> handleConstraintViolation(ConstraintViolationException e, HttpServletRequest request) {
String message = e.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage)
.collect(Collectors.joining(", "));
log.warn("【约束校验失败】URI: {}, 错误: {}", request.getRequestURI(), message);
return R.fail(ErrorCodeEnum.PARAM_VALIDATE_ERROR, message);
}
/**
* 处理缺少请求参数异常
*
* @param e 缺少参数异常
* @param request HTTP请求
* @return 统一错误响应
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
public R<Void> handleMissingParam(MissingServletRequestParameterException e, HttpServletRequest request) {
// 缺少参数的提示信息拼接参数名,不走国际化 key
String message = e.getMessage()
+ ": " + e.getParameterName();
log.warn("【缺少参数】URI: {}, 错误: {}", request.getRequestURI(), message);
return R.fail(ErrorCodeEnum.PARAM_ERROR, message);
}
/**
* 处理请求体不可读异常(请求体缺失、格式错误、缺少必填字段等)
*
* @param e 请求体不可读异常
* @param request HTTP请求
* @return 统一错误响应
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public R<Void> handleHttpMessageNotReadable(HttpMessageNotReadableException e, HttpServletRequest request) {
// 请求体格式错误,使用国际化后的错误码消息
String message = e.getMessage();
log.warn("【请求体不可读】URI: {}, 错误: {}", request.getRequestURI(), e.getMessage());
return R.fail(ErrorCodeEnum.PARAM_FORMAT_ERROR, message);
}
/**
* 处理参数类型不匹配异常
*
* @param e 参数类型不匹配异常
* @param request HTTP请求
* @return 统一错误响应
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public R<Void> handleTypeMismatch(MethodArgumentTypeMismatchException e, HttpServletRequest request) {
String message = String.format("参数[%s]类型错误,期望类型: %s",
e.getName(), e.getRequiredType() != null ? e.getRequiredType().getSimpleName() : "未知");
log.warn("【参数类型错误】URI: {}, 错误: {}", request.getRequestURI(), message);
return R.fail(ErrorCodeEnum.PARAM_FORMAT_ERROR, message);
}
/**
* 处理非法参数异常
*
* @param e 非法参数异常
* @param request HTTP请求
* @return 统一错误响应
*/
@ExceptionHandler(IllegalArgumentException.class)
public R<Void> handleIllegalArgument(IllegalArgumentException e, HttpServletRequest request) {
log.warn("【非法参数】URI: {}, 错误: {}", request.getRequestURI(), e.getMessage());
return R.fail(ErrorCodeEnum.PARAM_ERROR, e.getMessage());
}
/**
* 处理非法状态异常
*
* @param e 非法状态异常
* @param request HTTP请求
* @return 统一错误响应
*/
@ExceptionHandler(IllegalStateException.class)
public R<Void> handleIllegalState(IllegalStateException e, HttpServletRequest request) {
log.warn("【非法状态】URI: {}, 错误: {}", request.getRequestURI(), e.getMessage());
return R.fail(ErrorCodeEnum.OPERATION_NOT_ALLOWED, e.getMessage());
}
/**
* 兜底异常处理器(最低优先级)
* <p>
* 捕获所有未被前面处理器捕获的异常,避免异常信息直接暴露给客户端。
* </p>
*
* @param e 异常对象
* @param request HTTP请求
* @return 统一错误响应
*/
@ExceptionHandler(Exception.class)
public R<?> handleException(Exception e) {
log.error("系统异常: {}", e.getMessage(), e);
return R.fail("系统错误: " + e.getMessage());
public R<Void> handleException(Exception e, HttpServletRequest request) {
log.error("系统异常】URI: {}, 错误类型: {}, 消息: {}",
request.getRequestURI(), e.getClass().getName(), e.getMessage(), e);
// 兜底异常使用国际化后的系统繁忙提示
return R.fail(ErrorCodeEnum.INTERNAL_ERROR.getCode(), e.getMessage());
}
}
@@ -1,34 +0,0 @@
package com.tailbet.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* OpenIM配置
*/
@Data
@Component
@ConfigurationProperties(prefix = "openim")
public class OpenIMConfig {
/**
* OpenIM API地址
*/
private String apiUrl;
/**
* OpenIM WebSocket地址
*/
private String wsUrl;
/**
* 管理员账号
*/
private String adminUser;
/**
* 管理员Token
*/
private String adminToken;
}
@@ -0,0 +1,144 @@
package com.tailbet.config;
import cn.hutool.core.util.StrUtil;
import jakarta.annotation.PostConstruct;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* OpenIM 集成配置属性类
* <p>
* 从 Nacos/application.yml 中读取 OpenIM 第三方即时通讯服务相关配置属性,
* 对应配置前缀为 {@code openim}。OpenIM 是独立部署的外部第三方服务(非 Spring 微服务),
* 不参与 Nacos 服务发现,因此配置项中直接使用完整的 HTTP/WS 地址。
* </p>
*
* @author socialapp团队
* @since 1.0.0
*/
@Slf4j
@Data
@Component
@ConfigurationProperties(prefix = "openim")
public class OpenIMProperties {
/**
* OpenIM REST API 外网地址,下发给客户端 {@code httpUrl}(如 https://im-api-pre.aimchat.im
* <p>
* 历史配置中亦可能同时作为服务端调用地址;配置了 {@link #internalApiUrl} 后仅用于下发。
* </p>
*/
private String apiUrl;
/**
* OpenIM REST API 内网地址,服务端 {@code OpenIMApiClient} 调用(如 http://10.x.x.x:10002
* <p>
* 未配置时回落 {@link #apiUrl},兼容旧 Nacos。
* </p>
*/
private String internalApiUrl;
/**
* OpenIM WebSocket 外网地址,下发给客户端用于 IM SDK 初始化(如 wss://im-ws-pre.aimchat.im
*/
private String wsUrl;
/**
* OpenIM 管理员密钥,用于调用管理接口换取 adminToken
* 生产环境通过 Nacos 敏感配置注入,禁止硬编码
*/
private String secret;
/**
* OpenIM 管理员用户 ID,默认 imAdmin
*/
private String adminUserId = "imAdmin";
/**
* 应用标识,用于 operationID 生成前缀
*/
private String appId = "socialapp";
/**
* adminToken 有效期(秒),用于定时刷新任务计算刷新周期,默认 82800 秒(23小时)
*/
private int tokenExpireSeconds = 82800;
/**
* 调用 OpenIM 接口的连接超时时间(毫秒),默认 5000
*/
private int connectTimeoutMs = 5000;
/**
* 调用 OpenIM 接口的读取超时时间(毫秒),默认 10000
*/
private int readTimeoutMs = 10000;
/**
* Webhook 签名密钥,用于校验 OpenIM 服务端回调请求的合法性(对应请求头 X-Webhook-Secret
* 为空时跳过校验并输出警告日志
*/
private String webhookSecret;
/**
* 配置加载后的校验方法
* 检查必填配置是否已从 Nacos 注入,未配置则输出警告日志
*/
@PostConstruct
public void validate() {
if (StrUtil.isBlank(apiUrl)) {
log.warn("【OpenIM配置】apiUrl(外网下发 httpUrl)未配置,OpenIM 相关功能将不可用");
}
if (StrUtil.isBlank(internalApiUrl)) {
log.warn("【OpenIM配置】internalApiUrl(内网调用)未配置,服务端将回落 apiUrl");
}
if (StrUtil.isBlank(secret)) {
log.warn("【OpenIM配置】secret 未配置,管理员Token获取将失败");
}
if (StrUtil.isBlank(wsUrl)) {
log.warn("【OpenIM配置】wsUrl(外网下发)未配置,客户端 SDK 可能无法初始化");
}
if (StrUtil.isNotBlank(apiUrl)) {
log.info("【OpenIM配置】加载完成,外网apiUrl: {}, 内网internalApiUrl: {}, wsUrl: {}, adminUserId: {}, secret: {}",
apiUrl,
StrUtil.blankToDefault(internalApiUrl, "(回落 api-url)"),
wsUrl,
adminUserId,
maskString(secret));
}
}
/**
* 解析服务端调用 OpenIM REST 基址(优先内网)
*
* @return HTTP(S) 基址,末尾无斜杠约定由调用方拼接 path
*/
public String resolveServerApiUrl() {
return StrUtil.blankToDefault(internalApiUrl, apiUrl);
}
/**
* 解析下发给客户端的 OpenIM HTTP API 地址(外网)
*
* @return 外网 api-url
*/
public String resolvePublicApiUrl() {
return apiUrl;
}
/**
* 对敏感字符串进行脱敏处理
* 只显示前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);
}
}
@@ -1,5 +1,6 @@
package com.tailbet.model.vo;
import com.tailbet.common.enums.ErrorCodeEnum;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
@@ -49,6 +50,9 @@ public class R<T> implements Serializable {
public static <T> R<T> fail(int code, String msg) {
return new R<>(code, msg, null);
}
public static <T> R<T> fail(ErrorCodeEnum errorCode, String msg) {
return new R<>(errorCode.getCode(), msg, null);
}
public boolean isSuccess() {
return code == SUCCESS;
@@ -0,0 +1,170 @@
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);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,184 +0,0 @@
package com.tailbet.openim;
import cn.hutool.json.JSON;
import cn.hutool.json.JSONUtil;
import com.tailbet.config.OpenIMConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.Map;
/**
* OpenIM客户端
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OpenIMClient {
private final OpenIMConfig config;
private final RestTemplate restTemplate;
/**
* 发送群消息
*/
public void sendGroupMessage(String groupId, String message) {
String url = config.getApiUrl() + "/msg/send_msg";
Map<String, Object> body = new HashMap<>();
body.put("groupId", groupId);
body.put("msg", message);
body.put("msgType", "text");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("token", config.getAdminToken());
HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);
try {
restTemplate.postForEntity(url, request, String.class);
log.info("OpenIM群消息发送成功: groupId={}, message={}", groupId, message);
} catch (Exception e) {
log.error("OpenIM群消息发送失败: groupId={}, message={}, error={}",
groupId, message, e.getMessage());
}
}
/**
* 发送单聊消息
*/
public void sendUserMessage(String toUserId, String message) {
String url = config.getApiUrl() + "/msg/send_msg";
Map<String, Object> body = new HashMap<>();
body.put("to", toUserId);
body.put("msg", message);
body.put("msgType", "text");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("token", config.getAdminToken());
HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);
try {
restTemplate.postForEntity(url, request, String.class);
log.info("OpenIM单聊消息发送成功: to={}, message={}", toUserId, message);
} catch (Exception e) {
log.error("OpenIM单聊消息发送失败: to={}, message={}, error={}",
toUserId, message, e.getMessage());
}
}
/**
* 创建用户
*/
public boolean createUser(String userId, String nickname) {
String url = config.getApiUrl() + "/user/user_register";
Map<String, Object> body = new HashMap<>();
body.put("userID", userId);
body.put("nickname", nickname);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("token", config.getAdminToken());
HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);
try {
restTemplate.postForEntity(url, request, String.class);
return true;
} catch (Exception e) {
log.error("创建OpenIM用户失败: userId={}, error={}", userId, e.getMessage());
return false;
}
}
/**
* 创建群组
*/
public String createGroup(String groupName) {
String url = config.getApiUrl() + "/group/create_group";
Map<String, Object> body = new HashMap<>();
body.put("groupName", groupName);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("token", config.getAdminToken());
HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);
try {
ResponseEntity<Map> response = restTemplate.postForEntity(url, request, Map.class);
if (response.getBody() != null) {
return (String) response.getBody().get("groupID");
}
} catch (Exception e) {
log.error("创建OpenIM群组失败: groupName={}, error={}", groupName, e.getMessage());
}
return null;
}
/**
* 拉用户入群
*/
public boolean inviteUserToGroup(String groupId, String userId) {
String url = config.getApiUrl() + "/group/invite_user_to_group";
Map<String, Object> body = new HashMap<>();
body.put("groupId", groupId);
body.put("userIDs", new String[]{userId});
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("token", config.getAdminToken());
HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);
try {
restTemplate.postForEntity(url, request, String.class);
return true;
} catch (Exception e) {
log.error("拉用户入群失败: groupId={}, userId={}, error={}",
groupId, userId, e.getMessage());
return false;
}
}
/**
* 获取用户Token
*/
public String getUserToken(String userId) {
String url = config.getApiUrl() + "/auth/user_token";
Map<String, Object> body = new HashMap<>();
body.put("userID", userId);
body.put("expireTimeSeconds", 604800); // 7天有效期
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("token", config.getAdminToken());
headers.set("operationID", userId + System.currentTimeMillis());
HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);
try {
ResponseEntity<Map> response = restTemplate.postForEntity(url, request, Map.class);
log.info("getUserToken:{}", JSONUtil.toJsonStr(response.getBody()));
if (response.getBody() != null && response.getBody().get("data") != null) {
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
return (String) data.get("token");
}
} catch (Exception e) {
log.error("获取OpenIM用户Token失败: userId={}, error={}", userId, e.getMessage());
}
return null;
}
}